feat(scenario): export/import endpoints and integration tests
- 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
This commit is contained in:
+10
-8
@@ -15,8 +15,6 @@ import pkg from '../../package.json';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class McpService {
|
export class McpService {
|
||||||
private readonly server: McpServer;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
@@ -24,13 +22,15 @@ export class McpService {
|
|||||||
private readonly browserService: BrowserService,
|
private readonly browserService: BrowserService,
|
||||||
private readonly codeExecutor: CodeExecutorService,
|
private readonly codeExecutor: CodeExecutorService,
|
||||||
private readonly scenarioService: ScenarioService,
|
private readonly scenarioService: ScenarioService,
|
||||||
) {
|
) {}
|
||||||
this.server = new McpServer({ name: pkg.name, version: pkg.version });
|
|
||||||
this.registerTools();
|
private createServer(): McpServer {
|
||||||
|
const server = new McpServer({ name: pkg.name, version: pkg.version });
|
||||||
|
this.registerTools(server);
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerTools(): void {
|
private registerTools(server: McpServer): void {
|
||||||
const server = this.server;
|
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -454,12 +454,14 @@ export class McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handle(req: Request, res: Response): Promise<void> {
|
async handle(req: Request, res: Response): Promise<void> {
|
||||||
|
const server = this.createServer();
|
||||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
await this.server.connect(transport);
|
await server.connect(transport);
|
||||||
try {
|
try {
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
} finally {
|
} finally {
|
||||||
await transport.close();
|
await transport.close();
|
||||||
|
await server.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { StepType } from '../scenario-step.entity';
|
||||||
|
|
||||||
|
export class ScenarioStepExportDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
order: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['login', 'exec', 'sign'] })
|
||||||
|
@IsIn(['login', 'exec', 'sign'])
|
||||||
|
type: StepType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
sessionName: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
execCode: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
validateCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScenarioExportDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ScenarioStepExportDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ScenarioStepExportDto)
|
||||||
|
steps: ScenarioStepExportDto[];
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
|||||||
import { PaginationQueryDto } from './dto/pagination-query.dto';
|
import { PaginationQueryDto } from './dto/pagination-query.dto';
|
||||||
import { ScenarioOrderBy } from './scenario.service';
|
import { ScenarioOrderBy } from './scenario.service';
|
||||||
import { RunsQueryDto } from './dto/runs-query.dto';
|
import { RunsQueryDto } from './dto/runs-query.dto';
|
||||||
|
import { ScenarioExportDto } from './dto/scenario-export.dto';
|
||||||
|
|
||||||
@ApiTags('scenarios')
|
@ApiTags('scenarios')
|
||||||
@Controller('scenarios')
|
@Controller('scenarios')
|
||||||
@@ -34,6 +35,13 @@ export class ScenarioController {
|
|||||||
return this.scenarioService.create(dto);
|
return this.scenarioService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@ApiOperation({ summary: 'Import a scenario from an export payload' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Scenario imported' })
|
||||||
|
importScenario(@Body() dto: ScenarioExportDto) {
|
||||||
|
return this.scenarioService.importScenario(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all scenarios (paginated)' })
|
@ApiOperation({ summary: 'List all scenarios (paginated)' })
|
||||||
@ApiResponse({ status: 200 })
|
@ApiResponse({ status: 200 })
|
||||||
@@ -66,6 +74,14 @@ export class ScenarioController {
|
|||||||
return this.scenarioService.remove(id);
|
return this.scenarioService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/export')
|
||||||
|
@ApiOperation({ summary: 'Export a scenario as a portable JSON payload' })
|
||||||
|
@ApiResponse({ status: 200 })
|
||||||
|
@ApiResponse({ status: 404, description: 'Scenario not found' })
|
||||||
|
exportScenario(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.scenarioService.exportScenario(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post(':id/steps')
|
@Post(':id/steps')
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
|
|||||||
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
|
||||||
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto';
|
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto';
|
||||||
import { RunsQueryDto } from './dto/runs-query.dto';
|
import { RunsQueryDto } from './dto/runs-query.dto';
|
||||||
|
import { ScenarioExportDto } from './dto/scenario-export.dto';
|
||||||
|
|
||||||
export { PaginatedResult } from '../common/dto/pagination.dto';
|
export { PaginatedResult } from '../common/dto/pagination.dto';
|
||||||
export type ScenarioOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt';
|
export type ScenarioOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt';
|
||||||
@@ -115,8 +116,7 @@ export class ScenarioService {
|
|||||||
return { data, total, page, limit };
|
return { data, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRun(scenarioId: number): Promise<ScenarioRunEntity> {
|
async createRun(scenarioId: number): Promise<ScenarioRunEntity> { const scenario = await this.findOne(scenarioId);
|
||||||
const scenario = await this.findOne(scenarioId);
|
|
||||||
|
|
||||||
const run = await this.runRepo.save(
|
const run = await this.runRepo.save(
|
||||||
this.runRepo.create({ scenarioId, status: 'pending' }),
|
this.runRepo.create({ scenarioId, status: 'pending' }),
|
||||||
@@ -140,5 +140,41 @@ export class ScenarioService {
|
|||||||
order: { stepRuns: { order: 'ASC' } },
|
order: { stepRuns: { order: 'ASC' } },
|
||||||
}) as Promise<ScenarioRunEntity>;
|
}) as Promise<ScenarioRunEntity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Export / Import ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async exportScenario(id: number): Promise<ScenarioExportDto> {
|
||||||
|
const scenario = await this.findOne(id);
|
||||||
|
return {
|
||||||
|
name: scenario.name,
|
||||||
|
steps: scenario.steps.map((s) => ({
|
||||||
|
order: s.order,
|
||||||
|
type: s.type,
|
||||||
|
sessionName: s.sessionName,
|
||||||
|
execCode: s.execCode,
|
||||||
|
validateCode: s.validateCode,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
|
||||||
|
const scenario = await this.scenarioRepo.save(
|
||||||
|
this.scenarioRepo.create({ name: dto.name }),
|
||||||
|
);
|
||||||
|
if (dto.steps.length > 0) {
|
||||||
|
const steps = dto.steps.map((s) =>
|
||||||
|
this.stepRepo.create({
|
||||||
|
scenarioId: scenario.id,
|
||||||
|
order: s.order,
|
||||||
|
type: s.type,
|
||||||
|
sessionName: s.sessionName,
|
||||||
|
execCode: s.execCode ?? null,
|
||||||
|
validateCode: s.validateCode ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.stepRepo.save(steps);
|
||||||
|
}
|
||||||
|
return this.findOne(scenario.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,4 +371,157 @@ describe('ScenarioController', () => {
|
|||||||
await request(app.getHttpServer()).get('/scenarios/99999/runs').expect(404);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user