Skip to content

Commit e440434

Browse files
authored
Merge pull request #237 from oboroge0/fix/149-engine-reliability
fix: バックエンド信頼性の残項目を整備(シャットダウンテスト・validate不正JSON 400)
2 parents a78a7e1 + f0063f4 commit e440434

5 files changed

Lines changed: 176 additions & 20 deletions

File tree

apps/server-ts/src/index.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { createBunWebSocket } from "hono/bun";
66
import { serveStatic } from "hono/bun";
77
import { cors } from "hono/cors";
88
import { logger } from "hono/logger";
9-
import { closeDb, initDb } from "./db/database";
9+
import { initDb } from "./db/database";
1010
import { WorkflowExecutor } from "./engine/executor";
11+
import { shutdownGracefully } from "./shutdown";
1112
import { integrationRoutes } from "./routes/integrations";
1213
import { pluginRoutes } from "./routes/plugins";
1314
import { settingsRoutes } from "./routes/settings";
@@ -132,21 +133,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
132133
timeoutHandle.unref();
133134
}
134135

135-
try {
136-
const runningIds = executor.getRunningWorkflowIds();
137-
if (runningIds.length > 0) {
138-
console.log(`Stopping ${runningIds.length} running workflow(s)...`);
139-
await Promise.allSettled(runningIds.map((id) => executor.stopWorkflow(id)));
140-
}
141-
} catch (err) {
142-
console.error("Error stopping workflows on shutdown:", err);
143-
}
144-
145-
try {
146-
closeDb();
147-
} catch (err) {
148-
console.error("Error closing database on shutdown:", err);
149-
}
136+
await shutdownGracefully(executor);
150137

151138
clearTimeout(timeoutHandle);
152139
process.exit(0);

