feat(server): add auth, user, and studio modules

Auth: WeChat login, JWT, roles guard (24 tests passing)
User: profile CRUD, training stats with month/total calculations
Studio: config management with auto-default creation
This commit is contained in:
richarjiang
2026-04-02 12:12:18 +08:00
parent e653580155
commit a1a91f96d8
23 changed files with 1284 additions and 0 deletions

View File

@@ -2,6 +2,9 @@ import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { AppController } from './app.controller'
import { PrismaModule } from './prisma/prisma.module'
import { UserModule } from './user/user.module'
import { AuthModule } from './auth/auth.module'
import { StudioModule } from './studio/studio.module'
@Module({
imports: [
@@ -10,6 +13,9 @@ import { PrismaModule } from './prisma/prisma.module'
envFilePath: ['.env.local', '.env'],
}),
PrismaModule,
AuthModule,
UserModule,
StudioModule,
],
controllers: [AppController],
})

View File

@@ -0,0 +1,220 @@
import { Test, TestingModule } from '@nestjs/testing'
import { JwtService } from '@nestjs/jwt'
import { UnauthorizedException } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { AuthService } from '../auth.service'
import { WechatService } from '../wechat.service'
import { PrismaService } from '../../prisma/prisma.service'
// ─── Fixtures ────────────────────────────────────────────────────────────────
const OPENID = 'test_openid_123'
const SESSION_KEY = 'test_session_key'
const USER_ID = 'user-uuid-001'
const JWT_TOKEN = 'signed.jwt.token'
const mockUser = {
id: USER_ID,
openid: OPENID,
unionid: null,
phone: null,
nickname: '',
avatarUrl: null,
role: UserRole.MEMBER,
createdAt: new Date(),
updatedAt: new Date(),
}
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockPrismaService = {
user: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
}
const mockWechatService = {
code2Session: jest.fn(),
decryptData: jest.fn(),
}
const mockJwtService = {
sign: jest.fn(),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AuthService', () => {
let authService: AuthService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: WechatService, useValue: mockWechatService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile()
authService = module.get<AuthService>(AuthService)
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
})
// ── login ──────────────────────────────────────────────────────────────────
describe('login', () => {
const loginCode = 'wx_login_code_abc'
beforeEach(() => {
mockWechatService.code2Session.mockResolvedValue({
openid: OPENID,
sessionKey: SESSION_KEY,
})
})
it('creates a new user when openid is not found', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null)
mockPrismaService.user.create.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(mockWechatService.code2Session).toHaveBeenCalledWith(loginCode)
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID },
})
expect(result.user).toEqual(mockUser)
})
it('creates user with unionid when present', async () => {
const unionid = 'wx_union_id_xyz'
mockWechatService.code2Session.mockResolvedValue({
openid: OPENID,
sessionKey: SESSION_KEY,
unionid,
})
mockPrismaService.user.findUnique.mockResolvedValue(null)
mockPrismaService.user.create.mockResolvedValue({ ...mockUser, unionid })
await authService.login(loginCode)
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, unionid },
})
})
it('returns existing user when openid already exists', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
expect(result.user).toEqual(mockUser)
})
it('returns a valid JWT token', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(mockJwtService.sign).toHaveBeenCalledWith({
sub: USER_ID,
role: UserRole.MEMBER,
})
expect(result.token).toBe(JWT_TOKEN)
})
it('returns both token and user in result', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(result).toEqual({
token: JWT_TOKEN,
user: mockUser,
})
})
})
// ── bindPhone ──────────────────────────────────────────────────────────────
describe('bindPhone', () => {
const encryptedData = 'encrypted_phone_data'
const iv = 'init_vector'
const phoneNumber = '+8613800138000'
beforeEach(async () => {
// Seed the in-memory session key store by running login first
mockWechatService.code2Session.mockResolvedValue({
openid: OPENID,
sessionKey: SESSION_KEY,
})
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
await authService.login('login_code')
})
it('updates phone number and returns the updated user', async () => {
const updatedUser = { ...mockUser, phone: phoneNumber }
mockWechatService.decryptData.mockReturnValue({
phoneNumber,
purePhoneNumber: '13800138000',
countryCode: '86',
})
mockPrismaService.user.update.mockResolvedValue(updatedUser)
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
expect(mockWechatService.decryptData).toHaveBeenCalledWith(
SESSION_KEY,
encryptedData,
iv,
)
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { id: USER_ID },
data: { phone: phoneNumber },
})
expect(result).toEqual(updatedUser)
})
it('throws UnauthorizedException when session key is not found', async () => {
const unknownUserId = 'unknown-user-id'
await expect(
authService.bindPhone(unknownUserId, encryptedData, iv),
).rejects.toThrow(UnauthorizedException)
})
it('does not mutate the original user object', async () => {
const originalUser = { ...mockUser }
const updatedUser = { ...mockUser, phone: phoneNumber }
mockWechatService.decryptData.mockReturnValue({
phoneNumber,
purePhoneNumber: '13800138000',
countryCode: '86',
})
mockPrismaService.user.update.mockResolvedValue(updatedUser)
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
// Original mock user should be unchanged
expect(mockUser.phone).toBeNull()
// Result is a new object with the updated phone
expect(result.phone).toBe(phoneNumber)
expect(result).not.toBe(originalUser)
})
})
})

