Skip to content
Merged
11 changes: 10 additions & 1 deletion __tests__/booking-algorithm/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,13 @@ function makeMockTx(appointmentData: any = null) {
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() },
// transitionSlotCompletion reads the from-status, then moves the cohort
// with updateManyAndReturn so each moved id gets its history row.
slotOfAppointment: {
findMany: jest.fn().mockResolvedValue([]),
updateManyAndReturn: jest.fn().mockResolvedValue([{ id: "slot-1" }]),
deleteMany: jest.fn(),
},
appointmentParticipant: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
Expand All @@ -317,6 +323,9 @@ function makeMockTx(appointmentData: any = null) {
// Cancel closes any live reschedule proposal so the appointment's
// openForAppointmentId reservation is released.
rescheduleRequest: {
// The cancel route reads the open proposals, then CASes each by id.
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue({ status: "PENDING_REVIEW" }),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
create: jest.fn().mockResolvedValue({ id: "reschedule-request-1" }),
},
Expand Down
24 changes: 20 additions & 4 deletions __tests__/booking-algorithm/reschedule-preference-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,31 @@ function makeMockTx() {
{ id: "apt-2", slotsOfAppointment: [SIBLING_SLOTS[1]] },
]),
},
// Each transition helper reads the from-status before its CAS and appends
// one BookingStatusHistory row after it.
subscription: {
findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
count: jest.fn().mockResolvedValue(1),
},
consultation: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
webinar: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
class: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
consultation: {
findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
webinar: {
findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
class: {
findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) },
slotOfAppointment: {
updateMany: jest.fn().mockResolvedValue({ count: 2 }),
findMany: jest.fn().mockResolvedValue([]),
updateManyAndReturn: jest
.fn()
.mockResolvedValue([{ id: "slot-1" }, { id: "slot-2" }]),
},
rescheduleRequest: {
create: jest.fn().mockImplementation(({ data }) => {
Expand Down
27 changes: 19 additions & 8 deletions __tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,20 @@ interface StatusCas {
where: { id: string; status?: { in: string[] } };
data: Data;
}
/** transitionSlotCompletion's shape: the from-set is an `in` list. */
interface SlotCas {
where: { id: { in: string[] }; completionStatus: string };
where: { id: { in: string[] }; completionStatus: { in: string[] } };
data: Data;
}

function matchSlots(where: SlotCas["where"]): SlotRow[] {
return state.slots.filter(
(s) =>
where.id.in.includes(s.id) &&
where.completionStatus.in.includes(s.completionStatus),
);
}

function makeTx() {
return {
bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) },
Expand All @@ -70,14 +79,16 @@ function makeTx() {
}),
},
slotOfAppointment: {
updateMany: jest.fn(async ({ where, data }: SlotCas) => {
const targets = state.slots.filter(
(s) =>
where.id.in.includes(s.id) &&
s.completionStatus === where.completionStatus,
);
findMany: jest.fn(async ({ where }: SlotCas) =>
matchSlots(where).map((s) => ({
id: s.id,
completionStatus: s.completionStatus,
})),
),
updateManyAndReturn: jest.fn(async ({ where, data }: SlotCas) => {
const targets = matchSlots(where);
targets.forEach((s) => Object.assign(s, data));
return { count: targets.length };
return targets.map((s) => ({ id: s.id }));
}),
},
subscription: {
Expand Down
111 changes: 68 additions & 43 deletions __tests__/booking-algorithm/rescheduleCancel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,13 @@
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() },
// transitionSlotCompletion reads the from-status, then moves the cohort
// with updateManyAndReturn so each moved id gets its history row.
slotOfAppointment: {
findMany: jest.fn().mockResolvedValue([]),
updateManyAndReturn: jest.fn().mockResolvedValue([{ id: "slot-1" }]),
deleteMany: jest.fn(),
},
appointmentParticipant: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
Expand All @@ -283,6 +289,9 @@
// openForAppointmentId reservation is released and the expiry cron cannot
// act on a cancelled booking. Reschedule creates one when times are proposed.
rescheduleRequest: {
// The cancel route reads the open proposals, then CASes each by id.
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue({ status: "PENDING_REVIEW" }),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
create: jest.fn().mockResolvedValue({ id: "reschedule-request-1" }),
},
Expand Down Expand Up @@ -377,7 +386,7 @@
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe("SCHEDULE_CONFLICT");

Check failure on line 389 in __tests__/booking-algorithm/rescheduleCancel.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
}
});

Expand Down Expand Up @@ -406,7 +415,7 @@
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.notes).toBe(

Check failure on line 418 in __tests__/booking-algorithm/rescheduleCancel.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
"Need to cancel due to scheduling conflict",
);
}
Expand Down Expand Up @@ -552,14 +561,17 @@
expect(body.slotsAffected).toBe(2);

