Skip to content

Commit 7e14383

Browse files
Merge pull request #1463 from bobaivigitalpoint-ui/bobaivigitalpoint-ui/userapi-1347-1350
test(users): UsersService unit tests and controller integration tests; structured logging and error handling
2 parents 6415e92 + b20394a commit 7e14383

7 files changed

Lines changed: 486 additions & 16 deletions

File tree

‎docs/api/users-error-handling.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Users API error handling
2+
3+
`GET /users/:id` returned `200` with an empty body when the id was unknown or the account was deactivated, so clients could not tell "no such user" from success. It now responds `404 User not found`, matching the other lookups (`me/profile`, statistics). Authorization is unchanged: the route is still admin-only, so the response does not widen what a caller can learn.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Users API structured logging
2+
3+
`UsersService` now uses the project's `AppLogger`. Events are logged as a short constant message plus a metadata object with a stable `event` name, so the JSON logs produced in production can be filtered and alerted on:
4+
5+
| `event` | Level | Fields |
6+
| --- | --- | --- |
7+
| `users.created` | log | `userId`, `emailHash` |
8+
| `users.blocked` | warn | `userId` |
9+
| `users.unblocked` | log | `userId` |
10+
| `users.deleted` | warn | `userId` |
11+
| `users.deactivated` | log | `userId`, `emailHash`, `scheduledDeletionAt` |
12+
| `users.reactivation_token_generated` | log | `userId`, `emailHash` |
13+
| `users.reactivated` | log | `userId`, `emailHash` |
14+
| `users.deactivated_purged` | log | `deletedCount` |
15+
| `users.export_file_deleted` | log | `file` |
16+
17+
Emails appear only as a short hash. The reactivation-token log line previously contained the raw email address; that is fixed. `AppLogger` also redacts fields such as `password`, `token` and `secret` before writing.

‎docs/api/users-tests.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Users API tests
2+
3+
- **Unit** - `src/users/users.service.spec.ts` covers `UsersService` with a mocked Prisma client: account creation (hashed password, secrets stripped, history/preferences seeded, referral-code collision retry, referrer linking), active-only lookups, block/unblock, verify/unverify, removal, referral stats and login history.
4+
- **Integration** - `test/e2e/users-controller.integration.spec.ts` boots `UsersController` over HTTP with the same `ValidationPipe` settings as `main.ts`, mocked services and role-aware guards: authentication and admin-role enforcement, `GET /users/:id` including the 404 case, body validation for create and update (invalid values, unknown fields), search query coercion and rejection, and the self-service `me/*` routes.
5+
6+
Run with `npx jest src/users/users.service.spec.ts test/e2e/users-controller.integration.spec.ts`.

