Skip to content

Commit 11ff9fb

Browse files
committed
test: cover compose release recovery contracts
Что: - добавлены unit и integration tests для ENV upsert, exact deploy, idempotency, queue reconciliation и cloud availability - добавлены негативные fixtures для permissions, redaction placeholders, unsigned server hint и raw-source replay - стабилизирован import-heavy cloud API harness без накопления process listeners Зачем: - доказать fail-closed поведение release recovery одинаково для local и cloud runtime Риски: - generated OpenAPI остается contained gap из-за неизмененного baseline generator blocker Проверки: - Команды и результаты: полная recovery matrix 10 файлов, 123/123 passed; cloud availability 12/12 passed два запуска подряд; workspace typecheck passed; targeted Biome и git diff --check passed - Ограничения: live deploy, production и live Inngest не запускались What: - added unit and integration tests for ENV upsert, exact deployment, idempotency, queue reconciliation, and cloud availability - added negative fixtures for permissions, redaction placeholders, unsigned server hints, and raw-source replay - stabilized the import-heavy cloud API harness without accumulating process listeners Why: - prove fail-closed release recovery behavior across local and cloud runtimes Risks: - generated OpenAPI remains a contained gap because of the unchanged baseline generator blocker Checks: - Commands and results: full recovery matrix passed 123/123 across 10 files; cloud availability passed 12/12 twice consecutively; workspace typecheck passed; targeted Biome and git diff --check passed - Limitations: live deploy, production, and live Inngest were not run
1 parent c90ef0c commit 11ff9fb

10 files changed

Lines changed: 1796 additions & 0 deletions

‎apps/dokploy/__test__/deployment/cloud-deploy-idempotency.test.ts‎

