thermograph/backend/tests/data/test_store.py

154 lines
6.8 KiB
Python
Raw Permalink Normal View History

"""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}