cast/test/destroy.test.ts
claude-hdb 6849d29f0e feat(destroy): a scoped teardown verb, gated in state (#43)
`apply` fails closed on an immutable field with "resolve manually" — which
meant a hand deletion in the Coolify UI, unscoped and unconfirmed, against an
instance whose token can see every project on it. That is how the wrong project
gets deleted.

`cast destroy <org>/<repo> --env <env> [--with-project]` is that act, scoped:

- MANIFEST-SCOPED. It deletes the resources the manifest declares in that
  project and that environment, in reverse dependency order (applications →
  services → databases). Anything else it finds is reported and LEFT STANDING —
  that report is how a resource created outside cast gets discovered, and the
  boxes in this fleet are multi-project by design.
- Not a flag on apply. `apply never deletes` is the invariant that makes it safe
  to run on a schedule; apply.ts and diff.ts are untouched.
- REFUSES rather than no-ops: --all (always), a read-only instance, an absent
  project (D-237 — an absent target must never read as a clean empty plan), a
  manifest that declares nothing this environment holds, and --with-project
  while anything undeclared is still in the project.
- The prod interlock lives in STATE, not argv: environments.<env>.destroy_allowed
  in environments.yaml, absent = refuse. A flag is a thing you type without
  reading; this is a line a human edits, commits and merges.
- The plan says what the delete COSTS: every database line carries its backup
  schedule and when the last backup landed. A backups route cast cannot read
  prints UNKNOWN and is treated as unrecoverable — it never rounds down to NONE.
- Last gate: the environment's name, typed (capture's ceremony).

Coolify's DELETE query params are sent explicitly (all four default to true):
delete_volumes, delete_connected_networks, delete_configurations — and
docker_cleanup=FALSE, because that one prunes the whole SERVER, and these boxes
host other people's production.
2026-07-14 22:52:17 +00:00

