313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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>(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)
|
|
})
|
|
})
|
|
})
|