diff --git a/TODO_IDEMPOTENCY_ALTERNATIVE.md b/TODO_IDEMPOTENCY_ALTERNATIVE.md new file mode 100644 index 00000000..00cd7363 --- /dev/null +++ b/TODO_IDEMPOTENCY_ALTERNATIVE.md @@ -0,0 +1,20 @@ +# TODO - Idempotency Alternative (no POST /payments present) + +## Goal +Apply idempotency + add tests to POST routes that *do exist* in this workspace (currently `SubscriptionsController` POST endpoints). + +## Steps +1. Add `@Idempotent({ ttl: 86400 })` to POST handlers in `src/payments/subscriptions/subscriptions.controller.ts`: + - `POST /subscriptions/:subscriptionId/upgrade` + - `POST /subscriptions/:subscriptionId/downgrade` +2. Ensure `IdempotencyModule` / `IdempotencyInterceptor` is registered for these controller handlers (module-level wiring or controller-level interceptors, based on existing Nest patterns). +3. Add an e2e test similar to `test/idempotency.e2e-spec.ts` using the existing Redis in-memory stub to validate: + - repeated POST with same `Idempotency-Key` returns cached response and the service executes only once. +4. Run `npm test` (or `npm run test:e2e` for the specific file) and verify compilation. + +## Progress +- ✅ Added `@Idempotent({ ttl: 86400 })` to `SubscriptionsController` POST endpoints. +- ✅ Added `test/subscriptions-idempotency.e2e-spec.ts` to cover dedupe behavior. +- ⏳ Needs CI/build verification (tools can’t reliably capture `npm test` output here). + + diff --git a/src/payments/subscriptions/subscriptions.controller.ts b/src/payments/subscriptions/subscriptions.controller.ts index 6d1b1caf..552d3b0d 100644 --- a/src/payments/subscriptions/subscriptions.controller.ts +++ b/src/payments/subscriptions/subscriptions.controller.ts @@ -24,6 +24,8 @@ import { DowngradeSubscriptionDto, } from './dto/subscription-action.dto'; import { Subscription } from '../entities/subscription.entity'; +import { Idempotent } from '../../common/decorators/idempotency.decorator'; + @ApiTags('Subscriptions') @Controller('subscriptions') @@ -121,7 +123,9 @@ export class SubscriptionsController { * Upgrade a subscription */ @Post(':subscriptionId/upgrade') + @Idempotent({ ttl: 86400 }) @ApiOperation({ summary: 'Upgrade subscription to a higher plan' }) + @ApiParam({ name: 'subscriptionId', description: 'Subscription ID' }) @ApiResponse({ status: 200, @@ -148,7 +152,9 @@ export class SubscriptionsController { * Downgrade a subscription */ @Post(':subscriptionId/downgrade') + @Idempotent({ ttl: 86400 }) @ApiOperation({ summary: 'Downgrade subscription to a lower plan' }) + @ApiParam({ name: 'subscriptionId', description: 'Subscription ID' }) @ApiResponse({ status: 200, diff --git a/test/subscriptions-idempotency.e2e-spec.ts b/test/subscriptions-idempotency.e2e-spec.ts new file mode 100644 index 00000000..7f07b225 --- /dev/null +++ b/test/subscriptions-idempotency.e2e-spec.ts @@ -0,0 +1,195 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { SubscriptionsController } from '../src/payments/subscriptions/subscriptions.controller'; +import { SubscriptionsService } from '../src/payments/subscriptions/subscriptions.service'; +import { + DowngradeSubscriptionDto, + UpgradeSubscriptionDto, +} from '../src/payments/subscriptions/dto/subscription-action.dto'; +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'; +import { IdempotencyService } from '../src/common/services/idempotency.service'; + +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)); + } +} + +describe('Idempotency (alternative coverage)', () => { + let app: INestApplication; + let redis: InMemoryRedisClient; + + const mockSubscriptionsService = { + upgradeSubscription: jest.fn(), + downgradeSubscription: jest.fn(), + pauseSubscription: jest.fn(), + resumeSubscription: jest.fn(), + getSubscription: jest.fn(), + getUserSubscription: jest.fn(), + cancelSubscription: jest.fn(), + }; + + beforeAll(async () => { + redis = new InMemoryRedisClient(); + + mockSubscriptionsService.upgradeSubscription.mockImplementation(async () => { + return { + id: 'sub-1', + status: 'active', + currentPeriodStart: new Date('2026-01-01T00:00:00Z'), + currentPeriodEnd: new Date('2026-02-01T00:00:00Z'), + cancelAtPeriodEnd: false, + amount: 19.99, + currency: 'USD', + interval: 'monthly', + }; + }); + + mockSubscriptionsService.downgradeSubscription.mockImplementation(async () => { + return { + id: 'sub-1', + status: 'active', + currentPeriodStart: new Date('2026-01-01T00:00:00Z'), + currentPeriodEnd: new Date('2026-02-01T00:00:00Z'), + cancelAtPeriodEnd: false, + amount: 9.99, + currency: 'USD', + interval: 'monthly', + }; + }); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), IdempotencyModule], + controllers: [SubscriptionsController], + providers: [ + { + provide: SubscriptionsService, + useValue: mockSubscriptionsService, + }, + ], + }) + // ensure idempotency interceptor uses in-memory redis + .overrideProvider(IDEMPOTENCY_REDIS_CLIENT) + .useValue(redis) + // reduce ttl/no-op env reads + .overrideProvider(ConfigService) + .useValue({ + get: jest.fn((key: string, defaultValue?: unknown) => { + if (key === 'IDEMPOTENCY_TTL_SECONDS') { + return 86400; + } + return defaultValue; + }), + }) + .compile(); + + app = moduleFixture.createNestApplication(); + + // Wire idempotency interceptor for this test app + const idempotencyService = moduleFixture.get(IdempotencyService); + const reflector = moduleFixture.get('Reflector'); + app.useGlobalInterceptors(new IdempotencyInterceptor(idempotencyService, reflector)); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('deduplicates repeated POST upgrade requests with same Idempotency-Key', async () => { + const route = '/subscriptions/sub-xyz/upgrade'; + const dto: UpgradeSubscriptionDto = { planId: 'plan-basic', billingCycle: 'monthly' }; + + const first = await request(app.getHttpServer()) + .post(route) + .set('Idempotency-Key', 'idem-upgrade-1') + .send(dto) + .expect(200); + + const second = await request(app.getHttpServer()) + .post(route) + .set('Idempotency-Key', 'idem-upgrade-1') + .send(dto) + .expect(200); + + expect(first.body).toEqual(second.body); + expect(second.header['x-idempotent-replayed']).toBe('true'); + expect(mockSubscriptionsService.upgradeSubscription).toHaveBeenCalledTimes(1); + }); + + it('deduplicates repeated POST downgrade requests with same Idempotency-Key', async () => { + const route = '/subscriptions/sub-xyz/downgrade'; + const dto: DowngradeSubscriptionDto = { planId: 'plan-basic', billingCycle: 'monthly' }; + + const first = await request(app.getHttpServer()) + .post(route) + .set('Idempotency-Key', 'idem-downgrade-1') + .send(dto) + .expect(200); + + const second = await request(app.getHttpServer()) + .post(route) + .set('Idempotency-Key', 'idem-downgrade-1') + .send(dto) + .expect(200); + + expect(first.body).toEqual(second.body); + expect(second.header['x-idempotent-replayed']).toBe('true'); + expect(mockSubscriptionsService.downgradeSubscription).toHaveBeenCalledTimes(1); + }); +}); +