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:
Generated
+89
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 +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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user