cast/test/wire.test.ts
Daniel Marin 35d147f6c4
Merge pull request #65 from claude-hdb/feat/derive-resource-url
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60)
2026-07-15 00:49:49 +01:00

1245 lines
47 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
applicationApiFields,
buildExecutor,
databaseApiFields,
databaseVersionFromImage,
defaultDatabaseImage,
projectLiveFields,
serviceApiFields,
} from "../src/cli.js";
import { CoolifyClient } from "../src/coolify.js";
import { computeDiff } from "../src/diff.js";
import { DERIVED_UNRESOLVED } from "../src/envtemplate.js";
// Pure wire-translation helpers (Desired vocabulary <-> Coolify API
// vocabulary). Importing src/cli.ts here must not run the CLI — see the
// import.meta.url guard around main() at the bottom of that file.
describe("applicationApiFields", () => {
it("joins domains into a comma-separated string and renames port/healthcheck", () => {
const out = applicationApiFields({
port: 3000,
healthcheck: "/health",
domains: ["https://a.example.com", "https://b.example.com"],
});
expect(out).toEqual({
ports_exposes: "3000",
health_check_path: "/health",
domains: "https://a.example.com,https://b.example.com",
});
});
it("maps a docker_compose_domains map to the wire array-of-{name,domain} shape", () => {
const out = applicationApiFields({
docker_compose_domains: {
api: ["https://a", "https://b"],
admin: ["https://c"],
},
});
expect(out).toEqual({
docker_compose_domains: [
{ name: "api", domain: "https://a,https://b" },
{ name: "admin", domain: "https://c" },
],
});
});
});
describe("databaseApiFields", () => {
it("maps type+version to an image and drops the type/version keys", () => {
const out = databaseApiFields({ type: "postgresql", version: "17" });
expect(out).toEqual({ image: "postgres:17-alpine" });
expect(out).not.toHaveProperty("type");
expect(out).not.toHaveProperty("version");
});
});
describe("serviceApiFields", () => {
it("drops domains (services have no flat-domains create/update field)", () => {
const out = serviceApiFields({
type: "plausible",
domains: ["https://stats.example.com"],
});
expect(out).toEqual({ type: "plausible" });
expect(out).not.toHaveProperty("domains");
});
});
describe("projectLiveFields", () => {
it("projects a live application onto the Desired vocabulary", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
ports_exposes: "3000",
health_check_path: "/health",
fqdn: "https://a.example.com,https://b.example.com",
});
expect(out.domains).toEqual([
"https://a.example.com",
"https://b.example.com",
]);
expect(out.port).toBe(3000);
expect(out.healthcheck).toBe("/health");
});
it("normalizes a live database's database_type to the manifest vocabulary", () => {
const out = projectLiveFields("database", {
database_type: "standalone-postgresql",
image: "postgres:17-alpine",
});
expect(out).toEqual({ type: "postgresql", version: "17" });
});
it("projects both docker_compose_location and docker_compose_domains for a live compose app", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: JSON.stringify([
{ name: "api", domain: "https://api.widget.example.com" },
]),
});
expect(out.docker_compose_location).toBe("docker-compose.yaml");
expect(out.docker_compose_domains).toEqual({
api: ["https://api.widget.example.com"],
});
});
it("does not choke on an absent/null docker_compose_domains", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: null,
});
expect(out).not.toHaveProperty("docker_compose_domains");
});
// #63: the static-site fields must read back off a live application so they
// diff against the manifest — otherwise a UI flip of is_static is invisible.
it("reads is_static and the build/run commands off a live application", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "static",
base_directory: "/",
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
start_command: "node server.js",
});
expect(out.is_static).toBe(true);
expect(out.install_command).toBe("npm ci");
expect(out.build_command).toBe("npm run build -w apps/landing-site");
expect(out.start_command).toBe("node server.js");
});
it("always reports is_static as a boolean, tolerating Coolify's 1/0", () => {
expect(projectLiveFields("application", { is_static: 1 }).is_static).toBe(
true,
);
expect(projectLiveFields("application", { is_static: 0 }).is_static).toBe(
false,
);
// Absent on the wire reads as false (its real default), never undefined —
// so it compares against the desired side, which always emits it.
expect(projectLiveFields("application", {}).is_static).toBe(false);
});
});
describe("compose app idempotency (review finding #2)", () => {
it("produces zero field diffs when live docker_compose_location/domains match the manifest", () => {
const desired = [
{
kind: "application" as const,
name: "core",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: {
api: ["https://api.widget.example.com"],
},
},
},
];
const liveRaw = {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: JSON.stringify([
{ name: "api", domain: "https://api.widget.example.com" },
]),
};
const live = [
{
kind: "application" as const,
name: "core",
uuid: "app-uuid",
fields: projectLiveFields("application", liveRaw),
},
];
const report = computeDiff(desired, live, "structural");
expect(report.clean).toBe(true);
});
});
describe("buildExecutor createResource (application, dockercompose)", () => {
function mockFetch(handler: (path: string, init?: RequestInit) => Response) {
return vi.fn(async (url: string | URL, init?: RequestInit) =>
handler(new URL(String(url)).pathname, init),
) as unknown as typeof fetch;
}
it("sets connect_to_docker_network: true on a compose app create payload", async () => {
let createBody: Record<string, unknown> | undefined;
const fetchImpl = mockFetch((path, init) => {
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/projects/proj-1/environments")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
if (path === "/api/v1/applications/private-github-app") {
createBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ uuid: "app-1" }), {
status: 200,
});
}
return new Response("not found", { status: 404 });
});
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
});
const uuid = await exec.createResource({
kind: "application",
name: "core",
op: "create",
fieldDiffs: [
{ field: "build_pack", desired: "dockercompose", updatable: false },
{
field: "docker_compose_location",
desired: "docker-compose.yaml",
updatable: true,
},
{
field: "docker_compose_domains",
desired: { api: ["https://api.widget.example.com"] },
updatable: true,
},
],
envDiffs: [],
});
expect(uuid).toBe("app-1");
expect(createBody?.connect_to_docker_network).toBe(true);
expect(createBody?.docker_compose_domains).toEqual([
{ name: "api", domain: "https://api.widget.example.com" },
]);
});
it("does not set connect_to_docker_network for a non-compose app", async () => {
let createBody: Record<string, unknown> | undefined;
const fetchImpl = mockFetch((path, init) => {
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/projects/proj-1/environments")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
if (path === "/api/v1/applications/private-github-app") {
createBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ uuid: "app-2" }), {
status: 200,
});
}
return new Response("not found", { status: 404 });
});
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
});
await exec.createResource({
kind: "application",
name: "core-api",
op: "create",
fieldDiffs: [
{ field: "build_pack", desired: "nixpacks", updatable: false },
{ field: "domains", desired: ["https://a"], updatable: true },
],
envDiffs: [],
});
expect(createBody).not.toHaveProperty("connect_to_docker_network");
});
});
// Placement is create-time only, and every kind needs it: Coolify runs the same
// destination logic in ApplicationsController, DatabasesController and
// ServicesController, and 400s on a multi-destination server for whichever one
// omits it. Missing it on the database create alone would be enough to leave a
// project's Postgres on the shared default network.
describe("buildExecutor createResource (destination placement)", () => {
function captureCreates() {
const bodies: Record<string, Record<string, unknown>> = {};
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
// The environment a create names has to exist, so apply reads it first
// (#38). This project is an existing one and already carries `prod`.
if (path === "/api/v1/projects/proj-1/environments")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
if (
path === "/api/v1/applications/private-github-app" ||
path === "/api/v1/databases/postgresql" ||
path === "/api/v1/services"
) {
bodies[path] = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ uuid: "new-1" }), { status: 200 });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
return { bodies, fetchImpl };
}
const creates = [
{
path: "/api/v1/applications/private-github-app",
change: {
kind: "application" as const,
name: "core",
op: "create" as const,
fieldDiffs: [
{ field: "build_pack", desired: "nixpacks", updatable: false },
{ field: "domains", desired: ["https://a"], updatable: true },
],
envDiffs: [],
},
},
{
path: "/api/v1/databases/postgresql",
change: {
kind: "database" as const,
name: "postgres",
op: "create" as const,
fieldDiffs: [
{ field: "type", desired: "postgresql", updatable: false },
],
envDiffs: [],
},
},
{
path: "/api/v1/services",
change: {
kind: "service" as const,
name: "umami",
op: "create" as const,
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
envDiffs: [],
},
},
];
it.each(creates)(
"sends destination_uuid on the $path create",
async ({ path, change }) => {
const { bodies, fetchImpl } = captureCreates();
const client = new CoolifyClient(
"https://coolify.test",
"tok",
fetchImpl,
);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
destinationUuid: "dest-abc",
});
await exec.createResource(change);
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
// The server still has to be named — a destination belongs to one.
expect(bodies[path]?.server_uuid).toBe("srv-1");
},
);
// Undeclared must mean ABSENT, not empty-string: Coolify branches on
// `$request->has('destination_uuid')`, so sending "" would take the
// "you gave me one" path and then fail to match any destination.
it.each(creates)(
"omits destination_uuid entirely when none is declared ($path)",
async ({ path, change }) => {
const { bodies, fetchImpl } = captureCreates();
const client = new CoolifyClient(
"https://coolify.test",
"tok",
fetchImpl,
);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
});
await exec.createResource(change);
expect(bodies[path]).not.toHaveProperty("destination_uuid");
},
);
});
// #38: the first apply against a project that does not exist yet. POST /projects
// hands the new project Coolify's OWN default environment ("production"), never
// ours — so a create that names `environment_name: prod` 404s with "Environment
// not found" and leaves the project behind, created and empty. Every environment
// cast had touched until then was hand-built in a UI and adopted, which is why
// the from-nothing path is the one that had never run.
describe("buildExecutor createResource (environment reconcile)", () => {
// A Coolify with ONE project's worth of state, driven by what the test hands
// it: `projects` is what GET /projects answers, `environments` what the
// project carries. Both mutate as cast writes, so the mock stays honest about
// what a second read would see.
function fakeCoolify(opts: {
projects?: Array<{ uuid: string; name: string }>;
environments?: string[];
envCreateStatus?: number;
// What an environment HOLDS, by name — the guard on #40's delete. A project
// cast just created cannot really hold anything, which is exactly why the
// mock has to be able to say otherwise: the guard is worth only as much as
// the case it refuses.
holds?: Record<string, unknown[]>;
envDeleteStatus?: number;
}) {
const projects = opts.projects ?? [];
let environments = opts.environments ?? [];
const holds = opts.holds ?? {};
const calls: string[] = [];
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
const method = init?.method ?? "GET";
calls.push(`${method} ${path}`);
if (path === "/api/v1/projects" && method === "GET")
return new Response(JSON.stringify(projects), { status: 200 });
if (path === "/api/v1/projects" && method === "POST") {
projects.push({ uuid: "proj-new", name: "widget" });
// Coolify's doing, not ours: a brand-new project comes with this.
environments = ["production"];
return new Response(JSON.stringify({ uuid: "proj-new" }), {
status: 201,
});
}
const envRoute = /^\/api\/v1\/projects\/([^/]+)\/environments$/.exec(
path,
);
if (envRoute && method === "GET")
return new Response(
JSON.stringify(environments.map((name) => ({ name }))),
{ status: 200 },
);
// CoolifyClient.environments falls back to the project show route when
// the list route answers empty — it carries the same names as a relation.
if (/^\/api\/v1\/projects\/[^/]+$/.test(path) && method === "GET")
return new Response(
JSON.stringify({
environments: environments.map((name) => ({ name })),
}),
{ status: 200 },
);
if (envRoute && method === "POST") {
const name = JSON.parse(String(init?.body)).name as string;
const status = opts.envCreateStatus ?? 201;
if (status === 409) {
// A 409 is Coolify saying the name is TAKEN — so in the world the
// mock is modelling it exists, created by whoever won the race
// between our read and our write. The environment is there; only our
// create lost. A 409 whose environment did not exist is not a state
// Coolify can be in, and pretending otherwise would test nothing.
if (!environments.includes(name)) environments.push(name);
return new Response(
JSON.stringify({
message: "Environment with this name already exists.",
}),
{ status: 409 },
);
}
if (status !== 201)
return new Response(JSON.stringify({ message: "boom" }), { status });
environments.push(name);
return new Response(JSON.stringify({ uuid: "env-1" }), { status: 201 });
}
// DELETE /projects/{uuid}/environments/{name} — #40. Three segments, so the
// two-segment details route below cannot swallow it.
const envDelete =
/^\/api\/v1\/projects\/([^/]+)\/environments\/([^/]+)$/.exec(path);
if (envDelete && method === "DELETE") {
const status = opts.envDeleteStatus ?? 200;
if (status !== 200)
return new Response(JSON.stringify({ message: "boom" }), { status });
environments = environments.filter((e) => e !== envDelete[2]);
return new Response(
JSON.stringify({ message: "Environment deleted." }),
{ status: 200 },
);
}
// GET /projects/{uuid}/{environment} — the details route, the ONLY one that
// eager-loads an environment's resources, and so the only one that can
// answer "is it empty?". The list route's Environment objects carry no
// relations at all. Checked after the /environments routes above, which
// this pattern would otherwise match.
const envDetail = /^\/api\/v1\/projects\/([^/]+)\/([^/]+)$/.exec(path);
if (envDetail && method === "GET") {
const name = envDetail[2];
if (!environments.includes(name))
return new Response(JSON.stringify({ message: "Not found." }), {
status: 404,
});
return new Response(
JSON.stringify({
id: 1,
name,
project_id: 1,
description: "",
applications: holds[name] ?? [],
postgresqls: [],
redis: [],
services: [],
}),
{ status: 200 },
);
}
if (path === "/api/v1/applications/private-github-app") {
// Coolify's actual rule, and the whole of #38: a create names an
// environment, and an environment that is not there is a 404. Without
// it this mock would happily accept the create that a real box refuses,
// and the test below would pass against the very bug it exists to catch.
const body = JSON.parse(String(init?.body)) as {
environment_name: string;
};
if (!environments.includes(body.environment_name))
return new Response(
JSON.stringify({ message: "Environment not found." }),
{ status: 404 },
);
return new Response(JSON.stringify({ uuid: "app-1" }), { status: 200 });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
return { calls, fetchImpl, environments: () => environments };
}
const app = {
kind: "application" as const,
name: "core",
op: "create" as const,
fieldDiffs: [
{ field: "build_pack", desired: "nixpacks", updatable: false },
],
envDiffs: [],
};
// envName is a parameter because #40's third guard is about it: an environment
// named `production` is Coolify's leftover default in every run EXCEPT the one
// that asked for `--environment production`, where it is the environment
// everything is about to live in.
function exec(fetchImpl: typeof fetch, envName = "prod") {
return buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
{
projectName: "widget",
envName,
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
},
);
}
it("creates the environment on a project it just created, and the create names it", async () => {
const coolify = fakeCoolify({ projects: [] });
const uuid = await exec(coolify.fetchImpl).createResource(app);
expect(uuid).toBe("app-1");
// The fix: our environment is created BEFORE the resource that names it.
expect(coolify.calls).toContain(
"POST /api/v1/projects/proj-new/environments",
);
expect(coolify.environments()).toContain("prod");
const order = coolify.calls.indexOf(
"POST /api/v1/projects/proj-new/environments",
);
const create = coolify.calls.indexOf(
"POST /api/v1/applications/private-github-app",
);
expect(order).toBeGreaterThan(-1);
expect(order).toBeLessThan(create);
});
// The half of idempotence that protects every apply that works today: an
// environment that already exists must not be written to at all.
it("never touches the create route when the environment already exists", async () => {
const coolify = fakeCoolify({
projects: [{ uuid: "proj-1", name: "widget" }],
environments: ["prod", "staging"],
});
await exec(coolify.fetchImpl).createResource(app);
expect(coolify.calls).toContain("GET /api/v1/projects/proj-1/environments");
expect(coolify.calls).not.toContain(
"POST /api/v1/projects/proj-1/environments",
);
});
// Coolify 409s a duplicate environment name — the same answer as "present",
// reached when something else wins the race between our read and our write.
it("treats a 409 from the environment create as already-there", async () => {
const coolify = fakeCoolify({
projects: [{ uuid: "proj-1", name: "widget" }],
environments: [],
envCreateStatus: 409,
});
const uuid = await exec(coolify.fetchImpl).createResource(app);
expect(uuid).toBe("app-1");
});
// A 5xx is NOT "already there" — apply must not go on to create resources
// into an environment it has no reason to believe exists.
it("surfaces a non-409 failure from the environment create", async () => {
const coolify = fakeCoolify({
projects: [{ uuid: "proj-1", name: "widget" }],
environments: [],
envCreateStatus: 500,
});
await expect(exec(coolify.fetchImpl).createResource(app)).rejects.toThrow(
/environments → 500/,
);
});
// Five creates in a run must reconcile the project and its environment once,
// not five times.
it("reconciles once across several creates", async () => {
const coolify = fakeCoolify({ projects: [] });
const e = exec(coolify.fetchImpl);
await e.createResource(app);
await e.createResource({ ...app, name: "core-api" });
const envReads = coolify.calls.filter((c) => c.endsWith("/environments"));
expect(envReads).toHaveLength(2); // one GET + one POST, not two of each
expect(
coolify.calls.filter((c) => c === "POST /api/v1/projects").length,
).toBe(1);
});
// #40: what #39 left behind. POST /projects hands the new project Coolify's own
// default environment, and #39 then created OURS beside it — so every project cast
// creates from nothing carried a permanently-empty `production` next to the
// environment everything actually lives in. That is the shape that makes a box
// unreadable later; the box being migrated away from has an empty `production` and
// runs everything in `staging`, and "the obvious guess is the wrong one" is a note
// we had to write for ourselves.
//
// This is also the ONE delete cast performs, so the guards are the test: it happens
// only to a project cast made in this run, only to an environment that is empty, and
// only when the name is not the one we asked for.
describe("buildExecutor createResource (default environment removal, #40)", () => {
const DELETE_DEFAULT =
"DELETE /api/v1/projects/proj-new/environments/production";
it("removes the empty default environment from a project it just created", async () => {
const coolify = fakeCoolify({ projects: [] });
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
const uuid = await exec(coolify.fetchImpl).createResource(app);
expect(uuid).toBe("app-1");
expect(coolify.calls).toContain(DELETE_DEFAULT);
// The point of the whole issue: what is left is ours, and only ours.
expect(coolify.environments()).toEqual(["prod"]);
expect(notes.mock.calls.flat().join("\n")).toMatch(
/removed Coolify's default environment production/,
);
notes.mockRestore();
});
// Guard 1. The rule cast does not get to break: a project someone built by hand
// is not cast's to tidy, whatever it happens to carry. `production` sitting empty
// next to `staging` on an ADOPTED project is exactly the live box we migrate from
// — and it stays untouched.
it("never removes an environment from a project it adopted", async () => {
const coolify = fakeCoolify({
projects: [{ uuid: "proj-1", name: "widget" }],
environments: ["production", "prod"],
});
await exec(coolify.fetchImpl).createResource(app);
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
expect(coolify.environments()).toContain("production");
});
// Guard 2. Asked of Coolify, not inferred from "we just made this project". The
// mock is lying here — a fresh project cannot hold an application — and it must
// be able to, because a guard that is only ever handed the safe case is not a
// guard. cast declines, and says why.
it("leaves the default environment alone when it holds anything", async () => {
const coolify = fakeCoolify({
projects: [],
holds: { production: [{ uuid: "app-x", name: "legacy" }] },
});
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
await exec(coolify.fetchImpl).createResource(app);
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
expect(coolify.environments()).toContain("production");
expect(notes.mock.calls.flat().join("\n")).toMatch(
/left Coolify's default environment production .* NOT empty/,
);
notes.mockRestore();
});
// Guard 3. `production` is a leftover in every run except the one that asked for
// it, where it is the environment everything is about to live in. Deleting it
// there would delete the target of the very apply doing the deleting.
it("keeps the default environment when it is the one we asked for", async () => {
const coolify = fakeCoolify({ projects: [] });
await exec(coolify.fetchImpl, "production").createResource(app);
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
expect(coolify.environments()).toEqual(["production"]);
// ...and it was never re-created either: it was already there.
expect(coolify.calls).not.toContain(
"POST /api/v1/projects/proj-new/environments",
);
});
// Tidying is a courtesy, and a courtesy that can fail an apply is not one. The
// resource still gets created; the operator is told what was left behind.
it("does not fail the apply when the delete fails", async () => {
const coolify = fakeCoolify({ projects: [], envDeleteStatus: 500 });
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
const uuid = await exec(coolify.fetchImpl).createResource(app);
expect(uuid).toBe("app-1");
expect(coolify.environments()).toContain("production");
expect(notes.mock.calls.flat().join("\n")).toMatch(
/could not remove it .*500/s,
);
notes.mockRestore();
});
});
});
// #41: a first apply against a server with more than one destination 400s on the
// first create — with the project and the environment already made. Coolify's own
// message names neither the remedy nor the file it goes in, and cast cannot
// pre-flight the condition (4.1.2 serves no destinations API at all, so a server's
// destination count is unknowable until a create has been attempted). The diagnosis
// is the whole of what is fixable, so the diagnosis is what is tested.
describe("buildExecutor createResource (multi-destination 400, #41)", () => {
const app = {
kind: "application" as const,
name: "core",
op: "create" as const,
fieldDiffs: [
{ field: "build_pack", desired: "nixpacks", updatable: false },
],
envDiffs: [],
};
// Coolify's real answer, verbatim, from all three create controllers.
function multiDestinationCoolify() {
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
const method = init?.method ?? "GET";
if (path === "/api/v1/projects" && method === "GET")
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/projects/proj-1/environments" && method === "GET")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
return new Response(
JSON.stringify({
message:
"Server has multiple destinations and you do not set destination_uuid.",
}),
{ status: 400 },
);
}) as unknown as typeof fetch;
}
const exec = (fetchImpl: typeof fetch) =>
buildExecutor(new CoolifyClient("https://coolify.test", "tok", fetchImpl), {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
// The names the message has to be able to say back. None is on the wire:
// Coolify knows srv-1, the operator wrote prod-box.
serverName: "prod-box",
orgRepo: "heavy-duty/incubator",
bindingEnv: "prod",
});
const kinds = [
{ label: "application", change: app },
{
label: "database",
change: {
kind: "database" as const,
name: "postgres",
op: "create" as const,
fieldDiffs: [
{ field: "type", desired: "postgresql", updatable: false },
],
envDiffs: [],
},
},
{
label: "service",
change: {
kind: "service" as const,
name: "umami",
op: "create" as const,
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
envDiffs: [],
},
},
];
// Every kind, because which one 400s first depends only on the order of the
// manifest — Coolify runs the same destination logic in all three controllers.
it.each(kinds)(
"answers the $label 400 with the state-file path the UUID goes in",
async ({ change }) => {
const err = await exec(multiDestinationCoolify())
.createResource(change)
.catch((e: Error) => e);
expect(err).toBeInstanceOf(Error);
const message = (err as Error).message;
// The remedy: the exact key, in the exact place, with the repo and the env
// the operator actually named.
expect(message).toContain(
"environments.prod.projects.heavy-duty/incubator.destination_uuid",
);
// The server, by the name the operator wrote — not srv-1.
expect(message).toContain("prod-box has multiple destinations");
// Which resource it died on, so a half-applied run can be read.
expect(message).toContain(`cannot create ${change.kind} ${change.name}`);
// Create-time: the part that decides whether they can fix this with an
// apply (they cannot) or a delete + recreate (they must).
expect(message).toMatch(/cannot be moved between networks later/);
// Coolify's own words survive — a translation that hides the original
// makes the next person's search fail.
expect(message).toContain(
"Server has multiple destinations and you do not set destination_uuid.",
);
},
);
// The translation must be about THIS 400, not about 400s. A create rejected for
// any other reason has to arrive unmolested, or the next bug gets a confident
// answer about a destination it has nothing to do with.
it("leaves every other failure exactly as Coolify sent it", async () => {
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
const method = init?.method ?? "GET";
if (path === "/api/v1/projects" && method === "GET")
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/projects/proj-1/environments" && method === "GET")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
return new Response(
JSON.stringify({ message: "The name field is required." }),
{ status: 422 },
);
}) as unknown as typeof fetch;
const err = await exec(fetchImpl)
.createResource(app)
.catch((e: Error) => e);
expect((err as Error).message).toContain("The name field is required.");
expect((err as Error).message).not.toMatch(/destination/);
});
});
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
it("round-trips through defaultDatabaseImage for postgres", () => {
const image = defaultDatabaseImage("postgresql", "17");
expect(databaseVersionFromImage(image)).toBe("17");
});
});
// The write half of #51. Backup schedules live on their own route
// (/databases/{uuid}/backups), so `apply` has to read that route to know
// whether to POST or PATCH — and used to do neither outside the create branch.
describe("buildExecutor backup schedules", () => {
type Call = { method: string; path: string; body?: unknown };
// Records every request so a test can assert not just what was written, but
// what was NOT — a schedule silently skipped is the whole defect.
function recorder(handler: (path: string, init?: RequestInit) => Response): {
calls: Call[];
fetchImpl: typeof fetch;
} {
const calls: Call[] = [];
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
calls.push({
method: init?.method ?? "GET",
path,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
});
return handler(path, init);
}) as unknown as typeof fetch;
return { calls, fetchImpl };
}
const ctx = {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
s3DestinationUuid: "s3-1",
};
const schedule = { frequency: "0 3 * * *", retention: 7 };
const liveRow = {
uuid: "sched-1",
frequency: "0 5 * * *",
database_backup_retention_amount_locally: 3,
enabled: true,
};
// THE FIX. A database that exists and has no schedule gets one on update —
// before this, `apply` wrote a schedule only inside the create branch, so
// adding `backup:` to a live database was a clean run and zero backups.
it("CREATES the schedule on update when the database has none", async () => {
const { calls, fetchImpl } = recorder((path, init) => {
if (path === "/api/v1/databases/db-1/backups" && init?.method === "POST")
return new Response(JSON.stringify({ uuid: "sched-new" }), {
status: 201,
});
if (path === "/api/v1/databases/db-1/backups")
return new Response(JSON.stringify([]), { status: 200 }); // read: none
return new Response("{}", { status: 200 });
});
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.updateFields("db-1", "database", { backup: schedule });
const post = calls.find((c) => c.method === "POST");
expect(post?.path).toBe("/api/v1/databases/db-1/backups");
expect(post?.body).toEqual({
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
save_s3: true,
s3_storage_uuid: "s3-1",
enabled: true,
});
});
it("PATCHES the existing schedule rather than adding a second one", async () => {
const { calls, fetchImpl } = recorder((path, init) => {
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
return new Response(JSON.stringify([liveRow]), { status: 200 });
return new Response("{}", { status: 200 });
});
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.updateFields("db-1", "database", { backup: schedule });
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
const patch = calls.find((c) => c.method === "PATCH");
expect(patch?.path).toBe("/api/v1/databases/db-1/backups/sched-1");
expect(patch?.body).toMatchObject({
frequency: "0 3 * * *",
database_backup_retention_amount_locally: 7,
// Re-enabled: declaring `backup:` asks for backups, not for a disabled
// row that looks like backups.
enabled: true,
});
});
it("does not PATCH the database itself for a backup-only change", async () => {
const { calls, fetchImpl } = recorder((path, init) => {
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
return new Response(JSON.stringify([liveRow]), { status: 200 });
return new Response("{}", { status: 200 });
});
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.updateFields("db-1", "database", { backup: schedule });
// `backup` is not a column on the database — it must never reach the
// database's own update body, and an empty body is not worth a write.
expect(calls.some((c) => c.path === "/api/v1/databases/db-1")).toBe(false);
});
// Degrade honestly: an apply that promised a backup schedule and cannot tell
// whether one already exists must STOP, not guess. POSTing blind would
// duplicate an existing schedule; skipping is the silent no-op being fixed.
it("refuses to guess when the schedule read fails", async () => {
const { calls, fetchImpl } = recorder((path, init) => {
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
return new Response("gateway timeout", { status: 504 });
return new Response("{}", { status: 200 });
});
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await expect(
exec.updateFields("db-1", "database", { backup: schedule }),
).rejects.toThrow(/cannot set the declared backup schedule/);
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
});
it("refuses to guess which of several schedules the manifest meant", async () => {
const { fetchImpl } = recorder((path, init) => {
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
return new Response(
JSON.stringify([liveRow, { ...liveRow, uuid: "sched-2" }]),
{ status: 200 },
);
return new Response("{}", { status: 200 });
});
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await expect(
exec.updateFields("db-1", "database", { backup: schedule }),
).rejects.toThrow(/holds 2 backup schedules/);
});
it("leaves an undeclared schedule alone (apply never removes)", async () => {
const { calls, fetchImpl } = recorder(
() => new Response("{}", { status: 200 }),
);
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.updateFields("db-1", "database", { version: "17" });
expect(calls.some((c) => c.path.includes("/backups"))).toBe(false);
});
it("still POSTs the schedule on create, without a read", async () => {
const { calls, fetchImpl } = recorder((path) => {
if (path === "/api/v1/projects")
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/projects/proj-1/environments")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
if (path === "/api/v1/databases/postgresql")
return new Response(JSON.stringify({ uuid: "db-9" }), { status: 201 });
return new Response(JSON.stringify({ uuid: "sched-9" }), { status: 201 });
});
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.createResource({
kind: "database",
name: "postgres",
op: "create",
fieldDiffs: [
{ field: "type", desired: "postgresql", updatable: false },
{ field: "backup", desired: schedule, updatable: true },
],
envDiffs: [],
});
// A database created a moment ago provably has no schedule: POST straight
// out, with no read that could fail and abort the create.
expect(
calls.some((c) => c.method === "GET" && c.path.includes("/backups")),
).toBe(false);
const post = calls.find((c) => c.path === "/api/v1/databases/db-9/backups");
expect(post?.body).toMatchObject({ frequency: "0 3 * * *", save_s3: true });
// And `backup` never reaches the database's own create body.
const create = calls.find((c) => c.path === "/api/v1/databases/postgresql");
expect(create?.body).not.toHaveProperty("backup");
});
it("refuses a declared schedule with no s3_destination configured", async () => {
const { fetchImpl } = recorder(
() => new Response(JSON.stringify([]), { status: 200 }),
);
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
{ ...ctx, s3DestinationUuid: undefined },
);
await expect(
exec.updateFields("db-1", "database", { backup: schedule }),
).rejects.toThrow(/no s3_destination UUID/);
});
});
// The from-nothing half of derived resource URLs (#60): at plan time the
// database did not exist, so the app's ${resource:postgres.url} is still
// unresolved when apply goes to write its env. syncEnv reads the URL back from
// the environment_details route (the database was created earlier in the same
// apply) and writes the real value — or refuses, rather than writing a blank.
describe("buildExecutor syncEnv (derived resource URLs, #60)", () => {
const ctx = {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
serverName: "prod-box",
orgRepo: "acme/widget",
bindingEnv: "prod",
};
const derivedEnv = {
vars: {
DATABASE_URL: {
value: DERIVED_UNRESOLVED,
secret: true,
derived: { resource: "postgres", attr: "url" as const },
},
},
};
const URL_ = "postgres://u:p@pg-uuid:5432/widget";
// Serves /projects and the environment_details route; records the envs/bulk
// write so a test can assert what value landed (or that none did).
function box(internalDbUrl?: string) {
const bulkBodies: unknown[] = [];
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
if (path === "/api/v1/projects")
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/projects/proj-1/prod")
return new Response(
JSON.stringify({
postgresqls: [
internalDbUrl === undefined
? { name: "postgres" }
: { name: "postgres", internal_db_url: internalDbUrl },
],
}),
{ status: 200 },
);
if (path === "/api/v1/applications/app-1/envs/bulk") {
bulkBodies.push(JSON.parse(String(init?.body)));
return new Response(JSON.stringify([]), { status: 200 });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
return { fetchImpl, bulkBodies };
}
it("resolves the ref from the live database and writes the real URL", async () => {
const { fetchImpl, bulkBodies } = box(URL_);
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.syncEnv("app-1", "application", derivedEnv);
expect(bulkBodies).toHaveLength(1);
expect(bulkBodies[0]).toEqual({
data: [
{
key: "DATABASE_URL",
value: URL_,
is_buildtime: false,
is_preview: false,
},
],
});
});
it("refuses — and writes nothing — when the database has no URL yet", async () => {
const { fetchImpl, bulkBodies } = box(undefined);
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await expect(
exec.syncEnv("app-1", "application", derivedEnv),
).rejects.toThrow(/cannot resolve derived env var/);
expect(bulkBodies).toHaveLength(0);
});
it("does not read the box at all when there is nothing derived to resolve", async () => {
const { fetchImpl, bulkBodies } = box(URL_);
const exec = buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
ctx,
);
await exec.syncEnv("app-1", "application", {
vars: { PORT: { value: "3000", secret: false } },
});
// Straight to the write — no /projects lookup for a derived URL.
expect(bulkBodies).toHaveLength(1);
expect(
(
fetchImpl as unknown as { mock: { calls: unknown[][] } }
).mock.calls.some(([u]) => String(u).endsWith("/projects")),
).toBe(false);
});
});