/opt/mawid/apps/api/src/reminders
NameSizeModeActions
reminder-queue.ts5270644editdlrm
reminder-reply.service.ts34330644editdlrm
reminder-sender.service.ts21330644editdlrm
reminder.service.ts35900644editdlrm
reminder.worker.ts40600644editdlrm
reminders.e2e.spec.ts133040644editdlrm
reminders.module.ts10880644editdlrm
Edit: /opt/mawid/apps/api/src/reminders/reminders.e2e.spec.ts (13304B)
/** * Reminder lifecycle E2E (PROJECT_PLAN Phase 4 acceptance): appointment 25h out * → both reminders scheduled; firing verified by invoking the worker handlers * directly (instead of waiting on real delays); buttons work end-to-end; job * cleanup on cancel/reschedule verified against the real BullMQ queue. * Requires docker-compose postgres + redis. */ import { Queue } from 'bullmq'; import IORedis from 'ioredis'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { WaSenderService } from '../whatsapp/wa-sender.service'; import type { WaOutboundQueue } from '../whatsapp/wa-queue'; import { ReminderService } from './reminder.service'; import { ReminderSenderService } from './reminder-sender.service'; import { ReminderReplyService } from './reminder-reply.service'; import { ReminderWorker } from './reminder.worker'; import { riskCheckJobId, type ReminderQueue } from './reminder-queue'; process.env.DATABASE_URL ??= 'postgresql://mawid:mawid@localhost:5432/mawid'; const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; describe('Reminders E2E', () => { const prisma = new PrismaService(); const connection = new IORedis(REDIS_URL, { maxRetriesPerRequest: null }); // Isolated queue name per run so parallel/repeated runs never collide. const queue = new Queue(`reminders-test-${Date.now()}`, { connection }) as ReminderQueue; const outboundAdd = jest.fn(); const outboundQueue = { add: outboundAdd } as unknown as WaOutboundQueue; const waSender = new WaSenderService(prisma, outboundQueue); const reminderService = new ReminderService(prisma, queue); const reminderSender = new ReminderSenderService(waSender); const worker = new ReminderWorker( prisma, { getOrThrow: () => REDIS_URL } as unknown as ConfigService, reminderSender, ); const replyService = new ReminderReplyService(prisma, waSender, reminderService); let clinicId: string; let patientId: string; let staffId: string; let serviceId: string; beforeAll(async () => { const clinic = await prisma.clinic.create({ data: { name: `reminders-e2e-${Date.now()}`, phone: '+900000000097', timezone: 'Europe/Istanbul', defaultLanguage: 'tr', settings: { reminderOffsetsHours: [24, 2] }, }, }); clinicId = clinic.id; serviceId = ( await prisma.service.create({ data: { clinicId, name: { ar: 'فحص', tr: 'Muayene', en: 'Checkup' }, durationMinutes: 30, }, }) ).id; staffId = ( await prisma.staff.create({ data: { clinicId, name: 'Dr. Reminder', services: { connect: [{ id: serviceId }] } }, }) ).id; patientId = ( await prisma.patient.create({ data: { clinicId, waPhone: `+90597${Date.now().toString().slice(-7)}`, name: 'Reminder Patient', language: 'tr', }, }) ).id; // In production a button tap is itself an inbound message, which opens the // 24h customer-service window before the reply is sent — simulate that. const conversation = await waSender.getOrCreateConversation(clinicId, patientId); await prisma.message.create({ data: { conversationId: conversation.id, direction: 'inbound', type: 'text', body: 'merhaba' }, }); }); afterAll(async () => { await prisma.appointment.deleteMany({ where: { clinicId } }); await prisma.clinic.delete({ where: { id: clinicId } }); await prisma.$disconnect(); await queue.obliterate({ force: true }); await queue.close(); connection.disconnect(); }); function makeAppointment(hoursOut: number) { const startsAt = new Date(Date.now() + hoursOut * 3600_000); return prisma.appointment.create({ data: { clinicId, patientId, staffId, serviceId, startsAt, endsAt: new Date(startsAt.getTime() + 30 * 60_000), status: 'pending', source: 'whatsapp', }, }); } it('appointment 25h out gets h24 + h2 rows and delayed queue jobs with correct times', async () => { const appointment = await makeAppointment(25); await reminderService.scheduleForAppointment(appointment.id); const rows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id, status: 'pending' }, orderBy: { scheduledFor: 'asc' }, }); expect(rows.map((r) => r.kind)).toEqual(['h24', 'h2']); expect(rows[0].scheduledFor.getTime()).toBe(appointment.startsAt.getTime() - 24 * 3600_000); expect(rows[1].scheduledFor.getTime()).toBe(appointment.startsAt.getTime() - 2 * 3600_000); for (const row of rows) { const job = await queue.getJob(row.id); expect(job).toBeTruthy(); const fireAt = job!.timestamp + job!.delay; expect(Math.abs(fireAt - row.scheduledFor.getTime())).toBeLessThan(2000); } expect(await queue.getJob(riskCheckJobId(appointment.id))).toBeTruthy(); }); it('both reminders fire and send template messages with buttons', async () => { const appointment = await makeAppointment(26); await reminderService.scheduleForAppointment(appointment.id); const rows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id }, orderBy: { scheduledFor: 'asc' }, }); outboundAdd.mockClear(); await worker.handleReminder(rows[0].id); // T-24h await prisma.appointment.update({ where: { id: appointment.id }, data: { status: 'confirmed' } }); await worker.handleReminder(rows[1].id); // T-2h (confirmed → friendly final) const messages = await prisma.message.findMany({ where: { conversation: { clinicId, patientId }, direction: 'outbound', type: 'template' }, orderBy: { createdAt: 'asc' }, }); const [first, final] = messages.slice(-2); expect(first.body).toContain('Randevu hatırlatması'); expect(final.body).toContain('Görüşmek üzere'); const request = (first.payload as { request: { template: { components: unknown[] } } }).request; const buttons = request.template.components.filter( (c) => (c as { type: string }).type === 'button', ); expect(buttons).toHaveLength(3); expect(JSON.stringify(buttons)).toContain(`confirm:${appointment.id}`); expect(outboundAdd).toHaveBeenCalledTimes(2); const updatedRows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id }, }); expect(updatedRows.every((r) => r.status === 'sent')).toBe(true); // replay is a no-op (idempotent) outboundAdd.mockClear(); await worker.handleReminder(rows[0].id); expect(outboundAdd).not.toHaveBeenCalled(); }); it('risk check at T-4h flags the appointment and sends the nudge early, consuming the h2 job', async () => { const appointment = await makeAppointment(27); await reminderService.scheduleForAppointment(appointment.id); outboundAdd.mockClear(); await worker.handleRiskCheck(appointment.id); const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(updated?.unconfirmedRisk).toBe(true); const nudge = await prisma.message.findFirst({ where: { conversation: { clinicId, patientId }, direction: 'outbound' }, orderBy: { createdAt: 'desc' }, }); expect(nudge?.body).toContain('Henüz onayınızı alamadık'); const h2 = await prisma.reminderJob.findFirst({ where: { appointmentId: appointment.id, kind: 'h2' }, }); expect(h2?.status).toBe('sent'); // the delayed queue job firing later is now a no-op outboundAdd.mockClear(); await worker.handleReminder(h2!.id); expect(outboundAdd).not.toHaveBeenCalled(); }); it('risk check does nothing when the patient already confirmed', async () => { const appointment = await makeAppointment(28); await reminderService.scheduleForAppointment(appointment.id); await prisma.appointment.update({ where: { id: appointment.id }, data: { status: 'confirmed' } }); await worker.handleRiskCheck(appointment.id); const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(updated?.unconfirmedRisk).toBe(false); }); it('confirm button confirms the appointment and thanks the patient', async () => { const appointment = await makeAppointment(29); await reminderService.scheduleForAppointment(appointment.id); const handled = await replyService.tryHandle(clinicId, patientId, `confirm:${appointment.id}`); expect(handled.handled).toBe(true); const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(updated?.status).toBe('confirmed'); const reply = await prisma.message.findFirst({ where: { conversation: { clinicId, patientId }, direction: 'outbound' }, orderBy: { createdAt: 'desc' }, }); expect(reply?.body).toContain('onaylandı'); }); it('cancel button cancels the appointment and cleans up jobs', async () => { const appointment = await makeAppointment(30); await reminderService.scheduleForAppointment(appointment.id); const rows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id, status: 'pending' }, }); expect(rows.length).toBeGreaterThan(0); const handled = await replyService.tryHandle(clinicId, patientId, `cancel:${appointment.id}`); expect(handled.handled).toBe(true); const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(updated?.status).toBe('cancelled'); expect(updated?.cancelReason).toBe('reminder_button'); const after = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id } }); expect(after.every((r) => r.status === 'cancelled')).toBe(true); for (const row of rows) { expect(await queue.getJob(row.id)).toBeUndefined(); } expect(await queue.getJob(riskCheckJobId(appointment.id))).toBeUndefined(); }); it('reschedule button replies with the prompt and leaves the appointment untouched', async () => { const appointment = await makeAppointment(31); const handled = await replyService.tryHandle(clinicId, patientId, `resched:${appointment.id}`); expect(handled.handled).toBe(true); const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(updated?.status).toBe('pending'); const reply = await prisma.message.findFirst({ where: { conversation: { clinicId, patientId }, direction: 'outbound' }, orderBy: { createdAt: 'desc' }, }); expect(reply?.body).toContain('hangi gün'); }); it('a stale button (cancelled appointment) is not handled — falls through to the agent', async () => { const appointment = await makeAppointment(32); await prisma.appointment.update({ where: { id: appointment.id }, data: { status: 'cancelled' } }); const result = await replyService.tryHandle(clinicId, patientId, `confirm:${appointment.id}`); expect(result.handled).toBe(false); }); it('rescheduling rebuilds jobs idempotently: old jobs cancelled and removed, new ones live', async () => { const appointment = await makeAppointment(33); await reminderService.scheduleForAppointment(appointment.id); const oldRows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id, status: 'pending' }, }); const newStart = new Date(appointment.startsAt.getTime() + 3600_000); await prisma.appointment.update({ where: { id: appointment.id }, data: { startsAt: newStart, endsAt: new Date(newStart.getTime() + 30 * 60_000) }, }); await reminderService.scheduleForAppointment(appointment.id); for (const row of oldRows) { const fresh = await prisma.reminderJob.findUnique({ where: { id: row.id } }); expect(fresh?.status).toBe('cancelled'); expect(await queue.getJob(row.id)).toBeUndefined(); } const newRows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id, status: 'pending' }, orderBy: { scheduledFor: 'asc' }, }); expect(newRows).toHaveLength(2); expect(newRows[0].scheduledFor.getTime()).toBe(newStart.getTime() - 24 * 3600_000); for (const row of newRows) { expect(await queue.getJob(row.id)).toBeTruthy(); } // scheduling twice in a row must not duplicate anything await reminderService.scheduleForAppointment(appointment.id); const finalRows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id, status: 'pending' }, }); expect(finalRows).toHaveLength(2); }); it('appointments closer than an offset skip that reminder', async () => { const appointment = await makeAppointment(3); // 3h out: h24 impossible, h2 fine await reminderService.scheduleForAppointment(appointment.id); const rows = await prisma.reminderJob.findMany({ where: { appointmentId: appointment.id, status: 'pending' }, }); expect(rows.map((r) => r.kind)).toEqual(['h2']); // risk check (T-4h) is already in the past → not scheduled expect(await queue.getJob(riskCheckJobId(appointment.id))).toBeUndefined(); }); });