- add GET /scenarios/:id/export returning ScenarioExportDto - add POST /scenarios/import creating scenario with all steps - add ScenarioExportDto and ScenarioStepExportDto classes - fix McpServer singleton by creating fresh instance per handle() call - add 12 integration tests covering shape, ordering, round-trip, validation
528 lines
20 KiB
TypeScript
528 lines
20 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import request from 'supertest';
|
|
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: number; name: string };
|
|
}
|
|
|
|
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;',
|
|
...overrides,
|
|
})
|
|
.expect(201);
|
|
return res.body as { id: number };
|
|
}
|
|
|
|
// ── 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: any) => 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);
|
|
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/99999').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/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);
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── 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, 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');
|
|
});
|
|
|
|
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' })
|
|
.expect(201);
|
|
|
|
expect(res.body.type).toBe('login');
|
|
});
|
|
|
|
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' })
|
|
.expect(400);
|
|
});
|
|
|
|
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' })
|
|
.expect(400);
|
|
});
|
|
|
|
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' })
|
|
.expect(400);
|
|
});
|
|
|
|
it('returns 404 for unknown scenario', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/scenarios/99999/steps')
|
|
.send({ order: 0, type: 'exec', 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: any) => 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/99999`)
|
|
.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(5);
|
|
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/99999`)
|
|
.send({ order: 1 })
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
// ── 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: any) => 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/99999/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: any) => 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/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;' });
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.get(`/scenarios/${sc.id}/export`)
|
|
.expect(200);
|
|
|
|
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' });
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.get(`/scenarios/${sc.id}/export`)
|
|
.expect(200);
|
|
|
|
const orders = res.body.steps.map((s: any) => 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' });
|
|
|
|
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');
|
|
});
|
|
|
|
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);
|
|
|
|
expect(res.body.steps[0].validateCode).toBeNull();
|
|
});
|
|
|
|
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 () => {
|
|
const payload = {
|
|
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 },
|
|
],
|
|
};
|
|
|
|
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[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');
|
|
const exportRes = await request(app.getHttpServer())
|
|
.get(`/scenarios/${sc.id}/export`)
|
|
.expect(200);
|
|
|
|
const importRes = await request(app.getHttpServer())
|
|
.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;' });
|
|
|
|
const exportRes = await request(app.getHttpServer())
|
|
.get(`/scenarios/${sc.id}/export`)
|
|
.expect(200);
|
|
|
|
const importRes = await request(app.getHttpServer())
|
|
.post('/scenarios/import')
|
|
.send(exportRes.body)
|
|
.expect(201);
|
|
|
|
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);
|
|
});
|
|
|
|
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('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' }] })
|
|
.expect(400);
|
|
});
|
|
});
|
|
});
|