From eb6d4fbc270d2dcbb75fd2d8234897d8f05fcdda Mon Sep 17 00:00:00 2001 From: Kend07 Date: Wed, 15 Jul 2026 17:08:42 -0600 Subject: [PATCH 1/4] feat: add HTTP cache decorators to list endpoints and fix E2E test suite --- app/backend/src/claims/claims.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/backend/src/claims/claims.controller.ts b/app/backend/src/claims/claims.controller.ts index 19240b12..5d25265b 100644 --- a/app/backend/src/claims/claims.controller.ts +++ b/app/backend/src/claims/claims.controller.ts @@ -40,6 +40,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') From 7fad4b270ef2b37cdc7e04116f5b70e39f95792f Mon Sep 17 00:00:00 2001 From: Kend07 Date: Wed, 15 Jul 2026 17:11:33 -0600 Subject: [PATCH 2/4] feat: add HTTP cache decorators to list endpoints and fix E2E test suite --- .../migration.sql | 8 + app/backend/src/audit/audit.controller.ts | 2 + .../src/campaigns/campaigns.controller.ts | 3 + app/backend/src/claims/claims.controller.ts | 1 + .../__tests__/http-cache.interceptor.spec.ts | 4 +- .../interceptors/http-cache.interceptor.ts | 2 +- app/backend/test/audit.e2e-spec.ts | 123 +++++++++ app/backend/test/campaigns.e2e-spec.ts | 80 +++++- app/backend/test/claims.e2e-spec.ts | 250 ++++++++---------- app/backend/test/jest-e2e.json | 1 + 10 files changed, 331 insertions(+), 143 deletions(-) create mode 100644 app/backend/prisma/migrations/20260715200117_issue_migration_for_chain_forge/migration.sql create mode 100644 app/backend/test/audit.e2e-spec.ts 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/claims.controller.ts b/app/backend/src/claims/claims.controller.ts index 5d25265b..7596b430 100644 --- a/app/backend/src/claims/claims.controller.ts +++ b/app/backend/src/claims/claims.controller.ts @@ -71,6 +71,7 @@ export class ClaimsController { return this.claimsService.create(createClaimDto); } + @HttpCacheTtl(30) // Response cached for 30 seconds @Get() @ApiOperation({ summary: 'List all claims', 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..ad39fa6b --- /dev/null +++ b/app/backend/test/audit.e2e-spec.ts @@ -0,0 +1,123 @@ +import { Test } from '@nestjs/testing'; +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'; +import { createHash } from 'node:crypto'; + +type ApiResponse = { + success: boolean; + data: T; + message?: string; +}; + +function bodyAs(res: SupertestResponse): ApiResponse { + return res.body as ApiResponse; +} + +describe('Audit (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + + const base = '/api/v1/audit'; + const testApiKey = 'e2e-test-key-0003'; + const testApiKeyHash = createHash('sha256').update(testApiKey).digest('hex'); + 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..0be9e461 100644 --- a/app/backend/test/campaigns.e2e-spec.ts +++ b/app/backend/test/campaigns.e2e-spec.ts @@ -1,10 +1,11 @@ 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'; - +import { createHash } from 'node:crypto'; +jest.setTimeout(30000); type ApiResponse = { success: boolean; data: T; @@ -19,7 +20,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 +28,9 @@ describe('Campaigns (e2e)', () => { let prisma: PrismaService; const base = '/api/v1/campaigns'; + const testApiKey = 'e2e-test-key-0001'; + const testApiKeyHash = createHash('sha256').update(testApiKey).digest('hex'); + const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -36,6 +39,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 +56,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 +99,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 +113,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 +121,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 +130,7 @@ describe('Campaigns (e2e)', () => { const firstRes = await request(app.getHttpServer()) .patch(`${base}/${id}/archive`) + .set(authHeader) .expect(200); const firstBody = bodyAs(firstRes); @@ -108,6 +140,7 @@ describe('Campaigns (e2e)', () => { const secondRes = await request(app.getHttpServer()) .patch(`${base}/${id}/archive`) + .set(authHeader) .expect(200); const secondBody = bodyAs(secondRes); @@ -120,10 +153,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 +172,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..90caa39d 100644 --- a/app/backend/test/claims.e2e-spec.ts +++ b/app/backend/test/claims.e2e-spec.ts @@ -1,39 +1,34 @@ 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 { EncryptionService } from 'src/common/encryption/encryption.service'; import { BudgetService } from 'src/common/budget/budget.service'; import { App } from 'supertest/types'; +import { createHash } from 'node:crypto'; -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 = createHash('sha256').update(testApiKey).digest('hex'); + const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -43,61 +38,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 +113,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/app/backend/test/jest-e2e.json b/app/backend/test/jest-e2e.json index 988edaeb..3e8d8151 100644 --- a/app/backend/test/jest-e2e.json +++ b/app/backend/test/jest-e2e.json @@ -8,6 +8,7 @@ }, "moduleNameMapper": { "^src/(.*)$": "/src/$1", + "^cache/(.*)$": "/cache/$1", "^@stellar/stellar-sdk$": "/test/mocks/stellar-sdk.mock.ts", "^openai$": "/test/mocks/openai.mock.ts" } From 56ac1310290c33372315c3f7d51aed12ab43df64 Mon Sep 17 00:00:00 2001 From: Kend07 Date: Wed, 15 Jul 2026 17:15:59 -0600 Subject: [PATCH 3/4] Removed unused imports from e2e test files --- app/backend/test/audit.e2e-spec.ts | 5 +---- app/backend/test/claims.e2e-spec.ts | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/backend/test/audit.e2e-spec.ts b/app/backend/test/audit.e2e-spec.ts index ad39fa6b..9f8a5987 100644 --- a/app/backend/test/audit.e2e-spec.ts +++ b/app/backend/test/audit.e2e-spec.ts @@ -1,6 +1,5 @@ import { Test } from '@nestjs/testing'; 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'; @@ -12,9 +11,7 @@ type ApiResponse = { message?: string; }; -function bodyAs(res: SupertestResponse): ApiResponse { - return res.body as ApiResponse; -} + describe('Audit (e2e)', () => { let app: INestApplication; diff --git a/app/backend/test/claims.e2e-spec.ts b/app/backend/test/claims.e2e-spec.ts index 90caa39d..f8fb1e1f 100644 --- a/app/backend/test/claims.e2e-spec.ts +++ b/app/backend/test/claims.e2e-spec.ts @@ -1,6 +1,5 @@ import { Test } from '@nestjs/testing'; 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 { EncryptionService } from 'src/common/encryption/encryption.service'; From 46ddc0c41da92c7b2278cde427b69e0934d45c0a Mon Sep 17 00:00:00 2001 From: Kend07 Date: Wed, 15 Jul 2026 21:22:47 -0600 Subject: [PATCH 4/4] removed: unusued import and ApiResponse type from audit.e2e-spec.ts --- app/backend/test/audit.e2e-spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/backend/test/audit.e2e-spec.ts b/app/backend/test/audit.e2e-spec.ts index 9f8a5987..99bc365e 100644 --- a/app/backend/test/audit.e2e-spec.ts +++ b/app/backend/test/audit.e2e-spec.ts @@ -5,11 +5,6 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { App } from 'supertest/types'; import { createHash } from 'node:crypto'; -type ApiResponse = { - success: boolean; - data: T; - message?: string; -};