Files
whisper_web/tests/auth-client.test.ts
T
Lucas WintherandClaude Fable 5 9f39a82982 WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper
- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-10 04:02:28 +02:00

179 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect } from "bun:test";
import {
fetchCurrentUser,
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("fetchCurrentUser (fail-silent /me)", () => {
test("returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await fetchCurrentUser(fetch)).toEqual(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) on 401", async () => {
const { fetch } = mockFetch(jsonResponse(401, { error: "unauthorized" }));
expect(await fetchCurrentUser(fetch)).toBeNull();
});
test("resolves to anonymous 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 — just stay anonymous.
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(await fetchCurrentUser(fetch)).toBeNull();
});
test("resolves to anonymous on a malformed body", async () => {
const { fetch } = mockFetch(
new Response("<html>proxy error</html>", { status: 200 }),
);
expect(await fetchCurrentUser(fetch)).toBeNull();
});
});
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<string, string>)["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
});
});