Add backend test suite; gate direct pushes; serialize LAN deploys (#41)

- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
  covering grid snapping/round-trips, grading percentiles/bands/windows/
  dry streaks, the places index (norm, one-edit matchers, search,
  corrections), the derived store (token validity, cache=False, degraded
  mode), and route-level API tests over a faked climate layer — routing,
  validation, ETag/304 revalidation, store replay, the /cell bundle, and
  the v1/v2 aliases. The API tests would have caught the /place
  AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
  deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
  sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
  and an API health probe (page-only curl can't catch route wiring
  faults); deploy-dev.yml now runs the same build gate before deploying
  direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
  both workflows (previously per-PR groups could interleave two deploys
  to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
  externals mean a failure there is a genuine server bug.
This commit is contained in:
Emi Griffith 2026-07-11 12:37:49 -07:00 committed by GitHub
parent 686ac7afaf
commit 4ac5323375
7 changed files with 611 additions and 0 deletions

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
# Test/dev-only dependencies, layered on the runtime set.
-r requirements.txt
pytest==8.4.1

94
tests/conftest.py Normal file
View file

@ -0,0 +1,94 @@
"""Shared test setup: import path, offline guards, and synthetic weather data.
Everything here keeps the suite hermetic no Open-Meteo, no Nominatim, no
GeoNames download, and no writes into the repo's data/ or logs/ folders.
"""
import os
import sys
import tempfile
import threading
import numpy as np
import pandas as pd
import pytest
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BACKEND)
import places # noqa: E402
# app.py calls places.start_loading() at import; pretend it already ran so no
# background GeoNames download starts. Tests build their own tiny index.
places._load_started = True
_TMP = tempfile.mkdtemp(prefix="thermograph-tests-")
import store # noqa: E402
# Keep derived-store writes out of the repo's data/ folder. Individual store
# tests re-point this per test; everything else shares one throwaway DB.
store.DB_PATH = os.path.join(_TMP, "store.sqlite")
import audit # noqa: E402
audit.AUDIT_DIR = os.path.join(_TMP, "logs", "audit")
audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors")
def make_history(years: int = 20, end: str | None = None, seed: int = 7) -> pd.DataFrame:
"""A plausible daily record (tmax/tmin/precip + doy), matching the columns
and dtypes climate.get_history returns for an older cache."""
end_ts = pd.Timestamp(end) if end else pd.Timestamp.today().normalize() - pd.Timedelta(days=6)
dates = pd.date_range(end_ts - pd.DateOffset(years=years), end_ts, freq="D")
rng = np.random.default_rng(seed)
doy = dates.dayofyear.to_numpy()
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
tmax = seasonal + rng.normal(0, 8, len(dates))
tmin = tmax - 15 + rng.normal(0, 3, len(dates))
precip = np.where(rng.random(len(dates)) < 0.3, rng.gamma(1.5, 0.2, len(dates)), 0.0)
df = pd.DataFrame({
"date": dates,
"tmax": np.round(tmax, 1),
"tmin": np.round(tmin, 1),
"precip": np.round(precip, 2),
})
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df
def make_recent(history: pd.DataFrame, future_days: int = 7, seed: int = 11) -> pd.DataFrame:
"""A recent+forecast bundle: from a couple of weeks before the archive's end
through `future_days` past today the shape climate.get_recent_forecast returns."""
today = pd.Timestamp.today().normalize()
start = pd.Timestamp(history["date"].max()) - pd.Timedelta(days=14)
dates = pd.date_range(start, today + pd.Timedelta(days=future_days), freq="D")
rng = np.random.default_rng(seed)
doy = dates.dayofyear.to_numpy()
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
tmax = seasonal + rng.normal(0, 8, len(dates))
df = pd.DataFrame({
"date": dates,
"tmax": np.round(tmax, 1),
"tmin": np.round(tmax - 15, 1),
"precip": np.where(rng.random(len(dates)) < 0.3, 0.15, 0.0),
})
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df
@pytest.fixture(scope="session")
def history():
return make_history()
@pytest.fixture(scope="session")
def recent(history):
return make_recent(history)
@pytest.fixture
def tmp_store(tmp_path, monkeypatch):
"""store.py against a fresh database file, isolated per test."""
monkeypatch.setattr(store, "DB_PATH", str(tmp_path / "store.sqlite"))
monkeypatch.setattr(store, "_local", threading.local())
return store

138
tests/test_api.py Normal file
View file

@ -0,0 +1,138 @@
"""Route-level tests over the FastAPI app with the weather/geocode layer faked —
they exercise the real routing, validation, derived-store and ETag plumbing, and
would catch wiring regressions (e.g. a handler calling a deleted helper)."""
import pandas as pd
import pytest
from fastapi.testclient import TestClient
import app as appmod
import climate
@pytest.fixture
def client(monkeypatch, history, recent):
monkeypatch.setattr(climate, "get_history",
lambda cell: (history.copy(), {"cached": True, "cache_age_days": 3}))
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.copy())
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.copy())
monkeypatch.setattr(climate, "recent_stamp", lambda cell_id: "rs-test")
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
return TestClient(appmod.app)
Q = {"lat": 47.6062, "lon": -122.3321}
def test_place_serves_any_point_worldwide(client):
for q in (Q, {"lat": 48.8566, "lon": 2.3522}, {"lat": -33.8688, "lon": 151.2093}):
r = client.get("/thermograph/api/v2/place", params=q)
assert r.status_code == 200
assert r.json()["place"] == "Testville, Washington"
assert set(r.json()["cell"]) == {"center_lat", "center_lon"}
def test_grade_shape_and_conditional_revalidation(client, history):
r = client.get("/thermograph/api/v2/grade", params=Q)
assert r.status_code == 200
body = r.json()
assert body["place"] == "Testville, Washington"
assert body["climatology"]["tmax"] is not None
days = [d["date"] for d in body["recent"]]
assert days == sorted(days, reverse=True) # newest first
assert body["recent"][0]["tmax"]["grade"]
etag = r.headers["etag"]
r304 = client.get("/thermograph/api/v2/grade", params=Q,
headers={"If-None-Match": etag})
assert r304.status_code == 304 and r304.headers["etag"] == etag
# Without the validator the derived store replays the exact same bytes.
r2 = client.get("/thermograph/api/v2/grade", params=Q)
assert r2.status_code == 200 and r2.content == r.content
def test_grade_is_aliased_across_api_versions(client):
for prefix in ("api", "api/v1", "api/v2"):
assert client.get(f"/thermograph/{prefix}/grade", params=Q).status_code == 200
def test_query_validation_rejects_out_of_range(client):
assert client.get("/thermograph/api/v2/grade",
params={"lat": 999, "lon": 0}).status_code == 422
assert client.get("/thermograph/api/v2/grade",
params={**Q, "days": 0}).status_code == 422
def test_day_detail_and_ladders(client, history):
r = client.get("/thermograph/api/v2/day", params=Q)
assert r.status_code == 200
body = r.json()
assert body["latest"] == pd.Timestamp(history["date"].max()).date().isoformat()
tmax = body["detail"]["metrics"]["tmax"]
assert tmax["ladder"]["tiers"][0]["c"] == "rec-hot"
assert tmax["obs"]["grade"]
def test_day_maps_rate_limit_to_503(client, monkeypatch):
def rate_limited(cell):
raise RuntimeError("Open-Meteo request failed (429): rate-limited")
monkeypatch.setattr(climate, "get_history", rate_limited)
r = client.get("/thermograph/api/v2/day", params=Q)
assert r.status_code == 503
assert "rate-limited" in r.json()["detail"]
def test_calendar_compact_range(client, history):
r = client.get("/thermograph/api/v2/calendar", params={**Q, "months": 2})
assert r.status_code == 200
body = r.json()
assert body["range"]["end"] == pd.Timestamp(history["date"].max()).date().isoformat()
day = body["days"][0]
assert {"date", "dsr", "tmax", "tmin", "precip"} <= set(day)
assert set(day["tmax"]) == {"v", "pct", "c", "g"}
def test_forecast_grades_future_days(client):
r = client.get("/thermograph/api/v2/forecast", params=Q)
assert r.status_code == 200
body = r.json()
assert body["forecast"] is True
days = [d["date"] for d in body["recent"]]
assert days and days == sorted(days, reverse=True) # furthest-out first
assert min(days) > body["target_date"] # strictly future
def test_cell_bundle_matches_per_view_payloads(client):
r = client.get("/thermograph/api/v2/cell", params=Q)
assert r.status_code == 200
slices = r.json()["slices"]
assert set(slices) == {"calendar", "grade", "forecast", "day"}
for s in slices.values():
assert s["etag"] and s["data"]
# The grade slice must be byte-for-byte what /grade serves (same store row).
grade = client.get("/thermograph/api/v2/grade", params=Q)
assert grade.json() == slices["grade"]["data"]
assert grade.headers["etag"] == slices["grade"]["etag"]
r304 = client.get("/thermograph/api/v2/cell", params=Q,
headers={"If-None-Match": r.headers["etag"]})
assert r304.status_code == 304
def test_suggest_falls_back_to_upstream_geocoder(client, monkeypatch):
upstream = [{"name": "Seattle", "admin1": "Washington", "country": "United States",
"country_code": "US", "lat": 47.6, "lon": -122.33, "population": 737015}]
monkeypatch.setattr(climate, "geocode", lambda q, count=5: list(upstream))
r = client.get("/thermograph/api/v2/suggest", params={"q": "seattle-fallback-probe"})
assert r.status_code == 200
body = r.json()
assert body["results"][0]["name"] == "Seattle"
assert body["corrected"] is None
def test_pages_serve_with_origin_filled_in(client):
r = client.get("/thermograph/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text
assert client.head("/thermograph/calendar").status_code == 200

138
tests/test_grading.py Normal file
View file

@ -0,0 +1,138 @@
import numpy as np
import pandas as pd
import pytest
import grading
# ---- empirical percentile ----------------------------------------------------
def test_percentile_mid_rank_handles_ties():
samples = np.array([1.0, 2.0, 2.0, 3.0])
# less=1, equal=2 -> (1 + 0.5*2) / 4 = 50%
assert grading.empirical_percentile(samples, 2.0) == 50.0
def test_percentile_extremes_and_empties():
samples = np.array([1.0, 2.0, 3.0])
assert grading.empirical_percentile(samples, 0.0) == 0.0
assert grading.empirical_percentile(samples, 4.0) == 100.0
assert grading.empirical_percentile(np.array([]), 1.0) is None
assert grading.empirical_percentile(samples, None) is None
assert grading.empirical_percentile(samples, float("nan")) is None
# ---- tier bands ---------------------------------------------------------------
@pytest.mark.parametrize("pct,label,css", [
(99.5, "Near Record", "rec-hot"), # top tier is strict: > 99 only
(99.0, "Very High", "very-hot"), # p99 exactly is NOT near-record
(90.0, "Very High", "very-hot"),
(75.0, "High", "hot"),
(60.0, "Above Normal", "warm"),
(59.9, "Normal", "normal"),
(40.0, "Normal", "normal"),
(39.9, "Below Normal", "cool"),
(10.0, "Low", "cold"),
(1.0, "Very Low", "very-cold"),
(0.5, "Near Record", "rec-cold"), # strictly below the 1st percentile
])
def test_temp_band_boundaries(pct, label, css):
assert grading._band(pct, grading.TEMP_BANDS) == (label, css)
def test_ladders_stay_aligned_with_bands():
"""The detail-view ladders re-encode the band tables by hand; catch drift."""
for bands, ladder in [(grading.TEMP_BANDS, grading._TEMP_LADDER),
(grading.RAIN_BANDS, grading._RAIN_LADDER)]:
assert len(bands) == len(ladder)
for (_, label, css), (lcss, llabel, *_rest) in zip(bands, ladder):
assert (label, css) == (llabel, lcss)
# ---- seasonal window ----------------------------------------------------------
def test_window_mask_wraps_across_year_end():
doys = np.array([1, 180, 360, 366])
mask = grading.window_mask(doys, target_doy=1, half=7)
assert mask.tolist() == [True, False, True, True]
# ---- precip grading -----------------------------------------------------------
def test_dry_day_gets_dry_class_without_percentile():
g = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.005)
assert g["class"] == "dry" and g["percentile"] is None and g["grade"] == "Dry"
def test_rain_percentile_ranks_among_rain_days_only():
# 7 dry days + rain days [0.1, 0.2, 0.4]; 0.2 ranks among the 3 rain days:
# less=1, equal=1 -> (1 + 0.5) / 3 = 50% -> Moderate, unaffected by the dry mass.
samples = np.array([0.0] * 7 + [0.1, 0.2, 0.4])
g = grading._grade_precip(samples, 0.2)
assert g["percentile"] == 50.0
assert g["grade"] == "Moderate"
def test_rain_with_no_historical_rain_days_is_extreme():
g = grading._grade_precip(np.zeros(10), 0.3)
assert g["percentile"] == 100.0 and g["class"] == "wet-9"
# ---- dry streaks ---------------------------------------------------------------
def test_dry_streaks_walk():
dates = pd.date_range("2024-01-01", periods=5, freq="D")
precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
out = grading.dry_streaks(dates.values, precips)
assert list(out.values()) == [0, 1, 2, 0, 1] # NaN counts as dry
# ---- range + day grading over a synthetic record --------------------------------
def test_grade_range_compact_shape(history):
end = pd.Timestamp(history["date"].max())
start = end - pd.Timedelta(days=30)
days = grading.grade_range(history, start, end)
assert len(days) == 31
first = days[0]
assert set(first) == {"date", "dsr", *grading.TEMP_METRICS, "precip"}
assert first["dsr"] >= 0
g = first["tmax"]
assert set(g) == {"v", "pct", "c", "g"}
for rec in days: # dry days carry no rain percentile, wet days always do
if rec["precip"]["c"] == "dry":
assert rec["precip"]["pct"] is None
else:
assert rec["precip"]["pct"] is not None
def test_grade_day_normals_and_departure(history):
target = pd.Timestamp(history["date"].max())
row = history[history["date"] == target].iloc[0]
result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
"precip": row["precip"]})
assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60",
"p75", "p90", "p99"}
expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50))
assert result["departure"] == round(expected, 1)
def test_day_detail_with_and_without_observation(history):
target = pd.Timestamp(history["date"].max())
detail = grading.day_detail(history, target, {"tmax": 60.0, "precip": 0.0})
assert detail["metrics"]["tmax"]["obs"]["value"] == 60.0
assert detail["metrics"]["tmax"]["ladder"]["tiers"][0]["c"] == "rec-hot"
assert detail["metrics"]["precip"]["ladder"]["tiers"][-1]["c"] == "dry"
bare = grading.day_detail(history, target, None)
assert bare["metrics"]["tmax"]["obs"] is None
assert bare["metrics"]["tmax"]["ladder"] is not None
def test_climatology_summary(history):
climo = grading.climatology(history, 180)
# ±7-day window over ~20 years -> ~15 samples per year.
assert climo["n_samples"] >= 15 * 19
assert climo["tmax"]["p40"] <= climo["tmax"]["p50"] <= climo["tmax"]["p60"]
assert climo["feels"] is None # column absent from this record

