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.
2026-07-11 19:37:49 +00:00
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from data import grid
|
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.
2026-07-11 19:37:49 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
2026-07-11 20:47:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_neighbors_mid_latitude():
|
|
|
|
|
cell = grid.snap(47.6062, -122.3321)
|
|
|
|
|
ns = grid.neighbors(cell)
|
|
|
|
|
assert len(ns) == 8
|
|
|
|
|
ids = {n["id"] for n in ns}
|
|
|
|
|
assert cell["id"] not in ids and len(ids) == 8
|
|
|
|
|
# Every neighbor is at most one row away and shares a border region.
|
|
|
|
|
i = int(cell["id"].split("_")[0])
|
|
|
|
|
assert all(abs(int(n["id"].split("_")[0]) - i) <= 1 for n in ns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_neighbors_skip_rows_past_the_pole():
|
|
|
|
|
cell = grid.snap(89.99, 10.0) # topmost row
|
|
|
|
|
ns = grid.neighbors(cell)
|
|
|
|
|
assert 0 < len(ns) < 8 # no row above the pole
|
|
|
|
|
assert all(-90 <= n["center_lat"] <= 90 for n in ns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_neighbors_wrap_across_antimeridian():
|
|
|
|
|
cell = grid.snap(10.0, 179.999)
|
|
|
|
|
ns = grid.neighbors(cell)
|
|
|
|
|
assert len(ns) == 8
|
|
|
|
|
assert any(n["center_lon"] < 0 for n in ns) # some sit past the date line
|