Files
home-automation/tests/test_location.py
T
tliu93 3d3c2bcc57 M1-T03: unify data layer, models, deps and routes onto single app DB
Collapse the three data layers into one. app/db.py now exposes a single
Base, a cached engine bound to app_database_url with SQLite WAL enabled, and
get_engine/get_session_local/reset_db_caches/get_db_session. Delete
app/auth_db.py, app/poo_db.py and app/models/base.py. All models (auth,
config, public_ip, location, poo) inherit the one Base and register on a
single metadata. Dependencies converge to a single get_db; all routes use it.

Also update the alembic env.py files (app/location/poo) and tests that
imported the removed modules so the suite stays green, and drop the obsolete
test_legacy_style_location_db test whose flow (app reading a separate location
DB) no longer exists. Location/poo Alembic chains, adopt scripts and adoption
tests remain for M1-T04; config fields remain for M1-T05.

pytest 109 passed; ruff clean (pre-existing only); WAL verified; single
Base.metadata holds all seven tables.
2026-06-12 16:35:07 +02:00

329 lines
9.3 KiB
Python

from datetime import datetime
from pathlib import Path
import sqlite3
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from scripts.location_db_adopt import (
EXPECTED_USER_VERSION,
LOCATION_BASELINE_REVISION,
LocationDatabaseAdoptionError,
adopt_or_initialize_location_db,
)
def _make_alembic_config(database_url: str) -> Config:
config = Config("alembic_location.ini")
config.set_main_option("sqlalchemy.url", database_url)
return config
def test_location_record_endpoint_writes_row(location_client) -> None:
client, engine = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "1.23",
"longitude": "4.56",
"altitude": "7.89",
},
)
assert response.status_code == 200
assert response.text == ""
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT person, datetime, latitude, longitude, altitude "
"FROM location ORDER BY datetime DESC LIMIT 1"
)
).one()
assert row.person == "tianyu"
assert row.latitude == pytest.approx(1.23)
assert row.longitude == pytest.approx(4.56)
assert row.altitude == pytest.approx(7.89)
datetime.fromisoformat(row.datetime.replace("Z", "+00:00"))
def test_location_record_endpoint_rejects_unknown_fields(location_client) -> None:
client, _ = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "1.23",
"longitude": "4.56",
"extra": "not-allowed",
},
)
assert response.status_code == 400
assert response.text == "bad request"
assert "extra" not in response.text
assert "ValidationError" not in response.text
def test_location_record_endpoint_rejects_missing_latitude(location_client) -> None:
client, _ = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"longitude": "4.56",
},
)
assert response.status_code == 400
assert response.text == "bad request"
assert "latitude" not in response.text
def test_location_record_endpoint_rejects_missing_longitude(location_client) -> None:
client, _ = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "1.23",
},
)
assert response.status_code == 400
assert response.text == "bad request"
assert "longitude" not in response.text
def test_location_record_endpoint_rejects_invalid_latitude(location_client) -> None:
client, _ = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "bad-lat",
"longitude": "4.56",
},
)
assert response.status_code == 400
assert response.text == "bad request"
assert "bad-lat" not in response.text
assert "latitude" not in response.text
def test_location_record_endpoint_rejects_invalid_longitude(location_client) -> None:
client, _ = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "1.23",
"longitude": "bad-long",
},
)
assert response.status_code == 400
assert response.text == "bad request"
assert "bad-long" not in response.text
assert "longitude" not in response.text
def test_location_record_endpoint_defaults_missing_altitude_to_zero(location_client) -> None:
client, engine = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "1.23",
"longitude": "4.56",
},
)
assert response.status_code == 200
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT latitude, longitude, altitude "
"FROM location ORDER BY datetime DESC LIMIT 1"
)
).one()
assert row.latitude == pytest.approx(1.23)
assert row.longitude == pytest.approx(4.56)
assert row.altitude == pytest.approx(0.0)
def test_location_record_endpoint_defaults_invalid_altitude_to_zero(location_client) -> None:
client, engine = location_client
response = client.post(
"/location/record",
json={
"person": "tianyu",
"latitude": "1.23",
"longitude": "4.56",
"altitude": "bad-alt",
},
)
assert response.status_code == 200
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT latitude, longitude, altitude "
"FROM location ORDER BY datetime DESC LIMIT 1"
)
).one()
assert row.latitude == pytest.approx(1.23)
assert row.longitude == pytest.approx(4.56)
assert row.altitude == pytest.approx(0.0)
def test_location_db_adoption_initializes_new_db(tmp_path: Path) -> None:
database_path = tmp_path / "new_location.db"
result = adopt_or_initialize_location_db(f"sqlite:///{database_path}")
assert result == "initialized"
assert database_path.exists()
conn = sqlite3.connect(database_path)
try:
revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0]
location_table = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'location'"
).fetchone()
finally:
conn.close()
assert revision == LOCATION_BASELINE_REVISION
assert location_table is not None
def test_location_db_adoption_validates_and_stamps_legacy_db(tmp_path: Path) -> None:
database_path = tmp_path / "legacy_location.db"
conn = sqlite3.connect(database_path)
conn.execute(
"""
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime)
)
"""
)
conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}")
conn.commit()
conn.close()
result = adopt_or_initialize_location_db(f"sqlite:///{database_path}")
assert result == "adopted"
conn = sqlite3.connect(database_path)
try:
revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0]
finally:
conn.close()
assert revision == LOCATION_BASELINE_REVISION
def test_location_db_adoption_accepts_already_managed_matching_revision(
tmp_path: Path,
) -> None:
database_path = tmp_path / "managed_location.db"
command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head")
result = adopt_or_initialize_location_db(f"sqlite:///{database_path}")
assert result == "already_managed"
def test_location_db_adoption_fails_closed_on_alembic_revision_mismatch(
tmp_path: Path,
) -> None:
database_path = tmp_path / "wrong_revision.db"
conn = sqlite3.connect(database_path)
conn.execute(
"""
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime)
)
"""
)
conn.execute("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
conn.execute("INSERT INTO alembic_version (version_num) VALUES ('wrong_revision')")
conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}")
conn.commit()
conn.close()
with pytest.raises(LocationDatabaseAdoptionError, match="known migration revision"):
adopt_or_initialize_location_db(f"sqlite:///{database_path}")
def test_location_db_adoption_fails_closed_on_schema_mismatch(tmp_path: Path) -> None:
database_path = tmp_path / "bad_schema.db"
conn = sqlite3.connect(database_path)
conn.execute(
"""
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
PRIMARY KEY (person, datetime)
)
"""
)
conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}")
conn.commit()
conn.close()
with pytest.raises(LocationDatabaseAdoptionError, match="schema does not match"):
adopt_or_initialize_location_db(f"sqlite:///{database_path}")
def test_location_db_adoption_fails_closed_on_user_version_mismatch(tmp_path: Path) -> None:
database_path = tmp_path / "bad_user_version.db"
conn = sqlite3.connect(database_path)
conn.execute(
"""
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime)
)
"""
)
conn.execute("PRAGMA user_version = 999")
conn.commit()
conn.close()
with pytest.raises(LocationDatabaseAdoptionError, match="Expected PRAGMA user_version"):
adopt_or_initialize_location_db(f"sqlite:///{database_path}")