import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; /** Words that must never appear in PDA-friendly messages. */ const FORBIDDEN_WORDS = [ "overdue", "urgent", "must", "critical", "required", "error", "failed", "failure", ]; // Mock BetterAuth before importing the module under test vi.mock("better-auth/react", () => ({ createAuthClient: vi.fn(() => ({ signIn: vi.fn(), signOut: vi.fn(), useSession: vi.fn(), getSession: vi.fn(() => Promise.resolve({ data: null })), })), })); vi.mock("better-auth/client/plugins", () => ({ genericOAuthClient: vi.fn(() => ({})), })); vi.mock("./config", () => ({ API_BASE_URL: "http://localhost:3001", })); // Import after mocks are set up const { signInWithCredentials } = await import("./auth-client"); /** * Helper to build a mock Response object that behaves like the Fetch API Response. */ function mockResponse(options: { ok: boolean; status: number; body?: Record; }): Response { const { ok, status, body = {} } = options; return { ok, status, json: vi.fn(() => Promise.resolve(body)), headers: new Headers(), redirected: false, statusText: "", type: "basic" as ResponseType, url: "", clone: vi.fn(), body: null, bodyUsed: false, arrayBuffer: vi.fn(), blob: vi.fn(), formData: vi.fn(), text: vi.fn(), bytes: vi.fn(), } as unknown as Response; } describe("signInWithCredentials", (): void => { const originalFetch = global.fetch; beforeEach((): void => { global.fetch = vi.fn(); }); afterEach((): void => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it("should return data on successful response", async (): Promise => { const sessionData = { user: { id: "1", name: "Alice" }, token: "abc" }; vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: true, status: 200, body: sessionData }) ); const result = await signInWithCredentials("alice", "password123"); expect(result).toEqual(sessionData); expect(global.fetch).toHaveBeenCalledWith( "http://localhost:3001/auth/sign-in/credentials", expect.objectContaining({ method: "POST", credentials: "include", body: JSON.stringify({ username: "alice", password: "password123" }), }) ); }); it("should throw PDA-friendly message on 401 response", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 401, body: { message: "Unauthorized" } }) ); await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow( "The email and password combination wasn't recognized." ); }); it("should throw PDA-friendly message on 401 with no body message", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 401, body: {} }) ); // When there is no body message, the response object (status: 401) is used for parsing await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow( "The email and password combination wasn't recognized." ); }); it("should throw PDA-friendly message on 500 response", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 500, body: { message: "Internal Server Error" }, }) ); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The service is taking a break. Please try again in a moment." ); }); it("should throw PDA-friendly message on 500 with no body message", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 500, body: {} }) ); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The service is taking a break. Please try again in a moment." ); }); it("should throw PDA-friendly message on network error (fetch throws)", async (): Promise => { vi.mocked(global.fetch).mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(TypeError); }); it("should throw PDA-friendly message on 429 rate-limited response", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 429, body: { message: "Too many requests" }, }) ); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "You've tried a few times. Take a moment and try again shortly." ); }); it("should throw PDA-friendly message when response.json() throws", async (): Promise => { const resp = mockResponse({ ok: false, status: 403 }); vi.mocked(resp.json).mockRejectedValueOnce(new SyntaxError("Unexpected token")); vi.mocked(global.fetch).mockResolvedValueOnce(resp); // json().catch(() => ({})) returns {}, so no message -> falls back to response status await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The email and password combination wasn't recognized." ); }); }); describe("signInWithCredentials PDA-friendly language compliance", (): void => { const originalFetch = global.fetch; beforeEach((): void => { global.fetch = vi.fn(); }); afterEach((): void => { global.fetch = originalFetch; vi.restoreAllMocks(); }); const errorScenarios: Array<{ name: string; status: number; body: Record; }> = [ { name: "401 with message", status: 401, body: { message: "Unauthorized" } }, { name: "401 without message", status: 401, body: {} }, { name: "403 with message", status: 403, body: { message: "Forbidden" } }, { name: "429 with message", status: 429, body: { message: "Too many requests" } }, { name: "500 with message", status: 500, body: { message: "Internal Server Error" } }, { name: "500 without message", status: 500, body: {} }, { name: "502 without message", status: 502, body: {} }, { name: "503 without message", status: 503, body: {} }, { name: "400 unknown", status: 400, body: {} }, ]; for (const scenario of errorScenarios) { it(`should not contain forbidden words for ${scenario.name} response`, async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: scenario.status, body: scenario.body }) ); try { await signInWithCredentials("alice", "pass"); // Should not reach here expect.unreachable("signInWithCredentials should have thrown"); } catch (thrown: unknown) { expect(thrown).toBeInstanceOf(Error); const message = (thrown as Error).message.toLowerCase(); for (const forbidden of FORBIDDEN_WORDS) { expect(message).not.toContain(forbidden); } } }); } });