-
Notifications
You must be signed in to change notification settings - Fork 467
feat(anonymous-sessions): add anonymous sessions support (EA) #2813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
|
|
||
| import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import type { AnonymousSession } from "../../types/index.js"; | ||
| import { useAnonymousSession } from "./use-anonymous-session.js"; | ||
|
|
||
| describe("M4 BLOCKER: FR-3 useAnonymousSession Hook REAL execution", () => { | ||
| const mockSession: AnonymousSession = { | ||
| id: "anon@uuid-1234", | ||
| accessToken: "bearer-token-xyz", | ||
| expiresAt: Math.floor(Date.now() / 1000) + 3600, | ||
| metadata: { cart: { qty: 5 } } | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| // Clear global fetch mock before each test | ||
| global.fetch = vi.fn(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| // Clean up React components and SWR cache after each test | ||
| cleanup(); | ||
| }); | ||
|
|
||
| describe("Hook with REAL SWR execution", () => { | ||
| it("M4: Hook returns 200 → {anonymous: session, isLoading: false}", async () => { | ||
| // Mock fetch to return 200 with session | ||
| global.fetch = vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => mockSession | ||
| }); | ||
|
|
||
| // Use unique route per test to avoid SWR cache collision | ||
| const { result } = renderHook(() => | ||
| useAnonymousSession({ route: "/test/anon-200" }) | ||
| ); | ||
|
|
||
| // Wait for SWR to fetch (SWR may skip loading state if data arrives fast) | ||
| await waitFor(() => { | ||
| expect(result.current.anonymous).toEqual(mockSession); | ||
| }); | ||
|
|
||
| // ASSERT: session loaded | ||
| expect(result.current.error).toBeNull(); | ||
| expect(global.fetch).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("M4: Hook returns 204 → {anonymous: null, isLoading: false}", async () => { | ||
| // Mock fetch to return 204 (no session) | ||
| global.fetch = vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| status: 204 | ||
| }); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useAnonymousSession({ route: "/test/anon-204" }) | ||
| ); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.isLoading).toBe(false); | ||
| }); | ||
|
|
||
| // ASSERT: no session (null) | ||
| expect(result.current.anonymous).toBeNull(); | ||
| expect(result.current.error).toBeNull(); | ||
| }); | ||
|
|
||
| it("M4: Hook fetch error → {error: Error, anonymous: null, isLoading: false}", async () => { | ||
| // Mock fetch to return error | ||
| global.fetch = vi.fn().mockResolvedValue({ | ||
| ok: false, | ||
| status: 500 | ||
| }); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useAnonymousSession({ route: "/test/anon-error" }) | ||
| ); | ||
|
|
||
| await waitFor( | ||
| () => { | ||
| expect(result.current.error).toBeTruthy(); | ||
| }, | ||
| { timeout: 3000 } | ||
| ); | ||
|
|
||
| // ASSERT: error state | ||
| expect(result.current.error).toBeInstanceOf(Error); | ||
| expect(result.current.anonymous).toBeNull(); | ||
| expect(result.current.isLoading).toBe(false); | ||
| }); | ||
|
|
||
| it("M4: isLoading false after data loads", async () => { | ||
| global.fetch = vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => mockSession | ||
| }); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useAnonymousSession({ route: "/test/anon-loading" }) | ||
| ); | ||
|
|
||
| // Wait for load to complete | ||
| await waitFor(() => { | ||
| expect(result.current.isLoading).toBe(false); | ||
| }); | ||
|
|
||
| expect(result.current.anonymous).toEqual(mockSession); | ||
| }); | ||
|
|
||
| it("M4: invalidate() triggers SWR revalidation", async () => { | ||
| let callCount = 0; | ||
| global.fetch = vi.fn().mockImplementation(async () => { | ||
| callCount++; | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => ({ | ||
| ...mockSession, | ||
| id: `anon@call-${callCount}` | ||
| }) | ||
| }; | ||
| }); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useAnonymousSession({ route: "/test/anon-invalidate" }) | ||
| ); | ||
|
|
||
| // Wait for initial fetch | ||
| await waitFor(() => { | ||
| expect(callCount).toBeGreaterThanOrEqual(1); | ||
| }); | ||
|
|
||
| const firstId = result.current.anonymous?.id; | ||
|
|
||
| // Call invalidate to trigger refetch | ||
| await act(async () => { | ||
| result.current.invalidate(); | ||
| }); | ||
|
|
||
| // Wait for ID to change (React render commit) | ||
| await waitFor(() => { | ||
| expect(result.current.anonymous?.id).not.toBe(firstId); | ||
| }); | ||
|
|
||
| // Verify refetch happened | ||
| expect(callCount).toBeGreaterThanOrEqual(2); | ||
| }); | ||
|
|
||
| it("M4: Hook uses custom route from options", async () => { | ||
| global.fetch = vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => mockSession | ||
| }); | ||
|
|
||
| renderHook(() => useAnonymousSession({ route: "/custom/anon-route" })); | ||
|
|
||
| await waitFor(() => { | ||
| expect(global.fetch).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| // Verify fetch was called with custom route | ||
| const fetchCall = (global.fetch as any).mock.calls[0]; | ||
| expect(fetchCall[0]).toContain("/custom/anon-route"); | ||
| }); | ||
|
|
||
| it("M4: Hook returns correct shape {anonymous, isLoading, error, invalidate}", async () => { | ||
| global.fetch = vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => mockSession | ||
| }); | ||
|
|
||
| const { result } = renderHook(() => useAnonymousSession()); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.isLoading).toBe(false); | ||
| }); | ||
|
|
||
| // ASSERT: shape matches contract | ||
| expect(result.current).toHaveProperty("anonymous"); | ||
| expect(result.current).toHaveProperty("isLoading"); | ||
| expect(result.current).toHaveProperty("error"); | ||
| expect(result.current).toHaveProperty("invalidate"); | ||
| expect(typeof result.current.invalidate).toBe("function"); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| "use client"; | ||
|
|
||
| import useSWR from "swr"; | ||
|
|
||
| import type { | ||
| AnonymousSession, | ||
| UseAnonymousSessionOptions | ||
| } from "../../types/index.js"; | ||
| import { normalizeWithBasePath } from "../../utils/pathUtils.js"; | ||
|
|
||
| /** | ||
| * Fetch the anonymous session from the read route. | ||
| * | ||
| * The route answers 204 with an empty body when there is no session, which maps | ||
| * to null, and 200 with the session object otherwise. Any other status is a | ||
| * failure the hook surfaces through `error`. | ||
| * | ||
| * The return type is declared rather than inferred so the session case is a | ||
| * concrete value rather than the `any` that `Response.json()` produces. That is | ||
| * what keeps `null` from being the only value a reader (human or static analysis) | ||
| * can see the fetcher resolve to. | ||
| */ | ||
| async function fetchAnonymousSession( | ||
| route: string | ||
| ): Promise<AnonymousSession | null> { | ||
| const res = await fetch(route); | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error("Failed to load anonymous session"); | ||
| } | ||
|
|
||
| // 204 No Content → null (no session) | ||
| if (res.status === 204) { | ||
| return null; | ||
| } | ||
|
|
||
| // 200 + JSON → return session object | ||
| return (await res.json()) as AnonymousSession; | ||
| } | ||
|
|
||
| /** | ||
| * Client hook: fetch and cache anonymous session via SWR. | ||
| * Mirrors the useUser() pattern. | ||
| * | ||
| * Returns: | ||
| * - anonymous: AnonymousSession | null (null if no session or fetch error) | ||
| * - isLoading: boolean (false when error or data loaded) | ||
| * - error: Error | null (populated on fetch error) | ||
| * - invalidate: () => void (trigger SWR revalidate) | ||
| * | ||
| * Uses SWR key: resolved route (option, env var, or default "/auth/anonymous-session") | ||
| * Fetcher: standard fetch, returns null on 204 status, throws on !ok | ||
| * | ||
| * Test: T7 (hook + provider) | ||
| */ | ||
| export function useAnonymousSession(options: UseAnonymousSessionOptions = {}): { | ||
| anonymous: AnonymousSession | null; | ||
| isLoading: boolean; | ||
| error: Error | null; | ||
| invalidate: () => void; | ||
| } { | ||
| // Resolve SWR key (route path, normalized with basePath) | ||
| const route = normalizeWithBasePath( | ||
| options.route || | ||
| process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE || | ||
| "/auth/anonymous-session" | ||
| ); | ||
|
|
||
| // Fetch via SWR with standard error handling | ||
| const { data, error, isLoading, mutate } = useSWR< | ||
| AnonymousSession | null, | ||
| Error, | ||
| string | ||
| >(route, fetchAnonymousSession); | ||
|
|
||
| // Return shape matching useUser() pattern | ||
| if (error) { | ||
| return { | ||
| anonymous: null, | ||
| isLoading: false, | ||
| error, | ||
| invalidate: () => mutate() | ||
| }; | ||
| } | ||
|
|
||
| // The fetcher resolves to null for a 204, so `data` carries three distinct | ||
| // states: undefined while the first request is in flight, null once the route | ||
| // has answered that there is no session, and the session object otherwise. A | ||
| // truthiness test cannot tell the first two apart, and it reads as a test that | ||
| // can never succeed to a reader that only sees the null-returning branch of the | ||
| // fetcher. Comparing against undefined names the state that is actually being | ||
| // asked about, and every branch below is reachable for some value of `data`. | ||
| const hasLoaded = data !== undefined; | ||
|
|
||
| return { | ||
| anonymous: data ?? null, | ||
| isLoading: hasLoaded ? false : isLoading, | ||
| error: null, | ||
| invalidate: () => mutate() | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Return
nullfor the disabled anonymous-session route.When anonymous sessions are disabled, the route returns 404 and read operations must return
null. Line 28 throws before the hook can map that state tonull. Consumers of the default-disabled feature receiveerrorinstead.Handle 404 with 204 before the
!res.okbranch. Add a hook test for the 404 response.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents