Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions frontend/src/test/apolloAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
const fetchMock = vi.fn<typeof fetch>();

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"),
);
},
);
});
197 changes: 197 additions & 0 deletions frontend/src/test/apolloMutations.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <ApolloProvider client={apolloClient}>{children}</ApolloProvider>;
}

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<typeof fetch>(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<string, string> = {
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();
}
}
},
);
});