a9830c42d8
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist): mounts /assets and a GET catch-all returning index.html for client routes; catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets, ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI) - delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/ (all replaced by /api/* + SPA; auth service layer kept) - remove/replace Jinja page tests (JSON coverage already in test_api_*); add tests/test_spa_hosting.py for the fallback contract - regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
154 lines
5.0 KiB
Python
154 lines
5.0 KiB
Python
import sqlite3
|
|
|
|
import anyio
|
|
import pytest
|
|
from alembic import command
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.db import reset_db_caches
|
|
from app.config import get_settings
|
|
from app.main import create_app
|
|
from scripts.app_db_adopt import APP_BASELINE_REVISION, adopt_or_initialize_app_db
|
|
from tests.conftest import _make_app_alembic_config
|
|
|
|
|
|
async def _run_lifespan(app) -> None:
|
|
async with app.router.lifespan_context(app):
|
|
return None
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_app_starts(client: TestClient) -> None:
|
|
# With SPA enabled, GET / is served by the catch-all and returns index.html (200).
|
|
# Without SPA (e.g. SPA_DIST_DIR points to empty dir), it returns 404.
|
|
# Either way the app started successfully — just assert it is not a server error.
|
|
response = client.get("/", follow_redirects=False)
|
|
assert response.status_code in (200, 404)
|
|
|
|
|
|
def test_status_endpoint(client: TestClient) -> None:
|
|
response = client.get("/status")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
def test_app_start_fails_when_app_db_missing(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
missing_app_path = tmp_path / "missing_app.db"
|
|
|
|
monkeypatch.setenv("APP_DATABASE_URL", f"sqlite:///{missing_app_path}")
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
|
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
|
get_settings.cache_clear()
|
|
reset_db_caches()
|
|
|
|
app = create_app()
|
|
with pytest.raises(RuntimeError, match="Run 'python scripts/app_db_adopt.py' first"):
|
|
anyio.run(_run_lifespan, app)
|
|
|
|
assert not missing_app_path.exists()
|
|
|
|
get_settings.cache_clear()
|
|
reset_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()
|
|
}
|
|
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)
|
|
|
|
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")
|
|
get_settings.cache_clear()
|
|
reset_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_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_db_caches()
|
|
|
|
|
|
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)
|
|
|
|
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")
|
|
get_settings.cache_clear()
|
|
reset_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_db_caches()
|