diff --git a/frontend/src/test/apolloAuth.test.ts b/frontend/src/test/apolloAuth.test.ts new file mode 100644 index 0000000..c6eeb96 --- /dev/null +++ b/frontend/src/test/apolloAuth.test.ts @@ -0,0 +1,148 @@ +import { gql } from "@apollo/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apolloClient } from "../lib/apolloClient"; +import { syncApolloAuth } from "../lib/syncApolloAuth"; +import { useAuthStore } from "../store/authStore"; + +const QUERY = gql` + query SessionGalleries { + myGalleries { + id + title + } + } +`; +const alice = { id: "alice", username: "Alice" }; +const bob = { id: "bob", username: "Bob" }; +const data = (title: string) => ({ + myGalleries: [{ __typename: "Gallery", id: "gallery", title }], +}); +const response = (title: string) => + new Response(JSON.stringify({ data: data(title) }), { + headers: { "Content-Type": "application/json" }, + }); + +describe("Apollo authentication and session cache", () => { + let unsubscribe: () => void; + let waitForSessionReset: () => Promise; + const fetchMock = vi.fn(); + + beforeEach(async () => { + useAuthStore.getState().logout(); + await apolloClient.clearStore(); + fetchMock.mockReset().mockResolvedValue(response("Current user")); + vi.stubGlobal("fetch", fetchMock); + const clearStore = vi.spyOn(apolloClient, "clearStore"); + let expectedClearStoreCalls = 0; + // Wait for the actual asynchronous reset triggered by the subscription. + waitForSessionReset = async () => { + expectedClearStoreCalls += 1; + expect(clearStore).toHaveBeenCalledTimes(expectedClearStoreCalls); + await clearStore.mock.results.at(-1)?.value; + }; + unsubscribe = syncApolloAuth(); + }); + + afterEach(async () => { + unsubscribe(); + useAuthStore.getState().logout(); + await apolloClient.clearStore(); + sessionStorage.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("sends the current bearer token and preserves other request headers", async () => { + useAuthStore.getState().setAuth(alice, "alice-token"); + await waitForSessionReset(); + await apolloClient.query({ + query: QUERY, + context: { headers: { "X-Test": "preserved", Authorization: "stale" } }, + }); + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toBe("Bearer alice-token"); + expect(headers.get("X-Test")).toBe("preserved"); + }); + + it("removes even explicitly supplied authorization headers when logged out", async () => { + await apolloClient.query({ + query: QUERY, + context: { headers: { Authorization: "Bearer stale-token" } }, + }); + expect( + new Headers(fetchMock.mock.calls[0][1]?.headers).has("Authorization"), + ).toBe(false); + }); + + it.each(["logout", "user", "token"])( + "clears cached data on %s changes and fetches fresh data", + async (change) => { + useAuthStore.getState().setAuth(alice, "alice-token"); + await waitForSessionReset(); + fetchMock.mockResolvedValueOnce(response("Alice private data")); + await apolloClient.query({ query: QUERY }); + await apolloClient.query({ query: QUERY }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + if (change === "logout") useAuthStore.getState().logout(); + if (change === "user") + useAuthStore.getState().setAuth(bob, "alice-token"); + if (change === "token") + useAuthStore.getState().setAuth(alice, "new-token"); + + await waitForSessionReset(); + expect(apolloClient.readQuery({ query: QUERY })).toBeNull(); + const result = await apolloClient.query({ query: QUERY }); + expect(result.data).toEqual(data("Current user")); + expect(fetchMock).toHaveBeenCalledTimes(2); + const expectedToken = + change === "logout" + ? null + : `Bearer ${change === "token" ? "new-token" : "alice-token"}`; + expect( + new Headers(fetchMock.mock.calls[1][1]?.headers).get("Authorization"), + ).toBe(expectedToken); + }, + ); + + it("keeps cached queries when only user profile information changes", async () => { + useAuthStore.getState().setAuth(alice, "alice-token"); + await waitForSessionReset(); + await apolloClient.query({ query: QUERY }); + useAuthStore + .getState() + .setAuth({ ...alice, username: "Updated name" }, "alice-token"); + await apolloClient.query({ query: QUERY }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(apolloClient.readQuery({ query: QUERY })).toEqual( + data("Current user"), + ); + }); + + it.each(["logout", "switch"])( + "rejects an old session response arriving after %s", + async (change) => { + useAuthStore.getState().setAuth(alice, "alice-token"); + await waitForSessionReset(); + let resolveRequest!: (response: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + const pending = apolloClient.query({ query: QUERY }); + const rejected = expect(pending).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + if (change === "logout") useAuthStore.getState().logout(); + else useAuthStore.getState().setAuth(bob, "bob-token"); + resolveRequest(response("Alice private data")); + await rejected; + await waitForSessionReset(); + expect(apolloClient.readQuery({ query: QUERY })).toBeNull(); + expect((await apolloClient.query({ query: QUERY })).data).toEqual( + data("Current user"), + ); + }, + ); +}); diff --git a/frontend/src/test/apolloMutations.test.tsx b/frontend/src/test/apolloMutations.test.tsx new file mode 100644 index 0000000..fb49204 --- /dev/null +++ b/frontend/src/test/apolloMutations.test.tsx @@ -0,0 +1,197 @@ +import type { ReactNode } from "react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { ApolloProvider } from "@apollo/client/react"; +import { gql } from "@apollo/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apolloClient } from "../lib/apolloClient"; +import { useAuthStore } from "../store/authStore"; +import { useMyGalleries } from "../hooks/gallery/useMyGallery"; +import { useGallery } from "../hooks/gallery/useGallery"; +import { useCreateGallery } from "../hooks/gallery/useCreateGallery"; +import { useAddArtworkToGallery } from "../hooks/gallery/useAddArtworkToGallery"; +import { useRemoveArtworkFromGallery } from "../hooks/gallery/useRemoveArtworkFromGallery"; +import { useRenameGalleryMutation } from "../hooks/gallery/useRenameGalleryMutation"; +import { useDeleteGallery } from "../hooks/gallery/useDeleteGallery"; + +const PAGE = gql` + query CachedGalleryPage($offset: Int!) { + gallery(id: "gallery", limit: 12, offset: $offset) { + id + title + totalCount + artworks { + id + title + artist_display + image_url + } + } + } +`; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("Apollo gallery mutation cache handling", () => { + let mutated: boolean; + let operations: string[]; + const gallery = () => ({ + __typename: "Gallery", + id: "gallery", + userId: "alice", + title: mutated ? "Updated" : "Original", + artworkIds: mutated ? [2] : [1], + }); + const page = () => ({ + __typename: "GalleryPage", + id: "gallery", + title: gallery().title, + totalCount: 1, + artworks: [ + { + __typename: "Artwork", + id: mutated ? 2 : 1, + title: mutated ? "New artwork" : "Old artwork", + artist_display: null, + image_url: null, + }, + ], + }); + + beforeEach(async () => { + await apolloClient.clearStore(); + useAuthStore + .getState() + .setAuth({ id: "alice", username: "Alice" }, "token"); + mutated = false; + operations = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_uri, options) => { + const { operationName } = JSON.parse(String(options?.body)) as { + operationName: string; + }; + operations.push(operationName); + let data; + if (operationName === "GetMyGalleries") + data = { + myGalleries: + mutated && operations.includes("DeleteGallery") + ? [] + : [gallery()], + }; + else if (operationName === "GetGallery") data = { gallery: page() }; + else { + mutated = true; + const fields: Record = { + CreateGallery: "createGallery", + AddArtworkToGallery: "addArtworkToGallery", + RemoveArtwork: "removeArtworkFromGallery", + RenameGallery: "renameGallery", + DeleteGallery: "deleteGallery", + }; + if (!fields[operationName]) + throw new Error(`Unexpected operation ${operationName}`); + data = { + [fields[operationName]]: + operationName === "DeleteGallery" ? "gallery" : gallery(), + }; + } + return new Response(JSON.stringify({ data }), { + headers: { "Content-Type": "application/json" }, + }); + }), + ); + }); + + afterEach(async () => { + cleanup(); + await apolloClient.clearStore(); + useAuthStore.getState().logout(); + sessionStorage.clear(); + vi.unstubAllGlobals(); + }); + + it.each(["create", "add", "remove", "rename", "delete"] as const)( + "refreshes or invalidates cached queries after %s", + async (mutation) => { + // Keep the list active so refetchQueries must refresh real cached results. + const list = renderHook(() => useMyGalleries(), { wrapper }); + await waitFor(() => + expect(list.result.current.galleries[0]?.title).toBe("Original"), + ); + + // Cache multiple inactive pages to catch stale data when navigating back. + for (const offset of [12, 24]) { + apolloClient.writeQuery({ + query: PAGE, + variables: { offset }, + data: { gallery: page() }, + }); + } + + const detail = + mutation !== "create" && mutation !== "delete" + ? renderHook(() => useGallery("gallery", 1), { wrapper }) + : null; + if (detail) + await waitFor(() => + expect(detail.result.current.gallery?.title).toBe("Original"), + ); + + const mutations = renderHook( + () => ({ + create: useCreateGallery(), + add: useAddArtworkToGallery(), + remove: useRemoveArtworkFromGallery(), + rename: useRenameGalleryMutation(), + delete: useDeleteGallery(), + }), + { wrapper }, + ); + + await act(async () => { + const hooks = mutations.result.current; + let result; + if (mutation === "create") + result = await hooks.create.createGallery("Updated"); + if (mutation === "add") + result = await hooks.add.addArtwork("gallery", 2); + if (mutation === "remove") + result = await hooks.remove.removeArtwork("gallery", 1); + if (mutation === "rename") + result = await hooks.rename.renameGallery("gallery", "Updated"); + if (mutation === "delete") + result = await hooks.delete.deleteGallery("gallery"); + expect(result).not.toBeNull(); + }); + + await waitFor(() => + expect(list.result.current.galleries).toEqual( + mutation === "delete" ? [] : [gallery()], + ), + ); + expect( + operations.filter((name) => name === "GetMyGalleries"), + ).toHaveLength(2); + if (detail) { + await waitFor(() => + expect(detail.result.current.gallery?.artworks[0]?.title).toBe( + "New artwork", + ), + ); + expect(operations.filter((name) => name === "GetGallery")).toHaveLength( + 2, + ); + } + if (mutation !== "create") { + for (const offset of [12, 24]) { + expect( + apolloClient.readQuery({ query: PAGE, variables: { offset } }), + ).toBeNull(); + } + } + }, + ); +});