View File

@@ -0,0 +1,44 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { BindPhoneDto } from './dto/bind-phone.dto'
import { JwtAuthGuard } from './jwt-auth.guard'
import { AuthenticatedUser } from './jwt.strategy'
import { User } from '@prisma/client'
interface AuthenticatedRequest {
user: AuthenticatedUser
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
return this.authService.login(loginDto.code)
}
@Post('phone')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async bindPhone(
@Request() req: AuthenticatedRequest,
@Body() bindPhoneDto: BindPhoneDto,
): Promise<User> {
return this.authService.bindPhone(
req.user.userId,
bindPhoneDto.encryptedData,
bindPhoneDto.iv,
)
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { WechatService } from './wechat.service'
import { JwtStrategy } from './jwt.strategy'
import { JwtAuthGuard } from './jwt-auth.guard'
import { RolesGuard } from './roles.guard'
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '7d' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, WechatService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [JwtStrategy, JwtAuthGuard, RolesGuard, AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,82 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
import { UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
export interface LoginResult {
token: string
user: User
}
export interface JwtPayload {
sub: string
role: UserRole
}
/**
* In-memory session key store.
* TODO: Replace with Redis for production multi-instance deployments.
* Key: userId, Value: WeChat sessionKey
*/
const sessionKeyStore = new Map<string, string>()
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly wechatService: WechatService,
) {}
async login(code: string): Promise<LoginResult> {
const { openid, unionid, sessionKey } =
await this.wechatService.code2Session(code)
const existingUser = await this.prisma.user.findUnique({
where: { openid },
})
const user =
existingUser ??
(await this.prisma.user.create({
data: {
openid,
...(unionid !== undefined && { unionid }),
},
}))
sessionKeyStore.set(user.id, sessionKey)
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user }
}
async bindPhone(
userId: string,
encryptedData: string,
iv: string,
): Promise<User> {
const sessionKey = sessionKeyStore.get(userId)
if (!sessionKey) {
throw new UnauthorizedException(
'Session expired. Please log in again to bind your phone number.',
)
}
const phoneInfo = this.wechatService.decryptData(
sessionKey,
encryptedData,
iv,
)
return this.prisma.user.update({
where: { id: userId },
data: { phone: phoneInfo.phoneNumber },
})
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsNotEmpty } from 'class-validator'
export class BindPhoneDto {
@IsString()
@IsNotEmpty()
encryptedData!: string
@IsString()
@IsNotEmpty()
iv!: string
}

View File

@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator'
export class LoginDto {
@IsString()
@IsNotEmpty()
code!: string
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { ConfigService } from '@nestjs/config'
import { UserRole } from '@mp-pilates/shared'
import { JwtPayload } from './auth.service'
export interface AuthenticatedUser {
userId: string
role: UserRole
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
})
}
validate(payload: JwtPayload): AuthenticatedUser {
return {
userId: payload.sub,
role: payload.role,
}
}
}

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
export const ROLES_KEY = 'roles'
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)

View File

@@ -0,0 +1,33 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { UserRole } from '@mp-pilates/shared'
import { ROLES_KEY } from './roles.decorator'
import { AuthenticatedUser } from './jwt.strategy'
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
)
if (!requiredRoles || requiredRoles.length === 0) {
return true
}
const request = context.switchToHttp().getRequest<{
user?: AuthenticatedUser
}>()
const user = request.user
if (!user) {
return false
}
return requiredRoles.includes(user.role)
}
}

