Files
siftlode/backend/tests/test_not_found_detail.py
T
peter 47af4d5c05 fix(plex): an HLS artifact names the session it belongs to, and a paused viewer says so
Deep-review round 5, over round 4's own fixes.

- Sessions are keyed by rating_key alone while a session's DIRECTORY is per (key, offset, tag), and
  the artifact URL carried no session identity — so once a seek-restart or a second viewer of the
  same item took the registry slot, the old viewer's request for `seg_12.ts` was answered 200 with
  the new session's twelfth segment: bytes from a different `-ss` offset under `-copyts`, on a
  timeline the client is not on. Wrong content with a success status is the one failure a player
  cannot notice, and round 4's reclaim-on-404 could never fire on it. The id is in the path now (so
  the playlist's relative names resolve back into the same session), a mismatch is a 404, and the
  player reclaims at the playhead. The entry playlist's auto-start is gone with it: it could only
  ever begin at 0, silently discarding the viewer's position.

- Round 4 exempted paused viewers from the reaper by keeping EVERY finished session for the full
  ten-minute idle window — one whole segmented copy of a film per item, on the volume the download
  center shares. The signal it needed already existed: an open player checkpoints its position every
  10 s whether or not it is playing, so that route now touches the session and the periodic pass is
  back on the short rule.

- POST /session's opportunistic reap was never scoped to the key being started: it walked the whole
  registry with the 30 s finished rule, so pressing play on ANY item — even a direct-play one, the
  call ran before that early return — killed another viewer's paused session. It is gone; the
  periodic pass owns reaping.

- A segment that can never arrive no longer polls out the full 20 s: the producer's handle is passed
  to `wait_for`, so a finished ffmpeg answers at once. This is a sync route, so each such wait parked
  a threadpool worker AND a pooled DB connection, and an ordinary seek past the produced region was
  enough to do it.

- The DataError handler's re-raise branch — the half that keeps a genuine DataError a 500 — is
  pinned, so simplifying it to an unconditional 422 cannot pass in silence.

Six regression tests (two route-level, in the DB lane), each mutation-checked red on the assertion
it is named after. pytest 1124, vitest 999.
2026-09-12 03:57:52 +02:00

75 lines
3.7 KiB
Python

"""A row that is gone answers with a reason the bilingual UI can translate (deep-review round 3).
The 404 a user actually meets is the one for something deleted in another tab or on another device.
`owned_or_404` / `get_or_404` sent it as a bare English string ("Unknown view", "Not found"), and the
Hungarian UI toasted it verbatim. The structured-404 fix had gone into one ad-hoc helper instead of
the shared one, so every other owner check kept the English. Needs the DB.
"""
def test_a_gone_view_tag_or_pin_says_what_is_gone(db, make_user, as_user):
client = as_user(make_user())
for method, url, what in [
("patch", "/api/saved-views/999999", "view"),
("delete", "/api/saved-views/999999", "view"),
("delete", "/api/tags/999999", "tag"),
("delete", "/api/discover/pins/999999", "pin"),
]:
r = getattr(client, method)(url, **({"json": {"name": "x"}} if method == "patch" else {}))
assert r.status_code == 404, (url, r.text)
assert r.json()["detail"] == {"code": "not_found", "reason": what}, url
def test_a_gone_channel_video_or_user_says_what_is_gone(db, make_user, as_user):
"""The siblings that still hand-wrote `db.get(...) is None → 404 "Unknown …"` (deep-review
round 4): the same gone row, but the Hungarian UI showed the English."""
admin = make_user(role="admin")
client = as_user(admin)
for method, url, body, what in [
("post", "/api/channels/UCgone000000000000000000/block", None, "channel"),
("post", "/api/videos/gone0000000/state", {"status": "watched"}, "video"),
(
"post",
"/api/videos/gone0000000/progress",
{"position_seconds": 60, "duration_seconds": 600},
"video",
),
("put", "/api/admin/downloads/quota/999999", {"max_bytes": 1}, "user"),
]:
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
assert r.status_code == 404, (url, r.text)
assert r.json()["detail"] == {"code": "not_found", "reason": what}, url
def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user):
"""Postgres cannot hold NUL in text, so the driver refuses the query outright: a DataError, i.e.
a 500 with a traceback, wherever a `%00` or an escaped NUL reached a comparison (deep-review
round 4 — round 3 had guarded only the by-rating_key lookup). One app-wide handler now answers
all of them."""
client = as_user(make_user())
r = client.get("/api/plex/playlists?contains=%00")
assert r.status_code == 422, r.text
assert r.json()["detail"] == {"code": "invalid_input", "reason": "nul"}
assert client.get("/api/plex/playlists?contains_group=1,%00").status_code == 422
def test_any_other_data_error_is_still_a_500():
"""The other half of the app-wide handler, and the half a refactor can delete in silence: it is
registered for EVERY DataError on every route and decides on a substring of the driver's message,
so only a NUL may become a 422. Without this, simplifying it to an unconditional 422 — it reads
like dead defensive code — would answer a numeric overflow or an over-long ingested title with
"your input was bad", losing the traceback that names the real fault (deep-review round 5)."""
import asyncio
import pytest
from sqlalchemy.exc import DataError
from app.main import _nul_in_text
overflow = DataError("SELECT 1", {}, Exception("numeric field overflow"))
with pytest.raises(DataError):
asyncio.run(_nul_in_text(None, overflow)) # re-raised: it stays the 500 it always was
nul = DataError("SELECT 1", {}, Exception("invalid byte sequence: NUL (0x00)"))
assert asyncio.run(_nul_in_text(None, nul)).status_code == 422