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,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)
})
})
})