Skip to content

Commit a6ff990

Browse files
authored
Merge pull request Nexacore-Org#360 from BigBen-7/feat/swagger-idempotency-feature-flags-e2e
feat: Swagger docs, idempotency guards, feature flag evaluation engine, and E2E test suite
2 parents f2e7a0f + 15dd075 commit a6ff990

36 files changed

Lines changed: 1581 additions & 12 deletions

.github/workflows/e2e-tests.yml

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: E2E Integration Tests
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
- develop
8+
push:
9+
branches:
10+
- main
11+
12+
jobs:
13+
e2e:
14+
name: Run E2E Test Suite
15+
runs-on: ubuntu-latest
16+
17+
services:
18+
postgres:
19+
image: postgres:15-alpine
20+
env:
21+
POSTGRES_USER: nexafx
22+
POSTGRES_PASSWORD: nexafx_test
23+
POSTGRES_DB: nexafx_test
24+
ports:
25+
- 5432:5432
26+
options: >-
27+
--health-cmd pg_isready
28+
--health-interval 10s
29+
--health-timeout 5s
30+
--health-retries 5
31+
32+
redis:
33+
image: redis:7-alpine
34+
ports:
35+
- 6379:6379
36+
options: >-
37+
--health-cmd "redis-cli ping"
38+
--health-interval 10s
39+
--health-timeout 5s
40+
--health-retries 5
41+
42+
env:
43+
NODE_ENV: test
44+
DB_HOST: localhost
45+
DB_PORT: 5432
46+
DB_USER: nexafx
47+
DB_PASSWORD: nexafx_test
48+
DB_NAME: nexafx_test
49+
REDIS_HOST: localhost
50+
REDIS_PORT: 6379
51+
JWT_SECRET: e2e-test-jwt-secret-do-not-use-in-prod
52+
REFRESH_TOKEN_SECRET: e2e-test-refresh-secret
53+
OTP_SECRET: e2e-test-otp-secret
54+
DISABLE_BULL: "true"
55+
56+
steps:
57+
- name: Checkout code
58+
uses: actions/checkout@v4
59+
60+
- name: Set up Node.js
61+
uses: actions/setup-node@v4
62+
with:
63+
node-version: '20'
64+
cache: 'npm'
65+
66+
- name: Install dependencies
67+
run: npm ci
68+
69+
- name: Build application
70+
run: npm run build
71+
72+
- name: Wait for PostgreSQL
73+
run: |
74+
until pg_isready -h localhost -p 5432 -U nexafx; do
75+
echo "Waiting for postgres..."
76+
sleep 2
77+
done
78+
79+
- name: Run E2E tests
80+
run: npm run test:e2e -- --testPathPattern="test/e2e"
81+
timeout-minutes: 10
82+
83+
- name: Upload test results
84+
if: always()
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: e2e-test-results
88+
path: |
89+
coverage/
90+
test-results/
91+
retention-days: 7
92+
93+
- name: Comment PR with test results
94+
if: github.event_name == 'pull_request' && always()
95+
uses: actions/github-script@v7
96+
with:
97+
script: |
98+
const outcome = '${{ job.status }}';
99+
const emoji = outcome === 'success' ? '✅' : '❌';
100+
const body = `${emoji} **E2E Tests**: ${outcome}\n\nSee [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.`;
101+
github.rest.issues.createComment({
102+
issue_number: context.issue.number,
103+
owner: context.repo.owner,
104+
repo: context.repo.repo,
105+
body,
106+
});

src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ import { TransactionRiskModule } from './modules/transaction-risk/transaction-ri
2828
import { WebhooksModule } from './modules/webhooks/webhooks.module';
2929
import { SecretsModule } from './modules/secrets/secrets.module';
3030
import { DataArchiveModule } from './modules/data-archive/data-archive.module';
31+
import { IdempotencyModule } from './idempotency/idempotency.module';
3132
import { GoalsModule } from './goals/goal.module';
3233
import { AnnouncementsModule } from './announcement/announcement.module';
3334
import { ComplianceModule } from './compliance-evidence/compliance.module';
3435
import { LedgerModule } from './double-entry-ledger/ledger.module';
3536
import { VersioningModule } from './versioning/versioning.module';
3637
import { InsightsModule } from './exxagerated/exxagerated.module';
37-
import { NotificationsModule as WebSocketNotificationsModule } from './web-sockets/notifications.module';
3838

3939
const enableBull =
4040
process.env.NODE_ENV !== 'test' && process.env.DISABLE_BULL !== 'true';
@@ -96,6 +96,7 @@ const enableBull =
9696
WebhooksModule,
9797
SecretsModule,
9898
DataArchiveModule,
99+
IdempotencyModule,
99100
GoalsModule,
100101
AnnouncementsModule,
101102
ComplianceModule,

