Files
espresso_frame/server/tests/conftest.py
T
tfaour 1d39e439ff
Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Drop the last legacy widget-system and shared-token auth scaffolding
Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.

Firmware: the captive portal's "Access Token" field and its NVS/
build_url plumbing only ever mattered for pointing new firmware at an
old pre-multi-frame server -- gone along with the server-side fallback
it fed. Version bump to publish the change.
2026-08-04 18:33:29 +00:00

158 lines
6.1 KiB
Python

"""Shared pytest fixtures for the server test suite.
DATABASE_URL must be set before app.db (and anything importing it,
transitively including app.main) is first imported -- app.db builds its
engine/SessionLocal at module import time, not lazily -- so this file
sets it as the very first thing it does, ahead of any `from app...`
import below. Importing app.main also runs migration.run_migrations()
as a side effect of that import (see main.py), which is what actually
creates the schema in the fresh temp database this points at.
Each test shares one migrated schema (re-migrating per test would be
needless I/O), but gets a clean slate of *data*: every table is wiped
after each test rather than relying on SQLAlchemy's transaction-rollback
test-isolation pattern (Session bound to a connection-level transaction
via join_transaction_mode="create_savepoint") -- that pattern needs the
"pysqlite serializable" event-listener workaround (see SQLAlchemy's own
docs on pysqlite's implicit-transaction quirks) that app/db.py's engine
doesn't set up, and this suite has no reason to add production-affecting
engine config just to make tests work. Deleting tables in reverse
dependency order (children before parents) satisfies foreign keys
without needing that workaround at all.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
# Same reasoning as DATABASE_URL above: logging_setup.configure_logging()
# also runs as an app.main import-time side effect and would otherwise
# try to create the real /data directory.
os.environ["LOG_PATH"] = str(Path(_tmp_dir) / "server.log")
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app import db as db_module
from app import migration
from app.auth import hash_password
from app.main import app
from app.models import Base, Frame, User, UserFrame
def _reset_db() -> None:
"""Wipes every ORM-mapped table, then reseeds the same baseline a
real fresh install gets (frame #1 + the server-settings singleton --
see migration.py's _ensure_frame_one/_ensure_server_settings, both
idempotent and both already called by run_migrations). The raw
schema_version table isn't part of Base.metadata (see migration.py),
so it survives the wipe untouched and run_migrations() skips
straight to that reseed step instead of re-running every ALTER."""
with db_module.engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
conn.execute(table.delete())
migration.run_migrations()
@pytest.fixture
def db_session():
session = db_module.SessionLocal()
try:
yield session
finally:
session.close()
_reset_db()
@pytest.fixture
def client(db_session):
"""A TestClient whose every request shares this test's own db_session
-- so anything the test asserts against db_session sees exactly what
the app just did. Table data from this test is wiped once db_session
tears down (see _wipe_all_tables above)."""
def _override_get_db():
yield db_session
app.dependency_overrides[db_module.get_db] = _override_get_db
# follow_redirects=False: nearly every POST route in this app answers
# success with a 303 (POST/redirect/GET) -- tests assert against that
# 303 directly, matching what a real browser's network tab would show
# before it follows the redirect itself.
with TestClient(app, follow_redirects=False) as c:
yield c
app.dependency_overrides.clear()
def make_user(db: Session, username: str, password: str = "testpass123", **kwargs) -> User:
"""A user row directly via the ORM -- bypasses the HTTP signup/claim
flow for tests that only care about what happens once a user already
exists (permission boundaries, source ownership, etc.)."""
import time as _time
user = User(
username=username,
display_name=kwargs.pop("display_name", username.capitalize()),
password_hash=hash_password(password),
created_at=_time.time(),
**kwargs,
)
db.add(user)
db.flush()
return user
def link_user(db: Session, user: User, frame: Frame) -> None:
db.add(UserFrame(user_id=user.id, frame_id=frame.id))
db.flush()
def claim_device(db: Session, frame: Frame, device_id: str = "001122334455",
token: str = "devtok-1") -> str:
"""Gives `frame` device credentials and returns the "id=...&token=..."
query string real firmware always sends -- require_device has no
fallback for a bare /frame/* request without ?id= (the old shared-
MANAGEMENT_TOKEN/no-id path this project used to resolve to a single
legacy frame is gone), so any device-facing test needs this."""
frame.device_id = device_id
frame.device_token = token
db.commit()
return f"id={device_id}&token={token}"
def login(client: TestClient, username: str, password: str = "testpass123") -> None:
resp = client.post("/login", data={"username": username, "password": password})
assert resp.status_code == 303, resp.text
def get_csrf_token(client: TestClient, page_url: str) -> str:
"""Scrapes the csrf_token hidden field out of a rendered page -- the
same value a real browser's form submit would carry, see auth.py's
_csrf_ok."""
import re
resp = client.get(page_url)
assert resp.status_code == 200, resp.text
m = re.search(r'name="csrf_token" value="([^"]+)"', resp.text)
assert m, f"no csrf_token found on {page_url}"
return m.group(1)
def csrf_headers(client: TestClient, page_url: str = "/settings") -> dict:
"""X-CSRF-Token header for JSON API POSTs (require_user_api's
_csrf_ok checks this header, not a form field -- see common.js'
fetchJson, which reads the same <meta name="csrf-token"> tag this
scrapes)."""
import re
resp = client.get(page_url)
assert resp.status_code == 200, resp.text
m = re.search(r'name="csrf-token" content="([^"]+)"', resp.text)
assert m, f"no csrf-token meta tag found on {page_url}"
return {"X-CSRF-Token": m.group(1)}