View File

@@ -0,0 +1,107 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import * as crypto from 'crypto'
export interface Code2SessionResult {
openid: string
sessionKey: string
unionid?: string
}
export interface WechatPhoneInfo {
phoneNumber: string
purePhoneNumber: string
countryCode: string
}
@Injectable()
export class WechatService {
private readonly appId: string
private readonly secret: string
constructor(private readonly configService: ConfigService) {
this.appId = this.configService.getOrThrow<string>('WX_APPID')
this.secret = this.configService.getOrThrow<string>('WX_SECRET')
}
async code2Session(code: string): Promise<Code2SessionResult> {
const url =
`https://api.weixin.qq.com/sns/jscode2session` +
`?appid=${this.appId}` +
`&secret=${this.secret}` +
`&js_code=${code}` +
`&grant_type=authorization_code`
const response = await fetch(url)
if (!response.ok) {
throw new HttpException(
'WeChat API request failed',
HttpStatus.BAD_GATEWAY,
)
}
const data = (await response.json()) as {
openid?: string
session_key?: string
unionid?: string
errcode?: number
errmsg?: string
}
if (data.errcode) {
throw new HttpException(
`WeChat login error: ${data.errmsg ?? data.errcode}`,
HttpStatus.UNAUTHORIZED,
)
}
if (!data.openid || !data.session_key) {
throw new HttpException(
'Invalid response from WeChat API',
HttpStatus.BAD_GATEWAY,
)
}
return {
openid: data.openid,
sessionKey: data.session_key,
...(data.unionid !== undefined && { unionid: data.unionid }),
}
}
decryptData(
sessionKey: string,
encryptedData: string,
iv: string,
): WechatPhoneInfo {
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64')
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64')
const ivBuffer = Buffer.from(iv, 'base64')
const decipher = crypto.createDecipheriv(
'aes-128-cbc',
sessionKeyBuffer,
ivBuffer,
)
decipher.setAutoPadding(true)
const decryptedBuffer = Buffer.concat([
decipher.update(encryptedDataBuffer),
decipher.final(),
])
const decryptedText = decryptedBuffer.toString('utf8')
const decryptedData = JSON.parse(decryptedText) as {
phoneNumber: string
purePhoneNumber: string
countryCode: string
}
return {
phoneNumber: decryptedData.phoneNumber,
purePhoneNumber: decryptedData.purePhoneNumber,
countryCode: decryptedData.countryCode,
}
}
}

View File

@@ -0,0 +1,17 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
/**
* Extract a field from the JWT payload attached by JwtAuthGuard.
* JWT payload shape: { sub: userId, role: userRole }
*
* Usage:
* @CurrentUser('sub') userId: string ← specific field
* @CurrentUser() user: JwtPayload ← full payload
*/
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest()
const user = request.user as Record<string, unknown> | undefined
return data ? user?.[data] : user
},
)

View File

