fix(playlists): a racing move is not a 500; ownership through owned_or_404 everywhere
move_item checked "already in the target?" and then re-parented without a lock, so a second tab's add or the scheduler's YouTube merge committing the same video in between hit uq_playlist_video and answered a raw 500. The re-parent now runs in a savepoint and a conflict falls back to the already-present outcome. The target is touched only when the row landed in it (an untouched list was promoted in the rail's "recently changed" sort), and _touch stamps the database clock like the mixin's own onupdate. The canonical-patterns table names owned_or_404 against ad-hoc ownership helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view, _own_tag, notifications._owned, downloads._own_job), two of them hand-rolled with a bare English 404. All call sites now use owned_or_404, and the download/link/notification 404s are structured like the rest. The contract test reads every "what is gone" the routes name, and the YouTube refusal reasons, and requires both languages to name each.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
||||
@@ -86,6 +86,32 @@ const failDetails = [
|
||||
];
|
||||
const DETAIL = "downloads.errors.fail.catalog.";
|
||||
|
||||
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
|
||||
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
||||
const routeSources = (dir = "routes"): string[] =>
|
||||
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
|
||||
withFileTypes: true,
|
||||
}).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? routeSources(`${dir}/${e.name}`)
|
||||
: e.name.endsWith(".py")
|
||||
? [backend(`${dir}/${e.name}`)]
|
||||
: [],
|
||||
);
|
||||
const goneWhats = [
|
||||
...new Set(
|
||||
routeSources().flatMap((src) =>
|
||||
[...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!),
|
||||
),
|
||||
),
|
||||
];
|
||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
||||
const youtubeReasons = [
|
||||
...(
|
||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
].map((m) => m[1]!);
|
||||
|
||||
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
|
||||
const idsUnder = (keys: Set<string>, prefix: string) =>
|
||||
new Set(
|
||||
@@ -106,6 +132,8 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(quotaActions.length).toBeGreaterThan(0);
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
|
||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
||||
});
|
||||
@@ -159,6 +187,19 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => {
|
||||
expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]);
|
||||
expect(
|
||||
[...idsUnder(keys, "errors.notFound")].filter(
|
||||
(w) => w !== "generic" && !goneWhats.includes(w),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]);
|
||||
expect(
|
||||
[...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every admin refusal reason`, () => {
|
||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
"channel": "This channel isn't in the library any more.",
|
||||
"subscription": "You're not subscribed to this channel any more.",
|
||||
"recipient": "There's no user with that email address.",
|
||||
"job": "This job doesn't exist any more."
|
||||
"job": "This job doesn't exist any more.",
|
||||
"download": "This download doesn't exist any more.",
|
||||
"link": "This share link doesn't exist any more.",
|
||||
"notification": "This notification doesn't exist any more."
|
||||
},
|
||||
"admin": {
|
||||
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
"channel": "Ez a csatorna már nincs a könyvtárban.",
|
||||
"subscription": "Már nem vagy feliratkozva erre a csatornára.",
|
||||
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
|
||||
"job": "Ez a feladat már nem létezik."
|
||||
"job": "Ez a feladat már nem létezik.",
|
||||
"download": "Ez a letöltés már nem létezik.",
|
||||
"link": "Ez a megosztási link már nem létezik.",
|
||||
"notification": "Ez az értesítés már nem létezik."
|
||||
},
|
||||
"admin": {
|
||||
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
|
||||
|
||||
Reference in New Issue
Block a user