import { INestApplication } from "@nestjs/common"; import request from "supertest"; import { DataSource } from "typeorm"; import { parse as yamlParse } from "yaml"; import { buildTestApp } from "./app.harness"; describe("ScenarioController", () => { let app: INestApplication; beforeAll(async () => { app = await buildTestApp(); }); afterAll(async () => { await app.close(); }); // ── helpers ──────────────────────────────────────────────────────────────── async function createScenario(name = "test scenario") { const res = await request(app.getHttpServer()) .post("/scenarios") .send({ name }) .expect(201); return res.body as { id: string; name: string }; } async function createStep( scenarioId: string, overrides: Record = {}, ) { const res = await request(app.getHttpServer()) .post(`/scenarios/${scenarioId}/steps`) .send({ order: 0, sessionName: "test-session", execCode: "return 1;", ...overrides, }) .expect(201); return res.body as { id: string }; } // ── POST /scenarios ──────────────────────────────────────────────────────── describe("POST /scenarios", () => { it("creates a scenario and returns 201", async () => { const res = await request(app.getHttpServer()) .post("/scenarios") .send({ name: "my scenario" }) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.name).toBe("my scenario"); }); it("returns 400 when name is missing", async () => { await request(app.getHttpServer()) .post("/scenarios") .send({}) .expect(400); }); }); // ── GET /scenarios ───────────────────────────────────────────────────────── describe("GET /scenarios", () => { it("returns paginated result", async () => { const res = await request(app.getHttpServer()) .get("/scenarios") .expect(200); expect(res.body).toHaveProperty("data"); expect(res.body).toHaveProperty("total"); expect(res.body).toHaveProperty("page"); expect(res.body).toHaveProperty("limit"); expect(Array.isArray(res.body.data)).toBe(true); }); it("respects page and limit params", async () => { await createScenario("paged-sc-a"); await createScenario("paged-sc-b"); const res = await request(app.getHttpServer()) .get("/scenarios?page=1&limit=1") .expect(200); expect(res.body.data).toHaveLength(1); expect(res.body.limit).toBe(1); expect(res.body.page).toBe(1); }); it("returns empty data for out-of-range page", async () => { const res = await request(app.getHttpServer()) .get("/scenarios?page=9999&limit=20") .expect(200); expect(res.body.data).toHaveLength(0); }); it("returns 400 for invalid pagination params", async () => { await request(app.getHttpServer()).get("/scenarios?page=0").expect(400); }); it("orders by name ASC", async () => { await createScenario("zzz-order-sc"); await createScenario("aaa-order-sc"); const res = await request(app.getHttpServer()) .get("/scenarios?orderBy=name&orderDir=ASC") .expect(200); const names: string[] = res.body.data.map( (s: { name: string }) => s.name, ); expect(names).toEqual([...names].sort()); }); it("orders by name DESC", async () => { const res = await request(app.getHttpServer()) .get("/scenarios?orderBy=name&orderDir=DESC") .expect(200); const names: string[] = res.body.data.map( (s: { name: string }) => s.name, ); expect(names).toEqual([...names].sort().reverse()); }); it("returns 400 for invalid orderDir", async () => { await request(app.getHttpServer()) .get("/scenarios?orderDir=SIDEWAYS") .expect(400); }); }); // ── GET /scenarios/:id ───────────────────────────────────────────────────── describe("GET /scenarios/:id", () => { it("returns the scenario with steps array", async () => { const sc = await createScenario("scenario-get-one"); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}`) .expect(200); expect(res.body.id).toBe(sc.id); expect(Array.isArray(res.body.steps)).toBe(true); }); it("returns 404 for unknown id", async () => { await request(app.getHttpServer()).get("/scenarios/00000000-0000-0000-0000-000000000001").expect(404); }); }); // ── PATCH /scenarios/:id ─────────────────────────────────────────────────── describe("PATCH /scenarios/:id", () => { it("updates scenario name", async () => { const sc = await createScenario("patch-me"); const res = await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}`) .send({ name: "patched" }) .expect(200); expect(res.body.name).toBe("patched"); }); it("returns 404 for unknown id", async () => { await request(app.getHttpServer()) .patch("/scenarios/00000000-0000-0000-0000-000000000001") .send({ name: "x" }) .expect(404); }); }); // ── DELETE /scenarios/:id ────────────────────────────────────────────────── describe("DELETE /scenarios/:id", () => { it("deletes and returns 204", async () => { const sc = await createScenario("delete-me"); await request(app.getHttpServer()) .delete(`/scenarios/${sc.id}`) .expect(204); await request(app.getHttpServer()).get(`/scenarios/${sc.id}`).expect(404); }); it("returns 404 for unknown id", async () => { await request(app.getHttpServer()).delete("/scenarios/00000000-0000-0000-0000-000000000001").expect(404); }); }); // ── POST /scenarios/:id/steps ────────────────────────────────────────────── describe("POST /scenarios/:id/steps", () => { it("creates a step with required fields", async () => { const sc = await createScenario(); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) .send({ order: 0, sessionName: "my-session", execCode: "return 1;", }) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.order).toBe(0); expect(res.body.sessionName).toBe("my-session"); }); it("creates a step without execCode", async () => { const sc = await createScenario(); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) .send({ order: 0, sessionName: "session-x" }) .expect(201); expect(res.body.sessionName).toBe("session-x"); expect(res.body.execCode).toBeNull(); }); it("allows creating a step without explicit order", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) .send({ sessionName: "x" }) .expect(201); }); it("ignores unknown fields in payload", async () => { const sc = await createScenario(); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) .send({ order: 0, type: "unknown", sessionName: "x" }) .expect(201); expect(res.body).not.toHaveProperty("type"); }); it("allows missing sessionName", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) .send({ order: 0, execCode: "return 1;" }) .expect(201); }); it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) .post("/scenarios/00000000-0000-0000-0000-000000000001/steps") .send({ order: 0, sessionName: "x" }) .expect(404); }); it("returns steps ordered by order field", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 2, type: "exec", sessionName: "s" }); await createStep(sc.id, { order: 0, type: "exec", sessionName: "s" }); await createStep(sc.id, { order: 1, type: "exec", sessionName: "s" }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}`) .expect(200); const orders = res.body.steps.map((s: { order: number }) => s.order); expect(orders).toEqual([0, 1, 2]); }); }); // ── GET /scenarios/:id/steps/:stepId ────────────────────────────────────── describe("GET /scenarios/:id/steps/:stepId", () => { it("returns the step", async () => { const sc = await createScenario(); const step = await createStep(sc.id); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/steps/${step.id}`) .expect(200); expect(res.body.id).toBe(step.id); }); it("returns 404 for unknown step", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/steps/00000000-0000-0000-0000-000000000001`) .expect(404); }); }); // ── PATCH /scenarios/:id/steps/:stepId ──────────────────────────────────── describe("PATCH /scenarios/:id/steps/:stepId", () => { it("updates step fields", async () => { const sc = await createScenario(); const step = await createStep(sc.id, { order: 0, execCode: "return 1;" }); const res = await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}/steps/${step.id}`) .send({ order: 5, execCode: "return 99;" }) .expect(200); expect(res.body.order).toBe(0); expect(res.body.execCode).toBe("return 99;"); }); it("returns 404 for unknown step", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}/steps/00000000-0000-0000-0000-000000000001`) .send({ order: 1 }) .expect(404); }); it("reorders by exact target index", async () => { const sc = await createScenario(); const stepA = await createStep(sc.id, { title: "A" }); const stepB = await createStep(sc.id, { title: "B" }); const stepC = await createStep(sc.id, { title: "C" }); const stepD = await createStep(sc.id, { title: "D" }); await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}/steps/${stepA.id}`) .send({ order: 2 }) .expect(200); let res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}`) .expect(200); expect( res.body.steps.map((s: { title: string }) => s.title), ).toEqual(["B", "C", "A", "D"]); expect( res.body.steps.map((s: { order: number }) => s.order), ).toEqual([0, 1, 2, 3]); await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}/steps/${stepD.id}`) .send({ order: 0 }) .expect(200); res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}`) .expect(200); expect( res.body.steps.map((s: { title: string }) => s.title), ).toEqual(["D", "B", "C", "A"]); expect( res.body.steps.map((s: { order: number }) => s.order), ).toEqual([0, 1, 2, 3]); }); }); // ── DELETE /scenarios/:id/steps/:stepId ─────────────────────────────────── describe("DELETE /scenarios/:id/steps/:stepId", () => { it("deletes the step and returns 204", async () => { const sc = await createScenario(); const step = await createStep(sc.id); await request(app.getHttpServer()) .delete(`/scenarios/${sc.id}/steps/${step.id}`) .expect(204); await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/steps/${step.id}`) .expect(404); }); }); // ── POST /scenarios/:id/run ──────────────────────────────────────────────── describe("POST /scenarios/:id/run", () => { it("creates a run with stepRuns in correct initial states", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); await createStep(sc.id, { order: 1 }); await createStep(sc.id, { order: 2 }); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); expect(res.body.status).toBe("pending"); expect(Array.isArray(res.body.stepRuns)).toBe(true); expect(res.body.stepRuns).toHaveLength(3); const statuses = res.body.stepRuns.map( (s: { status: string }) => s.status, ); expect(statuses[0]).toBe("pending"); expect(statuses[1]).toBe("waiting"); expect(statuses[2]).toBe("waiting"); }); it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) .post("/scenarios/00000000-0000-0000-0000-000000000001/run") .expect(404); }); }); // ── GET /scenarios/:id/runs ──────────────────────────────────────────────── describe("GET /scenarios/:id/runs", () => { it("returns paginated runs with stepRuns embedded", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs`) .expect(200); expect(res.body.total).toBeGreaterThanOrEqual(1); expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true); }); it("filters by status", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); const pendingRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs?status=pending`) .expect(200); expect(pendingRes.body.total).toBeGreaterThanOrEqual(1); pendingRes.body.data.forEach((r: { status: string }) => expect(r.status).toBe("pending"), ); const passRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs?status=pass`) .expect(200); expect(passRes.body.total).toBe(0); }); it("returns 400 for invalid status filter", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs?status=invalid`) .expect(400); }); it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) .get("/scenarios/00000000-0000-0000-0000-000000000001/runs") .expect(404); }); }); // ── GET /scenarios/:id/export ───────────────────────────────────────────── describe("GET /scenarios/:id/export", () => { it("returns name and steps array", async () => { const sc = await createScenario("export-me"); await createStep(sc.id, { order: 0, sessionName: "s", execCode: '{"keyId":"k","environmentName":"e"}', }); await createStep(sc.id, { order: 1, sessionName: "s", execCode: "return 1;", validateCode: "return true;", }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const exported = yamlParse(res.text) as { name: string; steps: Array>; }; expect(exported.name).toBe("export-me"); expect(Array.isArray(exported.steps)).toBe(true); expect(exported.steps).toHaveLength(2); }); it("exports steps ordered by sequential position", async () => { const sc = await createScenario("export-order"); await createStep(sc.id, { sessionName: "s", execCode: "return 'first';" }); await createStep(sc.id, { sessionName: "s", execCode: "return 'second';" }); await createStep(sc.id, { sessionName: "s", execCode: "return 'third';" }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const exported = yamlParse(res.text) as { steps: Array<{ execCode: string }>; }; const codes = exported.steps.map((s: { execCode: string }) => s.execCode); expect(codes).toEqual([ "return 'first';", "return 'second';", "return 'third';", ]); expect(exported.steps[0]).not.toHaveProperty("order"); }); it("omits internal fields (id, scenarioId, timestamps)", async () => { const sc = await createScenario("export-shape"); await createStep(sc.id, { order: 0, sessionName: "s" }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const exported = yamlParse(res.text) as { steps: Array>; }; const step = exported.steps[0]; expect(step).not.toHaveProperty("id"); expect(step).not.toHaveProperty("scenarioId"); expect(step).not.toHaveProperty("createdAt"); expect(step).not.toHaveProperty("updatedAt"); }); it("exports null validateCode as null", async () => { const sc = await createScenario("export-null-validate"); await createStep(sc.id, { order: 0, sessionName: "s", execCode: "return 1;", }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const exported = yamlParse(res.text) as { steps: Array<{ validateCode: string | null }>; }; expect(exported.steps[0].validateCode).toBeNull(); }); it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) .get("/scenarios/00000000-0000-0000-0000-000000000001/export") .expect(404); }); }); // ── POST /scenarios/import ──────────────────────────────────────────────── describe("POST /scenarios/import", () => { it("creates a new scenario with all steps", async () => { const payload = { name: "imported scenario", steps: [ { sessionName: "s", execCode: '{"keyId":"k","environmentName":"e"}', validateCode: null, }, { sessionName: "s", execCode: "return 1;", validateCode: "return true;", }, { sessionName: "s", execCode: '{"keyId":"k"}', validateCode: null, }, ], }; const res = await request(app.getHttpServer()) .post("/scenarios/import") .send(payload) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.name).toBe("imported scenario"); expect(res.body.steps).toHaveLength(3); expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([ 0, 1, 2, ]); }); it("preserves id when importing an exported scenario with id", async () => { const sc = await createScenario("original"); const exportRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const exported = yamlParse(exportRes.text) as Record; const importRes = await request(app.getHttpServer()) .post("/scenarios/import") .send(exported) .expect(201); expect(importRes.body.id).toBe(sc.id); }); it("round-trips a scenario faithfully", async () => { const sc = await createScenario("roundtrip"); await createStep(sc.id, { order: 0, sessionName: "rs", execCode: "return 42;", validateCode: "return true;", }); const exportRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const exported = yamlParse(exportRes.text) as { steps: Array<{ execCode: string; validateCode: string | null }>; }; const importRes = await request(app.getHttpServer()) .post("/scenarios/import") .send(exported) .expect(201); expect(importRes.body.name).toBe("roundtrip"); expect(importRes.body.steps).toHaveLength(1); expect(importRes.body.steps[0].execCode).toBe( exported.steps[0].execCode, ); expect(importRes.body.steps[0].validateCode).toBe( exported.steps[0].validateCode, ); }); it("imports with empty steps array", async () => { const res = await request(app.getHttpServer()) .post("/scenarios/import") .send({ name: "empty-import", steps: [] }) .expect(201); expect(res.body.name).toBe("empty-import"); expect(res.body.steps).toHaveLength(0); }); it("returns 400 when name is missing", async () => { await request(app.getHttpServer()) .post("/scenarios/import") .send({ steps: [] }) .expect(400); }); it("returns 400 when steps is not an array", async () => { await request(app.getHttpServer()) .post("/scenarios/import") .send({ name: "bad", steps: "oops" }) .expect(400); }); it("ignores unknown step fields during import", async () => { await request(app.getHttpServer()) .post("/scenarios/import") .send({ name: "bad-type", steps: [{ type: "unknown", sessionName: "s" }], }) .expect(201); }); }); // ── GET /scenarios/:id/run/:runId ───────────────────────────────────────── describe("GET /scenarios/:id/run/:runId", () => { it("returns run with stepRuns (with scenarioStep) and logs array", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); const runRes = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); const runId = runRes.body.id; const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/run/${runId}`) .expect(200); expect(res.body.id).toBe(runId); expect(res.body.status).toBe("pending"); expect(Array.isArray(res.body.stepRuns)).toBe(true); expect(res.body.stepRuns[0]).toHaveProperty("scenarioStep"); expect(Array.isArray(res.body.logs)).toBe(true); }); it("stepRuns are ordered by order ASC", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); await createStep(sc.id, { order: 1 }); await createStep(sc.id, { order: 2 }); const runRes = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); const runId = runRes.body.id; const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/run/${runId}`) .expect(200); const orders = res.body.stepRuns.map((s: { order: number }) => s.order); expect(orders).toEqual([0, 1, 2]); }); it("returns 404 for unknown run", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001`) .expect(404); }); it("returns 404 when run belongs to a different scenario", async () => { const sc1 = await createScenario(); const sc2 = await createScenario(); await createStep(sc1.id, { order: 0 }); const runRes = await request(app.getHttpServer()) .post(`/scenarios/${sc1.id}/run`) .expect(201); const runId = runRes.body.id; await request(app.getHttpServer()) .get(`/scenarios/${sc2.id}/run/${runId}`) .expect(404); }); it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) .get( "/scenarios/00000000-0000-0000-0000-000000000001/run/00000000-0000-0000-0000-000000000001", ) .expect(404); }); }); // ── POST /scenarios/:id/run/:runId/wait ─────────────────────────────────── describe("POST /scenarios/:id/run/:runId/wait", () => { it("returns 200 with run data immediately when run is already terminal", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); const runRes = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); const runId = runRes.body.id; // Manually mark run as pass so wait resolves immediately const dataSource = app.get(DataSource); await dataSource.query( `UPDATE scenario_runs SET status='pass' WHERE id='${runId}'`, ); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run/${runId}/wait`) .expect(200); expect(res.body.id).toBe(runId); expect(res.body.status).toBe("pass"); expect(Array.isArray(res.body.logs)).toBe(true); expect(Array.isArray(res.body.stepRuns)).toBe(true); }); it("returns the run in fail state when it has failed", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); const runRes = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run`) .expect(201); const runId = runRes.body.id; const dataSource = app.get(DataSource); await dataSource.query( `UPDATE scenario_runs SET status='fail' WHERE id='${runId}'`, ); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run/${runId}/wait`) .expect(200); expect(res.body.status).toBe("fail"); }); it("returns 404 for unknown run", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`) .expect(404); }); it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) .post( "/scenarios/00000000-0000-0000-0000-000000000001/run/00000000-0000-0000-0000-000000000001/wait", ) .expect(404); }); }); });