Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions TODO_IDEMPOTENCY_ALTERNATIVE.md
Original file line number Diff line number Diff line change
@@ -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).


6 changes: 6 additions & 0 deletions src/payments/subscriptions/subscriptions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
DowngradeSubscriptionDto,
} from './dto/subscription-action.dto';
import { Subscription } from '../entities/subscription.entity';
import { Idempotent } from '../../common/decorators/idempotency.decorator';


Check failure on line 29 in src/payments/subscriptions/subscriptions.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`
@ApiTags('Subscriptions')
@Controller('subscriptions')
@UseGuards(JwtAuthGuard)
Expand Down Expand Up @@ -121,7 +123,9 @@
* Upgrade a subscription
*/
@Post(':subscriptionId/upgrade')
@Idempotent({ ttl: 86400 })
@ApiOperation({ summary: 'Upgrade subscription to a higher plan' })

Check failure on line 128 in src/payments/subscriptions/subscriptions.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`
@ApiParam({ name: 'subscriptionId', description: 'Subscription ID' })
@ApiResponse({
status: 200,
Expand All @@ -148,7 +152,9 @@
* Downgrade a subscription
*/
@Post(':subscriptionId/downgrade')
@Idempotent({ ttl: 86400 })
@ApiOperation({ summary: 'Downgrade subscription to a lower plan' })

Check failure on line 156 in src/payments/subscriptions/subscriptions.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`

@ApiParam({ name: 'subscriptionId', description: 'Subscription ID' })
@ApiResponse({
status: 200,
Expand Down
195 changes: 195 additions & 0 deletions test/subscriptions-idempotency.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, { value: string; expiresAt: number | null }>();

async get(key: string): Promise<string | null> {
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<string | number>): 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<number> {
let count = 0;
for (const key of keys) {
count += this.store.delete(key) ? 1 : 0;
}
return count;
}

async keys(pattern: string): Promise<string[]> {
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<any>('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);
});
});

Check failure on line 195 in test/subscriptions-idempotency.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`
Loading