From da2a370a62d269643171b3dbdbe60995f39bd5cc Mon Sep 17 00:00:00 2001 From: Dannyswiss1 Date: Thu, 30 Jul 2026 08:16:46 +0100 Subject: [PATCH] Standardize wallet limits, error envelope, and Prisma migration hygiene --- .github/workflows/ci.yml | 6 + README.md | 25 ++++ docs/PRISMA-MIGRATIONS.md | 47 +++++++ package.json | 4 +- .../migration.sql} | 2 +- scripts/check-migration-naming.spec.ts | 78 ++++++++++++ scripts/check-migration-naming.ts | 120 ++++++++++++++++++ scripts/jest.config.js | 8 ++ .../filters/http-exception.filter.spec.ts | 21 +++ src/common/filters/http-exception.filter.ts | 10 +- src/limits/limits.controller.ts | 3 +- src/limits/limits.service.spec.ts | 48 +++++-- src/limits/limits.service.ts | 36 +++--- src/main.ts | 5 + .../payments-limits.integration.spec.ts | 7 + 15 files changed, 384 insertions(+), 36 deletions(-) create mode 100644 docs/PRISMA-MIGRATIONS.md rename prisma/migrations/{network_scoped_api_keys.sql => 20260730000000_add_network_scoped_api_keys/migration.sql} (59%) create mode 100644 scripts/check-migration-naming.spec.ts create mode 100644 scripts/check-migration-naming.ts create mode 100644 scripts/jest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 673210b..2452d0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Generate Prisma client run: pnpm prisma:generate + - name: Check Prisma migration naming + run: pnpm run prisma:check-migrations + - name: Build run: pnpm run build @@ -64,3 +67,6 @@ jobs: - name: Test run: pnpm test + + - name: Test scripts + run: pnpm run test:scripts diff --git a/README.md b/README.md index c9d63d9..e6f2e01 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,31 @@ It handles wallet creation, transaction orchestration, fee sponsorship, and on-c All routes below are served under the `/v1` prefix (e.g. `GET /v1/health`). See [docs/API-VERSIONING.md](docs/API-VERSIONING.md) for the versioning strategy. +### Error responses + +Every error — thrown `HttpException`, unhandled exception, or validation +failure — is returned by a global exception filter in the same structured +envelope: + +```json +{ + "statusCode": 422, + "timestamp": "2026-07-30T12:34:56.789Z", + "path": "/v1/wallets/123/limits", + "method": "POST", + "message": "Per-transaction limit exceeded. Limit: 1000", + "error": "Unprocessable Entity", + "errorCode": "LIMIT_PER_TX_EXCEEDED", + "requestId": "..." +} +``` + +`error` and `message` are always present. `errorCode` (a stable, machine-readable +string) and `details` (a structured object) are included only when the thrown +exception provides them. `requestId` is echoed back from the `X-Request-ID` +request header when present. In production, `message` on unhandled 500 errors +is sanitized to strip connection strings, file paths, and secrets. + ### Request body size JSON and URL-encoded request bodies are limited to 100 KiB by default. Set diff --git a/docs/PRISMA-MIGRATIONS.md b/docs/PRISMA-MIGRATIONS.md new file mode 100644 index 0000000..0d0bf7f --- /dev/null +++ b/docs/PRISMA-MIGRATIONS.md @@ -0,0 +1,47 @@ +# Prisma migration conventions + +## Naming + +Every migration must live in its own folder directly under `prisma/migrations/`: + +``` +prisma/migrations/20260730120000_add_thing/migration.sql +``` + +- Folder name: `<14-digit-timestamp>_` — the format + `prisma migrate dev` generates by default. The timestamp must be unique and + should reflect when the migration was authored (`YYYYMMDDHHMMSS`). +- The folder must contain a `migration.sql` file. Don't drop loose `.sql` + files directly under `prisma/migrations/` — Prisma silently ignores + anything that isn't inside a migration folder, so a stray file never gets + applied by `prisma migrate deploy` even though it looks like it's part of + the migration history. +- `migration_lock.toml` is the only file allowed directly under + `prisma/migrations/`. + +A number of early migrations predate this convention — some use a bare +counter (`0_init`), a short date without a time component +(`20260602_add_wallet_key_version`), or reuse the same 14-digit timestamp as +another migration. They're already applied in every environment, so renaming +them would break Prisma's `_prisma_migrations` tracking table. They're listed +by name in the `LEGACY_EXCEPTIONS` set in `scripts/check-migration-naming.ts` +(the source of truth) and must not be used as a template for new migrations. + +## CI check + +`pnpm run prisma:check-migrations` (wired into `.github/workflows/ci.yml`) +verifies: + +- no loose files under `prisma/migrations/` other than `migration_lock.toml` +- every migration folder contains a `migration.sql` +- every non-legacy folder matches the naming pattern above +- no two non-legacy migrations reuse the same timestamp + +Run it locally before opening a PR that touches `prisma/migrations/`: + +``` +pnpm run prisma:check-migrations +``` + +The validation logic is unit tested in `scripts/check-migration-naming.spec.ts` +(`pnpm run test:scripts`). diff --git a/package.json b/package.json index acd1906..a9d1684 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "test:e2e": "jest --config ./test/jest-e2e.json", "preinstall": "npx only-allow pnpm", "openapi:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts", - "openapi:lint": "npx @redocly/cli@latest lint openapi.json --config redocly.yaml" + "openapi:lint": "npx @redocly/cli@latest lint openapi.json --config redocly.yaml", + "prisma:check-migrations": "ts-node -r tsconfig-paths/register scripts/check-migration-naming.ts", + "test:scripts": "jest --config scripts/jest.config.js" }, "dependencies": { "@nestjs/common": "^11.0.1", diff --git a/prisma/migrations/network_scoped_api_keys.sql b/prisma/migrations/20260730000000_add_network_scoped_api_keys/migration.sql similarity index 59% rename from prisma/migrations/network_scoped_api_keys.sql rename to prisma/migrations/20260730000000_add_network_scoped_api_keys/migration.sql index 95a18a2..219ff47 100644 --- a/prisma/migrations/network_scoped_api_keys.sql +++ b/prisma/migrations/20260730000000_add_network_scoped_api_keys/migration.sql @@ -1,5 +1,5 @@ -- AlterTable -ALTER TABLE "ApiKey" ADD COLUMN "network" TEXT; +ALTER TABLE "ApiKey" ADD COLUMN "network" "WalletNetwork"; -- CreateIndex CREATE INDEX "ApiKey_network_idx" ON "ApiKey"("network"); diff --git a/scripts/check-migration-naming.spec.ts b/scripts/check-migration-naming.spec.ts new file mode 100644 index 0000000..796d89f --- /dev/null +++ b/scripts/check-migration-naming.spec.ts @@ -0,0 +1,78 @@ +import { validateMigrationEntries, MigrationEntry } from './check-migration-naming'; + +function dir(name: string, hasMigrationSql = true): MigrationEntry { + return { name, isDirectory: true, hasMigrationSql }; +} + +function file(name: string): MigrationEntry { + return { name, isDirectory: false, hasMigrationSql: false }; +} + +describe('validateMigrationEntries', () => { + it('passes for a well-formed set of migrations plus the lock file', () => { + const errors = validateMigrationEntries([ + file('migration_lock.toml'), + dir('20260601000000_add_thing'), + dir('20260602000000_add_other_thing'), + ]); + expect(errors).toEqual([]); + }); + + it('grandfathers known legacy folder names without a timestamp prefix', () => { + const errors = validateMigrationEntries([ + dir('0_init'), + dir('1_add_wallet_limit'), + dir('20260602_add_wallet_key_version'), + ]); + expect(errors).toEqual([]); + }); + + it('fails on a loose file directly under prisma/migrations', () => { + const errors = validateMigrationEntries([ + file('network_scoped_api_keys.sql'), + ]); + expect(errors).toEqual([ + expect.stringContaining('network_scoped_api_keys.sql'), + ]); + }); + + it('fails on a migration folder missing migration.sql', () => { + const errors = validateMigrationEntries([ + dir('20260601000000_add_thing', false), + ]); + expect(errors).toEqual([ + expect.stringContaining('missing a migration.sql file'), + ]); + }); + + it('fails on a new (non-legacy) folder that does not match the naming pattern', () => { + const errors = validateMigrationEntries([dir('add_thing_without_timestamp')]); + expect(errors).toEqual([ + expect.stringContaining('does not match the required'), + ]); + }); + + it('fails on a non-legacy folder using an unpadded/short timestamp', () => { + const errors = validateMigrationEntries([dir('20260602_add_wallet_key_version_v2')]); + expect(errors.length).toBe(1); + expect(errors[0]).toContain('does not match the required'); + }); + + it('fails when two non-legacy migrations reuse the same timestamp', () => { + const errors = validateMigrationEntries([ + dir('20260601000000_add_thing'), + dir('20260601000000_add_other_thing'), + ]); + expect(errors).toEqual([ + expect.stringContaining('reuses timestamp 20260601000000'), + ]); + }); + + it('does not flag duplicate timestamps between two legacy-exception folders', () => { + const errors = validateMigrationEntries([ + dir('0_init'), + dir('1_add_wallet_limit'), + ]); + expect(errors).toEqual([]); + }); +}); diff --git a/scripts/check-migration-naming.ts b/scripts/check-migration-naming.ts new file mode 100644 index 0000000..4e23626 --- /dev/null +++ b/scripts/check-migration-naming.ts @@ -0,0 +1,120 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export const MIGRATIONS_DIR = path.join(__dirname, '..', 'prisma', 'migrations'); + +/** Files permitted to sit directly under prisma/migrations/ (not inside a migration folder). */ +export const ALLOWED_TOP_LEVEL_FILES = new Set(['migration_lock.toml']); + +/** + * Migration folders created before this naming convention was enforced. + * They are already applied to real databases, so they can't be renamed + * without breaking Prisma's `_prisma_migrations` tracking table. New + * migrations must not be added to this list. + */ +export const LEGACY_EXCEPTIONS = new Set([ + '0_init', + '1_add_wallet_limit', + '20260601000000_add_spending_limits', + '20260601000000_add_transaction_idempotency_key', + '20260601000000_add_wallet_successor_id', + '20260602_add_wallet_key_version', + '20260723_add_asset_code_to_payment', + '20260724000000_add_user_default_network', + '20260724000000_add_user_last_login_metadata', + '20260724_add_soft_delete_to_wallet_limit', + '20260729000000_add_maintenance_state', + '20260729000000_add_wallet_nickname', +]); + +const NAME_PATTERN = /^\d{14}_[a-z0-9_]+$/; + +export interface MigrationEntry { + name: string; + isDirectory: boolean; + hasMigrationSql: boolean; +} + +/** + * Pure validation over a directory listing so the rules can be unit tested + * without touching the filesystem. + */ +export function validateMigrationEntries(entries: MigrationEntry[]): string[] { + const errors: string[] = []; + const seenTimestamps = new Map(); + + for (const entry of entries) { + if (!entry.isDirectory) { + if (!ALLOWED_TOP_LEVEL_FILES.has(entry.name)) { + errors.push( + `"${entry.name}" is a loose file directly under prisma/migrations/. ` + + `Every migration must live in its own "_/migration.sql" folder.`, + ); + } + continue; + } + + if (!entry.hasMigrationSql) { + errors.push( + `"${entry.name}/" is missing a migration.sql file.`, + ); + } + + if (LEGACY_EXCEPTIONS.has(entry.name)) { + continue; + } + + if (!NAME_PATTERN.test(entry.name)) { + errors.push( + `"${entry.name}" does not match the required "<14-digit-timestamp>_" ` + + `format (e.g. 20260730120000_add_thing). See docs/PRISMA-MIGRATIONS.md.`, + ); + continue; + } + + const timestamp = entry.name.slice(0, 14); + const clash = seenTimestamps.get(timestamp); + if (clash) { + errors.push( + `"${entry.name}" reuses timestamp ${timestamp} already used by "${clash}". ` + + `Migration timestamps must be unique and monotonically increasing.`, + ); + } else { + seenTimestamps.set(timestamp, entry.name); + } + } + + return errors; +} + +function readEntries(dir: string): MigrationEntry[] { + return fs.readdirSync(dir).map((name) => { + const full = path.join(dir, name); + const isDirectory = fs.statSync(full).isDirectory(); + const hasMigrationSql = + isDirectory && fs.existsSync(path.join(full, 'migration.sql')); + return { name, isDirectory, hasMigrationSql }; + }); +} + +function main() { + const entries = readEntries(MIGRATIONS_DIR); + const errors = validateMigrationEntries(entries); + + if (errors.length > 0) { + console.error('Prisma migration naming check failed:\n'); + for (const error of errors) { + console.error(` - ${error}`); + } + console.error( + '\nSee docs/PRISMA-MIGRATIONS.md for the naming convention and how to fix this.', + ); + process.exit(1); + } + + console.log(`Prisma migration naming check passed (${entries.length} entries).`); +} + +if (require.main === module) { + main(); +} diff --git a/scripts/jest.config.js b/scripts/jest.config.js new file mode 100644 index 0000000..2cd8847 --- /dev/null +++ b/scripts/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.ts$': 'ts-jest', + }, + testEnvironment: 'node', +}; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index 8f217a3..81b3acb 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -388,6 +388,27 @@ describe('HttpExceptionFilter', () => { const jsonCall = mockResponse.json.mock.calls[0][0]; expect(jsonCall.details).toBeUndefined(); }); + + it('should include errorCode field when provided on the exception body', () => { + const exception = new HttpException( + { errorCode: 'LIMIT_PER_TX_EXCEEDED', message: 'Per-transaction limit exceeded' }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + + filter.catch(exception, mockArgumentsHost); + + const jsonCall = mockResponse.json.mock.calls[0][0]; + expect(jsonCall.errorCode).toBe('LIMIT_PER_TX_EXCEEDED'); + }); + + it('should not include errorCode field when not provided', () => { + const exception = new NotFoundException('Not found'); + + filter.catch(exception, mockArgumentsHost); + + const jsonCall = mockResponse.json.mock.calls[0][0]; + expect(jsonCall.errorCode).toBeUndefined(); + }); }); describe('HTTP status code mapping', () => { diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 2ebe235..e19449f 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -18,6 +18,7 @@ export interface ErrorResponse { method: string; message: string | string[]; error?: string; + errorCode?: string; details?: Record; requestId?: string; } @@ -69,10 +70,8 @@ export class HttpExceptionFilter implements ExceptionFilter { const exceptionResponse = exception.getResponse(); // Extract message and details from exception response - const { message, error, details } = this.parseHttpExceptionResponse( - exceptionResponse, - status, - ); + const { message, error, errorCode, details } = + this.parseHttpExceptionResponse(exceptionResponse, status); return { statusCode: status, @@ -81,6 +80,7 @@ export class HttpExceptionFilter implements ExceptionFilter { method, message, error, + ...(errorCode && { errorCode }), ...(details && { details }), ...(request.headers['x-request-id'] && { requestId: request.headers['x-request-id'] as string, @@ -126,6 +126,7 @@ export class HttpExceptionFilter implements ExceptionFilter { ): { message: string | string[]; error: string; + errorCode?: string; details?: Record; } { // If response is a string, use it as the message @@ -142,6 +143,7 @@ export class HttpExceptionFilter implements ExceptionFilter { return { message: responseObj.message || 'An error occurred', error: responseObj.error || this.getErrorNameFromStatus(status), + ...(responseObj.errorCode && { errorCode: responseObj.errorCode }), ...(responseObj.details && { details: responseObj.details }), }; } diff --git a/src/limits/limits.controller.ts b/src/limits/limits.controller.ts index 405e549..624f52f 100644 --- a/src/limits/limits.controller.ts +++ b/src/limits/limits.controller.ts @@ -19,6 +19,7 @@ import { } from '@nestjs/swagger'; import { LimitsService } from './limits.service'; import { SetLimitsDto } from './dto/set-limits.dto'; +import { UpdateLimitsDto } from './dto/update-limits.dto'; import { LimitsResponseDto } from './dto/limits-response.dto'; import { FeatureFlagGuard, @@ -34,7 +35,7 @@ export class LimitsController { @ApiOperation({ summary: 'Set wallet transaction and daily limits', - description: 'Set or update daily and per-transaction limits for a wallet. Requires API key authentication. Emits limit.updated events for each limit changed.', + description: 'Set or update daily and per-transaction limits for a wallet. The read-check and write are performed atomically in a single Prisma transaction, so concurrent requests for the same wallet cannot race. Requires API key authentication. Emits limit.updated events for each limit changed.', }) @ApiParam({ name: 'walletId', description: 'Wallet ID (UUID)' }) @ApiBody({ diff --git a/src/limits/limits.service.spec.ts b/src/limits/limits.service.spec.ts index cef6854..4e91e2d 100644 --- a/src/limits/limits.service.spec.ts +++ b/src/limits/limits.service.spec.ts @@ -22,12 +22,13 @@ describe('LimitsService', () => { walletLimit: { upsert: jest.fn(), findUnique: jest.fn(), - delete: jest.fn(), + update: jest.fn(), }, transaction: { findMany: jest.fn(), }, }; + prisma.$transaction = jest.fn((cb) => cb(prisma)); eventEmitter = { emit: jest.fn() }; metrics = { incrementLimitExceeded: jest.fn(), @@ -59,29 +60,41 @@ describe('LimitsService', () => { await service.setLimits(walletId, 100, 10); expect(prisma.walletLimit.upsert).toHaveBeenCalledWith({ where: { walletId }, - update: { dailyLimit: 100, perTransactionLimit: 10 }, + update: { dailyLimit: 100, perTransactionLimit: 10, deletedAt: null }, create: { walletId, dailyLimit: 100, perTransactionLimit: 10 }, }); }); + + it('should run the existence check and upsert inside a single Prisma transaction', async () => { + await service.setLimits(walletId, 100, 10); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.walletLimit.findUnique).toHaveBeenCalledWith({ + where: { walletId }, + }); + }); + + it('should propagate failure and emit no events if the transaction fails', async () => { + prisma.$transaction.mockRejectedValue(new Error('deadlock')); + await expect(service.setLimits(walletId, 100, 10)).rejects.toThrow( + 'deadlock', + ); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }, 10000); }); describe('getLimits', () => { it('should return limits for a wallet', async () => { const limit = { walletId, dailyLimit: 100, perTransactionLimit: 10 }; prisma.walletLimit.findUnique.mockResolvedValue(limit); + prisma.transaction.findMany.mockResolvedValue([]); const result = await service.getLimits(walletId); - expect(result).toEqual(limit); + expect(result).toEqual({ ...limit, remainingDailyLimit: 100 }); }); - it('should use the cache layer for wallet limits', async () => { - const limit = { walletId, dailyLimit: 100, perTransactionLimit: 10 }; - cacheService.get.mockReturnValue(limit); - + it('should return null when no limits exist for a wallet', async () => { + prisma.walletLimit.findUnique.mockResolvedValue(null); const result = await service.getLimits(walletId); - - expect(result).toEqual(limit); - expect(cacheService.get).toHaveBeenCalledWith(`limits:${walletId}`); - expect(prisma.walletLimit.findUnique).not.toHaveBeenCalled(); + expect(result).toBeNull(); }); }); @@ -96,6 +109,7 @@ describe('LimitsService', () => { perTransactionLimit: 50, dailyLimit: 1000, }); + prisma.transaction.findMany.mockResolvedValue([]); await expect(service.checkLimits(walletId, 100)).rejects.toBeInstanceOf( LimitExceededException, ); @@ -168,13 +182,18 @@ describe('LimitsService', () => { }); describe('removeLimits', () => { - it('should delete limits for a wallet', async () => { + it('should soft-delete limits for a wallet', async () => { const limit = { walletId, dailyLimit: 100, perTransactionLimit: 10 }; prisma.walletLimit.findUnique.mockResolvedValue(limit); - prisma.walletLimit.delete.mockResolvedValue(limit); + prisma.transaction.findMany.mockResolvedValue([]); + prisma.walletLimit.update.mockResolvedValue({ + ...limit, + deletedAt: new Date(), + }); await service.removeLimits(walletId); - expect(prisma.walletLimit.delete).toHaveBeenCalledWith({ + expect(prisma.walletLimit.update).toHaveBeenCalledWith({ where: { walletId }, + data: { deletedAt: expect.any(Date) }, }); }); @@ -193,6 +212,7 @@ describe('LimitsService', () => { perTransactionLimit: 50, dailyLimit: 1000, }); + prisma.transaction.findMany.mockResolvedValue([]); try { await service.checkLimits(walletId, 100); diff --git a/src/limits/limits.service.ts b/src/limits/limits.service.ts index 8c16fba..1eba95f 100644 --- a/src/limits/limits.service.ts +++ b/src/limits/limits.service.ts @@ -50,22 +50,28 @@ export class LimitsService { ) {} async setLimits(walletId: string, daily: number, perTx: number) { - const existing = await retryWithBackoff( + // Read-then-write is wrapped in a single Prisma transaction so a + // concurrent setLimits call for the same wallet can't interleave + // between the existence check and the upsert, which would otherwise + // produce incorrect limit.updated diffs (comparing against stale data). + const { existing, result } = await retryWithBackoff( () => - this.prisma.walletLimit.findUnique({ - where: { walletId }, - }), - 3, - 100, - this.logger, - ); - - const result = await retryWithBackoff( - () => - this.prisma.walletLimit.upsert({ - where: { walletId }, - update: { dailyLimit: daily, perTransactionLimit: perTx, deletedAt: null }, - create: { walletId, dailyLimit: daily, perTransactionLimit: perTx }, + this.prisma.$transaction(async (tx) => { + const existing = await tx.walletLimit.findUnique({ + where: { walletId }, + }); + + const result = await tx.walletLimit.upsert({ + where: { walletId }, + update: { + dailyLimit: daily, + perTransactionLimit: perTx, + deletedAt: null, + }, + create: { walletId, dailyLimit: daily, perTransactionLimit: perTx }, + }); + + return { existing, result }; }), 3, 100, diff --git a/src/main.ts b/src/main.ts index 439d62d..ccf48cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { AppModule } from './app.module'; import requestLogger from './common/middleware/request-logging.middleware'; import { configureBodySizeLimit } from './common/http/body-size-limit'; import { validateEnv } from './config/env.validation'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; /** * Parses the CORS_ALLOWED_ORIGINS env var into an array of allowed origins. @@ -62,6 +63,10 @@ async function bootstrap() { }), ); + // Ensure every error response (thrown HttpException, unhandled Error, or + // unknown value) is returned in the same structured envelope. + app.useGlobalFilters(new HttpExceptionFilter()); + // Let Nest call onModuleDestroy/beforeApplicationShutdown on SIGTERM/SIGINT // so in-flight requests can finish and connections (Prisma, etc.) close cleanly. app.enableShutdownHooks(); diff --git a/src/payments/payments-limits.integration.spec.ts b/src/payments/payments-limits.integration.spec.ts index 998609e..4216218 100644 --- a/src/payments/payments-limits.integration.spec.ts +++ b/src/payments/payments-limits.integration.spec.ts @@ -10,6 +10,8 @@ import { RequestContextService } from '../common/request-context/request-context import { PAYMENT_LIMITS_PORT } from './ports/payment-limits.port'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { MetricsService } from '../metrics/metrics.service'; +import { PaymentMetricsService } from './payment-metrics.service'; +import { ConfigService } from '@nestjs/config'; describe('Payments and Limits Integration', () => { let paymentsService: PaymentsService; @@ -28,6 +30,7 @@ describe('Payments and Limits Integration', () => { payment: { create: jest.fn(), findMany: jest.fn() }, transaction: { findMany: jest.fn() }, legacyUser: {}, + $transaction: jest.fn((cb: any) => cb(mockPrisma)), }; const mockWalletsService = { @@ -61,6 +64,8 @@ describe('Payments and Limits Integration', () => { incrementLimitChecks: jest.fn(), }, }, + PaymentMetricsService, + { provide: ConfigService, useValue: { get: jest.fn() } }, ], }).compile(); @@ -145,6 +150,7 @@ describe('Payments and Limits Integration', () => { const limit = { walletId: testWalletId, dailyLimit: 1000, perTransactionLimit: 500 }; mockPrisma.walletLimit.findUnique.mockResolvedValue(limit); + mockPrisma.transaction.findMany.mockResolvedValue([]); const result = await limitsService.getLimits(testWalletId); @@ -173,6 +179,7 @@ describe('Payments and Limits Integration', () => { .mockResolvedValueOnce(senderWallet) .mockResolvedValueOnce(receiverWallet); mockPrisma.walletLimit.findUnique.mockResolvedValue(limit); + mockPrisma.transaction.findMany.mockResolvedValue([]); const createPaymentDto = { walletId: testWalletId,