‎src/users/users.controller.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,11 @@ export class UsersController {
179179
@ApiResponse({ status: 403, description: 'Admin role required' })
180180
@ApiResponse({ status: 404, description: 'User not found' })
181181
@Get(':id')
182-
findOne(@Param('id') id: string) {
183-
return this.usersService.findOne(id);
182+
async findOne(@Param('id') id: string) {
183+
const user = await this.usersService.findOne(id);
184+
// Previously an unknown or deactivated id produced 200 with an empty body.
185+
if (!user) throw new NotFoundException('User not found');
186+
return user;
184187
}
185188

186189
@UseGuards(JwtAuthGuard, RolesGuard)

‎src/users/users.service.spec.ts‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { PrismaService } from '../database/prisma.service';
2+
import { SessionsService } from '../sessions/sessions.service';
3+
import { AppLogger } from '../common/logger';
4+
import { UsersService } from './users.service';
5+
6+
jest.mock('../auth/security.utils', () => ({
7+
...jest.requireActual('../auth/security.utils'),
8+
// bcrypt at 12 rounds is slow and irrelevant to what is being tested.
9+
hashPassword: jest.fn(async (password: string) => `hashed:${password}`),
10+
}));
11+
12+
type Mock = jest.Mock;
13+
14+
function makePrisma() {
15+
return {
16+
user: {
17+
findUnique: jest.fn(),
18+
findFirst: jest.fn(),
19+
findMany: jest.fn(),
20+
create: jest.fn(),
21+
update: jest.fn(),
22+
delete: jest.fn(),
23+
count: jest.fn(),
24+
},
25+
loginHistory: { findMany: jest.fn() },
26+
} as unknown as { user: Record<string, Mock>; loginHistory: Record<string, Mock> };
27+
}
28+
29+
describe('UsersService', () => {
30+
let prisma: ReturnType<typeof makePrisma>;
31+
let service: UsersService;
32+
33+
beforeEach(() => {
34+
jest.spyOn(AppLogger.prototype, 'log').mockImplementation(() => undefined);
35+
jest.spyOn(AppLogger.prototype, 'warn').mockImplementation(() => undefined);
36+
prisma = makePrisma();
37+
service = new UsersService(
38+
prisma as unknown as PrismaService,
39+
{ revokeAllSessions: jest.fn() } as unknown as SessionsService,
40+
);
41+
});
42+
43+
afterEach(() => jest.restoreAllMocks());
44+
45+
describe('create', () => {
46+
const dto = {
47+
email: 'new@example.com',
48+
password: 'Str0ng!Pass',
49+
firstName: 'Ada',
50+
lastName: 'Lovelace',
51+
};
52+
53+
beforeEach(() => {
54+
prisma.user.findUnique.mockResolvedValue(null); // referral code is free, no referrer
55+
prisma.user.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
56+
id: 'u1',
57+
...data,
58+
twoFactorSecret: 'secret',
59+
}));
60+
});
61+
62+
it('stores a hash, never the plain password, and strips secrets from the result', async () => {
63+
const result = await service.create(dto);
64+
65+
const data = prisma.user.create.mock.calls[0][0].data;
66+
expect(data.password).toBe('hashed:Str0ng!Pass');
67+
expect(result).not.toHaveProperty('password');
68+
expect(result).not.toHaveProperty('twoFactorSecret');
69+
expect(result).toMatchObject({ id: 'u1', email: 'new@example.com' });
70+
});
71+
72+
it('seeds password history and default notification preferences', async () => {
73+
await service.create(dto);
74+
75+
const data = prisma.user.create.mock.calls[0][0].data;
76+
expect(data.passwordHistory).toEqual({ create: { passwordHash: 'hashed:Str0ng!Pass' } });
77+
expect(data.preferences.create).toMatchObject({
78+
emailNotifications: true,
79+
inAppNotifications: true,
80+
smsNotifications: false,
81+
});
82+
});
83+
84+
it('generates a 6-character upper-case referral code', async () => {
85+
await service.create(dto);
86+
expect(prisma.user.create.mock.calls[0][0].data.referralCode).toMatch(/^[0-9A-Z]{1,6}$/);
87+
});
88+
89+
it('retries until the generated referral code is unused', async () => {
90+
prisma.user.findUnique
91+
.mockResolvedValueOnce({ id: 'taken' }) // first code collides
92+
.mockResolvedValueOnce(null); // second is free
93+
94+
await service.create(dto);
95+
96+
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
97+
});
98+
99+
it('links the new user to the referrer when a valid referral code is given', async () => {
100+
prisma.user.findUnique
101+
.mockResolvedValueOnce(null) // generated code is free
102+
.mockResolvedValueOnce({ id: 'referrer-1' }); // supplied code resolves
103+
104+
await service.create({ ...dto, referralCode: 'FRIEND' });
105+
106+
expect(prisma.user.findUnique).toHaveBeenLastCalledWith({ where: { referralCode: 'FRIEND' } });
107+
expect(prisma.user.create.mock.calls[0][0].data.referredById).toBe('referrer-1');
108+
});
109+
110+
it('ignores an unknown referral code', async () => {
111+
await service.create({ ...dto, referralCode: 'NOPE' });
112+
expect(prisma.user.create.mock.calls[0][0].data.referredById).toBeNull();
113+
});
114+
});
115+
116+
describe('lookups', () => {
117+
it('findOne only returns active users and selects public fields', async () => {
118+
prisma.user.findFirst.mockResolvedValue({ id: 'u1' });
119+
120+
await expect(service.findOne('u1')).resolves.toEqual({ id: 'u1' });
121+
122+
const args = prisma.user.findFirst.mock.calls[0][0];
123+
expect(args.where).toEqual({ id: 'u1', isDeactivated: false });
124+
expect(args.select).not.toHaveProperty('password');
125+
expect(args.select).toMatchObject({ email: true, role: true });
126+
});
127+
128+
it('findOne returns null for an unknown or deactivated user', async () => {
129+
prisma.user.findFirst.mockResolvedValue(null);
130+
await expect(service.findOne('missing')).resolves.toBeNull();
131+
});
132+
133+
it('findByEmail looks up by exact email', async () => {
134+
prisma.user.findUnique.mockResolvedValue({ id: 'u1' });
135+
await service.findByEmail('a@example.com');
136+
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: 'a@example.com' } });
137+
});
138+
});
139+
140+
describe('admin state changes', () => {
141+
beforeEach(() => {
142+
prisma.user.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => data);
143+
});
144+
145+
it('block / unblock toggle isBlocked', async () => {
146+
await service.block('u1');
147+
await service.unblock('u1');
148+
149+
expect(prisma.user.update.mock.calls[0][0]).toMatchObject({
150+
where: { id: 'u1' },
151+
data: { isBlocked: true },
152+
});
153+
expect(prisma.user.update.mock.calls[1][0]).toMatchObject({ data: { isBlocked: false } });
154+
});
155+
156+
it('verify / unverify toggle isVerified and select only id and isVerified', async () => {
157+
await service.verify('u1');
158+
await service.unverify('u1');
159+
160+
expect(prisma.user.update.mock.calls[0][0]).toMatchObject({
161+
data: { isVerified: true },
162+
select: { id: true, isVerified: true },
163+
});
164+
expect(prisma.user.update.mock.calls[1][0]).toMatchObject({ data: { isVerified: false } });
165+
});
166+
167+
it('remove deletes the user', async () => {
168+
prisma.user.delete.mockResolvedValue({ id: 'u1' });
169+
await expect(service.remove('u1')).resolves.toEqual({ id: 'u1' });
170+
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u1' } });
171+
});
172+
});
173+
174+
describe('referrals', () => {
175+
it('getReferralStats combines the referral code with the referral count', async () => {
176+
prisma.user.count.mockResolvedValue(3);
177+
prisma.user.findUnique.mockResolvedValue({ referralCode: 'ABC123' });
178+
179+
await expect(service.getReferralStats('u1')).resolves.toEqual({
180+
referralCode: 'ABC123',
181+
referralCount: 3,
182+
});
183+
expect(prisma.user.count).toHaveBeenCalledWith({ where: { referredById: 'u1' } });
184+
});
185+
186+
it('getReferralStats tolerates a missing user', async () => {
187+
prisma.user.count.mockResolvedValue(0);
188+
prisma.user.findUnique.mockResolvedValue(null);
189+
await expect(service.getReferralStats('ghost')).resolves.toEqual({
190+
referralCode: undefined,
191+
referralCount: 0,
192+
});
193+
});
194+
195+
it('getMyReferrals selects only non-sensitive fields', async () => {
196+
prisma.user.findMany.mockResolvedValue([]);
197+
await service.getMyReferrals('u1');
198+
199+
const args = prisma.user.findMany.mock.calls[0][0];
200+
expect(args.where).toEqual({ referredById: 'u1' });
201+
expect(Object.keys(args.select).sort()).toEqual(['createdAt', 'email', 'firstName', 'id', 'lastName']);
202+
});
203+
});
204+
205+
describe('getLoginHistory', () => {
206+
it('returns the caller\'s logins newest first without extra fields', async () => {
207+
prisma.loginHistory.findMany.mockResolvedValue([{ timestamp: new Date(), ipAddress: '1.1.1.1' }]);
208+
209+
await service.getLoginHistory('u1');
210+
211+
const args = prisma.loginHistory.findMany.mock.calls[0][0];
212+
expect(args.where).toEqual({ userId: 'u1' });
213+
expect(args.orderBy).toEqual({ timestamp: 'desc' });
214+
expect(args.select).toEqual({ timestamp: true, ipAddress: true, userAgent: true });
215+
});
216+
});
217+
});

