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() }) })