cast/test/coolify.test.ts
claude-hdb 1210ae4445 fix(draft): read backup schedules and emit backup blocks (#75)
--emit-draft still told every reader that backup schedules "are not
exposed by Coolify's API" — the exact pre-#51 claim that issue disproved:
GET /databases/{uuid}/backups is a route, and diff/apply have read it on
every run since. The draft path was never brought along, so it warned
instead of reading, and a rebuild from a draft came up with no backups.

Now the draft loop makes the same supplementary per-database GET
(databaseBackupSchedules) for every DRAFTED database and databaseSpec
emits a real backup: { frequency, retention } block for the one shape
the manifest can express — a single, enabled schedule. Ungated on
purpose: fetchLive's opts.backups gate exists because the read-side
sweeps never look at the answer, and the draft is the sweep that does.
The read stays sequential (like the existing per-resource env GETs) and
a failed read degrades to an UNCAPTURED entry per resource rather than
aborting the whole-instance sweep — a draft's reader is a human, not an
apply about to write.

UNCAPTURED keeps only what the route genuinely cannot answer:
- the S3 target: save_s3 now rides on LiveBackup, and a schedule that
  saves to S3 gets a per-database entry saying the target reads back
  only as s3_storage_id, an int nothing maps to a storage UUID
- a DISABLED schedule (declaring the block would make apply re-enable it)
- several schedules where a manifest declares one
- an unreadable route (reported, never read as "no backups")

The stale NO_API_COVERAGE "backup schedules" row becomes "a backup
schedule's S3 target", and semantics.md's draft section now tells the
truth about what is captured.

Closes #75

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00

194 lines
7.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { CoolifyClient, parseBackupSchedules } from "../src/coolify.js";
// A row as Coolify actually serializes one: a raw ScheduledDatabaseBackup
// Eloquent model (DatabasesController@database_backup_details_uuid, v4.1.2),
// so every column is present and `executions` is eager-loaded alongside.
const row = (over: Record<string, unknown> = {}) => ({
id: 3,
uuid: "sched-1",
team_id: 1,
enabled: true,
save_s3: true,
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
database_id: 9,
database_type: "App\\Models\\StandalonePostgresql",
s3_storage_id: 2,
executions: [],
...over,
});
function mockFetch(routes: Record<string, unknown>) {
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${new URL(String(url)).pathname}`;
if (!(key in routes)) return new Response("not found", { status: 404 });
return new Response(JSON.stringify(routes[key]), { status: 200 });
}) as unknown as typeof fetch;
}
describe("CoolifyClient", () => {
it("sends bearer auth and resolves servers by name", async () => {
const fetchImpl = mockFetch({
"GET /api/v1/servers": [{ uuid: "srv-1", name: "prod-box" }],
});
const c = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
expect(await c.serverUuid("prod-box")).toBe("srv-1");
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect((call[1].headers as Record<string, string>).Authorization).toBe(
"Bearer tok",
);
});
it("throws a named error when a resolver misses", async () => {
const c = new CoolifyClient(
"https://coolify.test",
"tok",
mockFetch({ "GET /api/v1/servers": [] }),
);
await expect(c.serverUuid("nope")).rejects.toThrow(
/not found in Coolify: server nope/,
);
});
it("surfaces API errors with method, path and status", async () => {
const c = new CoolifyClient("https://coolify.test", "tok", mockFetch({}));
await expect(c.get("/projects")).rejects.toThrow(/GET \/projects → 404/);
});
it("reads the token's team from /teams/current", async () => {
const c = new CoolifyClient(
"https://coolify.test",
"tok",
mockFetch({
"GET /api/v1/teams/current": {
id: 1,
name: "heavy-duty",
personal_team: false,
},
}),
);
await expect(c.currentTeam()).resolves.toEqual({
id: 1,
name: "heavy-duty",
});
});
it("reads version as plain text, not JSON", async () => {
const fetchImpl = vi.fn(
async () => new Response("4.1.2", { status: 200 }),
) as unknown as typeof fetch;
const c = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
await expect(c.version()).resolves.toBe("4.1.2");
});
it("reads a database's backup schedules", async () => {
const c = new CoolifyClient(
"https://coolify.test",
"tok",
mockFetch({ "GET /api/v1/databases/db-1/backups": [row()] }),
);
await expect(c.databaseBackupSchedules("db-1")).resolves.toEqual([
{
uuid: "sched-1",
frequency: "0 3 * * *",
retention: 7,
enabled: true,
saveS3: true,
},
]);
});
// The dangerous 404. fetchLive reads a 404 as "no live environment" three
// routes up, and that instinct is WRONG here: Coolify answers a database with
// no schedules with 200 [], never 404 — a 404 means the database wasn't found.
// Reading it as "no backups" would report an unbacked-up database as clean and
// make apply POST a duplicate schedule onto one that already had one.
it("does not turn a failed read into 'this database has no backups'", async () => {
const c = new CoolifyClient("https://coolify.test", "tok", mockFetch({}));
await expect(c.databaseBackupSchedules("db-1")).resolves.toBeUndefined();
});
});
// The shape cast cannot afford to be wrong about. Every unreadable answer must
// land on `undefined` ("cast cannot say"), and ONLY a genuinely empty list may
// land on `[]` ("there are none") — the two mean opposite things to every
// caller downstream, and to the operator staring at a cutover.
describe("parseBackupSchedules", () => {
it("reads a well-formed collection", () => {
expect(parseBackupSchedules([row()])).toEqual([
{
uuid: "sched-1",
frequency: "0 3 * * *",
retention: 7,
enabled: true,
saveS3: true,
},
]);
});
it("reads an empty list as a trustworthy 'no schedule', not as unknown", () => {
expect(parseBackupSchedules([])).toEqual([]);
});
it("reads the spec's own placeholder body as unknown, not as 'no schedule'", () => {
// What the vendored OpenAPI literally documents for this route. If Coolify
// ever really answered this, cast must not read it as "no backups".
expect(
parseBackupSchedules(
"Content is very complex. Will be implemented later.",
),
).toBeUndefined();
});
it.each([
["a non-array object", { data: [] }],
["null", null],
["a row that is not an object", ["nope"]],
["a row with no frequency", [row({ frequency: undefined })]],
["a row with a non-string frequency", [row({ frequency: 3 })]],
[
"a row with a null retention",
[row({ database_backup_retention_amount_locally: null })],
],
[
"a row with a non-numeric retention",
[row({ database_backup_retention_amount_locally: "many" })],
],
])("reads %s as unknown", (_label, body) => {
expect(parseBackupSchedules(body)).toBeUndefined();
});
it("collapses the WHOLE read when any one row is unreadable", () => {
// A partial list is indistinguishable from a complete one downstream, and
// the caller worth protecting is the one asking "is this backed up?".
expect(
parseBackupSchedules([row(), row({ uuid: 7, frequency: null })]),
).toBeUndefined();
});
it("reads a disabled schedule as disabled, however Coolify spells it", () => {
// `enabled` has no cast on the model (v4.1.2 casts() covers only the two
// float storage fields), so a tinyint column can serialize as 1/0.
expect(parseBackupSchedules([row({ enabled: 0 })])?.[0].enabled).toBe(
false,
);
expect(parseBackupSchedules([row({ enabled: false })])?.[0].enabled).toBe(
false,
);
expect(parseBackupSchedules([row({ enabled: 1 })])?.[0].enabled).toBe(true);
// Absent reads as enabled: Coolify's create path defaults it to true.
expect(
parseBackupSchedules([row({ enabled: undefined })])?.[0].enabled,
).toBe(true);
});
it("reads save_s3 however Coolify spells it — and absent as false", () => {
// Same tinyint-with-no-cast story as `enabled`, opposite default: "this
// backup lands in S3" must never be claimed off a field the row lacks.
expect(parseBackupSchedules([row({ save_s3: 1 })])?.[0].saveS3).toBe(true);
expect(parseBackupSchedules([row({ save_s3: 0 })])?.[0].saveS3).toBe(false);
expect(parseBackupSchedules([row({ save_s3: false })])?.[0].saveS3).toBe(
false,
);
expect(
parseBackupSchedules([row({ save_s3: undefined })])?.[0].saveS3,
).toBe(false);
});
it("accepts an integer retention however it is serialized", () => {
expect(
parseBackupSchedules([
row({ database_backup_retention_amount_locally: "7" }),
])?.[0].retention,
).toBe(7);
});
});