Files
mp-pilates/packages/server/src/user/__tests__/user.service.spec.ts
2026-04-12 21:44:44 +08:00

387 lines
12 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing'
import { NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service'
import { PrismaService } from '../../prisma/prisma.service'
import {
MembershipStatus,
BookingStatus,
UserRole,
SubscriptionMessageScene,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeUser = (overrides: Record<string, unknown> = {}) => ({
id: 'user-1',
openid: 'openid-1',
unionid: null,
phone: '13800000000',
nickname: 'Alice',
avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
_count: { memberships: 2 },
...overrides,
})
const makeBooking = (
date: Date,
startTime: string,
endTime: string,
status: BookingStatus = BookingStatus.COMPLETED,
) => ({
id: `booking-${Math.random()}`,
userId: 'user-1',
timeSlotId: `slot-${Math.random()}`,
membershipId: `membership-${Math.random()}`,
status,
cancelledAt: null,
createdAt: new Date(),
updatedAt: new Date(),
timeSlot: { date, startTime, endTime },
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
subscriptionMessageConsent: {
upsert: jest.fn(),
findMany: jest.fn(),
},
booking: {
findMany: jest.fn(),
},
}
const mockConfigService = {
get: jest.fn((key: string, defaultValue = '') => {
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
return defaultValue
}),
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('UserService', () => {
let service: UserService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile()
service = module.get<UserService>(UserService)
jest.clearAllMocks()
})
// -------------------------------------------------------------------------
// getProfile
// -------------------------------------------------------------------------
describe('getProfile', () => {
it('returns a UserProfileResponse with activeMembershipCount', async () => {
const user = makeUser({ _count: { memberships: 3 } })
mockPrisma.user.findUnique.mockResolvedValue(user)
const result = await service.getProfile('user-1')
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
},
},
},
})
expect(result).toEqual({
id: 'user-1',
phone: '13800000000',
nickname: 'Alice',
avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER,
activeMembershipCount: 3,
subscriptionMessageTemplates: {
templates: [
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
},
],
},
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
})
})
it('throws NotFoundException when user does not exist', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null)
await expect(service.getProfile('unknown')).rejects.toThrow(NotFoundException)
})
})
describe('reportSubscriptionMessageRequests', () => {
it('aggregates and returns subscription consent stats', async () => {
mockPrisma.subscriptionMessageConsent.upsert.mockResolvedValue(undefined)
mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([
{
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
totalRequestCount: 2,
acceptCount: 1,
rejectCount: 1,
banCount: 0,
filterCount: 0,
sentCount: 0,
lastResult: 'reject',
lastRequestedAt: new Date('2024-01-03T00:00:00Z'),
lastSentAt: null,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-03T00:00:00Z'),
},
])
const result = await service.reportSubscriptionMessageRequests('user-1', [
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
result: 'reject',
},
])
expect(mockPrisma.subscriptionMessageConsent.upsert).toHaveBeenCalledWith({
where: {
userId_templateId_scene: {
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
},
},
create: {
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
totalRequestCount: 1,
acceptCount: 0,
rejectCount: 1,
banCount: 0,
filterCount: 0,
sentCount: 0,
lastResult: 'reject',
lastRequestedAt: expect.any(Date),
},
update: {
totalRequestCount: { increment: 1 },
acceptCount: { increment: 0 },
rejectCount: { increment: 1 },
banCount: { increment: 0 },
filterCount: { increment: 0 },
lastResult: 'reject',
lastRequestedAt: expect.any(Date),
},
})
expect(result).toEqual([
{
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
totalRequestCount: 2,
acceptCount: 1,
rejectCount: 1,
banCount: 0,
filterCount: 0,
sentCount: 0,
lastResult: 'reject',
lastRequestedAt: '2024-01-03T00:00:00.000Z',
lastSentAt: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-03T00:00:00.000Z',
},
])
})
})
// -------------------------------------------------------------------------
// updateProfile
// -------------------------------------------------------------------------
describe('updateProfile', () => {
it('updates nickname and avatarUrl, returns new UserProfileResponse', async () => {
const updated = makeUser({
nickname: 'Bob',
avatarUrl: 'https://example.com/new.png',
_count: { memberships: 1 },
})
mockPrisma.user.update.mockResolvedValue(updated)
const result = await service.updateProfile('user-1', {
nickname: 'Bob',
avatarUrl: 'https://example.com/new.png',
})
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { nickname: 'Bob', avatarUrl: 'https://example.com/new.png' },
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
},
},
},
})
expect(result.nickname).toBe('Bob')
expect(result.avatarUrl).toBe('https://example.com/new.png')
expect(result.activeMembershipCount).toBe(1)
expect(result.subscriptionMessageTemplates.templates).toHaveLength(1)
})
it('only includes provided fields in the update payload', async () => {
const updated = makeUser({ nickname: 'Charlie', _count: { memberships: 0 } })
mockPrisma.user.update.mockResolvedValue(updated)
await service.updateProfile('user-1', { nickname: 'Charlie' })
const callArgs = mockPrisma.user.update.mock.calls[0][0]
expect(callArgs.data).toEqual({ nickname: 'Charlie' })
expect(callArgs.data.avatarUrl).toBeUndefined()
})
it('returns an immutable snapshot — the original dto is not mutated', async () => {
const updated = makeUser({ _count: { memberships: 0 } })
mockPrisma.user.update.mockResolvedValue(updated)
const dto = { nickname: 'Dave' }
const originalDto = { ...dto }
await service.updateProfile('user-1', dto)
expect(dto).toEqual(originalDto)
})
})
// -------------------------------------------------------------------------
// getStats
// -------------------------------------------------------------------------
describe('getStats', () => {
/**
* Build a date in the *current* month so the month-filter logic works
* regardless of when the test is run.
*/
const thisYear = new Date().getFullYear()
const thisMonth = new Date().getMonth()
const dateInMonth = (day: number) => new Date(thisYear, thisMonth, day)
const dateLastMonth = new Date(thisYear, thisMonth - 1, 15)
it('returns zeroed stats when there are no completed bookings', async () => {
mockPrisma.booking.findMany.mockResolvedValue([])
const result = await service.getStats('user-1')
expect(result).toEqual({
totalBookings: 0,
totalDays: 0,
monthBookings: 0,
monthDays: 0,
monthHours: 0,
})
})
it('counts all completed bookings for totalBookings', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'),
makeBooking(dateLastMonth, '09:00', '10:00'),
])
const result = await service.getStats('user-1')
expect(result.totalBookings).toBe(2)
})
it('counts distinct dates for totalDays', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'),
makeBooking(dateInMonth(1), '11:00', '12:00'), // same day → still 1 distinct day
makeBooking(dateLastMonth, '09:00', '10:00'),
])
const result = await service.getStats('user-1')
expect(result.totalDays).toBe(2) // day-in-month(1) + last-month-day
})
it('only counts this-month bookings in monthBookings', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(5), '09:00', '10:00'),
makeBooking(dateInMonth(10), '09:00', '10:00'),
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
])
const result = await service.getStats('user-1')
expect(result.monthBookings).toBe(2)
})
it('counts distinct this-month dates for monthDays', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(3), '09:00', '10:00'),
makeBooking(dateInMonth(3), '11:00', '12:00'), // same day
makeBooking(dateInMonth(7), '09:00', '10:00'),
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
])
const result = await service.getStats('user-1')
expect(result.monthDays).toBe(2)
})
it('sums hours for monthHours from startTime/endTime', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'), // 1 h
makeBooking(dateInMonth(2), '14:00', '15:30'), // 1.5 h
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
])
const result = await service.getStats('user-1')
expect(result.monthHours).toBeCloseTo(2.5)
})
it('queries only COMPLETED bookings for this user', async () => {
mockPrisma.booking.findMany.mockResolvedValue([])
await service.getStats('user-1')
expect(mockPrisma.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-1', status: BookingStatus.COMPLETED },
}),
)
})
})
})