Skip to content

Commit 4248916

Browse files
committed
test(web): add unit tests for UserProvider component with PostHog integration
1 parent ecedeab commit 4248916

File tree

3 files changed

+249
-13
lines changed

3 files changed

+249
-13
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { usePostHog } from "posthog-js/react";
2+
import Session from "supertokens-auth-react/recipe/session";
3+
import "@testing-library/jest-dom";
4+
import { render, waitFor } from "@testing-library/react";
5+
import { UserProvider } from "./UserProvider";
6+
7+
// Mock PostHog
8+
jest.mock("posthog-js/react");
9+
const mockUsePostHog = usePostHog as jest.MockedFunction<typeof usePostHog>;
10+
11+
// Mock SuperTokens Session
12+
jest.mock("supertokens-auth-react/recipe/session");
13+
const mockSession = Session as jest.Mocked<typeof Session>;
14+
15+
// Mock AbsoluteOverflowLoader
16+
jest.mock("@web/components/AbsoluteOverflowLoader", () => ({
17+
AbsoluteOverflowLoader: () => <div>Loading...</div>,
18+
}));
19+
20+
describe("UserProvider", () => {
21+
const mockIdentify = jest.fn();
22+
const mockGetAccessTokenPayloadSecurely = jest.fn();
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
27+
// Default mock implementation
28+
mockSession.getAccessTokenPayloadSecurely =
29+
mockGetAccessTokenPayloadSecurely;
30+
});
31+
32+
describe("PostHog Integration", () => {
33+
it("should call posthog.identify when PostHog is enabled and user data is available", async () => {
34+
const testUserId = "test-user-123";
35+
const testEmail = "test@example.com";
36+
37+
// Mock session with userId and email
38+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
39+
sub: testUserId,
40+
email: testEmail,
41+
});
42+
43+
// Mock PostHog as enabled
44+
mockUsePostHog.mockReturnValue({
45+
identify: mockIdentify,
46+
} as any);
47+
48+
render(
49+
<UserProvider>
50+
<div>Test Child</div>
51+
</UserProvider>,
52+
);
53+
54+
// Wait for async data fetch and PostHog identify to be called
55+
await waitFor(() => {
56+
expect(mockIdentify).toHaveBeenCalledWith(testEmail, {
57+
email: testEmail,
58+
userId: testUserId,
59+
});
60+
});
61+
62+
// Verify it was called exactly once
63+
expect(mockIdentify).toHaveBeenCalledTimes(1);
64+
});
65+
66+
it("should NOT call posthog.identify when PostHog is disabled", async () => {
67+
const testUserId = "test-user-123";
68+
const testEmail = "test@example.com";
69+
70+
// Mock session with userId and email
71+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
72+
sub: testUserId,
73+
email: testEmail,
74+
});
75+
76+
// Mock PostHog as disabled (returns undefined/null)
77+
mockUsePostHog.mockReturnValue(undefined as any);
78+
79+
render(
80+
<UserProvider>
81+
<div>Test Child</div>
82+
</UserProvider>,
83+
);
84+
85+
// Wait a bit to ensure no identify call happens
86+
await waitFor(() => {
87+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
88+
});
89+
90+
// Verify identify was never called
91+
expect(mockIdentify).not.toHaveBeenCalled();
92+
});
93+
94+
it("should NOT call posthog.identify when email is missing from session", async () => {
95+
const testUserId = "test-user-123";
96+
97+
// Mock session with userId but NO email
98+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
99+
sub: testUserId,
100+
// email is missing
101+
});
102+
103+
// Mock PostHog as enabled
104+
mockUsePostHog.mockReturnValue({
105+
identify: mockIdentify,
106+
} as any);
107+
108+
render(
109+
<UserProvider>
110+
<div>Test Child</div>
111+
</UserProvider>,
112+
);
113+
114+
// Wait for data fetch
115+
await waitFor(() => {
116+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
117+
});
118+
119+
// Verify identify was not called because email is missing
120+
expect(mockIdentify).not.toHaveBeenCalled();
121+
});
122+
123+
it("should NOT call posthog.identify when userId is missing", async () => {
124+
const testEmail = "test@example.com";
125+
126+
// Mock session with email but NO userId (sub)
127+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
128+
email: testEmail,
129+
// sub is missing
130+
});
131+
132+
// Mock PostHog as enabled
133+
mockUsePostHog.mockReturnValue({
134+
identify: mockIdentify,
135+
} as any);
136+
137+
render(
138+
<UserProvider>
139+
<div>Test Child</div>
140+
</UserProvider>,
141+
);
142+
143+
// The component should show loading state because userId is null
144+
// and identify should never be called
145+
await waitFor(() => {
146+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
147+
});
148+
149+
expect(mockIdentify).not.toHaveBeenCalled();
150+
});
151+
152+
it("should handle posthog.identify not being a function gracefully", async () => {
153+
const testUserId = "test-user-123";
154+
const testEmail = "test@example.com";
155+
156+
// Mock session with userId and email
157+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
158+
sub: testUserId,
159+
email: testEmail,
160+
});
161+
162+
// Mock PostHog with identify not being a function
163+
mockUsePostHog.mockReturnValue({
164+
identify: null,
165+
} as any);
166+
167+
// Should not throw an error
168+
expect(() => {
169+
render(
170+
<UserProvider>
171+
<div>Test Child</div>
172+
</UserProvider>,
173+
);
174+
}).not.toThrow();
175+
176+
await waitFor(() => {
177+
expect(mockGetAccessTokenPayloadSecurely).toHaveBeenCalled();
178+
});
179+
180+
// Verify identify was not called
181+
expect(mockIdentify).not.toHaveBeenCalled();
182+
});
183+
184+
it("should render children after user data is loaded", async () => {
185+
const testUserId = "test-user-123";
186+
const testEmail = "test@example.com";
187+
188+
mockGetAccessTokenPayloadSecurely.mockResolvedValue({
189+
sub: testUserId,
190+
email: testEmail,
191+
});
192+
193+
mockUsePostHog.mockReturnValue({
194+
identify: mockIdentify,
195+
} as any);
196+
197+
const { getByText } = render(
198+
<UserProvider>
199+
<div>Test Child Content</div>
200+
</UserProvider>,
201+
);
202+
203+
// Initially should show loading
204+
expect(getByText("Loading...")).toBeInTheDocument();
205+
206+
// After data loads, should show children
207+
await waitFor(() => {
208+
expect(getByText("Test Child Content")).toBeInTheDocument();
209+
});
210+
});
211+
212+
it("should handle session fetch errors gracefully", async () => {
213+
const consoleErrorSpy = jest
214+
.spyOn(console, "error")
215+
.mockImplementation(() => {});
216+
217+
// Mock session to throw an error
218+
mockGetAccessTokenPayloadSecurely.mockRejectedValue(
219+
new Error("Session error"),
220+
);
221+
222+
mockUsePostHog.mockReturnValue({
223+
identify: mockIdentify,
224+
} as any);
225+
226+
render(
227+
<UserProvider>
228+
<div>Test Child</div>
229+
</UserProvider>,
230+
);
231+
232+
// Should log error
233+
await waitFor(() => {
234+
expect(consoleErrorSpy).toHaveBeenCalledWith(
235+
"Failed to get user because:",
236+
expect.any(Error),
237+
);
238+
});
239+
240+
// Should not call identify
241+
expect(mockIdentify).not.toHaveBeenCalled();
242+
243+
consoleErrorSpy.mockRestore();
244+
});
245+
});
246+
});
Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { usePostHog } from "posthog-js/react";
22
import {
33
ReactNode,
44
createContext,
5-
useContext,
65
useEffect,
76
useLayoutEffect,
87
useState,
@@ -38,8 +37,9 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
3837
}, []);
3938

4039
// Identify user in PostHog when userId and email are available
40+
// Only runs if PostHog is enabled (POSTHOG_HOST and POSTHOG_KEY are set)
4141
useEffect(() => {
42-
if (userId && email && posthog) {
42+
if (userId && email && posthog && typeof posthog.identify === "function") {
4343
posthog.identify(email, { email, userId });
4444
}
4545
}, [userId, email, posthog]);
@@ -54,13 +54,3 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
5454
</UserContext.Provider>
5555
);
5656
};
57-
58-
export const useUser = () => {
59-
const context = useContext(UserContext);
60-
61-
if (context === undefined) {
62-
throw new Error("useUser must be used within a UserProvider");
63-
}
64-
65-
return context;
66-
};

packages/web/src/routers/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { RouterProvider, createBrowserRouter } from "react-router-dom";
22
import { ProtectedRoute } from "@web/auth/ProtectedRoute";
3-
import { UserProvider } from "@web/auth/UserContext";
3+
import { UserProvider } from "@web/auth/UserProvider";
44
import { ROOT_ROUTES } from "@web/common/constants/routes";
55
import SocketProvider from "@web/socket/SocketProvider";
66
import { DayView } from "@web/views/Day/view/DayView";

0 commit comments

Comments
 (0)