diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index 98a40bb..f8b074c 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -15,8 +15,6 @@ import pkg from '../../package.json'; @Injectable() export class McpService { - private readonly server: McpServer; - constructor( private readonly authService: AuthService, private readonly sessionService: SessionService, @@ -24,13 +22,15 @@ export class McpService { private readonly browserService: BrowserService, private readonly codeExecutor: CodeExecutorService, 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 { - const server = this.server; + private registerTools(server: McpServer): void { // ── Auth ────────────────────────────────────────────────────────────────── @@ -454,12 +454,14 @@ export class McpService { } async handle(req: Request, res: Response): Promise { + const server = this.createServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - await this.server.connect(transport); + await server.connect(transport); try { await transport.handleRequest(req, res, req.body); } finally { await transport.close(); + await server.close(); } } } diff --git a/src/scenario/dto/scenario-export.dto.ts b/src/scenario/dto/scenario-export.dto.ts new file mode 100644 index 0000000..a1ebf75 --- /dev/null +++ b/src/scenario/dto/scenario-export.dto.ts @@ -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[]; +} diff --git a/src/scenario/scenario.controller.ts b/src/scenario/scenario.controller.ts index 10e3eb5..5c3046c 100644 --- a/src/scenario/scenario.controller.ts +++ b/src/scenario/scenario.controller.ts @@ -19,6 +19,7 @@ import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; import { PaginationQueryDto } from './dto/pagination-query.dto'; import { ScenarioOrderBy } from './scenario.service'; import { RunsQueryDto } from './dto/runs-query.dto'; +import { ScenarioExportDto } from './dto/scenario-export.dto'; @ApiTags('scenarios') @Controller('scenarios') @@ -34,6 +35,13 @@ export class ScenarioController { 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() @ApiOperation({ summary: 'List all scenarios (paginated)' }) @ApiResponse({ status: 200 }) @@ -66,6 +74,14 @@ export class ScenarioController { 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 ───────────────────────────────────────────────────────────────── @Post(':id/steps') diff --git a/src/scenario/scenario.service.ts b/src/scenario/scenario.service.ts index d2409a5..4b72884 100644 --- a/src/scenario/scenario.service.ts +++ b/src/scenario/scenario.service.ts @@ -11,6 +11,7 @@ import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; import { RunsQueryDto } from './dto/runs-query.dto'; +import { ScenarioExportDto } from './dto/scenario-export.dto'; export { PaginatedResult } from '../common/dto/pagination.dto'; export type ScenarioOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt'; @@ -115,8 +116,7 @@ export class ScenarioService { return { data, total, page, limit }; } - async createRun(scenarioId: number): Promise { - const scenario = await this.findOne(scenarioId); + async createRun(scenarioId: number): Promise { const scenario = await this.findOne(scenarioId); const run = await this.runRepo.save( this.runRepo.create({ scenarioId, status: 'pending' }), @@ -140,5 +140,41 @@ export class ScenarioService { order: { stepRuns: { order: 'ASC' } }, }) as Promise; } + + // ── Export / Import ─────────────────────────────────────────────────────── + + async exportScenario(id: number): Promise { + 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 { + 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); + } } diff --git a/test/scenario.controller.spec.ts b/test/scenario.controller.spec.ts index 2a2f335..ba8d3cf 100644 --- a/test/scenario.controller.spec.ts +++ b/test/scenario.controller.spec.ts @@ -371,4 +371,157 @@ describe('ScenarioController', () => { 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); + }); + }); });