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.
75 lines
3.7 KiB
Python
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
|