61
tests/test_grid.py Normal file
View file

@ -0,0 +1,61 @@
import math
import pytest
import grid
def test_snap_is_deterministic_and_contains_point():
a = grid.snap(47.6062, -122.3321)
b = grid.snap(47.6062, -122.3321)
assert a == b
assert a["bounds"]["south"] <= 47.6062 <= a["bounds"]["north"]
def test_snap_center_lands_in_same_cell():
cell = grid.snap(40.7128, -74.0060)
again = grid.snap(cell["center_lat"], cell["center_lon"])
assert again["id"] == cell["id"]
def test_from_id_round_trip():
cell = grid.snap(35.6762, 139.6503) # Tokyo — worldwide coverage
rebuilt = grid.from_id(cell["id"])
assert rebuilt == cell
def test_from_id_rejects_malformed():
with pytest.raises(ValueError):
grid.from_id("not-a-cell")
def test_longitude_wraps_across_antimeridian():
assert grid.snap(10.0, 200.0)["id"] == grid.snap(10.0, -160.0)["id"]
assert grid.snap(10.0, 540.0)["id"] == grid.snap(10.0, 180.0)["id"]
def test_latitude_clamped_at_poles():
cell = grid.snap(95.0, 0.0)
assert cell == grid.snap(90.0, 0.0)
assert -90.0 <= cell["center_lat"] <= 90.0
assert -180.0 <= cell["center_lon"] <= 180.0
def test_reported_center_is_a_valid_coordinate_everywhere():
for lat, lon in [(89.99, 179.99), (-89.99, -179.99), (0.0, 0.0), (66.5, -179.5)]:
cell = grid.snap(lat, lon)
assert -90.0 <= cell["center_lat"] <= 90.0
assert -180.0 <= cell["center_lon"] <= 180.0
def test_cells_stay_roughly_square():
# The cos(lat) scaling should keep the area near ~4 sq mi away from the poles.
for lat in (0.0, 30.0, 45.0, 60.0):
cell = grid.snap(lat, 10.0)
assert cell["area_sq_mi"] == pytest.approx(4.0, rel=0.15)
def test_lon_step_clamps_near_poles():
# cos(89°) ~ 0.017 would blow the step up; the 0.05 clamp caps it.
assert grid._lon_step(89.0) == pytest.approx(grid.LAT_STEP / 0.05)
assert not math.isinf(grid._lon_step(90.0))

