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:
196
web/tests/test_noindex.py
Normal file
196
web/tests/test_noindex.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user