The backups page could list and restore archives but not create one,
and nothing surfaced backup/restore Job outcomes — a failed 202 was
visible only through kubectl. Add a Back up now action (enabled only on
a stopped server, the backend's own gate; a raced 409 is surfaced in
its words) and a Recent operations card fed by GET /servers/{name}/jobs
that shows running/succeeded/failed with the Job's failure message,
re-reads on an interval while a Job is running, and persists across
reloads. Wire-shape tests pin both endpoints.
585 lines
24 KiB
TypeScript
585 lines
24 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
// Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary
|
|
// (request() does `return parsed as T`), so nothing but a test pins the snake_case
|
|
// keys to the Go handler. If the handler's JSON ever drifts to camelCase — or the
|
|
// client starts transforming keys — `identity.is_admin` becomes undefined, which is
|
|
// falsy, and EVERY admin silently renders as a non-admin while typecheck/build stay
|
|
// green. This fixture is the canonical mirror of handlers_user.go handleMe.
|
|
|
|
vi.mock("./config", () => ({
|
|
loadConfig: async () => ({ apiBase: "", rootDomain: "example.test" }),
|
|
}));
|
|
|
|
// Imported after the mock so api.ts picks up the mocked loadConfig.
|
|
const { api } = await import("./api");
|
|
|
|
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
|
|
return vi.fn(async () => ({
|
|
ok: init?.ok ?? true,
|
|
status: init?.status ?? 200,
|
|
statusText: "OK",
|
|
text: async () => JSON.stringify(body),
|
|
})) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe("api.me wire shape", () => {
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("surfaces snake_case is_admin from GET /me verbatim (admin)", async () => {
|
|
// EXACTLY the JSON handlers_user.go handleMe emits.
|
|
const body = { user_id: "u1", email: "[email protected]", role: "admin", is_admin: true };
|
|
vi.stubGlobal("fetch", fakeFetch(body));
|
|
const id = await api.me();
|
|
expect(id.is_admin).toBe(true);
|
|
expect(id.user_id).toBe("u1");
|
|
expect(id.email).toBe("[email protected]");
|
|
expect(id.role).toBe("admin");
|
|
});
|
|
|
|
it("reports is_admin:false for a non-admin identity", async () => {
|
|
const body = { user_id: "u2", email: "[email protected]", role: "user", is_admin: false };
|
|
vi.stubGlobal("fetch", fakeFetch(body));
|
|
const id = await api.me();
|
|
expect(id.is_admin).toBe(false);
|
|
expect(id.role).toBe("user");
|
|
});
|
|
|
|
it("calls GET on the /me path", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
user_id: "u3",
|
|
email: "[email protected]",
|
|
role: "user",
|
|
is_admin: false,
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
await api.me();
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/me");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
expect((opts as RequestInit).credentials).toBe("include");
|
|
});
|
|
});
|
|
|
|
describe("session auth wire shapes", () => {
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
|
|
const fetchSpy = fakeFetch({ ok: true });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.logout();
|
|
expect(res.ok).toBe(true);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/auth/logout");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
});
|
|
|
|
it("bind POSTs {code} to /auth/bind", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
user_id: "mock-linked",
|
|
linked: true,
|
|
mc_uuid: "uuid-123",
|
|
auth_source: "mojang",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.bind("ABCD2345");
|
|
expect(res.user_id).toBe("mock-linked");
|
|
expect(res.linked).toBe(true);
|
|
expect(res.mc_uuid).toBe("uuid-123");
|
|
expect(res.auth_source).toBe("mojang");
|
|
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/auth/bind");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect((opts as RequestInit).headers).toEqual({
|
|
"Content-Type": "application/json",
|
|
});
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
code: "ABCD2345",
|
|
});
|
|
});
|
|
|
|
it("maps the auth error codes to stable human copy", async () => {
|
|
const { humanizeError } = await import("./api");
|
|
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
|
|
expect(humanizeError({ code: "staff_account" })).toMatch(/operator/i);
|
|
});
|
|
|
|
// Op-login (the staff door): start hands back the approval handle the panel shows
|
|
// as `/felis web op approve <id>`; status is polled; finish spends the mailed code.
|
|
// EXACTLY handlers_op_login.go's request/response keys.
|
|
it("opLoginStart POSTs {email} and surfaces {request_id, expires_at}", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
request_id: "req-1",
|
|
expires_at: "2026-07-19T00:10:00Z",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.opLoginStart("[email protected]");
|
|
expect(res.request_id).toBe("req-1");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/auth/op-login/start");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect((opts as RequestInit).headers).toEqual({
|
|
"Content-Type": "application/json",
|
|
});
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
email: "[email protected]",
|
|
});
|
|
});
|
|
|
|
it("opLoginStatus GETs /auth/op-login/status/{id} and surfaces approved", async () => {
|
|
const fetchSpy = fakeFetch({ approved: true });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.opLoginStatus("req-1");
|
|
expect(res.approved).toBe(true);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/auth/op-login/status/req-1");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
});
|
|
|
|
it("opLoginFinish POSTs {request_id, code}", async () => {
|
|
const fetchSpy = fakeFetch({ user_id: "u9", role: "admin" });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.opLoginFinish("req-1", "123456");
|
|
expect(res.user_id).toBe("u9");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/auth/op-login/finish");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
request_id: "req-1",
|
|
code: "123456",
|
|
});
|
|
});
|
|
});
|
|
|
|
// Pin the §B3 migration wire shapes (handlers_account_migrate.go). The status union
|
|
// ({active:false} | {active:true, state, ...}) and the issue/redeem bodies cross the
|
|
// untyped fetch().json() boundary, so a key drift leaves the Account migration card
|
|
// inert while typecheck/build stay green.
|
|
describe("account migration wire shapes", () => {
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("migrateStatus GETs /account/migrate and surfaces the state-machine fields", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
active: true,
|
|
state: "confirmed",
|
|
confirm_factor: "email_otp",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.migrateStatus();
|
|
expect(res.active).toBe(true);
|
|
expect(res.state).toBe("confirmed");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/account/migrate");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
expect((opts as RequestInit).credentials).toBe("include");
|
|
});
|
|
|
|
it("migrateIssueCode POSTs {target_user_id} and surfaces the one-time code", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
code: "MIGR-1234",
|
|
expires_at: "2026-07-19T00:10:00Z",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.migrateIssueCode("u2");
|
|
expect(res.code).toBe("MIGR-1234");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/account/migrate/issue-code");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
target_user_id: "u2",
|
|
});
|
|
});
|
|
|
|
it("migrateRedeem POSTs {code} and surfaces the moved servers", async () => {
|
|
const fetchSpy = fakeFetch({ migrated: true, servers_moved: 2, servers: ["a", "b"] });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.migrateRedeem("MIGR-1234");
|
|
expect(res.servers_moved).toBe(2);
|
|
expect(res.servers).toEqual(["a", "b"]);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/account/migrate/redeem");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
code: "MIGR-1234",
|
|
});
|
|
});
|
|
});
|
|
|
|
// Pin the GET /fleet wire shape (the SysAdmin cockpit's read). It is the ONLY
|
|
// place the panel asserts the fleetServerView fields: the cockpit consumes the raw
|
|
// CRD names (playersOnline/playersMax, not players/maxPlayers) plus the joined
|
|
// `owner`, all crossing the untyped fetch().json() boundary. If the Go handler's
|
|
// JSON ever drifts to the /me/servers shape — or drops owner — typecheck/build stay
|
|
// green while the table silently renders blank players and "unclaimed" everywhere.
|
|
describe("api.fleet wire shape", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("GETs /fleet and unwraps .servers with the CRD field names + owner", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
servers: [
|
|
{
|
|
name: "survival",
|
|
subdomain: "survival",
|
|
phase: "Running",
|
|
ready: true,
|
|
playersOnline: 3,
|
|
playersMax: 20,
|
|
owner: "[email protected]",
|
|
},
|
|
],
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const servers = await api.fleet();
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/fleet");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
expect((opts as RequestInit).credentials).toBe("include");
|
|
expect(servers).toHaveLength(1);
|
|
expect(servers[0].playersOnline).toBe(3);
|
|
expect(servers[0].playersMax).toBe(20);
|
|
expect(servers[0].owner).toBe("[email protected]");
|
|
});
|
|
|
|
it("defaults to [] when the body carries no servers key", async () => {
|
|
const fetchSpy = fakeFetch({});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
expect(await api.fleet()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// Pin the §access wire shapes (handlers_access.go). The panel translates structured
|
|
// fields into the request body — the backend re-validates and concatenates the RCON
|
|
// command, so the {action, player} keys and the GET-vs-POST split on the same path
|
|
// are the contract. A method/path/key drift here is invisible to typecheck (the body
|
|
// is `unknown`), so only these assertions catch it.
|
|
describe("api access-control wire shapes", () => {
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("accessWhitelistList GETs the whitelist path and returns players + raw output", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
name: "survival",
|
|
players: ["alice", "bob"],
|
|
output: "There are 2 whitelisted player(s): alice, bob",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.accessWhitelistList("survival");
|
|
expect(res.players).toEqual(["alice", "bob"]);
|
|
expect(res.output).toMatch(/alice, bob/);
|
|
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/access/whitelist");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
expect((opts as RequestInit).credentials).toBe("include");
|
|
});
|
|
|
|
it("accessWhitelist POSTs {action, player} to the same path", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
name: "survival",
|
|
action: "add",
|
|
player: "alice",
|
|
output: "[ok]",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
await api.accessWhitelist("survival", "add", "alice");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/access/whitelist");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
action: "add",
|
|
player: "alice",
|
|
});
|
|
});
|
|
|
|
it("accessBan POSTs {action, player} to the ban path (no reason field)", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
name: "survival",
|
|
action: "ban",
|
|
player: "griefer",
|
|
output: "[ok]",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
await api.accessBan("survival", "ban", "griefer");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/access/ban");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
action: "ban",
|
|
player: "griefer",
|
|
});
|
|
});
|
|
|
|
it("accessBanList GETs the ban path and returns players + raw output", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
name: "survival",
|
|
players: ["griefer", "spammer"],
|
|
output:
|
|
"There are 2 ban(s):\ngriefer was banned by Server: x\nspammer was banned by Server: y",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.accessBanList("survival");
|
|
expect(res.players).toEqual(["griefer", "spammer"]);
|
|
expect(res.output).toMatch(/griefer was banned by/);
|
|
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/access/ban");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
expect((opts as RequestInit).credentials).toBe("include");
|
|
});
|
|
|
|
it("accessPlayers GETs the players path and returns tally + names + raw output", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
name: "survival",
|
|
online: 2,
|
|
max: 20,
|
|
players: ["alice", "bob"],
|
|
output: "There are 2 of a max of 20 players online: alice, bob",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.accessPlayers("survival");
|
|
expect(res.online).toBe(2);
|
|
expect(res.max).toBe(20);
|
|
expect(res.players).toEqual(["alice", "bob"]);
|
|
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/access/players");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
expect((opts as RequestInit).credentials).toBe("include");
|
|
});
|
|
|
|
it("accessKick POSTs {player} to the kick path (no action, no reason)", async () => {
|
|
const fetchSpy = fakeFetch({
|
|
name: "survival",
|
|
player: "griefer",
|
|
output: "[ok]",
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
await api.accessKick("survival", "griefer");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/access/kick");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
player: "griefer",
|
|
});
|
|
});
|
|
|
|
it("maps the access error codes to stable human copy", async () => {
|
|
const { humanizeError } = await import("./api");
|
|
expect(humanizeError({ code: "not_running" })).toMatch(/running|wake/i);
|
|
expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i);
|
|
});
|
|
});
|
|
|
|
describe("image whitelist and builds wire shapes", () => {
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("addImage POSTs {image_ref} to /images", async () => {
|
|
const fetchSpy = fakeFetch({ image_ref: "x", source: "external", enabled: true });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.addImage("x");
|
|
expect(res.image_ref).toBe("x");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/images");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ image_ref: "x" });
|
|
});
|
|
|
|
it("removeImage DELETEs with ref in query params to /images", async () => {
|
|
const fetchSpy = fakeFetch(null);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
await api.removeImage("x");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/images?ref=x");
|
|
expect((opts as RequestInit).method).toBe("DELETE");
|
|
});
|
|
|
|
it("buildImage POSTs build details to /images/build", async () => {
|
|
const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "pending" });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.buildImage({ image_ref: "x", dockerfile: "FROM x", context_ref: "c" });
|
|
expect(res.id).toBe("bld-1");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/images/build");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
|
image_ref: "x",
|
|
dockerfile: "FROM x",
|
|
context_ref: "c",
|
|
});
|
|
});
|
|
|
|
it("getBuild GETs build status from /images/build/{id}", async () => {
|
|
const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "building" });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.getBuild("bld-1");
|
|
expect(res.status).toBe("building");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/images/build/bld-1");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
});
|
|
|
|
it("cancelBuild POSTs to cancel endpoint /images/build/{id}/cancel", async () => {
|
|
const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "cancelled" });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.cancelBuild("bld-1");
|
|
expect(res.status).toBe("cancelled");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/images/build/bld-1/cancel");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
});
|
|
|
|
describe("submissions", () => {
|
|
it("listSubmissions GETs from /submissions", async () => {
|
|
const submissions = [{ id: "sub-1", display_name: "test", status: "pending_review" }];
|
|
const fetchSpy = fakeFetch({ submissions });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.listSubmissions();
|
|
expect(res).toEqual(submissions);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/submissions");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
});
|
|
|
|
it("approveSubmission POSTs to /submissions/{id}/approve", async () => {
|
|
const sub = { id: "sub-1", status: "approved" };
|
|
const fetchSpy = fakeFetch(sub);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.approveSubmission("sub-1");
|
|
expect(res).toEqual(sub);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/submissions/sub-1/approve");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
});
|
|
|
|
it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => {
|
|
const sub = { id: "sub-1", status: "rejected", reject_reason: "bad" };
|
|
const fetchSpy = fakeFetch(sub);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.rejectSubmission("sub-1", "bad");
|
|
expect(res).toEqual(sub);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/submissions/sub-1/reject");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
|
});
|
|
|
|
it("listMySubmissions GETs from /me/submissions", async () => {
|
|
const submissions = [{ id: "sub-2", display_name: "my test", status: "pending_review" }];
|
|
const fetchSpy = fakeFetch({ submissions });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.listMySubmissions();
|
|
expect(res).toEqual(submissions);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/me/submissions");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
});
|
|
|
|
it("createSubmission POSTs {display_name} to /me/submissions", async () => {
|
|
const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" };
|
|
const fetchSpy = fakeFetch(sub);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.createSubmission("new submission");
|
|
expect(res).toEqual(sub);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/me/submissions");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ display_name: "new submission" });
|
|
});
|
|
|
|
it("uploadSubmissionContext POSTs Blob to /me/submissions/{id}/context", async () => {
|
|
const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" };
|
|
const fetchSpy = fakeFetch(sub);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const blob = new Blob(["test"], { type: "application/x-gzip" });
|
|
const res = await api.uploadSubmissionContext("sub-3", blob);
|
|
expect(res).toEqual(sub);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/me/submissions/sub-3/context");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect((opts as RequestInit).body).toBe(blob);
|
|
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" });
|
|
});
|
|
});
|
|
|
|
describe("updates maintenance window", () => {
|
|
it("getUpdateWindow GETs from /updates/window", async () => {
|
|
const win = { start: "2026-07-03T12:00:00Z", end: "2026-07-03T14:00:00Z" };
|
|
const fetchSpy = fakeFetch(win);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.getUpdateWindow();
|
|
expect(res).toEqual(win);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/updates/window");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
});
|
|
|
|
it("setUpdateWindow PUTs UpdateWindow payload to /updates/window", async () => {
|
|
const win = { start: "2026-07-03T12:00:00Z", end: "2026-07-03T14:00:00Z" };
|
|
const fetchSpy = fakeFetch(win);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.setUpdateWindow(win);
|
|
expect(res).toEqual(win);
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/updates/window");
|
|
expect((opts as RequestInit).method).toBe("PUT");
|
|
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/json" });
|
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
|
|
});
|
|
});
|
|
|
|
describe("backup now and server jobs wire shapes", () => {
|
|
it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
|
|
const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.backupNow("survival");
|
|
expect(res.status).toBe("backing_up");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/backup");
|
|
expect((opts as RequestInit).method).toBe("POST");
|
|
expect((opts as RequestInit).body).toBeUndefined();
|
|
});
|
|
|
|
it("serverJobs GETs /servers/{name}/jobs and unwraps the jobs array", async () => {
|
|
const job = {
|
|
name: "felis-backup-survival-123",
|
|
kind: "backup",
|
|
state: "failed",
|
|
message: "pod terminated",
|
|
started_at: "2026-07-03T12:00:00Z",
|
|
};
|
|
const fetchSpy = fakeFetch({ server: "survival", jobs: [job] });
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const res = await api.serverJobs("survival");
|
|
expect(res).toHaveLength(1);
|
|
expect(res[0].state).toBe("failed");
|
|
expect(res[0].message).toBe("pod terminated");
|
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(String(url)).toBe("/servers/survival/jobs");
|
|
expect((opts as RequestInit).method).toBe("GET");
|
|
});
|
|
});
|
|
});
|