fix(api): a gone row and a refused playlist action are said in the reader's language, and a failure is reported once

Deep-review round 3 of bug hunt 2026-09.

- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
  (errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
  ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
  views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
  goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
  exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
  instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
  notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
  and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
  status filters now go through the same check.
This commit is contained in:
2026-09-11 18:05:19 +02:00
parent 33fe895539
commit 1cfe6a7f59
22 changed files with 358 additions and 94 deletions
+8 -12
View File
@@ -40,7 +40,7 @@ import {
X,
Youtube,
} from "lucide-react";
import { api, HttpError, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -576,12 +576,10 @@ export default function Playlists() {
refreshAll(listId);
return true;
} catch (e) {
// `req` raises the global dialog for 4xx-validation and 5xx, but leaves 403/404 to the caller
// (the list was deleted or de-owned meanwhile) — so say it here, as moveItemTo does, or a
// confirmed removal fails in silence (inside the full-screen player there is not even a row
// snapping back to show it).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notifyActionError(e, t("playlists.removeFailed"));
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
// full-screen player there is not even a row snapping back to show it) — unless `req` already
// said it (the error dialog, the connection notice), which `notifyActionError` checks.
notifyActionError(e, t("playlists.removeFailed"));
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
return false;
}
@@ -613,11 +611,9 @@ export default function Playlists() {
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
// impossible — the submenu never offers the current list). So the row used to snap back with no
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notifyActionError(e, t("playlists.moveFailed"));
// A fault `req` already reported (the error dialog, the connection notice) is not toasted
// again — `notifyActionError` checks (E10 review round 2 found it reported twice).
notifyActionError(e, t("playlists.moveFailed"));
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
//
// A failed playlist delete is reported once (deep-review round 3 of bug hunt 2026-09). `req` already
// opens the blocking error dialog for a 422 (a YouTube-side delete failure) and a 5xx; the delete
// then toasted the same failure as well — in English, before the router's errors were structured.
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const h = vi.hoisted(() => ({ notify: vi.fn(), reportError: vi.fn() }));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("../lib/errorDialog", () => ({ reportError: (...a: unknown[]) => h.reportError(...a) }));
vi.mock("./ConfirmProvider", () => ({ useChoice: () => async () => "both" }));
vi.mock("./PlaylistsProvider", () => ({
usePlaylists: () => ({ selectedId: null }),
usePlaylistsActions: () => ({ setSelectedId: () => {} }),
}));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
import { usePlaylistActions } from "./usePlaylistActions";
import { HttpError, api } from "../lib/api";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
h.notify.mockReset();
h.reportError.mockReset();
});
function mount() {
const qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(() => usePlaylistActions(), { wrapper });
}
const linked = { id: 4, name: "Mix", kind: "user", yt_playlist_id: "PL1" } as const;
describe("usePlaylistActions — deleting a list", () => {
it("does not toast a failure the request layer already put on screen", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "playlist", reason: "youtube_failed" } }), {
status: 422,
headers: { "content-type": "application/json" },
}),
);
const { result } = mount();
await act(() => result.current.remove(linked));
expect(h.reportError).toHaveBeenCalledTimes(1);
expect(h.notify).not.toHaveBeenCalled();
});
it("does say one it was left to handle", async () => {
vi.spyOn(api, "deletePlaylist").mockRejectedValue(new HttpError(404, "gone"));
const { result } = mount();
await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "gone" }));
});
});
+10 -8
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { qk } from "../lib/queryKeys";
import { api, HttpError, type Playlist } from "../lib/api";
import { alreadyReported, api, HttpError, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
@@ -70,13 +70,15 @@ export function usePlaylistActions() {
await api.deletePlaylist(pl.id, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side — unless
// `req` already put it on screen (the error dialog, the connection notice): not twice.
if (!alreadyReported(err))
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(pl.id) });
qc.invalidateQueries({ queryKey: qk.playlists() });