Lines changed: 602 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import {
2+
afterAll,
3+
afterEach,
4+
beforeAll,
5+
beforeEach,
6+
describe,
7+
expect,
8+
it,
9+
vi,
10+
} from "vitest";
11+
12+
const signedJobMocks = vi.hoisted(() => ({
13+
assertSignedDeploymentCancelJob: vi.fn(),
14+
assertSignedDeploymentJobsReadRequest: vi.fn(),
15+
assertSignedDeploymentQueueJob: vi.fn(),
16+
}));
17+
18+
vi.mock("@hono/node-server", () => ({ serve: vi.fn() }));
19+
vi.mock("@dokploy/server/utils/deployments/signed-job", () => signedJobMocks);
20+
vi.mock("inngest", () => ({
21+
Inngest: class {
22+
createFunction() {
23+
return {};
24+
}
25+
26+
send = vi.fn();
27+
},
28+
}));
29+
vi.mock("inngest/hono", () => ({
30+
serve: () => () => new Response(null, { status: 204 }),
31+
}));
32+
33+
const signedReadRequest = {
34+
serverId: "server-1",
35+
scope: {
36+
version: 1 as const,
37+
operation: "read-jobs" as const,
38+
serverId: "server-1",
39+
organizationId: null,
40+
expiresAt: Date.now() + 60_000,
41+
nonce: "nonce-1",
42+
},
43+
signature: "signature-1",
44+
};
45+
46+
type QueueApp = {
47+
request(
48+
input: string,
49+
requestInit?: RequestInit,
50+
): Response | Promise<Response>;
51+
};
52+
53+
let missingConfigApp: QueueApp;
54+
let configuredApp: QueueApp;
55+
56+
beforeAll(async () => {
57+
vi.resetModules();
58+
vi.stubEnv("INNGEST_BASE_URL", "https://inngest.example");
59+
vi.stubEnv("INNGEST_SIGNING_KEY", "");
60+
vi.stubEnv("API_KEY", "api-key");
61+
vi.stubEnv("PORT", "0");
62+
missingConfigApp = (await import("../../../api/src/index")).app;
63+
64+
vi.resetModules();
65+
vi.stubEnv("INNGEST_SIGNING_KEY", "signing-key");
66+
configuredApp = (await import("../../../api/src/index")).app;
67+
});
68+
69+
afterAll(() => {
70+
vi.unstubAllEnvs();
71+
});
72+
73+
const requestJobs = async (app: QueueApp) =>
74+
app.request("http://deployments.test/jobs", {
75+
method: "POST",
76+
headers: {
77+
"content-type": "application/json",
78+
"x-api-key": "api-key",
79+
},
80+
body: JSON.stringify({
81+
...signedReadRequest,
82+
signature: `signature-${crypto.randomUUID()}`,
83+
}),
84+
});
85+
86+
const deploymentEvent = {
87+
id: "event-1",
88+
name: "deployment/requested",
89+
data: { serverId: "server-1" },
90+
ts: Date.now(),
91+
};
92+
93+
describe("cloud deployment queue availability", () => {
94+
beforeEach(() => {
95+
vi.clearAllMocks();
96+
signedJobMocks.assertSignedDeploymentJobsReadRequest.mockResolvedValue(
97+
"server-1",
98+
);
99+
});
100+
101+
afterEach(() => {
102+
vi.unstubAllGlobals();
103+
});
104+
105+
it("returns 503 without a signing key and never queries upstream", async () => {
106+
const fetchMock = vi.fn();
107+
vi.stubGlobal("fetch", fetchMock);
108+
109+
const response = await requestJobs(missingConfigApp);
110+
111+
expect(response.status).toBe(503);
112+
expect(fetchMock).not.toHaveBeenCalled();
113+
});
114+
115+
it.each([
116+
["first page", []],
117+
[
118+
"later page",
119+
[
120+
Response.json({
121+
data: Array.from({ length: 100 }, (_, index) => ({
122+
...deploymentEvent,
123+
id: `unrelated-${index}`,
124+
data: { serverId: "other-server" },
125+
internal_id: `cursor-${index}`,
126+
})),
127+
cursor: "next-page",
128+
}),
129+
],
130+
],
131+
])("returns 502 when an events %s request fails", async (_name, prefix) => {
132+
const fetchMock = vi
133+
.fn()
134+
.mockImplementationOnce(
135+
() => prefix[0] ?? new Response("unavailable", { status: 503 }),
136+
)
137+
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }));
138+
vi.stubGlobal("fetch", fetchMock);
139+
140+
const response = await requestJobs(configuredApp);
141+
142+
expect(response.status).toBe(502);
143+
expect(await response.json()).toEqual({
144+
message: "Deployment queue is unavailable",
145+
});
146+
});
147+
148+
it("CLOUD_QUEUE_UPSTREAM_FAILURE_NEVER_BECOMES_EMPTY: returns 502 on a network failure", async () => {
149+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
150+
151+
const response = await requestJobs(configuredApp);
152+
153+
expect(response.status).toBe(502);
154+
expect(await response.json()).toEqual({
155+
message: "Deployment queue is unavailable",
156+
});
157+
});
158+
159+
it("returns 502 when any event runs request fails", async () => {
160+
vi.stubGlobal(
161+
"fetch",
162+
vi
163+
.fn()
164+
.mockResolvedValueOnce(Response.json({ data: [deploymentEvent] }))
165+
.mockResolvedValueOnce(new Response("unavailable", { status: 502 })),
166+
);
167+
168+
const response = await requestJobs(configuredApp);
169+
170+
expect(response.status).toBe(502);
171+
});
172+
173+
it.each([
174+
["invalid JSON", new Response("not-json")],
175+
["missing data", Response.json({})],
176+
["non-array data", Response.json({ data: {} })],
177+
])("returns 502 for an events %s response", async (_name, upstream) => {
178+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(upstream));
179+
180+
const response = await requestJobs(configuredApp);
181+
182+
expect(response.status).toBe(502);
183+
});
184+
185+
it.each([
186+
["invalid JSON", new Response("not-json")],
187+
["missing data", Response.json({})],
188+
["non-array data", Response.json({ data: {} })],
189+
])("returns 502 for a runs %s response", async (_name, upstream) => {
190+
vi.stubGlobal(
191+
"fetch",
192+
vi
193+
.fn()
194+
.mockResolvedValueOnce(Response.json({ data: [deploymentEvent] }))
195+
.mockResolvedValueOnce(upstream),
196+
);
197+
198+
const response = await requestJobs(configuredApp);
199+
200+
expect(response.status).toBe(502);
201+
});
202+
203+
it("returns 200 empty only after a valid empty upstream response", async () => {
204+
vi.stubGlobal(
205+
"fetch",
206+
vi.fn().mockResolvedValue(Response.json({ data: [] })),
207+
);
208+
209+
const response = await requestJobs(configuredApp);
210+
211+
expect(response.status).toBe(200);
212+
expect(await response.json()).toEqual([]);
213+
});
214+
});

0 commit comments

Comments
 (0)