Google Search Console flags city pages with 'Missing field item (in itemListElement)': the country crumb has no page of its own, so its ListItem was emitted without the required item URL. Google only allows the final ListItem to omit item, so drop unlinked intermediate crumbs from the structured data (the visible breadcrumb is unchanged) and renumber positions. Shared helper now builds the BreadcrumbList for both the city and records pages, with a regression test parsing the emitted JSON-LD. Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
270 lines
11 KiB
Python
270 lines
11 KiB
Python
"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and
|
||
that the server-rendered pages contain the stats in the HTML (with the weather
|
||
layer faked, like test_api.py)."""
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
import app as appmod
|
||
import cities
|
||
import climate
|
||
|
||
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
||
B = "/thermograph"
|
||
SLUG = "london-england-gb" # present in cities.json
|
||
|
||
|
||
@pytest.fixture
|
||
def client(monkeypatch, history, recent):
|
||
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
||
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
|
||
monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True}))
|
||
return TestClient(appmod.app)
|
||
|
||
|
||
def test_city_set_slugs_unique_and_lookup():
|
||
slugs = cities.all_slugs()
|
||
assert len(slugs) == len(set(slugs)) >= 100
|
||
assert cities.get(SLUG) is not None
|
||
assert cities.get("does-not-exist") is None
|
||
|
||
|
||
def test_robots_txt(client):
|
||
r = client.get(f"{B}/robots.txt")
|
||
assert r.status_code == 200
|
||
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
|
||
assert "Disallow: /thermograph/api/" in r.text
|
||
|
||
|
||
def test_sitemap_lists_city_urls(client):
|
||
r = client.get(f"{B}/sitemap.xml")
|
||
assert r.status_code == 200
|
||
assert "<urlset" in r.text
|
||
assert f"/climate/{SLUG}</loc>" in r.text
|
||
assert f"/climate/{SLUG}/july</loc>" in r.text
|
||
assert f"/climate/{SLUG}/records</loc>" in r.text
|
||
|
||
|
||
def test_city_page_renders_stats_in_html(client):
|
||
r = client.get(f"{B}/climate/{SLUG}")
|
||
assert r.status_code == 200
|
||
b = r.text
|
||
assert "London" in b and "climate" in b.lower()
|
||
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
|
||
assert '"@type":"Dataset"' in b
|
||
assert "average temperatures by month" in b.lower()
|
||
# a real number rendered (°F appears in the normals/today text)
|
||
assert "°F" in b
|
||
|
||
|
||
def _jsonld_breadcrumbs(html: str) -> list[dict]:
|
||
import json, re
|
||
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
|
||
assert m, "no JSON-LD block on page"
|
||
graph = json.loads(m.group(1))["@graph"]
|
||
return [n for n in graph if n["@type"] == "BreadcrumbList"]
|
||
|
||
|
||
@pytest.mark.parametrize("path", [f"/climate/{SLUG}", f"/climate/{SLUG}/records"])
|
||
def test_breadcrumb_jsonld_items_valid_for_google(client, path):
|
||
# Google: every ListItem except the last must carry an ``item`` URL, so
|
||
# unlinked intermediate crumbs (the country) must not appear in JSON-LD.
|
||
(bc,) = _jsonld_breadcrumbs(client.get(B + path).text)
|
||
els = bc["itemListElement"]
|
||
assert [e["position"] for e in els] == list(range(1, len(els) + 1))
|
||
for e in els[:-1]:
|
||
assert e["item"].startswith("http"), f"crumb {e['name']!r} missing item"
|
||
|
||
|
||
def test_city_404(client):
|
||
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
|
||
|
||
|
||
def test_curated_events_have_valid_slugs():
|
||
import city_events
|
||
slugs = set(cities.all_slugs())
|
||
assert len(city_events.EVENTS) >= 20
|
||
assert all(s in slugs for s in city_events.EVENTS), \
|
||
[s for s in city_events.EVENTS if s not in slugs]
|
||
|
||
|
||
def test_curated_event_renders(client):
|
||
b = client.get(f"{B}/climate/new-orleans-louisiana-us").text
|
||
assert "Notable weather in New Orleans" in b
|
||
assert "Hurricane Katrina" in b
|
||
|
||
|
||
def test_uncurated_city_has_no_event_section(client):
|
||
b = client.get(f"{B}/climate/shanghai-cn").text
|
||
assert "city-event" not in b # falls back to the flavor blurb
|
||
|
||
|
||
def test_city_travel_cta_prefills_compare(client):
|
||
b = client.get(f"{B}/climate/{SLUG}").text
|
||
assert "Thinking of visiting" in b
|
||
assert "/compare#loc=" in b # the city is pre-loaded on the compare page
|
||
|
||
|
||
def test_city_blurb_renders_with_attribution(client, monkeypatch):
|
||
monkeypatch.setattr(cities, "flavor",
|
||
lambda slug: {"extract": "Testville is a lovely place to test.",
|
||
"url": "https://en.wikipedia.org/wiki/Testville", "title": "Testville"})
|
||
b = client.get(f"{B}/climate/{SLUG}").text
|
||
assert "Testville is a lovely place to test." in b
|
||
assert "via Wikipedia" in b # CC BY-SA attribution link
|
||
|
||
|
||
def test_month_and_records(client):
|
||
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
|
||
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
|
||
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
|
||
|
||
|
||
def test_month_records_cover_every_metric_high_and_low(client):
|
||
# The month page's records show a record high AND low for each metric present, not
|
||
# just warmest/coldest temperature — one card per metric with both extremes. (The
|
||
# test fixture carries tmax/tmin/precip; real cities add feels/humidity/wind/gust.)
|
||
b = client.get(f"{B}/climate/{SLUG}/july").text
|
||
assert "July records" in b
|
||
for label in ("High", "Low", "Precip"):
|
||
assert f'class="rc-metric">{label}<' in b
|
||
# Every card carries a labelled high and low row (temp metrics use Highest/Lowest).
|
||
n = b.count('rc-row rc-high')
|
||
assert n >= 3 and b.count('rc-row rc-low') == n
|
||
assert "Highest" in b and "Lowest" in b
|
||
# Precip's extremes are the wettest/driest whole month by total accumulation.
|
||
assert "Wettest" in b and "Driest" in b
|
||
|
||
|
||
def test_records_page_has_monthly_and_seasonal(client):
|
||
b = client.get(f"{B}/climate/{SLUG}/records").text
|
||
# Monthly records: all 12 months, each linking to its month page.
|
||
assert "Records by month" in b
|
||
for month in ("January", "July", "December"):
|
||
assert month in b
|
||
assert f"/climate/{SLUG}/january" in b
|
||
# Seasonal records: labelled for London's Northern Hemisphere, with month spans.
|
||
assert "Records by season" in b
|
||
assert "Northern Hemisphere" in b
|
||
assert 'Winter <span class="season-span">(Dec–Feb)</span>' in b
|
||
assert 'Summer <span class="season-span">(Jun–Aug)</span>' in b
|
||
# All-time table still present, plus structured data and real °F values.
|
||
assert "All-time records" in b
|
||
assert '"@type":"Dataset"' in b and "°F" in b
|
||
# Title/description advertise the new coverage.
|
||
assert "monthly" in b.lower() and "seasonal" in b.lower()
|
||
|
||
|
||
def test_records_seasons_flip_in_southern_hemisphere(client):
|
||
# São Paulo is south of the equator, so Dec–Feb is summer, not winter.
|
||
b = client.get(f"{B}/climate/sao-paulo-br/records").text
|
||
assert "Southern Hemisphere" in b
|
||
assert 'Summer <span class="season-span">(Dec–Feb)</span>' in b
|
||
assert 'Winter <span class="season-span">(Jun–Aug)</span>' in b
|
||
|
||
|
||
def test_climate_pages_are_colour_coded(client):
|
||
# Temperatures wear the diverging cold→hot palette (a heat map, not a plain grid),
|
||
# and the city page carries the monthly temperature-range strip.
|
||
city = client.get(f"{B}/climate/{SLUG}").text
|
||
assert "t-cell t-" in city # tinted temperature cells in the normals table
|
||
assert "range-fill" in city and "--c1:var(--" in city # the monthly range strip's gradient bars
|
||
recs = client.get(f"{B}/climate/{SLUG}/records").text
|
||
assert "t-inline t-" in recs # tinted record chips
|
||
month = client.get(f"{B}/climate/{SLUG}/july").text
|
||
assert "t-inline t-" in month # tinted hero numbers + records
|
||
|
||
|
||
def test_records_show_both_extremes_per_metric(client):
|
||
# Each metric column shows BOTH its record warmest (▲) and coldest (▼) — so the
|
||
# daytime high carries its record-low high, and the overnight low its record-high low.
|
||
b = client.get(f"{B}/climate/{SLUG}/records").text
|
||
assert "Daytime high" in b and "Overnight low" in b
|
||
assert "▲" in b and "▼" in b
|
||
# 12 months × 2 metric columns × 2 extremes = 48 chips just in the monthly table.
|
||
assert b.count('class="rec-ext"') >= 48
|
||
|
||
|
||
def test_hub_glossary_about(client):
|
||
assert client.get(f"{B}/climate").status_code == 200
|
||
assert client.get(f"{B}/glossary").status_code == 200
|
||
assert client.get(f"{B}/glossary/percentile").status_code == 200
|
||
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
|
||
assert client.get(f"{B}/about").status_code == 200
|
||
|
||
|
||
def test_climate_pages_carry_convertible_temps(client):
|
||
# Every temperature is a client-convertible span (default °F text so crawlers
|
||
# and no-JS visitors still see a real value); the old inline "(NN°C)" dual form
|
||
# is gone (the toggle now supplies Celsius).
|
||
import re
|
||
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
|
||
b = client.get(path).text
|
||
assert 'class="temp" data-temp-f=' in b
|
||
assert "°F" in b
|
||
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings
|
||
|
||
|
||
def test_seo_pages_load_unit_and_account_scripts(client):
|
||
# Header parity with the interactive pages: the °F/°C toggle, account/bell, and
|
||
# the temperature converter are all loaded.
|
||
b = client.get(f"{B}/climate/{SLUG}").text
|
||
for src in (f"{B}/units.js", f"{B}/account.js", f"{B}/climate.js"):
|
||
assert f'src="{src}"' in b
|
||
|
||
|
||
def test_indexnow_key_file_served(client):
|
||
import indexnow
|
||
k = indexnow.key()
|
||
r = client.get(f"{B}/{k}.txt")
|
||
assert r.status_code == 200
|
||
assert r.text.strip() == k
|
||
assert r.headers["content-type"].startswith("text/plain")
|
||
|
||
|
||
def test_sitemap_lastmod_is_stable(client):
|
||
import datetime
|
||
import re
|
||
body = client.get(f"{B}/sitemap.xml").text
|
||
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
|
||
assert len(mods) == 1 # one build date, not a per-request churn
|
||
datetime.date.fromisoformat(next(iter(mods))) # a valid ISO date
|
||
|
||
|
||
def test_indexnow_submit_all_builds_payload(monkeypatch):
|
||
import indexnow
|
||
|
||
calls = []
|
||
|
||
class _Resp:
|
||
status_code = 200
|
||
text = "ok"
|
||
|
||
monkeypatch.setattr(indexnow.httpx, "post",
|
||
lambda url, json=None, **kw: (calls.append((url, json)), _Resp())[1])
|
||
res = indexnow.submit_all("https://thermograph.org")
|
||
assert res["submitted"] == res["total"] > 100
|
||
first = calls[0][1]
|
||
assert first["host"] == "thermograph.org"
|
||
assert first["keyLocation"] == f"https://thermograph.org/{indexnow.key()}.txt"
|
||
assert first["urlList"][0] == "https://thermograph.org/"
|
||
assert all(len(c[1]["urlList"]) <= 10000 for c in calls) # batched under the IndexNow cap
|
||
|
||
|
||
def test_indexnow_if_changed_state(monkeypatch, tmp_path):
|
||
import indexnow
|
||
monkeypatch.setattr(indexnow, "_STATE_PATH", str(tmp_path / "state.txt"))
|
||
sig = indexnow.url_signature()
|
||
assert len(sig) == 40 and sig == indexnow.url_signature() # stable sha1 of the URL set
|
||
assert indexnow._read_state() == "" # first deploy → would submit
|
||
indexnow._write_state(sig)
|
||
assert indexnow._read_state() == sig # unchanged → deploy would skip
|
||
|
||
|
||
def test_search_verification_meta(client, monkeypatch):
|
||
monkeypatch.setenv("THERMOGRAPH_GOOGLE_VERIFY", "gtok123")
|
||
monkeypatch.setenv("THERMOGRAPH_BING_VERIFY", "btok456")
|
||
seo = client.get(f"{B}/climate/{SLUG}").text # Jinja SEO page
|
||
home = client.get(f"{B}/").text # static homepage via _page()
|
||
for b in (seo, home):
|
||
assert '<meta name="google-site-verification" content="gtok123">' in b
|
||
assert '<meta name="msvalidate.01" content="btok456">' in b
|