import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UnauthorizedError, UserExistsError, TopicReservedError, AccountActionLimitReachedError, IncorrectPasswordError, EmailVerificationCodeInvalidError, EmailPrimaryElsewhereError, throwAppError, fetchOrThrow, } from "./errors"; // A minimal stand-in for a fetch Response: just the bits errors.js reads. const fakeResponse = (status, body) => ({ status, json: async () => { if (body === undefined) throw new Error("no body"); return body; }, }); beforeEach(() => { // errors.js logs to console.log on every error path; keep test output clean. vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe("error classes", () => { it("carry the server error codes from errors.go", () => { expect(UserExistsError.CODE).toBe(40901); expect(TopicReservedError.CODE).toBe(40902); expect(AccountActionLimitReachedError.CODE).toBe(42906); expect(IncorrectPasswordError.CODE).toBe(40026); expect(EmailVerificationCodeInvalidError.CODE).toBe(40051); expect(EmailPrimaryElsewhereError.CODE).toBe(40908); }); it("are Error subclasses with human-readable messages", () => { expect(new UnauthorizedError()).toBeInstanceOf(Error); expect(new UnauthorizedError().message).toBe("Unauthorized"); expect(new UserExistsError().message).toBe("Username already exists"); }); }); describe("throwAppError", () => { it("maps 401 and 403 to UnauthorizedError", async () => { await expect(throwAppError(fakeResponse(401))).rejects.toBeInstanceOf(UnauthorizedError); await expect(throwAppError(fakeResponse(403))).rejects.toBeInstanceOf(UnauthorizedError); }); it("maps known ntfy error codes to their specific error classes", async () => { await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError); await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError); await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf( AccountActionLimitReachedError ); }); it("wraps an unknown code that carries an error message in a generic Error", async () => { await expect(throwAppError(fakeResponse(400, { code: 12345, error: "boom" }))).rejects.toThrow("Error 12345: boom"); }); it("throws a generic 'Unexpected response' error when there is no ntfy error body", async () => { await expect(throwAppError(fakeResponse(500))).rejects.toThrow("Unexpected response 500"); }); }); describe("fetchOrThrow", () => { afterEach(() => { vi.unstubAllGlobals(); }); it("returns the response unchanged on HTTP 200", async () => { const response = { status: 200 }; vi.stubGlobal( "fetch", vi.fn(async () => response) ); await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response); }); it("throws on a non-200 response", async () => { vi.stubGlobal( "fetch", vi.fn(async () => fakeResponse(401)) ); await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError); }); });