1
14import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15import { ApiError, authApi, isStaleRefreshError } from "@/lib/api";
16import { authStore } from "@/lib/auth-store";
17
18
19function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
20 let resolve!: (v: T) => void;
21 const promise = new Promise<T>((r) => (resolve = r));
22 return { promise, resolve };
23}
24
25function okBody(accessToken = "access-1"): Record<string, unknown> {
26 return { accessToken, refreshToken: "rft_new", expiresIn: 3600, session: { id: "s1" } };
27}
28function errBody(reason: string): Record<string, unknown> {
29 return { error: { code: "UNAUTHORIZED", message: "x", details: [{ reason }] } };
30}
31
32describe("single-flight refresh (authApi.refresh)", () => {
33 beforeEach(() => {
34 authStore.clear();
35 vi.stubGlobal("fetch", vi.fn());
36 });
37 afterEach(() => {
38 vi.unstubAllGlobals();
39 });
40
41 it("two concurrent bootstraps issue exactly ONE fetch and share the token", async () => {
42 const gate = deferred<void>();
43 const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
44 fetchMock.mockImplementation(
45 () =>
46 gate.promise.then(() => new Response(JSON.stringify(okBody("shared-access")), { status: 200 }))
47 );
48
49 const p1 = authApi.refresh();
50 const p2 = authApi.refresh();
51 expect(fetchMock).toHaveBeenCalledTimes(1);
52
53 gate.resolve();
54 const [t1, t2] = await Promise.all([p1, p2]);
55 expect(t1.accessToken).toBe("shared-access");
56 expect(t2.accessToken).toBe("shared-access");
57 expect(fetchMock).toHaveBeenCalledTimes(1);
58 expect(authStore.accessToken).toBe("shared-access");
59 });
60
61 it("reuses the in-flight promise for the SAME stale error (no extra fetch)", async () => {
62 const gate = deferred<void>();
63 const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
64 fetchMock.mockImplementation(
65 () =>
66 gate.promise.then(
67 () =>
68 new Response(JSON.stringify(errBody("refresh_token_stale")), {
69 status: 401,
70 headers: { "Content-Type": "application/json" }
71 })
72 )
73 );
74
75 const p1 = authApi.refresh();
76 const p2 = authApi.refresh();
77 gate.resolve();
78
79 await expect(p1).rejects.toBeInstanceOf(ApiError);
80 const err2 = await p2.catch((e: unknown) => e);
81 expect(err2).toBeInstanceOf(ApiError);
82 expect(isStaleRefreshError(err2)).toBe(true);
83 expect(fetchMock).toHaveBeenCalledTimes(1);
84 });
85
86 it("propagates a non-stale refresh failure as an ApiError (no null-swallow)", async () => {
87 (globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
88 new Response(JSON.stringify(errBody("refresh_token_reused")), {
89 status: 401,
90 headers: { "Content-Type": "application/json" }
91 })
92 );
93 const err = await authApi.refresh().catch((e: unknown) => e);
94 expect(err).toBeInstanceOf(ApiError);
95 expect(isStaleRefreshError(err)).toBe(false);
96 expect((err as ApiError).status).toBe(401);
97 });
98
99 it("resets the in-flight slot after settling (a later call fetches again)", async () => {
100 const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
101 fetchMock.mockResolvedValue(new Response(JSON.stringify(okBody("a")), { status: 200 }));
102 await authApi.refresh();
103 expect(fetchMock).toHaveBeenCalledTimes(1);
104
105 fetchMock.mockResolvedValue(new Response(JSON.stringify(okBody("b")), { status: 200 }));
106 const second = await authApi.refresh();
107 expect(second.accessToken).toBe("b");
108 expect(fetchMock).toHaveBeenCalledTimes(2);
109 });
110});
111
112describe("isStaleRefreshError", () => {
113 it("is true only for a 401 whose details carry reason=refresh_token_stale", () => {
114 expect(
115 isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x", [{ reason: "refresh_token_stale" }]))
116 ).toBe(true);
117 });
118 it("is false for a reused-token 401", () => {
119 expect(isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x", [{ reason: "refresh_token_reused" }]))).toBe(
120 false
121 );
122 });
123 it("is false for a non-ApiError and for a missing details array", () => {
124 expect(isStaleRefreshError(new Error("nope"))).toBe(false);
125 expect(isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x"))).toBe(false);
126 expect(isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x", "not-an-array"))).toBe(false);
127 });
128});
129