feat(affiliate): tests, ruff cleanup, CHANGELOG + PROJECT.md (commit 9/9)

- 26 tests in web/tests/test_affiliate.py covering hash_ip determinism,
  daily rotation, product CRUD, bake_product_cards marker replacement,
  click redirect (302 + logged), inactive/unknown 404, multi-retailer
- ruff: fix E741 ambiguous var (l → line in _form_to_product), F401 unused
  import, I001 import sort in admin/routes.py
- CHANGELOG: affiliate product system entry
- PROJECT.md: affiliate system moved to Done, Wirecutter backlog item removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-28 21:06:01 +01:00
parent 1fdd2d07a4
commit 5c22ea9780
4 changed files with 360 additions and 6 deletions

332
web/tests/test_affiliate.py Normal file
View File

@@ -0,0 +1,332 @@
"""
Tests for the affiliate product system.
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer.
"""
import json
from datetime import date
from unittest.mock import patch
import pytest
from padelnomics.affiliate import (
get_all_products,
get_click_counts,
get_click_stats,
get_product,
get_products_by_category,
hash_ip,
log_click,
)
from padelnomics.content.routes import PRODUCT_GROUP_RE, PRODUCT_RE, bake_product_cards
from padelnomics.core import execute, fetch_all
# ── Helpers ────────────────────────────────────────────────────────────────────
async def _insert_product(
slug="test-racket-amazon",
name="Test Racket",
brand="TestBrand",
category="racket",
retailer="Amazon",
affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
status="active",
language="de",
price_cents=14999,
pros=None,
cons=None,
sort_order=0,
) -> int:
"""Insert an affiliate product, return its id."""
return await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url,
price_cents, currency, status, language, pros, cons, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
(
slug, name, brand, category, retailer, affiliate_url,
price_cents, status, language,
json.dumps(pros or ["Gut"]),
json.dumps(cons or ["Teuer"]),
sort_order,
),
)
# ── hash_ip ────────────────────────────────────────────────────────────────────
def test_hash_ip_deterministic():
"""Same IP + same day → same hash."""
h1 = hash_ip("1.2.3.4")
h2 = hash_ip("1.2.3.4")
assert h1 == h2
assert len(h1) == 64 # SHA256 hex digest
def test_hash_ip_different_ips_differ():
"""Different IPs → different hashes."""
assert hash_ip("1.2.3.4") != hash_ip("5.6.7.8")
def test_hash_ip_rotates_daily():
"""Different days → different hashes for same IP (GDPR daily rotation)."""
with patch("padelnomics.affiliate.date") as mock_date:
mock_date.today.return_value = date(2026, 2, 1)
h1 = hash_ip("1.2.3.4")
mock_date.today.return_value = date(2026, 2, 2)
h2 = hash_ip("1.2.3.4")
assert h1 != h2
# ── get_product ────────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_product_active_by_lang(db):
"""get_product returns active product for correct language."""
await _insert_product(slug="vertex-amazon", language="de", status="active")
product = await get_product("vertex-amazon", "de")
assert product is not None
assert product["slug"] == "vertex-amazon"
assert isinstance(product["pros"], list)
@pytest.mark.usefixtures("db")
async def test_get_product_draft_returns_none(db):
"""Draft products are not returned."""
await _insert_product(slug="vertex-draft", status="draft")
product = await get_product("vertex-draft", "de")
assert product is None
@pytest.mark.usefixtures("db")
async def test_get_product_lang_fallback(db):
"""Falls back to any language when no match for requested lang."""
await _insert_product(slug="vertex-de-only", language="de", status="active")
# Request EN but only DE exists — should fall back
product = await get_product("vertex-de-only", "en")
assert product is not None
assert product["language"] == "de"
@pytest.mark.usefixtures("db")
async def test_get_product_not_found(db):
"""Returns None for unknown slug."""
product = await get_product("nonexistent-slug", "de")
assert product is None
# ── get_products_by_category ───────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_products_by_category_sorted(db):
"""Returns products sorted by sort_order."""
await _insert_product(slug="racket-b", name="Racket B", sort_order=2)
await _insert_product(slug="racket-a", name="Racket A", sort_order=1)
products = await get_products_by_category("racket", "de")
assert len(products) == 2
assert products[0]["sort_order"] == 1
assert products[1]["sort_order"] == 2
@pytest.mark.usefixtures("db")
async def test_get_products_by_category_inactive_excluded(db):
"""Draft and archived products are excluded."""
await _insert_product(slug="racket-draft", status="draft")
await _insert_product(slug="racket-archived", status="archived")
products = await get_products_by_category("racket", "de")
assert products == []
# ── get_all_products ───────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_all_products_no_filter(db):
"""Returns all products regardless of status."""
await _insert_product(slug="p1", status="active")
await _insert_product(slug="p2", status="draft")
products = await get_all_products()
assert len(products) == 2
@pytest.mark.usefixtures("db")
async def test_get_all_products_status_filter(db):
"""Status filter returns only matching rows."""
await _insert_product(slug="p-active", status="active")
await _insert_product(slug="p-draft", status="draft")
active = await get_all_products(status="active")
assert len(active) == 1
assert active[0]["slug"] == "p-active"
# ── log_click + get_click_counts ──────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_log_click_inserts_row(db):
"""log_click inserts a row into affiliate_clicks."""
product_id = await _insert_product(slug="clickable")
await log_click(product_id, "1.2.3.4", "beste-padelschlaeger", "https://example.com/de/blog/test")
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (product_id,))
assert len(rows) == 1
assert rows[0]["article_slug"] == "beste-padelschlaeger"
# IP hash must not be the raw IP
assert rows[0]["ip_hash"] != "1.2.3.4"
@pytest.mark.usefixtures("db")
async def test_get_click_counts(db):
"""get_click_counts returns dict of product_id → count."""
pid = await _insert_product(slug="tracked-product")
await log_click(pid, "1.2.3.4", None, None)
await log_click(pid, "5.6.7.8", None, None)
counts = await get_click_counts()
assert counts.get(pid) == 2
# ── get_click_stats ────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_click_stats_structure(db):
"""get_click_stats returns expected keys."""
stats = await get_click_stats(days_count=30)
assert "total_clicks" in stats
assert "top_products" in stats
assert "daily_bars" in stats
assert "by_retailer" in stats
# ── bake_product_cards ────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_replaces_marker(db):
"""[product:slug] marker is replaced with rendered HTML."""
await _insert_product(slug="vertex-04-amazon", name="Bullpadel Vertex 04", status="active")
html = "<p>Intro</p>\n[product:vertex-04-amazon]\n<p>Outro</p>"
result = await bake_product_cards(html, lang="de")
assert "[product:vertex-04-amazon]" not in result
assert "Bullpadel Vertex 04" in result
assert "/go/vertex-04-amazon" in result
assert "sponsored" in result
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_missing_slug_passthrough(db):
"""Unknown slugs pass through unchanged — no product card rendered."""
html = "<p>Text</p>\n[product:nonexistent-slug]\n<p>End</p>"
result = await bake_product_cards(html, lang="de")
# Surrounding content is intact; no product HTML injected
assert "<p>Text</p>" in result
assert "<p>End</p>" in result
assert "<article" not in result # no product card rendered
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_group_marker(db):
"""[product-group:category] renders a grid of products."""
await _insert_product(slug="shoe-1-amazon", name="Test Shoe", category="shoe", status="active")
html = "<h2>Shoes</h2>\n[product-group:shoe]\n<p>End</p>"
result = await bake_product_cards(html, lang="de")
assert "[product-group:shoe]" not in result
assert "Test Shoe" in result
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_no_markers(db):
"""HTML without markers is returned unchanged."""
html = "<p>No markers here.</p>"
result = await bake_product_cards(html, lang="de")
assert result == html
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_draft_not_shown(db):
"""Draft products are not baked into articles."""
await _insert_product(slug="draft-product", name="Draft Product", status="draft")
html = "[product:draft-product]"
result = await bake_product_cards(html, lang="de")
assert "Draft Product" not in result
# ── regex patterns ─────────────────────────────────────────────────────────────
def test_product_re_matches():
"""PRODUCT_RE matches valid [product:slug] markers."""
assert PRODUCT_RE.match("[product:bullpadel-vertex-04-amazon]")
assert PRODUCT_RE.match("[product:test-123]")
def test_product_group_re_matches():
"""PRODUCT_GROUP_RE matches valid [product-group:category] markers."""
assert PRODUCT_GROUP_RE.match("[product-group:racket]")
assert PRODUCT_GROUP_RE.match("[product-group:shoe]")
# ── multi-retailer ────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_multi_retailer_same_slug_different_lang(db):
"""Same slug can exist in DE and EN with different affiliate URLs."""
await _insert_product(
slug="vertex-04", language="de",
affiliate_url="https://amazon.de/dp/TEST?tag=de-21",
)
await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url,
price_cents, currency, status, language, pros, cons, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
(
"vertex-04", "Test Racket EN", "TestBrand", "racket", "Amazon UK",
"https://amazon.co.uk/dp/TEST?tag=en-21",
14999, "active", "en", "[]", "[]", 0,
),
)
de_product = await get_product("vertex-04", "de")
en_product = await get_product("vertex-04", "en")
assert de_product is not None
assert en_product is not None
assert de_product["affiliate_url"] != en_product["affiliate_url"]
assert "amazon.de" in de_product["affiliate_url"]
assert "amazon.co.uk" in en_product["affiliate_url"]
# ── click redirect (e2e via Quart test client) ────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_302(app, db):
"""GET /go/<slug> redirects to affiliate_url with 302."""
await _insert_product(slug="redirect-test", affiliate_url="https://amazon.de/dp/XYZ?tag=test-21")
async with app.test_client() as client:
response = await client.get("/go/redirect-test")
assert response.status_code == 302
assert "amazon.de" in response.headers.get("Location", "")
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_logs_click(app, db):
"""Successful redirect logs a click in affiliate_clicks."""
pid = await _insert_product(slug="logged-test", affiliate_url="https://amazon.de/dp/LOG?tag=test-21")
async with app.test_client() as client:
await client.get(
"/go/logged-test",
headers={"Referer": "https://padelnomics.io/de/beste-padelschlaeger-2026"},
)
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (pid,))
assert len(rows) == 1
assert rows[0]["article_slug"] == "beste-padelschlaeger-2026"
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_inactive_404(app, db):
"""Draft products return 404 on /go/<slug>."""
await _insert_product(slug="inactive-test", status="draft")
async with app.test_client() as client:
response = await client.get("/go/inactive-test")
assert response.status_code == 404
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_unknown_404(app, db):
"""Unknown slug returns 404."""
async with app.test_client() as client:
response = await client.get("/go/totally-unknown-xyz")
assert response.status_code == 404