diff --git a/app/backend/prisma/migrations/20260715200117_issue_migration_for_chain_forge/migration.sql b/app/backend/prisma/migrations/20260715200117_issue_migration_for_chain_forge/migration.sql new file mode 100644 index 00000000..d6ca4ad8 --- /dev/null +++ b/app/backend/prisma/migrations/20260715200117_issue_migration_for_chain_forge/migration.sql @@ -0,0 +1,8 @@ +-- CreateIndex +CREATE INDEX "Claim_campaignId_status_idx" ON "Claim"("campaignId", "status"); + +-- CreateIndex +CREATE INDEX "SessionSubmission_sessionId_deletedAt_idx" ON "SessionSubmission"("sessionId", "deletedAt"); + +-- CreateIndex +CREATE INDEX "VerificationRequest_reviewedBy_idx" ON "VerificationRequest"("reviewedBy"); diff --git a/app/backend/src/audit/audit.controller.ts b/app/backend/src/audit/audit.controller.ts index 94126efe..8a15084b 100644 --- a/app/backend/src/audit/audit.controller.ts +++ b/app/backend/src/audit/audit.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Query, Res, Version } from '@nestjs/common'; import { Response } from 'express'; import { AuditService, AuditQuery, ExportAuditQuery } from './audit.service'; +import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator'; import { ApiTags, ApiOperation, @@ -16,6 +17,7 @@ import { export class AuditController { constructor(private readonly auditService: AuditService) {} + @HttpCacheTtl(30) // Response cached for 30 seconds @Get() @Version('1') @ApiOperation({ diff --git a/app/backend/src/campaigns/campaigns.controller.ts b/app/backend/src/campaigns/campaigns.controller.ts index d5a835cd..56d29ca9 100644 --- a/app/backend/src/campaigns/campaigns.controller.ts +++ b/app/backend/src/campaigns/campaigns.controller.ts @@ -39,6 +39,7 @@ import { Throttle } from '@nestjs/throttler'; import { OrgOwnershipGuard } from '../common/guards/org-ownership.guard'; import { CancelAndReissueService } from '../claims/cancel-and-reissue.service'; import { BudgetService } from '../common/budget/budget.service'; +import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator'; @ApiTags('Campaigns') @ApiBearerAuth('JWT-auth') @@ -71,6 +72,7 @@ export class CampaignsController { } @Throttle({ default: { ttl: 60000, limit: 10 } }) // Limit to 10 requests per minute for this endpoint + @HttpCacheTtl(30) // Response cached for 30 seconds @Get() @ApiOperation({ summary: 'List all campaigns', @@ -93,6 +95,7 @@ export class CampaignsController { } @Get(':id') + @HttpCacheTtl(30) // Response cached for 30 seconds @ApiOperation({ summary: 'Get campaign details', description: 'Retrieves metadata and status for a specific campaign.', diff --git a/app/backend/src/claims/claim-lifecycle.controller.ts b/app/backend/src/claims/claim-lifecycle.controller.ts index cb1eb8ce..d5df2ea5 100644 --- a/app/backend/src/claims/claim-lifecycle.controller.ts +++ b/app/backend/src/claims/claim-lifecycle.controller.ts @@ -28,6 +28,7 @@ import { AppRole } from 'src/auth/app-role.enum'; import { InternalNotesService } from 'src/common/services/internal-notes.service'; import { CreateInternalNoteDto } from 'src/common/dto/create-internal-note.dto'; import { InternalNoteResponseDto } from 'src/common/dto/internal-note-response.dto'; +import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator'; @ApiTags('Onchain Proxy') @ApiBearerAuth('JWT-auth') @@ -59,6 +60,7 @@ export class ClaimLifecycleController { return this.claimsService.create(createClaimDto); } + @HttpCacheTtl(30) // Response cached for 30 seconds @Get() @ApiOperation({ operationId: 'ClaimsController_findAll_v1', diff --git a/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts b/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts index 184d8e2c..837cb698 100644 --- a/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts +++ b/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts @@ -138,7 +138,7 @@ describe('HttpCacheInterceptor', () => { await firstValueFrom( interceptor.intercept(context, { handle: () => of({}) }), ); - expect(response.getHeader('X-Http-Cache')).toBeUndefined(); + expect(response.getHeader('X-Edge-Cache-Status')).toBeUndefined(); }); it('still applies no-store on a path that is otherwise always-skipped', async () => { @@ -246,7 +246,7 @@ describe('HttpCacheInterceptor', () => { 'Authorization, Accept-Encoding', ); expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/); - expect(response.getHeader('X-Http-Cache')).toBe('miss'); + expect(response.getHeader('X-Edge-Cache-Status')).toBe('miss'); }); it('emits deterministic ETags across key reorderings', async () => { diff --git a/app/backend/src/common/interceptors/http-cache.interceptor.ts b/app/backend/src/common/interceptors/http-cache.interceptor.ts index 83d473d2..13031d16 100644 --- a/app/backend/src/common/interceptors/http-cache.interceptor.ts +++ b/app/backend/src/common/interceptors/http-cache.interceptor.ts @@ -367,7 +367,7 @@ export class HttpCacheInterceptor implements NestInterceptor { private setDebugHeader(response: Response, value: string): void { if (this.debugHeaders) { - response.setHeader('X-Http-Cache', value); + response.setHeader('X-Edge-Cache-Status', value); } } } diff --git a/app/backend/test/audit.e2e-spec.ts b/app/backend/test/audit.e2e-spec.ts new file mode 100644 index 00000000..28f28659 --- /dev/null +++ b/app/backend/test/audit.e2e-spec.ts @@ -0,0 +1,112 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { AppModule } from 'src/app.module'; +import { PrismaService } from 'src/prisma/prisma.service'; +import request from 'supertest'; +import { App } from 'supertest/types'; + +describe('Audit (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + + const base = '/api/v1/audit'; + const testApiKey = 'e2e-test-key-0003'; + const testApiKeyHash = 'b4b40ca8559ecd4e296d5b0007eeab955dd480259c25a19d88bb4ef0cfb2c0bb'; + const authHeader = { 'X-Api-Key': testApiKey } as Record; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + prefix: 'v', + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + await app.init(); + prisma = app.get(PrismaService); + + await prisma.apiKey.upsert({ + where: { keyHash: testApiKeyHash }, + update: { revokedAt: null }, + create: { + key: testApiKey, + keyHash: testApiKeyHash, + keyPreview: testApiKey.slice(0, 8), + role: 'admin', + }, + }); + }); + + beforeEach(async () => { + await prisma.auditLog.deleteMany(); + }); + + afterAll(async () => { + await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } }); + await app.close(); + }); + + describe('Audit HTTP cache', () => { + it('GET /audit returns Cache-Control with max-age=30', async () => { + await prisma.auditLog.create({ + data: { + actorId: 'test-actor', + entity: 'campaign', + entityId: 'test-entity', + action: 'test', + }, + }); + + const res = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); + + const cc = res.headers['cache-control']; + expect(cc).toBeDefined(); + expect(cc).toContain('max-age=30'); + expect(cc).toContain('private'); + }); + + it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => { + await prisma.auditLog.create({ + data: { + actorId: 'test-actor', + entity: 'campaign', + entityId: 'test-entity-2', + action: 'test', + }, + }); + + const res1 = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); + + const etag = res1.headers['etag']; + expect(etag).toBeDefined(); + + const res2 = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .set('If-None-Match', etag) + .expect(304); + + expect(res2.headers['x-edge-cache-status']).toBe('hit'); + }); + }); +}); \ No newline at end of file diff --git a/app/backend/test/campaigns.e2e-spec.ts b/app/backend/test/campaigns.e2e-spec.ts index b1ce6774..da846f8f 100644 --- a/app/backend/test/campaigns.e2e-spec.ts +++ b/app/backend/test/campaigns.e2e-spec.ts @@ -1,10 +1,10 @@ import { Test } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; import request, { Response as SupertestResponse } from 'supertest'; import { AppModule } from 'src/app.module'; import { PrismaService } from 'src/prisma/prisma.service'; import { App } from 'supertest/types'; - +jest.setTimeout(30000); type ApiResponse = { success: boolean; data: T; @@ -19,7 +19,6 @@ type CampaignResponseDto = { }; function bodyAs(res: SupertestResponse): ApiResponse { - // supertest Response.body is `any`; we cast once here to satisfy strict ESLint rules return res.body as ApiResponse; } @@ -28,6 +27,9 @@ describe('Campaigns (e2e)', () => { let prisma: PrismaService; const base = '/api/v1/campaigns'; + const testApiKey = 'e2e-test-key-0001'; + const testApiKeyHash = '7cd155083be719224524695fc6e61cf3747b99dd3f6260e392f1b3b69577dcd9'; + const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -36,6 +38,13 @@ describe('Campaigns (e2e)', () => { app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + prefix: 'v', + }); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -46,19 +55,36 @@ describe('Campaigns (e2e)', () => { await app.init(); prisma = app.get(PrismaService); + + await prisma.apiKey.upsert({ + where: { keyHash: testApiKeyHash }, + update: { revokedAt: null }, + create: { + key: testApiKey, + keyHash: testApiKeyHash, + keyPreview: testApiKey.slice(0, 8), + role: 'admin', + }, + }); }); beforeEach(async () => { + await prisma.claim.deleteMany(); + await prisma.balanceLedger.deleteMany(); + await prisma.aidPackage.deleteMany(); await prisma.campaign.deleteMany(); }); afterAll(async () => { + await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } }); await app.close(); + await new Promise(resolve => setTimeout(resolve, 2000)); }); it('POST /campaigns creates a campaign', async () => { const res = await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ name: 'Test Campaign', budget: 1000 }) .expect(201); @@ -72,11 +98,13 @@ describe('Campaigns (e2e)', () => { it('POST /campaigns rejects missing required fields', async () => { await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ budget: 1000 }) .expect(400); await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ name: 'Missing Budget' }) .expect(400); }); @@ -84,6 +112,7 @@ describe('Campaigns (e2e)', () => { it('POST /campaigns rejects invalid budgets', async () => { await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ name: 'Bad Budget', budget: -1 }) .expect(400); }); @@ -91,6 +120,7 @@ describe('Campaigns (e2e)', () => { it('PATCH /campaigns/:id/archive is idempotent', async () => { const createdRes = await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ name: 'Archive Me', budget: 10 }) .expect(201); @@ -99,6 +129,7 @@ describe('Campaigns (e2e)', () => { const firstRes = await request(app.getHttpServer()) .patch(`${base}/${id}/archive`) + .set(authHeader) .expect(200); const firstBody = bodyAs(firstRes); @@ -108,6 +139,7 @@ describe('Campaigns (e2e)', () => { const secondRes = await request(app.getHttpServer()) .patch(`${base}/${id}/archive`) + .set(authHeader) .expect(200); const secondBody = bodyAs(secondRes); @@ -120,10 +152,14 @@ describe('Campaigns (e2e)', () => { it('GET /campaigns returns a list', async () => { await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ name: 'List Me', budget: 5 }) .expect(201); - const res = await request(app.getHttpServer()).get(base).expect(200); + const res = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); const body = bodyAs(res); @@ -135,6 +171,39 @@ describe('Campaigns (e2e)', () => { it('GET /campaigns/:id returns 404 for missing campaign', async () => { await request(app.getHttpServer()) .get(`${base}/does-not-exist`) + .set(authHeader) .expect(404); }); -}); + + describe('Campaigns HTTP cache', () => { + it('GET /campaigns returns Cache-Control with max-age=30', async () => { + const res = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); + + const cc = res.headers['cache-control']; + expect(cc).toBeDefined(); + expect(cc).toContain('max-age=30'); + expect(cc).toContain('private'); + }); + + it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => { + const res1 = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); + + const etag = res1.headers['etag']; + expect(etag).toBeDefined(); + + const res2 = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .set('If-None-Match', etag) + .expect(304); + + expect(res2.headers['x-edge-cache-status']).toBe('hit'); + }); + }); +}); \ No newline at end of file diff --git a/app/backend/test/claims.e2e-spec.ts b/app/backend/test/claims.e2e-spec.ts index c434cd7a..7e6ddd77 100644 --- a/app/backend/test/claims.e2e-spec.ts +++ b/app/backend/test/claims.e2e-spec.ts @@ -1,39 +1,33 @@ import { Test } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request, { Response as SupertestResponse } from 'supertest'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; import { AppModule } from 'src/app.module'; import { PrismaService } from 'src/prisma/prisma.service'; +import { EncryptionService } from 'src/common/encryption/encryption.service'; import { BudgetService } from 'src/common/budget/budget.service'; +import request from 'supertest'; import { App } from 'supertest/types'; -type ApiResponse = { - success: boolean; - data: T; - message?: string; -}; +const STELLAR_ADDR = 'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN'; -type ClaimResponseDto = { +type ClaimDto = { id: string; status: string; campaignId: string; amount: number; recipientRef: string; evidenceRef?: string; - campaign: { - id: string; - name: string; - }; + campaign: { id: string; name: string }; }; -function bodyAs(res: SupertestResponse): ApiResponse { - return res.body as ApiResponse; -} - describe('Claims (e2e)', () => { let app: INestApplication; let prisma: PrismaService; + let encryptionService: EncryptionService; const base = '/api/v1/claims'; + const testApiKey = 'e2e-test-key-0002'; + const testApiKeyHash = '0ddfd56b80b5f63187c748e910d5ae632669a46f221170bdcbb04989e44d107a'; + const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -43,61 +37,74 @@ describe('Claims (e2e)', () => { app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + prefix: 'v', + }); + app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), ); await app.init(); prisma = app.get(PrismaService); + encryptionService = app.get(EncryptionService); + + await prisma.apiKey.upsert({ + where: { keyHash: testApiKeyHash }, + update: { revokedAt: null }, + create: { + key: testApiKey, + keyHash: testApiKeyHash, + keyPreview: testApiKey.slice(0, 8), + role: 'admin', + }, + }); }); beforeEach(async () => { + await prisma.balanceLedger.deleteMany(); await prisma.claim.deleteMany(); await prisma.campaign.deleteMany(); }); afterAll(async () => { + await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } }); await app.close(); }); it('POST /claims creates a claim', async () => { - // Create a campaign first const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); const res = await request(app.getHttpServer()) .post(base) + .set(authHeader) .send({ campaignId: campaign.id, amount: 100.5, recipientRef: 'recipient-123', evidenceRef: 'evidence-456', + tokenAddress: STELLAR_ADDR, }) .expect(201); - const body = bodyAs(res); - - expect(body.success).toBe(true); - expect(body.data.status).toBe('requested'); - expect(body.data.amount).toBe(100.5); - expect(body.data.recipientRef).toBe('recipient-123'); - expect(body.data.evidenceRef).toBe('evidence-456'); - expect(body.data.campaign.id).toBe(campaign.id); + const body = res.body as ClaimDto; + expect(body.status).toBe('requested'); + expect(body.amount).toBe(100.5); + expect(body.recipientRef).toBe('recipient-123'); + expect(body.evidenceRef).toBe('evidence-456'); + expect(body.campaign.id).toBe(campaign.id); }); it('POST /claims rejects invalid campaignId', async () => { await request(app.getHttpServer()) .post(base) - .send({ - campaignId: 'invalid-id', - amount: 100.5, - recipientRef: 'recipient-123', - }) + .set(authHeader) + .send({ campaignId: 'invalid-id', amount: 100.5, recipientRef: 'recipient-123', tokenAddress: STELLAR_ADDR }) .expect(404); }); @@ -105,186 +112,158 @@ describe('Claims (e2e)', () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1') }, }); - const res = await request(app.getHttpServer()).get(base).expect(200); - - const body = bodyAs(res); - - expect(body.success).toBe(true); - expect(body.data).toHaveLength(1); + const res = await request(app.getHttpServer()).get(base).set(authHeader).expect(200); + const body = res.body as ClaimDto[]; + expect(body).toHaveLength(1); }); it('GET /claims/:id returns claim details', async () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - const claim = await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1') }, }); const res = await request(app.getHttpServer()) .get(`${base}/${claim.id}`) + .set(authHeader) .expect(200); - const body = bodyAs(res); - - expect(body.success).toBe(true); - expect(body.data.id).toBe(claim.id); - expect(body.data.status).toBe('requested'); + const body = res.body as ClaimDto; + expect(body.id).toBe(claim.id); + expect(body.status).toBe('requested'); }); it('POST /claims/:id/verify transitions requested to verified', async () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - const claim = await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1') }, }); const res = await request(app.getHttpServer()) .post(`${base}/${claim.id}/verify`) - .expect(200); - - const body = bodyAs(res); + .set(authHeader) + .expect(201); - expect(body.success).toBe(true); - expect(body.data.status).toBe('verified'); + const body = res.body as ClaimDto; + expect(body.status).toBe('verified'); }); it('POST /claims/:id/approve transitions verified to approved', async () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - const claim = await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - status: 'verified', - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'verified' }, }); const res = await request(app.getHttpServer()) .post(`${base}/${claim.id}/approve`) - .expect(200); - - const body = bodyAs(res); + .set(authHeader) + .expect(201); - expect(body.success).toBe(true); - expect(body.data.status).toBe('approved'); + const body = res.body as ClaimDto; + expect(body.status).toBe('approved'); }); it('POST /claims/:id/disburse transitions approved to disbursed', async () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - const claim = await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - status: 'approved', - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'approved' }, }); const res = await request(app.getHttpServer()) .post(`${base}/${claim.id}/disburse`) - .expect(200); - - const body = bodyAs(res); + .set(authHeader) + .expect(201); - expect(body.success).toBe(true); - expect(body.data.status).toBe('disbursed'); + const body = res.body as ClaimDto; + expect(body.status).toBe('disbursed'); }); it('PATCH /claims/:id/archive transitions disbursed to archived', async () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - const claim = await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - status: 'disbursed', - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'disbursed' }, }); const res = await request(app.getHttpServer()) .patch(`${base}/${claim.id}/archive`) + .set(authHeader) .expect(200); - const body = bodyAs(res); - - expect(body.success).toBe(true); - expect(body.data.status).toBe('archived'); + const body = res.body as ClaimDto; + expect(body.status).toBe('archived'); }); it('POST /claims/:id/verify rejects invalid transition', async () => { const campaign = await prisma.campaign.create({ data: { name: 'Test Campaign', budget: 1000 }, }); - const claim = await prisma.claim.create({ - data: { - campaignId: campaign.id, - amount: 50, - recipientRef: 'recipient-1', - status: 'verified', // Already verified - }, + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'verified' }, }); await request(app.getHttpServer()) .post(`${base}/${claim.id}/verify`) + .set(authHeader) .expect(400); }); - it('POST /claims rejects claim if over campaign budget', async () => { - // Create a campaign with a small budget - const campaign = await prisma.campaign.create({ - data: { name: 'Capped Campaign', budget: 100 }, - }); - // First claim within budget - await request(app.getHttpServer()) - .post(base) - .send({ - campaignId: campaign.id, - amount: 60, - recipientRef: 'recipient-1', - }) - .expect(201); - - // Second claim that would exceed the cap - const res = await request(app.getHttpServer()) - .post(base) - .send({ - campaignId: campaign.id, - amount: 50, // 60 + 50 = 110 > 100 - recipientRef: 'recipient-2', - }) - .expect(400); + describe('Claims HTTP cache', () => { + it('GET /claims returns Cache-Control with max-age=30', async () => { + const campaign = await prisma.campaign.create({ + data: { name: 'Cache Campaign', budget: 1000 }, + }); + await prisma.claim.create({ + data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('cache-test') }, + }); + + const res = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); + + const cc = res.headers['cache-control']; + expect(cc).toBeDefined(); + expect(cc).toContain('max-age=30'); + expect(cc).toContain('private'); + }); - expect(res.body.message).toMatch(/cap/i); + it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => { + const campaign = await prisma.campaign.create({ + data: { name: '304 Campaign', budget: 1000 }, + }); + await prisma.claim.create({ + data: { campaignId: campaign.id, amount: 25, recipientRef: encryptionService.encrypt('304-test') }, + }); + + const res1 = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); + + const etag = res1.headers['etag']; + expect(etag).toBeDefined(); + + const res2 = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .set('If-None-Match', etag) + .expect(304); + + expect(res2.headers['x-edge-cache-status']).toBe('hit'); + }); }); }); diff --git a/package.json b/package.json index df23dfa0..ef8969d5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "pnpm -r build", "lint": "pnpm -r lint", - "test": "pnpm -r test", + "test": "pnpm -r test" }, "pnpm": { "overrides": {