apps/server-ts/src/routes/workflows.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,16 +290,22 @@ app.post("/:id/validate", async (c) => {
290290
const [existing] = await _db.select().from(workflows).where(eq(workflows.id, id));
291291
if (!existing) return c.json({ detail: "Workflow not found" }, 404);
292292

293+
// Distinguish "no body" (fine — validate the saved workflow) from
294+
// "malformed body" (reject), same as the /:id/start endpoint.
293295
let body: z.infer<typeof validateWorkflowBody> = {};
294-
try {
295-
const rawBody = await c.req.json();
296+
const rawText = await c.req.text();
297+
if (rawText.trim().length > 0) {
298+
let rawBody: unknown;
299+
try {
300+
rawBody = JSON.parse(rawText);
301+
} catch {
302+
return c.json({ error: "Invalid JSON in request body" }, 400);
303+
}
296304
const parsed = validateWorkflowBody.safeParse(rawBody);
297305
if (!parsed.success) {
298306
return c.json({ error: "Invalid request body", details: parsed.error.format() }, 400);
299307
}
300308
body = parsed.data ?? {};
301-
} catch {
302-
// No body provided - use saved workflow data
303309
}
304310

305311
const nodes = body?.nodes ?? JSON.parse(existing.nodesJson || "[]");

apps/server-ts/src/shutdown.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Graceful shutdown sequence, extracted from the index.ts signal handlers so
3+
* it can be unit-tested. process.exit and the watchdog timeout stay in
4+
* index.ts — this module only knows how to wind down cleanly.
5+
*/
6+
import { closeDb } from "./db/database";
7+
8+
export interface ShutdownTarget {
9+
getRunningWorkflowIds(): string[];
10+
stopWorkflow(workflowId: string): Promise<void>;
11+
}
12+
13+
export async function shutdownGracefully(
14+
executor: ShutdownTarget,
15+
options: { closeDatabase?: () => void } = {},
16+
): Promise<void> {
17+
const closeDatabase = options.closeDatabase ?? closeDb;
18+
19+
try {
20+
const runningIds = executor.getRunningWorkflowIds();
21+
if (runningIds.length > 0) {
22+
console.log(`Stopping ${runningIds.length} running workflow(s)...`);
23+
// allSettled: one workflow failing to stop must not skip the others
24+
// (or the DB close below)
25+
await Promise.allSettled(runningIds.map((id) => executor.stopWorkflow(id)));
26+
}
27+
} catch (err) {
28+
console.error("Error stopping workflows on shutdown:", err);
29+
}
30+
31+
try {
32+
closeDatabase();
33+
} catch (err) {
34+
console.error("Error closing database on shutdown:", err);
35+
}
36+
}

tests/engine/shutdown.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, mock } from "bun:test";
2+
import {
3+
shutdownGracefully,
4+
type ShutdownTarget,
5+
} from "../../apps/server-ts/src/shutdown";
6+
7+
function makeExecutor(runningIds: string[], stopImpl?: (id: string) => Promise<void>) {
8+
const stopWorkflow = mock(stopImpl ?? (async (_id: string) => {}));
9+
const target: ShutdownTarget = {
10+
getRunningWorkflowIds: () => runningIds,
11+
stopWorkflow,
12+
};
13+
return { target, stopWorkflow };
14+
}
15+
16+
describe("TestGracefulShutdown", () => {
17+
it("test_stops_all_running_workflows_then_closes_db", async () => {
18+
const { target, stopWorkflow } = makeExecutor(["wf-a", "wf-b", "wf-c"]);
19+
const order: string[] = [];
20+
const closeDatabase = mock(() => {
21+
order.push("close-db");
22+
});
23+
24+
await shutdownGracefully(target, { closeDatabase });
25+
26+
expect(stopWorkflow).toHaveBeenCalledTimes(3);
27+
expect(stopWorkflow.mock.calls.map((c) => c[0]).sort()).toEqual(["wf-a", "wf-b", "wf-c"]);
28+
expect(closeDatabase).toHaveBeenCalledTimes(1);
29+
});
30+
31+
it("test_closes_db_when_nothing_is_running", async () => {
32+
const { target, stopWorkflow } = makeExecutor([]);
33+
const closeDatabase = mock(() => {});
34+
35+
await shutdownGracefully(target, { closeDatabase });
36+
37+
expect(stopWorkflow).not.toHaveBeenCalled();
38+
expect(closeDatabase).toHaveBeenCalledTimes(1);
39+
});
40+
41+
it("test_one_failing_workflow_does_not_skip_others_or_db_close", async () => {
42+
const { target, stopWorkflow } = makeExecutor(["wf-bad", "wf-ok"], async (id) => {
43+
if (id === "wf-bad") throw new Error("stop failed");
44+
});
45+
const closeDatabase = mock(() => {});
46+
47+
await shutdownGracefully(target, { closeDatabase });
48+
49+
expect(stopWorkflow).toHaveBeenCalledTimes(2);
50+
expect(closeDatabase).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it("test_db_close_failure_does_not_throw", async () => {
54+
const { target } = makeExecutor([]);
55+
const closeDatabase = mock(() => {
56+
throw new Error("db close failed");
57+
});
58+
59+
await expect(shutdownGracefully(target, { closeDatabase })).resolves.toBeUndefined();
60+
expect(closeDatabase).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it("test_getRunningWorkflowIds_failure_still_closes_db", async () => {
64+
const closeDatabase = mock(() => {});
65+
const target: ShutdownTarget = {
66+
getRunningWorkflowIds: () => {
67+
throw new Error("executor broken");
68+
},
69+
stopWorkflow: async () => {},
70+
};
71+
72+
await expect(shutdownGracefully(target, { closeDatabase })).resolves.toBeUndefined();
73+
expect(closeDatabase).toHaveBeenCalledTimes(1);
74+
});
75+
});

tests/routes/workflows.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,3 +828,55 @@ describe("Edge Cases", () => {
828828
expect(imported.id).not.toBe(original.id);
829829
});
830830
});
831+
832+
// ═══════════════════════════════════════════════════════════════
833+
// Workflow Validation Endpoint (body handling)
834+
// ═══════════════════════════════════════════════════════════════
835+
836+
describe("Workflow validate body handling", () => {
837+
it("should validate the saved workflow when no body is sent", async () => {
838+
const created = await createWorkflowViaApi(app);
839+
840+
const res = await app.request(
841+
new Request(`http://localhost/api/workflows/${created.id}/validate`, {
842+
method: "POST",
843+
})
844+
);
845+
846+
expect(res.status).toBe(200);
847+
const data = await res.json();
848+
expect(data.valid).toBeDefined();
849+
expect(Array.isArray(data.errors)).toBe(true);
850+
});
851+
852+
it("should return 400 for malformed JSON body", async () => {
853+
const created = await createWorkflowViaApi(app);
854+
855+
const res = await app.request(
856+
new Request(`http://localhost/api/workflows/${created.id}/validate`, {
857+
method: "POST",
858+
headers: { "Content-Type": "application/json" },
859+
body: "{ this is not json",
860+
})
861+
);
862+
863+
expect(res.status).toBe(400);
864+
const data = await res.json();
865+
expect(data.error).toContain("Invalid JSON");
866+
});
867+
868+
it("should validate provided nodes when a valid body is sent", async () => {
869+
const created = await createWorkflowViaApi(app);
870+
871+
const res = await app.request(
872+
jsonRequest("POST", `/api/workflows/${created.id}/validate`, {
873+
nodes: [],
874+
connections: [],
875+
})
876+
);
877+
878+
expect(res.status).toBe(200);
879+
const data = await res.json();
880+
expect(data.valid).toBeDefined();
881+
});
882+
});

0 commit comments

Comments
 (0)