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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/<slug>` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded.
|
||||||
|
- `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema
|
||||||
|
- `web/src/padelnomics/affiliate.py`: `get_product()`, `get_products_by_category()`, `get_all_products()`, `log_click()`, `hash_ip()`, `get_click_stats()`, `get_click_counts()`, `get_distinct_retailers()`
|
||||||
|
- `web/src/padelnomics/content/routes.py`: `PRODUCT_RE`, `PRODUCT_GROUP_RE`, `bake_product_cards()` — chained after `bake_scenario_cards()` in `generate_articles()` and `preview_article()`
|
||||||
|
- `web/src/padelnomics/app.py`: `/go/<slug>` route with rate limiting (60/min per IP) and referer-based article/language extraction
|
||||||
|
- `web/src/padelnomics/admin/routes.py`: affiliate CRUD routes + `bake_product_cards()` chained in article rebuild flows
|
||||||
|
- New templates: `partials/product_card.html`, `partials/product_group.html`, `admin/affiliate_products.html`, `admin/affiliate_form.html`, `admin/affiliate_dashboard.html`, `admin/partials/affiliate_results.html`, `admin/partials/affiliate_row.html`
|
||||||
|
- `locales/en.json` + `locales/de.json`: 6 new affiliate i18n keys
|
||||||
|
- `data/content/articles/`: 10 new German equipment review scaffolds (rackets, balls, shoes, accessories, gifts)
|
||||||
|
- 26 tests in `web/tests/test_affiliate.py`
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Three-tier proxy system** for extraction pipeline: free (Webshare auto-fetched) → datacenter (`PROXY_URLS_DATACENTER`) → residential (`PROXY_URLS_RESIDENTIAL`). Webshare free proxies are now auto-fetched from their download API on each run — no more manually copying stale proxy lists.
|
- **Three-tier proxy system** for extraction pipeline: free (Webshare auto-fetched) → datacenter (`PROXY_URLS_DATACENTER`) → residential (`PROXY_URLS_RESIDENTIAL`). Webshare free proxies are now auto-fetched from their download API on each run — no more manually copying stale proxy lists.
|
||||||
- `proxy.py`: added `fetch_webshare_proxies()` (stdlib urllib, bounded read + timeout), `load_proxy_tiers()` (assembles N tiers from env), generalised `make_tiered_cycler()` to accept `list[list[str]]` with N-level escalation. Exposes `is_exhausted()`, `active_tier_index()`, `tier_count()`.
|
- `proxy.py`: added `fetch_webshare_proxies()` (stdlib urllib, bounded read + timeout), `load_proxy_tiers()` (assembles N tiers from env), generalised `make_tiered_cycler()` to accept `list[list[str]]` with N-level escalation. Exposes `is_exhausted()`, `active_tier_index()`, `tier_count()`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Padelnomics — Project Tracker
|
# Padelnomics — Project Tracker
|
||||||
|
|
||||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||||
> Last updated: 2026-02-27 (Phase 2b — EU NUTS-2 spatial join + US state income).
|
> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -132,6 +132,7 @@
|
|||||||
- [x] **pSEO article noindex** — `noindex` column on articles (migration 0025), `NOINDEX_THRESHOLDS` per-template lambdas in `content/__init__.py`, robots meta tag in `article_detail.html`, sitemap exclusion, pSEO dashboard count card + article row badge; 20 tests
|
- [x] **pSEO article noindex** — `noindex` column on articles (migration 0025), `NOINDEX_THRESHOLDS` per-template lambdas in `content/__init__.py`, robots meta tag in `article_detail.html`, sitemap exclusion, pSEO dashboard count card + article row badge; 20 tests
|
||||||
- [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row
|
- [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row
|
||||||
- [x] **Email-gated report PDF** — `reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/`
|
- [x] **Email-gated report PDF** — `reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/`
|
||||||
|
- [x] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/<slug>` click-tracking redirect (302, GDPR daily-rotated IP hash, rate-limited); admin CRUD with live preview, HTMX filter/search, status toggle; click stats dashboard (pure CSS charts); 10 German equipment review article scaffolds; 26 tests
|
||||||
|
|
||||||
### SEO & Legal
|
### SEO & Legal
|
||||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||||
@@ -243,7 +244,6 @@
|
|||||||
|
|
||||||
### Marketing & Content
|
### Marketing & Content
|
||||||
- [ ] LinkedIn presence (ongoing — founder posts, thought leadership)
|
- [ ] LinkedIn presence (ongoing — founder posts, thought leadership)
|
||||||
- [ ] "Wirecutter for padel" affiliate site (racket reviews, gear guides)
|
|
||||||
- [ ] "The Padel Business Report" newsletter
|
- [ ] "The Padel Business Report" newsletter
|
||||||
- [ ] Equipment supplier affiliate partnerships (€500–1,000/lead or 5%)
|
- [ ] Equipment supplier affiliate partnerships (€500–1,000/lead or 5%)
|
||||||
- [ ] Padel podcasts (guest appearances)
|
- [ ] Padel podcasts (guest appearances)
|
||||||
|
|||||||
@@ -2499,7 +2499,12 @@ async def article_results():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def article_new():
|
async def article_new():
|
||||||
"""Create a manual article."""
|
"""Create a manual article."""
|
||||||
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path
|
from ..content.routes import (
|
||||||
|
BUILD_DIR,
|
||||||
|
bake_product_cards,
|
||||||
|
bake_scenario_cards,
|
||||||
|
is_reserved_path,
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -2562,7 +2567,12 @@ async def article_new():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def article_edit(article_id: int):
|
async def article_edit(article_id: int):
|
||||||
"""Edit a manual article."""
|
"""Edit a manual article."""
|
||||||
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path
|
from ..content.routes import (
|
||||||
|
BUILD_DIR,
|
||||||
|
bake_product_cards,
|
||||||
|
bake_scenario_cards,
|
||||||
|
is_reserved_path,
|
||||||
|
)
|
||||||
|
|
||||||
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||||
if not article:
|
if not article:
|
||||||
@@ -3266,8 +3276,8 @@ def _form_to_product(form) -> dict:
|
|||||||
|
|
||||||
pros_raw = form.get("pros", "").strip()
|
pros_raw = form.get("pros", "").strip()
|
||||||
cons_raw = form.get("cons", "").strip()
|
cons_raw = form.get("cons", "").strip()
|
||||||
pros = json.dumps([l.strip() for l in pros_raw.splitlines() if l.strip()])
|
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
|
||||||
cons = json.dumps([l.strip() for l in cons_raw.splitlines() if l.strip()])
|
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"slug": form.get("slug", "").strip(),
|
"slug": form.get("slug", "").strip(),
|
||||||
|
|||||||
332
web/tests/test_affiliate.py
Normal file
332
web/tests/test_affiliate.py
Normal 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
|
||||||
Reference in New Issue
Block a user