Skip to content

Commit eb75e9b

Browse files
committed
Reapply "feat: user profiles"
This reverts commit cbcb79f.
1 parent 1e7eea7 commit eb75e9b

30 files changed

Lines changed: 1556 additions & 37 deletions

apps/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { EmailLoginModule } from './email-login/email-login.module';
1313
import { FileModule } from './file/file.module';
1414
import { ParseTokenPipe } from './lib/parseToken';
1515
import { MailingModule } from './mailing/mailing.module';
16+
import { ProfileModule } from './profile/profile.module';
1617
import { SeedModule } from './seed/seed.module';
1718
import { SongModule } from './song/song.module';
1819
import { UserModule } from './user/user.module';
@@ -74,6 +75,7 @@ import { UserModule } from './user/user.module';
7475
]),
7576
SongModule,
7677
UserModule,
78+
ProfileModule,
7779
AuthModule.forRootAsync(),
7880
FileModule.forRootAsync(),
7981
SeedModule.forRoot(),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Body, Controller, Get, Inject, Param, Patch } from '@nestjs/common';
2+
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
3+
4+
import type { UserDocument } from '@nbw/database';
5+
import type { PublicProfileDto } from '@nbw/validation';
6+
import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser';
7+
import { PatchProfileBodyDto, ProfileUsernameParamDto } from '@server/zod-dto';
8+
9+
import { ProfileService } from './profile.service';
10+
11+
@Controller('profile')
12+
export class ProfileController {
13+
constructor(
14+
@Inject(ProfileService)
15+
private readonly profileService: ProfileService,
16+
) {}
17+
18+
@Patch()
19+
@ApiTags('profile')
20+
@ApiBearerAuth()
21+
@ApiOperation({
22+
summary: 'Update the authenticated user profile (Profile document)',
23+
})
24+
public async patchProfile(
25+
@GetRequestToken() user: UserDocument | null,
26+
@Body() body: PatchProfileBodyDto,
27+
): Promise<PublicProfileDto> {
28+
user = validateUser(user);
29+
return await this.profileService.patchProfile(user, body);
30+
}
31+
32+
@Get('u/:username')
33+
@ApiTags('profile')
34+
@ApiOperation({
35+
summary:
36+
'Get public profile by normalized username (path matches User.username)',
37+
})
38+
public async getPublicProfile(
39+
@Param() params: ProfileUsernameParamDto,
40+
): Promise<PublicProfileDto> {
41+
return await this.profileService.getMergedPublicProfileByUsername(
42+
params.username,
43+
);
44+
}
45+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Module } from '@nestjs/common';
2+
import { MongooseModule } from '@nestjs/mongoose';
3+
4+
import { Profile, ProfileSchema } from '@nbw/database';
5+
6+
import { UserModule } from '@server/user/user.module';
7+
8+
import { ProfileController } from './profile.controller';
9+
import { ProfileService } from './profile.service';
10+
11+
@Module({
12+
imports: [
13+
MongooseModule.forFeature([{ name: Profile.name, schema: ProfileSchema }]),
14+
UserModule,
15+
],
16+
controllers: [ProfileController],
17+
providers: [ProfileService],
18+
exports: [ProfileService],
19+
})
20+
export class ProfileModule {}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { getModelToken } from '@nestjs/mongoose';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
import mongoose from 'mongoose';
4+
5+
import { Profile, type UserDocument } from '@nbw/database';
6+
import type { PatchProfileBody } from '@nbw/validation';
7+
import { UserService } from '@server/user/user.service';
8+
9+
import {
10+
mergeSocialLinks,
11+
ProfileService,
12+
sanitizeSocialLinksForPublic,
13+
toPublicProfileDto,
14+
} from './profile.service';
15+
16+
describe('ProfileService helpers', () => {
17+
it('sanitizeSocialLinksForPublic removes Mongo _id and keeps only known string URLs', () => {
18+
expect(
19+
sanitizeSocialLinksForPublic({
20+
_id: '67cf5fd96a6e68a6aa291d2a',
21+
github: 'https://github.com/x',
22+
discord: '',
23+
} as Record<string, unknown>),
24+
).toEqual({ github: 'https://github.com/x' });
25+
});
26+
27+
it('mergeSocialLinks overlays profile keys onto user', () => {
28+
expect(
29+
mergeSocialLinks(
30+
{ github: 'https://a.com', x: 'https://x.com/u' },
31+
{ github: 'https://b.com' },
32+
),
33+
).toEqual({ github: 'https://b.com', x: 'https://x.com/u' });
34+
});
35+
36+
it('toPublicProfileDto uses User when Profile is null', () => {
37+
const user = {
38+
_id: new mongoose.Types.ObjectId(),
39+
username: 'u',
40+
publicName: 'U',
41+
profileImage: '/img.png',
42+
description: 'from user',
43+
socialLinks: { github: 'https://gh' },
44+
} as unknown as UserDocument;
45+
46+
const dto = toPublicProfileDto(user, null);
47+
expect(dto.description).toBe('from user');
48+
expect(dto.socialLinks.github).toBe('https://gh');
49+
});
50+
51+
it('toPublicProfileDto prefers Profile description when document exists', () => {
52+
const user = {
53+
_id: new mongoose.Types.ObjectId(),
54+
username: 'u',
55+
publicName: 'U',
56+
profileImage: '/img.png',
57+
description: 'from user',
58+
socialLinks: { github: 'https://user-gh' },
59+
} as unknown as UserDocument;
60+
61+
const profile = {
62+
description: 'from profile',
63+
socialLinks: { x: 'https://x.com' },
64+
} as any;
65+
66+
const dto = toPublicProfileDto(user, profile);
67+
expect(dto.description).toBe('from profile');
68+
expect(dto.socialLinks).toEqual({
69+
github: 'https://user-gh',
70+
x: 'https://x.com',
71+
});
72+
});
73+
});
74+
75+
describe('ProfileService', () => {
76+
let service: ProfileService;
77+
const mockUserService = {
78+
findByID: jest.fn(),
79+
findByUsername: jest.fn(),
80+
normalizeUsername: jest.fn((s: string) => s),
81+
update: jest.fn(),
82+
};
83+
84+
const mockProfileModel = {
85+
findOne: jest.fn(),
86+
create: jest.fn(),
87+
};
88+
89+
beforeEach(async () => {
90+
jest.clearAllMocks();
91+
const module: TestingModule = await Test.createTestingModule({
92+
providers: [
93+
ProfileService,
94+
{
95+
provide: getModelToken(Profile.name),
96+
useValue: mockProfileModel,
97+
},
98+
{
99+
provide: UserService,
100+
useValue: mockUserService,
101+
},
102+
],
103+
}).compile();
104+
105+
service = module.get(ProfileService);
106+
});
107+
108+
it('getMergedPublicProfile throws when user missing', async () => {
109+
mockUserService.findByID.mockResolvedValue(null);
110+
await expect(
111+
service.getMergedPublicProfile(new mongoose.Types.ObjectId().toString()),
112+
).rejects.toThrow('User not found');
113+
});
114+
115+
it('getMergedPublicProfile returns merged dto', async () => {
116+
const id = new mongoose.Types.ObjectId();
117+
mockUserService.findByID.mockResolvedValue({
118+
_id: id,
119+
username: 'a',
120+
publicName: 'A',
121+
profileImage: '/p.jpg',
122+
description: 'bio',
123+
socialLinks: {},
124+
});
125+
mockProfileModel.findOne.mockReturnValue({
126+
exec: jest.fn().mockResolvedValue(null),
127+
});
128+
129+
const dto = await service.getMergedPublicProfile(id.toString());
130+
expect(dto.username).toBe('a');
131+
expect(dto.id).toBe(id.toString());
132+
});
133+
134+
it('getMergedPublicProfileByUsername normalizes and loads by username', async () => {
135+
const id = new mongoose.Types.ObjectId();
136+
const userDoc = {
137+
_id: id,
138+
username: 'alice',
139+
publicName: 'Alice',
140+
profileImage: '/p.jpg',
141+
description: 'bio',
142+
socialLinks: {},
143+
};
144+
mockUserService.normalizeUsername.mockReturnValue('alice');
145+
mockUserService.findByUsername.mockResolvedValue(userDoc);
146+
mockProfileModel.findOne.mockReturnValue({
147+
exec: jest.fn().mockResolvedValue(null),
148+
});
149+
150+
const dto = await service.getMergedPublicProfileByUsername('alice');
151+
expect(dto.username).toBe('alice');
152+
expect(mockUserService.normalizeUsername).toHaveBeenCalledWith('alice');
153+
expect(mockUserService.findByUsername).toHaveBeenCalledWith('alice');
154+
});
155+
156+
it('getMergedPublicProfileByUsername throws when username unknown', async () => {
157+
mockUserService.normalizeUsername.mockReturnValue('nobody');
158+
mockUserService.findByUsername.mockResolvedValue(null);
159+
await expect(
160+
service.getMergedPublicProfileByUsername('nobody'),
161+
).rejects.toThrow('User not found');
162+
});
163+
164+
it('patchProfile updates publicName via UserService.update', async () => {
165+
const id = new mongoose.Types.ObjectId();
166+
const userDoc = {
167+
_id: id,
168+
username: 'alice',
169+
publicName: 'Old',
170+
profileImage: '/p.jpg',
171+
description: '',
172+
socialLinks: {},
173+
} as UserDocument;
174+
175+
mockUserService.findByID.mockResolvedValue(userDoc);
176+
mockUserService.update.mockImplementation(async (u: UserDocument) => u);
177+
mockProfileModel.findOne.mockReturnValue({
178+
exec: jest.fn().mockResolvedValue(null),
179+
});
180+
181+
const body: PatchProfileBody = { publicName: 'New Name' };
182+
const dto = await service.patchProfile(userDoc, body);
183+
184+
expect(dto.publicName).toBe('New Name');
185+
expect(mockUserService.update).toHaveBeenCalled();
186+
});
187+
188+
it('patchProfile no-op body returns current profile', async () => {
189+
const id = new mongoose.Types.ObjectId();
190+
const userDoc = {
191+
_id: id,
192+
username: 'alice',
193+
publicName: 'Alice',
194+
profileImage: '/p.jpg',
195+
description: '',
196+
socialLinks: {},
197+
} as UserDocument;
198+
199+
mockUserService.findByID.mockResolvedValue(userDoc);
200+
mockProfileModel.findOne.mockReturnValue({
201+
exec: jest.fn().mockResolvedValue(null),
202+
});
203+
204+
const dto = await service.patchProfile(userDoc, {});
205+
206+
expect(dto.publicName).toBe('Alice');
207+
expect(mockUserService.update).not.toHaveBeenCalled();
208+
});
209+
});

0 commit comments

Comments
 (0)