diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx index bffa6610..b57763ec 100644 --- a/frontend/src/components/PlexCollectionView.dom.test.tsx +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -38,7 +38,13 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ })); import PlexCollectionView from "./PlexCollectionView"; -import { api, type PlexCard, type PlexCollectionDetail, type PlexShowDetail } from "../lib/api"; +import { + api, + HttpError, + type PlexCard, + type PlexCollectionDetail, + type PlexShowDetail, +} from "../lib/api"; afterEach(() => { cleanup(); @@ -135,12 +141,24 @@ describe("PlexCollectionView — after the sort joined the query key", () => { it("flips a poster's watched state on the page it was clicked on", async () => { // The optimistic write kept the key's OLD spelling — an entry nothing reads — and the resync // only touches the library root, so the click did nothing visible until a reload. - vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + // The request never answers, so the resync never runs: only the optimistic write can flip it. + vi.spyOn(api, "plexSetState").mockReturnValue(new Promise(() => {})); mount([card("movie1", "movie")]); fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" })); expect(await screen.findByRole("button", { name: "plex.markUnwatched" })).toBeTruthy(); }); + it("puts a toggle that failed back the way it was", async () => { + // With the flip landing on the entry the page reads, the resync is what corrects it — and it only + // touched the library root, so a failed toggle stayed "watched" beside its own error toast. + vi.spyOn(api, "plexSetState").mockRejectedValue(new HttpError(422, "nope")); + mount([card("movie1", "movie")]); + fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" })); + await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2)); + expect(await screen.findByRole("button", { name: "plex.markWatched" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "plex.markUnwatched" })).toBeNull(); + }); + it("keeps the page, and the open Sort panel, on screen while the new order loads", async () => { // Every sort change was a key with no data yet: the whole page — the control and its open panel // included — was swapped for a spinner, so key and direction could not be set in one visit. diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index bb901c06..02743cdc 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -165,7 +165,14 @@ export default function PlexCollectionView({ : old, ); }, - resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), + // This page's OWN key too, not just the library's: the flip above lands on the entry the page + // reads, and resync is what corrects it to the truth when the toggle fails — invalidating only + // `plex-library` left a failed toggle flipped on screen. The prefix (no sort in it) also + // refreshes the other sort orders of this collection cached behind this one. + resync: () => { + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) }); + }, }); async function doRename() {