Skip to content

Commit 64ae3d4

Browse files
Allow SWR mutation in useUser hook (#2045)
2 parents dbfd502 + e6967b5 commit 64ae3d4

12 files changed

+800
-117
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
3737
"@playwright/test": "^1.48.2",
3838
"@stylistic/eslint-plugin-ts": "^3.1.0",
39+
"@testing-library/react": "^16.3.0",
3940
"@types/node": "^22.8.6",
4041
"@types/react": "*",
4142
"@types/react-dom": "*",
@@ -45,6 +46,7 @@
4546
"eslint-plugin-prettier": "^5.2.3",
4647
"eslint-plugin-react": "^7.37.4",
4748
"globals": "^15.14.0",
49+
"jsdom": "^26.1.0",
4850
"next": "15.2.3",
4951
"prettier": "^3.3.3",
5052
"typedoc": "^0.27.7",

pnpm-lock.yaml

Lines changed: 402 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import React from "react";
6+
import { act, renderHook, waitFor } from "@testing-library/react";
7+
import * as swrModule from "swr";
8+
import {
9+
afterEach,
10+
beforeEach,
11+
describe,
12+
expect,
13+
it,
14+
vi,
15+
type MockInstance
16+
} from "vitest";
17+
18+
import type { User } from "../../types/index.js";
19+
import { useUser } from "./use-user.js";
20+
21+
// New test suite for integration testing with fetch and SWR cache
22+
describe("useUser Integration with SWR Cache", () => {
23+
const initialUser: User = {
24+
sub: "initial_user_123",
25+
name: "Initial User",
26+
email: "initial@example.com"
27+
};
28+
const updatedUser: User = {
29+
sub: "updated_user_456",
30+
name: "Updated User",
31+
email: "updated@example.com"
32+
};
33+
34+
// Explicitly type fetchSpy using MockInstance and the global fetch signature
35+
let fetchSpy: MockInstance<
36+
(
37+
input: RequestInfo | URL,
38+
init?: RequestInit | undefined
39+
) => Promise<Response>
40+
>;
41+
42+
beforeEach(() => {
43+
// Mock the global fetch
44+
fetchSpy = vi.spyOn(global, "fetch");
45+
});
46+
47+
afterEach(() => {
48+
vi.restoreAllMocks(); // Restore original fetch implementation
49+
});
50+
51+
it("should fetch initial user data and update after invalidate", async () => {
52+
// Mock fetch to return initial data first
53+
fetchSpy.mockResolvedValueOnce(
54+
new Response(JSON.stringify(initialUser), {
55+
status: 200,
56+
headers: { "Content-Type": "application/json" }
57+
})
58+
);
59+
60+
const wrapper = ({ children }: { children: React.ReactNode }) => (
61+
<swrModule.SWRConfig value={{ provider: () => new Map() }}>
62+
{children}
63+
</swrModule.SWRConfig>
64+
);
65+
66+
const { result } = renderHook(() => useUser(), { wrapper });
67+
68+
// Wait for the initial data to load
69+
await waitFor(() => expect(result.current.isLoading).toBe(false));
70+
71+
// Assert initial state
72+
expect(result.current.user).toEqual(initialUser);
73+
expect(result.current.error).toBe(null);
74+
75+
// Mock fetch to return updated data for the next call
76+
fetchSpy.mockResolvedValueOnce(
77+
new Response(JSON.stringify(updatedUser), {
78+
status: 200,
79+
headers: { "Content-Type": "application/json" }
80+
})
81+
);
82+
83+
// Call invalidate to trigger re-fetch
84+
await act(async () => {
85+
result.current.invalidate();
86+
});
87+
88+
// Wait for the hook to reflect the updated data
89+
await waitFor(() => expect(result.current.user).toEqual(updatedUser));
90+
91+
// Assert updated state
92+
expect(result.current.user).toEqual(updatedUser);
93+
expect(result.current.error).toBe(null);
94+
expect(result.current.isLoading).toBe(false);
95+
96+
// Verify fetch was called twice (initial load + invalidate)
97+
expect(fetchSpy).toHaveBeenCalledTimes(2);
98+
expect(fetchSpy).toHaveBeenCalledWith("/auth/profile");
99+
});
100+
101+
it("should handle fetch error during invalidation", async () => {
102+
// Mock fetch to return initial data first
103+
fetchSpy.mockResolvedValueOnce(
104+
new Response(JSON.stringify(initialUser), {
105+
status: 200,
106+
headers: { "Content-Type": "application/json" }
107+
})
108+
);
109+
110+
const wrapper = ({ children }: { children: React.ReactNode }) => (
111+
<swrModule.SWRConfig
112+
value={{
113+
provider: () => new Map(),
114+
shouldRetryOnError: false,
115+
dedupingInterval: 0
116+
}}
117+
>
118+
{children}
119+
</swrModule.SWRConfig>
120+
);
121+
122+
const { result } = renderHook(() => useUser(), { wrapper });
123+
124+
// Wait for the initial data to load
125+
await waitFor(() => expect(result.current.isLoading).toBe(false));
126+
expect(result.current.user).toEqual(initialUser);
127+
128+
// Mock fetch to return an error for the next call
129+
const fetchError = new Error("Network Error");
130+
fetchSpy.mockRejectedValueOnce(fetchError);
131+
132+
// Call invalidate to trigger re-fetch
133+
await act(async () => {
134+
result.current.invalidate();
135+
});
136+
137+
// Wait for the hook to reflect the error state, user should still be the initial one before error
138+
await waitFor(() => expect(result.current.error).not.toBeNull());
139+
140+
// Assert error state - SWR catches the rejection from fetch itself.
141+
// Check for the message of the error we explicitly rejected with.
142+
expect(result.current.user).toBeNull(); // Expect null now, not stale data
143+
expect(result.current.error?.message).toBe(fetchError.message); // Correct assertion
144+
expect(result.current.isLoading).toBe(false);
145+
146+
// Verify fetch was called twice
147+
expect(fetchSpy).toHaveBeenCalledTimes(2);
148+
});
149+
});

src/client/hooks/use-user.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,39 @@ import useSWR from "swr";
55
import type { User } from "../../types";
66

77
export function useUser() {
8-
const { data, error, isLoading } = useSWR<User, Error, string>(
8+
const { data, error, isLoading, mutate } = useSWR<User, Error, string>(
99
process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile",
1010
(...args) =>
1111
fetch(...args).then((res) => {
1212
if (!res.ok) {
1313
throw new Error("Unauthorized");
1414
}
15-
1615
return res.json();
1716
})
1817
);
1918

20-
// if we have the user loaded via the provider, return it
21-
if (data) {
19+
if (error) {
2220
return {
23-
user: data,
21+
user: null,
2422
isLoading: false,
25-
error: null
23+
error,
24+
invalidate: () => mutate()
2625
};
2726
}
2827

29-
if (error) {
28+
if (data) {
3029
return {
31-
user: null,
30+
user: data,
3231
isLoading: false,
33-
error
32+
error: null,
33+
invalidate: () => mutate()
3434
};
3535
}
3636

3737
return {
3838
user: data,
3939
isLoading,
40-
error
40+
error,
41+
invalidate: () => mutate()
4142
};
4243
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as swrModule from "swr";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import type { User } from "../../types/index.js";
5+
import { useUser } from "./use-user.js";
6+
7+
// Define mockMutate outside the mock factory so it can be referenced in tests
8+
const mockMutate = vi.fn();
9+
10+
// Mock the SWR module, preserving original exports like SWRConfig
11+
vi.mock("swr", async (importActual) => {
12+
const actual = await importActual<typeof swrModule>();
13+
return {
14+
...actual,
15+
default: vi.fn(() => ({
16+
// Mock the default export (useSWR hook)
17+
data: undefined,
18+
error: undefined,
19+
isLoading: true,
20+
isValidating: false,
21+
mutate: mockMutate
22+
}))
23+
};
24+
});
25+
26+
describe("useUser", () => {
27+
const mockUser: User = {
28+
sub: "user_123",
29+
name: "Test User",
30+
email: "test@example.com"
31+
};
32+
33+
beforeEach(() => {
34+
// Clear mocks before each test
35+
vi.clearAllMocks();
36+
mockMutate.mockClear();
37+
});
38+
39+
afterEach(() => {
40+
// restoreAllMocks handles spies and mocks
41+
vi.restoreAllMocks();
42+
});
43+
44+
it("should return isLoading when no data or error", () => {
45+
// Reset the global mock implementation for this specific test
46+
vi.mocked(swrModule.default).mockImplementation(() => ({
47+
data: undefined,
48+
error: undefined,
49+
isLoading: true,
50+
isValidating: false,
51+
mutate: mockMutate
52+
}));
53+
const result = useUser();
54+
55+
expect(result.isLoading).toBe(true);
56+
expect(result.user).toBe(undefined);
57+
expect(result.error).toBe(undefined);
58+
expect(typeof result.invalidate).toBe("function");
59+
});
60+
61+
it("should return user data when data is available", () => {
62+
// Mock SWR default export (useSWR hook) to return user data for this test
63+
vi.mocked(swrModule.default).mockImplementationOnce(() => ({
64+
data: mockUser,
65+
error: undefined,
66+
isLoading: false,
67+
isValidating: false,
68+
mutate: mockMutate
69+
}));
70+
71+
const result = useUser();
72+
73+
expect(result.isLoading).toBe(false);
74+
expect(result.user).toBe(mockUser);
75+
expect(result.error).toBe(null);
76+
expect(typeof result.invalidate).toBe("function");
77+
});
78+
79+
it("should return error when fetch fails", () => {
80+
const mockError = new Error("Unauthorized");
81+
// Mock SWR default export (useSWR hook) to return error for this test
82+
vi.mocked(swrModule.default).mockImplementationOnce(() => ({
83+
data: undefined,
84+
error: mockError,
85+
isLoading: false,
86+
isValidating: false,
87+
mutate: mockMutate
88+
}));
89+
90+
const result = useUser();
91+
92+
expect(result.isLoading).toBe(false);
93+
expect(result.user).toBe(null);
94+
expect(result.error).toBe(mockError);
95+
expect(typeof result.invalidate).toBe("function");
96+
});
97+
98+
it("should call mutate when invalidate is called", () => {
99+
// Mock SWR default export (useSWR hook) with mockMutate for invalidate testing
100+
vi.mocked(swrModule.default).mockImplementationOnce(() => ({
101+
data: mockUser,
102+
error: undefined,
103+
isLoading: false,
104+
isValidating: false,
105+
mutate: mockMutate
106+
}));
107+
108+
const result = useUser();
109+
110+
// Call invalidate function
111+
result.invalidate();
112+
113+
// Verify mutate was called
114+
expect(mockMutate).toHaveBeenCalledTimes(1);
115+
});
116+
});

0 commit comments

Comments
 (0)