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:
2026-10-31 01:00:26 +01:00
parent 4379f76747
commit ceadf69e43
8 changed files with 213 additions and 82 deletions
+17 -1
View File
@@ -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)
+12 -1
View File
@@ -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
+16 -7
View File
@@ -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
+2 -1
View File
@@ -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
+10 -1
View File
@@ -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).
+17 -2
View File
@@ -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})
+120 -69
View File
@@ -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"),