import { Test, TestingModule } from '@nestjs/testing' import { JwtService } from '@nestjs/jwt' import { UnauthorizedException } from '@nestjs/common' import { MembershipStatus, UserRole } from '@mp-pilates/shared' import { ConfigService } from '@nestjs/config' import { AuthService, RANDOM_FN_TOKEN } from '../auth.service' import { WechatService } from '../wechat.service' import { PrismaService } from '../../prisma/prisma.service' import { InviteService } from '../../invite/invite.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 TEST_NICKNAME = '优雅普拉提' const mockUser = { id: USER_ID, openid: OPENID, unionid: null, phone: null, nickname: TEST_NICKNAME, avatarUrl: null, role: UserRole.MEMBER, adminBookingSubscriptionCount: 0, lastLoginAt: new Date('2024-01-01T00:00:00Z'), createdAt: new Date(), updatedAt: new Date(), } // ─── Mocks ─────────────────────────────────────────────────────────────────── const mockPrismaService = { membership: { count: jest.fn(), }, 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(), } const mockInviteService = { bindInviterToUser: jest.fn(), } const mockConfigService = { get: 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 }, { provide: InviteService, useValue: mockInviteService }, { provide: ConfigService, useValue: mockConfigService }, { provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname ], }).compile() authService = module.get(AuthService) jest.clearAllMocks() mockJwtService.sign.mockReturnValue(JWT_TOKEN) mockPrismaService.membership.count.mockResolvedValue(0) mockConfigService.get.mockImplementation((key: string) => key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED' ? 'tmpl-booking-confirmed' : '') }) // ── 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, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0, lastLoginAt: expect.any(Date), }, }) expect(result.user).toEqual(expect.objectContaining({ id: mockUser.id, phone: mockUser.phone, nickname: mockUser.nickname, avatarUrl: mockUser.avatarUrl, role: mockUser.role, activeMembershipCount: 0, inviteShareEligible: false, adminBookingSubscriptionCount: 0, })) expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2) expect(result.isNewUser).toBe(true) expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined) }) it('binds inviter for new users when inviterId is present', async () => { mockPrismaService.user.findUnique.mockResolvedValue(null) mockPrismaService.user.create.mockResolvedValue(mockUser) await authService.login(loginCode, undefined, undefined, 'inviter-001') expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, 'inviter-001') }) 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, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0, lastLoginAt: expect.any(Date), }, }) }) it('returns existing user when openid already exists', async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.update.mockResolvedValue(mockUser) const result = await authService.login(loginCode) expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ where: { openid: OPENID }, }) expect(mockPrismaService.user.create).not.toHaveBeenCalled() expect(mockPrismaService.user.update).toHaveBeenCalledWith({ where: { id: USER_ID }, data: { lastLoginAt: expect.any(Date) }, }) expect(result.user).toEqual(expect.objectContaining({ id: mockUser.id, nickname: mockUser.nickname, role: mockUser.role, })) expect(result.isNewUser).toBe(false) }) it('returns a valid JWT token', async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.update.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) mockPrismaService.user.update.mockResolvedValue(mockUser) const result = await authService.login(loginCode) expect(result).toEqual(expect.objectContaining({ token: JWT_TOKEN, isNewUser: false, })) expect(result.user).toEqual(expect.objectContaining({ id: mockUser.id, subscriptionMessageTemplates: { templates: [ expect.objectContaining({ scene: 'BOOKING_CREATED' }), expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }), ], }, })) }) it('includes active membership count and invite eligibility in login response', async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.update.mockResolvedValue(mockUser) mockPrismaService.membership.count.mockResolvedValue(2) const result = await authService.login(loginCode) expect(mockPrismaService.membership.count).toHaveBeenCalledWith({ where: { userId: USER_ID, status: MembershipStatus.ACTIVE, }, }) expect(result.user.activeMembershipCount).toBe(2) expect(result.user.inviteShareEligible).toBe(true) }) }) // ── 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) mockPrismaService.user.update.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) }) }) })