112
tests/test_places.py Normal file
View file

@ -0,0 +1,112 @@
import pytest
import places
def _entry(name, admin1, country, cc, lat, lon, pop):
return (places._norm(name), name, admin1, country, cc, lat, lon, pop)
@pytest.fixture
def index(monkeypatch):
entries = [
_entry("Seattle", "Washington", "United States", "US", 47.60, -122.33, 750_000),
_entry("West Seattle", "Washington", "United States", "US", 47.57, -122.39, 30_000),
_entry("SeaTac", "Washington", "United States", "US", 47.44, -122.29, 31_000),
_entry("Portland", "Oregon", "United States", "US", 45.52, -122.68, 650_000),
_entry("Paris", None, "France", "FR", 48.86, 2.35, 2_100_000),
# The index normalizes GeoNames' ASCII name; the display name keeps accents.
(places._norm("Coeur d'Alene"), "Cœur d'Alene", "Idaho", "United States",
"US", 47.68, -116.78, 56_000),
]
monkeypatch.setattr(places, "_data", places._build(entries))
return places
# ---- normalization -------------------------------------------------------------
def test_norm_strips_accents_and_punctuation():
assert places._norm("Coeur d'Alene") == "coeur dalene"
assert places._norm("Winston-Salem") == "winston salem"
assert places._norm(" MÜNCHEN ") == "munchen"
assert places._norm("São Paulo") == "sao paulo"
# ---- one-edit matchers ----------------------------------------------------------
@pytest.mark.parametrize("q,w,hit", [
("chic", "chicago", True), # plain prefix
("chicgo", "chicago", True), # missing letter
("chhicago", "chicago", True), # extra letter
("chixago", "chicago", True), # substituted letter
("cihcago", "chicago", True), # adjacent swap
("chizzgo", "chicago", False), # two edits
("boston", "chicago", False),
])
def test_prefix_edit1(q, w, hit):
assert places._prefix_edit1(q, w) is hit
@pytest.mark.parametrize("a,b,hit", [
("west", "west", True),
("pest", "west", True), # substitution
("wst", "west", True), # deletion
("wesst", "west", True), # insertion
("ewst", "west", True), # transposition
("east", "west", False), # two substitutions
("we", "west", False), # length differs by 2
])
def test_within1(a, b, hit):
assert places._within1(a, b) is hit
# ---- search ----------------------------------------------------------------------
def test_search_returns_none_until_loaded(monkeypatch):
monkeypatch.setattr(places, "_data", None)
assert places.search("seattle") is None
def test_search_prefix_matches_by_population(index):
out = places.search("sea", 5)
assert [r["name"] for r in out] == ["Seattle", "SeaTac"]
assert all(r["match"] == "prefix" for r in out)
def test_search_tolerates_one_typo(index):
out = places.search("seatle", 5)
assert out[0]["name"] == "Seattle"
assert out[0]["match"] == "fuzzy"
def test_search_no_fuzzy_for_short_queries(index):
# 3 letters: "one letter off" would match half the index — prefix only.
assert all(r["match"] == "prefix" for r in places.search("sea", 5))
assert places.search("xea", 5) == []
def test_search_normalizes_the_query(index):
out = places.search("coeur dalene", 5)
assert out and out[0]["name"] == "Cœur d'Alene"
def test_search_result_shape(index):
r = places.search("paris", 1)[0]
assert r == {"name": "Paris", "admin1": None, "country": "France",
"country_code": "FR", "lat": 48.86, "lon": 2.35,
"population": 2_100_000, "match": "prefix"}
# ---- corrections ------------------------------------------------------------------
def test_corrections_respell_a_typod_token(index):
assert "west seattle" in places.corrections("pest seattle")
def test_corrections_empty_until_loaded(monkeypatch):
monkeypatch.setattr(places, "_data", None)
assert places.corrections("pest seattle") == []
def test_corrections_skip_short_tokens(index):
assert places.corrections("st paris") == [] # 1-2 letter tokens are noise

