Article slug is template_slug + city_slug ("city-cost-miami"),
not just the city slug ("miami").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1114 lines
46 KiB
Python
1114 lines
46 KiB
Python
"""
|
|
Tests for the programmatic SEO content generation engine.
|
|
|
|
Covers: slugify utility, migration 0010, generation pipeline,
|
|
scenario widget baking, public article serving, markets hub,
|
|
sitemap integration, admin CRUD routes, and path collision prevention.
|
|
"""
|
|
import importlib
|
|
import json
|
|
import sqlite3
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from padelnomics.content.routes import (
|
|
RESERVED_PREFIXES,
|
|
SCENARIO_RE,
|
|
SECTION_TEMPLATES,
|
|
bake_scenario_cards,
|
|
is_reserved_path,
|
|
)
|
|
from padelnomics.core import execute, fetch_all, fetch_one, slugify
|
|
from padelnomics.planner.calculator import calc, validate_state
|
|
|
|
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "schema.sql"
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────
|
|
|
|
def _table_names(conn):
|
|
rows = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
" AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
).fetchall()
|
|
return [r[0] for r in rows]
|
|
|
|
|
|
def _trigger_names(conn):
|
|
rows = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name"
|
|
).fetchall()
|
|
return [r[0] for r in rows]
|
|
|
|
|
|
def _index_names(conn):
|
|
rows = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='index'"
|
|
" AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
).fetchall()
|
|
return [r[0] for r in rows]
|
|
|
|
|
|
def _column_names(conn, table):
|
|
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
|
|
|
|
|
async def _create_published_scenario(slug="test-scenario", city="TestCity", country="TC"):
|
|
"""Insert a published scenario with real calc data, return its id."""
|
|
state = validate_state({"dblCourts": 4, "sglCourts": 2})
|
|
d = calc(state)
|
|
return await execute(
|
|
"""INSERT INTO published_scenarios
|
|
(slug, title, subtitle, location, country, venue_type, ownership,
|
|
court_config, state_json, calc_json)
|
|
VALUES (?, ?, '', ?, ?, 'indoor', 'rent', '4 double + 2 single', ?, ?)""",
|
|
(slug, f"Scenario {city}", city, country, json.dumps(state), json.dumps(d)),
|
|
)
|
|
|
|
|
|
async def _create_article(slug="test-article", url_path="/test-article",
|
|
status="published", published_at=None):
|
|
"""Insert an article row, return its id."""
|
|
pub = published_at or datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
|
return await execute(
|
|
"""INSERT INTO articles
|
|
(url_path, slug, title, meta_description, country, region,
|
|
status, published_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(url_path, slug, f"Title {slug}", f"Desc {slug}", "US", "North America",
|
|
status, pub),
|
|
)
|
|
|
|
|
|
async def _create_template():
|
|
"""Insert a template + 3 data rows, return (template_id, data_row_count)."""
|
|
template_id = await execute(
|
|
"""INSERT INTO article_templates
|
|
(name, slug, content_type, input_schema, url_pattern,
|
|
title_pattern, meta_description_pattern, body_template)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
"City Cost Analysis", "city-cost", "calculator",
|
|
json.dumps([
|
|
{"name": "city", "label": "City", "field_type": "text", "required": True},
|
|
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True},
|
|
{"name": "country", "label": "Country", "field_type": "text", "required": True},
|
|
{"name": "region", "label": "Region", "field_type": "text", "required": False},
|
|
{"name": "electricity", "label": "Electricity", "field_type": "number", "required": False},
|
|
]),
|
|
"/padel-court-cost-{{ city_slug }}",
|
|
"Padel Center Cost in {{ city }}",
|
|
"How much does a padel center cost in {{ city }}?",
|
|
"# Padel in {{ city }}\n\n[scenario:{{ scenario_slug }}]\n\n## CAPEX\n\n[scenario:{{ scenario_slug }}:capex]",
|
|
),
|
|
)
|
|
|
|
cities = [
|
|
("Miami", "miami", "US", "North America", 700),
|
|
("Madrid", "madrid", "ES", "Europe", 500),
|
|
("Berlin", "berlin", "DE", "Europe", 550),
|
|
]
|
|
for city, slug, country, region, elec in cities:
|
|
await execute(
|
|
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
|
|
(template_id, json.dumps({
|
|
"city": city, "city_slug": slug, "country": country,
|
|
"region": region, "electricity": elec,
|
|
})),
|
|
)
|
|
return template_id, len(cities)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# slugify()
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestSlugify:
|
|
def test_basic(self):
|
|
assert slugify("Hello World") == "hello-world"
|
|
|
|
def test_unicode_stripped(self):
|
|
assert slugify("Padel Court Cost in München") == "padel-court-cost-in-munchen"
|
|
|
|
def test_special_chars_removed(self):
|
|
assert slugify("Test/Special@Chars!") == "testspecialchars"
|
|
|
|
def test_max_length(self):
|
|
result = slugify("a " * 100, max_length_chars=20)
|
|
assert len(result) <= 20
|
|
|
|
def test_empty_string(self):
|
|
assert slugify("") == ""
|
|
|
|
def test_leading_trailing_hyphens_stripped(self):
|
|
assert slugify("---hello---") == "hello"
|
|
|
|
def test_multiple_spaces_collapsed(self):
|
|
assert slugify("hello world") == "hello-world"
|
|
|
|
def test_accented_characters(self):
|
|
assert slugify("café résumé naïve") == "cafe-resume-naive"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Migration 0010
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestMigration0010:
|
|
"""Synchronous tests — migration uses stdlib sqlite3."""
|
|
|
|
def _run_migration(self, db_path):
|
|
conn = sqlite3.connect(db_path)
|
|
mod = importlib.import_module("padelnomics.migrations.versions.0010_add_content_tables")
|
|
mod.up(conn)
|
|
conn.commit()
|
|
return conn
|
|
|
|
def test_creates_all_tables(self, tmp_path):
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
tables = _table_names(conn)
|
|
assert "published_scenarios" in tables
|
|
assert "article_templates" in tables
|
|
assert "template_data" in tables
|
|
assert "articles" in tables
|
|
conn.close()
|
|
|
|
def test_creates_fts_table(self, tmp_path):
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
tables = _table_names(conn)
|
|
assert "articles_fts" in tables
|
|
conn.close()
|
|
|
|
def test_creates_sync_triggers(self, tmp_path):
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
triggers = _trigger_names(conn)
|
|
assert "articles_ai" in triggers
|
|
assert "articles_ad" in triggers
|
|
assert "articles_au" in triggers
|
|
conn.close()
|
|
|
|
def test_creates_indexes(self, tmp_path):
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
indexes = _index_names(conn)
|
|
assert "idx_pub_scenarios_slug" in indexes
|
|
assert "idx_article_templates_slug" in indexes
|
|
assert "idx_template_data_template" in indexes
|
|
assert "idx_articles_url_path" in indexes
|
|
assert "idx_articles_slug" in indexes
|
|
assert "idx_articles_status" in indexes
|
|
conn.close()
|
|
|
|
def test_published_scenarios_columns(self, tmp_path):
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
cols = _column_names(conn, "published_scenarios")
|
|
for expected in ("id", "slug", "title", "location", "country",
|
|
"venue_type", "ownership", "court_config",
|
|
"state_json", "calc_json", "template_data_id"):
|
|
assert expected in cols, f"{expected} missing from published_scenarios"
|
|
conn.close()
|
|
|
|
def test_articles_columns(self, tmp_path):
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
cols = _column_names(conn, "articles")
|
|
for expected in ("id", "url_path", "slug", "title", "meta_description",
|
|
"country", "region", "status", "published_at",
|
|
"template_data_id"):
|
|
assert expected in cols, f"{expected} missing from articles"
|
|
conn.close()
|
|
|
|
def test_idempotent(self, tmp_path):
|
|
"""Running the migration twice does not raise."""
|
|
db_path = str(tmp_path / "test.db")
|
|
conn = self._run_migration(db_path)
|
|
conn.close()
|
|
# Run again
|
|
conn = self._run_migration(db_path)
|
|
tables = _table_names(conn)
|
|
assert "articles" in tables
|
|
conn.close()
|
|
|
|
def test_fts_sync_on_insert(self, tmp_path):
|
|
"""Inserting into articles populates articles_fts via trigger."""
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
conn.execute(
|
|
"""INSERT INTO articles (url_path, slug, title, meta_description,
|
|
country, region, status, published_at)
|
|
VALUES ('/test', 'test', 'Test Title', 'desc', 'US', 'NA',
|
|
'published', '2026-01-01')"""
|
|
)
|
|
conn.commit()
|
|
rows = conn.execute(
|
|
"SELECT * FROM articles_fts WHERE articles_fts MATCH 'Test'"
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
conn.close()
|
|
|
|
def test_fts_sync_on_delete(self, tmp_path):
|
|
"""Deleting from articles removes from articles_fts via trigger."""
|
|
conn = self._run_migration(str(tmp_path / "test.db"))
|
|
conn.execute(
|
|
"""INSERT INTO articles (url_path, slug, title, meta_description,
|
|
country, region, status, published_at)
|
|
VALUES ('/test', 'test', 'UniqueTitle', 'desc', 'US', 'NA',
|
|
'published', '2026-01-01')"""
|
|
)
|
|
conn.commit()
|
|
conn.execute("DELETE FROM articles WHERE slug = 'test'")
|
|
conn.commit()
|
|
rows = conn.execute(
|
|
"SELECT * FROM articles_fts WHERE articles_fts MATCH 'UniqueTitle'"
|
|
).fetchall()
|
|
assert len(rows) == 0
|
|
conn.close()
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Scenario regex
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestScenarioRegex:
|
|
def test_simple_marker(self):
|
|
m = SCENARIO_RE.search("[scenario:my-slug]")
|
|
assert m.group(1) == "my-slug"
|
|
assert m.group(2) is None
|
|
|
|
def test_section_marker(self):
|
|
m = SCENARIO_RE.search("[scenario:my-slug:capex]")
|
|
assert m.group(1) == "my-slug"
|
|
assert m.group(2) == "capex"
|
|
|
|
def test_all_sections_have_templates(self):
|
|
for section in (None, "capex", "operating", "cashflow", "returns", "full"):
|
|
assert section in SECTION_TEMPLATES
|
|
|
|
def test_no_match_on_invalid(self):
|
|
assert SCENARIO_RE.search("[scenario:UPPER]") is None
|
|
assert SCENARIO_RE.search("[scenario:]") is None
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Path collision prevention
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestReservedPaths:
|
|
def test_admin_reserved(self):
|
|
assert is_reserved_path("/admin/anything") is True
|
|
|
|
def test_planner_reserved(self):
|
|
assert is_reserved_path("/planner/") is True
|
|
|
|
def test_markets_reserved(self):
|
|
assert is_reserved_path("/markets") is True
|
|
|
|
def test_custom_path_allowed(self):
|
|
assert is_reserved_path("/padel-court-cost-miami") is False
|
|
|
|
def test_leading_slash_normalized(self):
|
|
assert is_reserved_path("admin/foo") is True
|
|
|
|
def test_all_prefixes_covered(self):
|
|
"""Every prefix in the tuple is actually checked."""
|
|
for prefix in RESERVED_PREFIXES:
|
|
assert is_reserved_path(prefix + "/test") is True
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Scenario card baking
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestBakeScenarioCards:
|
|
async def test_no_markers_returns_unchanged(self, db):
|
|
html = "<p>Hello world</p>"
|
|
result = await bake_scenario_cards(html)
|
|
assert result == html
|
|
|
|
async def test_unknown_slug_left_as_marker(self, db):
|
|
"""Markers referencing non-existent scenarios are removed (replaced with nothing)."""
|
|
html = "<p>[scenario:nonexistent]</p>"
|
|
result = await bake_scenario_cards(html)
|
|
# The marker position is replaced but scenario not found → no replacement
|
|
assert "scenario-widget" not in result
|
|
|
|
async def test_summary_card_baked(self, db):
|
|
await _create_published_scenario(slug="test-bake")
|
|
html = "<div>[scenario:test-bake]</div>"
|
|
result = await bake_scenario_cards(html)
|
|
assert "scenario-widget" in result
|
|
assert "scenario-summary" in result
|
|
assert "TestCity" in result
|
|
|
|
async def test_capex_section_baked(self, db):
|
|
await _create_published_scenario(slug="test-capex")
|
|
html = "[scenario:test-capex:capex]"
|
|
result = await bake_scenario_cards(html)
|
|
assert "scenario-capex" in result
|
|
assert "Investment Breakdown" in result
|
|
|
|
async def test_operating_section_baked(self, db):
|
|
await _create_published_scenario(slug="test-op")
|
|
html = "[scenario:test-op:operating]"
|
|
result = await bake_scenario_cards(html)
|
|
assert "scenario-operating" in result
|
|
|
|
async def test_cashflow_section_baked(self, db):
|
|
await _create_published_scenario(slug="test-cf")
|
|
html = "[scenario:test-cf:cashflow]"
|
|
result = await bake_scenario_cards(html)
|
|
assert "scenario-cashflow" in result
|
|
|
|
async def test_returns_section_baked(self, db):
|
|
await _create_published_scenario(slug="test-ret")
|
|
html = "[scenario:test-ret:returns]"
|
|
result = await bake_scenario_cards(html)
|
|
assert "scenario-returns" in result
|
|
|
|
async def test_full_section_baked(self, db):
|
|
await _create_published_scenario(slug="test-full")
|
|
html = "[scenario:test-full:full]"
|
|
result = await bake_scenario_cards(html)
|
|
# Full includes all sections
|
|
assert "scenario-summary" in result
|
|
assert "scenario-capex" in result
|
|
assert "scenario-operating" in result
|
|
assert "scenario-cashflow" in result
|
|
assert "scenario-returns" in result
|
|
|
|
async def test_multiple_markers_same_slug(self, db):
|
|
await _create_published_scenario(slug="multi")
|
|
html = "[scenario:multi]\n---\n[scenario:multi:capex]"
|
|
result = await bake_scenario_cards(html)
|
|
assert "scenario-summary" in result
|
|
assert "scenario-capex" in result
|
|
|
|
async def test_planner_cta_link(self, db):
|
|
await _create_published_scenario(slug="cta-test")
|
|
html = "[scenario:cta-test]"
|
|
result = await bake_scenario_cards(html)
|
|
assert "planner" in result
|
|
assert "Try with your own numbers" in result
|
|
|
|
async def test_invalid_section_ignored(self, db):
|
|
await _create_published_scenario(slug="bad-sec")
|
|
html = "[scenario:bad-sec:nonexistent]"
|
|
result = await bake_scenario_cards(html)
|
|
# Invalid section key → no template → marker untouched
|
|
assert "scenario-widget" not in result
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Generation pipeline
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestGenerationPipeline:
|
|
async def test_generates_correct_count(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, count = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
generated = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
assert generated == count
|
|
|
|
async def test_staggered_dates_two_per_day(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 2)
|
|
|
|
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
|
assert len(articles) == 3
|
|
dates = [a["published_at"][:10] for a in articles]
|
|
# 2 on day 1, 1 on day 2
|
|
assert dates[0] == "2026-03-01"
|
|
assert dates[1] == "2026-03-01"
|
|
assert dates[2] == "2026-03-02"
|
|
|
|
async def test_staggered_dates_one_per_day(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 1)
|
|
|
|
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
|
dates = sorted({a["published_at"][:10] for a in articles})
|
|
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
|
|
|
|
async def test_article_url_and_title(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
|
|
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'")
|
|
assert miami is not None
|
|
assert miami["url_path"] == "/padel-court-cost-miami"
|
|
assert miami["title"] == "Padel Center Cost in Miami"
|
|
assert miami["country"] == "US"
|
|
assert miami["region"] == "North America"
|
|
assert miami["status"] == "published"
|
|
|
|
async def test_scenario_created_per_row(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, count = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
|
|
scenarios = await fetch_all("SELECT * FROM published_scenarios")
|
|
assert len(scenarios) == count
|
|
|
|
async def test_scenario_has_valid_calc_json(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
|
|
scenario = await fetch_one(
|
|
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
|
|
)
|
|
assert scenario is not None
|
|
d = json.loads(scenario["calc_json"])
|
|
assert "capex" in d
|
|
assert "ebitdaMonth" in d
|
|
assert "irr" in d
|
|
assert d["capex"] > 0
|
|
|
|
async def test_template_data_linked(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
|
|
rows = await fetch_all(
|
|
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
|
)
|
|
for row in rows:
|
|
assert row["article_id"] is not None, f"Row {row['id']} not linked to article"
|
|
assert row["scenario_id"] is not None, f"Row {row['id']} not linked to scenario"
|
|
|
|
async def test_build_files_written(self, db):
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
from padelnomics.content.routes import BUILD_DIR
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
|
|
articles = await fetch_all("SELECT slug FROM articles")
|
|
try:
|
|
for a in articles:
|
|
build_path = BUILD_DIR / f"{a['slug']}.html"
|
|
assert build_path.exists(), f"Missing build file: {build_path}"
|
|
content = build_path.read_text()
|
|
assert len(content) > 100, f"Build file too small: {build_path}"
|
|
assert "scenario-widget" in content
|
|
finally:
|
|
# Cleanup build files
|
|
for a in articles:
|
|
p = BUILD_DIR / f"{a['slug']}.html"
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
async def test_skips_already_generated(self, db):
|
|
"""Running generate twice does not duplicate articles."""
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, count = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
|
|
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
assert first == count
|
|
|
|
# Second run: all rows already linked → 0 generated
|
|
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10)
|
|
assert second == 0
|
|
|
|
articles = await fetch_all("SELECT * FROM articles")
|
|
assert len(articles) == count
|
|
|
|
# Cleanup
|
|
from padelnomics.content.routes import BUILD_DIR
|
|
for a in articles:
|
|
p = BUILD_DIR / f"{a['slug']}.html"
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
async def test_calc_overrides_applied(self, db):
|
|
"""Data row values that match DEFAULTS keys are used as calc overrides."""
|
|
from padelnomics.admin.routes import _generate_from_template
|
|
template_id, _ = await _create_template()
|
|
template = await fetch_one(
|
|
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
)
|
|
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
|
|
# Miami had electricity=700, default is 600
|
|
scenario = await fetch_one(
|
|
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
|
|
)
|
|
state = json.loads(scenario["state_json"])
|
|
assert state["electricity"] == 700
|
|
|
|
# Cleanup
|
|
from padelnomics.content.routes import BUILD_DIR
|
|
for slug in ("miami", "madrid", "berlin"):
|
|
p = BUILD_DIR / f"{slug}.html"
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Jinja string rendering
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestRenderJinjaString:
|
|
def test_simple(self):
|
|
from padelnomics.admin.routes import _render_jinja_string
|
|
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
|
|
|
|
def test_missing_var_empty(self):
|
|
from padelnomics.admin.routes import _render_jinja_string
|
|
result = _render_jinja_string("Hello {{ missing }}!", {})
|
|
assert result == "Hello !"
|
|
|
|
def test_url_pattern(self):
|
|
from padelnomics.admin.routes import _render_jinja_string
|
|
result = _render_jinja_string("/padel-court-cost-{{ slug }}", {"slug": "miami"})
|
|
assert result == "/padel-court-cost-miami"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Public routes
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestMarketsHub:
|
|
async def test_markets_returns_200(self, client):
|
|
resp = await client.get("/en/markets")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_markets_has_search(self, client):
|
|
resp = await client.get("/en/markets")
|
|
html = (await resp.data).decode()
|
|
assert 'id="market-q"' in html
|
|
|
|
async def test_markets_results_partial(self, client):
|
|
resp = await client.get("/en/markets/results")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_markets_shows_published_articles(self, client, db):
|
|
await _create_article(slug="pub-test", url_path="/pub-test", status="published")
|
|
resp = await client.get("/en/markets")
|
|
html = (await resp.data).decode()
|
|
assert "Title pub-test" in html
|
|
|
|
async def test_markets_hides_draft_articles(self, client, db):
|
|
await _create_article(slug="draft-test", url_path="/draft-test", status="draft")
|
|
resp = await client.get("/en/markets")
|
|
html = (await resp.data).decode()
|
|
assert "Title draft-test" not in html
|
|
|
|
async def test_markets_hides_future_articles(self, client, db):
|
|
await _create_article(
|
|
slug="future-test", url_path="/future-test",
|
|
status="published", published_at="2099-01-01T00:00:00",
|
|
)
|
|
resp = await client.get("/en/markets")
|
|
html = (await resp.data).decode()
|
|
assert "Title future-test" not in html
|
|
|
|
async def test_markets_filter_by_country(self, client, db):
|
|
await _create_article(slug="us-art", url_path="/us-art")
|
|
resp = await client.get("/en/markets/results?country=US")
|
|
html = (await resp.data).decode()
|
|
assert "Title us-art" in html
|
|
|
|
resp = await client.get("/en/markets/results?country=DE")
|
|
html = (await resp.data).decode()
|
|
assert "Title us-art" not in html
|
|
|
|
|
|
class TestArticleServing:
|
|
async def test_nonexistent_article_returns_404(self, client):
|
|
resp = await client.get("/en/padel-court-cost-nonexistent")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_draft_article_returns_404(self, client, db):
|
|
await _create_article(slug="draft-serve", url_path="/draft-serve", status="draft")
|
|
resp = await client.get("/en/draft-serve")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_future_article_returns_404(self, client, db):
|
|
await _create_article(
|
|
slug="future-serve", url_path="/future-serve",
|
|
status="published", published_at="2099-01-01T00:00:00",
|
|
)
|
|
resp = await client.get("/en/future-serve")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_published_article_served(self, client, db):
|
|
from padelnomics.content.routes import BUILD_DIR
|
|
await _create_article(slug="live-art", url_path="/live-art")
|
|
|
|
# Write a build file
|
|
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
|
build_path = BUILD_DIR / "live-art.html"
|
|
build_path.write_text("<p>Article body content</p>")
|
|
|
|
try:
|
|
resp = await client.get("/en/live-art")
|
|
assert resp.status_code == 200
|
|
html = (await resp.data).decode()
|
|
assert "Article body content" in html
|
|
assert "Title live-art" in html
|
|
finally:
|
|
if build_path.exists():
|
|
build_path.unlink()
|
|
|
|
async def test_article_missing_build_file_returns_404(self, client, db):
|
|
await _create_article(slug="no-build", url_path="/no-build")
|
|
resp = await client.get("/en/no-build")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Sitemap integration
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestSitemapContent:
|
|
async def test_sitemap_includes_markets(self, client):
|
|
resp = await client.get("/sitemap.xml")
|
|
xml = (await resp.data).decode()
|
|
assert "/markets" in xml
|
|
|
|
async def test_sitemap_includes_published_articles(self, client, db):
|
|
await _create_article(slug="sitemap-art", url_path="/sitemap-art")
|
|
resp = await client.get("/sitemap.xml")
|
|
xml = (await resp.data).decode()
|
|
assert "/sitemap-art" in xml
|
|
|
|
async def test_sitemap_excludes_draft_articles(self, client, db):
|
|
await _create_article(slug="sitemap-draft", url_path="/sitemap-draft", status="draft")
|
|
resp = await client.get("/sitemap.xml")
|
|
xml = (await resp.data).decode()
|
|
assert "/sitemap-draft" not in xml
|
|
|
|
async def test_sitemap_excludes_future_articles(self, client, db):
|
|
await _create_article(
|
|
slug="sitemap-future", url_path="/sitemap-future",
|
|
status="published", published_at="2099-01-01T00:00:00",
|
|
)
|
|
resp = await client.get("/sitemap.xml")
|
|
xml = (await resp.data).decode()
|
|
assert "/sitemap-future" not in xml
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Route registration
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestRouteRegistration:
|
|
def test_markets_route_registered(self, app):
|
|
rules = [r.rule for r in app.url_map.iter_rules()]
|
|
assert "/<lang>/markets" in rules
|
|
|
|
def test_admin_content_routes_registered(self, app):
|
|
rules = [r.rule for r in app.url_map.iter_rules()]
|
|
assert "/admin/templates" in rules
|
|
assert "/admin/scenarios" in rules
|
|
assert "/admin/articles" in rules
|
|
assert "/admin/scenarios/new" in rules
|
|
assert "/admin/rebuild-all" in rules
|
|
|
|
def test_catchall_route_registered(self, app):
|
|
rules = [r.rule for r in app.url_map.iter_rules()]
|
|
assert "/<lang>/<path:url_path>" in rules
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Admin routes (require admin session)
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
@pytest.fixture
|
|
async def admin_client(app, db):
|
|
"""Test client with admin user (has admin role)."""
|
|
from datetime import datetime
|
|
now = datetime.utcnow().isoformat()
|
|
async with db.execute(
|
|
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
|
|
("admin@test.com", "Admin", now),
|
|
) as cursor:
|
|
admin_id = cursor.lastrowid
|
|
await db.execute(
|
|
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
|
|
)
|
|
await db.commit()
|
|
|
|
async with app.test_client() as c:
|
|
async with c.session_transaction() as sess:
|
|
sess["user_id"] = admin_id
|
|
yield c
|
|
|
|
|
|
class TestAdminTemplates:
|
|
async def test_template_list_requires_admin(self, client):
|
|
resp = await client.get("/admin/templates")
|
|
assert resp.status_code == 302
|
|
|
|
async def test_template_list(self, admin_client):
|
|
resp = await admin_client.get("/admin/templates")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_template_new_form(self, admin_client):
|
|
resp = await admin_client.get("/admin/templates/new")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_template_create(self, admin_client, db):
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post("/admin/templates/new", form={
|
|
"csrf_token": "test",
|
|
"name": "Test Template",
|
|
"slug": "test-tmpl",
|
|
"content_type": "calculator",
|
|
"input_schema": '[{"name":"city","label":"City","field_type":"text","required":true}]',
|
|
"url_pattern": "/test-{{ city }}",
|
|
"title_pattern": "Test {{ city }}",
|
|
"meta_description_pattern": "",
|
|
"body_template": "# Hello {{ city }}",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT * FROM article_templates WHERE slug = 'test-tmpl'")
|
|
assert row is not None
|
|
assert row["name"] == "Test Template"
|
|
|
|
async def test_template_edit(self, admin_client, db):
|
|
template_id = await execute(
|
|
"""INSERT INTO article_templates
|
|
(name, slug, content_type, input_schema, url_pattern,
|
|
title_pattern, body_template)
|
|
VALUES ('Edit Me', 'edit-me', 'calculator', '[]',
|
|
'/edit', 'Edit', '# body')"""
|
|
)
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", form={
|
|
"csrf_token": "test",
|
|
"name": "Edited",
|
|
"input_schema": "[]",
|
|
"url_pattern": "/edit",
|
|
"title_pattern": "Edited",
|
|
"body_template": "# edited",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
|
assert row["name"] == "Edited"
|
|
|
|
async def test_template_delete(self, admin_client, db):
|
|
template_id = await execute(
|
|
"""INSERT INTO article_templates
|
|
(name, slug, content_type, input_schema, url_pattern,
|
|
title_pattern, body_template)
|
|
VALUES ('Del Me', 'del-me', 'calculator', '[]',
|
|
'/del', 'Del', '# body')"""
|
|
)
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", form={
|
|
"csrf_token": "test",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
|
assert row is None
|
|
|
|
|
|
class TestAdminScenarios:
|
|
async def test_scenario_list(self, admin_client):
|
|
resp = await admin_client.get("/admin/scenarios")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_scenario_new_form(self, admin_client):
|
|
resp = await admin_client.get("/admin/scenarios/new")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_scenario_create(self, admin_client, db):
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post("/admin/scenarios/new", form={
|
|
"csrf_token": "test",
|
|
"title": "Miami Test",
|
|
"slug": "miami-test",
|
|
"location": "Miami",
|
|
"country": "US",
|
|
"venue": "indoor",
|
|
"own": "rent",
|
|
"dblCourts": "4",
|
|
"sglCourts": "2",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT * FROM published_scenarios WHERE slug = 'miami-test'")
|
|
assert row is not None
|
|
assert row["title"] == "Miami Test"
|
|
d = json.loads(row["calc_json"])
|
|
assert d["capex"] > 0
|
|
|
|
async def test_scenario_edit_recalculates(self, admin_client, db):
|
|
scenario_id = await _create_published_scenario(slug="edit-sc")
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/scenarios/{scenario_id}/edit", form={
|
|
"csrf_token": "test",
|
|
"title": "Updated",
|
|
"location": "TestCity",
|
|
"country": "TC",
|
|
"dblCourts": "6",
|
|
"sglCourts": "0",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
|
assert row["title"] == "Updated"
|
|
state = json.loads(row["state_json"])
|
|
assert state["dblCourts"] == 6
|
|
|
|
async def test_scenario_delete(self, admin_client, db):
|
|
scenario_id = await _create_published_scenario(slug="del-sc")
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/scenarios/{scenario_id}/delete", form={
|
|
"csrf_token": "test",
|
|
})
|
|
assert resp.status_code == 302
|
|
assert await fetch_one("SELECT 1 FROM published_scenarios WHERE id = ?", (scenario_id,)) is None
|
|
|
|
async def test_scenario_preview(self, admin_client, db):
|
|
scenario_id = await _create_published_scenario(slug="preview-sc")
|
|
resp = await admin_client.get(f"/admin/scenarios/{scenario_id}/preview")
|
|
assert resp.status_code == 200
|
|
html = (await resp.data).decode()
|
|
assert "scenario-widget" in html
|
|
|
|
|
|
class TestAdminArticles:
|
|
async def test_article_list(self, admin_client):
|
|
resp = await admin_client.get("/admin/articles")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_article_new_form(self, admin_client):
|
|
resp = await admin_client.get("/admin/articles/new")
|
|
assert resp.status_code == 200
|
|
|
|
async def test_article_create_manual(self, admin_client, db):
|
|
from padelnomics.content.routes import BUILD_DIR
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post("/admin/articles/new", form={
|
|
"csrf_token": "test",
|
|
"title": "Manual Article",
|
|
"slug": "manual-art",
|
|
"url_path": "/manual-art",
|
|
"meta_description": "A test article",
|
|
"country": "US",
|
|
"region": "North America",
|
|
"body": "# Hello\n\nThis is a test.",
|
|
"status": "published",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT * FROM articles WHERE slug = 'manual-art'")
|
|
assert row is not None
|
|
assert row["title"] == "Manual Article"
|
|
|
|
# Build file created
|
|
build_path = BUILD_DIR / "manual-art.html"
|
|
try:
|
|
assert build_path.exists()
|
|
content = build_path.read_text()
|
|
assert "<h1>Hello</h1>" in content
|
|
finally:
|
|
if build_path.exists():
|
|
build_path.unlink()
|
|
md_path = Path("data/content/articles/manual-art.md")
|
|
if md_path.exists():
|
|
md_path.unlink()
|
|
|
|
async def test_article_reserved_path_rejected(self, admin_client, db):
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post("/admin/articles/new", form={
|
|
"csrf_token": "test",
|
|
"title": "Bad Path",
|
|
"slug": "bad-path",
|
|
"url_path": "/admin/evil",
|
|
"body": "# Nope",
|
|
"status": "draft",
|
|
})
|
|
# Should re-render form with error, not redirect
|
|
assert resp.status_code == 200
|
|
html = (await resp.data).decode()
|
|
assert "reserved" in html.lower() or "conflict" in html.lower()
|
|
|
|
async def test_article_publish_toggle(self, admin_client, db):
|
|
article_id = await _create_article(slug="toggle-art", url_path="/toggle-art", status="draft")
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/articles/{article_id}/publish", form={
|
|
"csrf_token": "test",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
row = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
|
|
assert row["status"] == "published"
|
|
|
|
# Toggle back
|
|
resp = await admin_client.post(f"/admin/articles/{article_id}/publish", form={
|
|
"csrf_token": "test",
|
|
})
|
|
row = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
|
|
assert row["status"] == "draft"
|
|
|
|
async def test_article_delete(self, admin_client, db):
|
|
article_id = await _create_article(slug="del-art", url_path="/del-art")
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/articles/{article_id}/delete", form={
|
|
"csrf_token": "test",
|
|
})
|
|
assert resp.status_code == 302
|
|
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
|
|
|
|
|
|
class TestAdminTemplateData:
|
|
async def test_data_add(self, admin_client, db):
|
|
template_id, _ = await _create_template()
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/templates/{template_id}/data/add", form={
|
|
"csrf_token": "test",
|
|
"city": "London",
|
|
"city_slug": "london",
|
|
"country": "UK",
|
|
"region": "Europe",
|
|
"electricity": "650",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
rows = await fetch_all(
|
|
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
|
)
|
|
# 3 from _create_template + 1 just added
|
|
assert len(rows) == 4
|
|
|
|
async def test_data_delete(self, admin_client, db):
|
|
template_id, _ = await _create_template()
|
|
rows = await fetch_all(
|
|
"SELECT id FROM template_data WHERE template_id = ?", (template_id,)
|
|
)
|
|
data_id = rows[0]["id"]
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(
|
|
f"/admin/templates/{template_id}/data/{data_id}/delete",
|
|
form={"csrf_token": "test"},
|
|
)
|
|
assert resp.status_code == 302
|
|
|
|
remaining = await fetch_all(
|
|
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
|
)
|
|
assert len(remaining) == 2
|
|
|
|
|
|
class TestAdminGenerate:
|
|
async def test_generate_form(self, admin_client, db):
|
|
template_id, _ = await _create_template()
|
|
resp = await admin_client.get(f"/admin/templates/{template_id}/generate")
|
|
assert resp.status_code == 200
|
|
html = (await resp.data).decode()
|
|
assert "3" in html # pending count
|
|
|
|
async def test_generate_creates_articles(self, admin_client, db):
|
|
from padelnomics.content.routes import BUILD_DIR
|
|
template_id, _ = await _create_template()
|
|
|
|
async with admin_client.session_transaction() as sess:
|
|
sess["csrf_token"] = "test"
|
|
|
|
resp = await admin_client.post(f"/admin/templates/{template_id}/generate", form={
|
|
"csrf_token": "test",
|
|
"start_date": "2026-04-01",
|
|
"articles_per_day": "2",
|
|
})
|
|
assert resp.status_code == 302
|
|
|
|
articles = await fetch_all("SELECT * FROM articles")
|
|
assert len(articles) == 3
|
|
|
|
# Cleanup
|
|
for a in articles:
|
|
p = BUILD_DIR / f"{a['slug']}.html"
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Admin dashboard quick links
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestAdminDashboardLinks:
|
|
async def test_dashboard_has_content_links(self, admin_client):
|
|
resp = await admin_client.get("/admin/")
|
|
html = (await resp.data).decode()
|
|
assert "/admin/templates" in html
|
|
assert "/admin/scenarios" in html
|
|
assert "/admin/articles" in html
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Footer
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestFooterMarkets:
|
|
async def test_footer_has_markets_link(self, client):
|
|
resp = await client.get("/en/")
|
|
html = (await resp.data).decode()
|
|
assert "/markets" in html
|