‎src/users/users.service.ts‎

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
Injectable,
3-
Logger,
43
OnModuleInit,
54
Optional,
65
NotFoundException,
@@ -29,7 +28,7 @@ import { userRegistrationsTotal } from '../metrics/metrics.controller';
2928

3029
@Injectable()
3130
export class UsersService implements OnModuleInit {
32-
private readonly logger = new Logger(UsersService.name);
31+
private readonly logger = new AppLogger(UsersService.name);
3332

3433
constructor(
3534
private readonly prisma: PrismaService,
@@ -207,7 +206,7 @@ export class UsersService implements OnModuleInit {
207206

208207
if (now - stats.mtimeMs > expirationTime) {
209208
fs.unlinkSync(filepath);
210-
this.logger.log(`Deleted expired export file: ${file}`);
209+
this.logger.log('Deleted expired export file', { event: 'users.export_file_deleted', file });
211210
}
212211
});
213212
}
@@ -307,6 +306,7 @@ export class UsersService implements OnModuleInit {
307306
});
308307

309308
userRegistrationsTotal.inc({ method: 'email' });
309+
this.logger.log('User created', { event: 'users.created', userId: user.id, emailHash: redactEmail(user.email) });
310310

311311
return sanitizeUser(user);
312312
}
@@ -433,7 +433,7 @@ export class UsersService implements OnModuleInit {
433433
}
434434

