fix(i18n): hold every refusal reason by type, and refuse what the scan cannot read
Review round 1 of the refusal contract: it claimed more than it held. - The other reason carriers get their Literal too (BlockedUrl, ClipError, FolderError, the playlist and sign-in refusal helpers), so the type checker refuses a reason passed by variable or an unknown literal, and the contract reads one shape. - A literal reason in a detail dict built anywhere counts as a reason of its code; a code the scan cannot read, a refused() whose reason it cannot read, and a new reason passed by variable each fail the contract until listed with what holds them. - A sign-in refusal key the backend no longer sends is caught too. - localizeDetail's numbers (download cap in GB, upload cap in bytes) and the shared-sentence fallbacks are tested in both languages. - One Literal parser in the contract; a stale comment fixed.
This commit is contained in:
@@ -22,6 +22,7 @@ import json
|
|||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||||
from app.plex.sync import HDR_TRANSFERS
|
from app.plex.sync import HDR_TRANSFERS
|
||||||
@@ -57,11 +58,26 @@ _CROP_ASPECTS = (0.25, 4.0)
|
|||||||
_EDGE_SLACK = 1e-3
|
_EDGE_SLACK = 1e-3
|
||||||
|
|
||||||
|
|
||||||
|
# Every reason a clip is refused; the UI names each one (`clip.errors.<reason>`, held by the frontend's
|
||||||
|
# backendIds contract, which reads this Literal).
|
||||||
|
ClipReason = Literal[
|
||||||
|
"bad_source",
|
||||||
|
"bad_range",
|
||||||
|
"too_short",
|
||||||
|
"too_long",
|
||||||
|
"bad_audio",
|
||||||
|
"bad_crop",
|
||||||
|
"not_found",
|
||||||
|
"media_missing",
|
||||||
|
"past_end",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ClipError(Exception):
|
class ClipError(Exception):
|
||||||
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
||||||
message names (the cap itself, so the client never says a stale one)."""
|
message names (the cap itself, so the client never says a stale one)."""
|
||||||
|
|
||||||
def __init__(self, reason: str, **params: float):
|
def __init__(self, reason: ClipReason, **params: float):
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.params = params
|
self.params = params
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ kept it — and its `ready_at`, the moment it finished (`stamp_done`).
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -181,10 +182,20 @@ def stamp_done(db: Session, job: DownloadJob) -> None:
|
|||||||
job.expiry_warned = False
|
job.expiry_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
# Every folder change the rules refuse, answered as a generic refusal; the UI names each one
|
||||||
|
# (`errors.refused.<reason>`, held by the frontend's backendIds contract, which reads this Literal).
|
||||||
|
FolderReason = Literal[
|
||||||
|
"file_folder_gone",
|
||||||
|
"file_folder_name_taken",
|
||||||
|
"file_folder_not_its_source",
|
||||||
|
"file_folder_not_own",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class FolderError(Exception):
|
class FolderError(Exception):
|
||||||
"""A folder change the rules refuse; `reason` is the refusal the route answers with."""
|
"""A folder change the rules refuse; `reason` is the refusal the route answers with."""
|
||||||
|
|
||||||
def __init__(self, reason: str) -> None:
|
def __init__(self, reason: FolderReason) -> None:
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
|
||||||
|
|||||||
@@ -33,22 +33,31 @@ user-controlled destination to guard.
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
# vocabulary — keep them stable. The UI names each one (`downloads.errors.urlBlocked.<reason>`, held by
|
||||||
REASON_SCHEME = "scheme" # not http(s)
|
# the frontend's backendIds contract, which reads this Literal).
|
||||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
UrlBlockedReason = Literal["scheme", "no_host", "malformed", "unresolvable", "private"]
|
||||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
REASON_SCHEME: UrlBlockedReason = "scheme" # not http(s)
|
||||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
REASON_NO_HOST: UrlBlockedReason = "no_host" # URL carries no hostname
|
||||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
REASON_MALFORMED: UrlBlockedReason = (
|
||||||
|
"malformed" # unparseable authority or port; urlparse raises late
|
||||||
|
)
|
||||||
|
REASON_UNRESOLVABLE: UrlBlockedReason = (
|
||||||
|
"unresolvable" # DNS said nothing (not the same as "blocked")
|
||||||
|
)
|
||||||
|
REASON_PRIVATE: UrlBlockedReason = (
|
||||||
|
"private" # resolves into a network the server must not fetch from
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockedUrl(Exception):
|
class BlockedUrl(Exception):
|
||||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||||
REASON_* codes above."""
|
REASON_* codes above."""
|
||||||
|
|
||||||
def __init__(self, reason: str):
|
def __init__(self, reason: UrlBlockedReason):
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ A test (`test_http_details.py`) reads every `HTTPException` in the app and fails
|
|||||||
and the frontend's contract test (`i18n/backendIds.contract.test.ts`) requires every `code` a detail
|
and the frontend's contract test (`i18n/backendIds.contract.test.ts`) requires every `code` a detail
|
||||||
carries to be in one record of `lib/api/refusalCodes.ts`, and every reason it can be sent with — a
|
carries to be in one record of `lib/api/refusalCodes.ts`, and every reason it can be sent with — a
|
||||||
literal, or a `Literal` type when it travels in a variable — to have its sentence in both languages.
|
literal, or a `Literal` type when it travels in a variable — to have its sentence in both languages.
|
||||||
So the rule is enforced, not described.
|
A code or reason that scan cannot read fails it until it is listed with what holds it. So the rule is
|
||||||
|
enforced, not described.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|||||||
@@ -976,7 +976,16 @@ def _resend_verify_if_eligible(email: str) -> None:
|
|||||||
send_verification_link(db, user)
|
send_verification_link(db, user)
|
||||||
|
|
||||||
|
|
||||||
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
|
# Every refusal a password sign-in answers with; the UI names each one (`welcome.auth.blocked.<reason>`,
|
||||||
|
# held by the frontend's backendIds contract, which reads this Literal).
|
||||||
|
LoginRefusal = Literal[
|
||||||
|
"rate_limited", "invalid", "suspended", "email_unverified", "pending_approval"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _login_refused(
|
||||||
|
status: int, reason: LoginRefusal, user: User | None = None, **extra
|
||||||
|
) -> HTTPException:
|
||||||
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
||||||
shape so the SPA can say it in the user's own language (the old bare English strings were
|
shape so the SPA can say it in the user's own language (the old bare English strings were
|
||||||
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
|||||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -53,7 +53,22 @@ def _check_pull_cooldown(user: User) -> None:
|
|||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
|
|
||||||
|
|
||||||
def _refused(status: int, reason: str) -> HTTPException:
|
# Every playlist action the server refuses; the UI names each one (`playlists.errors.<reason>`, held by
|
||||||
|
# the frontend's backendIds contract, which reads this Literal).
|
||||||
|
PlaylistRefusal = Literal[
|
||||||
|
"name_required",
|
||||||
|
"not_on_youtube",
|
||||||
|
"quota_exceeded",
|
||||||
|
"sync_all_busy",
|
||||||
|
"sync_busy",
|
||||||
|
"watch_later_not_pushable",
|
||||||
|
"watch_later_undeletable",
|
||||||
|
"youtube_editing_off",
|
||||||
|
"youtube_read_scope",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _refused(status: int, reason: PlaylistRefusal) -> HTTPException:
|
||||||
"""A playlist action the server refuses, as a reason the UI translates
|
"""A playlist action the server refuses, as a reason the UI translates
|
||||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
||||||
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
|
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ const runEnum = (name: string) =>
|
|||||||
const runStatuses = runEnum("RunStatus");
|
const runStatuses = runEnum("RunStatus");
|
||||||
const runTriggers = runEnum("RunTrigger");
|
const runTriggers = runEnum("RunTrigger");
|
||||||
const quotaActions = enumValues("quota.py");
|
const quotaActions = enumValues("quota.py");
|
||||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are the refusal records
|
||||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
// under that prefix (lib/api/refusalCodes), so a code recorded there is scanned here too; a detail
|
||||||
// ruff wraps over several lines still matches.
|
// dict ruff wraps over several lines still matches.
|
||||||
const adminCodes = Object.entries(REFUSAL_FAMILIES)
|
const adminCodes = Object.entries(REFUSAL_FAMILIES)
|
||||||
.filter(([, f]) => f.prefix === "errors.admin")
|
.filter(([, f]) => f.prefix === "errors.admin")
|
||||||
.map(([code]) => code);
|
.map(([code]) => code);
|
||||||
@@ -172,29 +172,38 @@ const goneWhats = [
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
// A backend `Name = Literal["a", "b"]` alias, by name: the reasons a typed parameter accepts, which
|
||||||
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
|
// basedpyright then holds at every call site — a reason passed by variable included.
|
||||||
// `setting_refused` picks from at runtime, and every `FolderError` reason.
|
const literalOf = (file: string, name: string) =>
|
||||||
const appSources = (dir = ""): string[] =>
|
[
|
||||||
|
...(
|
||||||
|
backend(file).match(new RegExp(`^${name} = Literal\\[([^\\]]*)\\]`, "m"))?.[1] ?? ""
|
||||||
|
).matchAll(/"(\w+)"/g),
|
||||||
|
].map((m) => m[1]!);
|
||||||
|
// Every backend source, by its path under app/.
|
||||||
|
const appPaths = (dir = ""): string[] =>
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
}).flatMap((e) =>
|
}).flatMap((e) =>
|
||||||
e.isDirectory()
|
e.isDirectory()
|
||||||
? appSources(`${dir}${e.name}/`)
|
? e.name === "__pycache__"
|
||||||
|
? []
|
||||||
|
: appPaths(`${dir}${e.name}/`)
|
||||||
: e.name.endsWith(".py")
|
: e.name.endsWith(".py")
|
||||||
? [backend(`${dir}${e.name}`)]
|
? [`${dir}${e.name}`]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
const appSources = (): string[] => appPaths().map(backend);
|
||||||
|
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
||||||
|
// the backend (not `_refused`, a router's own family), the values of the settings map, which
|
||||||
|
// `setting_refused` picks from at runtime, and the folder rules' `FolderReason`, answered with
|
||||||
|
// `refused(409, e.reason)`.
|
||||||
const refusedReasons = [
|
const refusedReasons = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...appSources().flatMap((src) =>
|
...appSources().flatMap((src) =>
|
||||||
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
||||||
),
|
),
|
||||||
// A folder change the rules refuse travels as `FolderError("<reason>")` and is answered with
|
...literalOf("downloads/folders.py", "FolderReason"),
|
||||||
// `refused(409, e.reason)`, which the pattern above cannot read.
|
|
||||||
...appSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/FolderError\("(\w+)"\)/g)].map((m) => m[1]!),
|
|
||||||
),
|
|
||||||
...[
|
...[
|
||||||
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
||||||
/:\s*"(\w+)"/g,
|
/:\s*"(\w+)"/g,
|
||||||
@@ -202,61 +211,90 @@ const refusedReasons = [
|
|||||||
].map((m) => m[1]!),
|
].map((m) => m[1]!),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
// A `refused(…)` whose reason the scan above cannot read. Only two may: a folder rule's `e.reason`
|
||||||
const youtubeReasons = [
|
// (the nearest `except` above it catches `FolderError`, typed `FolderReason`) and the settings map.
|
||||||
...(
|
const opaqueRefused = appPaths().flatMap((f) => {
|
||||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
const src = backend(f);
|
||||||
).matchAll(/"(\w+)"/g),
|
return [...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*(?![\s"])([^,)]+)/g)]
|
||||||
].map((m) => m[1]!);
|
.filter((m) => {
|
||||||
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
|
const arg = m[1]!.trim();
|
||||||
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
|
if (f === "refusal.py" && arg.startsWith("_SETTING_REASONS[")) return false;
|
||||||
const clipReasons = [
|
const caught = [...src.slice(0, m.index).matchAll(/except ([^\n:]*):/g)].pop()?.[1] ?? "";
|
||||||
...new Set(
|
return !(arg === "e.reason" && /FolderError as e$/.test(caught));
|
||||||
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
|
})
|
||||||
),
|
.map((m) => `${f}: ${m[0]}`);
|
||||||
];
|
});
|
||||||
|
const youtubeReasons = literalOf("youtube/client.py", "YouTubeRefusal");
|
||||||
|
const clipReasons = literalOf("downloads/clip.py", "ClipReason");
|
||||||
|
|
||||||
// Every refusal code the backend answers: each `"code": "<x>"` of a detail anywhere in the app. A
|
// Every `"code": …` a backend dict carries, with the `"reason": …` that follows it. Two modules use a
|
||||||
// subtitle's status payload (`{"state": …, "code": …}`) is the player's own vocabulary, not a refusal.
|
// `code` key for something that is not a refusal; everything else is one.
|
||||||
const backendCodes = [
|
const NOT_REFUSALS: Record<string, string> = {
|
||||||
|
"plex/subtitles.py":
|
||||||
|
"the player's subtitle status vocabulary (`/status`), read by the player itself",
|
||||||
|
"plex/tmdb.py": "a production country's ISO code",
|
||||||
|
};
|
||||||
|
const detailSites = appPaths()
|
||||||
|
.filter((f) => !(f in NOT_REFUSALS))
|
||||||
|
.flatMap((f) =>
|
||||||
|
[
|
||||||
|
...backend(f).matchAll(
|
||||||
|
/"code":\s*(?:"(\w+)"|([^\s,}]+))(?:,\s*"reason":\s*(?:"(\w+)"|([^\s,}]+)))?/g,
|
||||||
|
),
|
||||||
|
].map((m) => ({ f, code: m[1], codeExpr: m[2], reason: m[3], reasonExpr: m[4] })),
|
||||||
|
);
|
||||||
|
const backendCodes = [...new Set(detailSites.flatMap((s) => (s.code ? [s.code] : [])))];
|
||||||
|
// A code the scan cannot read (`"code": SOME_CONSTANT`) is a refusal no record is checked for.
|
||||||
|
const opaqueCodes = detailSites.filter((s) => !s.code).map((s) => `${s.f}: "code": ${s.codeExpr}`);
|
||||||
|
// A reason passed by variable, each pinned to what holds it, so a new one has to say how it is held.
|
||||||
|
const OPAQUE_REASONS: Record<string, string> = {
|
||||||
|
"refusal.py: refused ← reason": "a literal at each refused() call, else opaqueRefused",
|
||||||
|
"routes/auth.py: login ← reason": "_login_refused's LoginRefusal",
|
||||||
|
"routes/downloads.py: url_blocked ← urlguard.REASON_MALFORMED": "UrlBlockedReason",
|
||||||
|
"routes/downloads.py: url_blocked ← e.reason": "BlockedUrl's UrlBlockedReason",
|
||||||
|
"routes/downloads.py: cookies ← e.reason": "InvalidCookies' CookieReason",
|
||||||
|
"routes/downloads.py: edit ← e.reason": "EditError's EditReason",
|
||||||
|
"routes/downloads.py: clip ← e.reason": "ClipError's ClipReason",
|
||||||
|
"routes/files.py: quota ← e.reason": "QuotaExceeded's QuotaReason",
|
||||||
|
"routes/playlists.py: playlist ← reason": "_refused's PlaylistRefusal",
|
||||||
|
"routes/playlists.py: playlist_move ← e.reason": "MoveRefused's MoveReason",
|
||||||
|
"routes/plex/playback.py: subtitle ← code": "REFUSAL_UNSHOWN: no screen reads it",
|
||||||
|
"routes/_common.py: not_found ← what": "opaqueWhats",
|
||||||
|
"routes/_common.py: youtube ← reason": "YouTubeRefusal",
|
||||||
|
};
|
||||||
|
const opaqueReasons = [
|
||||||
...new Set(
|
...new Set(
|
||||||
appSources().flatMap((src) =>
|
detailSites
|
||||||
src
|
.filter((s) => s.code && s.reasonExpr)
|
||||||
.split("\n")
|
.map((s) => `${s.f}: ${s.code} ← ${s.reasonExpr}`),
|
||||||
.filter((line) => !line.includes('"state":'))
|
|
||||||
.flatMap((line) => [...line.matchAll(/"code":\s*"(\w+)"/g)].map((m) => m[1]!)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
const literalOf = (file: string, name: string) =>
|
// A reason written as a literal in a detail dict, wherever that dict is built.
|
||||||
[
|
const literalReasons = (code: string) =>
|
||||||
...(
|
detailSites.filter((s) => s.code === code && s.reason).map((s) => s.reason!);
|
||||||
backend(file).match(new RegExp(`^${name} = Literal\\[([^\\]]*)\\]`, "m"))?.[1] ?? ""
|
// What each refusal code can carry as its reason: the Literal its typed parameter accepts, or the
|
||||||
).matchAll(/"(\w+)"/g),
|
// call's own literal — and every literal any detail dict anywhere gives it.
|
||||||
].map((m) => m[1]!);
|
const reasonsOf: Record<string, string[]> = Object.fromEntries(
|
||||||
const literalCalls = (file: string, re: RegExp) => [
|
Object.entries({
|
||||||
...new Set([...backend(file).matchAll(re)].map((m) => m[1]!)),
|
refused: refusedReasons,
|
||||||
];
|
youtube: youtubeReasons,
|
||||||
const detailReasons = (file: string, code: string) =>
|
not_found: goneWhats,
|
||||||
literalCalls(file, new RegExp(`"code":\\s*"${code}",\\s*"reason":\\s*"(\\w+)"`, "g"));
|
clip: clipReasons,
|
||||||
// What each refusal code can carry as its reason, read where it is decided: a Literal when the reason
|
...Object.fromEntries(adminCodes.map((c) => [c, []])),
|
||||||
// travels in a variable (`e.reason`), the call's own literal otherwise.
|
playlist: literalOf("routes/playlists.py", "PlaylistRefusal"),
|
||||||
const reasonsOf: Record<string, string[]> = {
|
playlist_move: literalOf("reader/service.py", "MoveReason"),
|
||||||
refused: refusedReasons,
|
edit: literalOf("downloads/service.py", "EditReason"),
|
||||||
youtube: youtubeReasons,
|
cookies: literalOf("downloads/credentials.py", "CookieReason"),
|
||||||
not_found: goneWhats,
|
url_blocked: literalOf("downloads/urlguard.py", "UrlBlockedReason"),
|
||||||
clip: clipReasons,
|
login: literalOf("routes/auth.py", "LoginRefusal"),
|
||||||
...Object.fromEntries(adminCodes.map((c) => [c, detailReasons("routes/admin.py", c)])),
|
quota: literalOf("downloads/quota.py", "QuotaReason"),
|
||||||
playlist: literalCalls("routes/playlists.py", /_refused\(\s*\d+,\s*"(\w+)"/g),
|
reset: [],
|
||||||
playlist_move: literalOf("reader/service.py", "MoveReason"),
|
collection: [],
|
||||||
edit: literalOf("downloads/service.py", "EditReason"),
|
} as Record<string, string[]>).map(([code, rs]) => [
|
||||||
cookies: literalOf("downloads/credentials.py", "CookieReason"),
|
code,
|
||||||
url_blocked: literalCalls("downloads/urlguard.py", /^REASON_\w+ = "(\w+)"/gm),
|
[...new Set([...rs, ...literalReasons(code)])],
|
||||||
login: literalCalls("routes/auth.py", /_login_refused\(\s*\d+,\s*"(\w+)"/g),
|
]),
|
||||||
quota: literalOf("downloads/quota.py", "QuotaReason"),
|
);
|
||||||
reset: detailReasons("routes/auth.py", "reset"),
|
|
||||||
collection: detailReasons("routes/plex/collections.py", "collection"),
|
|
||||||
};
|
|
||||||
const recordedCodes = [
|
const recordedCodes = [
|
||||||
...Object.keys(REFUSAL_FAMILIES),
|
...Object.keys(REFUSAL_FAMILIES),
|
||||||
...Object.keys(REFUSAL_BY_REASON),
|
...Object.keys(REFUSAL_BY_REASON),
|
||||||
@@ -359,6 +397,14 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
|
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("every refusal code and reason is one the scans can read, or says what holds it", () => {
|
||||||
|
// The modules set aside as not refusals still exist and still use the key.
|
||||||
|
for (const f of Object.keys(NOT_REFUSALS)) expect(backend(f), f).toContain('"code":');
|
||||||
|
expect(opaqueCodes).toEqual([]);
|
||||||
|
expect(opaqueRefused).toEqual([]);
|
||||||
|
expect(opaqueReasons.sort()).toEqual(Object.keys(OPAQUE_REASONS).sort());
|
||||||
|
});
|
||||||
|
|
||||||
it("read every refusal's reasons (a scan that matches nothing is green over nothing)", () => {
|
it("read every refusal's reasons (a scan that matches nothing is green over nothing)", () => {
|
||||||
const coded = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
|
const coded = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
|
||||||
expect(Object.keys(reasonsOf).sort()).toEqual(coded.sort());
|
expect(Object.keys(reasonsOf).sort()).toEqual(coded.sort());
|
||||||
@@ -542,16 +588,21 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
|
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
|
||||||
// A family whose prefix is its own (the shared `errors.admin`, and the sign-in screens, which
|
// A family whose prefix is its own (the shared `errors.admin` is checked below). A suffixed
|
||||||
// name more than the one-line refusal, are checked elsewhere or not at all).
|
// family shares its prefix with more than its refusals — the sign-in screens' `gate`,
|
||||||
|
// `addressLabel` — so only an id with the refusal's own key counts as a reason there.
|
||||||
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
|
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
|
||||||
if (f.prefix === "errors.admin" || "suffix" in f) continue;
|
if (f.prefix === "errors.admin") continue;
|
||||||
|
const suffix = "suffix" in f ? f.suffix : "";
|
||||||
const fallbackId = f.fallback.startsWith(`${f.prefix}.`)
|
const fallbackId = f.fallback.startsWith(`${f.prefix}.`)
|
||||||
? f.fallback.slice(f.prefix.length + 1)
|
? f.fallback.slice(f.prefix.length + 1)
|
||||||
: null;
|
: null;
|
||||||
expect(
|
expect(
|
||||||
[...idsUnder(keys, f.prefix)].filter(
|
[...idsUnder(keys, f.prefix)].filter(
|
||||||
(r) => r !== fallbackId && !reasonsOf[code]!.includes(r),
|
(r) =>
|
||||||
|
keys.has(`${f.prefix}.${r}${suffix}`) &&
|
||||||
|
r !== fallbackId &&
|
||||||
|
!reasonsOf[code]!.includes(r),
|
||||||
),
|
),
|
||||||
code,
|
code,
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// one case per record shape, so moving the dispatch into data changed what no reader sees.
|
// one case per record shape, so moving the dispatch into data changed what no reader sees.
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
|
import { formatBytes } from "../format";
|
||||||
import { localizeDetail } from "./refusalText";
|
import { localizeDetail } from "./refusalText";
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -45,6 +46,24 @@ describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("puts a download cap in GB and an upload cap in bytes, in the reader's number format", async () => {
|
||||||
|
expect(await say({ code: "quota", reason: "max_bytes", limit: 1.5 * 2 ** 30 })).toContain(
|
||||||
|
lng === "hu" ? "1,5" : "1.5",
|
||||||
|
);
|
||||||
|
const upload = await say({ code: "upload_too_large", limit: 5_242_880 });
|
||||||
|
expect(upload).toContain(formatBytes(5_242_880, lng));
|
||||||
|
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
|
||||||
|
expect(await say({ code: "quota", reason: "nope" })).toBe(
|
||||||
|
i18n.t("downloads.errors.quota.generic"),
|
||||||
|
);
|
||||||
|
expect(await say({ code: "collection", reason: "nope" })).toBe(
|
||||||
|
i18n.t("plex.collection.editFailed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
|
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
|
||||||
expect(await say({ code: "reset", reason: "invalid" })).toBe(
|
expect(await say({ code: "reset", reason: "invalid" })).toBe(
|
||||||
i18n.t("welcome.auth.deadLink.short"),
|
i18n.t("welcome.auth.deadLink.short"),
|
||||||
|
|||||||
Reference in New Issue
Block a user