Skip to content

Commit 2e8be20

Browse files
authored
Merge pull request #613 from Meshmulla/feature/476-wallet-network-preference
feat(wallets): persist per-user network preference
2 parents 14745d3 + 5e2155e commit 2e8be20

7 files changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable: add defaultNetwork preference to User
2+
ALTER TABLE "User" ADD COLUMN "defaultNetwork" "WalletNetwork";

prisma/schema.prisma

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ model User {
9999
/// Authentication provider type
100100
authProvider String @default("UNKNOWN")
101101
102+
/// Preferred network (mainnet/testnet) for wallet operations that don't
103+
/// explicitly specify one. Null = no preference set.
104+
defaultNetwork WalletNetwork?
105+
102106
/// Last login timestamp
103107
lastLoginAt DateTime?
104108
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { IsEnum } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
import { WalletNetwork } from '../domain/wallet.model';
4+
5+
export class SetNetworkPreferenceDto {
6+
@ApiProperty({
7+
enum: WalletNetwork,
8+
example: WalletNetwork.TESTNET,
9+
description:
10+
'Preferred network for wallet operations that do not explicitly specify one',
11+
})
12+
@IsEnum(WalletNetwork, { message: 'network must be one of MAINNET, TESTNET' })
13+
network: WalletNetwork;
14+
}

src/wallets/wallets.controller.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ describe('WalletsController', () => {
2121
getWalletStatus: jest.fn(),
2222
activateWallet: jest.fn(),
2323
findWalletsByUserId: jest.fn(),
24+
getNetworkPreference: jest.fn(),
25+
setNetworkPreference: jest.fn(),
2426
};
2527

2628
const mockWalletCreationOrchestrator = {
@@ -241,4 +243,41 @@ describe('WalletsController', () => {
241243
);
242244
});
243245
});
246+
247+
describe('getNetworkPreference', () => {
248+
it('should return the network preference for a userId', async () => {
249+
const preference = {
250+
userId: 'user-123',
251+
defaultNetwork: WalletNetwork.TESTNET,
252+
};
253+
mockWalletsService.getNetworkPreference.mockResolvedValue(preference);
254+
255+
await expect(
256+
controller.getNetworkPreference('user-123'),
257+
).resolves.toEqual(preference);
258+
expect(mockWalletsService.getNetworkPreference).toHaveBeenCalledWith(
259+
'user-123',
260+
);
261+
});
262+
});
263+
264+
describe('setNetworkPreference', () => {
265+
it('should persist the network preference for a userId', async () => {
266+
const preference = {
267+
userId: 'user-123',
268+
defaultNetwork: WalletNetwork.MAINNET,
269+
};
270+
mockWalletsService.setNetworkPreference.mockResolvedValue(preference);
271+
272+
await expect(
273+
controller.setNetworkPreference('user-123', {
274+
network: WalletNetwork.MAINNET,
275+
}),
276+
).resolves.toEqual(preference);
277+
expect(mockWalletsService.setNetworkPreference).toHaveBeenCalledWith(
278+
'user-123',
279+
WalletNetwork.MAINNET,
280+
);
281+
});
282+
});
244283
});

