diff --git a/src/booru/dto/booru-queries.dto.spec.ts b/src/booru/dto/booru-queries.dto.spec.ts index 8cc6df8..524da3d 100644 --- a/src/booru/dto/booru-queries.dto.spec.ts +++ b/src/booru/dto/booru-queries.dto.spec.ts @@ -1,68 +1,67 @@ -import { plainToInstance } from 'class-transformer' +import { Controller, Get, Query } from '@nestjs/common' +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' +import { Test, TestingModule } from '@nestjs/testing' +import { createAppValidationPipe } from '../../common/validation' import { booruQueryValuesPostsDTO } from './booru-queries.dto' -describe('booruQueryValuesPostsDTO', () => { - describe('tags transform', () => { - it('should normalize a single tag string into an array', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: 'panty_&_stocking_with_garterbelt' - }) +@Controller('dto-test') +class BooruQueriesTestController { + @Get('posts') + getPosts(@Query() queries: booruQueryValuesPostsDTO) { + return { tags: queries.tags } + } +} - expect(dto.tags).toEqual(['panty_&_stocking_with_garterbelt']) - }) - - it('should split pipe-separated tags', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: 'panty_&_stocking_with_garterbelt|rating:safe' - }) +describe('booruQueryValuesPostsDTO request handling', () => { + let app: NestFastifyApplication - expect(dto.tags).toEqual(['panty_&_stocking_with_garterbelt', 'rating:safe']) - }) + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [BooruQueriesTestController] + }).compile() - it('should normalize array tag inputs and keep tag array shape', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: ['panty_&_stocking_with_garterbelt|rating:safe', 'score:>100'] - }) + app = moduleRef.createNestApplication(new FastifyAdapter()) + app.useGlobalPipes(createAppValidationPipe()) - expect(dto.tags).toEqual(['panty_&_stocking_with_garterbelt', 'rating:safe', 'score:>100']) - }) + await app.init() + await app.getHttpAdapter().getInstance().ready() + }) - it('should normalize non-string tag input without throwing', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: 123 - }) + afterEach(async () => { + await app.close() + }) - expect(dto.tags).toEqual(['123']) + it('should rely on request parsing for percent-decoding and split tags by pipe', async () => { + const response = await app.inject({ + method: 'GET', + url: '/dto-test/posts?baseEndpoint=gelbooru.com&tags=panty_%26_stocking_with_garterbelt%7Crating%3Asafe' }) - it('should keep non-encoded percent tags unchanged', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: '100%_real' - }) + const body = JSON.parse(response.body) - expect(dto.tags).toEqual(['100%_real']) - }) - - it('should keep malformed percent tag values unchanged', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: 'bad%%' - }) + expect(response.statusCode).toBe(200) + expect(body.tags).toEqual(['panty_&_stocking_with_garterbelt', 'rating:safe']) + }) - expect(dto.tags).toEqual(['bad%%']) + it('should normalize repeated tags query params and split each entry', async () => { + const response = await app.inject({ + method: 'GET', + url: '/dto-test/posts?baseEndpoint=gelbooru.com&tags=artist%3Afoo%7Crating%3Asafe&tags=score%3A%3E100' }) - it('should return undefined when tags is undefined', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, {}) - - expect(dto.tags).toBeUndefined() - }) + const body = JSON.parse(response.body) - it('should return null when tags is null', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: null - }) + expect(response.statusCode).toBe(200) + expect(body.tags).toEqual(['artist:foo', 'rating:safe', 'score:>100']) + }) - expect(dto.tags).toBeNull() + it('should reject empty tags created after pipe splitting', async () => { + const response = await app.inject({ + method: 'GET', + url: '/dto-test/posts?baseEndpoint=gelbooru.com&tags=tag1%7C' }) + + expect(response.statusCode).toBe(400) + expect(response.body).toContain('tags should not contain') }) }) diff --git a/src/booru/dto/booru-queries.dto.ts b/src/booru/dto/booru-queries.dto.ts index 4eaf77b..89a09ea 100644 --- a/src/booru/dto/booru-queries.dto.ts +++ b/src/booru/dto/booru-queries.dto.ts @@ -180,14 +180,15 @@ export class booruQueryValuesPostsDTO extends booruQueriesDTO { @IsArray() @ArrayNotEmpty() @ArrayNotContains(['']) + @IsString({ each: true }) @Transform(({ value }) => { if (value === undefined || value === null) { return value } - return (Array.isArray(value) ? value : [value]) - .map((tag) => (typeof tag === 'string' ? tag : String(tag))) - .flatMap((tag) => tag.trim().split('|')) + return (Array.isArray(value) ? value : [value]).flatMap((tag) => + typeof tag === 'string' ? tag.trim().split('|') : [tag] + ) }) @IsOptional() readonly tags: IBooruQueryValues['posts']['tags'] diff --git a/src/common/validation.ts b/src/common/validation.ts new file mode 100644 index 0000000..cb65cbf --- /dev/null +++ b/src/common/validation.ts @@ -0,0 +1,11 @@ +import { ValidationPipe, ValidationPipeOptions } from '@nestjs/common' + +export const APP_VALIDATION_PIPE_OPTIONS: ValidationPipeOptions = { + transform: true, + whitelist: true, + forbidNonWhitelisted: true +} + +export function createAppValidationPipe() { + return new ValidationPipe(APP_VALIDATION_PIPE_OPTIONS) +} diff --git a/src/main.ts b/src/main.ts index 53b18a1..9d83e87 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,3 @@ -import { ValidationPipe } from '@nestjs/common' import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface' import { NestFactory } from '@nestjs/core' import { ConfigService } from '@nestjs/config' @@ -10,6 +9,7 @@ import { AppModule } from './app.module' import { escapeRegExp } from 'lodash' import { AppClusterService } from './cluster.service' import { join } from 'path' +import { createAppValidationPipe } from './common/validation' async function bootstrap() { const app = await NestFactory.create(AppModule, new FastifyAdapter()) @@ -40,15 +40,7 @@ async function bootstrap() { app.enableCors(corsOptions) - app.useGlobalPipes( - new ValidationPipe({ - transform: true, // Transform to DTO type - // transformOptions: { enableImplicitConversion: true }, - - whitelist: true, // Remove unnecessary properties - forbidNonWhitelisted: true // Sends "property should not exist." error - }) - ) + app.useGlobalPipes(createAppValidationPipe()) await app.listen(configService.get('PORT') ?? 3000, '0.0.0.0') }