feat: outreach follow-up scheduling, activity timeline, and pSEO noindex (migration 0025)

Feature A — Outreach follow-up + activity timeline:
- follow_up_at column on suppliers (migration 0025)
- HTMX date picker on outreach rows, POST /admin/outreach/<id>/follow-up
- Amber due-today banner on /admin/outreach with ?follow_up=due filter
- get_follow_up_due_count() for dashboard widget
- Activity timeline on /admin/suppliers/<id>: merges sent + received emails by contact_email

Feature B — pSEO article noindex:
- noindex column on articles (migration 0025)
- NOINDEX_THRESHOLDS per-template lambdas in content/__init__.py
- generate_articles() evaluates threshold and stores noindex=1 for thin-data articles
- <meta name="robots" content="noindex, follow"> in article_detail.html
- Sitemap excludes noindex articles (AND noindex = 0)
- pSEO dashboard noindex count card + article row badge

Tests: 49 new tests (29 outreach, 20 noindex), 1377 total, 0 failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 17:51:38 +01:00
16 changed files with 605 additions and 14 deletions

196
web/tests/test_noindex.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Tests for pSEO article noindex feature.
Covers:
- NOINDEX_THRESHOLDS: lambda functions evaluate correctly per template
- Sitemap excludes articles with noindex=1
- Article detail page emits <meta name="robots" content="noindex, follow"> for noindex articles
- Article detail page has no robots meta tag for indexable articles
"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.content import NOINDEX_THRESHOLDS
# ── Threshold unit tests ─────────────────────────────────────────────────────
class TestNoindexThresholds:
def test_city_pricing_low_venue_count_is_noindex(self):
check = NOINDEX_THRESHOLDS["city-pricing"]
assert check({"venue_count": 0}) is True
assert check({"venue_count": 1}) is True
assert check({"venue_count": 2}) is True
def test_city_pricing_sufficient_venues_is_indexable(self):
check = NOINDEX_THRESHOLDS["city-pricing"]
assert check({"venue_count": 3}) is False
assert check({"venue_count": 10}) is False
def test_city_pricing_missing_venue_count_treated_as_zero(self):
check = NOINDEX_THRESHOLDS["city-pricing"]
assert check({}) is True
assert check({"venue_count": None}) is True
def test_city_cost_de_partial_data_is_noindex(self):
check = NOINDEX_THRESHOLDS["city-cost-de"]
assert check({"data_confidence": 0.0}) is True
assert check({"data_confidence": 0.5}) is True
assert check({"data_confidence": 0.99}) is True
def test_city_cost_de_full_confidence_is_indexable(self):
check = NOINDEX_THRESHOLDS["city-cost-de"]
assert check({"data_confidence": 1.0}) is False
def test_city_cost_de_missing_confidence_is_noindex(self):
check = NOINDEX_THRESHOLDS["city-cost-de"]
assert check({}) is True
assert check({"data_confidence": None}) is True
def test_country_overview_low_venues_is_noindex(self):
check = NOINDEX_THRESHOLDS["country-overview"]
assert check({"total_venues": 0}) is True
assert check({"total_venues": 4}) is True
def test_country_overview_sufficient_venues_is_indexable(self):
check = NOINDEX_THRESHOLDS["country-overview"]
assert check({"total_venues": 5}) is False
assert check({"total_venues": 100}) is False
def test_unknown_template_slug_has_no_threshold(self):
assert "manual" not in NOINDEX_THRESHOLDS
assert "unknown-template" not in NOINDEX_THRESHOLDS
# ── Sitemap exclusion ────────────────────────────────────────────────────────
async def _insert_article(
db,
url_path: str = "/markets/de/berlin",
title: str = "Test Article",
language: str = "en",
noindex: int = 0,
) -> int:
"""Insert a published article row and return its id."""
# Use a past published_at in SQLite-compatible format (space separator, no tz).
# SQLite's datetime('now') returns "YYYY-MM-DD HH:MM:SS" with a space.
# ISO format with T is lexicographically AFTER the space format for the
# same instant, so current-time ISO strings fail the <= datetime('now') check.
published_at = "2020-01-01 08:00:00"
created_at = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_slug, language, noindex, created_at)
VALUES (?, ?, ?, '', 'published', ?, 'city-pricing', ?, ?, ?)""",
(url_path, f"slug-{url_path.replace('/', '-')}", title,
published_at, language, noindex, created_at),
) as cursor:
article_id = cursor.lastrowid
await db.commit()
return article_id
class TestSitemapNoindex:
async def test_indexable_article_in_sitemap(self, client, db):
"""Article with noindex=0 should appear in sitemap."""
await _insert_article(db, url_path="/markets/en/berlin", noindex=0)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/markets/en/berlin" in xml
async def test_noindex_article_excluded_from_sitemap(self, client, db):
"""Article with noindex=1 must NOT appear in sitemap."""
await _insert_article(db, url_path="/markets/en/thin-city", noindex=1)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/markets/en/thin-city" not in xml
async def test_mixed_articles_only_indexable_in_sitemap(self, client, db):
"""Only indexable articles appear; noindex articles are silently dropped."""
await _insert_article(db, url_path="/markets/en/good-city", noindex=0)
await _insert_article(db, url_path="/markets/en/bad-city", noindex=1)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "good-city" in xml
assert "bad-city" not in xml
# ── Article detail robots meta tag ──────────────────────────────────────────
class TestArticleDetailRobotsTag:
"""
Test that the article detail template emits (or omits) the robots meta tag.
We test via the content blueprint's article route.
Routes.py imports BUILD_DIR from content/__init__.py at module load time, so
we must patch padelnomics.content.routes.BUILD_DIR (the local binding), not
padelnomics.content.BUILD_DIR.
"""
async def test_noindex_article_has_robots_meta(self, client, db, tmp_path, monkeypatch):
"""Article with noindex=1 → <meta name="robots" content="noindex, follow"> in HTML."""
import padelnomics.content.routes as routes_mod
build_dir = tmp_path / "en"
build_dir.mkdir(parents=True)
url_path = "/markets/noindex-test"
slug = "city-pricing-en-noindex-test"
(build_dir / f"{slug}.html").write_text("<p>Article body</p>")
monkeypatch.setattr(routes_mod, "BUILD_DIR", tmp_path)
# Use past published_at in SQLite space-separator format
async with db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_slug, language, noindex, created_at)
VALUES (?, ?, 'Noindex Test', '', 'published', '2020-01-01 08:00:00',
'city-pricing', 'en', 1, datetime('now'))""",
(url_path, slug),
) as cursor:
pass
await db.commit()
resp = await client.get(f"/en{url_path}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert 'name="robots"' in html
assert "noindex" in html
async def test_indexable_article_has_no_robots_meta(self, client, db, tmp_path, monkeypatch):
"""Article with noindex=0 → no robots meta tag in HTML."""
import padelnomics.content.routes as routes_mod
build_dir = tmp_path / "en"
build_dir.mkdir(parents=True)
url_path = "/markets/indexable-test"
slug = "city-pricing-en-indexable-test"
(build_dir / f"{slug}.html").write_text("<p>Article body</p>")
monkeypatch.setattr(routes_mod, "BUILD_DIR", tmp_path)
async with db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_slug, language, noindex, created_at)
VALUES (?, ?, 'Indexable Test', '', 'published', '2020-01-01 08:00:00',
'city-pricing', 'en', 0, datetime('now'))""",
(url_path, slug),
) as cursor:
pass
await db.commit()
resp = await client.get(f"/en{url_path}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert 'content="noindex' not in html

View File

@@ -22,6 +22,7 @@ from quart.datastructures import FileStorage
from padelnomics import core
from padelnomics.admin.routes import (
OUTREACH_STATUSES,
get_follow_up_due_count,
get_outreach_pipeline,
get_outreach_suppliers,
)
@@ -74,6 +75,7 @@ async def _insert_supplier(
outreach_notes: str = None,
outreach_sequence_step: int = 0,
last_contacted_at: str = None,
follow_up_at: str = None,
) -> int:
"""Insert a supplier row and return its id."""
now = datetime.now(UTC).isoformat()
@@ -82,12 +84,12 @@ async def _insert_supplier(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
contact_email, outreach_status, outreach_notes, outreach_sequence_step,
last_contacted_at, created_at)
last_contacted_at, follow_up_at, created_at)
VALUES (?, ?, ?, 'Europe', 'construction', 'free',
?, ?, ?, ?, ?, ?)""",
?, ?, ?, ?, ?, ?, ?)""",
(name, slug, country_code, contact_email,
outreach_status, outreach_notes, outreach_sequence_step,
last_contacted_at, now),
last_contacted_at, follow_up_at, now),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
@@ -617,6 +619,216 @@ class TestComposePipelineUpdate:
assert row["outreach_sequence_step"] == 0 # unchanged
# ── Follow-up Scheduling ─────────────────────────────────────────────────────
class TestFollowUpRoute:
async def test_follow_up_set(self, admin_client, db):
"""POST a date → follow_up_at saved, updated row returned."""
supplier_id = await _insert_supplier(db, outreach_status="prospect")
resp = await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "2026-03-01", "csrf_token": _TEST_CSRF},
)
assert resp.status_code == 200
row = await core.fetch_one(
"SELECT follow_up_at FROM suppliers WHERE id = ?", (supplier_id,)
)
assert row["follow_up_at"] == "2026-03-01"
async def test_follow_up_set_returns_row_html(self, admin_client, db):
"""Response should contain the supplier row (for HTMX outerHTML swap)."""
supplier_id = await _insert_supplier(db, outreach_status="contacted")
resp = await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "2026-04-15", "csrf_token": _TEST_CSRF},
)
html = (await resp.data).decode()
assert str(supplier_id) in html
async def test_follow_up_clear(self, admin_client, db):
"""POST empty date → follow_up_at set to NULL."""
supplier_id = await _insert_supplier(
db, outreach_status="contacted", follow_up_at="2026-03-01"
)
await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "", "csrf_token": _TEST_CSRF},
)
row = await core.fetch_one(
"SELECT follow_up_at FROM suppliers WHERE id = ?", (supplier_id,)
)
assert row["follow_up_at"] is None
async def test_follow_up_404_for_non_pipeline_supplier(self, admin_client, db):
"""Supplier not in outreach pipeline → 404."""
supplier_id = await _insert_supplier(db, outreach_status=None)
resp = await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "2026-03-01", "csrf_token": _TEST_CSRF},
)
assert resp.status_code == 404
class TestFollowUpDueCount:
async def test_counts_only_due_and_overdue(self, db):
"""Only suppliers with follow_up_at <= today are counted."""
# Past (overdue)
await _insert_supplier(db, name="Past", outreach_status="contacted",
follow_up_at="2020-01-01")
# Future (not yet due)
await _insert_supplier(db, name="Future", outreach_status="contacted",
follow_up_at="2099-12-31")
# No follow-up scheduled
await _insert_supplier(db, name="None", outreach_status="prospect",
follow_up_at=None)
count = await get_follow_up_due_count()
assert count == 1
async def test_zero_when_no_follow_ups(self, db):
await _insert_supplier(db, outreach_status="prospect")
count = await get_follow_up_due_count()
assert count == 0
async def test_follow_up_due_filter(self, db):
"""?follow_up=due filter returns only overdue suppliers."""
await _insert_supplier(db, name="Overdue", outreach_status="contacted",
follow_up_at="2020-06-01")
await _insert_supplier(db, name="NotYet", outreach_status="contacted",
follow_up_at="2099-01-01")
due = await get_outreach_suppliers(follow_up="due")
names = [s["name"] for s in due]
assert "Overdue" in names
assert "NotYet" not in names
async def test_follow_up_set_filter(self, db):
"""?follow_up=set returns suppliers with any follow-up date."""
await _insert_supplier(db, name="HasDate", outreach_status="contacted",
follow_up_at="2099-01-01")
await _insert_supplier(db, name="NoDate", outreach_status="contacted",
follow_up_at=None)
with_date = await get_outreach_suppliers(follow_up="set")
names = [s["name"] for s in with_date]
assert "HasDate" in names
assert "NoDate" not in names
async def test_outreach_dashboard_shows_follow_up_banner(self, admin_client, db):
"""Banner visible when there's at least one due follow-up."""
await _insert_supplier(db, name="DueNow", outreach_status="prospect",
follow_up_at="2020-01-01")
resp = await admin_client.get("/admin/outreach")
html = (await resp.data).decode()
assert "follow-up" in html.lower() or "Follow-up" in html
async def test_outreach_results_follow_up_filter(self, admin_client, db):
"""follow_up=due querystring filters results partial."""
await _insert_supplier(db, name="DueSupplier", outreach_status="contacted",
follow_up_at="2020-01-01")
await _insert_supplier(db, name="FutureSupplier", outreach_status="contacted",
follow_up_at="2099-01-01")
resp = await admin_client.get("/admin/outreach/results?follow_up=due")
html = (await resp.data).decode()
assert "DueSupplier" in html
assert "FutureSupplier" not in html
# ── Activity Timeline ─────────────────────────────────────────────────────────
async def _insert_email_log(db, to_addr: str, subject: str, email_type: str = "outreach") -> int:
"""Insert a sent email entry into email_log."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO email_log (from_addr, to_addr, subject, email_type, created_at)
VALUES (?, ?, ?, ?, ?)""",
("hello@hello.padelnomics.io", to_addr, subject, email_type, now),
) as cursor:
row_id = cursor.lastrowid
await db.commit()
return row_id
async def _insert_inbound_email(db, from_addr: str, subject: str) -> int:
"""Insert a received email entry into inbound_emails."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO inbound_emails
(resend_id, from_addr, to_addr, subject, received_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(f"test-{now}", from_addr, "inbox@hello.padelnomics.io", subject, now, now),
) as cursor:
row_id = cursor.lastrowid
await db.commit()
return row_id
class TestActivityTimeline:
async def test_timeline_shows_sent_emails(self, admin_client, db):
"""Sent outreach emails visible in supplier detail timeline."""
supplier_id = await _insert_supplier(
db, name="Timeline Test", contact_email="timeline@example.com",
outreach_status="contacted",
)
await _insert_email_log(db, "timeline@example.com", "Introduction email")
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Introduction email" in html
async def test_timeline_shows_received_emails(self, admin_client, db):
"""Received emails visible in supplier detail timeline."""
supplier_id = await _insert_supplier(
db, name="Inbound Test", contact_email="inbound@example.com",
outreach_status="replied",
)
await _insert_inbound_email(db, "inbound@example.com", "Re: Partnership")
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Re: Partnership" in html
async def test_timeline_empty_state(self, admin_client, db):
"""Supplier with no emails shows empty state message."""
supplier_id = await _insert_supplier(
db, name="No Emails", contact_email="noemail@example.com",
)
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "No email history" in html
async def test_timeline_excludes_non_outreach_sent_emails(self, admin_client, db):
"""Transactional sent emails (magic links etc.) not shown in timeline."""
supplier_id = await _insert_supplier(
db, name="Transact Test", contact_email="trans@example.com",
)
await _insert_email_log(
db, "trans@example.com", "Magic link", email_type="magic_link"
)
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
html = (await resp.data).decode()
assert "Magic link" not in html
async def test_timeline_not_shown_when_no_contact_email(self, admin_client, db):
"""Timeline section should still load (empty) when contact_email is NULL."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO suppliers (name, slug, tier, country_code, region, category, created_at)
VALUES ('No Contact', 'no-contact', 'free', 'XX', 'Europe', 'construction', ?)""",
(now,),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
# ── CSV writer helper (avoids importing DictWriter at module level) ────────────
import csv as _csv_module