import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; // Mock the Tauri HTTP plugin — vi.hoisted ensures the fn is available when // vi.mock's factory runs (hoisted above all imports). const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn(), })); vi.mock("@tauri-apps/plugin-http", () => ({ fetch: mockFetch, })); // Mock the Rust HTTP TOFU proxy so ensureHttpProxy resolves synchronously to a // stable origin. Returning `https://{host}` keeps the URL assertions below // unchanged — the proxy indirection is exercised by the Rust unit tests and by // httpProxy's own tests, not here. vi.mock("../../src/lib/httpProxy", () => ({ ensureHttpProxy: (host: string) => Promise.resolve(`https://${host}`), stopHttpProxy: () => Promise.resolve(), })); import { createApiClient, ApiClientError, type OnUnauthorized } from "../../src/lib/api"; function jsonResponse(data: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, status, statusText: "OK", json: () => Promise.resolve(data), headers: new Headers(), } as unknown as Response; } function errorResponse(status: number, code: string, message: string): Response { return { ok: false, status, statusText: message, json: () => Promise.resolve({ error: code, message }), headers: new Headers(), } as unknown as Response; } /** Error response whose json() throws (simulates non-JSON body). */ function brokenJsonErrorResponse(status: number, statusText: string): Response { return { ok: false, status, statusText, json: () => Promise.reject(new SyntaxError("Unexpected token")), headers: new Headers(), } as unknown as Response; } describe("API Client", () => { let api: ReturnType; let onUnauthorized: Mock; beforeEach(() => { mockFetch.mockReset(); onUnauthorized = vi.fn(); api = createApiClient({ host: "localhost:8443", token: "test-token" }, onUnauthorized); }); afterEach(() => { vi.restoreAllMocks(); }); // ── helpers ────────────────────────────────────────────── /** Extract the call args for the Nth fetch invocation. */ function fetchCallUrl(n = 0): string { return mockFetch.mock.calls[n]?.[0] as string; } function fetchCallOpts(n = 0): Record { return mockFetch.mock.calls[n]?.[1] as Record; } describe("API base path uses /api/v1/", () => { it("login calls /api/v1/auth/login", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", requires_2fa: false })); await api.login("user", "pass"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/auth/login"); }); it("getMessages calls /api/v1/channels/{id}/messages", async () => { mockFetch.mockResolvedValue(jsonResponse({ messages: [], has_more: false })); await api.getMessages(5); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/5/messages"); }); it("search calls /api/v1/search", async () => { mockFetch.mockResolvedValue(jsonResponse({ results: [] })); await api.search("hello"); expect(fetchCallUrl()).toContain("https://localhost:8443/api/v1/search"); }); it("getHealth calls /api/v1/health", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 100 })); await api.getHealth(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/health"); }); }); describe("auth endpoints", () => { it("register sends invite_code", async () => { mockFetch.mockResolvedValue( jsonResponse({ user: { id: 1, username: "u" }, token: "t" }, 201), ); await api.register("user", "pass", "invite123"); const body = JSON.parse(fetchCallOpts().body as string); expect(body.invite_code).toBe("invite123"); }); it("sends Authorization header", async () => { mockFetch.mockResolvedValue(jsonResponse({})); await api.getMe(); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer test-token"); }); it("logout sends POST /auth/logout", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.logout(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/auth/logout"); expect(fetchCallOpts().method).toBe("POST"); }); it("deleteAccount sends DELETE /auth/account with password", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.deleteAccount("mypass"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/auth/account"); expect(fetchCallOpts().method).toBe("DELETE"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ password: "mypass" }); }); }); describe("error handling", () => { it("throws ApiClientError on non-ok response", async () => { mockFetch.mockResolvedValue(errorResponse(403, "FORBIDDEN", "No permission")); await expect(api.getMe()).rejects.toThrow(ApiClientError); await expect(api.getMe()).rejects.toMatchObject({ status: 403, code: "FORBIDDEN", }); }); it("calls onUnauthorized on 401", async () => { mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "Invalid session")); await expect(api.getMe()).rejects.toThrow(); expect(onUnauthorized).toHaveBeenCalledTimes(1); }); it("does not call onUnauthorized on other errors", async () => { mockFetch.mockResolvedValue(errorResponse(500, "SERVER_ERROR", "Internal error")); await expect(api.getMe()).rejects.toThrow(); expect(onUnauthorized).not.toHaveBeenCalled(); }); it("throws original Error when fetch rejects with an Error instance", async () => { const networkErr = new TypeError("Failed to fetch"); mockFetch.mockRejectedValue(networkErr); await expect(api.getMe()).rejects.toBe(networkErr); }); it("wraps non-Error fetch rejection (string) in a new Error", async () => { mockFetch.mockRejectedValue("connection refused"); await expect(api.getMe()).rejects.toThrow("connection refused"); }); it("wraps non-Error non-string fetch rejection in a new Error via String()", async () => { mockFetch.mockRejectedValue(42); await expect(api.getMe()).rejects.toThrow("42"); }); it("parseError falls back to statusText when JSON body is not parseable", async () => { mockFetch.mockResolvedValue(brokenJsonErrorResponse(502, "Bad Gateway")); await expect(api.getMe()).rejects.toMatchObject({ status: 502, code: "UNKNOWN", message: "Bad Gateway", }); }); it("parseError uses UNKNOWN when error field missing from JSON", async () => { mockFetch.mockResolvedValue({ ok: false, status: 422, statusText: "Unprocessable", json: () => Promise.resolve({ message: "bad input" }), headers: new Headers(), } as unknown as Response); await expect(api.getMe()).rejects.toMatchObject({ status: 422, code: "UNKNOWN", message: "bad input", }); }); it("parseError uses statusText when message field missing from JSON", async () => { mockFetch.mockResolvedValue({ ok: false, status: 422, statusText: "Unprocessable", json: () => Promise.resolve({ error: "VALIDATION" }), headers: new Headers(), } as unknown as Response); await expect(api.getMe()).rejects.toMatchObject({ status: 422, code: "VALIDATION", message: "Unprocessable", }); }); it("handles 204 No Content response", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); const result = await api.logout(); expect(result).toBeUndefined(); }); }); describe("cancellation", () => { it("passes AbortSignal to fetch", async () => { mockFetch.mockResolvedValue(jsonResponse({})); const controller = new AbortController(); await api.getMe(controller.signal); expect(fetchCallOpts().signal).toBe(controller.signal); }); }); describe("pagination", () => { it("getMessages passes before and limit params", async () => { mockFetch.mockResolvedValue(jsonResponse({ messages: [], has_more: false })); await api.getMessages(5, { before: 100, limit: 25 }); expect(fetchCallUrl()).toContain("before=100"); expect(fetchCallUrl()).toContain("limit=25"); }); it("getMessages works without options (no query string)", async () => { mockFetch.mockResolvedValue(jsonResponse({ messages: [], has_more: false })); await api.getMessages(3); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/3/messages"); }); it("getMessagesAround hits the around route with the message id in the path", async () => { mockFetch.mockResolvedValue( jsonResponse({ messages: [], has_more_before: false, has_more_after: false }), ); await api.getMessagesAround(5, 4242); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/5/messages/around/4242"); }); it("getMessagesAround passes the limit", async () => { mockFetch.mockResolvedValue( jsonResponse({ messages: [], has_more_before: false, has_more_after: false }), ); await api.getMessagesAround(5, 42, { limit: 30 }); expect(fetchCallUrl()).toContain("limit=30"); }); it("getMessagesAround returns the has-more flags for both sides", async () => { mockFetch.mockResolvedValue( jsonResponse({ messages: [], has_more_before: true, has_more_after: false }), ); const resp = await api.getMessagesAround(5, 42); expect(resp.has_more_before).toBe(true); expect(resp.has_more_after).toBe(false); }); // The emoji is a path segment: unescaped it would either break the route or // resolve to a different emoji than the one on the pill. it("getReactionUsers percent-encodes the emoji in the path", async () => { mockFetch.mockResolvedValue(jsonResponse({ users: [] })); await api.getReactionUsers(5, 42, "👍"); expect(fetchCallUrl()).toBe( "https://localhost:8443/api/v1/channels/5/messages/42/reactions/%F0%9F%91%8D/users", ); }); it("getReactionUsers returns the reactor list", async () => { mockFetch.mockResolvedValue( jsonResponse({ users: [{ id: 1, username: "alice", avatar: "" }] }), ); const resp = await api.getReactionUsers(5, 42, "👍"); expect(resp.users).toHaveLength(1); expect(resp.users[0]?.username).toBe("alice"); }); }); describe("config management", () => { it("setConfig updates token", async () => { mockFetch.mockResolvedValue(jsonResponse({})); api.setConfig({ token: "new-token" }); await api.getMe(); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer new-token"); }); it("getConfig returns config with redacted token", () => { const cfg = api.getConfig(); expect(cfg.host).toBe("localhost:8443"); expect(cfg.token).toBe("[redacted]"); }); it("getConfig returns undefined token when no token set", () => { const noTokenApi = createApiClient({ host: "h" }); const cfg = noTokenApi.getConfig(); expect(cfg.token).toBeUndefined(); }); it("omits Authorization header when no token is set", async () => { const noTokenApi = createApiClient({ host: "localhost:8443" }); mockFetch.mockResolvedValue(jsonResponse({})); await noTokenApi.login("u", "p"); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBeUndefined(); expect(headers["Content-Type"]).toBe("application/json"); }); // B4_conn_ipc-2: a host switch must not carry the previous host's bearer // token forward — otherwise a login/register request to a new server // rides a still-live session token for the old one. it("setConfig drops the previous token when switching to a different host without a new token", async () => { mockFetch.mockResolvedValue(jsonResponse({})); // `api` (beforeEach) already holds token "test-token" for "localhost:8443". api.setConfig({ host: "evil.example.com:8443" }); await api.getMe(); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBeUndefined(); }); it("setConfig keeps the token when the host is unchanged", async () => { mockFetch.mockResolvedValue(jsonResponse({})); api.setConfig({ host: "localhost:8443" }); await api.getMe(); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer test-token"); }); it("setConfig keeps a token provided alongside a host change", async () => { mockFetch.mockResolvedValue(jsonResponse({})); api.setConfig({ host: "new.example.com:8443", token: "fresh-token" }); await api.getMe(); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer fresh-token"); }); // OC-0136: every other layer of this client (livekitSession.ts's // ensureLiveKitProxy, http_proxy.rs / livekit_proxy.rs's // validate_remote_host + parse_server_name) deliberately accepts IPv6 // literals, bracketed or bare. setConfig's host gate must not be the one // place that refuses — otherwise login/register/auto-login to an IPv6 // server throws "Invalid host format" even though the health check // (which tunnels through the same Rust proxy) already reported it // reachable. describe("host validation accepts IPv6 literals", () => { it("accepts a bracketed IPv6 literal with a port", () => { expect(() => api.setConfig({ host: "[::1]:8443" })).not.toThrow(); }); it("accepts a bracketed IPv6 literal without a port", () => { expect(() => api.setConfig({ host: "[fd00::1]" })).not.toThrow(); }); it("accepts a bare (unbracketed) IPv6 literal", () => { expect(() => api.setConfig({ host: "2001:db8::1" })).not.toThrow(); }); it("still rejects hosts with disallowed characters", () => { expect(() => api.setConfig({ host: "evil host name" })).toThrow("Invalid host format"); }); it("still rejects hosts that could inject headers", () => { expect(() => api.setConfig({ host: "evil\r\nhost:8443" })).toThrow("Invalid host format"); }); }); }); describe("user endpoints", () => { it("getMe calls GET /users/me", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 1, username: "me" })); const result = await api.getMe(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me"); expect(fetchCallOpts().method).toBe("GET"); expect(result).toEqual({ id: 1, username: "me" }); }); it("updateProfile sends PATCH /users/me with data", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 1, username: "newname" })); await api.updateProfile({ username: "newname" }); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me"); expect(fetchCallOpts().method).toBe("PATCH"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ username: "newname" }); }); it("updateProfile sends avatar field", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 1, avatar: "data:image/png;base64,abc" })); await api.updateProfile({ avatar: "data:image/png;base64,abc" }); const body = JSON.parse(fetchCallOpts().body as string); expect(body.avatar).toBe("data:image/png;base64,abc"); }); it("updateProfile sends display_name and about, empty string included", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 1, username: "u" })); // "" is how the API says "clear it"; omitting a field means "leave it // alone", so the two must not be collapsed on the way out. await api.updateProfile({ username: "u", display_name: "Ada L.", about: "" }); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ username: "u", display_name: "Ada L.", about: "" }); }); it("changePassword sends PUT /users/me/password", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.changePassword("oldpw", "newpw"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/password"); expect(fetchCallOpts().method).toBe("PUT"); const body = JSON.parse(fetchCallOpts().body as string); // The server decodes json:"old_password" (Server/api/profile_handler.go), // and Go's encoding/json has no alias matching: any other key is a 400. expect(body).toEqual({ old_password: "oldpw", new_password: "newpw" }); }); it("getSessions calls correct endpoint", async () => { mockFetch.mockResolvedValue(jsonResponse({ sessions: [] })); await api.getSessions(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/sessions"); }); // Regression for v112: the server wraps the list in a {sessions: [...]} // envelope (Server/api/profile_handler.go's sessionsListResponse); a bare // array would make every consumer's .map/.length fail or read undefined. it("getSessions unwraps the {sessions: [...]} envelope", async () => { const session = { id: 1, device: "Chrome on Linux", ip: "127.0.0.1", created_at: "2026-01-01T00:00:00Z", last_used: "2026-01-02T00:00:00Z", is_current: true, }; mockFetch.mockResolvedValue(jsonResponse({ sessions: [session] })); const result = await api.getSessions(); expect(result).toEqual([session]); }); it("revokeSession calls DELETE with session ID", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.revokeSession(42); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/sessions/42"); expect(fetchCallOpts().method).toBe("DELETE"); }); }); describe("TOTP management endpoints", () => { it("enableTotp sends POST /users/me/totp/enable with password", async () => { mockFetch.mockResolvedValue( jsonResponse({ qr_uri: "otpauth://totp/test", backup_codes: ["abc"] }), ); const result = await api.enableTotp("mypassword"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/totp/enable"); expect(fetchCallOpts().method).toBe("POST"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ password: "mypassword" }); expect(result).toEqual({ qr_uri: "otpauth://totp/test", backup_codes: ["abc"], }); }); it("confirmTotp sends POST /users/me/totp/confirm with password and code", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.confirmTotp("mypassword", "123456"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/totp/confirm"); expect(fetchCallOpts().method).toBe("POST"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ password: "mypassword", code: "123456" }); }); it("disableTotp sends DELETE /users/me/totp with password", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.disableTotp("mypassword"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/totp"); expect(fetchCallOpts().method).toBe("DELETE"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ password: "mypassword" }); }); it("enableTotp throws ApiClientError on bad password", async () => { mockFetch.mockResolvedValue(errorResponse(401, "INVALID_PASSWORD", "Wrong password")); await expect(api.enableTotp("wrongpw")).rejects.toThrow(ApiClientError); await expect(api.enableTotp("wrongpw")).rejects.toMatchObject({ status: 401, }); }); it("confirmTotp throws ApiClientError on invalid code", async () => { mockFetch.mockResolvedValue(errorResponse(400, "INVALID_CODE", "Invalid verification code")); await expect(api.confirmTotp("pw", "000000")).rejects.toThrow(ApiClientError); }); it("confirmTotp does NOT call onUnauthorized on a wrong enrollment code, even though the server answers 401", async () => { // Server contract: handleConfirmTOTP answers 401 UNAUTHORIZED / // "invalid two-factor code" for a wrong code — the session itself is // still perfectly valid. Firing the global session-expiry sink here // would sign the user out and (via main.ts) delete their stored // credential over a mistyped enrollment code. mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "invalid two-factor code")); await expect(api.confirmTotp("pw", "000000")).rejects.toMatchObject({ status: 401, code: "UNAUTHORIZED", }); expect(onUnauthorized).not.toHaveBeenCalled(); }); it("disableTotp throws ApiClientError when 2FA is required", async () => { mockFetch.mockResolvedValue( errorResponse(403, "TOTP_REQUIRED", "2FA is required by server policy"), ); await expect(api.disableTotp("pw")).rejects.toThrow(ApiClientError); await expect(api.disableTotp("pw")).rejects.toMatchObject({ status: 403, }); }); }); describe("verifyTotp", () => { it("sends POST /auth/verify-totp with partial token in header", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "full-token", user: { id: 1 } })); const result = await api.verifyTotp("123456", "partial-tok"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/auth/verify-totp"); expect(fetchCallOpts().method).toBe("POST"); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer partial-tok"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ code: "123456" }); expect(result.token).toBe("full-token"); }); it("throws ApiClientError on 401 and calls onUnauthorized", async () => { mockFetch.mockResolvedValue(errorResponse(401, "INVALID_TOTP", "Bad code")); await expect(api.verifyTotp("000000", "pt")).rejects.toMatchObject({ status: 401, code: "INVALID_TOTP", }); expect(onUnauthorized).toHaveBeenCalledTimes(1); }); it("throws ApiClientError on non-ok non-401", async () => { mockFetch.mockResolvedValue(errorResponse(429, "RATE_LIMITED", "Too many attempts")); await expect(api.verifyTotp("000000", "pt")).rejects.toMatchObject({ status: 429, code: "RATE_LIMITED", }); }); it("re-throws Error when fetch rejects with Error", async () => { const networkErr = new TypeError("Network failure"); mockFetch.mockRejectedValue(networkErr); await expect(api.verifyTotp("123456", "pt")).rejects.toBe(networkErr); }); it("wraps non-Error string rejection in new Error", async () => { mockFetch.mockRejectedValue("dns lookup failed"); await expect(api.verifyTotp("123456", "pt")).rejects.toThrow("dns lookup failed"); }); it("wraps non-Error non-string rejection via String()", async () => { mockFetch.mockRejectedValue(99); await expect(api.verifyTotp("123456", "pt")).rejects.toThrow("99"); }); it("passes AbortSignal to fetch", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", user: { id: 1 } })); const controller = new AbortController(); await api.verifyTotp("123456", "pt", controller.signal); expect(fetchCallOpts().signal).toBe(controller.signal); }); it("never sets danger.acceptInvalidCerts (cert pinning is handled by the Rust proxy)", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", user: { id: 1 } })); await api.verifyTotp("123456", "pt"); const opts = fetchCallOpts(); expect((opts as Record).danger).toBeUndefined(); }); it("routes verify-totp through the resolved proxy origin", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", user: { id: 1 } })); await api.verifyTotp("123456", "pt"); // The httpProxy mock resolves ensureHttpProxy("localhost:8443") to // https://localhost:8443, so the tunneled URL matches the direct one. expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/auth/verify-totp"); }); }); describe("channel endpoints", () => { it("getPins calls GET /channels/{id}/pins", async () => { mockFetch.mockResolvedValue(jsonResponse({ messages: [] })); await api.getPins(7); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/7/pins"); expect(fetchCallOpts().method).toBe("GET"); }); it("pinMessage calls POST /channels/{id}/pins/{msgId}", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.pinMessage(7, 99); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/7/pins/99"); expect(fetchCallOpts().method).toBe("POST"); }); it("unpinMessage calls DELETE /channels/{id}/pins/{msgId}", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.unpinMessage(7, 99); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/7/pins/99"); expect(fetchCallOpts().method).toBe("DELETE"); }); it("purgeMessages posts the limit to /channels/{id}/messages/purge", async () => { mockFetch.mockResolvedValue(jsonResponse({ channel_id: 7, ids: [3, 2], count: 2 })); const result = await api.purgeMessages(7, 25); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/channels/7/messages/purge"); expect(fetchCallOpts().method).toBe("POST"); expect(JSON.parse(String(fetchCallOpts().body))).toEqual({ limit: 25 }); expect(result.count).toBe(2); }); it("purgeMessages includes before only when supplied", async () => { mockFetch.mockResolvedValue(jsonResponse({ channel_id: 7, ids: [], count: 0 })); await api.purgeMessages(7, 10, { before: 42 }); expect(JSON.parse(String(fetchCallOpts().body))).toEqual({ limit: 10, before: 42 }); }); }); describe("search endpoint", () => { it("passes channelId and limit options", async () => { mockFetch.mockResolvedValue(jsonResponse({ results: [] })); await api.search("hello", { channelId: 3, limit: 10 }); const url = fetchCallUrl(); expect(url).toContain("q=hello"); expect(url).toContain("channel_id=3"); expect(url).toContain("limit=10"); }); it("works with query only (no options)", async () => { mockFetch.mockResolvedValue(jsonResponse({ results: [] })); await api.search("test"); const url = fetchCallUrl(); expect(url).toContain("q=test"); expect(url).not.toContain("channel_id"); expect(url).not.toContain("limit"); }); }); describe("file upload", () => { it("uploadFile sends POST /uploads with FormData", async () => { mockFetch.mockResolvedValue( jsonResponse({ url: "https://cdn/file.png", filename: "file.png" }), ); const file = new File(["hello"], "file.png", { type: "image/png" }); const result = await api.uploadFile(file); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/uploads"); expect(fetchCallOpts().method).toBe("POST"); // Should use FormData (not JSON) expect(fetchCallOpts().body).toBeInstanceOf(FormData); // Auth header should be present const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer test-token"); // Should NOT set Content-Type (browser sets multipart boundary) expect(headers["Content-Type"]).toBeUndefined(); expect(result).toEqual({ url: "https://cdn/file.png", filename: "file.png" }); }); it("uploadAvatar posts multipart to /users/me/avatar", async () => { mockFetch.mockResolvedValue( jsonResponse({ id: "abc", filename: "me.png", size: 100, mime: "image/png", url: "/api/v1/files/abc", }), ); const file = new File(["png"], "me.png", { type: "image/png" }); const result = await api.uploadAvatar(file); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/avatar"); expect(fetchCallOpts().method).toBe("POST"); expect(fetchCallOpts().body).toBeInstanceOf(FormData); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBe("Bearer test-token"); // The browser has to set the multipart boundary itself. expect(headers["Content-Type"]).toBeUndefined(); // The URL the server stored is what the caller needs back. expect(result.url).toBe("/api/v1/files/abc"); }); it("uploadAvatar surfaces a rejected image as an ApiClientError", async () => { mockFetch.mockResolvedValue( errorResponse(400, "BAD_REQUEST", "avatar must be a PNG, JPEG or WebP image"), ); const file = new File(["x"], "me.gif", { type: "image/gif" }); await expect(api.uploadAvatar(file)).rejects.toMatchObject({ status: 400, code: "BAD_REQUEST", }); }); it("uploadAvatar calls onUnauthorized on 401", async () => { mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "Invalid session")); const file = new File(["x"], "me.png", { type: "image/png" }); await expect(api.uploadAvatar(file)).rejects.toMatchObject({ status: 401 }); expect(onUnauthorized).toHaveBeenCalledTimes(1); }); it("uploadFile throws ApiClientError on non-ok", async () => { mockFetch.mockResolvedValue(errorResponse(413, "FILE_TOO_LARGE", "File exceeds limit")); const file = new File(["x"], "big.bin"); await expect(api.uploadFile(file)).rejects.toMatchObject({ status: 413, code: "FILE_TOO_LARGE", }); }); it("uploadFile calls onUnauthorized on 401 like other REST calls", async () => { mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "Invalid session")); const file = new File(["x"], "f.txt"); await expect(api.uploadFile(file)).rejects.toMatchObject({ status: 401 }); expect(onUnauthorized).toHaveBeenCalledTimes(1); }); it("uploadFile does not call onUnauthorized on other errors", async () => { mockFetch.mockResolvedValue(errorResponse(500, "SERVER_ERROR", "Internal error")); const file = new File(["x"], "f.txt"); await expect(api.uploadFile(file)).rejects.toThrow(); expect(onUnauthorized).not.toHaveBeenCalled(); }); it("uploadFile omits Authorization header when no token set", async () => { const noTokenApi = createApiClient({ host: "localhost:8443" }); mockFetch.mockResolvedValue(jsonResponse({ url: "https://cdn/f.png", filename: "f.png" })); const file = new File(["data"], "f.png"); await noTokenApi.uploadFile(file); const headers = fetchCallOpts().headers as Record; expect(headers["Authorization"]).toBeUndefined(); }); it("uploadFile passes AbortSignal", async () => { mockFetch.mockResolvedValue(jsonResponse({ url: "u", filename: "f" })); const controller = new AbortController(); await api.uploadFile(new File(["x"], "f"), controller.signal); expect(fetchCallOpts().signal).toBe(controller.signal); }); it("uploadFile parseError fallback on non-JSON error body", async () => { mockFetch.mockResolvedValue(brokenJsonErrorResponse(500, "Internal Server Error")); const file = new File(["x"], "f"); await expect(api.uploadFile(file)).rejects.toMatchObject({ status: 500, code: "UNKNOWN", message: "Internal Server Error", }); }); }); describe("invite endpoints", () => { it("getInvites calls GET /invites", async () => { mockFetch.mockResolvedValue(jsonResponse([])); const result = await api.getInvites(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/invites"); expect(fetchCallOpts().method).toBe("GET"); expect(result).toEqual([]); }); it("createInvite calls POST /invites with data", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 1, code: "abc123", max_uses: 5 })); const result = await api.createInvite({ max_uses: 5, expires_in_hours: 24 }); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/invites"); expect(fetchCallOpts().method).toBe("POST"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ max_uses: 5, expires_in_hours: 24 }); expect(result.code).toBe("abc123"); }); it("revokeInvite calls DELETE /invites/{code}", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.revokeInvite("abc123"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/invites/abc123"); expect(fetchCallOpts().method).toBe("DELETE"); }); }); describe("emoji endpoints", () => { it("listEmoji calls GET /emoji", async () => { mockFetch.mockResolvedValue( jsonResponse([{ id: 1, shortcode: "smile", url: "/api/v1/emoji/1/image" }]), ); const result = await api.listEmoji(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/emoji"); expect(fetchCallOpts().method).toBe("GET"); expect(result).toEqual([{ id: 1, shortcode: "smile", url: "/api/v1/emoji/1/image" }]); }); it("uploadEmoji POSTs multipart with the shortcode and file", async () => { mockFetch.mockResolvedValue( jsonResponse({ id: 7, shortcode: "wave", url: "/api/v1/emoji/7/image" }, 201), ); const file = new File(["png"], "wave.png", { type: "image/png" }); const result = await api.uploadEmoji("wave", file); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/emoji"); const opts = fetchCallOpts(); expect(opts.method).toBe("POST"); const body = opts.body as FormData; expect(body.get("shortcode")).toBe("wave"); expect(body.get("file")).toBe(file); // The browser owns the multipart boundary — setting Content-Type breaks it. expect((opts.headers as Record)["Content-Type"]).toBeUndefined(); expect(result.shortcode).toBe("wave"); }); it("uploadEmoji calls onUnauthorized on 401", async () => { mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "Invalid session")); const file = new File(["png"], "wave.png", { type: "image/png" }); await expect(api.uploadEmoji("wave", file)).rejects.toMatchObject({ status: 401 }); expect(onUnauthorized).toHaveBeenCalledTimes(1); }); it("uploadEmoji surfaces a 403 without calling onUnauthorized", async () => { mockFetch.mockResolvedValue(errorResponse(403, "FORBIDDEN", "insufficient permissions")); const file = new File(["png"], "wave.png", { type: "image/png" }); await expect(api.uploadEmoji("wave", file)).rejects.toMatchObject({ status: 403, code: "FORBIDDEN", }); expect(onUnauthorized).not.toHaveBeenCalled(); }); it("deleteEmoji calls DELETE /emoji/{id}", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.deleteEmoji(5); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/emoji/5"); expect(fetchCallOpts().method).toBe("DELETE"); }); }); describe("DM endpoints", () => { it("getDmChannels calls GET /dms", async () => { mockFetch.mockResolvedValue(jsonResponse({ channels: [] })); const result = await api.getDmChannels(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/dms"); expect(fetchCallOpts().method).toBe("GET"); expect(result).toEqual({ channels: [] }); }); it("createDm calls POST /dms with recipient_id", async () => { mockFetch.mockResolvedValue(jsonResponse({ channel: { id: 10, type: "dm" } })); const result = await api.createDm(42); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/dms"); expect(fetchCallOpts().method).toBe("POST"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ recipient_id: 42 }); expect(result).toEqual({ channel: { id: 10, type: "dm" } }); }); it("createGroupDm calls POST /dms/group with recipient_ids and a name", async () => { mockFetch.mockResolvedValue(jsonResponse({ channel_id: 11, is_group: true })); const result = await api.createGroupDm([2, 3], "Crew"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/dms/group"); expect(fetchCallOpts().method).toBe("POST"); expect(JSON.parse(fetchCallOpts().body as string)).toEqual({ recipient_ids: [2, 3], name: "Crew", }); expect(result).toEqual({ channel_id: 11, is_group: true }); }); it("createGroupDm sends an empty name when none is given", async () => { mockFetch.mockResolvedValue(jsonResponse({ channel_id: 11 })); await api.createGroupDm([2, 3]); expect(JSON.parse(fetchCallOpts().body as string)).toEqual({ recipient_ids: [2, 3], name: "", }); }); it("renameGroupDm calls PATCH /dms/{channelId}", async () => { mockFetch.mockResolvedValue(jsonResponse({ channel_id: 11, name: "New" })); await api.renameGroupDm(11, "New"); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/dms/11"); expect(fetchCallOpts().method).toBe("PATCH"); expect(JSON.parse(fetchCallOpts().body as string)).toEqual({ name: "New" }); }); it("closeDm calls DELETE /dms/{channelId}", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.closeDm(10); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/dms/10"); expect(fetchCallOpts().method).toBe("DELETE"); }); }); describe("voice endpoints", () => { it("getVoiceCredentials calls GET /voice/credentials", async () => { mockFetch.mockResolvedValue(jsonResponse({ url: "wss://lk", token: "vt" })); const result = await api.getVoiceCredentials(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/voice/credentials"); expect(fetchCallOpts().method).toBe("GET"); expect(result).toEqual({ url: "wss://lk", token: "vt" }); }); }); describe("health endpoint", () => { it("getHealth uses custom host when provided", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 50 })); await api.getHealth("other-host:9443"); expect(fetchCallUrl()).toBe("https://other-host:9443/api/v1/health"); }); it("getHealth falls back to config host when no host arg", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 50 })); await api.getHealth(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/health"); }); it("getHealth throws ApiClientError on non-ok response", async () => { mockFetch.mockResolvedValue({ ok: false, status: 503, statusText: "Service Unavailable", json: () => Promise.resolve({}), headers: new Headers(), } as unknown as Response); await expect(api.getHealth()).rejects.toMatchObject({ status: 503, code: "HEALTH_CHECK_FAILED", }); }); it("getHealth clears timeout on success", async () => { const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 0 })); await api.getHealth(); expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); it("getHealth clears timeout on failure", async () => { const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Error", json: () => Promise.resolve({}), headers: new Headers(), } as unknown as Response); await expect(api.getHealth()).rejects.toThrow(); expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); it("getHealth sets abort timeout with provided timeoutMs", async () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 0 })); await api.getHealth(undefined, 5000); // setTimeout should have been called with the timeout value expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); setTimeoutSpy.mockRestore(); }); it("getHealth never sets danger (cert pinning handled by the Rust proxy)", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 0 })); await api.getHealth(); expect((fetchCallOpts() as Record).danger).toBeUndefined(); }); it("getHealth fetches through the resolved proxy origin", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 0 })); await api.getHealth(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/health"); }); }); describe("admin channel endpoints", () => { it("adminCreateChannel calls POST /admin/api/channels", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 1, name: "general", type: "text" })); const result = await api.adminCreateChannel({ name: "general", type: "text", category: "Main", topic: "General chat", position: 0, }); expect(fetchCallUrl()).toBe("https://localhost:8443/admin/api/channels"); expect(fetchCallOpts().method).toBe("POST"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ name: "general", type: "text", category: "Main", topic: "General chat", position: 0, }); expect(result).toEqual({ id: 1, name: "general", type: "text" }); }); it("adminUpdateChannel calls PATCH /admin/api/channels/{id}", async () => { mockFetch.mockResolvedValue(jsonResponse({ id: 5, name: "renamed", topic: "new topic" })); const result = await api.adminUpdateChannel(5, { name: "renamed", topic: "new topic", slow_mode: 10, position: 2, archived: false, }); expect(fetchCallUrl()).toBe("https://localhost:8443/admin/api/channels/5"); expect(fetchCallOpts().method).toBe("PATCH"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ name: "renamed", topic: "new topic", slow_mode: 10, position: 2, archived: false, }); expect(result.name).toBe("renamed"); }); it("adminDeleteChannel calls DELETE /admin/api/channels/{id}", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.adminDeleteChannel(5); expect(fetchCallUrl()).toBe("https://localhost:8443/admin/api/channels/5"); expect(fetchCallOpts().method).toBe("DELETE"); }); }); describe("admin member endpoints", () => { it("adminKickMember calls DELETE /admin/api/users/{id}/sessions", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.adminKickMember(42); expect(fetchCallUrl()).toBe("https://localhost:8443/admin/api/users/42/sessions"); expect(fetchCallOpts().method).toBe("DELETE"); }); it("adminBanMember calls PATCH /admin/api/users/{id} with banned:true", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.adminBanMember(42, "spamming"); expect(fetchCallUrl()).toBe("https://localhost:8443/admin/api/users/42"); expect(fetchCallOpts().method).toBe("PATCH"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ banned: true, ban_reason: "spamming" }); }); it("adminBanMember uses empty string when no reason provided", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.adminBanMember(42); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ banned: true, ban_reason: "" }); }); it("adminChangeRole calls PATCH /admin/api/users/{id} with role_id", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.adminChangeRole(42, 3); expect(fetchCallUrl()).toBe("https://localhost:8443/admin/api/users/42"); expect(fetchCallOpts().method).toBe("PATCH"); const body = JSON.parse(fetchCallOpts().body as string); expect(body).toEqual({ role_id: 3 }); }); }); describe("ApiClientError class", () => { it("has correct name, status, code, message properties", () => { const err = new ApiClientError(404, "NOT_FOUND", "Resource not found"); expect(err.name).toBe("ApiClientError"); expect(err.status).toBe(404); expect(err.code).toBe("NOT_FOUND"); expect(err.message).toBe("Resource not found"); expect(err).toBeInstanceOf(Error); }); }); describe("doFetch transport", () => { it("never sets danger — TLS trust is enforced by the Rust HTTP proxy", async () => { mockFetch.mockResolvedValue(jsonResponse({})); await api.getMe(); expect((fetchCallOpts() as Record).danger).toBeUndefined(); }); it("fetches through the resolved proxy origin", async () => { mockFetch.mockResolvedValue(jsonResponse({})); await api.getMe(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me"); }); }); describe("client without onUnauthorized callback", () => { it("does not throw when onUnauthorized is undefined and 401 received", async () => { const apiNoCallback = createApiClient({ host: "localhost:8443", token: "t" }); mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "No session")); await expect(apiNoCallback.getMe()).rejects.toMatchObject({ status: 401, code: "UNAUTHORIZED", }); }); }); describe("doFetch body serialization", () => { it("omits body when body is undefined (GET requests)", async () => { mockFetch.mockResolvedValue(jsonResponse({ sessions: [] })); await api.getSessions(); expect(fetchCallOpts().body).toBeUndefined(); }); it("serializes body as JSON for POST requests", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", requires_2fa: false })); await api.login("u", "p"); expect(typeof fetchCallOpts().body).toBe("string"); expect(JSON.parse(fetchCallOpts().body as string)).toEqual({ username: "u", password: "p", }); }); }); });