Migrate poo recorder and align Alembic naming
This commit is contained in:
+34
-3
@@ -13,7 +13,13 @@ from app.main import create_app
|
||||
|
||||
|
||||
def _make_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic.ini")
|
||||
config = Config("alembic_location.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
def _make_poo_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic_poo.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
@@ -47,7 +53,13 @@ def ready_location_database(test_database_urls):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(ready_location_database):
|
||||
def ready_poo_database(test_database_urls):
|
||||
command.upgrade(_make_poo_alembic_config(test_database_urls["poo_url"]), "head")
|
||||
return test_database_urls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(ready_location_database, ready_poo_database):
|
||||
yield create_app()
|
||||
|
||||
|
||||
@@ -58,7 +70,7 @@ def client(app):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch):
|
||||
def location_client(ready_location_database, ready_poo_database, monkeypatch: pytest.MonkeyPatch):
|
||||
database_url = ready_location_database["location_url"]
|
||||
|
||||
engine = create_engine(database_url, connect_args={"check_same_thread": False})
|
||||
@@ -72,3 +84,22 @@ def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch):
|
||||
yield client, engine
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def poo_client(ready_location_database, ready_poo_database, monkeypatch: pytest.MonkeyPatch):
|
||||
database_url = ready_poo_database["poo_url"]
|
||||
|
||||
engine = create_engine(database_url, connect_args={"check_same_thread": False})
|
||||
session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
import app.poo_db as poo_db
|
||||
|
||||
monkeypatch.setattr(poo_db, "poo_engine", engine)
|
||||
monkeypatch.setattr(poo_db, "PooSessionLocal", session_local)
|
||||
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
yield client, engine
|
||||
|
||||
engine.dispose()
|
||||
|
||||
+13
-4
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.main import create_app
|
||||
from tests.conftest import _make_alembic_config
|
||||
from tests.conftest import _make_alembic_config, _make_poo_alembic_config
|
||||
|
||||
|
||||
async def _run_lifespan(app) -> None:
|
||||
@@ -29,8 +29,11 @@ def test_status_endpoint(client: TestClient) -> None:
|
||||
def test_app_start_fails_when_location_db_missing(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
poo_database_path = tmp_path / "poo_ready.db"
|
||||
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
||||
|
||||
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{tmp_path / 'missing.db'}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
||||
get_settings.cache_clear()
|
||||
|
||||
app = create_app()
|
||||
@@ -43,6 +46,9 @@ def test_app_start_fails_when_location_db_missing(
|
||||
def test_app_start_fails_when_location_db_exists_but_is_not_adopted(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
poo_database_path = tmp_path / "poo_ready.db"
|
||||
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
||||
|
||||
database_path = tmp_path / "legacy_only.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute(
|
||||
@@ -62,7 +68,7 @@ def test_app_start_fails_when_location_db_exists_but_is_not_adopted(
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
||||
get_settings.cache_clear()
|
||||
|
||||
app = create_app()
|
||||
@@ -75,6 +81,9 @@ def test_app_start_fails_when_location_db_exists_but_is_not_adopted(
|
||||
def test_app_start_fails_when_location_db_revision_mismatches(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
poo_database_path = tmp_path / "poo_ready.db"
|
||||
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
||||
|
||||
database_path = tmp_path / "wrong_revision.db"
|
||||
command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head")
|
||||
|
||||
@@ -84,7 +93,7 @@ def test_app_start_fails_when_location_db_revision_mismatches(
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
||||
get_settings.cache_clear()
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -4,6 +4,9 @@ from app.config import Settings
|
||||
def test_settings_support_two_independent_database_urls(monkeypatch) -> None:
|
||||
monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db")
|
||||
monkeypatch.setenv("POO_WEBHOOK_ID", "poo-hook")
|
||||
monkeypatch.setenv("POO_SENSOR_ENTITY_NAME", "sensor.test_poo_status")
|
||||
monkeypatch.setenv("POO_SENSOR_FRIENDLY_NAME", "Poo Status")
|
||||
monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://ha.local:8123")
|
||||
monkeypatch.setenv("HOME_ASSISTANT_AUTH_TOKEN", "token")
|
||||
monkeypatch.setenv("HOME_ASSISTANT_TIMEOUT_SECONDS", "2.5")
|
||||
@@ -12,6 +15,9 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None:
|
||||
|
||||
assert settings.location_database_url == "sqlite:///./data/locationRecorder.db"
|
||||
assert settings.poo_database_url == "sqlite:///./data/pooRecorder.db"
|
||||
assert settings.poo_webhook_id == "poo-hook"
|
||||
assert settings.poo_sensor_entity_name == "sensor.test_poo_status"
|
||||
assert settings.poo_sensor_friendly_name == "Poo Status"
|
||||
assert settings.home_assistant_base_url == "http://ha.local:8123"
|
||||
assert settings.home_assistant_auth_token == "token"
|
||||
assert settings.home_assistant_timeout_seconds == 2.5
|
||||
|
||||
@@ -97,7 +97,7 @@ def test_homeassistant_client_raises_on_http_error(monkeypatch: pytest.MonkeyPat
|
||||
|
||||
|
||||
def test_homeassistant_client_raises_when_not_configured() -> None:
|
||||
client = HomeAssistantClient(settings=Settings())
|
||||
client = HomeAssistantClient(settings=Settings(_env_file=None))
|
||||
|
||||
with pytest.raises(HomeAssistantConfigError, match="not configured"):
|
||||
client.publish_sensor(entity_id="sensor.test_status", state="ok")
|
||||
|
||||
@@ -16,10 +16,11 @@ from scripts.location_db_adopt import (
|
||||
LocationDatabaseAdoptionError,
|
||||
adopt_or_initialize_location_db,
|
||||
)
|
||||
from tests.conftest import _make_poo_alembic_config
|
||||
|
||||
|
||||
def _make_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic.ini")
|
||||
config = Config("alembic_location.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
@@ -201,6 +202,7 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted(
|
||||
) -> None:
|
||||
database_path = test_database_urls["location_path"]
|
||||
database_url = test_database_urls["location_url"]
|
||||
poo_database_url = test_database_urls["poo_url"]
|
||||
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute(
|
||||
@@ -220,6 +222,7 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted(
|
||||
conn.close()
|
||||
|
||||
command.stamp(_make_alembic_config(database_url), LOCATION_BASELINE_REVISION)
|
||||
command.upgrade(_make_poo_alembic_config(poo_database_url), "head")
|
||||
|
||||
engine = create_engine(database_url, connect_args={"check_same_thread": False})
|
||||
session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_homeassistant_client
|
||||
from scripts.poo_db_adopt import (
|
||||
EXPECTED_USER_VERSION,
|
||||
POO_BASELINE_REVISION,
|
||||
PooDatabaseAdoptionError,
|
||||
adopt_or_initialize_poo_db,
|
||||
)
|
||||
|
||||
|
||||
class _FakeHomeAssistantClient:
|
||||
def __init__(self) -> None:
|
||||
self.sensor_calls: list[dict] = []
|
||||
self.webhook_calls: list[dict] = []
|
||||
|
||||
def publish_sensor(self, *, entity_id: str, state: str, attributes: dict | None = None) -> None:
|
||||
self.sensor_calls.append(
|
||||
{"entity_id": entity_id, "state": state, "attributes": attributes or {}}
|
||||
)
|
||||
|
||||
def trigger_webhook(self, *, webhook_id: str, body) -> None:
|
||||
self.webhook_calls.append({"webhook_id": webhook_id, "body": body})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def poo_client_with_overrides(poo_client):
|
||||
client, engine = poo_client
|
||||
fake_ha = _FakeHomeAssistantClient()
|
||||
settings = Settings(
|
||||
poo_webhook_id="poo-hook",
|
||||
poo_sensor_entity_name="sensor.test_poo_status",
|
||||
poo_sensor_friendly_name="Poo Status",
|
||||
)
|
||||
client.app.dependency_overrides[get_homeassistant_client] = lambda: fake_ha
|
||||
client.app.dependency_overrides[get_app_settings] = lambda: settings
|
||||
try:
|
||||
yield client, engine, fake_ha
|
||||
finally:
|
||||
client.app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_poo_record_endpoint_writes_row_and_notifies_homeassistant(
|
||||
poo_client_with_overrides,
|
||||
) -> None:
|
||||
client, engine, fake_ha = poo_client_with_overrides
|
||||
|
||||
response = client.post(
|
||||
"/poo/record",
|
||||
json={
|
||||
"status": "done",
|
||||
"latitude": "1.23",
|
||||
"longitude": "4.56",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == ""
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT status, latitude, longitude FROM poo_records "
|
||||
"ORDER BY timestamp DESC LIMIT 1"
|
||||
)
|
||||
).one()
|
||||
|
||||
assert row.status == "done"
|
||||
assert row.latitude == pytest.approx(1.23)
|
||||
assert row.longitude == pytest.approx(4.56)
|
||||
assert len(fake_ha.sensor_calls) == 1
|
||||
assert fake_ha.sensor_calls[0]["entity_id"] == "sensor.test_poo_status"
|
||||
assert fake_ha.sensor_calls[0]["state"] == "done"
|
||||
assert fake_ha.sensor_calls[0]["attributes"]["friendly_name"] == "Poo Status"
|
||||
assert len(fake_ha.webhook_calls) == 1
|
||||
assert fake_ha.webhook_calls[0] == {
|
||||
"webhook_id": "poo-hook",
|
||||
"body": {"status": "done"},
|
||||
}
|
||||
|
||||
|
||||
def test_poo_latest_endpoint_publishes_latest_status(poo_client_with_overrides) -> None:
|
||||
client, engine, fake_ha = poo_client_with_overrides
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO poo_records (timestamp, status, latitude, longitude) "
|
||||
"VALUES (:timestamp, :status, :latitude, :longitude)"
|
||||
),
|
||||
{
|
||||
"timestamp": "2026-04-20T10:05Z",
|
||||
"status": "urgent",
|
||||
"latitude": 3.21,
|
||||
"longitude": 6.54,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get("/poo/latest")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == ""
|
||||
assert len(fake_ha.sensor_calls) == 1
|
||||
assert fake_ha.sensor_calls[0]["state"] == "urgent"
|
||||
assert fake_ha.sensor_calls[0]["attributes"]["last_poo"]
|
||||
|
||||
|
||||
def test_poo_record_endpoint_rejects_unknown_fields(poo_client_with_overrides) -> None:
|
||||
client, _, _ = poo_client_with_overrides
|
||||
|
||||
response = client.post(
|
||||
"/poo/record",
|
||||
json={
|
||||
"status": "done",
|
||||
"latitude": "1.23",
|
||||
"longitude": "4.56",
|
||||
"extra": "nope",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "bad request"
|
||||
|
||||
|
||||
def test_poo_record_endpoint_rejects_invalid_latitude(poo_client_with_overrides) -> None:
|
||||
client, _, _ = poo_client_with_overrides
|
||||
|
||||
response = client.post(
|
||||
"/poo/record",
|
||||
json={
|
||||
"status": "done",
|
||||
"latitude": "oops",
|
||||
"longitude": "4.56",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "bad request"
|
||||
|
||||
|
||||
def test_poo_latest_endpoint_returns_ok_when_no_record_exists(poo_client_with_overrides) -> None:
|
||||
client, _, _ = poo_client_with_overrides
|
||||
|
||||
response = client.get("/poo/latest")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == ""
|
||||
|
||||
|
||||
def test_poo_db_adoption_initializes_new_db(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "new_poo.db"
|
||||
|
||||
result = adopt_or_initialize_poo_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]
|
||||
poo_table = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'poo_records'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert revision == POO_BASELINE_REVISION
|
||||
assert poo_table is not None
|
||||
|
||||
|
||||
def test_poo_db_adoption_validates_and_stamps_legacy_db(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "legacy_poo.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE poo_records (
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
PRIMARY KEY (timestamp)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
result = adopt_or_initialize_poo_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 == POO_BASELINE_REVISION
|
||||
|
||||
|
||||
def test_poo_db_adoption_fails_closed_on_schema_mismatch(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "bad_poo_schema.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE poo_records (
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
PRIMARY KEY (timestamp)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with pytest.raises(PooDatabaseAdoptionError, match="schema does not match"):
|
||||
adopt_or_initialize_poo_db(f"sqlite:///{database_path}")
|
||||
|
||||
|
||||
def test_poo_db_adoption_fails_closed_on_user_version_mismatch(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "bad_poo_user_version.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE poo_records (
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
PRIMARY KEY (timestamp)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("PRAGMA user_version = 999")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with pytest.raises(PooDatabaseAdoptionError, match="Expected PRAGMA user_version"):
|
||||
adopt_or_initialize_poo_db(f"sqlite:///{database_path}")
|
||||
Reference in New Issue
Block a user