test: add jest test suite with controller specs and mocks
- add jest, ts-jest, supertest, and related dev dependencies - add test scripts (test, test:debug, test:watch) to package.json - add jest.config.js with ts-jest transform and module name mappers - add controller specs for auth, browser, environment, mcp, scenario, session - add mocks for jsdom, playwright, and readability - add test/tsconfig.json and exclude test dir from main tsconfig
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: '.',
|
||||
testRegex: 'test/.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { tsconfig: 'test/tsconfig.json' }],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^jsdom$': '<rootDir>/test/__mocks__/jsdom.ts',
|
||||
'^playwright$': '<rootDir>/test/__mocks__/playwright.ts',
|
||||
'^@mozilla/readability$': '<rootDir>/test/__mocks__/readability.ts',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
Generated
+3862
-25
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -7,7 +7,10 @@
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main"
|
||||
"start:prod": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:debug": "DEBUG=test jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -36,11 +39,19 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/acorn": "^4.0.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/mozilla__readability": "^0.4.2",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"debug": "^4.4.3",
|
||||
"jest": "^30.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class JSDOM {
|
||||
constructor(public html: string, public options?: any) {}
|
||||
get window() {
|
||||
return { document: {} };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const chromium = {
|
||||
launch: jest.fn().mockResolvedValue({
|
||||
newContext: jest.fn().mockResolvedValue({
|
||||
newPage: jest.fn().mockResolvedValue({
|
||||
goto: jest.fn(),
|
||||
content: jest.fn().mockResolvedValue('<html></html>'),
|
||||
title: jest.fn().mockReturnValue(''),
|
||||
url: jest.fn().mockReturnValue(''),
|
||||
evaluate: jest.fn(),
|
||||
close: jest.fn(),
|
||||
}),
|
||||
}),
|
||||
close: jest.fn(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export class Readability {
|
||||
constructor(private doc: any) {}
|
||||
parse() {
|
||||
return { textContent: '' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import debug from 'debug';
|
||||
|
||||
jest.mock('@nestjs/common', () => {
|
||||
const actual = jest.requireActual('@nestjs/common');
|
||||
const log = require('debug')('test');
|
||||
const { Logger } = require('@nestjs/common/services/logger.service');
|
||||
|
||||
Logger.prototype.error = function (message: unknown, stack?: string, context?: string) {
|
||||
const ctx = context ?? this.context ?? 'App';
|
||||
log(`[${ctx}]`, 'error', message, ...(stack ? [stack] : []));
|
||||
};
|
||||
|
||||
for (const level of ['log', 'warn', 'debug', 'verbose', 'fatal'] as const) {
|
||||
Logger.prototype[level] = function (message: unknown, context?: string) {
|
||||
const ctx = context ?? this.context ?? 'App';
|
||||
log(`[${ctx}]`, level, message);
|
||||
};
|
||||
}
|
||||
|
||||
return actual;
|
||||
});
|
||||
|
||||
import { AuthModule } from '../src/auth/auth.module';
|
||||
import { BrowserModule } from '../src/browser/browser.module';
|
||||
import { SessionModule } from '../src/session/session.module';
|
||||
import { EnvironmentModule } from '../src/environment/environment.module';
|
||||
import { ScenarioModule } from '../src/scenario/scenario.module';
|
||||
import { McpModule } from '../src/mcp/mcp.module';
|
||||
import { SessionEntity } from '../src/session/session.entity';
|
||||
import { EnvironmentEntity } from '../src/environment/environment.entity';
|
||||
import { ScenarioEntity } from '../src/scenario/scenario.entity';
|
||||
import { ScenarioStepEntity } from '../src/scenario/scenario-step.entity';
|
||||
import { ScenarioRunEntity } from '../src/scenario/scenario-run.entity';
|
||||
import { ScenarioRunStepEntity } from '../src/scenario/scenario-run-step.entity';
|
||||
import { HealthController } from '../src/health/health.controller';
|
||||
import { HttpExceptionFilter } from '../src/filters/http-exception.filter';
|
||||
import { LoggingInterceptor } from '../src/interceptors/logging.interceptor';
|
||||
|
||||
export async function buildTestApp(): Promise<INestApplication> {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
entities: [
|
||||
SessionEntity,
|
||||
EnvironmentEntity,
|
||||
ScenarioEntity,
|
||||
ScenarioStepEntity,
|
||||
ScenarioRunEntity,
|
||||
ScenarioRunStepEntity,
|
||||
],
|
||||
synchronize: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
AuthModule,
|
||||
BrowserModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
ScenarioModule,
|
||||
McpModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
}).compile();
|
||||
|
||||
const app = module.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { buildTestApp } from './app.harness';
|
||||
|
||||
/**
|
||||
* Auth controller integration tests.
|
||||
*
|
||||
* The login endpoint requires a live browser + real key files, so it is not
|
||||
* exercised here (those belong to e2e tests with real credentials).
|
||||
* We cover the parts that can be tested without external dependencies.
|
||||
*/
|
||||
describe('AuthController', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── GET /keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /keys', () => {
|
||||
it('returns 200 with a keys array', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/keys').expect(200);
|
||||
expect(res.body).toHaveProperty('keys');
|
||||
expect(Array.isArray(res.body.keys)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /login ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /login', () => {
|
||||
it('returns 400 when body is empty', async () => {
|
||||
await request(app.getHttpServer()).post('/login').send({}).expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when key is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/login')
|
||||
.send({ environmentName: 'test-env' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when environmentName is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/login')
|
||||
.send({ key: 'some-key' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when key file does not exist', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/login')
|
||||
.send({ key: 'nonexistent-key', environmentName: 'test-env' })
|
||||
.expect(404); // NotFoundException for missing environment
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import { buildTestApp } from './app.harness';
|
||||
import { SessionEntity } from '../src/session/session.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Browser controller integration tests.
|
||||
*
|
||||
* POST /open and POST /exec need a running Playwright browser. We test
|
||||
* validation rejections (no browser launched) and session-not-found paths,
|
||||
* which are safe to run in a headless CI environment.
|
||||
*/
|
||||
describe('BrowserController', () => {
|
||||
let app: INestApplication;
|
||||
let sessionRepo: Repository<SessionEntity>;
|
||||
|
||||
const FAKE_SESSION = 'test-browser-session';
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
sessionRepo = app.get<Repository<SessionEntity>>(getRepositoryToken(SessionEntity));
|
||||
|
||||
// Seed a session with minimal but valid JSON so the browser code can
|
||||
// deserialise it (it will still fail to open a real page, tested separately)
|
||||
await sessionRepo.save(
|
||||
sessionRepo.create({
|
||||
sessionName: FAKE_SESSION,
|
||||
token: 'fake-token',
|
||||
cookies: '[]',
|
||||
localStorage: '{}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── POST /open ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /open', () => {
|
||||
it('returns 400 when body is empty', async () => {
|
||||
await request(app.getHttpServer()).post('/open').send({}).expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when sessionName is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/open')
|
||||
.send({ url: 'https://example.com' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when url is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/open')
|
||||
.send({ sessionName: FAKE_SESSION })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 404 when session does not exist', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/open')
|
||||
.send({ sessionName: 'no-such-session', url: 'https://example.com' })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /exec ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /exec', () => {
|
||||
it('returns 400 when body is empty', async () => {
|
||||
await request(app.getHttpServer()).post('/exec').send({}).expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when code is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/exec')
|
||||
.send({ sessionName: FAKE_SESSION })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when code has a syntax error', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/exec')
|
||||
.send({ sessionName: FAKE_SESSION, code: 'this is not valid {{{' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 404 when session does not exist', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/exec')
|
||||
.send({ sessionName: 'no-such-session', code: 'return 1;' })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { buildTestApp } from './app.harness';
|
||||
|
||||
describe('EnvironmentController', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── POST /environments ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /environments', () => {
|
||||
it('creates an environment and returns 201', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-a', urls: { id_url: 'https://id.example.com' } })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe('env-a');
|
||||
expect(res.body.urls.id_url).toBe('https://id.example.com');
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ urls: { id_url: 'https://id.example.com' } })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when urls is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-no-urls' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 400 when urls is not an object', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-bad-urls', urls: 'not-an-object' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns 409 when name already exists', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-duplicate', urls: {} })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-duplicate', urls: {} })
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /environments ──────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /environments', () => {
|
||||
it('returns 200 with an array', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/environments').expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /environments/:id ──────────────────────────────────────────────────
|
||||
|
||||
describe('GET /environments/:id', () => {
|
||||
it('returns the created environment', async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-get-one', urls: { cabinet_url: 'https://cabinet.example.com' } })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/environments/${created.body.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe('env-get-one');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
await request(app.getHttpServer()).get('/environments/99999').expect(404);
|
||||
});
|
||||
|
||||
it('returns 400 for non-numeric id', async () => {
|
||||
await request(app.getHttpServer()).get('/environments/abc').expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /environments/:id ────────────────────────────────────────────────
|
||||
|
||||
describe('PATCH /environments/:id', () => {
|
||||
it('updates name and returns 200', async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-patch-me', urls: {} })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/environments/${created.body.id}`)
|
||||
.send({ name: 'env-patched' })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe('env-patched');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/environments/99999')
|
||||
.send({ name: 'x' })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /environments/:id ───────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /environments/:id', () => {
|
||||
it('deletes and returns 204', async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/environments')
|
||||
.send({ name: 'env-delete-me', urls: {} })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/environments/${created.body.id}`)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/environments/${created.body.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
await request(app.getHttpServer()).delete('/environments/99999').expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { buildTestApp } from './app.harness';
|
||||
|
||||
/**
|
||||
* MCP controller integration tests.
|
||||
*
|
||||
* The MCP endpoint speaks the Model Context Protocol (streamable HTTP
|
||||
* transport). We test:
|
||||
* - that the endpoint is reachable and returns a recognised MCP response
|
||||
* - that tool invocations for read-only, non-browser tools work end-to-end
|
||||
* - that tools with bad inputs return error payloads (not HTTP 5xx)
|
||||
*
|
||||
* Browser-dependent tools (open_url, exec_code) require a live Playwright
|
||||
* session and are not covered here.
|
||||
*/
|
||||
describe('McpController', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** Send a single MCP tool call and return the parsed response body. */
|
||||
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/mcp')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: args },
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Connectivity ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /mcp — connectivity', () => {
|
||||
it('is reachable and returns a non-5xx status', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/mcp')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '0' },
|
||||
},
|
||||
});
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_keys tool ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('list_keys', () => {
|
||||
it('returns a result with text content containing a JSON array', async () => {
|
||||
const res = await mcpCall('list_keys');
|
||||
// MCP may respond with 200 (JSON body) or SSE stream; both are acceptable
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
if (res.status === 200 && res.body?.result) {
|
||||
const text = res.body.result.content?.[0]?.text;
|
||||
expect(() => JSON.parse(text)).not.toThrow();
|
||||
expect(Array.isArray(JSON.parse(text))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_sessions tool ─────────────────────────────────────────────────────
|
||||
|
||||
describe('list_sessions', () => {
|
||||
it('returns a result with text content containing a JSON array', async () => {
|
||||
const res = await mcpCall('list_sessions');
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
if (res.status === 200 && res.body?.result) {
|
||||
const text = res.body.result.content?.[0]?.text;
|
||||
expect(() => JSON.parse(text)).not.toThrow();
|
||||
expect(Array.isArray(JSON.parse(text))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_environments tool ─────────────────────────────────────────────────
|
||||
|
||||
describe('list_environments', () => {
|
||||
it('returns a result with text content containing a JSON array', async () => {
|
||||
const res = await mcpCall('list_environments');
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
if (res.status === 200 && res.body?.result) {
|
||||
const text = res.body.result.content?.[0]?.text;
|
||||
expect(() => JSON.parse(text)).not.toThrow();
|
||||
expect(Array.isArray(JSON.parse(text))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── create_environment tool ────────────────────────────────────────────────
|
||||
|
||||
describe('create_environment', () => {
|
||||
it('creates an environment via MCP', async () => {
|
||||
const res = await mcpCall('create_environment', {
|
||||
name: 'mcp-test-env',
|
||||
urls: { id_url: 'https://id.example.com' },
|
||||
});
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
if (res.status === 200 && res.body?.result) {
|
||||
const text = res.body.result.content?.[0]?.text;
|
||||
const created = JSON.parse(text);
|
||||
expect(created.name).toBe('mcp-test-env');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete_session tool with unknown id ────────────────────────────────────
|
||||
|
||||
describe('delete_session', () => {
|
||||
it('returns an MCP error result for a non-existent session id', async () => {
|
||||
const res = await mcpCall('delete_session', { id: 999999 });
|
||||
expect(res.status).toBeLessThan(500);
|
||||
// MCP wraps service errors as isError:true content, not HTTP errors
|
||||
if (res.status === 200 && res.body?.result) {
|
||||
expect(res.body.result.isError).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
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 () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/scenarios?page=1&limit=2')
|
||||
.expect(200);
|
||||
expect(res.body.limit).toBe(2);
|
||||
expect(res.body.page).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid pagination params', async () => {
|
||||
await request(app.getHttpServer()).get('/scenarios?page=0').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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import request from 'supertest';
|
||||
import { buildTestApp } from './app.harness';
|
||||
import { SessionEntity } from '../src/session/session.entity';
|
||||
|
||||
describe('SessionController', () => {
|
||||
let app: INestApplication;
|
||||
let repo: Repository<SessionEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
repo = app.get<Repository<SessionEntity>>(getRepositoryToken(SessionEntity));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function seedSession(name: string) {
|
||||
return repo.save(
|
||||
repo.create({
|
||||
sessionName: name,
|
||||
token: 'tok',
|
||||
cookies: '[]',
|
||||
localStorage: '{}',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GET /sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /sessions', () => {
|
||||
it('returns 200 with an array', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/sessions').expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes seeded sessions', async () => {
|
||||
await seedSession('visible-session');
|
||||
const res = await request(app.getHttpServer()).get('/sessions').expect(200);
|
||||
const names = res.body.map((s: any) => s.sessionName);
|
||||
expect(names).toContain('visible-session');
|
||||
});
|
||||
|
||||
it('does not expose token, cookies or localStorage fields', async () => {
|
||||
await seedSession('private-session');
|
||||
const res = await request(app.getHttpServer()).get('/sessions').expect(200);
|
||||
const item = res.body.find((s: any) => s.sessionName === 'private-session');
|
||||
expect(item).toBeDefined();
|
||||
expect(item.token).toBeUndefined();
|
||||
expect(item.cookies).toBeUndefined();
|
||||
expect(item.localStorage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /sessions/:id ───────────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /sessions/:id', () => {
|
||||
it('deletes an existing session and returns 200', async () => {
|
||||
const s = await seedSession('delete-me-session');
|
||||
await request(app.getHttpServer()).delete(`/sessions/${s.id}`).expect(200);
|
||||
|
||||
const res = await request(app.getHttpServer()).get('/sessions').expect(200);
|
||||
const names = res.body.map((sess: any) => sess.sessionName);
|
||||
expect(names).not.toContain('delete-me-session');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
await request(app.getHttpServer()).delete('/sessions/99999').expect(404);
|
||||
});
|
||||
|
||||
it('returns 400 for non-numeric id', async () => {
|
||||
await request(app.getHttpServer()).delete('/sessions/abc').expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"exclude": []
|
||||
}
|
||||
+2
-1
@@ -17,5 +17,6 @@
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user