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