From 03cd3f17a0bdf8275a9c8218644e6197d07f736f Mon Sep 17 00:00:00 2001 From: barry01_hash Date: Sat, 20 Jun 2026 13:19:25 +0100 Subject: [PATCH] fix campaign browse cache key --- src/campaigns/campaigns.controller.spec.ts | 149 +++++++++++++++++++++ src/campaigns/campaigns.controller.ts | 29 +++- 2 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 src/campaigns/campaigns.controller.spec.ts diff --git a/src/campaigns/campaigns.controller.spec.ts b/src/campaigns/campaigns.controller.spec.ts new file mode 100644 index 0000000..8999cd2 --- /dev/null +++ b/src/campaigns/campaigns.controller.spec.ts @@ -0,0 +1,149 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CampaignsController } from './campaigns.controller'; +import { CampaignsService } from './campaigns.service'; +import { DonationsService } from '../donations/donations.service'; + +jest.mock('./campaigns.service', () => ({ + CampaignsService: class CampaignsService {}, +})); + +jest.mock('../donations/donations.service', () => ({ + DonationsService: class DonationsService {}, +})); + +jest.mock('../auth/jwt-auth.guard', () => ({ + JwtAuthGuard: class JwtAuthGuard { + canActivate() { + return true; + } + }, +})); + +jest.mock('../common/guards/roles.guard', () => ({ + RolesGuard: class RolesGuard { + canActivate() { + return true; + } + }, +})); + +jest.mock('../users/guards/admin.guard', () => ({ + AdminGuard: class AdminGuard { + canActivate() { + return true; + } + }, +})); + +describe('CampaignsController browseCampaigns cache keying', () => { + let controller: CampaignsController; + const campaignsService = { + browseCampaigns: jest.fn(), + }; + const cacheStore = new Map(); + const cacheManager = { + get: jest.fn(async (key: string) => cacheStore.get(key)), + set: jest.fn(async (key: string, value: unknown) => { + cacheStore.set(key, value); + }), + }; + + beforeEach(async () => { + cacheStore.clear(); + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CampaignsController], + providers: [ + { provide: CampaignsService, useValue: campaignsService }, + { provide: DonationsService, useValue: {} }, + { provide: CACHE_MANAGER, useValue: cacheManager }, + ], + }).compile(); + + controller = module.get(CampaignsController); + }); + + it('creates distinct cached entries for different page and sortBy values', async () => { + campaignsService.browseCampaigns.mockImplementation( + async (query: { page: number; limit: number; sortBy: string }) => ({ + data: [{ id: `${query.sortBy}-${query.page}` }], + total: 2, + page: query.page, + limit: query.limit, + }), + ); + + const first = await controller.browseCampaigns({ + category: 'health', + status: 'ACTIVE', + search: 'solar', + page: 1, + limit: 10, + sortBy: 'newest', + }); + + const second = await controller.browseCampaigns({ + category: 'health', + status: 'ACTIVE', + search: 'solar', + page: 2, + limit: 10, + sortBy: 'newest', + }); + + const third = await controller.browseCampaigns({ + category: 'health', + status: 'ACTIVE', + search: 'solar', + page: 1, + limit: 10, + sortBy: 'mostFunded', + }); + + expect(first).toEqual({ + data: [{ id: 'newest-1' }], + total: 2, + page: 1, + limit: 10, + }); + expect(second).toEqual({ + data: [{ id: 'newest-2' }], + total: 2, + page: 2, + limit: 10, + }); + expect(third).toEqual({ + data: [{ id: 'mostFunded-1' }], + total: 2, + page: 1, + limit: 10, + }); + + expect(campaignsService.browseCampaigns).toHaveBeenCalledTimes(3); + + const cacheKeys = cacheManager.set.mock.calls.map(([key]) => key as string); + expect(cacheKeys).toHaveLength(3); + expect(new Set(cacheKeys).size).toBe(3); + }); + + it('hashes long search queries to keep cache keys compact', async () => { + campaignsService.browseCampaigns.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 10, + }); + + await controller.browseCampaigns({ + search: 'x'.repeat(300), + page: 1, + limit: 10, + sortBy: 'newest', + }); + + const [cacheKey] = cacheManager.set.mock.calls[0]; + expect(cacheKey).toMatch(/^campaigns:[a-f0-9]{64}$/); + }); +}); diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts index 7114490..cf13565 100644 --- a/src/campaigns/campaigns.controller.ts +++ b/src/campaigns/campaigns.controller.ts @@ -16,6 +16,7 @@ import { } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import type { Cache } from 'cache-manager'; +import { createHash } from 'crypto'; import { CampaignsService } from './campaigns.service'; import type { CampaignStats } from './interfaces/campaign-stats.interface'; import { Roles } from '../common/decorators/roles.decorator'; @@ -185,13 +186,29 @@ export class CampaignsController { } private generateCacheKey(query: BrowseCampaignsQueryDto): string { - const parts = ['campaigns']; - - if (query.category) parts.push(`category:${query.category}`); - if (query.status) parts.push(`status:${query.status}`); - if (query.search) parts.push(`search:${query.search}`); + const normalized = { + category: query.category?.trim() ?? '', + status: query.status?.trim() ?? '', + search: query.search?.trim() ?? '', + page: query.page ?? 1, + limit: query.limit ?? 10, + sortBy: query.sortBy ?? 'newest', + }; + + const rawKey = [ + `c=${encodeURIComponent(normalized.category)}`, + `s=${encodeURIComponent(normalized.status)}`, + `q=${encodeURIComponent(normalized.search)}`, + `p=${normalized.page}`, + `l=${normalized.limit}`, + `sort=${normalized.sortBy}`, + ].join('|'); + + if (rawKey.length > 120) { + return `campaigns:${createHash('sha256').update(rawKey).digest('hex')}`; + } - return parts.join(':'); + return `campaigns:${rawKey}`; } }