@@ -0,0 +1,109 @@
import { Test, TestingModule } from '@nestjs/testing'
import { StudioService } from '../studio.service'
import { PrismaService } from '../../prisma/prisma.service'
import { UpdateStudioDto } from '../dto/update-studio.dto'
const mockStudioConfig = {
id: 'test-id-001',
name: '普拉提工作室',
logo: null,
bannerUrl: null,
address: '',
phone: '',
latitude: null,
longitude: null,
cancelHoursLimit: 2,
photos: [],
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
const mockPrismaService = {
studioConfig: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
}
describe('StudioService', () => {
let service: StudioService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
StudioService,
{ provide: PrismaService, useValue: mockPrismaService },
],
}).compile()
service = module.get<StudioService>(StudioService)
jest.clearAllMocks()
})
describe('getInfo()', () => {
it('should return existing config when one exists', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
const result = await service.getInfo()
expect(result).toEqual(mockStudioConfig)
expect(mockPrismaService.studioConfig.findFirst).toHaveBeenCalledTimes(1)
expect(mockPrismaService.studioConfig.create).not.toHaveBeenCalled()
})
it('should create and return a default config when none exists', async () => {
const defaultConfig = { ...mockStudioConfig, name: '普拉提工作室' }
mockPrismaService.studioConfig.findFirst.mockResolvedValue(null)
mockPrismaService.studioConfig.create.mockResolvedValue(defaultConfig)
const result = await service.getInfo()
expect(result).toEqual(defaultConfig)
expect(mockPrismaService.studioConfig.findFirst).toHaveBeenCalledTimes(1)
expect(mockPrismaService.studioConfig.create).toHaveBeenCalledWith({
data: { name: '普拉提工作室' },
})
})
})
describe('updateInfo()', () => {
it('should update and return a new config object with changes applied', async () => {
const dto: UpdateStudioDto = {
name: 'New Studio Name',
address: '123 Main St',
cancelHoursLimit: 4,
}
const updatedConfig = {
...mockStudioConfig,
name: dto.name!,
address: dto.address!,
cancelHoursLimit: dto.cancelHoursLimit!,
updatedAt: new Date(),
}
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
mockPrismaService.studioConfig.update.mockResolvedValue(updatedConfig)
const result = await service.updateInfo(dto)
expect(result).toEqual(updatedConfig)
expect(mockPrismaService.studioConfig.update).toHaveBeenCalledWith({
where: { id: mockStudioConfig.id },
data: { ...dto },
})
})
it('should return a new object reference (immutable)', async () => {
const dto: UpdateStudioDto = { name: 'Updated Name' }
const updatedConfig = { ...mockStudioConfig, name: 'Updated Name' }
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
mockPrismaService.studioConfig.update.mockResolvedValue(updatedConfig)
const result = await service.updateInfo(dto)
expect(result).not.toBe(updatedConfig)
expect(result).toEqual(updatedConfig)
})
})
})

View File

@@ -0,0 +1,53 @@
import {
IsArray,
IsNumber,
IsOptional,
IsString,
IsUrl,
Max,
Min,
} from 'class-validator'
export class UpdateStudioDto {
@IsOptional()
@IsString()
name?: string
@IsOptional()
@IsString()
logo?: string
@IsOptional()
@IsString()
bannerUrl?: string
@IsOptional()
@IsString()
address?: string
@IsOptional()
@IsString()
phone?: string
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number
@IsOptional()
@IsNumber()
@Min(0)
cancelHoursLimit?: number
@IsOptional()
@IsArray()
@IsString({ each: true })
photos?: string[]
}

View File

