The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library resume points) leave app/models.py for the unit's own module; app.tables registers every unit's tables for the migrations, the test database and the ownership check. No schema change.
149 lines
7.3 KiB
Python
149 lines
7.3 KiB
Python
"""A row that is gone answers with a reason the bilingual UI can translate.
|
|
|
|
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.
|
|
"""
|
|
|
|
from app.auth import require_write_scope
|
|
from app.downloads.models import DownloadJob, MediaAsset
|
|
|
|
|
|
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 …"`: 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_every_gone_channel_route_says_it_the_same_way(db, make_user, as_user, override_dep):
|
|
"""An earlier sweep converted ONE of the five `db.get(Channel, …) is None -> 404 "Unknown
|
|
channel"` sites (block), and shipped the HU/EN string with it — so the Hungarian UI said it in
|
|
Hungarian on the Block button and in English on the four beside it. The
|
|
asymmetric fix is the smell this repo bans, so all five are pinned together.
|
|
|
|
Literally all five: a review found this loop issuing three requests under a docstring claiming
|
|
five, with explore silently absent and subscribe excused as "covered by the tags route" — which
|
|
reaches `_user_subscription`, a different site answering `reason: subscription`. Reverting
|
|
either of those two to its bare English string left the whole lane green."""
|
|
admin = make_user(role="admin")
|
|
client = as_user(admin)
|
|
# Subscribe's own gate (the optional YouTube write scope) sits in FRONT of the lookup, so a
|
|
# plain account 403s before reaching it. The gate is not what is under test here.
|
|
override_dep(require_write_scope, lambda: admin)
|
|
gone = "UCgone000000000000000000"
|
|
for method, url in [
|
|
("get", f"/api/channels/{gone}"),
|
|
("post", f"/api/channels/{gone}/explore"),
|
|
("post", f"/api/channels/{gone}/reset-backfill"),
|
|
("post", f"/api/channels/{gone}/subscribe"),
|
|
("post", f"/api/channels/{gone}/block"),
|
|
]:
|
|
r = getattr(client, method)(url)
|
|
assert r.status_code == 404, (url, r.text)
|
|
assert r.json()["detail"] == {"code": "not_found", "reason": "channel"}, url
|
|
|
|
|
|
def test_a_gone_subscription_recipient_or_job_says_what_is_gone(db, make_user, as_user):
|
|
"""The three bare-English 404s the same sweep left behind, which `notifyActionError` printed
|
|
verbatim into an otherwise Hungarian UI: "Not subscribed to this channel", "No user with that
|
|
email.", "Unknown job".
|
|
|
|
All three, with a request each. A review found this test naming three and issuing two: the
|
|
recipient site had no test in the whole suite (nor did the `errors.notFound.recipient` keys
|
|
added beside it), and `run_job_now` rode on its PATCH sibling's coverage."""
|
|
admin = make_user(role="admin")
|
|
client = as_user(admin)
|
|
r = client.post("/api/channels/UCgone000000000000000000/tags", json={"tag_id": 1})
|
|
assert r.status_code == 404, r.text
|
|
assert r.json()["detail"] == {"code": "not_found", "reason": "subscription"}
|
|
for method, url in [
|
|
("patch", "/api/admin/scheduler/jobs/no_such_job"),
|
|
("post", "/api/admin/scheduler/jobs/no_such_job/run"),
|
|
]:
|
|
kw = {"json": {"interval_minutes": 30}} if method == "patch" else {}
|
|
r = getattr(client, method)(url, **kw)
|
|
assert r.status_code == 404, (url, r.text)
|
|
assert r.json()["detail"] == {"code": "not_found", "reason": "job"}, url
|
|
# Sharing needs a finished download of one's own to share; the recipient lookup is the next check.
|
|
asset = MediaAsset(
|
|
source_kind="url", source_ref="share-404", format_sig="sig", status="ready", ref_count=1
|
|
)
|
|
db.add(asset)
|
|
db.flush()
|
|
job = DownloadJob(
|
|
user_id=admin.id,
|
|
asset_id=asset.id,
|
|
source_kind="url",
|
|
source_ref="share-404",
|
|
status="done",
|
|
queue_pos=1,
|
|
profile_snapshot={},
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
r = client.post(f"/api/downloads/{job.id}/share", json={"email": "nobody@example.com"})
|
|
assert r.status_code == 404, r.text
|
|
assert r.json()["detail"] == {"code": "not_found", "reason": "recipient"}
|
|
|
|
|
|
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 (an
|
|
earlier fix 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."""
|
|
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
|