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
232 changes: 232 additions & 0 deletions src/booru/booru.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { Test, TestingModule } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import request from 'supertest'
import { BooruController } from './booru.controller'
import { BooruService } from './booru.service'
import { BooruCacheControlInterceptor } from './interceptors/booru-cache-control.interceptor'
import { BooruErrorsInterceptor } from './interceptors/booru-exception.interceptor'
import { BooruAuthManagerService } from './services/booru-auth-manager.service'
import { createAppValidationPipe } from '../common/validation'
import { ResponseDto } from '../lib/dto/response.dto'
import { Reflector } from '@nestjs/core'
import { EmptyDataError } from '@alejandroakbal/universal-booru-wrapper'

describe('BooruController', () => {
let app: NestFastifyApplication
let mockBooruService: jest.Mocked<Partial<BooruService>>

beforeEach(async () => {
mockBooruService = {
buildApiClass: jest.fn().mockReturnValue({
booruType: { initialPageID: 0 }
}),
executeWithAuthStrategy: jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = {
getPosts: jest.fn().mockResolvedValue([]),
getRandomPosts: jest.fn().mockResolvedValue([]),
getSinglePost: jest.fn().mockResolvedValue([]),
getTags: jest.fn().mockResolvedValue([])
}
return operation(mockApi, { source: 'none' })
})
}

jest.spyOn(ResponseDto, 'createFromController').mockReturnValue({
data: [],
meta: {
items_count: 0,
total_items: null,
current_page: 0,
total_pages: null,
items_per_page: 0
},
links: {
self: null,
first: null,
last: null,
prev: null,
next: null
}
} as any)

const module: TestingModule = await Test.createTestingModule({
controllers: [BooruController],
providers: [
{ provide: BooruService, useValue: mockBooruService },
BooruCacheControlInterceptor,
BooruErrorsInterceptor,
Reflector,
{ provide: BooruAuthManagerService, useValue: { reportAuthFailure: jest.fn() } }
]
}).compile()

app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(createAppValidationPipe())
await app.init()
await app.getHttpAdapter().getInstance().ready()
})

afterEach(async () => {
await app.close()
jest.restoreAllMocks()
})

