diff --git a/package-lock.json b/package-lock.json index 8edb6c9..2c5ea6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b537fa3..7dd13a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ea5c8a5..07a2c43 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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; diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..aca6261 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -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 { + @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 { + data: T[]; + total: number; + page: number; + limit: number; +} diff --git a/src/environment/environment.controller.ts b/src/environment/environment.controller.ts index 8897d64..7acfe87 100644 --- a/src/environment/environment.controller.ts +++ b/src/environment/environment.controller.ts @@ -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) { + return this.environmentService.findAll(query); } @Get(':id') diff --git a/src/environment/environment.service.ts b/src/environment/environment.service.ts index 2cd5554..94df33e 100644 --- a/src/environment/environment.service.ts +++ b/src/environment/environment.service.ts @@ -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 { - return this.repo.find(); + async findAll(query: PaginationQueryDto = {}): Promise> { + 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 { diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index 23db54c..f429303 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -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) }] }; }, ); diff --git a/src/scenario/dto/pagination-query.dto.ts b/src/scenario/dto/pagination-query.dto.ts index 1b8b501..0a5f39c 100644 --- a/src/scenario/dto/pagination-query.dto.ts +++ b/src/scenario/dto/pagination-query.dto.ts @@ -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'; diff --git a/src/scenario/scenario.controller.ts b/src/scenario/scenario.controller.ts index b24ba8f..10e3eb5 100644 --- a/src/scenario/scenario.controller.ts +++ b/src/scenario/scenario.controller.ts @@ -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) { return this.scenarioService.findAll(query); } diff --git a/src/scenario/scenario.service.ts b/src/scenario/scenario.service.ts index 404fd51..d2409a5 100644 --- a/src/scenario/scenario.service.ts +++ b/src/scenario/scenario.service.ts @@ -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 { - 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> { + async findAll(query: PaginationQueryDto): Promise> { 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, }); diff --git a/src/session/session.controller.ts b/src/session/session.controller.ts index e8aa461..653c230 100644 --- a/src/session/session.controller.ts +++ b/src/session/session.controller.ts @@ -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) { + 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 { 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); diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 637c687..8e8e42b 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -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[]> { - return this.repo.find({ select: ['id', 'sessionName', 'createdAt', 'updatedAt'] }); + async findAll(query: PaginationQueryDto = {}): Promise>> { + 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 { diff --git a/test/environment.controller.spec.ts b/test/environment.controller.spec.ts index d87dd21..33ce963 100644 --- a/test/environment.controller.spec.ts +++ b/test/environment.controller.spec.ts @@ -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); }); }); diff --git a/test/mcp.controller.spec.ts b/test/mcp.controller.spec.ts index 6c2fd05..aba10be 100644 --- a/test/mcp.controller.spec.ts +++ b/test/mcp.controller.spec.ts @@ -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'); }); }); diff --git a/test/scenario.controller.spec.ts b/test/scenario.controller.spec.ts index 4970da3..2a2f335 100644 --- a/test/scenario.controller.spec.ts +++ b/test/scenario.controller.spec.ts @@ -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 ───────────────────────────────────────────────────── diff --git a/test/session.controller.spec.ts b/test/session.controller.spec.ts index 6f3905c..3f3c457 100644 --- a/test/session.controller.spec.ts +++ b/test/session.controller.spec.ts @@ -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'); });