src/double-entry-ledger/ledger.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ import {
99
HttpCode,
1010
HttpStatus,
1111
ParseUUIDPipe,
12+
UseInterceptors,
1213
} from '@nestjs/common';
1314
import {
1415
ApiTags,
1516
ApiBearerAuth,
1617
ApiOperation,
1718
ApiOkResponse,
1819
ApiCreatedResponse,
20+
ApiHeader,
1921
} from '@nestjs/swagger';
22+
import { Idempotent } from '../idempotency/idempotency.decorator';
23+
import { IdempotencyGuard } from '../idempotency/idempotency.guard';
24+
import { IdempotencyInterceptor } from '../idempotency/idempotency.interceptor';
2025
import { LedgerService } from './ledger.service';
2126
import {
2227
CreateDoubleEntryDto,
@@ -36,8 +41,12 @@ export class LedgerController {
3641

3742
@Post('entries')
3843
@HttpCode(HttpStatus.CREATED)
44+
@Idempotent()
45+
@UseGuards(IdempotencyGuard)
46+
@UseInterceptors(IdempotencyInterceptor)
3947
@ApiCreatedResponse({ description: 'Double-entry posted successfully' })
4048
@ApiOperation({ summary: 'Post a balanced double-entry transaction' })
49+
@ApiHeader({ name: 'Idempotency-Key', description: 'Unique key to prevent duplicate ledger entries (min 16 chars)', required: true })
4150
async postDoubleEntry(@Body() dto: CreateDoubleEntryDto) {
4251
return this.ledgerService.postDoubleEntry(dto);
4352
}

src/fx/fx-conversion.controller.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import {
88
Query,
99
Request,
1010
UseGuards,
11+
UseInterceptors,
1112
} from '@nestjs/common';
13+
import { ApiTags, ApiBearerAuth, ApiOperation, ApiOkResponse, ApiCreatedResponse, ApiHeader } from '@nestjs/swagger';
1214
import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard';
15+
import { Idempotent } from '../../idempotency/idempotency.decorator';
16+
import { IdempotencyGuard } from '../../idempotency/idempotency.guard';
17+
import { IdempotencyInterceptor } from '../../idempotency/idempotency.interceptor';
1318
import { FxConversionService } from '../services/fx-conversion.service';
1419
import {
1520
ConversionHistoryDto,
@@ -19,6 +24,8 @@ import {
1924
} from '../dto/fx-conversion.dto';
2025
import { LoyaltyTier } from '../../loyalty/entities/loyalty-account.entity';
2126

27+
@ApiTags('FX Conversion')
28+
@ApiBearerAuth('access-token')
2229
@UseGuards(JwtAuthGuard)
2330
@Controller('fx')
2431
export class FxConversionController {
@@ -35,6 +42,8 @@ export class FxConversionController {
3542
* - regulatoryDisclosure text for the user's jurisdiction
3643
*/
3744
@Get('convert/quote')
45+
@ApiOperation({ summary: 'Get a locked FX conversion quote' })
46+
@ApiOkResponse({ description: 'Quote with locked rate, fees, and TTL' })
3847
async getQuote(@Request() req, @Query() dto: GetQuoteDto) {
3948
const user = req.user;
4049
return this.fxService.createQuote(
@@ -57,6 +66,12 @@ export class FxConversionController {
5766
*/
5867
@Post('convert')
5968
@HttpCode(HttpStatus.CREATED)
69+
@Idempotent()
70+
@UseGuards(IdempotencyGuard)
71+
@UseInterceptors(IdempotencyInterceptor)
72+
@ApiOperation({ summary: 'Execute a currency conversion at a locked quote rate' })
73+
@ApiCreatedResponse({ description: 'Conversion executed successfully' })
74+
@ApiHeader({ name: 'Idempotency-Key', description: 'Unique key to prevent duplicate conversions (min 16 chars)', required: true })
6075
async executeConversion(@Request() req, @Body() dto: ExecuteConversionDto) {
6176
return this.fxService.executeConversion(req.user.id, dto);
6277
}
@@ -72,6 +87,8 @@ export class FxConversionController {
7287
* Does NOT lock a quote — purely informational.
7388
*/
7489
@Get('fees')
90+
@ApiOperation({ summary: 'Get FX fee breakdown (informational, no quote lock)' })
91+
@ApiOkResponse({ description: 'Fee breakdown with mid-rate, markup and total cost' })
7592
async getFees(@Request() req, @Query() dto: GetFeesDto) {
7693
return this.fxService.getFeeBreakdown(
7794
dto.fromCurrency.toUpperCase(),
@@ -87,6 +104,8 @@ export class FxConversionController {
87104
* Paginated conversion history — newest first.
88105
*/
89106
@Get('convert/history')
107+
@ApiOperation({ summary: 'Get paginated FX conversion history' })
108+
@ApiOkResponse({ description: 'Paginated conversion history' })
90109
async getHistory(@Request() req, @Query() dto: ConversionHistoryDto) {
91110
return this.fxService.getHistory(req.user.id, dto);
92111
}

src/idempotency/cleanup.job.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { IdempotencyService } from './idempotency.service';
4+
5+
@Injectable()
6+
export class IdempotencyCleanupJob {
7+
private readonly logger = new Logger(IdempotencyCleanupJob.name);
8+
9+
constructor(private readonly idempotencyService: IdempotencyService) {}
10+
11+
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
12+
async cleanupExpiredKeys(): Promise<void> {
13+
this.logger.log('Running idempotency key cleanup...');
14+
const deleted = await this.idempotencyService.cleanup();
15+
this.logger.log(`Cleaned up ${deleted} expired idempotency keys`);
16+
}
17+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const IDEMPOTENCY_KEY = 'idempotency';
4+
export const Idempotent = () => SetMetadata(IDEMPOTENCY_KEY, true);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Entity, Column, PrimaryColumn, CreateDateColumn, Index } from 'typeorm';
2+
3+
@Entity('idempotency_keys')
4+
@Index(['createdAt'])
5+
@Index(['expiresAt'])
6+
export class IdempotencyKey {
7+
@PrimaryColumn()
8+
key: string;
9+
10+
@Column('text')
11+
requestHash: string;
12+
13+
@Column('jsonb')
14+
response: any;
15+
16+
@Column('int')
17+
statusCode: number;
18+
19+
@CreateDateColumn()
20+
createdAt: Date;
21+
22+
@Column({ type: 'timestamp', nullable: true })
23+
expiresAt: Date;
24+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
ConflictException,
6+
BadRequestException,
7+
UnprocessableEntityException,
8+
} from '@nestjs/common';
9+
import { Reflector } from '@nestjs/core';
10+
import { IdempotencyService } from './idempotency.service';
11+
import { IDEMPOTENCY_KEY } from './idempotency.decorator';
12+
13+
const MIN_KEY_LENGTH = 16;
14+
15+
@Injectable()
16+
export class IdempotencyGuard implements CanActivate {
17+
constructor(
18+
private reflector: Reflector,
19+
private idempotencyService: IdempotencyService,
20+
) {}
21+
22+
async canActivate(context: ExecutionContext): Promise<boolean> {
23+
const isIdempotent = this.reflector.get<boolean>(
24+
IDEMPOTENCY_KEY,
25+
context.getHandler(),
26+
);
27+
28+
if (!isIdempotent) {
29+
return true;
30+
}
31+
32+
const request = context.switchToHttp().getRequest();
33+
const idempotencyKey = request.headers['idempotency-key'];
34+
35+
if (!idempotencyKey) {
36+
throw new BadRequestException('Idempotency-Key header is required');
37+
}
38+
39+
if (idempotencyKey.length < MIN_KEY_LENGTH) {
40+
throw new BadRequestException(
41+
`Idempotency-Key must be at least ${MIN_KEY_LENGTH} characters`,
42+
);
43+
}
44+
45+
const requestHash = this.idempotencyService.hashRequest(
46+
request.method,
47+
request.url,
48+
request.body,
49+
);
50+
51+
const existing = await this.idempotencyService.findByKey(idempotencyKey);
52+
53+
if (existing) {
54+
if (existing.requestHash !== requestHash) {
55+
throw new UnprocessableEntityException(
56+
'Idempotency-Key already used with different request parameters',
57+
);
58+
}
59+
60+
// Return cached response
61+
request.idempotencyResponse = {
62+
statusCode: existing.statusCode,
63+
body: existing.response,
64+
};
65+
}
66+
67+
request.idempotencyKey = idempotencyKey;
68+
request.requestHash = requestHash;
69+
70+
return true;
71+
}
72+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
Injectable,
3+
NestInterceptor,
4+
ExecutionContext,
5+
CallHandler,
6+
} from '@nestjs/common';
7+
import { Observable, of } from 'rxjs';
8+
import { tap } from 'rxjs/operators';
9+
import { IdempotencyService } from './idempotency.service';
10+
11+
@Injectable()
12+
export class IdempotencyInterceptor implements NestInterceptor {
13+
constructor(private idempotencyService: IdempotencyService) {}
14+
15+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
16+
const request = context.switchToHttp().getRequest();
17+
const response = context.switchToHttp().getResponse();
18+
19+
// Return cached response immediately if it exists
20+
if (request.idempotencyResponse) {
21+
response.status(request.idempotencyResponse.statusCode);
22+
return of(request.idempotencyResponse.body);
23+
}
24+
25+
return next.handle().pipe(
26+
tap(async (data) => {
27+
// Cache response after successful commit (post-response)
28+
if (request.idempotencyKey) {
29+
await this.idempotencyService.store(
30+
request.idempotencyKey,
31+
request.requestHash,
32+
data,
33+
response.statusCode,
34+
);
35+
}
36+
}),
37+
);
38+
}
39+
}

0 commit comments

Comments
 (0)