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:
109
packages/server/src/studio/__tests__/studio.service.spec.ts
Normal file
109
packages/server/src/studio/__tests__/studio.service.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
53
packages/server/src/studio/dto/update-studio.dto.ts
Normal file
53
packages/server/src/studio/dto/update-studio.dto.ts
Normal 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[]
|
||||
}
|
||||
30
packages/server/src/studio/studio.controller.ts
Normal file
30
packages/server/src/studio/studio.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
10
packages/server/src/studio/studio.module.ts
Normal file
10
packages/server/src/studio/studio.module.ts
Normal 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 {}
|
||||
34
packages/server/src/studio/studio.service.ts
Normal file
34
packages/server/src/studio/studio.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user