Add a real pytest suite, gating the CI build
Build and push server image / test (push) Successful in 1m18s
Build and push server image / build-and-push (push) Successful in 2m12s

64 tests covering: auth/setup and the CSRF gate, the "owner adds their
own data, anyone linked can mute it" permission pattern shared across
calendar-select/tasks-source/whiteboard-source, migration correctness
(fresh install, idempotent re-run, expected columns), battery estimate
outlier rejection, calendar_feed's fetch/merge/partial-failure handling,
webdav_client's fetch/list-directory, the whiteboard force-refresh
throttle bypass and browse endpoint, and render-size invariants across
calendar views/orientations.

No DB/HTTP fixtures need Docker, Node, or a real Immich/CalDAV/WebDAV
server -- a fresh temp SQLite file plus a couple of small local HTTP
servers as test doubles cover it all. Table data is wiped and reseeded
between tests rather than relying on SQLAlchemy's transaction-rollback
isolation pattern, which needs a pysqlite event-listener workaround
app/db.py's engine doesn't have and has no reason to gain just for tests.

Wired into .gitea/workflows/server-docker-build.yml as its own job that
build-and-push now depends on, so a failing suite blocks the image push
rather than just running alongside it for show.
This commit is contained in:
2026-07-23 18:51:54 -04:00
parent b4ca795003
commit 31adc34a19
15 changed files with 1110 additions and 0 deletions
@@ -0,0 +1,150 @@
"""get_or_refresh_whiteboard's fetch throttle (and force=True bypassing
it) plus the whiteboard-browse HTTP endpoint. whiteboard.fetch_and_render
is monkeypatched -- it talks to a real WebDAV server and the Node render
sidecar (see render-service/), neither of which this suite needs a real
copy of to verify the *throttle*/*permission* logic around it."""
from __future__ import annotations
import time
from app import whiteboard
from app.models import Frame, User
from app.routers.common import get_or_refresh_whiteboard
from .conftest import csrf_headers, link_user, login, make_user
def _configure_whiteboard(db_session, frame: Frame, user: User) -> None:
frame.whiteboard_user_id = user.id
frame.whiteboard_url = "http://example.invalid/board.whiteboard"
frame.whiteboard_cached_image = b"OLD_CACHED_PNG"
frame.whiteboard_checked_at = time.time() # just refreshed -- well within the throttle
db_session.commit()
def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
alice = db_session.query(User).filter_by(username="alice").one()
alice.webdav_username = "alice"
alice.webdav_password = "secret"
frame = db_session.get(Frame, 1)
_configure_whiteboard(db_session, frame, alice)
calls = []
monkeypatch.setattr(whiteboard, "fetch_and_render",
lambda url, u, p: calls.append(1) or b"NEW_PNG")
result = get_or_refresh_whiteboard(db_session, frame)
assert result == b"OLD_CACHED_PNG"
assert calls == []
def test_force_bypasses_throttle_and_persists(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
alice = db_session.query(User).filter_by(username="alice").one()
alice.webdav_username = "alice"
alice.webdav_password = "secret"
frame = db_session.get(Frame, 1)
_configure_whiteboard(db_session, frame, alice)
calls = []
monkeypatch.setattr(whiteboard, "fetch_and_render",
lambda url, u, p: calls.append(1) or b"NEW_PNG")
result = get_or_refresh_whiteboard(db_session, frame, force=True)
assert result == b"NEW_PNG"
assert len(calls) == 1
db_session.refresh(frame)
assert frame.whiteboard_cached_image == b"NEW_PNG"
def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
alice = db_session.query(User).filter_by(username="alice").one()
alice.webdav_username = "alice"
alice.webdav_password = "secret"
frame = db_session.get(Frame, 1)
# The preview endpoint runs whatever get_or_refresh_whiteboard returns
# through PIL (Image.open) -- unlike the other tests in this file,
# the placeholder "cached" bytes need to be real, valid PNG data, not
# just an arbitrary marker string.
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (1, 1), (255, 255, 255)).save(buf, format="PNG")
tiny_png = buf.getvalue()
frame.whiteboard_user_id = alice.id
frame.whiteboard_url = "http://example.invalid/board.whiteboard"
frame.whiteboard_cached_image = tiny_png
frame.whiteboard_checked_at = time.time()
db_session.commit()
calls = []
monkeypatch.setattr(whiteboard, "fetch_and_render",
lambda url, u, p: calls.append(1) or tiny_png)
resp = client.get("/api/frames/1/preview/whiteboard")
assert resp.status_code == 200
assert calls == [] # fresh cache, no force -- no refetch
resp = client.get("/api/frames/1/preview/whiteboard?force=1")
assert resp.status_code == 200
assert len(calls) == 1 # force=1 -- refetched despite fresh cache
# --- whiteboard-browse ---
def test_browse_requires_webdav_creds(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/whiteboard-browse")
assert resp.status_code == 400
assert "credentials" in resp.json()["detail"].lower()
def test_browse_requires_a_base_url_when_none_supplied(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
alice = db_session.query(User).filter_by(username="alice").one()
alice.webdav_username = "alice"
alice.webdav_password = "secret"
db_session.commit()
resp = client.get("/api/frames/1/whiteboard-browse")
assert resp.status_code == 400
assert "browse root" in resp.json()["detail"].lower()
def test_browse_uses_the_caller_own_creds_not_the_frames_owner(client, db_session, monkeypatch):
"""bob, linked but not the frame's whiteboard owner, should still be
able to browse HIS OWN webdav account to pick a file for himself --
browsing is a "help me find a file" lookup against the caller's own
credentials, unrelated to whose account the frame is currently
pointed at."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
bob.webdav_username = "bob"
bob.webdav_password = "bobsecret"
bob.webdav_base_url = "https://cloud.example.com/dav/files/bob/"
db_session.commit()
seen_creds = []
def fake_list_directory(url, username, password):
seen_creds.append((url, username, password))
return [{"name": "Board.whiteboard", "url": url + "Board.whiteboard", "is_dir": False}]
from app import webdav_client
monkeypatch.setattr(webdav_client, "list_directory", fake_list_directory)
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/whiteboard-browse")
assert resp.status_code == 200, resp.text
assert seen_creds == [("https://cloud.example.com/dav/files/bob/", "bob", "bobsecret")]
assert resp.json()["entries"][0]["name"] == "Board.whiteboard"