Files
siftlode/backend/tests/test_request_models.py
T
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
2026-10-06 23:55:41 +02:00

71 lines
2.6 KiB
Python

"""The request models turn payloads that used to 500 into clean validation errors,
and PATCH models distinguish an absent field from an explicit null. Pure-logic (no DB) — it
exercises the Pydantic models the route modules define, not the endpoints."""
import pytest
from pydantic import ValidationError
from app.routes.channels import AttachTagIn, ChannelUpdateIn
from app.routes.downloads import AdminSetQuotaIn
from app.routes._common import ProgressIn
from app.routes.setup import SetupAdminIn
from app.routes.tags import TagUpdateIn
# --- The three ROADMAP-named 500-producers are now 422s (ValidationError at the boundary). ---
def test_channel_priority_rejects_non_numeric():
# Was `int(payload["priority"])` → 500 on garbage. Now a validation error.
with pytest.raises(ValidationError):
ChannelUpdateIn(priority="not-a-number")
def test_channel_priority_accepts_int_and_numeric_string():
assert ChannelUpdateIn(priority=5).priority == 5
assert ChannelUpdateIn(priority="5").priority == 5 # lax numeric-string coercion, as before
def test_channel_update_fields_all_optional():
m = ChannelUpdateIn()
assert m.priority is None and m.hidden is None and m.deep_requested is None
def test_attach_tag_rejects_non_int_id():
# Was a str tag_id into db.get(Tag, ...) — a DB cast 500. Now rejected up front.
with pytest.raises(ValidationError):
AttachTagIn(tag_id="abc")
def test_setup_admin_password_must_be_a_string():
# Was a non-string password reaching len()/argon2 → 500. `str` rejects it.
with pytest.raises(ValidationError):
SetupAdminIn(email="a@b.c", password=12345)
def test_admin_quota_rejects_non_numeric_limit():
# Was `int(payload["max_bytes"])` → 500. Now a validation error.
with pytest.raises(ValidationError):
AdminSetQuotaIn(max_bytes="lots")
def test_progress_accepts_fractional_seconds():
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
# handler) — a plain `int` field would 422 on the progress hot path.
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
with pytest.raises(ValidationError):
ProgressIn(position_seconds="not-a-number")
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
def test_tag_update_absent_vs_null_color():
absent = TagUpdateIn()
assert "color" not in absent.model_fields_set # untouched on PATCH
cleared = TagUpdateIn(color=None)
assert "color" in cleared.model_fields_set # an explicit null CLEARS it
assert cleared.color is None