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:
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user