From ec839478c3d1d7f9a195e0285f872c73408c7f09 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:51:26 +0100 Subject: [PATCH] feat(affiliate): add program + URL assembly tests; update CHANGELOG + PROJECT.md 41 tests total (+15). New coverage: get_all_programs(), get_program(), get_program_by_slug(), build_affiliate_url() (program path, legacy fallback, no program_id, no program dict), program-based redirect, legacy redirect, migration seed assertion, ASIN backfill assertion. All ruff checks pass. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 ++ PROJECT.md | 3 +- web/tests/test_affiliate.py | 286 +++++++++++++++++++++++++++++++++++- 3 files changed, 300 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d538f0..b7bcf4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Affiliate programs management** — centralised retailer config (`affiliate_programs` table) with URL template + tracking tag + commission %. Products now use a program dropdown + product identifier (e.g. ASIN) instead of manually baking full URLs. URL is assembled at redirect time via `build_affiliate_url()`, so changing a tag propagates instantly to all products. Legacy products (baked `affiliate_url`) continue to work via fallback. Amazon OneLink configured in the Associates dashboard handles geo-redirect to local marketplaces — no per-country programs needed. + - `web/src/padelnomics/migrations/versions/0027_affiliate_programs.py`: `affiliate_programs` table, nullable `program_id` + `product_identifier` columns on `affiliate_products`, seeds "Amazon" program, backfills ASINs from existing URLs + - `web/src/padelnomics/affiliate.py`: `get_all_programs()`, `get_program()`, `get_program_by_slug()`, `build_affiliate_url()`; `get_product()` JOINs program for redirect assembly; `_parse_product()` extracts `_program` sub-dict + - `web/src/padelnomics/app.py`: `/go/` uses `build_affiliate_url()` — program-based products get URLs assembled at redirect time + - `web/src/padelnomics/admin/routes.py`: program CRUD routes (list, new, edit, delete — delete blocked if products reference the program); product form updated to program dropdown + identifier; `retailer` auto-populated from program name + - New templates: `admin/affiliate_programs.html`, `admin/affiliate_program_form.html`, `admin/partials/affiliate_program_results.html` + - Updated templates: `admin/affiliate_form.html` (program dropdown + JS toggle), `admin/base_admin.html` (Programs subnav tab) + - 15 new tests in `web/tests/test_affiliate.py` (41 total) + +### Fixed +- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews. + ### 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/` 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 diff --git a/PROJECT.md b/PROJECT.md index 9b1a226..f2be3ae 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,7 +1,7 @@ # Padelnomics — Project Tracker > Move tasks across columns as you work. Add new tasks at the top of the relevant column. -> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking). +> Last updated: 2026-02-28 (Affiliate programs management — centralised retailer config + URL template assembly). --- @@ -133,6 +133,7 @@ - [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] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/` 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 +- [x] **Affiliate programs management** — `affiliate_programs` table centralises retailer configs (URL template, tracking tag, commission %); product form uses program dropdown + product identifier (ASIN etc.); `build_affiliate_url()` assembles at redirect time; legacy baked-URL products still work; admin CRUD (delete blocked if products reference program); Amazon OneLink for multi-marketplace; article frontmatter preview bug fixed; 41 tests ### SEO & Legal - [x] Sitemap (both language variants, `` on all entries) diff --git a/web/tests/test_affiliate.py b/web/tests/test_affiliate.py index b1e98a6..2d9e0ec 100644 --- a/web/tests/test_affiliate.py +++ b/web/tests/test_affiliate.py @@ -2,7 +2,8 @@ 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. +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 @@ -10,11 +11,15 @@ 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, ) @@ -330,3 +335,282 @@ async def test_affiliate_redirect_unknown_404(app, db): 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()