describe('Cache-Control headers', () => {
it('posts endpoint returns public cache header', async () => {
const res = await request(app.getHttpServer())
.get('/booru/gelbooru/posts')
.query({ baseEndpoint: 'gelbooru.com' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('public, max-age=300, s-maxage=14400, stale-while-revalidate=3600')
})

it('single-post endpoint returns public cache header', async () => {
const res = await request(app.getHttpServer())
.get('/booru/gelbooru/single-post')
.query({ baseEndpoint: 'gelbooru.com' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('public, max-age=21600, s-maxage=604800, stale-while-revalidate=86400')
})

it('tags endpoint returns public cache header', async () => {
const res = await request(app.getHttpServer())
.get('/booru/gelbooru/tags')
.query({ baseEndpoint: 'gelbooru.com', tag: 'test' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('public, max-age=21600, s-maxage=604800, stale-while-revalidate=86400')
})

it('random-posts endpoint returns no-store header', async () => {
const res = await request(app.getHttpServer())
.get('/booru/gelbooru/random-posts')
.query({ baseEndpoint: 'gelbooru.com' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('no-store, no-cache, must-revalidate')
})

it('posts endpoint with auth returns private, no-store', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getPosts: jest.fn().mockResolvedValue([]) }
return operation(mockApi, { source: 'query', selectedCredential: { user: 'u', password: 'p' } })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/posts')
.query({ baseEndpoint: 'gelbooru.com', auth_user: 'u', auth_pass: 'p' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('private, no-store')
})

it('posts endpoint with env auth keeps the public cache header', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getPosts: jest.fn().mockResolvedValue([]) }
return operation(mockApi, { source: 'env', selectedCredential: { user: 'u', password: 'p' } })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/posts')
.query({ baseEndpoint: 'gelbooru.com' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('public, max-age=300, s-maxage=14400, stale-while-revalidate=3600')
})

it('posts endpoint with partial auth query returns private, no-store', async () => {
const res = await request(app.getHttpServer())
.get('/booru/gelbooru/posts')
.query({ baseEndpoint: 'gelbooru.com', auth_user: 'u' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('private, no-store')
})

it('posts endpoint keeps the public cache header for legitimate empty results', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getPosts: jest.fn().mockRejectedValue(new EmptyDataError()) }
return operation(mockApi, { source: 'none' })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/posts')
.query({ baseEndpoint: 'gelbooru.com' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('public, max-age=300, s-maxage=14400, stale-while-revalidate=3600')
})

it('posts endpoint with auth keeps empty results private and non-cacheable', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getPosts: jest.fn().mockRejectedValue(new EmptyDataError()) }
return operation(mockApi, { source: 'query', selectedCredential: { user: 'u', password: 'p' } })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/posts')
.query({ baseEndpoint: 'gelbooru.com', auth_user: 'u', auth_pass: 'p' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('private, no-store')
})

it('single-post endpoint with auth returns private, no-store', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getSinglePost: jest.fn().mockResolvedValue([]) }
return operation(mockApi, { source: 'query', selectedCredential: { user: 'u', password: 'p' } })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/single-post')
.query({ baseEndpoint: 'gelbooru.com', auth_user: 'u', auth_pass: 'p' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('private, no-store')
})

it('single-post not found returns the strict error cache header', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getSinglePost: jest.fn().mockRejectedValue(new EmptyDataError()) }
return operation(mockApi, { source: 'none' })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/single-post')
.query({ baseEndpoint: 'gelbooru.com', ID: 1 })

expect(res.status).toBe(404)
expect(res.headers['cache-control']).toBe('no-store, no-cache, must-revalidate')
})

it('tags endpoint with auth returns private, no-store', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getTags: jest.fn().mockResolvedValue([]) }
return operation(mockApi, { source: 'query', selectedCredential: { user: 'u', password: 'p' } })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/tags')
.query({ baseEndpoint: 'gelbooru.com', tag: 'test', auth_user: 'u', auth_pass: 'p' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('private, no-store')
})

it('random-posts endpoint with auth returns private, no-store', async () => {
mockBooruService.executeWithAuthStrategy = jest.fn().mockImplementation(async (_params, _queries, operation) => {
const mockApi = { getRandomPosts: jest.fn().mockResolvedValue([]) }
return operation(mockApi, { source: 'query', selectedCredential: { user: 'u', password: 'p' } })
})

const res = await request(app.getHttpServer())
.get('/booru/gelbooru/random-posts')
.query({ baseEndpoint: 'gelbooru.com', auth_user: 'u', auth_pass: 'p' })

expect(res.status).toBe(200)
expect(res.headers['cache-control']).toBe('private, no-store')
})
})
})
34 changes: 20 additions & 14 deletions src/booru/booru.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { EmptyDataError, IBooruQueryValues } from '@alejandroakbal/universal-booru-wrapper'
import { Controller, Get, Header, Param, Query, Request, UseInterceptors } from '@nestjs/common'
import { Controller, Get, Param, Query, Request, UseInterceptors } from '@nestjs/common'
import { ResponseDto } from '../lib/dto/response.dto'
import { BooruService } from './booru.service'
import { ResolvedAuthCredentials } from './booru.service'
import { BOORU_CACHE_CONTROL_POLICIES } from './constants/cache-control-policies'
import { BooruCachePolicy } from './decorators/booru-cache-policy.decorator'
import {
booruQueryValuesPostsDTO,
booruQueryValuesRandomPostsDTO,
booruQueryValuesSinglePostDTO,
booruQueryValuesTagsDTO
} from './dto/booru-queries.dto'
import { BooruEndpointParamsDTO } from './dto/request-booru.dto'
import { BooruCacheControlInterceptor } from './interceptors/booru-cache-control.interceptor'
import { BooruErrorsInterceptor } from './interceptors/booru-exception.interceptor'

interface BooruAuthContext {
Expand All @@ -22,8 +25,11 @@ interface AuthContextRequest {
booruAuthContext?: BooruAuthContext
}

// Successful responses use the route policy from BooruCacheControlInterceptor.
// Thrown responses are intentionally left to BooruErrorsInterceptor so they
// always fall back to the strict error cache policy.
@Controller('booru')
@UseInterceptors(BooruErrorsInterceptor)
@UseInterceptors(BooruCacheControlInterceptor, BooruErrorsInterceptor)
export class BooruController {
constructor(private readonly booruService: BooruService) {}

Expand All @@ -46,7 +52,7 @@ export class BooruController {
}

@Get(':booruType/posts')
@Header('Cache-Control', 'public, max-age=300, stale-while-revalidate=3600, stale-if-error=0') // 5 minutes, 1 hour
@BooruCachePolicy(BOORU_CACHE_CONTROL_POLICIES.POSTS)
async GetPosts(
@Request()
request,
Expand All @@ -69,8 +75,8 @@ export class BooruController {
}

try {
const posts = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authResolution) => {
this.attachAuthContext(request, queries.baseEndpoint, authResolution)
const posts = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authRes) => {
this.attachAuthContext(request, queries.baseEndpoint, authRes)
return Api.getPosts(postQueryValues)
})

Expand All @@ -88,7 +94,7 @@ export class BooruController {
}

@Get(':booruType/random-posts')
@Header('Cache-Control', 'no-cache')
@BooruCachePolicy(BOORU_CACHE_CONTROL_POLICIES.RANDOM_POSTS)
async GetRandomPosts(
@Request()
request,
Expand All @@ -111,8 +117,8 @@ export class BooruController {
}

try {
const posts = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authResolution) => {
this.attachAuthContext(request, queries.baseEndpoint, authResolution)
const posts = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authRes) => {
this.attachAuthContext(request, queries.baseEndpoint, authRes)
return Api.getRandomPosts(postQueryValues)
})

Expand All @@ -130,7 +136,7 @@ export class BooruController {
}

@Get(':booruType/single-post')
@Header('Cache-Control', 'public, max-age=604800, immutable') // 1 week
@BooruCachePolicy(BOORU_CACHE_CONTROL_POLICIES.SINGLE_POST)
async GetSinglePost(
@Request()
request,
Expand All @@ -145,16 +151,16 @@ export class BooruController {

const initialApi = this.booruService.buildApiClass(params, queries)

const posts = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authResolution) => {
this.attachAuthContext(request, queries.baseEndpoint, authResolution)
const posts = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authRes) => {
this.attachAuthContext(request, queries.baseEndpoint, authRes)
return Api.getSinglePost(postQueryValues)
})

return ResponseDto.createFromController(request, queries, initialApi, posts)
}

@Get(':booruType/tags')
@Header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=86400, stale-if-error=0') // 1 day, 1 day
@BooruCachePolicy(BOORU_CACHE_CONTROL_POLICIES.TAGS)
async GetTags(
@Request()
request,
Expand All @@ -176,8 +182,8 @@ export class BooruController {
}

try {
const tags = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authResolution) => {
this.attachAuthContext(request, queries.baseEndpoint, authResolution)
const tags = await this.booruService.executeWithAuthStrategy(params, queries, async (Api, authRes) => {
this.attachAuthContext(request, queries.baseEndpoint, authRes)
return Api.getTags(postQueryValues)
})

Expand Down
4 changes: 3 additions & 1 deletion src/booru/booru.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'
import { BooruService } from './booru.service'
import { BooruController } from './booru.controller'
import { BooruCacheControlInterceptor } from './interceptors/booru-cache-control.interceptor'
import { BooruErrorsInterceptor } from './interceptors/booru-exception.interceptor'
import { BooruAuthManagerService } from './services/booru-auth-manager.service'

@Module({
providers: [BooruService, BooruAuthManagerService],
providers: [BooruService, BooruAuthManagerService, BooruCacheControlInterceptor, BooruErrorsInterceptor],
controllers: [BooruController],
exports: [BooruAuthManagerService]
})
Expand Down
6 changes: 1 addition & 5 deletions src/booru/booru.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,6 @@ describe('BooruService', () => {
})
expect(mockAuthManager.getAvailableCredential).not.toHaveBeenCalled()
})


})

describe('Managed Strategy Execution', () => {
Expand Down Expand Up @@ -265,9 +263,7 @@ describe('BooruService', () => {

const queries = { ...baseQueries } as booruQueriesDTO

await expect(
service.executeWithAuthStrategy(mockParams, queries, async () => 'unused')
).rejects.toEqual(
await expect(service.executeWithAuthStrategy(mockParams, queries, async () => 'unused')).rejects.toEqual(
expect.objectContaining<Partial<ManagedCredentialPoolUnavailableError>>({
name: 'ManagedCredentialPoolUnavailableError',
retryAfterSeconds: 42,
Expand Down
Loading