"""data/store.py: hermetic SQLite tests (always run, via the tmp_store fixture in conftest.py) plus a gated Postgres round-trip through the bounded connection pool (skipped unless THERMOGRAPH_TEST_DATABASE_URL points at a reachable Postgres).""" import json import sqlite3 import time import pytest 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 == "v2:47.606,-122.333" # v2: label format now carries country assert tmp_store.get_revgeo(key) == (False, None) tmp_store.put_revgeo(key, "Belltown, Seattle, Washington, United States") assert tmp_store.get_revgeo(key) == (True, "Belltown, Seattle, Washington, United States") 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 # --- gated Postgres integration (the bounded pool, _get_pg_pool) ----------- @pytest.fixture def pg_store(): """Point store.py at THERMOGRAPH_TEST_DATABASE_URL for one test -- exercises the real bounded ConnectionPool (borrow-per-operation, configure=_configure_pg running the schema DDL) rather than the SQLite fallback. store.py picks its dialect-specific SQL text (``?`` vs ``%s`` placeholders, INSERT OR REPLACE vs ON CONFLICT) once, at import time, from IS_POSTGRES -- correct in production (THERMOGRAPH_DATABASE_URL is set before the module is ever imported) but not something flipping IS_POSTGRES back on afterwards retroactively fixes, so this fixture swaps the SQL constants too, not just IS_POSTGRES/_PG_DSN. Restores all of it afterwards.""" import os from data import store url = os.environ.get("THERMOGRAPH_TEST_DATABASE_URL", "").strip() if not url: pytest.skip("set THERMOGRAPH_TEST_DATABASE_URL to run the Postgres pool test") saved = (store.IS_POSTGRES, store._PG_DSN, store._pg_pool, store._SQL_GET_PAYLOAD, store._SQL_PUT_PAYLOAD, store._SQL_GET_REVGEO, store._SQL_PUT_REVGEO) store.IS_POSTGRES = True store._PG_DSN = store._libpq_dsn(url) store._pg_pool = None # force a fresh pool bound to the test DSN store._SQL_GET_PAYLOAD = "SELECT token, payload FROM derived WHERE kind=%s AND cell_id=%s AND key=%s" store._SQL_PUT_PAYLOAD = ( "INSERT INTO derived (kind, cell_id, key, token, payload, updated_at) " "VALUES (%s,%s,%s,%s,%s,%s) " "ON CONFLICT (kind, cell_id, key) DO UPDATE SET " "token=EXCLUDED.token, payload=EXCLUDED.payload, updated_at=EXCLUDED.updated_at" ) store._SQL_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=%s" store._SQL_PUT_REVGEO = ( "INSERT INTO revgeo (key, label, updated_at) VALUES (%s,%s,%s) " "ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, updated_at=EXCLUDED.updated_at" ) import psycopg with psycopg.connect(store._PG_DSN, autocommit=True) as conn: conn.execute("DROP TABLE IF EXISTS derived") conn.execute("DROP TABLE IF EXISTS revgeo") yield store if store._pg_pool is not None: store._pg_pool.close() (store.IS_POSTGRES, store._PG_DSN, store._pg_pool, store._SQL_GET_PAYLOAD, store._SQL_PUT_PAYLOAD, store._SQL_GET_REVGEO, store._SQL_PUT_REVGEO) = saved def test_pg_payload_round_trip_through_the_pool(pg_store): """Two consecutive calls borrow (and return) separate pooled connections -- unlike the old thread-local model there is no single connection object tying the write and the read together, so this also exercises that the schema (applied via the pool's configure= callback) is visible across borrows.""" body = pg_store.put_payload("grade", "1_2", "k", "tok", {"v": 1}) assert json.loads(body) == {"v": 1} assert pg_store.get_payload("grade", "1_2", "k", "tok") == body assert pg_store.get_payload("grade", "1_2", "k", "tok-mismatch") is None def test_pg_revgeo_round_trip_through_the_pool(pg_store): key = pg_store.revgeo_key(47.6, -122.3) assert pg_store.get_revgeo(key) == (False, None) pg_store.put_revgeo(key, "Somewhere, WA") assert pg_store.get_revgeo(key) == (True, "Somewhere, WA") def test_pg_pool_survives_many_sequential_borrows(pg_store): """A crude stand-in for the finding this pool exists to fix: many operations in a row must keep working off a small bounded pool (max_size=8, well below this loop) instead of piling up unclosed connections.""" for i in range(25): pg_store.put_payload("grade", "cell", f"k{i}", "tok", {"i": i}) for i in range(25): assert pg_store.get_json("grade", "cell", f"k{i}", "tok") == {"i": i}