""" 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, program CRUD, build_affiliate_url(), program-based redirect. """ import json from datetime import date from unittest.mock import patch import pytest from padelnomics.affiliate import ( build_affiliate_url, get_all_products, get_all_programs, get_click_counts, get_click_stats, get_product, get_products_by_category, get_program, get_program_by_slug, 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 = "

Intro

\n[product:vertex-04-amazon]\n

Outro

" 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 = "

Text

\n[product:nonexistent-slug]\n

End

" result = await bake_product_cards(html, lang="de") # Surrounding content is intact; no product HTML injected assert "

Text

" in result assert "

End

" in result assert " 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/.""" 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 # ── affiliate_programs ──────────────────────────────────────────────────────── async def _insert_program( name="Test Shop", slug="test-shop", url_template="https://testshop.example.com/p/{product_id}?ref={tag}", tracking_tag="testref", commission_pct=5.0, homepage_url="https://testshop.example.com", status="active", ) -> int: """Insert an affiliate program, return its id.""" return await execute( """INSERT INTO affiliate_programs (name, slug, url_template, tracking_tag, commission_pct, homepage_url, status) VALUES (?, ?, ?, ?, ?, ?, ?)""", (name, slug, url_template, tracking_tag, commission_pct, homepage_url, status), ) @pytest.mark.usefixtures("db") async def test_get_all_programs_returns_all(db): """get_all_programs returns inserted programs sorted by name.""" await _insert_program(slug="zebra-shop", name="Zebra Shop") await _insert_program(slug="alpha-shop", name="Alpha Shop") programs = await get_all_programs() names = [p["name"] for p in programs] assert "Alpha Shop" in names assert "Zebra Shop" in names # Sorted by name ascending assert names.index("Alpha Shop") < names.index("Zebra Shop") @pytest.mark.usefixtures("db") async def test_get_all_programs_status_filter(db): """get_all_programs(status='active') excludes inactive programs.""" await _insert_program(slug="inactive-prog", status="inactive") await _insert_program(slug="active-prog", name="Active Shop") active = await get_all_programs(status="active") statuses = [p["status"] for p in active] assert all(s == "active" for s in statuses) slugs = [p["slug"] for p in active] assert "inactive-prog" not in slugs assert "active-prog" in slugs @pytest.mark.usefixtures("db") async def test_get_program_by_id(db): """get_program returns a program by id.""" prog_id = await _insert_program() prog = await get_program(prog_id) assert prog is not None assert prog["slug"] == "test-shop" @pytest.mark.usefixtures("db") async def test_get_program_not_found(db): """get_program returns None for unknown id.""" prog = await get_program(99999) assert prog is None @pytest.mark.usefixtures("db") async def test_get_program_by_slug(db): """get_program_by_slug returns the program for a known slug.""" await _insert_program(slug="find-by-slug") prog = await get_program_by_slug("find-by-slug") assert prog is not None assert prog["name"] == "Test Shop" @pytest.mark.usefixtures("db") async def test_get_program_by_slug_not_found(db): """get_program_by_slug returns None for unknown slug.""" prog = await get_program_by_slug("nonexistent-slug-xyz") assert prog is None @pytest.mark.usefixtures("db") async def test_get_all_programs_product_count(db): """get_all_programs includes product_count for each program.""" prog_id = await _insert_program(slug="counted-prog") await _insert_product(slug="p-for-count", program_id=prog_id) programs = await get_all_programs() prog = next(p for p in programs if p["slug"] == "counted-prog") assert prog["product_count"] == 1 # ── build_affiliate_url ─────────────────────────────────────────────────────── def test_build_affiliate_url_with_program(): """build_affiliate_url assembles URL from program template.""" product = {"program_id": 1, "product_identifier": "B0TESTTEST", "affiliate_url": ""} program = {"url_template": "https://amazon.de/dp/{product_id}?tag={tag}", "tracking_tag": "mysite-21"} url = build_affiliate_url(product, program) assert url == "https://amazon.de/dp/B0TESTTEST?tag=mysite-21" def test_build_affiliate_url_legacy_fallback(): """build_affiliate_url falls back to baked affiliate_url when no program.""" product = {"program_id": None, "product_identifier": "", "affiliate_url": "https://baked.example.com/p?tag=x"} url = build_affiliate_url(product, None) assert url == "https://baked.example.com/p?tag=x" def test_build_affiliate_url_no_program_id(): """build_affiliate_url uses fallback when program_id is 0/falsy.""" product = {"program_id": 0, "product_identifier": "B0IGNORED", "affiliate_url": "https://fallback.example.com"} program = {"url_template": "https://shop.example.com/{product_id}?ref={tag}", "tracking_tag": "tag123"} url = build_affiliate_url(product, program) # program_id is falsy → fallback assert url == "https://fallback.example.com" def test_build_affiliate_url_no_program_dict(): """build_affiliate_url uses fallback when program dict is None.""" product = {"program_id": 5, "product_identifier": "ASIN123", "affiliate_url": "https://fallback.example.com"} url = build_affiliate_url(product, None) assert url == "https://fallback.example.com" # ── program-based redirect ──────────────────────────────────────────────────── async def _insert_product( # noqa: F811 — redefined to add program_id support 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, program_id=None, product_identifier="", ) -> int: """Insert an affiliate product with optional program_id, 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, program_id, product_identifier) 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, program_id, product_identifier, ), ) @pytest.mark.usefixtures("db") async def test_affiliate_redirect_uses_program_url(app, db): """Redirect assembles URL from program template when product has program_id.""" prog_id = await _insert_program( slug="amzn-test", url_template="https://www.amazon.de/dp/{product_id}?tag={tag}", tracking_tag="testsite-21", ) await _insert_product( slug="program-redirect-test", affiliate_url="", program_id=prog_id, product_identifier="B0PROGRAM01", ) async with app.test_client() as client: response = await client.get("/go/program-redirect-test") assert response.status_code == 302 location = response.headers.get("Location", "") assert "B0PROGRAM01" in location assert "testsite-21" in location @pytest.mark.usefixtures("db") async def test_affiliate_redirect_legacy_url_still_works(app, db): """Legacy products with baked affiliate_url still redirect correctly.""" await _insert_product( slug="legacy-redirect-test", affiliate_url="https://amazon.de/dp/LEGACY?tag=old-21", program_id=None, product_identifier="", ) async with app.test_client() as client: response = await client.get("/go/legacy-redirect-test") assert response.status_code == 302 assert "LEGACY" in response.headers.get("Location", "") # ── migration backfill ──────────────────────────────────────────────────────── def _load_migration_0027(): """Import migration 0027 via importlib (filename starts with a digit).""" import importlib from pathlib import Path versions_dir = Path(__file__).parent.parent / "src/padelnomics/migrations/versions" spec = importlib.util.spec_from_file_location( "migration_0027", versions_dir / "0027_affiliate_programs.py" ) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _make_pre_migration_db(): """Create a minimal sqlite3 DB simulating state just before migration 0027. Provides the affiliate_products table (migration ALTERs it), but not affiliate_programs (migration CREATEs it). """ import sqlite3 conn = sqlite3.connect(":memory:") conn.execute(""" CREATE TABLE affiliate_products ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', affiliate_url TEXT NOT NULL DEFAULT '', UNIQUE(slug) ) """) conn.commit() return conn def test_migration_seeds_amazon_program(): """Migration 0027 up() seeds the Amazon program with expected fields. Tests the migration function directly against a real sqlite3 DB (the conftest only replays CREATE TABLE DDL, not INSERT seeds). """ migration = _load_migration_0027() conn = _make_pre_migration_db() migration.up(conn) conn.commit() row = conn.execute( "SELECT * FROM affiliate_programs WHERE slug = 'amazon'" ).fetchone() assert row is not None cols = [d[0] for d in conn.execute("SELECT * FROM affiliate_programs WHERE slug = 'amazon'").description] prog = dict(zip(cols, row)) assert prog["name"] == "Amazon" assert "padelnomics-21" in prog["tracking_tag"] assert "{product_id}" in prog["url_template"] assert "{tag}" in prog["url_template"] assert prog["commission_pct"] == 3.0 conn.close() def test_migration_backfills_asin_from_url(): """Migration 0027 up() extracts ASINs from existing affiliate_url values.""" migration = _load_migration_0027() conn = _make_pre_migration_db() conn.execute( "INSERT INTO affiliate_products (slug, affiliate_url) VALUES (?, ?)", ("test-racket", "https://www.amazon.de/dp/B0ASIN1234?tag=test-21"), ) conn.commit() migration.up(conn) conn.commit() row = conn.execute( "SELECT program_id, product_identifier FROM affiliate_products WHERE slug = 'test-racket'" ).fetchone() assert row is not None assert row[0] is not None # program_id set assert row[1] == "B0ASIN1234" # ASIN extracted correctly conn.close()