feat(scenario): run logs, lint/format tooling, CONTRIBUTING

- add ScenarioRunLogEntity to persist step script output to DB
- stepLogger dual-writes to NestJS logger and DB (fire-and-forget)
- add GET /scenarios/:id/run/:runId returning run, stepRuns and logs
- add POST /scenarios/:id/run/:runId/wait (polls until terminal state)
- 9 new integration tests for the two endpoints (136 total)
- add eslint with typescript-eslint and eslint-config-prettier
- add npm scripts: format, lint, lint:fix
- resolve all lint errors across src and test (no any types)
- add CONTRIBUTING.md covering dev workflow
This commit is contained in:
2026-04-08 18:10:42 +03:00
parent b0c02a1cdc
commit 9a9ccbfe37
67 changed files with 3809 additions and 1412 deletions
+378 -169
View File
@@ -1,8 +1,9 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { buildTestApp } from './app.harness';
import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { DataSource } from "typeorm";
import { buildTestApp } from "./app.harness";
describe('ScenarioController', () => {
describe("ScenarioController", () => {
let app: INestApplication;
beforeAll(async () => {
@@ -15,22 +16,25 @@ describe('ScenarioController', () => {
// ── helpers ────────────────────────────────────────────────────────────────
async function createScenario(name = 'test scenario') {
async function createScenario(name = "test scenario") {
const res = await request(app.getHttpServer())
.post('/scenarios')
.post("/scenarios")
.send({ name })
.expect(201);
return res.body as { id: number; name: string };
}
async function createStep(scenarioId: number, overrides: Record<string, unknown> = {}) {
async function createStep(
scenarioId: number,
overrides: Record<string, unknown> = {},
) {
const res = await request(app.getHttpServer())
.post(`/scenarios/${scenarioId}/steps`)
.send({
order: 0,
type: 'exec',
sessionName: 'test-session',
execCode: 'return 1;',
type: "exec",
sessionName: "test-session",
execCode: "return 1;",
...overrides,
})
.expect(201);
@@ -39,80 +43,93 @@ describe('ScenarioController', () => {
// ── POST /scenarios ────────────────────────────────────────────────────────
describe('POST /scenarios', () => {
it('creates a scenario and returns 201', async () => {
describe("POST /scenarios", () => {
it("creates a scenario and returns 201", async () => {
const res = await request(app.getHttpServer())
.post('/scenarios')
.send({ name: 'my scenario' })
.post("/scenarios")
.send({ name: "my scenario" })
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('my scenario');
expect(res.body.name).toBe("my scenario");
});
it('returns 400 when name is missing', async () => {
await request(app.getHttpServer()).post('/scenarios').send({}).expect(400);
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');
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');
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')
.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);
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("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');
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: any) => s.name);
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: any) => s.name);
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);
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');
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);
@@ -120,121 +137,128 @@ describe('ScenarioController', () => {
expect(Array.isArray(res.body.steps)).toBe(true);
});
it('returns 404 for unknown id', async () => {
await request(app.getHttpServer()).get('/scenarios/99999').expect(404);
it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).get("/scenarios/99999").expect(404);
});
});
// ── PATCH /scenarios/:id ───────────────────────────────────────────────────
describe('PATCH /scenarios/:id', () => {
it('updates scenario name', async () => {
const sc = await createScenario('patch-me');
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' })
.send({ name: "patched" })
.expect(200);
expect(res.body.name).toBe('patched');
expect(res.body.name).toBe("patched");
});
it('returns 404 for unknown id', async () => {
it("returns 404 for unknown id", async () => {
await request(app.getHttpServer())
.patch('/scenarios/99999')
.send({ name: 'x' })
.patch("/scenarios/99999")
.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);
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/99999').expect(404);
it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).delete("/scenarios/99999").expect(404);
});
});
// ── POST /scenarios/:id/steps ──────────────────────────────────────────────
describe('POST /scenarios/:id/steps', () => {
it('creates a step with required fields', async () => {
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, type: 'exec', sessionName: 'my-session', execCode: 'return 1;' })
.send({
order: 0,
type: "exec",
sessionName: "my-session",
execCode: "return 1;",
})
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.order).toBe(0);
expect(res.body.type).toBe('exec');
expect(res.body.sessionName).toBe('my-session');
expect(res.body.type).toBe("exec");
expect(res.body.sessionName).toBe("my-session");
});
it('creates a login step', async () => {
it("creates a login step", async () => {
const sc = await createScenario();
const res = await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`)
.send({ order: 0, type: 'login', sessionName: 'session-x' })
.send({ order: 0, type: "login", sessionName: "session-x" })
.expect(201);
expect(res.body.type).toBe('login');
expect(res.body.type).toBe("login");
});
it('returns 400 when order is missing', async () => {
it("returns 400 when order is missing", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`)
.send({ type: 'exec', sessionName: 'x' })
.send({ type: "exec", sessionName: "x" })
.expect(400);
});
it('returns 400 when type is invalid', async () => {
it("returns 400 when type is invalid", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`)
.send({ order: 0, type: 'unknown', sessionName: 'x' })
.send({ order: 0, type: "unknown", sessionName: "x" })
.expect(400);
});
it('returns 400 when sessionName is missing', async () => {
it("returns 400 when sessionName is missing", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`)
.send({ order: 0, type: 'exec' })
.send({ order: 0, type: "exec" })
.expect(400);
});
it('returns 404 for unknown scenario', async () => {
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.post('/scenarios/99999/steps')
.send({ order: 0, type: 'exec', sessionName: 'x' })
.post("/scenarios/99999/steps")
.send({ order: 0, type: "exec", sessionName: "x" })
.expect(404);
});
it('returns steps ordered by order field', async () => {
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' });
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: any) => s.order);
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 () => {
describe("GET /scenarios/:id/steps/:stepId", () => {
it("returns the step", async () => {
const sc = await createScenario();
const step = await createStep(sc.id);
@@ -245,7 +269,7 @@ describe('ScenarioController', () => {
expect(res.body.id).toBe(step.id);
});
it('returns 404 for unknown step', async () => {
it("returns 404 for unknown step", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/steps/99999`)
@@ -255,21 +279,21 @@ describe('ScenarioController', () => {
// ── PATCH /scenarios/:id/steps/:stepId ────────────────────────────────────
describe('PATCH /scenarios/:id/steps/:stepId', () => {
it('updates step fields', async () => {
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 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;' })
.send({ order: 5, execCode: "return 99;" })
.expect(200);
expect(res.body.order).toBe(5);
expect(res.body.execCode).toBe('return 99;');
expect(res.body.execCode).toBe("return 99;");
});
it('returns 404 for unknown step', async () => {
it("returns 404 for unknown step", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/99999`)
@@ -280,8 +304,8 @@ describe('ScenarioController', () => {
// ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
describe('DELETE /scenarios/:id/steps/:stepId', () => {
it('deletes the step and returns 204', async () => {
describe("DELETE /scenarios/:id/steps/:stepId", () => {
it("deletes the step and returns 204", async () => {
const sc = await createScenario();
const step = await createStep(sc.id);
@@ -297,8 +321,8 @@ describe('ScenarioController', () => {
// ── POST /scenarios/:id/run ────────────────────────────────────────────────
describe('POST /scenarios/:id/run', () => {
it('creates a run with stepRuns in correct initial states', async () => {
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 });
@@ -308,30 +332,32 @@ describe('ScenarioController', () => {
.post(`/scenarios/${sc.id}/run`)
.expect(201);
expect(res.body.status).toBe('pending');
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: any) => s.status);
expect(statuses[0]).toBe('pending');
expect(statuses[1]).toBe('waiting');
expect(statuses[2]).toBe('waiting');
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 () => {
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.post('/scenarios/99999/run')
.post("/scenarios/99999/run")
.expect(404);
});
});
// ── GET /scenarios/:id/runs ────────────────────────────────────────────────
describe('GET /scenarios/:id/runs', () => {
it('returns paginated runs with stepRuns embedded', async () => {
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);
await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/run`)
.expect(201);
const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/runs`)
@@ -341,17 +367,21 @@ describe('ScenarioController', () => {
expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true);
});
it('filters by status', async () => {
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);
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: any) => expect(r.status).toBe('pending'));
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`)
@@ -360,67 +390,84 @@ describe('ScenarioController', () => {
expect(passRes.body.total).toBe(0);
});
it('returns 400 for invalid status filter', async () => {
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/99999/runs').expect(404);
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.get("/scenarios/99999/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, type: 'login', sessionName: 's', execCode: '{"keyId":"k","environmentName":"e"}' });
await createStep(sc.id, { order: 1, type: 'exec', sessionName: 's', execCode: 'return 1;', validateCode: 'return true;' });
describe("GET /scenarios/:id/export", () => {
it("returns name and steps array", async () => {
const sc = await createScenario("export-me");
await createStep(sc.id, {
order: 0,
type: "login",
sessionName: "s",
execCode: '{"keyId":"k","environmentName":"e"}',
});
await createStep(sc.id, {
order: 1,
type: "exec",
sessionName: "s",
execCode: "return 1;",
validateCode: "return true;",
});
const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`)
.expect(200);
expect(res.body.name).toBe('export-me');
expect(res.body.name).toBe("export-me");
expect(Array.isArray(res.body.steps)).toBe(true);
expect(res.body.steps).toHaveLength(2);
});
it('exports steps ordered by order field', async () => {
const sc = await createScenario('export-order');
await createStep(sc.id, { order: 2, sessionName: 's' });
await createStep(sc.id, { order: 0, sessionName: 's' });
await createStep(sc.id, { order: 1, sessionName: 's' });
it("exports steps ordered by order field", async () => {
const sc = await createScenario("export-order");
await createStep(sc.id, { order: 2, sessionName: "s" });
await createStep(sc.id, { order: 0, sessionName: "s" });
await createStep(sc.id, { order: 1, sessionName: "s" });
const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`)
.expect(200);
const orders = res.body.steps.map((s: any) => s.order);
const orders = res.body.steps.map((s: { order: number }) => s.order);
expect(orders).toEqual([0, 1, 2]);
});
it('omits internal fields (id, scenarioId, timestamps)', async () => {
const sc = await createScenario('export-shape');
await createStep(sc.id, { order: 0, sessionName: 's' });
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 step = res.body.steps[0];
expect(step).not.toHaveProperty('id');
expect(step).not.toHaveProperty('scenarioId');
expect(step).not.toHaveProperty('createdAt');
expect(step).not.toHaveProperty('updatedAt');
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;' });
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`)
@@ -429,99 +476,261 @@ describe('ScenarioController', () => {
expect(res.body.steps[0].validateCode).toBeNull();
});
it('returns 404 for unknown scenario', async () => {
await request(app.getHttpServer()).get('/scenarios/99999/export').expect(404);
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.get("/scenarios/99999/export")
.expect(404);
});
});
// ── POST /scenarios/import ────────────────────────────────────────────────
describe('POST /scenarios/import', () => {
it('creates a new scenario with all steps', async () => {
describe("POST /scenarios/import", () => {
it("creates a new scenario with all steps", async () => {
const payload = {
name: 'imported scenario',
name: "imported scenario",
steps: [
{ order: 0, type: 'login', sessionName: 's', execCode: '{"keyId":"k","environmentName":"e"}', validateCode: null },
{ order: 1, type: 'exec', sessionName: 's', execCode: 'return 1;', validateCode: 'return true;' },
{ order: 2, type: 'sign', sessionName: 's', execCode: '{"keyId":"k"}', validateCode: null },
{
order: 0,
type: "login",
sessionName: "s",
execCode: '{"keyId":"k","environmentName":"e"}',
validateCode: null,
},
{
order: 1,
type: "exec",
sessionName: "s",
execCode: "return 1;",
validateCode: "return true;",
},
{
order: 2,
type: "sign",
sessionName: "s",
execCode: '{"keyId":"k"}',
validateCode: null,
},
],
};
const res = await request(app.getHttpServer())
.post('/scenarios/import')
.post("/scenarios/import")
.send(payload)
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('imported scenario');
expect(res.body.name).toBe("imported scenario");
expect(res.body.steps).toHaveLength(3);
expect(res.body.steps[0].type).toBe('login');
expect(res.body.steps[1].type).toBe('exec');
expect(res.body.steps[2].type).toBe('sign');
expect(res.body.steps[0].type).toBe("login");
expect(res.body.steps[1].type).toBe("exec");
expect(res.body.steps[2].type).toBe("sign");
});
it('assigns a new id (does not collide with source)', async () => {
const sc = await createScenario('original');
it("assigns a new id (does not collide with source)", async () => {
const sc = await createScenario("original");
const exportRes = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`)
.expect(200);
const importRes = await request(app.getHttpServer())
.post('/scenarios/import')
.post("/scenarios/import")
.send(exportRes.body)
.expect(201);
expect(importRes.body.id).not.toBe(sc.id);
});
it('round-trips a scenario faithfully', async () => {
const sc = await createScenario('roundtrip');
await createStep(sc.id, { order: 0, type: 'exec', sessionName: 'rs', execCode: 'return 42;', validateCode: 'return true;' });
it("round-trips a scenario faithfully", async () => {
const sc = await createScenario("roundtrip");
await createStep(sc.id, {
order: 0,
type: "exec",
sessionName: "rs",
execCode: "return 42;",
validateCode: "return true;",
});
const exportRes = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`)
.expect(200);
const importRes = await request(app.getHttpServer())
.post('/scenarios/import')
.post("/scenarios/import")
.send(exportRes.body)
.expect(201);
expect(importRes.body.name).toBe('roundtrip');
expect(importRes.body.name).toBe("roundtrip");
expect(importRes.body.steps).toHaveLength(1);
expect(importRes.body.steps[0].execCode).toBe(exportRes.body.steps[0].execCode);
expect(importRes.body.steps[0].validateCode).toBe(exportRes.body.steps[0].validateCode);
expect(importRes.body.steps[0].execCode).toBe(
exportRes.body.steps[0].execCode,
);
expect(importRes.body.steps[0].validateCode).toBe(
exportRes.body.steps[0].validateCode,
);
});
it('imports with empty steps array', async () => {
it("imports with empty steps array", async () => {
const res = await request(app.getHttpServer())
.post('/scenarios/import')
.send({ name: 'empty-import', steps: [] })
.post("/scenarios/import")
.send({ name: "empty-import", steps: [] })
.expect(201);
expect(res.body.name).toBe('empty-import');
expect(res.body.name).toBe("empty-import");
expect(res.body.steps).toHaveLength(0);
});
it('returns 400 when name is missing', async () => {
it("returns 400 when name is missing", async () => {
await request(app.getHttpServer())
.post('/scenarios/import')
.post("/scenarios/import")
.send({ steps: [] })
.expect(400);
});
it('returns 400 when steps is not an array', async () => {
it("returns 400 when steps is not an array", async () => {
await request(app.getHttpServer())
.post('/scenarios/import')
.send({ name: 'bad', steps: 'oops' })
.post("/scenarios/import")
.send({ name: "bad", steps: "oops" })
.expect(400);
});
it('returns 400 when a step has an invalid type', async () => {
it("returns 400 when a step has an invalid type", async () => {
await request(app.getHttpServer())
.post('/scenarios/import')
.send({ name: 'bad-type', steps: [{ order: 0, type: 'unknown', sessionName: 's' }] })
.post("/scenarios/import")
.send({
name: "bad-type",
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
})
.expect(400);
});
});
// ── 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/99999`)
.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/99999/run/1")
.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/99999/wait`)
.expect(404);
});
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.post("/scenarios/99999/run/1/wait")
.expect(404);
});
});
});