65
tests/test_store.py Normal file
View file

@ -0,0 +1,65 @@
import json
import sqlite3
import time
def test_payload_round_trip(tmp_store):
payload = {"cell": "1_2", "days": [1, 2, 3]}
body = tmp_store.put_payload("grade", "1_2", "k", "tok", payload)
assert json.loads(body) == payload
assert tmp_store.get_payload("grade", "1_2", "k", "tok") == body
assert tmp_store.get_json("grade", "1_2", "k", "tok") == payload
def test_token_mismatch_is_a_miss(tmp_store):
tmp_store.put_payload("grade", "1_2", "k", "tok-a", {"v": 1})
assert tmp_store.get_payload("grade", "1_2", "k", "tok-b") is None
# A rewrite under the new token replaces the row (same primary key).
tmp_store.put_payload("grade", "1_2", "k", "tok-b", {"v": 2})
assert tmp_store.get_json("grade", "1_2", "k", "tok-b") == {"v": 2}
assert tmp_store.get_payload("grade", "1_2", "k", "tok-a") is None
def test_cache_false_serves_without_persisting(tmp_store):
body = tmp_store.put_payload("grade", "1_2", "k", "tok", {"v": 1}, cache=False)
assert json.loads(body) == {"v": 1}
assert tmp_store.get_payload("grade", "1_2", "k", "tok") is None
def test_put_payload_rejects_nan(tmp_store):
import pytest
with pytest.raises(ValueError):
tmp_store.put_payload("grade", "1_2", "k", "tok", {"v": float("nan")})
def test_revgeo_round_trip(tmp_store):
key = tmp_store.revgeo_key(47.60623, -122.33305)
assert key == "47.606,-122.333"
assert tmp_store.get_revgeo(key) == (False, None)
tmp_store.put_revgeo(key, "Belltown, Seattle, Washington")
assert tmp_store.get_revgeo(key) == (True, "Belltown, Seattle, Washington")
def test_revgeo_cached_miss_retries_after_ttl(tmp_store):
key = tmp_store.revgeo_key(1.0, 2.0)
tmp_store.put_revgeo(key, None)
assert tmp_store.get_revgeo(key) == (True, None) # fresh miss: don't re-ask yet
# Age the row past the TTL directly in SQLite.
conn = sqlite3.connect(tmp_store.DB_PATH)
conn.execute("UPDATE revgeo SET updated_at=? WHERE key=?",
(time.time() - tmp_store.REVGEO_MISS_TTL - 1, key))
conn.commit()
conn.close()
assert tmp_store.get_revgeo(key) == (False, None) # stale miss: retryable
def test_store_is_a_pure_accelerator_when_db_unavailable(tmp_store, monkeypatch):
# Point the store somewhere unwritable: every helper degrades, none raises.
monkeypatch.setattr(tmp_store, "DB_PATH", "/proc/nope/store.sqlite")
import threading
monkeypatch.setattr(tmp_store, "_local", threading.local())
assert tmp_store.get_payload("grade", "c", "k", "t") is None
body = tmp_store.put_payload("grade", "c", "k", "t", {"v": 1})
assert json.loads(body) == {"v": 1} # still serves the encoded payload
assert tmp_store.get_revgeo("x") == (False, None)
tmp_store.put_revgeo("x", "label") # no-op, no exception