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

@@ -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 }
}
}