feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-10 16:20:13 +08:00
parent d32f592e54
commit 3e049d2c1d
26 changed files with 1268 additions and 44 deletions

View File

@@ -0,0 +1,122 @@
import { ClassReminderService } from '../class-reminder.service'
import { PrismaService } from '../../prisma/prisma.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { ConfigService } from '@nestjs/config'
import { Logger } from '@nestjs/common'
import { BookingStatus } from '@mp-pilates/shared'
describe('ClassReminderService', () => {
const db = {
booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
studioConfig: { findFirst: jest.fn() },
}
const messages = {
getClassReminderTemplateId: jest.fn(),
sendClassReminderMessage: jest.fn(),
}
const config = { get: jest.fn() }
let service: ClassReminderService
beforeEach(() => {
jest.resetAllMocks()
messages.getClassReminderTemplateId.mockReturnValue('reminder-template-id')
db.studioConfig.findFirst.mockResolvedValue({ name: 'FocusCore Studio' })
service = new ClassReminderService(
db as unknown as PrismaService,
messages as unknown as SubscriptionMessageService,
config as unknown as ConfigService,
)
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {})
})
afterEach(() => jest.restoreAllMocks())
it('skips run when class reminder template is not configured', async () => {
messages.getClassReminderTemplateId.mockReturnValue('')
await service.run()
expect(db.booking.findMany).not.toHaveBeenCalled()
})
it('scans and sends reminders for bookings starting in ~1 hour (e.g. 60 mins)', async () => {
// Current time fixed or calculated
const now = new Date()
const targetDate = new Date(now.getTime() + 60 * 60 * 1000)
// Convert targetDate to China time string representation
// China is UTC+8
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
const dateStr = chinaDate.toISOString().slice(0, 10)
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
const startTime = `${hours}:${minutes}:00`
db.booking.findMany.mockResolvedValue([
{
id: 'booking-reminder-1',
userId: 'user-1',
status: BookingStatus.CONFIRMED,
user: { openid: 'openid-user-1' },
timeSlot: {
date: new Date(`${dateStr}T00:00:00.000Z`),
startTime,
endTime: '12:00:00',
},
},
])
db.booking.updateMany.mockResolvedValue({ count: 1 })
messages.sendClassReminderMessage.mockResolvedValue(true)
await service.run()
expect(db.booking.updateMany).toHaveBeenCalledWith({
where: {
id: 'booking-reminder-1',
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
},
data: { classReminderClaimedAt: expect.any(Date) },
})
expect(messages.sendClassReminderMessage).toHaveBeenCalledWith(
expect.objectContaining({
openid: 'openid-user-1',
userId: 'user-1',
bookingId: 'booking-reminder-1',
courseName: 'FocusCore Studio',
}),
)
expect(db.booking.update).toHaveBeenCalledWith({
where: { id: 'booking-reminder-1' },
data: { classReminderSentAt: expect.any(Date) },
})
})
it('skips bookings that are not in the 50-70 minutes window (e.g. starting in 2 hours)', async () => {
const now = new Date()
const targetDate = new Date(now.getTime() + 120 * 60 * 1000)
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
const dateStr = chinaDate.toISOString().slice(0, 10)
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
db.booking.findMany.mockResolvedValue([
{
id: 'booking-far',
userId: 'user-far',
status: BookingStatus.CONFIRMED,
user: { openid: 'openid-far' },
timeSlot: {
date: new Date(`${dateStr}T00:00:00.000Z`),
startTime: `${hours}:${minutes}:00`,
endTime: '20:00:00',
},
},
])
await service.run()
expect(db.booking.updateMany).not.toHaveBeenCalled()
expect(messages.sendClassReminderMessage).not.toHaveBeenCalled()
})
})