748 lines
26 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DESTROY_ORDER,
type DestroyExecutor,
type DestroyTarget,
executeDestroy,
planDestroy,
readBackupState,
renderDestroyPlan,
} from "../src/destroy.js";
// The refusals ARE the product. `destroy` is the only verb in cast that removes
// something a manifest declared, and the difference between it and a hand
// deletion in the Coolify UI is entirely in what it declines to do — so every
// gate below is a test, and the happy path is the short section at the end.
// ---------------------------------------------------------------- unit tests
describe("DESTROY_ORDER", () => {
it("is the create order, backwards", () => {
expect([...DESTROY_ORDER]).toEqual(["application", "service", "database"]);
});
});
describe("planDestroy", () => {
const live = [
{ kind: "database" as const, name: "postgres", uuid: "d1" },
{ kind: "application" as const, name: "core", uuid: "a1" },
{ kind: "service" as const, name: "metabase", uuid: "s1" },
{ kind: "database" as const, name: "cache", uuid: "d2" },
];
it("orders the deletes applications → services → databases", () => {
const plan = planDestroy(
[
{ kind: "application", name: "core" },
{ kind: "service", name: "metabase" },
{ kind: "database", name: "postgres" },
{ kind: "database", name: "cache" },
],
live,
);
expect(plan.targets.map((t) => `${t.kind} ${t.name}`)).toEqual([
"application core",
"service metabase",
"database cache",
"database postgres",
]);
});
// The single most important property of this verb: a resource the manifest
// does not declare is REPORTED, and never becomes a target. An instance-scoped
// (or even environment-scoped) destroy is one wrong argument away from
// deleting another project's production, and the boxes in this fleet are
// multi-project by design.
it("leaves what the manifest does not declare standing, and reports it", () => {
const plan = planDestroy([{ kind: "application", name: "core" }], live);
expect(plan.targets.map((t) => t.name)).toEqual(["core"]);
expect(plan.undeclared).toEqual([
{ kind: "database", name: "postgres" },
{ kind: "service", name: "metabase" },
{ kind: "database", name: "cache" },
]);
});
// Same name, different kind, is a different resource — and one of them is a
// delete of a database.
it("matches on kind AND name, never on name alone", () => {
const plan = planDestroy(
[{ kind: "application", name: "postgres" }],
[{ kind: "database", name: "postgres", uuid: "d1" }],
);
expect(plan.targets).toEqual([]);
expect(plan.absent).toEqual([{ kind: "application", name: "postgres" }]);
expect(plan.undeclared).toEqual([{ kind: "database", name: "postgres" }]);
});
it("reports what the manifest declares and the box does not have", () => {
const plan = planDestroy(
[
{ kind: "application", name: "core" },
{ kind: "application", name: "worker" },
],
[{ kind: "application", name: "core", uuid: "a1" }],
);
expect(plan.absent).toEqual([{ kind: "application", name: "worker" }]);
});
});
describe("readBackupState", () => {
it("reads the schedule and the last execution that actually landed", () => {
const state = readBackupState([
{
uuid: "b1",
frequency: "0 2 * * *",
enabled: true,
executions: [
{ created_at: "2026-07-10T02:00:03Z", status: "success" },
{ created_at: "2026-07-13T02:00:11Z", status: "success" },
{ created_at: "2026-07-11T02:00:07Z", status: "failed" },
],
},
]);
expect(state.state).toBe("scheduled");
if (state.state !== "scheduled") return;
expect(state.schedules[0].frequency).toBe("0 2 * * *");
// The NEWEST, not the first in the array — the ordering of the response is
// not a thing to depend on when the answer decides whether a delete is
// recoverable.
expect(state.schedules[0].last?.at).toBe("2026-07-13T02:00:11Z");
expect(state.schedules[0].executions).toBe(3);
});
it("says NONE only when Coolify actually says there are no backups", () => {
expect(readBackupState([])).toEqual({ state: "none" });
});
// The direction that must never round the wrong way. Anything cast cannot read
// is UNKNOWN — an unreadable backup configuration must never render as "this
// database has no backups" (which reads as "expected, go ahead"), and equally
// never as "backed up" (which reads as "recoverable").
it("says UNKNOWN, never NONE, for a shape it cannot read", () => {
for (const raw of [
null,
undefined,
"Content is very complex. Will be implemented later.",
{ message: "Database not found." },
42,
]) {
const state = readBackupState(raw);
expect(state.state).toBe("unknown");
}
});
it("accepts an enveloped response rather than calling it unknown", () => {
const state = readBackupState({
data: [{ frequency: "0 3 * * *", executions: [] }],
});
expect(state.state).toBe("scheduled");
});
it("counts a schedule that has never run as a schedule that has never run", () => {
const state = readBackupState([{ frequency: "0 3 * * *", executions: [] }]);
if (state.state !== "scheduled") throw new Error("expected scheduled");
expect(state.schedules[0].executions).toBe(0);
expect(state.schedules[0].last).toBeUndefined();
});
});
describe("renderDestroyPlan", () => {
const ctx = {
orgRepo: "heavy-duty/incubator",
env: "staging",
project: "incubator",
environment: "staging",
withProject: false,
};
it("says, for every database, whether deleting it is recoverable", () => {
const targets: DestroyTarget[] = [
{
kind: "database",
name: "postgres",
uuid: "d1",
backup: {
state: "scheduled",
schedules: [
{
frequency: "0 2 * * *",
enabled: true,
last: { at: "2026-07-13T02:00:11Z", status: "success" },
executions: 12,
},
],
},
},
{
kind: "database",
name: "cache",
uuid: "d2",
backup: { state: "none" },
},
{
kind: "database",
name: "ledger",
uuid: "d3",
backup: { state: "unknown", reason: "GET … → 404" },
},
];
const out = renderDestroyPlan({ targets, absent: [], undeclared: [] }, ctx);
expect(out).toContain("0 2 * * *");
expect(out).toContain("2026-07-13T02:00:11Z");
// The two that cannot be brought back say so, in the word an operator reads
// at 2am.
expect(out).toContain("backup schedule: NONE");
expect(out).toContain("backup schedule: UNKNOWN");
expect(out.match(/UNRECOVERABLE/g)?.length).toBe(2);
// And the one that CAN does not.
const backedUp = out.split("cache")[0];
expect(backedUp).not.toContain("UNRECOVERABLE");
});
it("names what it is leaving standing", () => {
const out = renderDestroyPlan(
{
targets: [{ kind: "application", name: "core", uuid: "a1" }],
absent: [],
undeclared: [{ kind: "service", name: "clients-site", uuid: "s9" }],
} as never,
ctx,
);
expect(out).toContain("LEFT STANDING");
expect(out).toContain("clients-site");
});
});
// A fake Coolify for the executor: it records the order of every call, which is
// the thing being asserted.
function fakeExecutor(opts: { emptyAfter?: number; failOn?: string } = {}) {
const calls: string[] = [];
let polls = 0;
const exec: DestroyExecutor = {
async deleteResource(t) {
if (opts.failOn === t.name) throw new Error(`boom: ${t.name}`);
calls.push(`delete ${t.kind} ${t.name}`);
},
async environmentIsEmpty() {
polls += 1;
return polls > (opts.emptyAfter ?? 0);
},
async deleteEnvironment() {
calls.push("delete environment");
},
async deleteProject() {
calls.push("delete project");
},
};
return { exec, calls };
}
const plan = (targets: DestroyTarget[]) => ({
targets,
absent: [],
undeclared: [],
});
const TARGETS: DestroyTarget[] = [
{ kind: "application", name: "core", uuid: "a1" },
{ kind: "service", name: "metabase", uuid: "s1" },
{ kind: "database", name: "postgres", uuid: "d1" },
];
const NO_WAIT = { attempts: 3, intervalMs: 0, sleep: async () => {} };
describe("executeDestroy", () => {
it("deletes in reverse dependency order", async () => {
const { exec, calls } = fakeExecutor();
const outcome = await executeDestroy(plan(TARGETS), exec, {
withProject: false,
});
expect(calls).toEqual([
"delete application core",
"delete service metabase",
"delete database postgres",
]);
expect(outcome.deleted).toHaveLength(3);
expect(outcome.projectDeleted).toBe(false);
});
it("stops at the first failure rather than carrying on down the order", async () => {
const { exec, calls } = fakeExecutor({ failOn: "metabase" });
await expect(
executeDestroy(plan(TARGETS), exec, { withProject: false }),
).rejects.toThrow("boom: metabase");
// The database is still there. A teardown that continues past an unexplained
// failure is deleting the thing the failed one may still depend on.
expect(calls).toEqual(["delete application core"]);
});
// Coolify's DELETE queues a job and answers immediately, so "I deleted them"
// is not the same claim as "they are gone" — and only one of the two makes it
// safe to delete the project.
it("waits for Coolify's queue before it removes the environment and project", async () => {
const { exec, calls } = fakeExecutor({ emptyAfter: 2 });
const outcome = await executeDestroy(plan(TARGETS), exec, {
withProject: true,
wait: NO_WAIT,
});
expect(calls).toEqual([
"delete application core",
"delete service metabase",
"delete database postgres",
"delete environment",
"delete project",
]);
expect(outcome.projectDeleted).toBe(true);
expect(outcome.note).toBeUndefined();
});
it("will not delete a project it cannot see is empty", async () => {
const { exec, calls } = fakeExecutor({ emptyAfter: 99 });
const outcome = await executeDestroy(plan(TARGETS), exec, {
withProject: true,
wait: NO_WAIT,
});
expect(calls).not.toContain("delete environment");
expect(calls).not.toContain("delete project");
expect(outcome.projectDeleted).toBe(false);
expect(outcome.note).toContain("still not empty");
});
});
// ----------------------------------------------------------- end-to-end tests
//
// The real CLI (`dist/cli.js`), against a stub Coolify that MUTATES: a delete
// removes the resource, so the environment really does read back empty
// afterwards, and --with-project's wait is exercised rather than mocked away.
type Stub = {
url: string;
calls: string[];
close: () => Promise<void>;
};
const stubs: Stub[] = [];
type Resource = { name: string; uuid: string };
type Env = {
applications: Resource[];
postgresqls: Resource[];
redis: Resource[];
services: Resource[];
};
const env = (e: Partial<Env> = {}): Env => ({
applications: [],
postgresqls: [],
redis: [],
services: [],
...e,
});
async function stubCoolify(
opts: {
environments?: Record<string, Env>;
backups?: Record<string, unknown>;
projects?: Array<{ uuid: string; name: string }>;
} = {},
): Promise<Stub> {
const environments = opts.environments ?? {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
}),
};
const projects = opts.projects ?? [{ uuid: "p1", name: "incubator" }];
const backups = opts.backups ?? {
d1: [
{
uuid: "b1",
frequency: "0 2 * * *",
enabled: true,
executions: [{ created_at: "2026-07-13T02:00:11Z", status: "success" }],
},
],
d2: [],
};
const calls: string[] = [];
const server = createServer((req, res) => {
const url = (req.url ?? "").replace("/api/v1", "");
const path = url.split("?")[0];
calls.push(`${req.method} ${url}`);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (req.method === "GET" && path === "/projects") return json(projects);
if (path === "/projects/p1/environments")
return json(Object.keys(environments).map((name) => ({ name })));
const envMatch = path.match(/^\/projects\/p1\/([^/]+)$/);
if (req.method === "GET" && envMatch) {
const found = environments[envMatch[1]];
if (!found) {
res.writeHead(404);
return res.end("{}");
}
return json(found);
}
const backupMatch = path.match(/^\/databases\/([^/]+)\/backups$/);
if (req.method === "GET" && backupMatch)
return json(backups[backupMatch[1]] ?? []);
const del = path.match(/^\/(applications|databases|services)\/([^/]+)$/);
if (req.method === "DELETE" && del) {
const uuid = del[2];
for (const e of Object.values(environments)) {
for (const key of [
"applications",
"postgresqls",
"redis",
"services",
] as const) {
e[key] = e[key].filter((r) => r.uuid !== uuid);
}
}
return json({ message: "deletion request queued" });
}
if (
req.method === "DELETE" &&
path.startsWith("/projects/p1/environments/")
)
return json({ message: "Environment deleted." });
if (req.method === "DELETE" && path === "/projects/p1")
return json({ message: "Project deleted." });
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
calls,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
// core (app), postgres + cache (databases). `metabase` is deliberately NOT here:
// wherever the stub serves it, it is a resource created outside cast.
const MANIFEST = `project: incubator
environments:
staging:
applications:
core:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://core.example.com"]
databases:
postgres: { type: postgresql, version: "16" }
cache: { type: redis }
`;
function fixture(
url: string,
opts: { destroyAllowed?: boolean | undefined; readOnly?: boolean } = {},
) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
mkdirSync(join(checkout, ".infra"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
writeFileSync(
join(state, ".coolify.env"),
[
`COOLIFY_BASE_URL="${url}"`,
'COOLIFY_ACCESS_TOKEN="t"',
...(opts.readOnly ? ["COOLIFY_READ_ONLY=true"] : []),
"",
].join("\n"),
);
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: staging-box",
" team: { id: 0, name: Root Team }",
...(opts.destroyAllowed === undefined
? []
: [` destroy_allowed: ${opts.destroyAllowed}`]),
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return { checkout, state };
}
function runDestroy(
args: string[],
opts: { stdin?: string } = {},
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "destroy", ...args], {
stdio: ["pipe", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.stdin.end(opts.stdin ?? "");
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const base = (f: ReturnType<typeof fixture>) => [
"heavy-duty/incubator",
"--env",
"staging",
"--state",
f.state,
"--path",
f.checkout,
];
const deletes = (stub: Stub) =>
stub.calls.filter((c) => c.startsWith("DELETE"));
describe("cast destroy (end to end): the refusals", () => {
// The one verb that must never iterate a fleet.
it("refuses --all, always, and before it opens anything", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--all"], { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain("--all is not a thing destroy does");
expect(stub.calls).toEqual([]);
});
// The interlock lives in state, not in argv — and absent means refuse.
it("refuses an environment with no destroy_allowed binding", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url);
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain("does not allow it");
expect(r.output).toContain("destroy_allowed: true");
expect(r.output).toContain("(absent)");
// Nothing was even asked of Coolify: the state repo said no.
expect(stub.calls).toEqual([]);
});
it("refuses destroy_allowed: false as loudly as an absent one", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: false });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain("destroy_allowed: false");
expect(stub.calls).toEqual([]);
});
// The same refusal apply/smoke/server-add take, from the same assert, with the
// same exit code (assertWritable throws, and main's handler exits 1) — an
// instance declared for inspection cannot be written to, whatever its token
// would permit.
it("refuses a read-only instance", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true, readOnly: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(1);
expect(r.output).toContain("refusing to destroy");
expect(r.output).toContain("read-only");
expect(deletes(stub)).toEqual([]);
});
// D-237, for the verb whose empty plan is "delete nothing" — an absent project
// must never read as a clean teardown.
it("refuses an absent project, and names what IS there", async () => {
const stub = await stubCoolify({
projects: [{ uuid: "p9", name: "clients-site" }],
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain('no project named "incubator"');
expect(r.output).toContain("clients-site");
expect(deletes(stub)).toEqual([]);
});
it("refuses when the manifest declares nothing this environment holds", async () => {
const stub = await stubCoolify({
environments: {
staging: env({ services: [{ name: "metabase", uuid: "s1" }] }),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain(
"none of the 3 resource(s) the manifest declares",
);
expect(r.output).toContain("metabase");
expect(deletes(stub)).toEqual([]);
});
it("aborts on anything but the environment's own name, typed", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true });
for (const answer of ["y\n", "yes\n", "prod\n", ""]) {
const r = await runDestroy(base(f), { stdin: answer });
expect(r.code).toBe(2);
expect(r.output).toContain("aborted — nothing deleted");
}
expect(deletes(stub)).toEqual([]);
});
// --with-project, blocked by something cast did not declare. Refused BEFORE the
// confirmation, because Coolify would refuse the project delete after the
// resources were already gone.
it("refuses --with-project while an undeclared resource is in the way", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
services: [{ name: "metabase", uuid: "s1" }],
}),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--with-project"], {
stdin: "staging\n",
});
expect(r.code).toBe(2);
expect(r.output).toContain("would not be empty");
expect(r.output).toContain("metabase");
expect(deletes(stub)).toEqual([]);
});
it("refuses --with-project while another environment of it holds resources", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
}),
production: env({ applications: [{ name: "core", uuid: "a9" }] }),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--with-project"], {
stdin: "staging\n",
});
expect(r.code).toBe(2);
expect(r.output).toContain("would not be empty");
expect(r.output).toContain("production");
expect(deletes(stub)).toEqual([]);
});
});
describe("cast destroy (end to end): what it does when it does it", () => {
it("deletes exactly what the manifest declares, in reverse order, and says what it left", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
// Created outside cast. It survives this run.
services: [{ name: "metabase", uuid: "s1" }],
}),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(0);
const del = deletes(stub).map((c) => c.split("?")[0]);
expect(del).toEqual([
"DELETE /applications/a1",
"DELETE /databases/d2",
"DELETE /databases/d1",
]);
// The service nobody declared is still standing, and was said out loud.
expect(del).not.toContain("DELETE /services/s1");
expect(r.output).toContain("LEFT STANDING");
expect(r.output).toContain("metabase");
// The query cast sends, rather than the defaults it would inherit: volumes
// and the resource's own network go, a server-wide docker prune does not.
const query = deletes(stub)[0];
expect(query).toContain("delete_volumes=true");
expect(query).toContain("delete_connected_networks=true");
expect(query).toContain("delete_configurations=true");
expect(query).toContain("docker_cleanup=false");
});
it("says at the prompt which database can be brought back and which cannot", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(0);
// postgres (d1) has a schedule that has run; cache (d2) has none at all.
expect(r.output).toContain("0 2 * * *");
expect(r.output).toContain("2026-07-13T02:00:11Z");
expect(r.output).toContain("backup schedule: NONE");
expect(r.output).toContain("UNRECOVERABLE");
});
// A backups route cast cannot read must never make a backed-up database read
// as an unbacked one, or the reverse. It reads as UNKNOWN, and says so.
it("prints UNKNOWN when the backups route cannot be read", async () => {
const stub = await stubCoolify({
backups: { d1: "not json we know", d2: [] },
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(0);
expect(r.output).toContain("backup schedule: UNKNOWN");
});
it("--with-project removes the environment and the project once they are empty", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
}),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--with-project"], {
stdin: "staging\n",
});
expect(r.code).toBe(0);
const del = deletes(stub).map((c) => c.split("?")[0]);
expect(del).toEqual([
"DELETE /applications/a1",
"DELETE /databases/d2",
"DELETE /databases/d1",
"DELETE /projects/p1/environments/staging",
"DELETE /projects/p1",
]);
expect(r.output).toContain('deleted project "incubator"');
});
});