746 lines
25 KiB
TypeScript
746 lines
25 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing'
|
|
import { BadRequestException, NotFoundException } from '@nestjs/common'
|
|
import { UserService } from '../user.service'
|
|
import { UserController } from '../user.controller'
|
|
import { PrismaService } from '../../prisma/prisma.service'
|
|
import {
|
|
MembershipStatus,
|
|
BookingStatus,
|
|
UserRole,
|
|
CardTypeCategory,
|
|
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,
|
|
adminBookingSubscriptionCount: 0,
|
|
lastLoginAt: new Date('2024-06-01T08:00:00Z'),
|
|
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 = {
|
|
lessonSupplement: {
|
|
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }),
|
|
groupBy: jest.fn().mockResolvedValue([]),
|
|
},
|
|
user: {
|
|
findUnique: jest.fn(),
|
|
findMany: jest.fn(),
|
|
count: jest.fn(),
|
|
update: jest.fn(),
|
|
},
|
|
subscriptionMessageConsent: {
|
|
upsert: jest.fn(),
|
|
findMany: jest.fn(),
|
|
},
|
|
booking: {
|
|
findMany: jest.fn(),
|
|
groupBy: jest.fn(),
|
|
},
|
|
membership: {
|
|
findFirst: jest.fn(),
|
|
update: jest.fn(),
|
|
create: jest.fn(),
|
|
updateMany: 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,
|
|
inviteShareEligible: true,
|
|
adminBookingSubscriptionCount: 0,
|
|
subscriptionMessageTemplates: {
|
|
templates: [
|
|
{
|
|
templateId: 'tmpl-booking-confirmed',
|
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
|
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
|
usageTarget: 'consent',
|
|
},
|
|
{
|
|
templateId: 'tmpl-booking-confirmed',
|
|
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
|
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
|
usageTarget: 'counter',
|
|
},
|
|
],
|
|
},
|
|
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.adminBookingSubscriptionCount).toBe(0)
|
|
expect(result.subscriptionMessageTemplates.templates).toHaveLength(2)
|
|
})
|
|
|
|
it('increments admin booking subscription count for admin users', async () => {
|
|
mockPrisma.user.findUnique.mockResolvedValue(makeUser({
|
|
role: UserRole.ADMIN,
|
|
adminBookingSubscriptionCount: 2,
|
|
}))
|
|
mockPrisma.user.update.mockResolvedValue(makeUser({
|
|
role: UserRole.ADMIN,
|
|
adminBookingSubscriptionCount: 3,
|
|
}))
|
|
|
|
const result = await service.grantAdminBookingSubscriptionCount('user-1')
|
|
|
|
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
|
where: { id: 'user-1' },
|
|
data: {
|
|
adminBookingSubscriptionCount: {
|
|
increment: 1,
|
|
},
|
|
},
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
expect(result.adminBookingSubscriptionCount).toBe(3)
|
|
})
|
|
|
|
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('adds ten active historical classes only to lifetime total', async () => {
|
|
mockPrisma.booking.findMany.mockResolvedValue([makeBooking(dateInMonth(1), '09:00', '10:00')])
|
|
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
|
|
expect(await service.getStats('user-1')).toEqual({ totalBookings: 11, totalDays: 1, monthBookings: 1, monthDays: 1, monthHours: 1 })
|
|
expect(mockPrisma.lessonSupplement.aggregate).toHaveBeenCalledWith({ where: { userId: 'user-1', revokedAt: null }, _sum: { quantity: true } })
|
|
})
|
|
|
|
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 },
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
it('batch-adds supplements to member-list completed counts without inflating reservations', async () => {
|
|
mockPrisma.user.findMany.mockResolvedValue([makeUser({ memberships: [] })])
|
|
mockPrisma.user.count.mockResolvedValue(1)
|
|
mockPrisma.booking.groupBy.mockResolvedValue([{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } }])
|
|
mockPrisma.lessonSupplement.groupBy.mockResolvedValueOnce([{ userId: 'user-1', _sum: { quantity: 10 } }])
|
|
const result = await service.getMembers(1, 20)
|
|
expect(result.items[0]).toMatchObject({ totalBookings: 3, completedBookings: 13 })
|
|
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
|
|
})
|
|
|
|
describe('member filters', () => {
|
|
it.each([
|
|
['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
|
|
['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
|
|
['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
|
|
['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
|
|
['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
|
|
[undefined, {}],
|
|
])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
|
|
mockPrisma.user.findMany.mockResolvedValue([])
|
|
mockPrisma.user.count.mockResolvedValue(0)
|
|
const controller = new UserController(service)
|
|
await controller.getMembers('2', '20', undefined, filter as string | undefined)
|
|
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ where, skip: 20, take: 20 }))
|
|
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where })
|
|
})
|
|
})
|
|
|
|
describe('getMemberDetail', () => {
|
|
const cardType = {
|
|
id: 'ct-1',
|
|
name: '10次卡',
|
|
type: CardTypeCategory.TIMES,
|
|
totalTimes: 10,
|
|
durationDays: 180,
|
|
price: 150000,
|
|
originalPrice: null,
|
|
description: null,
|
|
coverUrl: null,
|
|
isActive: true,
|
|
sortOrder: 0,
|
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
|
}
|
|
|
|
const membership = {
|
|
id: 'mem-1',
|
|
userId: 'user-1',
|
|
cardTypeId: 'ct-1',
|
|
remainingTimes: 6,
|
|
totalTimes: 10,
|
|
startDate: new Date('2024-01-01T00:00:00Z'),
|
|
expireDate: new Date('2099-01-01T00:00:00Z'),
|
|
status: MembershipStatus.ACTIVE,
|
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
|
cardType,
|
|
}
|
|
|
|
it('returns profile, memberships, stats and upcoming bookings', async () => {
|
|
mockPrisma.user.findUnique.mockResolvedValue({
|
|
...makeUser(),
|
|
memberships: [membership],
|
|
})
|
|
mockPrisma.booking.groupBy.mockResolvedValue([
|
|
{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } },
|
|
{ userId: 'user-1', status: BookingStatus.CANCELLED, _count: { id: 1 } },
|
|
{ userId: 'user-1', status: BookingStatus.NO_SHOW, _count: { id: 1 } },
|
|
])
|
|
mockPrisma.booking.findMany.mockResolvedValue([
|
|
{
|
|
id: 'booking-up-1',
|
|
status: BookingStatus.CONFIRMED,
|
|
timeSlot: {
|
|
date: new Date('2099-12-31T00:00:00Z'),
|
|
startTime: '09:00',
|
|
endTime: '10:00',
|
|
},
|
|
membership: { cardType: { name: '10次卡' } },
|
|
},
|
|
])
|
|
|
|
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
|
|
const result = await service.getMemberDetail('user-1')
|
|
|
|
expect(result.user.userId).toBe('user-1')
|
|
expect(result.user.lastLoginAt).toBe('2024-06-01T08:00:00.000Z')
|
|
expect(result.memberships).toHaveLength(1)
|
|
expect(result.memberships[0].cardType.name).toBe('10次卡')
|
|
expect(result.stats).toEqual({
|
|
totalBookings: 5,
|
|
completedBookings: 13,
|
|
cancelledBookings: 1,
|
|
noShowBookings: 1,
|
|
})
|
|
expect(result.upcomingBookings).toEqual([
|
|
{
|
|
id: 'booking-up-1',
|
|
status: BookingStatus.CONFIRMED,
|
|
date: '2099-12-31',
|
|
startTime: '09:00',
|
|
endTime: '10:00',
|
|
cardName: '10次卡',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('throws NotFoundException when member does not exist', async () => {
|
|
mockPrisma.user.findUnique.mockResolvedValue(null)
|
|
|
|
await expect(service.getMemberDetail('missing')).rejects.toThrow(NotFoundException)
|
|
})
|
|
})
|
|
|
|
describe('updateMemberProfile', () => {
|
|
it('updates nickname and phone then returns the dossier', async () => {
|
|
mockPrisma.user.findUnique
|
|
.mockResolvedValueOnce(makeUser())
|
|
.mockResolvedValueOnce({ ...makeUser({ nickname: 'Bob', phone: '13900000000' }), memberships: [] })
|
|
mockPrisma.user.update.mockResolvedValue(makeUser({ nickname: 'Bob', phone: '13900000000' }))
|
|
mockPrisma.booking.groupBy.mockResolvedValue([])
|
|
mockPrisma.booking.findMany.mockResolvedValue([])
|
|
|
|
const result = await service.updateMemberProfile('user-1', {
|
|
nickname: 'Bob',
|
|
phone: '13900000000',
|
|
})
|
|
|
|
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
|
where: { id: 'user-1' },
|
|
data: { nickname: 'Bob', phone: '13900000000' },
|
|
})
|
|
expect(result.user.nickname).toBe('Bob')
|
|
expect(result.user.phone).toBe('13900000000')
|
|
})
|
|
})
|
|
|
|
describe('updateUserMembership', () => {
|
|
const startDate = '2026-09-01'
|
|
const expireDate = '2099-10-01'
|
|
|
|
it('snapshots a submitted count when creating a DURATION membership', async () => {
|
|
mockPrisma.membership.findFirst.mockResolvedValue(null)
|
|
mockPrisma.membership.create.mockResolvedValue({ id: 'mem-duration-001' })
|
|
|
|
await service.updateUserMembership('user-1', {
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 10,
|
|
startDate,
|
|
expireDate,
|
|
})
|
|
|
|
expect(mockPrisma.membership.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
userId: 'user-1',
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 10,
|
|
totalTimes: 10,
|
|
status: MembershipStatus.ACTIVE,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('preserves the existing count snapshot when the submitted remaining count does not exceed it', async () => {
|
|
mockPrisma.membership.findFirst.mockResolvedValue({
|
|
id: 'mem-duration-001',
|
|
userId: 'user-1',
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 6,
|
|
totalTimes: 10,
|
|
})
|
|
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
|
|
|
|
await service.updateUserMembership('user-1', {
|
|
membershipId: 'mem-duration-001',
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 8,
|
|
startDate,
|
|
expireDate,
|
|
})
|
|
|
|
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: 'mem-duration-001' },
|
|
data: expect.objectContaining({
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 8,
|
|
totalTimes: 10,
|
|
status: MembershipStatus.ACTIVE,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('raises the count snapshot when the submitted remaining count exceeds it', async () => {
|
|
mockPrisma.membership.findFirst.mockResolvedValue({
|
|
id: 'mem-duration-001',
|
|
userId: 'user-1',
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 6,
|
|
totalTimes: 10,
|
|
})
|
|
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
|
|
|
|
await service.updateUserMembership('user-1', {
|
|
membershipId: 'mem-duration-001',
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 12,
|
|
startDate,
|
|
expireDate,
|
|
})
|
|
|
|
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
remainingTimes: 12,
|
|
totalTimes: 12,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('resets the count snapshot when changing to a different card type', async () => {
|
|
mockPrisma.membership.findFirst.mockResolvedValue({
|
|
id: 'mem-duration-001',
|
|
userId: 'user-1',
|
|
cardTypeId: 'card-duration-001',
|
|
remainingTimes: 6,
|
|
totalTimes: 10,
|
|
})
|
|
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
|
|
|
|
await service.updateUserMembership('user-1', {
|
|
membershipId: 'mem-duration-001',
|
|
cardTypeId: 'card-duration-002',
|
|
remainingTimes: 8,
|
|
startDate,
|
|
expireDate,
|
|
})
|
|
|
|
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
cardTypeId: 'card-duration-002',
|
|
remainingTimes: 8,
|
|
totalTimes: 8,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('deleteUserMembership', () => {
|
|
it('expires only the selected membership', async () => {
|
|
mockPrisma.membership.findFirst.mockResolvedValue({
|
|
id: 'mem-1',
|
|
userId: 'user-1',
|
|
status: MembershipStatus.ACTIVE,
|
|
})
|
|
mockPrisma.membership.update.mockResolvedValue({
|
|
id: 'mem-1',
|
|
status: MembershipStatus.EXPIRED,
|
|
})
|
|
|
|
await service.deleteUserMembership('user-1', 'mem-1')
|
|
|
|
expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({
|
|
where: { id: 'mem-1', userId: 'user-1' },
|
|
})
|
|
expect(mockPrisma.membership.update).toHaveBeenCalledWith({
|
|
where: { id: 'mem-1' },
|
|
data: { status: MembershipStatus.EXPIRED },
|
|
})
|
|
expect(mockPrisma.membership.updateMany).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects when the membership is missing or belongs to another user', async () => {
|
|
mockPrisma.membership.findFirst.mockResolvedValue(null)
|
|
|
|
await expect(service.deleteUserMembership('user-1', 'mem-other')).rejects.toThrow(
|
|
NotFoundException,
|
|
)
|
|
expect(mockPrisma.membership.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a missing membershipId', async () => {
|
|
await expect(service.deleteUserMembership('user-1', '')).rejects.toThrow(
|
|
BadRequestException,
|
|
)
|
|
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|