import { describe, test, expect } from "bun:test"; import { fetchAuthStatus, login, logout, register, validatePassword, validateUsername, type FetchLike, } from "../src/lib/authClient"; // The auth client is framework-free with an injectable fetch, so we can test // validation, the request contract, and the fail-silent /me behaviour without // a DOM or a running server (the M4 backend is exercised separately). function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } /** Mock fetch returning a fixed response and recording the call. */ function mockFetch(response: Response | Error): { fetch: FetchLike; calls: { input: string; init?: RequestInit }[]; } { const calls: { input: string; init?: RequestInit }[] = []; const fetch: FetchLike = (input, init) => { calls.push({ input, init }); return response instanceof Error ? Promise.reject(response) : Promise.resolve(response); }; return { fetch, calls }; } const USER = { id: 1, username: "alice" }; describe("validateUsername", () => { test("accepts the allowed charset within 3–32 chars", () => { expect(validateUsername("abc")).toBeNull(); // lower bound expect(validateUsername("a".repeat(32))).toBeNull(); // upper bound expect(validateUsername("User_1.name-x")).toBeNull(); }); test("rejects usernames that are too short or too long", () => { expect(validateUsername("")).not.toBeNull(); expect(validateUsername("ab")).not.toBeNull(); expect(validateUsername("a".repeat(33))).not.toBeNull(); }); test("rejects characters outside [a-zA-Z0-9_.-]", () => { expect(validateUsername("has space")).not.toBeNull(); expect(validateUsername("email@host")).not.toBeNull(); expect(validateUsername("émile")).not.toBeNull(); }); }); describe("validatePassword", () => { test("requires at least 8 characters", () => { expect(validatePassword("1234567")).not.toBeNull(); expect(validatePassword("12345678")).toBeNull(); }); }); describe("fetchAuthStatus (fail-silent /me)", () => { test("returns the user on 200", async () => { const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER })); expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: USER }); // The cookie is HttpOnly — it must ride along via same-origin credentials. expect(calls[0]?.input).toBe("api/auth/me"); expect(calls[0]?.init?.credentials).toBe("same-origin"); }); test("resolves to anonymous (null user) on 401", async () => { const { fetch } = mockFetch(jsonResponse(401, { error: "unauthorized" })); expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null }); }); test("resolves to disabled on 404 (AUTH_ENABLED=false, D-015)", async () => { const { fetch } = mockFetch(jsonResponse(404, { error: "Not Found" })); expect(await fetchAuthStatus(fetch)).toEqual({ enabled: false, user: null }); }); test("resolves to disabled on network failure (offline-first)", async () => { // /api/* is network-only in the service worker, so offline /me rejects. // That must NOT throw or surface an error — the auth UI just stays // hidden (signing in is impossible without the server anyway). const { fetch } = mockFetch(new TypeError("Failed to fetch")); expect(await fetchAuthStatus(fetch)).toEqual({ enabled: false, user: null }); }); test("resolves to anonymous on a malformed body", async () => { const { fetch } = mockFetch( new Response("proxy error", { status: 200 }), ); expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null }); }); }); describe("login", () => { test("POSTs JSON credentials and returns the user on 200", async () => { const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER })); expect(await login("alice", "hunter22", fetch)).toEqual(USER); const call = calls[0]; expect(call?.input).toBe("api/auth/login"); expect(call?.init?.method).toBe("POST"); expect(call?.init?.credentials).toBe("same-origin"); expect( (call?.init?.headers as Record)["Content-Type"], ).toBe("application/json"); expect(JSON.parse(call?.init?.body as string)).toEqual({ username: "alice", password: "hunter22", }); }); test("surfaces the server's error message on 401", async () => { const { fetch } = mockFetch( jsonResponse(401, { error: "invalid credentials" }), ); expect(login("alice", "wrong-pass", fetch)).rejects.toThrow( "invalid credentials", ); }); test("throws a UI-safe message on network failure", async () => { const { fetch } = mockFetch(new TypeError("Failed to fetch")); expect(login("alice", "hunter22", fetch)).rejects.toThrow( /could not reach the server/i, ); }); test("throws on an unexpected success body", async () => { const { fetch } = mockFetch(jsonResponse(200, { nope: true })); expect(login("alice", "hunter22", fetch)).rejects.toThrow( /unexpected response/i, ); }); }); describe("register", () => { test("returns the user on 201", async () => { const { fetch, calls } = mockFetch(jsonResponse(201, { user: USER })); expect(await register("alice", "hunter22", fetch)).toEqual(USER); expect(calls[0]?.input).toBe("api/auth/register"); }); test("surfaces {error} on 409 (username taken)", async () => { const { fetch } = mockFetch(jsonResponse(409, { error: "username taken" })); expect(register("alice", "hunter22", fetch)).rejects.toThrow( "username taken", ); }); test("surfaces {error} on 400 (server-side validation)", async () => { const { fetch } = mockFetch(jsonResponse(400, { error: "invalid input" })); expect(register("a", "short", fetch)).rejects.toThrow("invalid input"); }); test("falls back to a generic message when the error body is not JSON", async () => { const { fetch } = mockFetch(new Response("boom", { status: 500 })); expect(register("alice", "hunter22", fetch)).rejects.toThrow( "Request failed (500).", ); }); }); describe("logout", () => { test("POSTs to /api/auth/logout with credentials", async () => { const { fetch, calls } = mockFetch(new Response(null, { status: 204 })); await logout(fetch); expect(calls[0]?.input).toBe("api/auth/logout"); expect(calls[0]?.init?.method).toBe("POST"); expect(calls[0]?.init?.credentials).toBe("same-origin"); }); test("resolves even when the server is unreachable", async () => { const { fetch } = mockFetch(new TypeError("Failed to fetch")); await logout(fetch); // must not throw — local state is cleared regardless }); });