@@ -0,0 +1,30 @@
import {
Body,
Controller,
Get,
Put,
UseGuards,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { UpdateStudioDto } from './dto/update-studio.dto'
import { StudioService } from './studio.service'
@Controller()
export class StudioController {
constructor(private readonly studioService: StudioService) {}
@Get('studio/info')
getInfo() {
return this.studioService.getInfo()
}
@Put('admin/studio/info')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
updateInfo(@Body() dto: UpdateStudioDto) {
return this.studioService.updateInfo(dto)
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { StudioController } from './studio.controller'
import { StudioService } from './studio.service'
@Module({
controllers: [StudioController],
providers: [StudioService],
exports: [StudioService],
})
export class StudioModule {}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common'
import { StudioConfig } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateStudioDto } from './dto/update-studio.dto'
@Injectable()
export class StudioService {
constructor(private readonly prisma: PrismaService) {}
async getInfo(): Promise<StudioConfig> {
const existing = await this.prisma.studioConfig.findFirst()
if (existing) {
return existing
}
return this.prisma.studioConfig.create({
data: {
name: '普拉提工作室',
},
})
}
async updateInfo(dto: UpdateStudioDto): Promise<StudioConfig> {
const existing = await this.getInfo()
const updated = await this.prisma.studioConfig.update({
where: { id: existing.id },
data: { ...dto },
})
return { ...updated }
}
}

View File

@@ -0,0 +1,275 @@
import { Test, TestingModule } from '@nestjs/testing'
import { NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service'
import { PrismaService } from '../../prisma/prisma.service'
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeUser = (overrides: Record<string, unknown> = {}) => ({
id: 'user-1',
openid: 'openid-1',
unionid: null,
phone: '13800000000',
nickname: 'Alice',
avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
_count: { memberships: 2 },
...overrides,
})
const makeBooking = (
date: Date,
startTime: string,
endTime: string,
status: BookingStatus = BookingStatus.COMPLETED,
) => ({
id: `booking-${Math.random()}`,
userId: 'user-1',
timeSlotId: `slot-${Math.random()}`,
membershipId: `membership-${Math.random()}`,
status,
cancelledAt: null,
createdAt: new Date(),
updatedAt: new Date(),
timeSlot: { date, startTime, endTime },
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
booking: {
findMany: jest.fn(),
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('UserService', () => {
let service: UserService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile()
service = module.get<UserService>(UserService)
jest.clearAllMocks()
})
// -------------------------------------------------------------------------
// getProfile
// -------------------------------------------------------------------------
describe('getProfile', () => {
it('returns a UserProfileResponse with activeMembershipCount', async () => {
const user = makeUser({ _count: { memberships: 3 } })
mockPrisma.user.findUnique.mockResolvedValue(user)
const result = await service.getProfile('user-1')
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
},
},
},
})
expect(result).toEqual({
id: 'user-1',
phone: '13800000000',
nickname: 'Alice',
avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER,
activeMembershipCount: 3,
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
})
})
it('throws NotFoundException when user does not exist', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null)
await expect(service.getProfile('unknown')).rejects.toThrow(NotFoundException)
})
})
// -------------------------------------------------------------------------
// updateProfile
// -------------------------------------------------------------------------
describe('updateProfile', () => {
it('updates nickname and avatarUrl, returns new UserProfileResponse', async () => {
const updated = makeUser({
nickname: 'Bob',
avatarUrl: 'https://example.com/new.png',
_count: { memberships: 1 },
})
mockPrisma.user.update.mockResolvedValue(updated)
const result = await service.updateProfile('user-1', {
nickname: 'Bob',
avatarUrl: 'https://example.com/new.png',
})
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { nickname: 'Bob', avatarUrl: 'https://example.com/new.png' },
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
},
},
},
})
expect(result.nickname).toBe('Bob')
expect(result.avatarUrl).toBe('https://example.com/new.png')
expect(result.activeMembershipCount).toBe(1)
})
it('only includes provided fields in the update payload', async () => {
const updated = makeUser({ nickname: 'Charlie', _count: { memberships: 0 } })
mockPrisma.user.update.mockResolvedValue(updated)
await service.updateProfile('user-1', { nickname: 'Charlie' })
const callArgs = mockPrisma.user.update.mock.calls[0][0]
expect(callArgs.data).toEqual({ nickname: 'Charlie' })
expect(callArgs.data.avatarUrl).toBeUndefined()
})
it('returns an immutable snapshot — the original dto is not mutated', async () => {
const updated = makeUser({ _count: { memberships: 0 } })
mockPrisma.user.update.mockResolvedValue(updated)
const dto = { nickname: 'Dave' }
const originalDto = { ...dto }
await service.updateProfile('user-1', dto)
expect(dto).toEqual(originalDto)
})
})
// -------------------------------------------------------------------------
// getStats
// -------------------------------------------------------------------------
describe('getStats', () => {
/**
* Build a date in the *current* month so the month-filter logic works
* regardless of when the test is run.
*/
const thisYear = new Date().getFullYear()
const thisMonth = new Date().getMonth()
const dateInMonth = (day: number) => new Date(thisYear, thisMonth, day)
const dateLastMonth = new Date(thisYear, thisMonth - 1, 15)
it('returns zeroed stats when there are no completed bookings', async () => {
mockPrisma.booking.findMany.mockResolvedValue([])
const result = await service.getStats('user-1')
expect(result).toEqual({
totalBookings: 0,
totalDays: 0,
monthBookings: 0,
monthDays: 0,
monthHours: 0,
})
})
it('counts all completed bookings for totalBookings', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'),
makeBooking(dateLastMonth, '09:00', '10:00'),
])
const result = await service.getStats('user-1')
expect(result.totalBookings).toBe(2)
})
it('counts distinct dates for totalDays', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'),
makeBooking(dateInMonth(1), '11:00', '12:00'), // same day → still 1 distinct day
makeBooking(dateLastMonth, '09:00', '10:00'),
])
const result = await service.getStats('user-1')
expect(result.totalDays).toBe(2) // day-in-month(1) + last-month-day
})
it('only counts this-month bookings in monthBookings', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(5), '09:00', '10:00'),
makeBooking(dateInMonth(10), '09:00', '10:00'),
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
])
const result = await service.getStats('user-1')
expect(result.monthBookings).toBe(2)
})
it('counts distinct this-month dates for monthDays', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(3), '09:00', '10:00'),
makeBooking(dateInMonth(3), '11:00', '12:00'), // same day
makeBooking(dateInMonth(7), '09:00', '10:00'),
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
])
const result = await service.getStats('user-1')
expect(result.monthDays).toBe(2)
})
it('sums hours for monthHours from startTime/endTime', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'), // 1 h
makeBooking(dateInMonth(2), '14:00', '15:30'), // 1.5 h
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
])
const result = await service.getStats('user-1')
expect(result.monthHours).toBeCloseTo(2.5)
})
it('queries only COMPLETED bookings for this user', async () => {
mockPrisma.booking.findMany.mockResolvedValue([])
await service.getStats('user-1')
expect(mockPrisma.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-1', status: BookingStatus.COMPLETED },
}),
)
})
})
})

