diff --git a/frontend/src/components/PlexPlaylistReorder.dom.test.tsx b/frontend/src/components/PlexPlaylistReorder.dom.test.tsx new file mode 100644 index 00000000..b085f2b6 --- /dev/null +++ b/frontend/src/components/PlexPlaylistReorder.dom.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +// +// One accepted write, one cache entry that matches it — the Plex half. +// +// `Playlists.tsx` (the YouTube list) got this rule in round 5: the detail cache is what a rollback +// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted +// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got +// the rollback half of that round's fix and not this half (round 6). +// +// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly — +// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture). +import { it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, screen, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const { dragEnd } = vi.hoisted(() => ({ dragEnd: { fire: (_e: unknown) => {} } })); +vi.mock("@dnd-kit/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DndContext: ({ + children, + onDragEnd, + }: { + children: React.ReactNode; + onDragEnd: (e: unknown) => void; + }) => { + dragEnd.fire = onDragEnd; + return <>{children}; + }, + }; +}); + +import PlexPlaylistView from "./PlexPlaylistView"; +import { api, type PlexCard } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const movie = (id: string) => + ({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard; + +it("writes the order the server accepted into the detail cache", async () => { + vi.spyOn(api, "plexPlaylist").mockResolvedValue({ + id: 5, + title: "Films", + items: [movie("1"), movie("2"), movie("3")], + }); + const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} onPlay={() => {}} /> + , + ); + await screen.findByText("Movie 1"); + act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } })); + await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"])); + // The list key was always invalidated; the DETAIL key — the one `putBack` reads and the one a + // re-entry renders from — kept the pre-reorder order, so leaving and coming back inside the + // staleTime showed the reorder undone, and the next drag persisted that. + await waitFor(() => + expect( + qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id), + ).toEqual(["2", "1", "3"]), + ); +}); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 9e3f33f3..a4cc0e58 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -427,7 +427,18 @@ export default function PlexPlaylistView({ playlistId, next.map((i) => i.id), ) - .then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() })) + .then(() => { + // Write the accepted order into the DETAIL cache, not just the list. That cache is what + // `putBack` rolls back to and what a re-entry inside the 30 s staleTime renders without + // refetching — so after an accepted reorder it showed the pre-reorder order again, and the + // next drag from there persisted it, silently undoing the one the server took. The + // YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round + // 6). One accepted write, one cache entry that matches it. + qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) => + old ? { ...old, items: next } : old, + ); + qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); + }) .catch((e: unknown) => { notifyActionError(e, t("plex.playlist.reorderFailed")); putBack();