""" 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 = "
Intro
\n[product:vertex-04-amazon]\nOutro
" 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]\nEnd
" 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 "