View File

@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength } from 'class-validator'
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(32)
readonly nickname?: string
@IsOptional()
@IsString()
@MaxLength(512)
readonly avatarUrl?: string
}

View File

@@ -0,0 +1,35 @@
import {
Controller,
Get,
Put,
Body,
UseGuards,
} from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { UserService } from './user.service'
import { UpdateProfileDto } from './dto/update-profile.dto'
@UseGuards(JwtAuthGuard)
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('profile')
getProfile(@CurrentUser('sub') userId: string) {
return this.userService.getProfile(userId)
}
@Put('profile')
updateProfile(
@CurrentUser('sub') userId: string,
@Body() dto: UpdateProfileDto,
) {
return this.userService.updateProfile(userId, dto)
}
@Get('stats')
getStats(@CurrentUser('sub') userId: string) {
return this.userService.getStats(userId)
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { UserController } from './user.controller'
import { UserService } from './user.service'
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

View File

@@ -0,0 +1,120 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import type { UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async getProfile(userId: string): Promise<UserProfileResponse> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
_count: {
select: {
memberships: {
where: { status: MembershipStatus.ACTIVE },
},
},
},
},
})
if (!user) {
throw new NotFoundException('User not found')
}
return {
id: user.id,
phone: user.phone,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount: user._count.memberships,
createdAt: user.createdAt.toISOString(),
}
}
async updateProfile(
userId: string,
dto: { nickname?: string; avatarUrl?: string },
): Promise<UserProfileResponse> {
const updated = await this.prisma.user.update({
where: { id: userId },
data: {
...(dto.nickname !== undefined && { nickname: dto.nickname }),
...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }),
},
include: {
_count: {
select: {
memberships: {
where: { status: MembershipStatus.ACTIVE },
},
},
},
},
})
return {
id: updated.id,
phone: updated.phone,
nickname: updated.nickname,
avatarUrl: updated.avatarUrl,
role: updated.role as UserRole,
activeMembershipCount: updated._count.memberships,
createdAt: updated.createdAt.toISOString(),
}
}
async getStats(userId: string): Promise<UserStatsResponse> {
const completedBookings = await this.prisma.booking.findMany({
where: {
userId,
status: BookingStatus.COMPLETED,
},
include: {
timeSlot: {
select: {
date: true,
startTime: true,
endTime: true,
},
},
},
})
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
const monthBookings = completedBookings.filter((b) => {
const slotDate = new Date(b.timeSlot.date)
return slotDate >= monthStart && slotDate <= monthEnd
})
const totalDays = new Set(
completedBookings.map((b) => b.timeSlot.date.toISOString().split('T')[0]),
).size
const monthDays = new Set(
monthBookings.map((b) => b.timeSlot.date.toISOString().split('T')[0]),
).size
const monthHours = monthBookings.reduce((sum, b) => {
const [startH, startM] = b.timeSlot.startTime.split(':').map(Number)
const [endH, endM] = b.timeSlot.endTime.split(':').map(Number)
const durationMinutes = endH * 60 + endM - (startH * 60 + startM)
return sum + durationMinutes / 60
}, 0)
return {
totalBookings: completedBookings.length,
totalDays,
monthBookings: monthBookings.length,
monthDays,
monthHours,
}
}
}