435435
async block(id: string) {
436-
return this.prisma.user.update({
436+
const blocked = await this.prisma.user.update({
437437
where: { id },
438438
data: { isBlocked: true },
439439
select: {
@@ -442,10 +442,12 @@ export class UsersService implements OnModuleInit {
442442
isBlocked: true,
443443
},
444444
});
445+
this.logger.warn('User blocked', { event: 'users.blocked', userId: id });
446+
return blocked;
445447
}
446448

447449
async unblock(id: string) {
448-
return this.prisma.user.update({
450+
const unblocked = await this.prisma.user.update({
449451
where: { id },
450452
data: { isBlocked: false },
451453
select: {
@@ -454,12 +456,16 @@ export class UsersService implements OnModuleInit {
454456
isBlocked: true,
455457
},
456458
});
459+
this.logger.log('User unblocked', { event: 'users.unblocked', userId: id });
460+
return unblocked;
457461
}
458462

459463
async remove(id: string) {
460-
return this.prisma.user.delete({
464+
const removed = await this.prisma.user.delete({
461465
where: { id },
462466
});
467+
this.logger.warn('User deleted', { event: 'users.deleted', userId: id });
468+
return removed;
463469
}
464470

465471
async exportPersonalData(id: string) {
@@ -562,11 +568,12 @@ export class UsersService implements OnModuleInit {
562568
},
563569
});
564570

565-
this.logger.log(
566-
`User ${userId} (${redactEmail(user.email)}) deactivated. Scheduled deletion: ${
567-
scheduledDeletionAt ? scheduledDeletionAt.toISOString() : 'None'
568-
}`,
569-
);
571+
this.logger.log('User deactivated', {
572+
event: 'users.deactivated',
573+
userId,
574+
emailHash: redactEmail(user.email),
575+
scheduledDeletionAt: scheduledDeletionAt ? scheduledDeletionAt.toISOString() : null,
576+
});
570577

571578
await this.sessionsService.revokeAllSessions(userId);
572579

@@ -597,7 +604,11 @@ export class UsersService implements OnModuleInit {
597604
},
598605
});
599606

600-
this.logger.log(`Reactivation token generated for user ${userId} (${user.email})`);
607+
this.logger.log('Reactivation token generated', {
608+
event: 'users.reactivation_token_generated',
609+
userId,
610+
emailHash: redactEmail(user.email),
611+
});
601612

602613
return {
603614
message: 'Reactivation token generated. Use the token to reactivate your account.',
@@ -677,7 +688,11 @@ export class UsersService implements OnModuleInit {
677688
},
678689
});
679690

680-
this.logger.log(`User ${userId} (${redactEmail(user.email)}) reactivated`);
691+
this.logger.log('User reactivated', {
692+
event: 'users.reactivated',
693+
userId,
694+
emailHash: redactEmail(user.email),
695+
});
681696

682697
return updatedUser;
683698
}
@@ -833,7 +848,10 @@ export class UsersService implements OnModuleInit {
833848
});
834849
});
835850

836-
this.logger.log(`Deleted ${result.count} deactivated users`);
851+
this.logger.log('Deleted deactivated users', {
852+
event: 'users.deactivated_purged',
853+
deletedCount: result.count,
854+
});
837855

838856
return { deletedCount: result.count };
839857
}

0 commit comments

Comments
 (0)