import "@testing-library/jest-dom/vitest"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ConnectError, Code } from "@connectrpc/connect"; import { AuthError } from "../src/api/auth"; import { classifyError, isRetryable, type ErrorKind, } from "../src/lib/error/classify"; import { errorMessageKey, reportError } from "../src/lib/error/report"; import { toast } from "../src/lib/toast.svelte"; afterEach(() => { toast.resetForTests(); vi.restoreAllMocks(); }); describe("classifyError", () => { it("maps HTTP statuses (AuthError) to kinds", () => { const at = (status: number) => classifyError(new AuthError("x", "msg", status)); expect(at(401)).toBe("auth"); expect(at(403)).toBe("forbidden"); expect(at(404)).toBe("notFound"); expect(at(409)).toBe("conflict"); expect(at(429)).toBe("rateLimit"); expect(at(500)).toBe("server"); expect(at(400)).toBe("unknown"); }); it("maps Connect codes to kinds", () => { const at = (code: Code) => classifyError(new ConnectError("msg", code)); expect(at(Code.Unauthenticated)).toBe("auth"); expect(at(Code.PermissionDenied)).toBe("forbidden"); expect(at(Code.NotFound)).toBe("notFound"); expect(at(Code.FailedPrecondition)).toBe("conflict"); expect(at(Code.ResourceExhausted)).toBe("rateLimit"); expect(at(Code.Unavailable)).toBe("network"); expect(at(Code.Internal)).toBe("server"); }); it("treats a fetch TypeError as a network error", () => { expect(classifyError(new TypeError("Failed to fetch"))).toBe("network"); }); it("duck-types any object with a numeric status", () => { expect(classifyError({ status: 409 })).toBe("conflict"); }); it("falls back to unknown for a plain error", () => { expect(classifyError(new Error("boom"))).toBe("unknown"); }); it("short-circuits to offline when the browser is offline", () => { Object.defineProperty(navigator, "onLine", { configurable: true, value: false, }); try { expect(classifyError(new AuthError("x", "m", 500))).toBe("offline"); } finally { Object.defineProperty(navigator, "onLine", { configurable: true, value: true, }); } }); it("marks only transient kinds retryable", () => { const retryable: ErrorKind[] = ["offline", "network", "server"]; const notRetryable: ErrorKind[] = [ "auth", "forbidden", "conflict", "notFound", "rateLimit", "unknown", ]; expect(retryable.every(isRetryable)).toBe(true); expect(notRetryable.some(isRetryable)).toBe(false); }); }); describe("reportError / errorMessageKey", () => { it("returns the translated message key for inline use", () => { expect(errorMessageKey(new AuthError("x", "m", 403))).toBe("error.forbidden"); expect(errorMessageKey(new Error("?"))).toBe("error.unknown"); }); it("raises an auto-dismiss toast for a non-retryable error", () => { const kind = reportError(new AuthError("x", "m", 403)); expect(kind).toBe("forbidden"); expect(toast.current?.messageKey).toBe("error.forbidden"); expect(toast.current?.actionLabelKey).toBeUndefined(); expect(toast.current?.durationMs).toBe(8000); }); it("raises a sticky Retry toast for a retryable error with onRetry", () => { const onRetry = vi.fn(); const kind = reportError(new TypeError("Failed to fetch"), { onRetry }); expect(kind).toBe("network"); expect(toast.current?.messageKey).toBe("error.network"); expect(toast.current?.actionLabelKey).toBe("common.retry"); expect(toast.current?.durationMs).toBeNull(); toast.current?.onAction?.(); expect(onRetry).toHaveBeenCalledOnce(); }); it("does not add a Retry action to a non-retryable error", () => { reportError(new AuthError("x", "m", 403), { onRetry: vi.fn() }); expect(toast.current?.actionLabelKey).toBeUndefined(); }); });