Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 48 additions & 49 deletions src/booru/dto/booru-queries.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -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<NestFastifyApplication>(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')
})
})
7 changes: 4 additions & 3 deletions src/booru/dto/booru-queries.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
11 changes: 11 additions & 0 deletions src/common/validation.ts
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 2 additions & 10 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<NestFastifyApplication>(AppModule, new FastifyAdapter())
Expand Down Expand Up @@ -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 <property> should not exist." error
})
)
app.useGlobalPipes(createAppValidationPipe())

await app.listen(configService.get<number>('PORT') ?? 3000, '0.0.0.0')
}
Expand Down