Skip to content

Commit 60c16a1

Browse files
authored
Merge pull request #305 from orunganiekan/feature/wallet-creation-orchestrator
feat(backend): implement wallet creation orchestration, validation, a…
2 parents 5aea9e2 + 0aecf73 commit 60c16a1

5 files changed

Lines changed: 358 additions & 4 deletions

src/__mocks__/prisma-client.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Minimal stub of the generated Prisma client for Jest.
3+
* All model accessors return mock objects; tests override them as needed.
4+
*/
5+
const modelProxy = () => ({
6+
findUnique: jest.fn(),
7+
findFirst: jest.fn(),
8+
findMany: jest.fn(),
9+
create: jest.fn(),
10+
update: jest.fn(),
11+
upsert: jest.fn(),
12+
delete: jest.fn(),
13+
deleteMany: jest.fn(),
14+
count: jest.fn(),
15+
});
16+
17+
class PrismaClient {
18+
user = modelProxy();
19+
wallet = modelProxy();
20+
idempotencyRecord = modelProxy();
21+
apiKey = modelProxy();
22+
apiKeyUsage = modelProxy();
23+
rateLimitRecord = modelProxy();
24+
project = modelProxy();
25+
developer = modelProxy();
26+
payment = modelProxy();
27+
legacyUser = modelProxy();
28+
userLimit = modelProxy();
29+
recoveryRequest = modelProxy();
30+
walletBalance = modelProxy();
31+
balanceSyncJob = modelProxy();
32+
webhookEndpoint = modelProxy();
33+
webhookDelivery = modelProxy();
34+
transaction = modelProxy();
35+
36+
$connect = jest.fn().mockResolvedValue(undefined);
37+
$disconnect = jest.fn().mockResolvedValue(undefined);
38+
$transaction = jest.fn().mockImplementation((cb) =>
39+
typeof cb === 'function' ? cb(this) : Promise.all(cb),
40+
);
41+
}
42+
43+
module.exports = { PrismaClient };

