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:
2026-04-07 15:54:13 +03:00
parent cff36ffeff
commit 00f310e899
15 changed files with 4874 additions and 27 deletions
+15
View File
@@ -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',
};
+3862 -25
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export class JSDOM {
constructor(public html: string, public options?: any) {}
get window() {
return { document: {} };
}
}
+15
View File
@@ -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(),
}),
};
+6
View File
@@ -0,0 +1,6 @@
export class Readability {
constructor(private doc: any) {}
parse() {
return { textContent: '' };
}
}
+78
View File
@@ -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;
}
+61
View File
@@ -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
});
});
});
+98
View File
@@ -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);
});
});
});
+145
View File
@@ -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);
});
});
});
+141
View File
@@ -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);
}
});
});
});
+346
View File
@@ -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);
});
});
});
+78
View File
@@ -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);
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"noEmit": true,
"types": ["jest", "node"]
},
"exclude": []
}
+2 -1
View File
@@ -17,5 +17,6 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"resolveJsonModule": true
}
},
"exclude": ["node_modules", "dist", "test"]
}