"""warm_cities.warm_content -- the off-request pre-warm that rebuilds the SEO content derived-store (api/content_routes.py's content-city / content-month / content-records rows) so the first request after each daily archive advance hits a warm cache instead of a cold 45-year recompute. The invariants under test mirror the ones content_routes.py relies on: * it populates all three kinds under the exact (kind, key, token) the routes use; * a city with no cached archive is skipped and never triggers an upstream fetch (warming must not spend quota); * it is idempotent -- a second run with nothing changed writes zero payloads. """ import warm_cities from api import content_payloads from api import payloads as cache_ids from data import cities as cities_mod from data import climate from data import grid from data import store # A fixed archive last-date -> a stable content token across a test's runs, so the # idempotency check exercises the "already fresh for this token" fast path. _MAX_DATE = "2026-07-20" def _two_cities(): """Two real curated cities (so the payload builders get the fields they read), the first with a cached archive, the second without.""" everyone = cities_mod.all_cities() return everyone[0], everyone[1] def _wire(monkeypatch, city_with_history, history): """Point warm_content's cache-only reads at fixtures and make any upstream fetch a hard failure, so a test proves warming never reaches the network.""" have_cell = grid.snap(city_with_history["lat"], city_with_history["lon"])["id"] monkeypatch.setattr(climate, "history_max_date", lambda cell_id: _MAX_DATE) def fake_cached_history(cell): return history if cell["id"] == have_cell else None monkeypatch.setattr(climate, "load_cached_history", fake_cached_history) monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None) def _no_upstream(*a, **k): # pragma: no cover - only fires on a regression raise AssertionError("warm_content must not fetch upstream") monkeypatch.setattr(climate, "get_history", _no_upstream) monkeypatch.setattr(climate, "get_recent_forecast", _no_upstream) def test_warms_three_kinds_with_matching_key_and_token(tmp_store, monkeypatch, history): warm, cold = _two_cities() _wire(monkeypatch, warm, history) monkeypatch.setattr(cities_mod, "all_cities", lambda: [warm, cold]) result = warm_cities.warm_content(origin="https://thermograph.org") origin = "https://thermograph.org" cell_id = grid.snap(warm["lat"], warm["lon"])["id"] token = cache_ids.content_token(cell_id) slug = warm["slug"] # content-city + content-records under {slug}:{origin}, 12 months under {slug}:{month} assert store.get_json("content-city", cell_id, f"{slug}:{origin}", token) is not None assert store.get_json("content-records", cell_id, f"{slug}:{origin}", token) is not None for month in content_payloads.MONTH_INDEX: assert store.get_json("content-month", cell_id, f"{slug}:{month}", token) is not None # 1 city + 1 records + 12 months = 14 payloads for the one city with history. assert result["built"] == 14 assert result["warmed"] == 1 def test_skips_city_without_cached_history_and_never_fetches(tmp_store, monkeypatch, history): warm, cold = _two_cities() _wire(monkeypatch, warm, history) monkeypatch.setattr(cities_mod, "all_cities", lambda: [warm, cold]) result = warm_cities.warm_content(origin="https://thermograph.org") # The archive-less city produced no rows (and _no_upstream never fired, or the # call above would have raised). cold_cell = grid.snap(cold["lat"], cold["lon"])["id"] token = cache_ids.content_token(cold_cell) assert store.get_json("content-city", cold_cell, f"{cold['slug']}:https://thermograph.org", token) is None assert result["empty"] == 1 def test_idempotent_second_run_writes_nothing(tmp_store, monkeypatch, history): warm, cold = _two_cities() _wire(monkeypatch, warm, history) monkeypatch.setattr(cities_mod, "all_cities", lambda: [warm, cold]) # Spy on put_payload while still persisting, so run 2 sees run 1's rows. calls = [] real_put = store.put_payload def spy(kind, cell_id, key, token, payload, cache=True): calls.append((kind, cell_id, key, token)) return real_put(kind, cell_id, key, token, payload, cache) monkeypatch.setattr(store, "put_payload", spy) first = warm_cities.warm_content(origin="https://thermograph.org") assert len(calls) == 14 # one full city warmed assert first["built"] == 14 calls.clear() second = warm_cities.warm_content(origin="https://thermograph.org") assert calls == [] # nothing rebuilt: every kind still fresh assert second["built"] == 0 assert second["warmed"] == 0 assert second["skipped"] == 1 # the warm city skipped cheaply assert second["empty"] == 1 # the archive-less city still empty def test_limit_caps_cities_built_per_call(tmp_store, monkeypatch, history): # Two cities, both with a cached archive; limit=1 should build exactly one. a, b = _two_cities() have = {grid.snap(a["lat"], a["lon"])["id"], grid.snap(b["lat"], b["lon"])["id"]} monkeypatch.setattr(climate, "history_max_date", lambda cell_id: _MAX_DATE) monkeypatch.setattr(climate, "load_cached_history", lambda cell: history if cell["id"] in have else None) monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None) monkeypatch.setattr(cities_mod, "all_cities", lambda: [a, b]) result = warm_cities.warm_content(limit=1, origin="https://thermograph.org") assert result["warmed"] == 1 assert result["built"] == 14