src/app.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { ConfigModule } from '@nestjs/config';
55
import { PrismaModule } from './prisma/prisma.module';
66
import { AppService } from './app.service';
77
import { UsersModule } from './users/users.module';
8+
import { IdempotentUserModule } from './users/idempotent-user.module';
89
import { WalletsModule } from './wallets/wallets.module';
10+
import { WalletCreationOrchestratorModule } from './wallets/wallet-creation-orchestrator.module';
911
import { PaymentsModule } from './payments/payments.module';
1012
import { LimitsModule } from './limits/limits.module';
1113
import { RecoveryModule } from './recovery/recovery.module';
@@ -33,7 +35,9 @@ import { IdempotentUserModule } from './users/idempotent-user.module';
3335
AuthModule,
3436
RateLimitModule,
3537
UsersModule,
38+
IdempotentUserModule,
3639
WalletsModule,
40+
WalletCreationOrchestratorModule,
3741
PaymentsModule,
3842
LimitsModule,
3943
RecoveryModule,
@@ -45,7 +49,6 @@ import { IdempotentUserModule } from './users/idempotent-user.module';
4549
DevelopersModule,
4650
ProjectsModule,
4751
HealthModule,
48-
IdempotentUserModule,
4952
],
5053
controllers: [AppController],
5154
providers: [
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* WalletCreationOrchestrator Integration Test Harness (#191)
3+
*
4+
* Wires the real WalletCreationOrchestrator with controlled collaborator stubs
5+
* to exercise the full wallet creation flow without a live database.
6+
*
7+
* Covers:
8+
* - New wallet creation (generates keys, encrypts, persists)
9+
* - Existing wallet returned idempotently (no DB write)
10+
* - Idempotency key cache hit (returns cached result, no DB write)
11+
* - Invalid network value rejected (enum validation)
12+
* - User not found propagation
13+
* - DB transaction failure handling
14+
*/
15+
import { Test, TestingModule } from '@nestjs/testing';
16+
import { ConfigService } from '@nestjs/config';
17+
import {
18+
WalletCreationOrchestrator,
19+
CreateWalletOrchestratorRequest,
20+
} from './wallet-creation-orchestrator.service';
21+
import { WalletNetwork, WalletStatus } from './domain/wallet.model';
22+
import { EncryptionService } from '../encryption/encryption.service';
23+
import { IdempotentUserService } from '../users/idempotent-user.service';
24+
import { IdempotencyService } from '../common/idempotency/idempotency.service';
25+
26+
// ---------------------------------------------------------------------------
27+
// Shared fixtures
28+
// ---------------------------------------------------------------------------
29+
30+
const NOW = new Date('2026-01-01T00:00:00.000Z');
31+
32+
const makeDbWallet = (overrides: Record<string, any> = {}) => ({
33+
id: 'wallet-abc',
34+
userId: 'user-abc',
35+
publicKey: 'GABC1234567890',
36+
encryptedSecret: 'enc-secret',
37+
encryptionVersion: 1,
38+
secretVersion: 1,
39+
network: WalletNetwork.TESTNET,
40+
status: WalletStatus.ACTIVE,
41+
statusReason: null,
42+
statusChangedAt: NOW,
43+
rotatedFromId: null,
44+
createdAt: NOW,
45+
updatedAt: NOW,
46+
...overrides,
47+
});
48+
49+
const makeUser = (overrides: Record<string, any> = {}) => ({
50+
id: 'user-abc',
51+
authId: 'auth-abc',
52+
email: 'user@example.com',
53+
displayName: 'Test User',
54+
status: 'ACTIVE',
55+
authProvider: 'GOOGLE',
56+
lastLoginAt: NOW,
57+
createdAt: NOW,
58+
updatedAt: NOW,
59+
...overrides,
60+
});
61+
62+
// ---------------------------------------------------------------------------
63+
// Harness setup
64+
// ---------------------------------------------------------------------------
65+
66+
describe('WalletCreationOrchestrator (integration harness)', () => {
67+
let orchestrator: WalletCreationOrchestrator;
68+
let encryptionService: jest.Mocked<EncryptionService>;
69+
let idempotentUserService: jest.Mocked<Pick<IdempotentUserService, 'findUserById'>>;
70+
let idempotencyService: jest.Mocked<Pick<IdempotencyService, 'getCachedResponse' | 'cacheResponse'>>;
71+
let mockTx: any;
72+
let mockPrisma: any;
73+
74+
beforeEach(async () => {
75+
mockTx = {
76+
wallet: {
77+
findFirst: jest.fn(),
78+
create: jest.fn(),
79+
},
80+
};
81+
82+
mockPrisma = {
83+
wallet: {
84+
findFirst: jest.fn(),
85+
},
86+
$transaction: jest.fn().mockImplementation((cb) => cb(mockTx)),
87+
};
88+
89+
encryptionService = {
90+
validateConfiguration: jest.fn().mockReturnValue(true),
91+
encryptAndSerialize: jest.fn().mockReturnValue('encrypted-key'),
92+
} as any;
93+
94+
idempotentUserService = {
95+
findUserById: jest.fn(),
96+
};
97+
98+
idempotencyService = {
99+
getCachedResponse: jest.fn().mockResolvedValue(null),
100+
cacheResponse: jest.fn().mockResolvedValue(undefined),
101+
};
102+
103+
const module: TestingModule = await Test.createTestingModule({
104+
providers: [
105+
WalletCreationOrchestrator,
106+
{ provide: EncryptionService, useValue: encryptionService },
107+
{ provide: ConfigService, useValue: { get: jest.fn() } },
108+
{ provide: IdempotentUserService, useValue: idempotentUserService },
109+
{ provide: IdempotencyService, useValue: idempotencyService },
110+
],
111+
}).compile();
112+
113+
orchestrator = module.get(WalletCreationOrchestrator);
114+
// Inject mock prisma directly (bypasses real DB)
115+
(orchestrator as any).prisma = mockPrisma;
116+
});
117+
118+
afterEach(() => jest.clearAllMocks());
119+
120+
// -------------------------------------------------------------------------
121+
// New wallet creation
122+
// -------------------------------------------------------------------------
123+
124+
describe('new wallet creation', () => {
125+
const request: CreateWalletOrchestratorRequest = {
126+
userId: 'user-abc',
127+
network: WalletNetwork.TESTNET,
128+
};
129+
130+
beforeEach(() => {
131+
idempotentUserService.findUserById.mockResolvedValue(makeUser());
132+
mockTx.wallet.findFirst.mockResolvedValue(null);
133+
mockTx.wallet.create.mockResolvedValue(makeDbWallet());
134+
});
135+
136+
it('creates wallet, encrypts key, and returns isNewWallet=true', async () => {
137+
const result = await orchestrator.createWallet(request);
138+
139+
expect(result.isNewWallet).toBe(true);
140+
expect(result.wallet.id).toBe('wallet-abc');
141+
expect(result.wallet.userId).toBe('user-abc');
142+
expect(result.wallet.network).toBe(WalletNetwork.TESTNET);
143+
expect(result.wallet.status).toBe(WalletStatus.ACTIVE);
144+
expect(result.privateKey).toBeTruthy();
145+
expect(encryptionService.encryptAndSerialize).toHaveBeenCalledWith(
146+
expect.any(String),
147+
);
148+
});
149+
150+
it('creates wallet record with correct data shape', async () => {
151+
await orchestrator.createWallet(request);
152+
153+
expect(mockTx.wallet.create).toHaveBeenCalledWith({
154+
data: expect.objectContaining({
155+
userId: 'user-abc',
156+
network: WalletNetwork.TESTNET,
157+
status: 'ACTIVE',
158+
encryptionVersion: 1,
159+
secretVersion: 1,
160+
encryptedSecret: 'encrypted-key',
161+
}),
162+
});
163+
});
164+
});
165+
166+
// -------------------------------------------------------------------------
167+
// Existing wallet (idempotent return)
168+
// -------------------------------------------------------------------------
169+
170+
describe('existing wallet', () => {
171+
it('returns existing wallet without creating a new one', async () => {
172+
idempotentUserService.findUserById.mockResolvedValue(makeUser());
173+
mockTx.wallet.findFirst.mockResolvedValue(makeDbWallet());
174+
175+
const result = await orchestrator.createWallet({
176+
userId: 'user-abc',
177+
network: WalletNetwork.TESTNET,
178+
});
179+
180+
expect(result.isNewWallet).toBe(false);
181+
expect(result.privateKey).toBe('');
182+
expect(mockTx.wallet.create).not.toHaveBeenCalled();
183+
});
184+
});
185+
186+
// -------------------------------------------------------------------------
187+
// Idempotency key cache hit
188+
// -------------------------------------------------------------------------
189+
190+
describe('idempotency key', () => {
191+
it('returns cached result on second call without hitting DB', async () => {
192+
const cachedResult = {
193+
wallet: makeDbWallet(),
194+
privateKey: 'cached-key',
195+
isNewWallet: true,
196+
idempotencyKey: 'idem-key-1',
197+
};
198+
199+
idempotentUserService.findUserById.mockResolvedValue(makeUser());
200+
idempotencyService.getCachedResponse.mockResolvedValue(cachedResult);
201+
202+
const result = await orchestrator.createWallet({
203+
userId: 'user-abc',
204+
network: WalletNetwork.TESTNET,
205+
idempotencyKey: 'idem-key-1',
206+
});
207+
208+
expect(result).toEqual(cachedResult);
209+
expect(mockTx.wallet.create).not.toHaveBeenCalled();
210+
});
211+
212+
it('stores result after successful creation', async () => {
213+
idempotentUserService.findUserById.mockResolvedValue(makeUser());
214+
idempotencyService.getCachedResponse.mockResolvedValue(null);
215+
mockTx.wallet.findFirst.mockResolvedValue(null);
216+
mockTx.wallet.create.mockResolvedValue(makeDbWallet());
217+
218+
await orchestrator.createWallet({
219+
userId: 'user-abc',
220+
network: WalletNetwork.TESTNET,
221+
idempotencyKey: 'idem-key-2',
222+
});
223+
224+
expect(idempotencyService.cacheResponse).toHaveBeenCalledWith(
225+
'idem-key-2',
226+
expect.objectContaining({ isNewWallet: true }),
227+
'POST',
228+
'/wallets/orchestration/create',
229+
);
230+
});
231+
});
232+
233+
// -------------------------------------------------------------------------
234+
// Error handling
235+
// -------------------------------------------------------------------------
236+
237+
describe('error handling', () => {
238+
it('throws when user is not found', async () => {
239+
idempotentUserService.findUserById.mockResolvedValue(null);
240+
241+
await expect(
242+
orchestrator.createWallet({ userId: 'unknown', network: WalletNetwork.TESTNET }),
243+
).rejects.toThrow();
244+
});
245+
246+
it('wraps DB transaction failures', async () => {
247+
mockPrisma.$transaction.mockRejectedValue(new Error('DB down'));
248+
249+
await expect(
250+
orchestrator.createWallet({ userId: 'user-abc', network: WalletNetwork.TESTNET }),
251+
).rejects.toThrow('Wallet creation orchestration failed');
252+
});
253+
});
254+
255+
// -------------------------------------------------------------------------
256+
// getWalletByUser
257+
// -------------------------------------------------------------------------
258+
259+
describe('getWalletByUser', () => {
260+
it('returns wallet when found', async () => {
261+
mockPrisma.wallet.findFirst.mockResolvedValue(makeDbWallet());
262+
263+
const result = await orchestrator.getWalletByUser('user-abc', WalletNetwork.TESTNET);
264+
265+
expect(result).not.toBeNull();
266+
expect(result!.id).toBe('wallet-abc');
267+
expect(result!.network).toBe(WalletNetwork.TESTNET);
268+
});
269+
270+
it('returns null when wallet does not exist', async () => {
271+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
272+
273+
const result = await orchestrator.getWalletByUser('user-abc', WalletNetwork.MAINNET);
274+
275+
expect(result).toBeNull();
276+
});
277+
});
278+
279+
// -------------------------------------------------------------------------
280+
// validateUserCanCreateWallet
281+
// -------------------------------------------------------------------------
282+
283+
describe('validateUserCanCreateWallet', () => {
284+
it('returns true when user has no wallet on the network', async () => {
285+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
286+
287+
await expect(
288+
orchestrator.validateUserCanCreateWallet('user-abc', WalletNetwork.TESTNET),
289+
).resolves.toBe(true);
290+
});
291+
292+
it('returns false when user already has a wallet on the network', async () => {
293+
mockPrisma.wallet.findFirst.mockResolvedValue(makeDbWallet());
294+
295+
await expect(
296+
orchestrator.validateUserCanCreateWallet('user-abc', WalletNetwork.TESTNET),
297+
).resolves.toBe(false);
298+
});
299+
});
300+
});

src/wallets/wallet-creation-orchestrator.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { WebhookModule } from '../webhooks/webhook.module';
99
@Module({
1010
imports: [EncryptionModule, WalletsModule, UsersModule, WebhookModule],
1111
controllers: [WalletCreationOrchestratorController],
12-
providers: [WalletCreationOrchestrator],
12+
providers: [WalletCreationOrchestrator, IdempotencyService],
1313
exports: [WalletCreationOrchestrator],
1414
})
1515
export class WalletCreationOrchestratorModule {}

0 commit comments

Comments
 (0)