// Verify slots marked tentative by appointmentId (non-subscription path)
expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({
where: {
appointmentId: "apt-1",
// #837 — reschedule never resurrects COMPLETED/CANCELLED history
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
});
expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith(
// objectContaining: `select` is the helper's own business.
expect.objectContaining({
where: {
appointmentId: "apt-1",
// #837 — reschedule never resurrects COMPLETED/CANCELLED history
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
}),
);

// Verify consultation status reverted
expect(mockTx.consultation.updateMany).toHaveBeenCalledWith({
Expand Down Expand Up @@ -617,13 +629,16 @@
expect(body.rescheduleType).toBe("entire_booking");

// Should mark all appointment slots tentative
expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({
where: {
appointmentId: { in: ["apt-1", "apt-2"] },
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
});
expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith(
// objectContaining: `select` is the helper's own business.
expect.objectContaining({
where: {
appointmentId: { in: ["apt-1", "apt-2"] },
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
}),
);

// Should update subscription status
expect(mockTx.subscription.updateMany).toHaveBeenCalledWith({
Expand Down Expand Up @@ -668,13 +683,16 @@
// The route marks ALL slots belonging to the affected appointment(s), not just the
// specified slot ID. This ensures multi-slot sessions (e.g. 1.5h = 3 × 30-min slots)
// are rescheduled atomically — a partial-tentative session would be inconsistent.
expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({
where: {
appointmentId: { in: ["apt-1"] },
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
});
expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith(
// objectContaining: `select` is the helper's own business.
expect.objectContaining({
where: {
appointmentId: { in: ["apt-1"] },
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
}),
);
});

it("should return 'multiple_sessions' type when slotIds span multiple sessions", async () => {
Expand Down Expand Up @@ -759,14 +777,17 @@
expect(body.success).toBe(true);
expect(body.rescheduleType).toBe("entire_booking");

expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({
where: {
appointmentId: "apt-1",
// #837 — reschedule never resurrects COMPLETED/CANCELLED history
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
});
expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith(
// objectContaining: `select` is the helper's own business.
expect.objectContaining({
where: {
appointmentId: "apt-1",
// #837 — reschedule never resurrects COMPLETED/CANCELLED history
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { isTentative: true, completionStatus: "RESCHEDULED" },
}),
);

expect(mockTx.webinar.updateMany).toHaveBeenCalledWith({
where: { id: "web-1", status: { in: ["SCHEDULED", "IN_PROGRESS"] } },
Expand Down Expand Up @@ -979,16 +1000,20 @@
// Verify slots soft-cancelled (not hard-deleted — preserves payment
// audit trail); only live SCHEDULED slots flip, history is never
// re-stamped.
expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalledWith({
// RESCHEDULED counts too: a slot released by a pending reschedule is
// not SCHEDULED, and skipping it left non-terminal rows on a booking
// that no longer exists.
where: {
appointmentId: "apt-1",
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
data: { completionStatus: "CANCELLED" },
});
expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith(
// objectContaining: `select` is the helper's own business.
expect.objectContaining({
// RESCHEDULED counts too: a slot released by a pending reschedule is
// not SCHEDULED, and skipping it left non-terminal rows on a booking
// that no longer exists.
where: {
appointmentId: "apt-1",
completionStatus: { in: ["SCHEDULED", "RESCHEDULED"] },
},
// The tombstone is the other half of the soft-cancel (#676 A10).
data: { completionStatus: "CANCELLED", deletedAt: expect.any(Date) },
}),
);

// Verify appointment is NOT deleted (soft-cancel preserves records)
expect(mockTx.appointment.delete).not.toHaveBeenCalled();
Expand All @@ -998,8 +1023,8 @@
const req = makeCancelRequest("apt-1");
await cancelHandler(req, makeParams("apt-1"));

// Soft-cancel: updateMany with completionStatus, not deleteMany
expect(mockTx.slotOfAppointment.updateMany).toHaveBeenCalled();
// Soft-cancel: a completionStatus write, not deleteMany
expect(mockTx.slotOfAppointment.updateManyAndReturn).toHaveBeenCalled();
expect(mockTx.slotOfAppointment.deleteMany).not.toHaveBeenCalled();
expect(mockTx.appointment.delete).not.toHaveBeenCalled();
});
Expand Down
17 changes: 16 additions & 1 deletion __tests__/booking-algorithm/rescheduleResponses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,15 @@ function makeMockTx(appointmentData: any) {
},
consultation: {
update: jest.fn(),
// Each transition helper reads the from-status before its CAS.
findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }),
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
subscription: {
update: jest.fn(),
// Each transition helper reads the from-status before its CAS.
findUnique: jest.fn().mockResolvedValue({ status: "APPROVED" }),
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
// #448 — a PARTIAL (slotIds) subscription reschedule only terminal-guards
Expand All @@ -177,15 +181,26 @@ function makeMockTx(appointmentData: any) {
},
webinar: {
update: jest.fn(),
// Each transition helper reads the from-status before its CAS.
findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }),
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
class: {
update: jest.fn(),
// Each transition helper reads the from-status before its CAS.
findUnique: jest.fn().mockResolvedValue({ status: "SCHEDULED" }),
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
slotOfAppointment: { updateMany: jest.fn(), deleteMany: jest.fn() },
// transitionSlotCompletion reads the from-status, then moves the cohort
// with updateManyAndReturn so each moved id gets its history row.
slotOfAppointment: {
findMany: jest.fn().mockResolvedValue([]),
updateManyAndReturn: jest.fn().mockResolvedValue([{ id: "slot-1" }]),
deleteMany: jest.fn(),
},
bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) },
};
}

Expand Down
Loading
Loading