/opt/mawid/apps/api/src/waitlist
NameSizeModeActions
waitlist-queue.ts3630644editdlrm
waitlist-reply.service.ts8050644editdlrm
waitlist.e2e.spec.ts140810644editdlrm
waitlist.module.ts10560644editdlrm
waitlist.service.ts132590644editdlrm
waitlist.worker.ts11970644editdlrm
Edit: /opt/mawid/apps/api/src/waitlist/waitlist.e2e.spec.ts (14081B)
/** * Waitlist auto-refill E2E (PROJECT_PLAN Phase 5 acceptance): cancel a booked * slot with 2 waitlisted patients → first gets the offer; on decline/timeout * the second gets it; concurrent accepts never double-book. * Requires docker-compose postgres + redis. */ import { Queue } from 'bullmq'; import IORedis from 'ioredis'; import { PrismaService } from '../prisma/prisma.service'; import { WaSenderService } from '../whatsapp/wa-sender.service'; import type { WaOutboundQueue } from '../whatsapp/wa-queue'; import type { OwnerNotifierService } from '../whatsapp/owner-notifier.service'; import type { ReminderService } from '../reminders/reminder.service'; import { WaitlistService } from './waitlist.service'; import { WaitlistReplyService } from './waitlist-reply.service'; import { holdExpiryJobId, type WaitlistQueue } from './waitlist-queue'; process.env.DATABASE_URL ??= 'postgresql://mawid:mawid@localhost:5432/mawid'; const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; describe('Waitlist E2E', () => { const prisma = new PrismaService(); const connection = new IORedis(REDIS_URL, { maxRetriesPerRequest: null }); const queue = new Queue(`waitlist-test-${Date.now()}`, { connection }) as WaitlistQueue; const outboundQueue = { add: jest.fn() } as unknown as WaOutboundQueue; const waSender = new WaSenderService(prisma, outboundQueue); const scheduledReminders: string[] = []; const reminders = { scheduleForAppointment: async (id: string) => { scheduledReminders.push(id); }, cancelForAppointment: async () => {}, } as unknown as ReminderService; const ownerMessages: string[] = []; const ownerNotifier = { sendText: async (_clinicId: string, text: string) => { ownerMessages.push(text); }, } as unknown as OwnerNotifierService; const waitlist = new WaitlistService(prisma, waSender, reminders, ownerNotifier, queue); const replyService = new WaitlistReplyService(waitlist); let clinicId: string; let staffId: string; let serviceId: string; let cancellerPatientId: string; interface Candidate { patientId: string; entryId: string; } beforeAll(async () => { const clinic = await prisma.clinic.create({ data: { name: `waitlist-e2e-${Date.now()}`, phone: '+900000000096', timezone: 'Europe/Istanbul', defaultLanguage: 'tr', settings: { waitlistHoldMinutes: 20, ownerWaPhone: '+905550001111' }, }, }); clinicId = clinic.id; serviceId = ( await prisma.service.create({ data: { clinicId, name: { ar: 'تنظيف', tr: 'Temizlik', en: 'Cleaning' }, durationMinutes: 30, }, }) ).id; staffId = ( await prisma.staff.create({ data: { clinicId, name: 'Dr. Waitlist', services: { connect: [{ id: serviceId }] } }, }) ).id; cancellerPatientId = ( await prisma.patient.create({ data: { clinicId, waPhone: `+90596${Date.now().toString().slice(-7)}`, language: 'tr' }, }) ).id; }); 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(); }); let candidateCounter = 0; async function makeCandidate(createdOffsetMs: number, window: object = {}): Promise { candidateCounter += 1; const patient = await prisma.patient.create({ data: { clinicId, waPhone: `+9059${(Date.now() + candidateCounter).toString().slice(-8)}`, name: `Candidate ${candidateCounter}`, language: 'tr', }, }); // Open the 24h window so freeform offers/replies can be sent in tests. const conversation = await waSender.getOrCreateConversation(clinicId, patient.id); await prisma.message.create({ data: { conversationId: conversation.id, direction: 'inbound', type: 'text', body: 'selam' }, }); const entry = await prisma.waitlistEntry.create({ data: { clinicId, patientId: patient.id, serviceId, preferredWindow: window, createdAt: new Date(Date.now() - 1000_000 + createdOffsetMs), }, }); return { patientId: patient.id, entryId: entry.id }; } async function makeCancelledAppointment(hoursOut: number) { const startsAt = new Date(Date.now() + hoursOut * 3600_000); const appointment = await prisma.appointment.create({ data: { clinicId, patientId: cancellerPatientId, staffId, serviceId, startsAt, endsAt: new Date(startsAt.getTime() + 30 * 60_000), status: 'cancelled', cancelReason: 'test', source: 'whatsapp', }, }); return appointment; } async function activeHold() { return prisma.slotHold.findFirst({ where: { clinicId, status: 'offered' }, orderBy: { createdAt: 'desc' }, include: { waitlistEntry: true }, }); } async function cleanSlate() { await prisma.slotHold.deleteMany({ where: { clinicId } }); await prisma.waitlistEntry.deleteMany({ where: { clinicId } }); await prisma.appointment.deleteMany({ where: { clinicId } }); } it('cancelling with 2 waitlisted patients offers the slot to the older entry first', async () => { await cleanSlate(); const first = await makeCandidate(0); const second = await makeCandidate(60_000); const appointment = await makeCancelledAppointment(24); await waitlist.onAppointmentCancelled(appointment.id); const hold = await activeHold(); expect(hold).not.toBeNull(); expect(hold!.waitlistEntry.id).toBe(first.entryId); expect(hold!.startsAt.getTime()).toBe(appointment.startsAt.getTime()); const firstEntry = await prisma.waitlistEntry.findUnique({ where: { id: first.entryId } }); expect(firstEntry?.status).toBe('notified'); const secondEntry = await prisma.waitlistEntry.findUnique({ where: { id: second.entryId } }); expect(secondEntry?.status).toBe('active'); // offer message with 2 buttons went out const offer = await prisma.message.findFirst({ where: { conversation: { clinicId, patientId: first.patientId }, direction: 'outbound' }, orderBy: { createdAt: 'desc' }, }); expect(offer?.body).toContain('bir yer boşaldı'); const request = (offer?.payload as { request: { interactive: { action: { buttons: unknown[] } } } }) .request; expect(request.interactive.action.buttons).toHaveLength(2); expect(JSON.stringify(request)).toContain(`wl_yes:${hold!.id}`); // expiry job queued for the hold expect(await queue.getJob(holdExpiryJobId(hold!.id))).toBeTruthy(); }); it('decline cascades to the second candidate', async () => { const hold1 = await activeHold(); const declinerPatient = (await prisma.waitlistEntry.findUnique({ where: { id: hold1!.waitlistEntryId }, }))!.patientId; const handled = await replyService.tryHandle(clinicId, declinerPatient, `wl_no:${hold1!.id}`); expect(handled).toBe(true); const declined = await prisma.slotHold.findUnique({ where: { id: hold1!.id } }); expect(declined?.status).toBe('declined'); const declinerEntry = await prisma.waitlistEntry.findUnique({ where: { id: hold1!.waitlistEntryId }, }); expect(declinerEntry?.status).toBe('active'); // cascade: second candidate now holds the slot const hold2 = await activeHold(); expect(hold2).not.toBeNull(); expect(hold2!.id).not.toBe(hold1!.id); expect(hold2!.waitlistEntryId).not.toBe(hold1!.waitlistEntryId); expect(hold2!.startsAt.getTime()).toBe(hold1!.startsAt.getTime()); }); it('accept books the slot as confirmed, schedules reminders, and notifies the owner', async () => { const hold = await activeHold(); const entry = await prisma.waitlistEntry.findUnique({ where: { id: hold!.waitlistEntryId }, include: { patient: true }, }); scheduledReminders.length = 0; ownerMessages.length = 0; const handled = await replyService.tryHandle(clinicId, entry!.patientId, `wl_yes:${hold!.id}`); expect(handled).toBe(true); const appointment = await prisma.appointment.findFirst({ where: { clinicId, patientId: entry!.patientId, status: 'confirmed' }, }); expect(appointment).not.toBeNull(); expect(appointment!.startsAt.getTime()).toBe(hold!.startsAt.getTime()); expect((await prisma.slotHold.findUnique({ where: { id: hold!.id } }))?.status).toBe('accepted'); expect((await prisma.waitlistEntry.findUnique({ where: { id: hold!.waitlistEntryId } }))?.status).toBe( 'fulfilled', ); expect(scheduledReminders).toEqual([appointment!.id]); expect(ownerMessages).toHaveLength(1); expect(ownerMessages[0]).toContain('dolduruldu'); // "Slot recovered" in Turkish expect(ownerMessages[0]).toContain(entry!.patient.name!); const confirmation = await prisma.message.findFirst({ where: { conversation: { clinicId, patientId: entry!.patientId }, direction: 'outbound' }, orderBy: { createdAt: 'desc' }, }); expect(confirmation?.body).toContain('onaylandı'); }); it('hold expiry (timeout) cascades to the next candidate', async () => { await cleanSlate(); const first = await makeCandidate(0); const second = await makeCandidate(60_000); const appointment = await makeCancelledAppointment(26); await waitlist.onAppointmentCancelled(appointment.id); const hold1 = await activeHold(); expect(hold1!.waitlistEntryId).toBe(first.entryId); await waitlist.expireHold(hold1!.id); expect((await prisma.slotHold.findUnique({ where: { id: hold1!.id } }))?.status).toBe('expired'); expect((await prisma.waitlistEntry.findUnique({ where: { id: first.entryId } }))?.status).toBe( 'active', ); const hold2 = await activeHold(); expect(hold2!.waitlistEntryId).toBe(second.entryId); }); it('CONCURRENCY: two candidates racing for the same slot never double-book', async () => { await cleanSlate(); const a = await makeCandidate(0); const b = await makeCandidate(60_000); const appointment = await makeCancelledAppointment(28); // Simulate the race: two live holds for the same slot (e.g. an expiry // cascade fired while the first patient's accept was still in flight). const slot = { clinicId, staffId, serviceId, startsAt: appointment.startsAt, endsAt: appointment.endsAt, expiresAt: new Date(Date.now() + 20 * 60_000), }; const holdA = await prisma.slotHold.create({ data: { ...slot, waitlistEntryId: a.entryId } }); const holdB = await prisma.slotHold.create({ data: { ...slot, waitlistEntryId: b.entryId } }); const [resA, resB] = await Promise.all([ waitlist.acceptHold(clinicId, a.patientId, holdA.id), waitlist.acceptHold(clinicId, b.patientId, holdB.id), ]); expect(resA).toBe(true); expect(resB).toBe(true); const booked = await prisma.appointment.findMany({ where: { clinicId, staffId, status: { in: ['pending', 'confirmed'] }, startsAt: appointment.startsAt, }, }); expect(booked).toHaveLength(1); // exactly one winner const holds = await prisma.slotHold.findMany({ where: { id: { in: [holdA.id, holdB.id] } }, orderBy: { createdAt: 'asc' }, }); expect(holds.map((h) => h.status).sort()).toEqual(['accepted', 'cancelled']); // the loser got the "slot gone" message const loserEntry = holds.find((h) => h.status === 'cancelled')!.waitlistEntryId; const loserPatient = (await prisma.waitlistEntry.findUnique({ where: { id: loserEntry } }))! .patientId; const loserMsg = await prisma.message.findFirst({ where: { conversation: { clinicId, patientId: loserPatient }, direction: 'outbound' }, orderBy: { createdAt: 'desc' }, }); expect(loserMsg?.body).toContain('az önce doldu'); }); it('cancellation <3h before start does not trigger a refill', async () => { await cleanSlate(); await makeCandidate(0); const appointment = await makeCancelledAppointment(2); await waitlist.onAppointmentCancelled(appointment.id); expect(await activeHold()).toBeNull(); }); it('candidates outside their preferred window or for other services are skipped', async () => { await cleanSlate(); // window that ended long before the slot await makeCandidate(0, { fromDate: '2020-01-01', toDate: '2020-01-31' }); const matching = await makeCandidate(60_000, {}); // no constraints const appointment = await makeCancelledAppointment(30); await waitlist.onAppointmentCancelled(appointment.id); const hold = await activeHold(); expect(hold!.waitlistEntryId).toBe(matching.entryId); }); it('the cancelling patient is never offered their own freed slot', async () => { await cleanSlate(); // canceller is themselves on the waitlist await prisma.waitlistEntry.create({ data: { clinicId, patientId: cancellerPatientId, serviceId, preferredWindow: {} }, }); const appointment = await makeCancelledAppointment(32); await waitlist.onAppointmentCancelled(appointment.id); expect(await activeHold()).toBeNull(); }); it('no refill when the slot was already rebooked', async () => { await cleanSlate(); await makeCandidate(0); const appointment = await makeCancelledAppointment(34); // someone else booked the slot through the agent meanwhile await prisma.appointment.create({ data: { clinicId, patientId: cancellerPatientId, staffId, serviceId, startsAt: appointment.startsAt, endsAt: appointment.endsAt, status: 'pending', source: 'dashboard', }, }); await waitlist.onAppointmentCancelled(appointment.id); expect(await activeHold()).toBeNull(); }); });