2026-04-19 23:02:43 +02:00
|
|
|
import sqlite3
|
|
|
|
|
|
|
|
|
|
import anyio
|
|
|
|
|
import pytest
|
|
|
|
|
from alembic import command
|
2026-04-19 20:19:58 +02:00
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
2026-04-20 15:16:47 +02:00
|
|
|
from app.auth_db import reset_auth_db_caches
|
2026-04-19 23:02:43 +02:00
|
|
|
from app.config import get_settings
|
|
|
|
|
from app.main import create_app
|
2026-04-20 15:16:47 +02:00
|
|
|
from scripts.app_db_adopt import APP_BASELINE_REVISION, adopt_or_initialize_app_db
|
|
|
|
|
from tests.conftest import _make_alembic_config, _make_app_alembic_config, _make_poo_alembic_config
|
2026-04-19 23:02:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_lifespan(app) -> None:
|
|
|
|
|
async with app.router.lifespan_context(app):
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-19 20:19:58 +02:00
|
|
|
|
2026-04-20 15:16:47 +02:00
|
|
|
def _prepare_app_db(tmp_path) -> str:
|
|
|
|
|
app_database_path = tmp_path / "app_ready.db"
|
|
|
|
|
app_database_url = f"sqlite:///{app_database_path}"
|
|
|
|
|
command.upgrade(_make_app_alembic_config(app_database_url), "head")
|
|
|
|
|
return app_database_url
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 20:19:58 +02:00
|
|
|
def test_app_starts(client: TestClient) -> None:
|
2026-04-20 15:56:10 +02:00
|
|
|
response = client.get("/", follow_redirects=False)
|
|
|
|
|
assert response.status_code == 303
|
|
|
|
|
assert response.headers["location"] == "/login"
|
2026-04-19 20:19:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_status_endpoint(client: TestClient) -> None:
|
|
|
|
|
response = client.get("/status")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
2026-04-19 23:02:43 +02:00
|
|
|
|
2026-04-20 15:16:47 +02:00
|
|
|
def test_app_start_fails_when_app_db_missing(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
poo_database_path = tmp_path / "poo_ready.db"
|
|
|
|
|
location_database_path = tmp_path / "location_ready.db"
|
|
|
|
|
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
|
|
|
|
command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", f"sqlite:///{tmp_path / 'missing_app.db'}")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
|
|
|
|
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}")
|
|
|
|
|
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
reset_auth_db_caches()
|
|
|
|
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
with pytest.raises(RuntimeError, match="Run 'python scripts/app_db_adopt.py' first"):
|
|
|
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
|
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
reset_auth_db_caches()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_db_adoption_initializes_new_database(tmp_path) -> None:
|
|
|
|
|
database_url = f"sqlite:///{tmp_path / 'app_init.db'}"
|
|
|
|
|
|
|
|
|
|
result = adopt_or_initialize_app_db(database_url)
|
|
|
|
|
|
|
|
|
|
assert result == "initialized"
|
|
|
|
|
conn = sqlite3.connect(tmp_path / "app_init.db")
|
|
|
|
|
try:
|
|
|
|
|
revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0]
|
|
|
|
|
assert revision == APP_BASELINE_REVISION
|
|
|
|
|
tables = {
|
|
|
|
|
row[0]
|
|
|
|
|
for row in conn.execute(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
|
|
|
|
|
).fetchall()
|
|
|
|
|
}
|
2026-04-20 15:56:10 +02:00
|
|
|
assert {"auth_users", "auth_sessions", "app_config", "alembic_version"} <= tables
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_values(
|
|
|
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
app_database_url = _prepare_app_db(tmp_path)
|
|
|
|
|
location_database_path = tmp_path / "location_ready.db"
|
|
|
|
|
poo_database_path = tmp_path / "poo_ready.db"
|
|
|
|
|
command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head")
|
|
|
|
|
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
|
|
|
|
|
|
|
|
|
app_database_path = tmp_path / "app_ready.db"
|
|
|
|
|
conn = sqlite3.connect(app_database_path)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
|
|
|
|
("APP_NAME", "Database Owned Name"),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
|
|
|
|
monkeypatch.setenv("APP_NAME", "Bootstrap Name")
|
|
|
|
|
monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://bootstrap-ha.local:8123")
|
|
|
|
|
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}")
|
|
|
|
|
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
reset_auth_db_caches()
|
|
|
|
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
|
|
|
|
|
|
conn = sqlite3.connect(app_database_path)
|
|
|
|
|
try:
|
|
|
|
|
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
2026-04-20 15:16:47 +02:00
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
2026-04-20 15:56:10 +02:00
|
|
|
assert rows["APP_NAME"] == "Database Owned Name"
|
|
|
|
|
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
|
|
|
|
|
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
|
|
|
|
|
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
reset_auth_db_caches()
|
|
|
|
|
|
2026-04-20 15:16:47 +02:00
|
|
|
|
2026-04-20 20:40:04 +02:00
|
|
|
def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value(
|
|
|
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
app_database_url = _prepare_app_db(tmp_path)
|
|
|
|
|
location_database_path = tmp_path / "location_ready.db"
|
|
|
|
|
poo_database_path = tmp_path / "poo_ready.db"
|
|
|
|
|
command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head")
|
|
|
|
|
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
|
|
|
|
|
|
|
|
|
app_database_path = tmp_path / "app_ready.db"
|
|
|
|
|
conn = sqlite3.connect(app_database_path)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
|
|
|
|
("APP_HOSTNAME", "old.example.com"),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
|
|
|
|
monkeypatch.setenv("APP_HOSTNAME", "new.example.com")
|
|
|
|
|
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}")
|
|
|
|
|
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
reset_auth_db_caches()
|
|
|
|
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
|
|
|
|
|
|
conn = sqlite3.connect(app_database_path)
|
|
|
|
|
try:
|
|
|
|
|
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
assert rows["APP_HOSTNAME"] == "new.example.com"
|
|
|
|
|
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
reset_auth_db_caches()
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 23:02:43 +02:00
|
|
|
def test_app_start_fails_when_location_db_missing(
|
|
|
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
2026-04-20 15:16:47 +02:00
|
|
|
app_database_url = _prepare_app_db(tmp_path)
|
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
2026-04-20 11:48:48 +02:00
|
|
|
poo_database_path = tmp_path / "poo_ready.db"
|
|
|
|
|
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
|
|
|
|
|
2026-04-19 23:02:43 +02:00
|
|
|
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{tmp_path / 'missing.db'}")
|
2026-04-20 11:48:48 +02:00
|
|
|
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
2026-04-19 23:02:43 +02:00
|
|
|
get_settings.cache_clear()
|
2026-04-20 15:16:47 +02:00
|
|
|
reset_auth_db_caches()
|
2026-04-19 23:02:43 +02:00
|
|
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
with pytest.raises(RuntimeError, match="Run 'python scripts/location_db_adopt.py' first"):
|
|
|
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
|
|
|
|
|
|
get_settings.cache_clear()
|
2026-04-20 15:16:47 +02:00
|
|
|
reset_auth_db_caches()
|
2026-04-19 23:02:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_start_fails_when_location_db_exists_but_is_not_adopted(
|
|
|
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
2026-04-20 15:16:47 +02:00
|
|
|
app_database_url = _prepare_app_db(tmp_path)
|
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
2026-04-20 11:48:48 +02:00
|
|
|
poo_database_path = tmp_path / "poo_ready.db"
|
|
|
|
|
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
|
|
|
|
|
2026-04-19 23:02:43 +02:00
|
|
|
database_path = tmp_path / "legacy_only.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 = 2")
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}")
|
2026-04-20 11:48:48 +02:00
|
|
|
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
2026-04-19 23:02:43 +02:00
|
|
|
get_settings.cache_clear()
|
2026-04-20 15:16:47 +02:00
|
|
|
reset_auth_db_caches()
|
2026-04-19 23:02:43 +02:00
|
|
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
with pytest.raises(RuntimeError, match="is not yet Alembic-managed"):
|
|
|
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
|
|
|
|
|
|
get_settings.cache_clear()
|
2026-04-20 15:16:47 +02:00
|
|
|
reset_auth_db_caches()
|
2026-04-19 23:02:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_start_fails_when_location_db_revision_mismatches(
|
|
|
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
2026-04-20 15:16:47 +02:00
|
|
|
app_database_url = _prepare_app_db(tmp_path)
|
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
|
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
2026-04-20 11:48:48 +02:00
|
|
|
poo_database_path = tmp_path / "poo_ready.db"
|
|
|
|
|
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
|
|
|
|
|
2026-04-19 23:02:43 +02:00
|
|
|
database_path = tmp_path / "wrong_revision.db"
|
|
|
|
|
command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head")
|
|
|
|
|
|
|
|
|
|
conn = sqlite3.connect(database_path)
|
|
|
|
|
conn.execute("UPDATE alembic_version SET version_num = 'wrong_revision'")
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}")
|
2026-04-20 11:48:48 +02:00
|
|
|
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
2026-04-19 23:02:43 +02:00
|
|
|
get_settings.cache_clear()
|
2026-04-20 15:16:47 +02:00
|
|
|
reset_auth_db_caches()
|
2026-04-19 23:02:43 +02:00
|
|
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
with pytest.raises(RuntimeError, match="Location DB revision mismatch"):
|
|
|
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
|
|
|
|
|
|
get_settings.cache_clear()
|
2026-04-20 15:16:47 +02:00
|
|
|
reset_auth_db_caches()
|