diff --git a/src/app.module.ts b/src/app.module.ts index 6d1284c9..e1ca2438 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,8 @@ import { CanaryModule } from './canary/canary.module'; import { IncidentManagementModule } from './incident-management/incident-management.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor'; +import { IdempotencyModule } from './common/modules/idempotency.module'; +import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { ReportingModule } from './payments/reporting/reporting.module'; @@ -48,6 +50,7 @@ const featureFlags = loadFeatureFlags(); CanaryModule, IncidentManagementModule, MonitoringModule, + IdempotencyModule, DeepLinkModule, InvoicesModule, ReportingModule, @@ -66,6 +69,7 @@ const featureFlags = loadFeatureFlags(); providers: [ ...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []), { provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor }, + { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/src/common/constants/idempotency.constants.ts b/src/common/constants/idempotency.constants.ts new file mode 100644 index 00000000..39ec63bd --- /dev/null +++ b/src/common/constants/idempotency.constants.ts @@ -0,0 +1,9 @@ +export const IDEMPOTENCY_REDIS_CLIENT = 'IDEMPOTENCY_REDIS_CLIENT'; + +export const IDEMPOTENCY_METADATA_KEY = 'idempotency:options'; +export const IDEMPOTENCY_DEFAULT_HEADER_NAME = 'Idempotency-Key'; +export const IDEMPOTENCY_DEFAULT_TTL_SECONDS = 86400; +export const IDEMPOTENCY_DEFAULT_LOCK_TTL_MS = 5000; +export const IDEMPOTENCY_DEFAULT_POLL_INTERVAL_MS = 50; +export const IDEMPOTENCY_DEFAULT_WAIT_TIMEOUT_MS = 5000; + diff --git a/src/common/decorators/idempotency.decorator.ts b/src/common/decorators/idempotency.decorator.ts index e0a4acc1..b3860ebd 100644 --- a/src/common/decorators/idempotency.decorator.ts +++ b/src/common/decorators/idempotency.decorator.ts @@ -1,13 +1,20 @@ import { SetMetadata } from '@nestjs/common'; - -export const IDEMPOTENCY_KEY_METADATA = 'idempotency:ttl'; +import { IDEMPOTENCY_METADATA_KEY, IDEMPOTENCY_DEFAULT_HEADER_NAME, IDEMPOTENCY_DEFAULT_TTL_SECONDS } from '../constants/idempotency.constants'; export interface IdempotencyOptions { ttl?: number; // Time-to-live in seconds headerName?: string; // Custom header name for idempotency key + lockTtlMs?: number; + pollIntervalMs?: number; + waitTimeoutMs?: number; } export const Idempotent = (options: IdempotencyOptions = {}) => { - const ttl = options.ttl || 86400; // Default 24 hours - return SetMetadata(IDEMPOTENCY_KEY_METADATA, ttl); + return SetMetadata(IDEMPOTENCY_METADATA_KEY, { + ttl: options.ttl ?? IDEMPOTENCY_DEFAULT_TTL_SECONDS, + headerName: options.headerName ?? IDEMPOTENCY_DEFAULT_HEADER_NAME, + lockTtlMs: options.lockTtlMs, + pollIntervalMs: options.pollIntervalMs, + waitTimeoutMs: options.waitTimeoutMs, + }); }; diff --git a/src/common/interceptors/idempotency.interceptor.ts b/src/common/interceptors/idempotency.interceptor.ts index 7593f10e..dc02e159 100644 --- a/src/common/interceptors/idempotency.interceptor.ts +++ b/src/common/interceptors/idempotency.interceptor.ts @@ -3,15 +3,26 @@ import { NestInterceptor, ExecutionContext, CallHandler, - HttpException, HttpStatus, + ConflictException, + BadRequestException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { Observable, of, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { Observable, of, from } from 'rxjs'; +import { finalize, map, mergeMap } from 'rxjs/operators'; import { Request, Response } from 'express'; -import { IdempotencyService } from '../services/idempotency.service'; -import { IDEMPOTENCY_KEY_METADATA } from '../decorators/idempotency.decorator'; +import { + IdempotencyRecord, + IdempotencyService, +} from '../services/idempotency.service'; +import { + IDEMPOTENCY_DEFAULT_HEADER_NAME, + IDEMPOTENCY_DEFAULT_LOCK_TTL_MS, + IDEMPOTENCY_DEFAULT_POLL_INTERVAL_MS, + IDEMPOTENCY_DEFAULT_WAIT_TIMEOUT_MS, + IDEMPOTENCY_METADATA_KEY, +} from '../constants/idempotency.constants'; +import { IdempotencyOptions } from '../decorators/idempotency.decorator'; @Injectable() export class IdempotencyInterceptor implements NestInterceptor { @@ -30,55 +41,134 @@ export class IdempotencyInterceptor implements NestInterceptor { } // Check if endpoint is marked as idempotent - const ttl = this.reflector.get(IDEMPOTENCY_KEY_METADATA, context.getHandler()); - if (!ttl) { + const options = this.reflector.getAllAndOverride(IDEMPOTENCY_METADATA_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!options) { return next.handle(); } - // Get idempotency key from header - const idempotencyKey = request.headers['x-idempotency-key'] as string; + const headerName = (options.headerName ?? IDEMPOTENCY_DEFAULT_HEADER_NAME).toLowerCase(); + const idempotencyKey = this.getHeaderValue(request, headerName); if (!idempotencyKey) { - throw new HttpException( - 'X-Idempotency-Key header is required for this operation', - HttpStatus.BAD_REQUEST, + throw new BadRequestException( + `${options.headerName ?? IDEMPOTENCY_DEFAULT_HEADER_NAME} header is required for this operation`, ); } + const routePath = this.getRoutePath(request); + const scopeKey = this.idempotencyService.buildScopeKey({ + method: request.method, + routePath, + idempotencyKey, + }); + const fingerprint = this.idempotencyService.buildFingerprint({ + method: request.method, + routePath, + body: request.body, + query: request.query, + params: request.params, + }); + // Check if request already processed - const existingRecord = await this.idempotencyService.getRecord(idempotencyKey); + const existingRecord = await this.idempotencyService.getRecord(scopeKey); if (existingRecord) { - response.status(existingRecord.statusCode); + this.assertFingerprintMatch(existingRecord, fingerprint); + this.applyCachedResponse(response, existingRecord); return of(existingRecord.response); } // Try to acquire lock - const lockAcquired = await this.idempotencyService.acquireLock(idempotencyKey); + const lockAcquired = await this.idempotencyService.acquireLock( + scopeKey, + fingerprint, + options.lockTtlMs ?? IDEMPOTENCY_DEFAULT_LOCK_TTL_MS, + ); if (!lockAcquired) { - throw new HttpException('Request is being processed, please wait', HttpStatus.CONFLICT); + const lockRecord = await this.idempotencyService.getLockRecord(scopeKey); + + if (lockRecord && lockRecord.fingerprint !== fingerprint) { + throw new ConflictException('Idempotency key already used for a different payload'); + } + + const cachedRecord = await this.idempotencyService.waitForRecord( + scopeKey, + options.waitTimeoutMs ?? IDEMPOTENCY_DEFAULT_WAIT_TIMEOUT_MS, + options.pollIntervalMs ?? IDEMPOTENCY_DEFAULT_POLL_INTERVAL_MS, + ); + + if (cachedRecord) { + this.assertFingerprintMatch(cachedRecord, fingerprint); + this.applyCachedResponse(response, cachedRecord); + return of(cachedRecord.response); + } + + throw new ConflictException('Request is being processed, please retry'); } - try { - // Process the request - return next.handle().pipe( - tap(async (data) => { - // Save successful response - await this.idempotencyService.saveRecord(idempotencyKey, { - idempotencyKey, + return next.handle().pipe( + mergeMap((data) => + from( + this.idempotencyService.saveRecord(scopeKey, { + idempotencyKey: scopeKey, + fingerprint, statusCode: response.statusCode || HttpStatus.OK, response: data, - timestamp: Date.now(), - ttl, - }); - }), - catchError(async (error) => { - // Release lock on error - await this.idempotencyService.releaseLock(idempotencyKey); - return throwError(() => error); - }), - ); - } catch (error) { - await this.idempotencyService.releaseLock(idempotencyKey); - throw error; + cachedAt: Date.now(), + ttlSeconds: options.ttl, + } as IdempotencyRecord), + ).pipe(map(() => data)), + ), + finalize(() => { + void this.idempotencyService.releaseLock(scopeKey); + }), + ); + } + + private applyCachedResponse(response: Response, record: IdempotencyRecord): void { + response.status(record.statusCode); + response.setHeader('X-Idempotent-Replayed', 'true'); + + if (record.responseHeaders) { + for (const [headerName, headerValue] of Object.entries(record.responseHeaders)) { + response.setHeader(headerName, headerValue); + } } } + + private assertFingerprintMatch(record: IdempotencyRecord, fingerprint: string): void { + if (record.fingerprint !== fingerprint) { + throw new ConflictException('Idempotency key already used for a different payload'); + } + } + + private getRoutePath(request: Request): string { + return `${request.baseUrl || ''}${request.route?.path || request.path || ''}`; + } + + private getHeaderValue(request: Request, headerName: string): string | undefined { + const normalizedHeaderName = headerName.toLowerCase(); + const candidates = new Set([ + normalizedHeaderName, + headerName, + normalizedHeaderName.startsWith('x-') + ? normalizedHeaderName.replace(/^x-/, '') + : `x-${normalizedHeaderName}`, + ]); + + const headerValue = [...candidates].reduce((value, candidate) => { + if (value !== undefined) { + return value; + } + + return request.headers[candidate]; + }, undefined); + + if (Array.isArray(headerValue)) { + return headerValue[0]; + } + + return typeof headerValue === 'string' ? headerValue : undefined; + } } diff --git a/src/common/modules/idempotency.module.ts b/src/common/modules/idempotency.module.ts index f3a0cb4c..b2982e5e 100644 --- a/src/common/modules/idempotency.module.ts +++ b/src/common/modules/idempotency.module.ts @@ -1,9 +1,22 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { getSharedRedisClient } from '../../config/cache.config'; +import { IDEMPOTENCY_REDIS_CLIENT } from '../constants/idempotency.constants'; import { IdempotencyService } from '../services/idempotency.service'; import { IdempotencyInterceptor } from '../interceptors/idempotency.interceptor'; @Module({ - providers: [IdempotencyService, IdempotencyInterceptor], + imports: [ConfigModule], + providers: [ + { + provide: IDEMPOTENCY_REDIS_CLIENT, + inject: [ConfigService], + useFactory: (configService: ConfigService): ReturnType => + getSharedRedisClient(configService), + }, + IdempotencyService, + IdempotencyInterceptor, + ], exports: [IdempotencyService, IdempotencyInterceptor], }) export class IdempotencyModule {} diff --git a/src/common/services/idempotency.service.ts b/src/common/services/idempotency.service.ts index 4da5ea75..d0220d12 100644 --- a/src/common/services/idempotency.service.ts +++ b/src/common/services/idempotency.service.ts @@ -1,40 +1,74 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { Redis } from 'ioredis'; +import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; +import { Redis } from 'ioredis'; +import { + IDEMPOTENCY_DEFAULT_LOCK_TTL_MS, + IDEMPOTENCY_DEFAULT_POLL_INTERVAL_MS, + IDEMPOTENCY_DEFAULT_TTL_SECONDS, + IDEMPOTENCY_DEFAULT_WAIT_TIMEOUT_MS, + IDEMPOTENCY_REDIS_CLIENT, +} from '../constants/idempotency.constants'; export interface IdempotencyRecord { idempotencyKey: string; + fingerprint: string; statusCode: number; - response: any; - timestamp: number; - ttl: number; + response: unknown; + responseHeaders?: Record; + cachedAt: number; + ttlSeconds?: number; } -@Injectable() -export class IdempotencyService implements OnModuleInit { - private redisClient: Redis; - private defaultTTL: number; +export interface IdempotencyLockRecord { + idempotencyKey: string; + fingerprint: string; + lockedAt: number; +} - constructor(private configService: ConfigService) { - this.defaultTTL = this.configService.get('IDEMPOTENCY_TTL_SECONDS', 86400); // 24 hours default - } +export interface IdempotencyRequestScope { + method: string; + routePath: string; + idempotencyKey: string; +} - async onModuleInit() { - this.redisClient = new Redis({ - host: this.configService.get('REDIS_HOST', 'localhost'), - port: this.configService.get('REDIS_PORT', 6379), - password: this.configService.get('REDIS_PASSWORD', undefined), - }); +export interface IdempotencyRequestFingerprint { + method: string; + routePath: string; + body: unknown; + query: unknown; + params: unknown; +} - this.redisClient.on('error', (error) => { - console.error('Redis idempotency client error:', error); - }); +export interface IdempotencyRedisClient { + get(key: string): Promise; + set( + key: string, + value: string, + ...args: Array + ): Promise<'OK' | null>; + del(...keys: string[]): Promise; + ttl?(key: string): Promise; + keys?(pattern: string): Promise; +} + +@Injectable() +export class IdempotencyService { + private readonly defaultTTLSeconds: number; + + constructor( + @Inject(IDEMPOTENCY_REDIS_CLIENT) private readonly redisClient: Redis | IdempotencyRedisClient, + private readonly configService: ConfigService, + ) { + this.defaultTTLSeconds = this.configService.get( + 'IDEMPOTENCY_TTL_SECONDS', + IDEMPOTENCY_DEFAULT_TTL_SECONDS, + ); } async getRecord(key: string): Promise { try { - const record = await this.redisClient.get(`idempotency:${key}`); + const record = await this.redisClient.get(this.getRecordKey(key)); if (record) { return JSON.parse(record); } @@ -47,8 +81,13 @@ export class IdempotencyService implements OnModuleInit { async saveRecord(key: string, record: IdempotencyRecord): Promise { try { - const ttl = record.ttl || this.defaultTTL; - await this.redisClient.set(`idempotency:${key}`, JSON.stringify(record), 'EX', ttl); + const ttlSeconds = record.ttlSeconds ?? this.defaultTTLSeconds; + await this.redisClient.set( + this.getRecordKey(key), + JSON.stringify(record), + 'EX', + ttlSeconds, + ); } catch (error) { console.error('Error saving idempotency record:', error); } @@ -56,15 +95,39 @@ export class IdempotencyService implements OnModuleInit { async deleteRecord(key: string): Promise { try { - await this.redisClient.del(`idempotency:${key}`); + await this.redisClient.del(this.getRecordKey(key)); } catch (error) { console.error('Error deleting idempotency record:', error); } } - async acquireLock(key: string, ttlMs: number = 5000): Promise { + async getLockRecord(key: string): Promise { + try { + const record = await this.redisClient.get(this.getLockKey(key)); + if (!record) { + return null; + } + + return JSON.parse(record); + } catch (error) { + console.error('Error getting idempotency lock record:', error); + return null; + } + } + + async acquireLock(key: string, fingerprint: string, ttlMs = IDEMPOTENCY_DEFAULT_LOCK_TTL_MS): Promise { try { - const result = await this.redisClient.set(`idempotency:lock:${key}`, '1', 'PX', ttlMs, 'NX'); + const result = await this.redisClient.set( + this.getLockKey(key), + JSON.stringify({ + idempotencyKey: key, + fingerprint, + lockedAt: Date.now(), + } satisfies IdempotencyLockRecord), + 'PX', + ttlMs, + 'NX', + ); return result === 'OK'; } catch (error) { console.error('Error acquiring idempotency lock:', error); @@ -74,15 +137,34 @@ export class IdempotencyService implements OnModuleInit { async releaseLock(key: string): Promise { try { - await this.redisClient.del(`idempotency:lock:${key}`); + await this.redisClient.del(this.getLockKey(key)); } catch (error) { console.error('Error releasing idempotency lock:', error); } } + async waitForRecord( + key: string, + timeoutMs = IDEMPOTENCY_DEFAULT_WAIT_TIMEOUT_MS, + pollIntervalMs = IDEMPOTENCY_DEFAULT_POLL_INTERVAL_MS, + ): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const record = await this.getRecord(key); + if (record) { + return record; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return null; + } + async cleanup(): Promise { try { - const keys = await this.redisClient.keys('idempotency:*'); + const keys = this.redisClient.keys ? await this.redisClient.keys('idempotency:*') : []; if (keys.length > 0) { await this.redisClient.del(...keys); } @@ -91,9 +173,30 @@ export class IdempotencyService implements OnModuleInit { } } - generateKey(userId: string, endpoint: string, payload: any): string { - const payloadHash = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + buildScopeKey(scope: IdempotencyRequestScope): string { + return crypto + .createHash('sha256') + .update(`${scope.method.toUpperCase()}:${scope.routePath}:${scope.idempotencyKey}`) + .digest('hex'); + } + + buildFingerprint(fingerprint: IdempotencyRequestFingerprint): string { + const payload = JSON.stringify({ + method: fingerprint.method.toUpperCase(), + routePath: fingerprint.routePath, + body: fingerprint.body ?? null, + query: fingerprint.query ?? null, + params: fingerprint.params ?? null, + }); + + return crypto.createHash('sha256').update(payload).digest('hex'); + } + + private getRecordKey(key: string): string { + return `idempotency:record:${key}`; + } - return crypto.createHash('sha256').update(`${userId}:${endpoint}:${payloadHash}`).digest('hex'); + private getLockKey(key: string): string { + return `idempotency:lock:${key}`; } } diff --git a/test/idempotency.e2e-spec.ts b/test/idempotency.e2e-spec.ts new file mode 100644 index 00000000..e97768d4 --- /dev/null +++ b/test/idempotency.e2e-spec.ts @@ -0,0 +1,209 @@ +import { Controller, Post, UseInterceptors } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Idempotent } from '../src/common/decorators/idempotency.decorator'; +import { IdempotencyInterceptor } from '../src/common/interceptors/idempotency.interceptor'; +import { IdempotencyModule } from '../src/common/modules/idempotency.module'; +import { IDEMPOTENCY_REDIS_CLIENT } from '../src/common/constants/idempotency.constants'; + +class InMemoryRedisClient { + private readonly store = new Map(); + + async get(key: string): Promise { + const entry = this.store.get(key); + if (!entry) { + return null; + } + + if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + this.store.delete(key); + return null; + } + + return entry.value; + } + + async set(key: string, value: string, ...args: Array): Promise<'OK' | null> { + const normalizedArgs = args.map((arg) => String(arg)); + const nxIndex = normalizedArgs.indexOf('NX'); + const exIndex = normalizedArgs.indexOf('EX'); + const pxIndex = normalizedArgs.indexOf('PX'); + + if (nxIndex >= 0 && this.store.has(key)) { + const existing = await this.get(key); + if (existing !== null) { + return null; + } + } + + let expiresAt: number | null = null; + if (exIndex >= 0) { + expiresAt = Date.now() + Number(normalizedArgs[exIndex + 1]) * 1000; + } else if (pxIndex >= 0) { + expiresAt = Date.now() + Number(normalizedArgs[pxIndex + 1]); + } + + this.store.set(key, { value, expiresAt }); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let count = 0; + for (const key of keys) { + count += this.store.delete(key) ? 1 : 0; + } + return count; + } + + async keys(pattern: string): Promise { + const matcher = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`); + return [...this.store.keys()].filter((key) => matcher.test(key)); + } +} + +@Controller('idempotency') +@UseInterceptors(IdempotencyInterceptor) +class IdempotencyTestController { + private executions = 0; + + @Post('dedup') + @Idempotent({ ttl: 60, waitTimeoutMs: 1000, pollIntervalMs: 25, lockTtlMs: 2000 }) + async createResource(): Promise<{ execution: number }> { + this.executions += 1; + await new Promise((resolve) => setTimeout(resolve, 150)); + return { execution: this.executions }; + } + + @Post('missing-header') + @Idempotent() + async requiresKey(): Promise<{ ok: boolean }> { + return { ok: true }; + } + + @Post('custom-header') + @Idempotent({ headerName: 'X-Custom-Idempotency-Key', ttl: 60 }) + async customHeader(): Promise<{ ok: boolean }> { + return { ok: true }; + } +} + +describe('Idempotency deduplication (e2e)', () => { + let app: INestApplication; + let redis: InMemoryRedisClient; + + beforeAll(async () => { + redis = new InMemoryRedisClient(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), IdempotencyModule], + controllers: [IdempotencyTestController], + }) + .overrideProvider(IDEMPOTENCY_REDIS_CLIENT) + .useValue(redis) + .overrideProvider(ConfigService) + .useValue({ + get: jest.fn((key: string, defaultValue?: unknown) => { + if (key === 'IDEMPOTENCY_TTL_SECONDS') { + return 60; + } + return defaultValue; + }), + }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('returns the cached response for a repeated idempotent request', async () => { + const first = await request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('Idempotency-Key', 'dedup-key-1') + .send({ name: 'first' }) + .expect(201); + + const second = await request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('Idempotency-Key', 'dedup-key-1') + .send({ name: 'first' }) + .expect(201); + + expect(first.body).toEqual({ execution: 1 }); + expect(second.body).toEqual({ execution: 1 }); + expect(second.header['x-idempotent-replayed']).toBe('true'); + }); + + it('accepts the legacy X-Idempotency-Key header as a fallback', async () => { + const first = await request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('X-Idempotency-Key', 'dedup-key-legacy') + .send({ name: 'legacy' }) + .expect(201); + + const second = await request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('X-Idempotency-Key', 'dedup-key-legacy') + .send({ name: 'legacy' }) + .expect(201); + + expect(first.body).toEqual({ execution: 2 }); + expect(second.body).toEqual({ execution: 2 }); + }); + + it('deduplicates concurrent requests instead of processing them twice', async () => { + const [first, second] = await Promise.all([ + request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('Idempotency-Key', 'dedup-key-2') + .send({ name: 'concurrent' }), + request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('Idempotency-Key', 'dedup-key-2') + .send({ name: 'concurrent' }), + ]); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(first.body).toEqual({ execution: 3 }); + expect(second.body).toEqual({ execution: 3 }); + }); + + it('rejects requests that omit the idempotency header', async () => { + const response = await request(app.getHttpServer()).post('/idempotency/missing-header').send({}); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('Idempotency-Key header is required'); + }); + + it('supports custom idempotency header names', async () => { + const response = await request(app.getHttpServer()) + .post('/idempotency/custom-header') + .set('X-Custom-Idempotency-Key', 'dedup-key-3') + .send({}) + .expect(201); + + expect(response.body).toEqual({ ok: true }); + }); + + it('rejects the same idempotency key when payload changes', async () => { + await request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('Idempotency-Key', 'dedup-key-4') + .send({ name: 'alpha' }) + .expect(201); + + const response = await request(app.getHttpServer()) + .post('/idempotency/dedup') + .set('Idempotency-Key', 'dedup-key-4') + .send({ name: 'beta' }) + .expect(409); + + expect(response.body.message).toContain('different payload'); + }); +});