From 3727c13ee23b85aa8ff510315f5c42ecf462b165 Mon Sep 17 00:00:00 2001 From: Alejandro Akbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Mon, 11 May 2026 19:46:35 +0200 Subject: [PATCH 1/2] fix(cache): centralize booru cache control for API responses --- src/booru/booru.controller.spec.ts | 232 ++++++++++++++++++ src/booru/booru.controller.ts | 34 +-- src/booru/booru.module.ts | 4 +- src/booru/constants/cache-control-policies.ts | 8 + .../booru-cache-policy.decorator.ts | 5 + .../booru-cache-control.interceptor.ts | 48 ++++ .../booru-exception.interceptor.spec.ts | 35 ++- .../booru-exception.interceptor.ts | 6 + 8 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 src/booru/booru.controller.spec.ts create mode 100644 src/booru/constants/cache-control-policies.ts create mode 100644 src/booru/decorators/booru-cache-policy.decorator.ts create mode 100644 src/booru/interceptors/booru-cache-control.interceptor.ts diff --git a/src/booru/booru.controller.spec.ts b/src/booru/booru.controller.spec.ts new file mode 100644 index 0000000..912ec31 --- /dev/null +++ b/src/booru/booru.controller.spec.ts @@ -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> + + 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(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') + }) + }) +}) diff --git a/src/booru/booru.controller.ts b/src/booru/booru.controller.ts index 696e533..a19aed6 100644 --- a/src/booru/booru.controller.ts +++ b/src/booru/booru.controller.ts @@ -1,8 +1,10 @@ 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, @@ -10,6 +12,7 @@ import { 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 { @@ -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) {} @@ -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, @@ -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) }) @@ -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, @@ -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) }) @@ -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, @@ -145,8 +151,8 @@ 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) }) @@ -154,7 +160,7 @@ export class BooruController { } @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, @@ -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) }) diff --git a/src/booru/booru.module.ts b/src/booru/booru.module.ts index 407ed7f..a05372d 100644 --- a/src/booru/booru.module.ts +++ b/src/booru/booru.module.ts @@ -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] }) diff --git a/src/booru/constants/cache-control-policies.ts b/src/booru/constants/cache-control-policies.ts new file mode 100644 index 0000000..7987cea --- /dev/null +++ b/src/booru/constants/cache-control-policies.ts @@ -0,0 +1,8 @@ +export const BOORU_CACHE_CONTROL_POLICIES = { + POSTS: 'public, max-age=300, s-maxage=14400, stale-while-revalidate=3600', + RANDOM_POSTS: 'no-store, no-cache, must-revalidate', + SINGLE_POST: 'public, max-age=21600, s-maxage=604800, stale-while-revalidate=86400', + TAGS: 'public, max-age=21600, s-maxage=604800, stale-while-revalidate=86400', + PRIVATE_AUTH: 'private, no-store', + ERROR: 'no-store, no-cache, must-revalidate' +} as const diff --git a/src/booru/decorators/booru-cache-policy.decorator.ts b/src/booru/decorators/booru-cache-policy.decorator.ts new file mode 100644 index 0000000..10862d8 --- /dev/null +++ b/src/booru/decorators/booru-cache-policy.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common' + +export const BOORU_CACHE_POLICY_METADATA_KEY = 'booru:cache-policy' + +export const BooruCachePolicy = (policy: string) => SetMetadata(BOORU_CACHE_POLICY_METADATA_KEY, policy) diff --git a/src/booru/interceptors/booru-cache-control.interceptor.ts b/src/booru/interceptors/booru-cache-control.interceptor.ts new file mode 100644 index 0000000..164b1b5 --- /dev/null +++ b/src/booru/interceptors/booru-cache-control.interceptor.ts @@ -0,0 +1,48 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { Observable } from 'rxjs' +import { tap } from 'rxjs/operators' +import { BOORU_CACHE_CONTROL_POLICIES } from '../constants/cache-control-policies' +import { BOORU_CACHE_POLICY_METADATA_KEY } from '../decorators/booru-cache-policy.decorator' + +@Injectable() +export class BooruCacheControlInterceptor implements NestInterceptor { + constructor(private readonly reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const response = context.switchToHttp().getResponse() + const request = context.switchToHttp().getRequest() + const cachePolicy = this.reflector.getAllAndOverride(BOORU_CACHE_POLICY_METADATA_KEY, [ + context.getHandler(), + context.getClass() + ]) + + if (!cachePolicy) { + return next.handle() + } + + // Only write success-path policies here. Thrown responses are left to + // BooruErrorsInterceptor so strict error headers are not replaced later. + return next.handle().pipe( + tap(() => { + if (!response || typeof response.header !== 'function') { + return + } + + // Keep both checks on purpose: + // - raw query params catch partial auth URLs that should never be shared-cacheable + // - resolved auth context catches requests that actually used query credentials + if (this.hasAuthQueryParams(request) || request?.booruAuthContext?.source === 'query') { + response.header('Cache-Control', BOORU_CACHE_CONTROL_POLICIES.PRIVATE_AUTH) + return + } + + response.header('Cache-Control', cachePolicy) + }) + ) + } + + private hasAuthQueryParams(request: any): boolean { + return Boolean(request?.query?.auth_user || request?.query?.auth_pass) + } +} diff --git a/src/booru/interceptors/booru-exception.interceptor.spec.ts b/src/booru/interceptors/booru-exception.interceptor.spec.ts index d3c31f6..4c3969e 100644 --- a/src/booru/interceptors/booru-exception.interceptor.spec.ts +++ b/src/booru/interceptors/booru-exception.interceptor.spec.ts @@ -32,8 +32,7 @@ class TestBooruErrorsController { @Get('auth-failure') getAuthFailure() { const error = new HttpError({ - message: - 'Forbidden for https://www.gelbooru.com/index.php?page=dapi&auth_user=www-gel-user&auth_pass=secret123', + message: 'Forbidden for https://www.gelbooru.com/index.php?page=dapi&auth_user=www-gel-user&auth_pass=secret123', statusCode: 403, failureKind: 'auth_forbidden' }) @@ -270,4 +269,36 @@ describe('BooruErrorsInterceptor', () => { expect(response.status).toBe(401) expect(disabledCredentials).toHaveLength(0) }) + + describe('Cache-Control on errors', () => { + it('should set Cache-Control: no-store on EmptyDataError responses', async () => { + const response = await request(app.getHttpServer()).get('/test-booru-errors/empty') + expect(response.status).toBe(404) + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate') + }) + + it('should set Cache-Control: no-store on auth failure responses', async () => { + const response = await request(app.getHttpServer()).get('/test-booru-errors/auth-failure').query({ + baseEndpoint: 'https://www.gelbooru.com/index.php?page=dapi', + auth_user: 'www-gel-user' + }) + expect(response.status).toBe(401) + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate') + }) + + it('should set Cache-Control: no-store on rate-limit responses', async () => { + const response = await request(app.getHttpServer()).get('/test-booru-errors/rate-limit').query({ + baseEndpoint: 'https://www.gelbooru.com/index.php?page=dapi', + auth_user: 'www-gel-user' + }) + expect(response.status).toBe(429) + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate') + }) + + it('should set Cache-Control: no-store on pool unavailable responses', async () => { + const response = await request(app.getHttpServer()).get('/test-booru-errors/pool-unavailable') + expect(response.status).toBe(503) + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate') + }) + }) }) diff --git a/src/booru/interceptors/booru-exception.interceptor.ts b/src/booru/interceptors/booru-exception.interceptor.ts index a8681eb..8485e9a 100644 --- a/src/booru/interceptors/booru-exception.interceptor.ts +++ b/src/booru/interceptors/booru-exception.interceptor.ts @@ -15,6 +15,7 @@ import { EmptyDataError, EndpointError, HttpError } from '@alejandroakbal/univer import { NoContentException } from '../../common/exceptions/no-content.exception' import { BooruAuthManagerService } from '../services/booru-auth-manager.service' import { AuthFailureEvent } from '../interfaces/auth-manager.interface' +import { BOORU_CACHE_CONTROL_POLICIES } from '../constants/cache-control-policies' import { SENSITIVE_AUTH_PARAMS } from '../constants/sensitive-auth-params' import { ManagedCredentialPoolUnavailableError } from '../booru.service' @@ -28,6 +29,11 @@ export class BooruErrorsInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( catchError((error) => { + const response = context.switchToHttp().getResponse() + if (response && typeof response.header === 'function') { + response.header('Cache-Control', BOORU_CACHE_CONTROL_POLICIES.ERROR) + } + // Check for authentication failures before processing other errors this.checkForAuthFailure(error, context) From 1875ed297e6a3ea9b25f9ce7f264d2677d3a9704 Mon Sep 17 00:00:00 2001 From: Alejandro Akbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Mon, 11 May 2026 19:48:50 +0200 Subject: [PATCH 2/2] chore(booru): apply lint-only formatting to service files --- src/booru/booru.service.spec.ts | 6 +----- src/booru/booru.service.ts | 15 ++++----------- .../services/booru-auth-manager.service.spec.ts | 10 ++++++---- src/booru/services/booru-auth-manager.service.ts | 16 ++++------------ 4 files changed, 15 insertions(+), 32 deletions(-) diff --git a/src/booru/booru.service.spec.ts b/src/booru/booru.service.spec.ts index 2fa5a51..9ca97f3 100644 --- a/src/booru/booru.service.spec.ts +++ b/src/booru/booru.service.spec.ts @@ -163,8 +163,6 @@ describe('BooruService', () => { }) expect(mockAuthManager.getAvailableCredential).not.toHaveBeenCalled() }) - - }) describe('Managed Strategy Execution', () => { @@ -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>({ name: 'ManagedCredentialPoolUnavailableError', retryAfterSeconds: 42, diff --git a/src/booru/booru.service.ts b/src/booru/booru.service.ts index 5e9a9b2..c8f86fb 100644 --- a/src/booru/booru.service.ts +++ b/src/booru/booru.service.ts @@ -223,10 +223,7 @@ export class BooruService { private getManagedRetryCap(): number { const configuredCap = this.configService.get('BOORU_MANAGED_RETRY_CAP') - const parsedCap = - typeof configuredCap === 'number' - ? configuredCap - : parseInt(configuredCap ?? '', 10) + const parsedCap = typeof configuredCap === 'number' ? configuredCap : parseInt(configuredCap ?? '', 10) if (!Number.isFinite(parsedCap) || parsedCap < 1) { return 5 @@ -266,13 +263,9 @@ export class BooruService { return error.toString() } - private getFailureKind(error: any): - | 'auth_invalid' - | 'auth_forbidden' - | 'rate_limited' - | 'upstream_error' - | 'network_error' - | 'unknown' { + private getFailureKind( + error: any + ): 'auth_invalid' | 'auth_forbidden' | 'rate_limited' | 'upstream_error' | 'network_error' | 'unknown' { if (error.failureKind) { return error.failureKind } diff --git a/src/booru/services/booru-auth-manager.service.spec.ts b/src/booru/services/booru-auth-manager.service.spec.ts index d6356b4..22e495e 100644 --- a/src/booru/services/booru-auth-manager.service.spec.ts +++ b/src/booru/services/booru-auth-manager.service.spec.ts @@ -355,9 +355,7 @@ describe('BooruAuthManagerService', () => { timestamp: new Date() }) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Low credential availability for same-user.test') - ) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Low credential availability for same-user.test')) jest.advanceTimersByTime(1_100) service.getDomainStats('same-user.test') @@ -392,7 +390,11 @@ describe('BooruAuthManagerService', () => { // With fix: reconstruct Dates before calling disableCredentialLocally const reconstructed = serialized.state === 'cooldown' - ? { ...serialized, disabledAt: new Date(serialized.disabledAt), cooldownUntil: new Date(serialized.cooldownUntil) } + ? { + ...serialized, + disabledAt: new Date(serialized.disabledAt), + cooldownUntil: new Date(serialized.cooldownUntil) + } : { ...serialized, disabledAt: new Date(serialized.disabledAt) } // Should NOT throw — disabledAt.getTime() and cooldownUntil.getTime() must work diff --git a/src/booru/services/booru-auth-manager.service.ts b/src/booru/services/booru-auth-manager.service.ts index 5031b23..e5a7f93 100644 --- a/src/booru/services/booru-auth-manager.service.ts +++ b/src/booru/services/booru-auth-manager.service.ts @@ -16,14 +16,10 @@ import { createCredentialKey, parseCredentialKey } from './credential-key.util' @Injectable() export class BooruAuthManagerService implements OnModuleInit { - private static readonly HTTP_STATUS_PATTERN = - /(?:status(?:\s*code|_code)?|http)\s*[:=]?\s*(\d{3})|\b(\d{3})\b/i + private static readonly HTTP_STATUS_PATTERN = /(?:status(?:\s*code|_code)?|http)\s*[:=]?\s*(\d{3})|\b(\d{3})\b/i private disabledCredentials = new Map() - private cooldownCredentials = new Map< - string, - { disabledAt: number; cooldownUntil: number; reason: string } - >() + private cooldownCredentials = new Map() private selectionCursorByDomain = new Map() private availabilityByDomain = new Map() private authConfig: BooruAuthConfig = {} @@ -143,9 +139,7 @@ export class BooruAuthManagerService implements OnModuleInit { this.broadcastDisabledCredential(disabledCredential) const stats = this.getDomainStats(normalizedDomain) - const action = isRateLimit - ? `cooldown for ${cooldownSeconds}s` - : 'permanently disabled' + const action = isRateLimit ? `cooldown for ${cooldownSeconds}s` : 'permanently disabled' console.error(`❌ Auth failure for ${normalizedDomain}:${sanitizedUser} - ${sanitizedError} (${action})`) console.warn( @@ -319,9 +313,7 @@ export class BooruAuthManagerService implements OnModuleInit { this.cleanupExpiredCooldowns(normalizedDomain) const credentials = this.authConfig[normalizedDomain] || [] - const status = credentials.map((credential) => - this.getMaskedCredentialStatus(normalizedDomain, credential) - ) + const status = credentials.map((credential) => this.getMaskedCredentialStatus(normalizedDomain, credential)) const stats = this.getDomainStats(normalizedDomain) return {