/opt/mawid/apps/api/src/reminders
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();
});
});