import { describe, it, expect, vi, beforeEach } from "vitest" import { api, UnauthorizedError } from "./client" const PROJECT_ID = "11111111-1111-1111-1111-111111111111" beforeEach(() => { vi.restoreAllMocks() }) function mockFetch(body: unknown, ok = true, status = 200) { return vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok, status, json: async () => body, text: async () => JSON.stringify(body), } as Response) } describe("api client", () => { it("sends credentials:include on every request", async () => { const spy = mockFetch([]) await api.listAccounts(PROJECT_ID) const [, opts] = spy.mock.calls[0] expect((opts as RequestInit).credentials).toBe("include") }) describe("api.auth", () => { it("login POSTs to /api/v1/auth/login with credentials:include", async () => { const spy = mockFetch({ user: { id: "u1", email: "a@b.com" }, project: { id: "p1", name: "Default" } }) await api.auth.login("a@b.com", "secret") const [url, opts] = spy.mock.calls[0] expect(url).toBe("/api/v1/auth/login") expect((opts as RequestInit).method).toBe("POST") expect((opts as RequestInit).credentials).toBe("include") expect(String((opts as RequestInit).body)).toContain("secret") }) it("register POSTs to /api/v1/auth/register", async () => { const spy = mockFetch({ user: { id: "u1", email: "a@b.com" }, project: { id: "p1", name: "Default" } }) await api.auth.register("a@b.com", "secret") const [url, opts] = spy.mock.calls[0] expect(url).toBe("/api/v1/auth/register") expect((opts as RequestInit).method).toBe("POST") }) it("logout POSTs to /api/v1/auth/logout", async () => { const spy = mockFetch(undefined, true, 204) await api.auth.logout() const [url, opts] = spy.mock.calls[0] expect(url).toBe("/api/v1/auth/logout") expect((opts as RequestInit).method).toBe("POST") }) it("me GETs /api/v1/auth/me and returns AuthState", async () => { const state = { user: { id: "u1", email: "a@b.com" }, project: { id: "p1", name: "Default" } } const spy = mockFetch(state) const result = await api.auth.me() const [url] = spy.mock.calls[0] expect(url).toBe("/api/v1/auth/me") expect(result).toEqual(state) }) }) it("resource methods hit project-scoped path with projectId first", async () => { const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }]) const accounts = await api.listAccounts(PROJECT_ID) expect(accounts).toHaveLength(1) expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/accounts`, expect.objectContaining({ method: "GET" }), ) }) it("listDomains(projectId) hits /api/v1/projects/{projectId}/domains", async () => { const spy = mockFetch([]) await api.listDomains(PROJECT_ID) expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/domains`, expect.objectContaining({ method: "GET" }), ) }) it("sends secret on account creation but path has no secret leakage in response typing", async () => { const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" }) await api.createAccount(PROJECT_ID, { provider: "selectel", secret: { username: "svc-user", password: "TOKEN", account_id: "123456", project_name: "default" }, comment: "prod", }) const [, opts] = spy.mock.calls[0] expect((opts as RequestInit).method).toBe("POST") expect(String((opts as RequestInit).body)).toContain("TOKEN") }) it("throws on non-ok response", async () => { mockFetch({ error: "boom" }, false, 500) await expect(api.listDomains(PROJECT_ID)).rejects.toThrow() }) it("throws UnauthorizedError on 401", async () => { mockFetch({ error: "unauthorized" }, false, 401) await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError) }) it("applies with prune flag using projectId, id, body order", async () => { const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) await api.applyDomain(PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }) const [url, opts] = spy.mock.calls[0] expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`) expect(String((opts as RequestInit).body)).toContain("applyPrunes") }) it("checkDomain(projectId, id) hits project-scoped check path", async () => { const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) await api.checkDomain(PROJECT_ID, "d1") expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/domains/d1/check`, expect.objectContaining({ method: "GET" }), ) }) it("importZones(projectId, accountId) hits project-scoped import path", async () => { const spy = mockFetch([]) await api.importZones(PROJECT_ID, "acc1") expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/accounts/acc1/import`, expect.objectContaining({ method: "POST" }), ) }) it("setDomainTemplate(projectId, id, templateId) hits project-scoped domain path", async () => { const spy = mockFetch({ id: "d1", providerAccountId: "acc1", zoneName: "x.", zoneId: "z1" }) await api.setDomainTemplate(PROJECT_ID, "d1", "t1") const [url, opts] = spy.mock.calls[0] expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1`) expect((opts as RequestInit).method).toBe("PATCH") }) describe("schedule", () => { it("getSchedule(projectId) GETs /schedule", async () => { const spy = mockFetch({ intervalSeconds: 3600, enabled: false }) await api.getSchedule(PROJECT_ID) expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/schedule`, expect.objectContaining({ method: "GET", credentials: "include" }), ) }) it("putSchedule(projectId, {intervalSeconds,enabled}) PUTs /schedule", async () => { const spy = mockFetch({ intervalSeconds: 120, enabled: true }) await api.putSchedule(PROJECT_ID, { intervalSeconds: 120, enabled: true }) const [url, opts] = spy.mock.calls[0] expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/schedule`) expect((opts as RequestInit).method).toBe("PUT") expect((opts as RequestInit).credentials).toBe("include") expect(String((opts as RequestInit).body)).toContain("intervalSeconds") }) }) describe("channels", () => { it("listChannels(projectId) GETs /channels", async () => { const spy = mockFetch([]) await api.listChannels(PROJECT_ID) expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/channels`, expect.objectContaining({ method: "GET" }), ) }) it("createChannel(projectId, {type,config,secret}) POSTs /channels with secret in body", async () => { const spy = mockFetch({ id: "c1", type: "telegram", config: { chat_id: "1" }, enabled: true }) await api.createChannel(PROJECT_ID, { type: "telegram", config: { chat_id: "1" }, secret: "BOT_TOKEN" }) const [url, opts] = spy.mock.calls[0] expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/channels`) expect((opts as RequestInit).method).toBe("POST") expect(String((opts as RequestInit).body)).toContain("BOT_TOKEN") }) it("deleteChannel(projectId, id) DELETEs /channels/{id}", async () => { const spy = mockFetch(undefined, true, 204) await api.deleteChannel(PROJECT_ID, "c1") expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/channels/c1`, expect.objectContaining({ method: "DELETE" }), ) }) it("testChannel(projectId, id) POSTs /channels/{id}/test", async () => { const spy = mockFetch({ status: "ok" }) await api.testChannel(PROJECT_ID, "c1") expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/channels/c1/test`, expect.objectContaining({ method: "POST" }), ) }) }) it("domainHistory(projectId, domainId) GETs /domains/{did}/history", async () => { const spy = mockFetch([]) await api.domainHistory(PROJECT_ID, "d1") expect(spy).toHaveBeenCalledWith( `/api/v1/projects/${PROJECT_ID}/domains/d1/history`, expect.objectContaining({ method: "GET", credentials: "include" }), ) }) })