diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..c894b9c --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { api } from "./client" +import { DEFAULT_PROJECT_ID } from "@/lib/config" + +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("lists accounts at project-scoped path", async () => { + const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }]) + const accounts = await api.listAccounts() + expect(accounts).toHaveLength(1) + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`, + 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({ provider: "selectel", secret: "TOKEN", 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()).rejects.toThrow() + }) + + it("applies with prune flag", async () => { + const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) + await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true }) + const [url, opts] = spy.mock.calls[0] + expect(url).toContain("/domains/d1/apply") + expect(String((opts as RequestInit).body)).toContain("applyPrunes") + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..4d5d65b --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,47 @@ +import { API_BASE } from "@/lib/config" +import type { + Account, CreateAccountInput, Template, CreateTemplateInput, + Domain, CreateDomainInput, ChangesetResponse, ApplyRequest, +} from "./types" + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + method: "GET", + ...init, + }) + if (!res.ok) { + let msg = `HTTP ${res.status}` + try { const b = await res.json(); if (b?.error) msg = b.error } catch { /* ignore */ } + throw new Error(msg) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T +} + +export const api = { + listAccounts: () => req("/accounts"), + createAccount: (input: CreateAccountInput) => + req("/accounts", { method: "POST", body: JSON.stringify(input) }), + deleteAccount: (id: string) => req(`/accounts/${id}`, { method: "DELETE" }), + + listTemplates: () => req("/templates"), + createTemplate: (input: CreateTemplateInput) => + req