From ded312c83ff1e75901cbd92c1a6e74063f262849 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:21:49 -0700 Subject: [PATCH 1/3] refactor: simplify booru tag DTO handling --- src/booru/dto/booru-queries.dto.spec.ts | 104 ++++++++++++------------ src/booru/dto/booru-queries.dto.ts | 7 +- 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/src/booru/dto/booru-queries.dto.spec.ts b/src/booru/dto/booru-queries.dto.spec.ts index 8cc6df8..2df7d43 100644 --- a/src/booru/dto/booru-queries.dto.spec.ts +++ b/src/booru/dto/booru-queries.dto.spec.ts @@ -1,68 +1,72 @@ -import { plainToInstance } from 'class-transformer' +import { Controller, Get, Query, ValidationPipe } from '@nestjs/common' +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' +import { Test, TestingModule } from '@nestjs/testing' 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 } + } +} + +describe('booruQueryValuesPostsDTO request handling', () => { + let app: NestFastifyApplication + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [booruQueriesTestController] + }).compile() + + app = moduleRef.createNestApplication(new FastifyAdapter()) + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true }) + ) - 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' - }) - - expect(dto.tags).toEqual(['panty_&_stocking_with_garterbelt', 'rating:safe']) - }) + await app.init() + await app.getHttpAdapter().getInstance().ready() + }) - it('should normalize array tag inputs and keep tag array shape', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: ['panty_&_stocking_with_garterbelt|rating:safe', 'score:>100'] - }) + afterEach(async () => { + await app.close() + }) - expect(dto.tags).toEqual(['panty_&_stocking_with_garterbelt', 'rating:safe', 'score:>100']) + 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 normalize non-string tag input without throwing', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: 123 - }) - - expect(dto.tags).toEqual(['123']) - }) + const body = JSON.parse(response.body) - it('should keep non-encoded percent tags unchanged', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: '100%_real' - }) + expect(response.statusCode).toBe(200) + expect(body.tags).toEqual(['panty_&_stocking_with_garterbelt', 'rating:safe']) + }) - expect(dto.tags).toEqual(['100%_real']) + 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 keep malformed percent tag values unchanged', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: 'bad%%' - }) - - expect(dto.tags).toEqual(['bad%%']) - }) + const body = JSON.parse(response.body) - it('should return undefined when tags is undefined', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, {}) + expect(response.statusCode).toBe(200) + expect(body.tags).toEqual(['artist:foo', 'rating:safe', 'score:>100']) + }) - expect(dto.tags).toBeUndefined() + 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' }) - it('should return null when tags is null', () => { - const dto = plainToInstance(booruQueryValuesPostsDTO, { - tags: null - }) - - expect(dto.tags).toBeNull() - }) + 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'] From 637e05c419a81aee1bf4d711cac6b422ca4af980 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:31:14 -0700 Subject: [PATCH 2/3] test: share app validation setup --- src/booru/dto/booru-queries.dto.spec.ts | 11 +++-------- src/common/validation.ts | 11 +++++++++++ src/main.ts | 12 ++---------- 3 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 src/common/validation.ts diff --git a/src/booru/dto/booru-queries.dto.spec.ts b/src/booru/dto/booru-queries.dto.spec.ts index 2df7d43..b02f7da 100644 --- a/src/booru/dto/booru-queries.dto.spec.ts +++ b/src/booru/dto/booru-queries.dto.spec.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Query, ValidationPipe } from '@nestjs/common' +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' @Controller('dto-test') @@ -20,13 +21,7 @@ describe('booruQueryValuesPostsDTO request handling', () => { }).compile() app = moduleRef.createNestApplication(new FastifyAdapter()) - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true - }) - ) + app.useGlobalPipes(createAppValidationPipe()) await app.init() await app.getHttpAdapter().getInstance().ready() 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') } From a9f7dbf4138ecaf2195e9cf92222db44dec7608a Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:44:48 -0700 Subject: [PATCH 3/3] test: rename booru DTO test controller --- src/booru/dto/booru-queries.dto.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/booru/dto/booru-queries.dto.spec.ts b/src/booru/dto/booru-queries.dto.spec.ts index b02f7da..524da3d 100644 --- a/src/booru/dto/booru-queries.dto.spec.ts +++ b/src/booru/dto/booru-queries.dto.spec.ts @@ -5,7 +5,7 @@ import { createAppValidationPipe } from '../../common/validation' import { booruQueryValuesPostsDTO } from './booru-queries.dto' @Controller('dto-test') -class booruQueriesTestController { +class BooruQueriesTestController { @Get('posts') getPosts(@Query() queries: booruQueryValuesPostsDTO) { return { tags: queries.tags } @@ -17,7 +17,7 @@ describe('booruQueryValuesPostsDTO request handling', () => { beforeEach(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [booruQueriesTestController] + controllers: [BooruQueriesTestController] }).compile() app = moduleRef.createNestApplication(new FastifyAdapter())