feat(pagination): add generic typed pagination with ordering to all list endpoints

- add PaginationQueryDto<TOrderBy> generic with orderBy and orderDir fields
- add SessionOrderBy, EnvironmentOrderBy, ScenarioOrderBy type aliases
- update session, environment, scenario services and controllers to use typed pagination
- update MCP list_sessions, list_environments, list_scenarios tools to expose orderBy/orderDir
- update tests for all list endpoints to cover page, limit, ordering, and invalid param rejection
This commit is contained in:
2026-04-07 17:54:23 +03:00
parent d9cdb64ee2
commit 36f8b8c0ca
16 changed files with 337 additions and 81 deletions
+89
View File
@@ -36,6 +36,7 @@
"@types/acorn": "^4.0.6",
"@types/better-sqlite3": "^7.6.13",
"@types/debug": "^4.1.13",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsdom": "^28.0.1",
"@types/mozilla__readability": "^0.4.2",
@@ -2622,6 +2623,27 @@
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -2668,6 +2690,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2783,6 +2837,41 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
+1
View File
@@ -43,6 +43,7 @@
"@types/acorn": "^4.0.6",
"@types/better-sqlite3": "^7.6.13",
"@types/debug": "^4.1.13",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsdom": "^28.0.1",
"@types/mozilla__readability": "^0.4.2",
+1 -1
View File
@@ -43,7 +43,7 @@ export class AuthService {
async login(keyId: string, environmentName: string, sessionName?: string): Promise<{ token: string; sessionName: string }> {
const resolvedSession = sessionName ?? crypto.randomUUID();
const env = await this.environmentService.findAll().then(all => all.find(e => e.name === environmentName));
const env = await this.environmentService.findAll().then(({ data }) => data.find(e => e.name === environmentName));
if (!env) throw new NotFoundException(`Environment "${environmentName}" not found`);
const loginUrl = env.urls.id_url;
+36
View File
@@ -0,0 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class PaginationQueryDto<TOrderBy extends string = string> {
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;
@ApiPropertyOptional({ example: 'id', default: 'id', description: 'Field to order by' })
@IsOptional()
@IsString()
orderBy?: TOrderBy;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
orderDir?: 'ASC' | 'DESC' = 'ASC';
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
}
+7 -4
View File
@@ -8,11 +8,14 @@ import {
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { EnvironmentService } from './environment.service';
import { CreateEnvironmentDto } from './dto/create-environment.dto';
import { UpdateEnvironmentDto } from './dto/update-environment.dto';
import { PaginationQueryDto } from '../common/dto/pagination.dto';
import { EnvironmentOrderBy } from './environment.service';
@ApiTags('environments')
@Controller('environments')
@@ -28,10 +31,10 @@ export class EnvironmentController {
}
@Get()
@ApiOperation({ summary: 'List all environments' })
@ApiResponse({ status: 200, description: 'Array of environments' })
findAll() {
return this.environmentService.findAll();
@ApiOperation({ summary: 'List all environments (paginated)' })
@ApiResponse({ status: 200, description: 'Paginated environments' })
findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) {
return this.environmentService.findAll(query);
}
@Get(':id')
+14 -2
View File
@@ -4,6 +4,9 @@ import { Repository } from 'typeorm';
import { EnvironmentEntity } from './environment.entity';
import { CreateEnvironmentDto } from './dto/create-environment.dto';
import { UpdateEnvironmentDto } from './dto/update-environment.dto';
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto';
export type EnvironmentOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt';
@Injectable()
export class EnvironmentService {
@@ -20,8 +23,17 @@ export class EnvironmentService {
return this.repo.save(this.repo.create(dto));
}
findAll(): Promise<EnvironmentEntity[]> {
return this.repo.find();
async findAll(query: PaginationQueryDto<EnvironmentOrderBy> = {}): Promise<PaginatedResult<EnvironmentEntity>> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? 'id';
const orderDir = query.orderDir ?? 'ASC';
const [data, total] = await this.repo.findAndCount({
order: { [orderBy]: orderDir },
skip: (page - 1) * limit,
take: limit,
});
return { data, total, page, limit };
}
async findOne(id: number): Promise<EnvironmentEntity> {
+28 -10
View File
@@ -55,9 +55,17 @@ export class McpService {
server.registerTool(
'list_sessions',
{ description: 'List all stored sessions (id, sessionName, createdAt, updatedAt)' },
async () => {
const sessions = await this.sessionService.findAll();
{
description: 'List all stored sessions (id, sessionName, createdAt, updatedAt), paginated',
inputSchema: {
page: z.number().int().min(1).optional().describe('Page number (default 1)'),
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'),
orderBy: z.enum(['id', 'sessionName', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'),
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'),
},
},
async ({ page, limit, orderBy, orderDir }) => {
const sessions = await this.sessionService.findAll({ page, limit, orderBy, orderDir });
return { content: [{ type: 'text' as const, text: JSON.stringify(sessions) }] };
},
);
@@ -71,8 +79,8 @@ export class McpService {
},
},
async ({ id }) => {
const sessions = await this.sessionService.findAll();
if (!sessions.find(s => s.id === id)) {
const { data } = await this.sessionService.findAll();
if (!data.find(s => s.id === id)) {
return { isError: true, content: [{ type: 'text' as const, text: `Session ${id} not found` }] };
}
await this.sessionService.remove(id);
@@ -84,9 +92,17 @@ export class McpService {
server.registerTool(
'list_environments',
{ description: 'List all environments' },
async () => {
const envs = await this.environmentService.findAll();
{
description: 'List all environments, paginated',
inputSchema: {
page: z.number().int().min(1).optional().describe('Page number (default 1)'),
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'),
orderBy: z.enum(['id', 'name', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'),
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'),
},
},
async ({ page, limit, orderBy, orderDir }) => {
const envs = await this.environmentService.findAll({ page, limit, orderBy, orderDir });
return { content: [{ type: 'text' as const, text: JSON.stringify(envs) }] };
},
);
@@ -218,10 +234,12 @@ export class McpService {
inputSchema: {
page: z.number().int().min(1).optional().describe('Page number (default 1)'),
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'),
orderBy: z.enum(['id', 'name', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'),
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'),
},
},
async ({ page, limit }) => {
const result = await this.scenarioService.findAll({ page, limit });
async ({ page, limit, orderBy, orderDir }) => {
const result = await this.scenarioService.findAll({ page, limit, orderBy, orderDir });
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
},
);
+1 -19
View File
@@ -1,19 +1 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
export class PaginationQueryDto {
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;
}
export { PaginationQueryDto } from '../../common/dto/pagination.dto';
+2 -1
View File
@@ -17,6 +17,7 @@ import { UpdateScenarioDto } from './dto/update-scenario.dto';
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
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';
@ApiTags('scenarios')
@@ -36,7 +37,7 @@ export class ScenarioController {
@Get()
@ApiOperation({ summary: 'List all scenarios (paginated)' })
@ApiResponse({ status: 200 })
findAll(@Query() query: PaginationQueryDto) {
findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) {
return this.scenarioService.findAll(query);
}
+7 -9
View File
@@ -9,15 +9,11 @@ import { CreateScenarioDto } from './dto/create-scenario.dto';
import { UpdateScenarioDto } from './dto/update-scenario.dto';
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto';
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto';
import { PaginationQueryDto } from './dto/pagination-query.dto';
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto';
import { RunsQueryDto } from './dto/runs-query.dto';
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
}
export { PaginatedResult } from '../common/dto/pagination.dto';
export type ScenarioOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt';
@Injectable()
export class ScenarioService {
@@ -38,11 +34,13 @@ export class ScenarioService {
return this.scenarioRepo.save(this.scenarioRepo.create(dto));
}
async findAll(query: PaginationQueryDto): Promise<PaginatedResult<ScenarioEntity>> {
async findAll(query: PaginationQueryDto<ScenarioOrderBy>): Promise<PaginatedResult<ScenarioEntity>> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? 'id';
const orderDir = query.orderDir ?? 'ASC';
const [data, total] = await this.scenarioRepo.findAndCount({
order: { id: 'ASC' },
order: { [orderBy]: orderDir },
skip: (page - 1) * limit,
take: limit,
});
+8 -20
View File
@@ -1,6 +1,8 @@
import { Controller, Delete, Get, NotFoundException, Param, ParseIntPipe } from '@nestjs/common';
import { Controller, Delete, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { SessionService } from './session.service';
import { PaginationQueryDto } from '../common/dto/pagination.dto';
import { SessionOrderBy } from './session.service';
@ApiTags('sessions')
@Controller('sessions')
@@ -8,24 +10,10 @@ export class SessionController {
constructor(private readonly sessionService: SessionService) {}
@Get()
@ApiOperation({ summary: 'List all stored sessions' })
@ApiResponse({
status: 200,
description: 'Array of sessions',
schema: {
type: 'array',
items: {
properties: {
id: { type: 'number' },
sessionName: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
},
})
findAll() {
return this.sessionService.findAll();
@ApiOperation({ summary: 'List all stored sessions (paginated)' })
@ApiResponse({ status: 200, description: 'Paginated sessions' })
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) {
return this.sessionService.findAll(query);
}
@Delete(':id')
@@ -34,7 +22,7 @@ export class SessionController {
@ApiResponse({ status: 404, description: 'Session not found' })
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
const sessions = await this.sessionService.findAll();
if (!sessions.find(s => s.id === id)) {
if (!sessions.data.find(s => s.id === id)) {
throw new NotFoundException(`Session ${id} not found`);
}
await this.sessionService.remove(id);
+15 -2
View File
@@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SessionEntity } from './session.entity';
import type { Cookie } from 'playwright';
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto';
export type SessionOrderBy = 'id' | 'sessionName' | 'createdAt' | 'updatedAt';
@Injectable()
export class SessionService {
@@ -40,8 +43,18 @@ export class SessionService {
return this.repo.findOneBy({ sessionName });
}
findAll(): Promise<Pick<SessionEntity, 'id' | 'sessionName' | 'createdAt' | 'updatedAt'>[]> {
return this.repo.find({ select: ['id', 'sessionName', 'createdAt', 'updatedAt'] });
async findAll(query: PaginationQueryDto<SessionOrderBy> = {}): Promise<PaginatedResult<Pick<SessionEntity, 'id' | 'sessionName' | 'createdAt' | 'updatedAt'>>> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? 'id';
const orderDir = query.orderDir ?? 'ASC';
const [data, total] = await this.repo.findAndCount({
select: ['id', 'sessionName', 'createdAt', 'updatedAt'],
order: { [orderBy]: orderDir },
skip: (page - 1) * limit,
take: limit,
});
return { data, total, page, limit };
}
async remove(id: number): Promise<void> {
+44 -2
View File
@@ -64,9 +64,51 @@ describe('EnvironmentController', () => {
// ── GET /environments ──────────────────────────────────────────────────────
describe('GET /environments', () => {
it('returns 200 with an array', async () => {
it('returns 200 with a paginated result', async () => {
const res = await request(app.getHttpServer()).get('/environments').expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(typeof res.body.total).toBe('number');
expect(res.body).toHaveProperty('page');
expect(res.body).toHaveProperty('limit');
});
it('respects page and limit params', async () => {
// seed two extra environments
await request(app.getHttpServer()).post('/environments').send({ name: 'env-page-1', urls: {} }).expect(201);
await request(app.getHttpServer()).post('/environments').send({ name: 'env-page-2', urls: {} }).expect(201);
const res = await request(app.getHttpServer()).get('/environments?page=1&limit=1').expect(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.page).toBe(1);
expect(res.body.limit).toBe(1);
});
it('returns empty data array for out-of-range page', async () => {
const res = await request(app.getHttpServer()).get('/environments?page=9999&limit=20').expect(200);
expect(res.body.data).toHaveLength(0);
});
it('returns 400 for invalid page param', async () => {
await request(app.getHttpServer()).get('/environments?page=0').expect(400);
});
it('orders by name ASC', async () => {
await request(app.getHttpServer()).post('/environments').send({ name: 'zzz-env', urls: {} });
await request(app.getHttpServer()).post('/environments').send({ name: 'aaa-env', urls: {} });
const res = await request(app.getHttpServer()).get('/environments?orderBy=name&orderDir=ASC').expect(200);
const names: string[] = res.body.data.map((e: any) => e.name);
expect(names).toEqual([...names].sort());
});
it('orders by name DESC', async () => {
const res = await request(app.getHttpServer()).get('/environments?orderBy=name&orderDir=DESC').expect(200);
const names: string[] = res.body.data.map((e: any) => e.name);
expect(names).toEqual([...names].sort().reverse());
});
it('returns 400 for invalid orderDir', async () => {
await request(app.getHttpServer()).get('/environments?orderDir=SIDEWAYS').expect(400);
});
});
+8 -4
View File
@@ -83,22 +83,26 @@ describe('McpController', () => {
// ── list_sessions tool ─────────────────────────────────────────────────────
describe('list_sessions', () => {
it('returns a result with text content containing a JSON array', async () => {
it('returns a paginated result with a data array', async () => {
const { status, rpc } = await mcpCall('list_sessions');
expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] };
expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true);
const body = JSON.parse(result.content[0].text) as { data: unknown[]; total: number };
expect(Array.isArray(body.data)).toBe(true);
expect(typeof body.total).toBe('number');
});
});
// ── list_environments tool ─────────────────────────────────────────────────
describe('list_environments', () => {
it('returns a result with text content containing a JSON array', async () => {
it('returns a paginated result with a data array', async () => {
const { status, rpc } = await mcpCall('list_environments');
expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] };
expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true);
const body = JSON.parse(result.content[0].text) as { data: unknown[]; total: number };
expect(Array.isArray(body.data)).toBe(true);
expect(typeof body.total).toBe('number');
});
});
+30 -2
View File
@@ -68,16 +68,44 @@ describe('ScenarioController', () => {
});
it('respects page and limit params', async () => {
await createScenario('paged-sc-a');
await createScenario('paged-sc-b');
const res = await request(app.getHttpServer())
.get('/scenarios?page=1&limit=2')
.get('/scenarios?page=1&limit=1')
.expect(200);
expect(res.body.limit).toBe(2);
expect(res.body.data).toHaveLength(1);
expect(res.body.limit).toBe(1);
expect(res.body.page).toBe(1);
});
it('returns empty data for out-of-range page', async () => {
const res = await request(app.getHttpServer()).get('/scenarios?page=9999&limit=20').expect(200);
expect(res.body.data).toHaveLength(0);
});
it('returns 400 for invalid pagination params', async () => {
await request(app.getHttpServer()).get('/scenarios?page=0').expect(400);
});
it('orders by name ASC', async () => {
await createScenario('zzz-order-sc');
await createScenario('aaa-order-sc');
const res = await request(app.getHttpServer()).get('/scenarios?orderBy=name&orderDir=ASC').expect(200);
const names: string[] = res.body.data.map((s: any) => s.name);
expect(names).toEqual([...names].sort());
});
it('orders by name DESC', async () => {
const res = await request(app.getHttpServer()).get('/scenarios?orderBy=name&orderDir=DESC').expect(200);
const names: string[] = res.body.data.map((s: any) => s.name);
expect(names).toEqual([...names].sort().reverse());
});
it('returns 400 for invalid orderDir', async () => {
await request(app.getHttpServer()).get('/scenarios?orderDir=SIDEWAYS').expect(400);
});
});
// ── GET /scenarios/:id ─────────────────────────────────────────────────────
+46 -5
View File
@@ -32,27 +32,68 @@ describe('SessionController', () => {
// ── GET /sessions ──────────────────────────────────────────────────────────
describe('GET /sessions', () => {
it('returns 200 with an array', async () => {
it('returns 200 with a paginated result', async () => {
const res = await request(app.getHttpServer()).get('/sessions').expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(typeof res.body.total).toBe('number');
expect(res.body).toHaveProperty('page');
expect(res.body).toHaveProperty('limit');
});
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);
const names = res.body.data.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');
const item = res.body.data.find((s: any) => s.sessionName === 'private-session');
expect(item).toBeDefined();
expect(item.token).toBeUndefined();
expect(item.cookies).toBeUndefined();
expect(item.localStorage).toBeUndefined();
});
it('respects page and limit params', async () => {
await seedSession('paged-session-a');
await seedSession('paged-session-b');
const res = await request(app.getHttpServer()).get('/sessions?page=1&limit=1').expect(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.page).toBe(1);
expect(res.body.limit).toBe(1);
});
it('returns empty data array for out-of-range page', async () => {
const res = await request(app.getHttpServer()).get('/sessions?page=9999&limit=20').expect(200);
expect(res.body.data).toHaveLength(0);
});
it('returns 400 for invalid page param', async () => {
await request(app.getHttpServer()).get('/sessions?page=0').expect(400);
});
it('orders by sessionName ASC', async () => {
await seedSession('zzz-sort-session');
await seedSession('aaa-sort-session');
const res = await request(app.getHttpServer()).get('/sessions?orderBy=sessionName&orderDir=ASC').expect(200);
const names: string[] = res.body.data.map((s: any) => s.sessionName);
expect(names).toEqual([...names].sort());
});
it('orders by sessionName DESC', async () => {
const res = await request(app.getHttpServer()).get('/sessions?orderBy=sessionName&orderDir=DESC').expect(200);
const names: string[] = res.body.data.map((s: any) => s.sessionName);
expect(names).toEqual([...names].sort().reverse());
});
it('returns 400 for invalid orderDir', async () => {
await request(app.getHttpServer()).get('/sessions?orderDir=SIDEWAYS').expect(400);
});
});
// ── DELETE /sessions/:id ───────────────────────────────────────────────────
@@ -63,7 +104,7 @@ describe('SessionController', () => {
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);
const names = res.body.data.map((sess: any) => sess.sessionName);
expect(names).not.toContain('delete-me-session');
});