Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
170 lines
6.7 KiB
TypeScript
170 lines
6.7 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 },
|
|
]);
|
|
});
|
|
// 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 },
|
|
]);
|
|
});
|
|
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("accepts an integer retention however it is serialized", () => {
|
|
expect(
|
|
parseBackupSchedules([
|
|
row({ database_backup_retention_amount_locally: "7" }),
|
|
])?.[0].retention,
|
|
).toBe(7);
|
|
});
|
|
});
|