src/wallets/wallets.controller.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Controller,
33
Get,
44
Post,
5+
Put,
56
Body,
67
Patch,
78
Param,
@@ -25,6 +26,7 @@ import {
2526
import { WalletsService } from './wallets.service';
2627
import { CreateWalletDto } from './dto/create-wallet.dto';
2728
import { UpdateWalletDto } from './dto/update-wallet.dto';
29+
import { SetNetworkPreferenceDto } from './dto/set-network-preference.dto';
2830
import { WalletNetwork, WalletStatus } from './domain/wallet.model';
2931
import { RequireApiKey } from '../api-keys/decorators/require-api-key.decorator';
3032
import { ApiKeyCtx } from '../api-keys/decorators/api-key-context.decorator';
@@ -160,6 +162,31 @@ export class WalletsController {
160162
return this.walletsService.findWalletsByUserId(userId);
161163
}
162164

165+
@ApiOperation({
166+
summary: "Get a user's default network preference",
167+
description:
168+
'Retrieve the persisted mainnet/testnet preference for a user. Requires API key authentication.',
169+
})
170+
@ApiParam({ name: 'userId', description: 'User ID (UUID)' })
171+
@Get('users/:userId/network-preference')
172+
async getNetworkPreference(@Param('userId') userId: string) {
173+
return this.walletsService.getNetworkPreference(userId);
174+
}
175+
176+
@ApiOperation({
177+
summary: "Set a user's default network preference",
178+
description:
179+
'Persist the mainnet/testnet preference for a user, used by wallet operations that do not explicitly specify a network. Requires API key authentication.',
180+
})
181+
@ApiParam({ name: 'userId', description: 'User ID (UUID)' })
182+
@Put('users/:userId/network-preference')
183+
async setNetworkPreference(
184+
@Param('userId') userId: string,
185+
@Body() dto: SetNetworkPreferenceDto,
186+
) {
187+
return this.walletsService.setNetworkPreference(userId, dto.network);
188+
}
189+
163190
@Get(':id')
164191
findOne(@Param('id') id: string) {
165192
return this.walletsService.findOne(id);

src/wallets/wallets.service.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ const mockPrismaWallet = {
2424
count: jest.fn(),
2525
};
2626

27+
// Shared mock Prisma user methods (used by network preference lookups)
28+
const mockPrismaUser = {
29+
findUnique: jest.fn(),
30+
update: jest.fn(),
31+
};
32+
2733
// Mock the PrismaClient module so new PrismaClient() returns our mock
2834
jest.mock('../generated/prisma/client', () => ({
2935
PrismaClient: jest.fn(() => ({
3036
wallet: mockPrismaWallet,
37+
user: mockPrismaUser,
3138
})),
3239
}));
3340

@@ -724,4 +731,76 @@ describe('WalletsService', () => {
724731
expect(result.hasMore).toBe(false);
725732
});
726733
});
734+
735+
describe('getNetworkPreference', () => {
736+
it('returns the persisted preference for an existing user', async () => {
737+
mockPrismaUser.findUnique.mockResolvedValue({
738+
id: 'user-1',
739+
defaultNetwork: 'TESTNET',
740+
});
741+
742+
const result = await service.getNetworkPreference('user-1');
743+
744+
expect(mockPrismaUser.findUnique).toHaveBeenCalledWith({
745+
where: { id: 'user-1' },
746+
});
747+
expect(result).toEqual({
748+
userId: 'user-1',
749+
defaultNetwork: WalletNetwork.TESTNET,
750+
});
751+
});
752+
753+
it('returns null defaultNetwork when the user has no preference set', async () => {
754+
mockPrismaUser.findUnique.mockResolvedValue({
755+
id: 'user-1',
756+
defaultNetwork: null,
757+
});
758+
759+
const result = await service.getNetworkPreference('user-1');
760+
761+
expect(result).toEqual({ userId: 'user-1', defaultNetwork: null });
762+
});
763+
764+
it('throws NotFoundException when the user does not exist', async () => {
765+
mockPrismaUser.findUnique.mockResolvedValue(null);
766+
767+
await expect(
768+
service.getNetworkPreference('missing-user'),
769+
).rejects.toThrow('User with ID missing-user not found');
770+
});
771+
});
772+
773+
describe('setNetworkPreference', () => {
774+
it('persists the network preference for an existing user', async () => {
775+
mockPrismaUser.findUnique.mockResolvedValue({ id: 'user-1' });
776+
mockPrismaUser.update.mockResolvedValue({
777+
id: 'user-1',
778+
defaultNetwork: 'MAINNET',
779+
});
780+
781+
const result = await service.setNetworkPreference(
782+
'user-1',
783+
WalletNetwork.MAINNET,
784+
);
785+
786+
expect(mockPrismaUser.update).toHaveBeenCalledWith({
787+
where: { id: 'user-1' },
788+
data: { defaultNetwork: WalletNetwork.MAINNET },
789+
});
790+
expect(result).toEqual({
791+
userId: 'user-1',
792+
defaultNetwork: WalletNetwork.MAINNET,
793+
});
794+
});
795+
796+
it('throws NotFoundException when the user does not exist', async () => {
797+
mockPrismaUser.findUnique.mockResolvedValue(null);
798+
799+
await expect(
800+
service.setNetworkPreference('missing-user', WalletNetwork.MAINNET),
801+
).rejects.toThrow('User with ID missing-user not found');
802+
803+
expect(mockPrismaUser.update).not.toHaveBeenCalled();
804+
});
805+
});
727806
});

src/wallets/wallets.service.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,39 @@ export class WalletsService {
308308
return wallets.map((wallet) => this.mapPrismaWalletToDomain(wallet));
309309
}
310310

311+
/** Retrieves the user's persisted default network preference (null if unset). */
312+
async getNetworkPreference(userId: string): Promise<{
313+
userId: string;
314+
defaultNetwork: WalletNetwork | null;
315+
}> {
316+
const user = await this.prisma.user.findUnique({ where: { id: userId } });
317+
if (!user) throw new NotFoundException(`User with ID ${userId} not found`);
318+
return {
319+
userId: user.id,
320+
defaultNetwork: (user.defaultNetwork as WalletNetwork) ?? null,
321+
};
322+
}
323+
324+
/** Persists the user's default network preference for future wallet operations. */
325+
async setNetworkPreference(
326+
userId: string,
327+
network: WalletNetwork,
328+
): Promise<{ userId: string; defaultNetwork: WalletNetwork }> {
329+
const user = await this.prisma.user.findUnique({ where: { id: userId } });
330+
if (!user) throw new NotFoundException(`User with ID ${userId} not found`);
331+
332+
const updated = await this.prisma.user.update({
333+
where: { id: userId },
334+
data: { defaultNetwork: network },
335+
});
336+
337+
this.logger.log(`Set network preference for user ${userId} to ${network}`);
338+
return {
339+
userId: updated.id,
340+
defaultNetwork: updated.defaultNetwork as WalletNetwork,
341+
};
342+
}
343+
311344
async findAll(filters?: WalletListFilters): Promise<WalletListResult> {
312345
const where: Record<string, unknown> = {};
313346

0 commit comments

Comments
 (0)