diff --git a/__tests__/booking-algorithm/allocationAlgorithms.test.ts b/__tests__/booking-algorithm/allocationAlgorithms.test.ts index 41ae78555..127e92c8c 100644 --- a/__tests__/booking-algorithm/allocationAlgorithms.test.ts +++ b/__tests__/booking-algorithm/allocationAlgorithms.test.ts @@ -4,7 +4,7 @@ * Covers: * - manualAllocate (validation, business rules, error handling) * - autoAllocate (all strategies, preference filtering, error handling) - * - preAllocate (validation, delegation) + * - allocateRequestedSlots (validation, delegation; formerly preAllocate) * - filterSlotsByPreferences (private, tested via autoAllocate) * - allocateConsultationSlots (session duration fix verification) * - allocateWebinarSlots (consecutive slot finding) @@ -606,11 +606,11 @@ describe("AllocationAlgorithms.autoAllocate", () => { }); }); -// ─── preAllocate ──────────────────────────────────────────────────────────── +// ─── allocateRequestedSlots ───────────────────────────────────────────────── -describe("AllocationAlgorithms.preAllocate", () => { +describe("AllocationAlgorithms.allocateRequestedSlots", () => { it("should reject when no requested slots provided", async () => { - const result = await AllocationAlgorithms.preAllocate({ + const result = await AllocationAlgorithms.allocateRequestedSlots({ eventType: "consultation", eventId: "event-1", durationInHours: 1, @@ -620,7 +620,7 @@ describe("AllocationAlgorithms.preAllocate", () => { }); it("should reject when requested slots is empty array", async () => { - const result = await AllocationAlgorithms.preAllocate({ + const result = await AllocationAlgorithms.allocateRequestedSlots({ eventType: "consultation", eventId: "event-1", durationInHours: 1, @@ -631,7 +631,7 @@ describe("AllocationAlgorithms.preAllocate", () => { it("should reject wrong number of requested slots", async () => { const slots = makeFutureConsecutiveSlots("2025-06-01T09:00:00Z", 1); - const result = await AllocationAlgorithms.preAllocate({ + const result = await AllocationAlgorithms.allocateRequestedSlots({ eventType: "consultation", eventId: "event-1", durationInHours: 1, @@ -643,7 +643,7 @@ describe("AllocationAlgorithms.preAllocate", () => { it("should succeed with correct number of slots", async () => { const slots = makeFutureConsecutiveSlots("2025-06-01T09:00:00Z", 2); - const result = await AllocationAlgorithms.preAllocate({ + const result = await AllocationAlgorithms.allocateRequestedSlots({ eventType: "consultation", eventId: "event-1", durationInHours: 1, @@ -655,7 +655,11 @@ describe("AllocationAlgorithms.preAllocate", () => { "consultation", "event-1", slots, - { useRequestedSlots: true }, + { + useRequestedSlots: true, + idempotencyKey: undefined, + initialAllocation: undefined, + }, ); }); @@ -666,7 +670,7 @@ describe("AllocationAlgorithms.preAllocate", () => { }); const slots = makeFutureConsecutiveSlots("2025-06-01T09:00:00Z", 2); - const result = await AllocationAlgorithms.preAllocate({ + const result = await AllocationAlgorithms.allocateRequestedSlots({ eventType: "consultation", eventId: "event-1", durationInHours: 1, @@ -680,7 +684,7 @@ describe("AllocationAlgorithms.preAllocate", () => { mockAllocateSlots.mockRejectedValue(new Error("Connection failed")); const slots = makeFutureConsecutiveSlots("2025-06-01T09:00:00Z", 2); - const result = await AllocationAlgorithms.preAllocate({ + const result = await AllocationAlgorithms.allocateRequestedSlots({ eventType: "consultation", eventId: "event-1", durationInHours: 1, diff --git a/__tests__/booking-algorithm/calendarUtils.test.ts b/__tests__/booking-algorithm/calendarUtils.test.ts index fe348b3f6..60ae07bdf 100644 --- a/__tests__/booking-algorithm/calendarUtils.test.ts +++ b/__tests__/booking-algorithm/calendarUtils.test.ts @@ -438,7 +438,8 @@ describe("Delegated week functions", () => { it("startOfWeekSunday should return Sunday", () => { const result = startOfWeekSunday(new Date("2025-01-08")); // Wednesday - expect(result.getDay()).toBe(0); + // UTC weekday — this helper is UTC-based; local getDay() shifts by machine TZ + expect(result.getUTCDay()).toBe(0); }); }); diff --git a/__tests__/booking-algorithm/idempotency-key.test.ts b/__tests__/booking-algorithm/idempotency-key.test.ts new file mode 100644 index 000000000..0a59c4833 --- /dev/null +++ b/__tests__/booking-algorithm/idempotency-key.test.ts @@ -0,0 +1,91 @@ +/** + * Idempotency-Key lifecycle for allocation attempts. A retry of the SAME + * payload must reuse the key (the server replays the original batch, #837); + * any change to mode, event, or slots must mint a fresh key. + */ + +import "./setup"; + +import { + computeAttemptFingerprint, + resolveAttemptKey, +} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation"; +// eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) +import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; +import type { TimeSlot } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; + +const slots = makeConsecutiveTimeSlots( + "2026-08-03T09:00:00.000Z", + 2, +) as TimeSlot[]; + +describe("computeAttemptFingerprint", () => { + it("is stable regardless of slot order", () => { + const reversed = [...slots].reverse(); + expect(computeAttemptFingerprint("manual", "e1", slots)).toBe( + computeAttemptFingerprint("manual", "e1", reversed), + ); + }); + + it("differs across modes, events, and slot sets", () => { + const fp = computeAttemptFingerprint("manual", "e1", slots); + expect(computeAttemptFingerprint("auto", "e1", slots)).not.toBe(fp); + expect(computeAttemptFingerprint("manual", "e2", slots)).not.toBe(fp); + expect( + computeAttemptFingerprint( + "manual", + "e1", + makeConsecutiveTimeSlots("2026-08-04T09:00:00.000Z", 2) as TimeSlot[], + ), + ).not.toBe(fp); + }); +}); + +describe("resolveAttemptKey", () => { + it("reuses the key for an identical retry", () => { + const fp = computeAttemptFingerprint("manual", "e1", slots); + const first = resolveAttemptKey(null, fp); + const retry = resolveAttemptKey(first, fp); + expect(retry.key).toBe(first.key); + }); + + it("mints a new key when the payload changes", () => { + const first = resolveAttemptKey( + null, + computeAttemptFingerprint("manual", "e1", slots), + ); + const changed = resolveAttemptKey( + first, + computeAttemptFingerprint("manual", "e1", [ + ...slots, + ...(makeConsecutiveTimeSlots( + "2026-08-05T09:00:00.000Z", + 2, + ) as TimeSlot[]), + ]), + ); + expect(changed.key).not.toBe(first.key); + }); + + it("mints a new key when the mode changes", () => { + const manual = resolveAttemptKey( + null, + computeAttemptFingerprint("manual", "e1", slots), + ); + const auto = resolveAttemptKey( + manual, + computeAttemptFingerprint("auto", "e1", []), + ); + expect(auto.key).not.toBe(manual.key); + }); + + it("keys look like UUIDs", () => { + const attempt = resolveAttemptKey( + null, + computeAttemptFingerprint("auto", "e1", []), + ); + expect(attempt.key).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); +}); diff --git a/__tests__/booking-algorithm/initial-allocation-guard.test.ts b/__tests__/booking-algorithm/initial-allocation-guard.test.ts new file mode 100644 index 000000000..04888466e --- /dev/null +++ b/__tests__/booking-algorithm/initial-allocation-guard.test.ts @@ -0,0 +1,232 @@ +/** + * initialAllocation multi-tab guard (service level). + * + * Auto locks the whole consultant while manual shards its Redis key by day + * (#860), so a cross-mode race between two tabs slips past the locks and the + * manual path would silently delete-and-replace the winner's allocation. + * With `initialAllocation: true` (sent by the Allocate Slots dialog for fresh + * PENDING requests) any existing confirmed slot must produce a typed 409. + * Without the flag, replace/reschedule semantics are preserved. + */ + +import "./setup"; + +// Mock prisma (relative path required — @/ aliases fail in jest.mock) +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + $transaction: jest.fn(), + consultation: { findUnique: jest.fn() }, + subscription: { findUnique: jest.fn() }, + webinar: { findUnique: jest.fn() }, + class: { findUnique: jest.fn() }, + appointment: { findMany: jest.fn(), findFirst: jest.fn() }, + slotOfAppointment: { count: jest.fn() }, + }, + ALLOCATION_TX_MAX_WAIT_MS: 8000, + ALLOCATION_TX_TIMEOUT_MS: 30000, +})); + +// Mock SlotValidationService so the race-window test can reach the write +// transaction without a full availability fixture (same pattern as +// slotAllocationService.test.ts). +const mockValidateFn = jest.fn(); +const mockRevalidateConflictsFn = jest.fn(); +jest.mock("../../utils/slotAllocation/SlotValidationService", () => ({ + ...jest.requireActual("../../utils/slotAllocation/SlotValidationService"), + SlotValidationService: jest.fn().mockImplementation(() => ({ + validate: mockValidateFn, + revalidateConflicts: mockRevalidateConflictsFn, + })), +})); + +// Mock appointmentlock to avoid @upstash/redis ESM import issues in Jest +jest.mock("../../utils/appointmentlock", () => ({ + lockAutoAllocate: jest + .fn() + .mockResolvedValue({ key: "mock-key", value: "mock-value" }), + unlockAutoAllocate: jest.fn().mockResolvedValue(undefined), + lockConsulteeBooking: jest + .fn() + .mockResolvedValue({ key: "mock-consultee-key", value: "mock-value" }), + unlockConsulteeBooking: jest.fn().mockResolvedValue(undefined), +})); + +import prisma from "@/lib/prisma"; +import { SlotAllocationService } from "@/utils/slotAllocation/SlotAllocationService"; + +const mockPrisma = prisma as unknown as { + $transaction: jest.Mock; + subscription: { findUnique: jest.Mock }; + appointment: { findMany: jest.Mock; findFirst: jest.Mock }; + slotOfAppointment: { count: jest.Mock }; +}; + +const FUTURE_SLOTS = [ + "2026-08-03T09:00:00.000Z", + "2026-08-03T09:30:00.000Z", +]; + +beforeEach(() => { + jest.clearAllMocks(); + // getConsultantProfileId + getConsulteeUserId both read subscription.findUnique + // with different selects; return a superset that satisfies both. + mockPrisma.subscription.findUnique.mockResolvedValue({ + subscriptionPlan: { consultantProfileId: "cp-1" }, + requestedBy: { user: { id: "user-1" } }, + }); + mockPrisma.appointment.findFirst.mockResolvedValue(null); + mockPrisma.appointment.findMany.mockResolvedValue([]); +}); + +describe("manual allocation with initialAllocation", () => { + it("returns a typed 409 when another session already confirmed slots", async () => { + mockPrisma.slotOfAppointment.count.mockResolvedValue(4); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + initialAllocation: true, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(409); + expect(result.error).toContain("already allocated in another session"); + // The guard fires before any event data is fetched or written. + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("counts only confirmed slots — tentative checkout holds do not trip the guard", async () => { + mockPrisma.slotOfAppointment.count.mockResolvedValue(0); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + initialAllocation: true, + }); + + // Guard passes (count=0) and the flow proceeds until event data is + // missing in this harness — a NOT_FOUND, decisively not the 409 guard. + expect(mockPrisma.slotOfAppointment.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isTentative: false }), + }), + ); + expect(result.httpStatus).not.toBe(409); + }); + + it("without the flag, existing confirmed slots do NOT 409 (replace/reschedule preserved)", async () => { + mockPrisma.slotOfAppointment.count.mockResolvedValue(4); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + }); + + expect(mockPrisma.slotOfAppointment.count).not.toHaveBeenCalled(); + expect(result.error).not.toContain("already allocated in another session"); + }); +}); + +describe("manual allocation: transaction race window", () => { + it("409s when the pre-lock count sees zero but the in-txn count sees confirmed slots", async () => { + // Tab B commits between tab A's out-of-txn guard and its write txn: the + // first count returns 0, the advisory-locked in-txn count returns 2. + mockPrisma.slotOfAppointment.count.mockResolvedValueOnce(0); + mockPrisma.subscription.findUnique.mockResolvedValue({ + subscriptionPlan: { + consultantProfileId: "cp-1", + consultantProfile: { + user: { id: "consultant-user-1" }, + scheduleType: "WEEKLY", + slotsOfAvailabilityWeekly: [], + slotsOfAvailabilityCustom: [], + }, + durationInMonths: 1, + callsPerWeek: 1, + sessionDurationInHours: 1, + totalSessions: 1, + }, + requestedBy: { user: { id: "user-1" } }, + appointments: [], + schedulingPeriodStartsAt: new Date("2026-08-02T00:00:00.000Z"), + schedulingPeriodEndsAt: new Date("2026-08-29T23:59:59.000Z"), + schedulingTimezone: "Asia/Kolkata", + }); + mockValidateFn.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [], + }); + mockRevalidateConflictsFn.mockResolvedValue({ isValid: true, errors: [] }); + + const mockTx = { + $queryRaw: jest.fn().mockResolvedValue([]), + slotOfAppointment: { count: jest.fn().mockResolvedValue(2) }, + }; + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: unknown) => Promise) => fn(mockTx), + ); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "manual", + slots: FUTURE_SLOTS, + initialAllocation: true, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(409); + // The advisory lock is taken before the in-txn count + expect(mockTx.$queryRaw).toHaveBeenCalled(); + expect(mockTx.slotOfAppointment.count).toHaveBeenCalled(); + }); +}); + +describe("auto allocation with initialAllocation", () => { + it("returns a typed 409 when another session already confirmed slots", async () => { + mockPrisma.slotOfAppointment.count.mockResolvedValue(2); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "auto", + initialAllocation: true, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(409); + expect(result.error).toContain("already allocated in another session"); + }); +}); + +describe("requested allocation with initialAllocation", () => { + it("re-checks the guard INSIDE the transaction and 409s", async () => { + const mockTx = { + // Advisory xact lock taken before the guard count (ADR B10 atomicity) + $queryRaw: jest.fn().mockResolvedValue([]), + slotOfAppointment: { count: jest.fn().mockResolvedValue(2) }, + }; + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: unknown) => Promise) => fn(mockTx), + ); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-1", + mode: "requested", + initialAllocation: true, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(409); + expect(mockTx.slotOfAppointment.count).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/booking-algorithm/mode-parity.test.ts b/__tests__/booking-algorithm/mode-parity.test.ts new file mode 100644 index 000000000..3f16d18b2 --- /dev/null +++ b/__tests__/booking-algorithm/mode-parity.test.ts @@ -0,0 +1,347 @@ +/** + * Mode parity: the three allocation entry points (manual / auto / requested) + * must agree on required-slot math — including the in-progress reschedule + * reduction — and auto-allocate's output must pass the manual validators + * under the shared UTC bucketing. + */ + +process.env.TZ = "Asia/Kolkata"; + +import "./setup"; + +import { + AllocationAlgorithms, + type AllocationOptions, +} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms"; +import { AllocationService } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService"; +import { + validateEventSlots, + getEventConstraints, + getSlotLimits, + groupSlotsByDay, +} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation"; +import { + validateSlotDistribution, + type TimeSlot, +} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +// eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) +import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; + +let mockAllocateSlots: jest.SpyInstance; + +beforeEach(() => { + mockAllocateSlots = jest + .spyOn(AllocationService, "allocateSlots") + .mockResolvedValue({ success: true, data: [] }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** Availability grid: `hoursPerDay` hours of 30-min slots per day for every + * day in [start, days). Times chosen to include the IST/UTC boundary. */ +function makeAvailabilityGrid( + startISO: string, + days: number, + hoursPerDay = 4, +): TimeSlot[] { + const slots: TimeSlot[] = []; + const dayStart = new Date(startISO); + for (let d = 0; d < days; d++) { + const base = new Date(dayStart.getTime() + d * 24 * 60 * 60 * 1000); + slots.push( + ...(makeConsecutiveTimeSlots( + base.toISOString(), + hoursPerDay * 2, + ) as TimeSlot[]), + ); + } + return slots; +} + +const FUTURE_SUNDAY = "2026-08-02T09:00:00.000Z"; // Sunday, comfortably future + +describe("auto-allocate output passes manual validation (UTC bucketing)", () => { + it.each([ + { callsPerWeek: 1, sessionDurationInHours: 1 }, + { callsPerWeek: 2, sessionDurationInHours: 1.5 }, + { callsPerWeek: 3, sessionDurationInHours: 0.5 }, + ])( + "subscription %o: picked slots satisfy every manual validator", + async ({ callsPerWeek, sessionDurationInHours }) => { + const startDate = new Date("2026-08-02T00:00:00.000Z"); // Sunday + const endDate = new Date("2026-08-29T23:59:59.000Z"); // Saturday (4 weeks) + const totalSessions = 4 * callsPerWeek; + const slotsPerCall = Math.ceil(sessionDurationInHours / 0.5); + + const options: AllocationOptions = { + eventType: "subscription", + eventId: "sub-1", + callsPerWeek, + sessionDurationInHours, + startDate, + endDate, + totalSessions, + maxCallsPerDay: 1, + }; + + const result = await AllocationAlgorithms.autoAllocate( + makeAvailabilityGrid(FUTURE_SUNDAY, 28, 6), + options, + ); + + expect(result.success).toBe(true); + const picked = result.selectedSlots; + expect(picked).toHaveLength(totalSessions * slotsPerCall); + + // The same selection must pass the interactive validators… + const validationOptions = { + callsPerWeek, + sessionDurationInHours, + maxTotalCalls: totalSessions, + startDate, + endDate, + }; + const constraints = getEventConstraints("subscription", validationOptions); + const limits = getSlotLimits("subscription", validationOptions); + const verdict = validateEventSlots( + picked, + "subscription", + constraints, + limits, + validationOptions, + ); + expect(verdict.errors).toEqual([]); + expect(verdict.isValid).toBe(true); + + // …and the manual-allocate weekly distribution check… + const distribution = validateSlotDistribution( + picked, + callsPerWeek * slotsPerCall, + ); + expect(distribution.isValid).toBe(true); + + // …and the per-day cap under UTC day keys. + groupSlotsByDay(picked).forEach((daySlots) => { + expect(daySlots.length).toBeLessThanOrEqual(slotsPerCall); + }); + }, + ); + + it("explicit non-default timezone is honored end-to-end (America/New_York)", async () => { + const schedulingTimezone = "America/New_York"; + const startDate = new Date("2026-08-02T00:00:00.000Z"); + const endDate = new Date("2026-08-29T23:59:59.000Z"); + const options: AllocationOptions = { + eventType: "subscription", + eventId: "sub-tz", + callsPerWeek: 1, + sessionDurationInHours: 1, + startDate, + endDate, + totalSessions: 4, + maxCallsPerDay: 1, + schedulingTimezone, + }; + + const result = await AllocationAlgorithms.autoAllocate( + makeAvailabilityGrid(FUTURE_SUNDAY, 28, 6), + options, + ); + expect(result.success).toBe(true); + + const validationOptions = { + callsPerWeek: 1, + sessionDurationInHours: 1, + maxTotalCalls: 4, + startDate, + endDate, + schedulingTimezone, + }; + const verdict = validateEventSlots( + result.selectedSlots, + "subscription", + getEventConstraints("subscription", validationOptions), + getSlotLimits("subscription", validationOptions), + validationOptions, + ); + expect(verdict.errors).toEqual([]); + + // Per-day cap holds under the SAME (non-default) timezone the allocator used + groupSlotsByDay(result.selectedSlots, schedulingTimezone).forEach( + (daySlots) => { + expect(daySlots.length).toBeLessThanOrEqual(2); + }, + ); + expect( + validateSlotDistribution(result.selectedSlots, 2, schedulingTimezone) + .isValid, + ).toBe(true); + }); + + it("class: per-day cap of 2 sessions holds in the auto output", async () => { + const startDate = new Date("2026-08-02T00:00:00.000Z"); + const endDate = new Date("2026-08-29T23:59:59.000Z"); + const options: AllocationOptions = { + eventType: "class", + eventId: "class-1", + callsPerWeek: 2, + sessionDurationInHours: 1, + startDate, + endDate, + totalSessions: 8, + maxSessionsPerDay: 2, + }; + + const result = await AllocationAlgorithms.autoAllocate( + makeAvailabilityGrid(FUTURE_SUNDAY, 28, 8), + options, + ); + expect(result.success).toBe(true); + + groupSlotsByDay(result.selectedSlots).forEach((daySlots) => { + // ≤ 2 sessions of 2 atoms per UTC day + expect(daySlots.length).toBeLessThanOrEqual(2 * 2); + }); + }); +}); + +describe("required-count parity across the three modes", () => { + const base: AllocationOptions = { + eventType: "subscription", + eventId: "sub-1", + callsPerWeek: 1, + sessionDurationInHours: 1, + startDate: new Date("2026-08-02T00:00:00.000Z"), + endDate: new Date("2026-08-29T23:59:59.000Z"), + totalSessions: 4, // 4 sessions × 2 atoms = 8 slots + }; + + it("manual and requested reject the same wrong count with the same expectation", async () => { + const sixSlots = [ + ...makeConsecutiveTimeSlots("2026-08-03T09:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-08-10T09:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-08-17T09:00:00.000Z", 2), + ] as TimeSlot[]; + + const manual = await AllocationAlgorithms.manualAllocate(sixSlots, base); + expect(manual.success).toBe(false); + expect(manual.error).toContain("Expected 8 slots but received 6"); + + const requested = await AllocationAlgorithms.allocateRequestedSlots({ + ...base, + requestedSlots: sixSlots, + }); + expect(requested.success).toBe(false); + expect(requested.error).toContain("Requested 6 slots but need 8"); + }); + + it("requested honors pastConfirmedSlotCount like manual (in-progress reschedule)", async () => { + // 1 of 4 sessions already confirmed in the past → only 6 future atoms due. + const withPast = { ...base, pastConfirmedSlotCount: 2 }; + const sixSlots = [ + ...makeConsecutiveTimeSlots("2026-08-03T09:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-08-10T09:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-08-17T09:00:00.000Z", 2), + ] as TimeSlot[]; + + const manual = await AllocationAlgorithms.manualAllocate( + sixSlots, + withPast, + ); + expect(manual.success).toBe(true); + + const requested = await AllocationAlgorithms.allocateRequestedSlots({ + ...withPast, + requestedSlots: sixSlots, + }); + expect(requested.success).toBe(true); + expect(mockAllocateSlots).toHaveBeenCalledTimes(2); + }); + + it("requested honors totalSessions as authoritative (previously ignored)", async () => { + // Period spans 4 weeks × 1 call = 4 sessions, but the plan says 2. + const twoSessionPlan = { ...base, totalSessions: 2 }; + const fourSlots = [ + ...makeConsecutiveTimeSlots("2026-08-03T09:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-08-10T09:00:00.000Z", 2), + ] as TimeSlot[]; + + const requested = await AllocationAlgorithms.allocateRequestedSlots({ + ...twoSessionPlan, + requestedSlots: fourSlots, + }); + expect(requested.success).toBe(true); + }); +}); + +describe("getSlotLimits defensive bounds", () => { + it("maxSlots floors at 0 when past sessions exceed the plan total (over-allocated data)", () => { + const limits = getSlotLimits("subscription", { + sessionDurationInHours: 1, + maxTotalCalls: 4, + // 6 past sessions × 2 atoms — more than the plan's 4 sessions + pastConfirmedSlotCount: 12, + callsPerWeek: 1, + startDate: new Date("2026-08-02T00:00:00.000Z"), + endDate: new Date("2026-08-29T23:59:59.000Z"), + }); + expect(limits.maxSlots).toBe(0); + }); +}); + +describe("consecutive-atom rules at the scheduling-timezone day boundary", () => { + it("a consultation straddling IST midnight is rejected (same-day rule, server parity)", () => { + // 18:00Z–19:00Z = 23:30–00:30 IST: consecutive but on two IST days. + const straddling = makeConsecutiveTimeSlots( + "2026-08-03T18:00:00.000Z", + 2, + ) as TimeSlot[]; + const options = { durationInHours: 1 }; + const verdict = validateEventSlots( + straddling, + "consultation", + getEventConstraints("consultation", options), + getSlotLimits("consultation", options), + options, + ); + expect(verdict.isValid).toBe(false); + expect(verdict.errors.join(" ")).toContain("same day"); + }); + + it("1.5h and 2h sessions require 3 and 4 consecutive same-day atoms", () => { + for (const [duration, atoms] of [ + [1.5, 3], + [2, 4], + ] as const) { + const options = { durationInHours: duration }; + const consecutive = makeConsecutiveTimeSlots( + "2026-08-03T09:00:00.000Z", + atoms, + ) as TimeSlot[]; + const gappy = [ + ...makeConsecutiveTimeSlots("2026-08-03T09:00:00.000Z", atoms - 1), + ...makeConsecutiveTimeSlots("2026-08-03T14:00:00.000Z", 1), + ] as TimeSlot[]; + + const good = validateEventSlots( + consecutive, + "consultation", + getEventConstraints("consultation", options), + getSlotLimits("consultation", options), + options, + ); + expect(good.isValid).toBe(true); + + const bad = validateEventSlots( + gappy, + "consultation", + getEventConstraints("consultation", options), + getSlotLimits("consultation", options), + options, + ); + expect(bad.isValid).toBe(false); + } + }); +}); diff --git a/__tests__/booking-algorithm/required-slots-periods.test.ts b/__tests__/booking-algorithm/required-slots-periods.test.ts new file mode 100644 index 000000000..ce1e1d1af --- /dev/null +++ b/__tests__/booking-algorithm/required-slots-periods.test.ts @@ -0,0 +1,172 @@ +/** + * Required-slot math across month lengths and event types. + * + * Pins countWeeks over 28/29/30/31-day scheduling periods (including a leap + * February and a 31-day month spanning six Sunday weeks) and the full + * calculateRequiredSlots matrix for every event type, callsPerWeek 1–7, and + * session durations 0.5–2h. Also pins the totalSessions-authoritative rule + * and the throw for a subscription with no period. + */ + +process.env.TZ = "Asia/Kolkata"; + +import "./setup"; + +import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService"; + +// ─── countWeeks across month lengths ──────────────────────────────────────── + +describe("countWeeks month-length permutations", () => { + it("28-day February 2027 (starts Monday) spans 5 Sunday weeks", () => { + // Feb 1 2027 is a Monday, Feb 28 a Sunday → weeks of Jan 31, Feb 7, 14, 21, 28. + expect( + SlotCalculationService.countWeeks( + new Date("2027-02-01T00:00:00.000Z"), + new Date("2027-02-28T23:59:59.000Z"), + ), + ).toBe(5); + }); + + it("29-day leap February 2028 (starts Tuesday) spans 5 Sunday weeks", () => { + // Feb 1 2028 is a Tuesday, Feb 29 a Tuesday → weeks of Jan 30, Feb 6, 13, 20, 27. + expect( + SlotCalculationService.countWeeks( + new Date("2028-02-01T00:00:00.000Z"), + new Date("2028-02-29T23:59:59.000Z"), + ), + ).toBe(5); + }); + + it("30-day June 2026 (starts Monday) spans 5 Sunday weeks", () => { + expect( + SlotCalculationService.countWeeks( + new Date("2026-06-01T00:00:00.000Z"), + new Date("2026-06-30T23:59:59.000Z"), + ), + ).toBe(5); + }); + + it("31-day August 2026 starting on a Saturday spans 6 Sunday weeks", () => { + // Aug 1 2026 is a Saturday → weeks of Jul 26, Aug 2, 9, 16, 23, 30. + expect( + SlotCalculationService.countWeeks( + new Date("2026-08-01T00:00:00.000Z"), + new Date("2026-08-31T23:59:59.000Z"), + ), + ).toBe(6); + }); + + it("an exact Sunday-to-Saturday 28-day window spans exactly 4 weeks", () => { + // Jun 21 2026 is a Sunday; Jul 18 2026 is a Saturday. + expect( + SlotCalculationService.countWeeks( + new Date("2026-06-21T00:00:00.000Z"), + new Date("2026-07-18T23:59:59.000Z"), + ), + ).toBe(4); + }); + + it("a mid-week 30-day rolling window (like the checkout default) spans 5 weeks", () => { + // Jun 21 → Jul 21 2026 (the screenshot scenario): Sun-start through Tue. + expect( + SlotCalculationService.countWeeks( + new Date("2026-06-21T15:19:00.000Z"), + new Date("2026-07-21T15:19:00.000Z"), + ), + ).toBe(5); + }); +}); + +// ─── calculateRequiredSlots matrix ────────────────────────────────────────── + +describe("calculateRequiredSlots per event type", () => { + const durations = [0.5, 1, 1.5, 2]; + + it.each(durations)( + "consultation of %sh needs ceil(d/0.5) slots", + (duration) => { + expect( + SlotCalculationService.calculateRequiredSlots("consultation", { + durationInHours: duration, + }), + ).toBe(Math.ceil(duration / 0.5)); + }, + ); + + it.each(durations)("webinar of %sh needs ceil(d/0.5) slots", (duration) => { + expect( + SlotCalculationService.calculateRequiredSlots("webinar", { + durationInHours: duration, + }), + ).toBe(Math.ceil(duration / 0.5)); + }); + + it("subscription matrix: callsPerWeek 1–7 × duration 0.5–2h over an exact 4-week period", () => { + const schedulingPeriodStartsAt = new Date("2026-06-21T00:00:00.000Z"); // Sunday + const schedulingPeriodEndsAt = new Date("2026-07-18T23:59:59.000Z"); // Saturday + for (let callsPerWeek = 1; callsPerWeek <= 7; callsPerWeek++) { + for (const sessionDurationInHours of durations) { + const slotsPerCall = Math.ceil(sessionDurationInHours / 0.5); + expect( + SlotCalculationService.calculateRequiredSlots("subscription", { + schedulingPeriodStartsAt, + schedulingPeriodEndsAt, + callsPerWeek, + sessionDurationInHours, + }), + ).toBe(4 * callsPerWeek * slotsPerCall); + } + } + }); + + it("class matrix mirrors subscription math", () => { + const schedulingPeriodStartsAt = new Date("2026-06-21T00:00:00.000Z"); + const schedulingPeriodEndsAt = new Date("2026-07-18T23:59:59.000Z"); + for (let callsPerWeek = 1; callsPerWeek <= 7; callsPerWeek++) { + for (const sessionDurationInHours of durations) { + const slotsPerSession = Math.ceil(sessionDurationInHours / 0.5); + expect( + SlotCalculationService.calculateRequiredSlots("class", { + schedulingPeriodStartsAt, + schedulingPeriodEndsAt, + callsPerWeek, + sessionDurationInHours, + }), + ).toBe(4 * callsPerWeek * slotsPerSession); + } + } + }); + + it("totalSessions from the plan overrides weeks × callsPerWeek (28-day period spanning 5 weeks)", () => { + // Rolling 28-day window that touches 5 Sunday weeks — the plan's 4 + // sessions win over 5 × callsPerWeek. + expect( + SlotCalculationService.calculateRequiredSlots("subscription", { + schedulingPeriodStartsAt: new Date("2026-06-24T00:00:00.000Z"), // Wednesday + schedulingPeriodEndsAt: new Date("2026-07-21T23:59:59.000Z"), + callsPerWeek: 1, + sessionDurationInHours: 1, + totalSessions: 4, + }), + ).toBe(4 * 2); + }); + + it("subscription without a scheduling period throws (no silent 4-weeks/month guess)", () => { + expect(() => + SlotCalculationService.calculateRequiredSlots("subscription", { + callsPerWeek: 1, + sessionDurationInHours: 1, + durationInMonths: 1, + }), + ).toThrow(/Start date and end date are required/); + }); + + it("class without a scheduling period throws", () => { + expect(() => + SlotCalculationService.calculateRequiredSlots("class", { + callsPerWeek: 1, + sessionDurationInHours: 1, + }), + ).toThrow(/Start date and end date are required/); + }); +}); diff --git a/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts b/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts new file mode 100644 index 000000000..7a2f783ad --- /dev/null +++ b/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts @@ -0,0 +1,189 @@ +/** + * Scheduling-timezone day/week bucketing tests (ADR B9). + * + * Limits bucket by the event's schedulingTimezone (default Asia/Kolkata), so + * "one session per day" means one session per calendar day AS THE CUSTOMER + * SEES IT — and the verdict is identical on every machine because the keys + * come from Intl with an explicit timezone, never from the process locale. + */ + +// Deliberately hostile process timezone: if any bucketing accidentally uses +// local time, these tests fail under UTC while production browsers run IST. +process.env.TZ = "UTC"; + +import "./setup"; + +import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService"; +import { + validateSubscriptionSlots, + groupSlotsByDay, + countCompleteCallsInMap, + dayKey, + weekKey, + type SlotLimits, +} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation"; +// eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) +import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; +import type { TimeSlot } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; + +const limits = (slotsPerSession: number, maxSlots: number): SlotLimits => ({ + minSlots: maxSlots * slotsPerSession, + maxSlots, + slotsPerSession, + totalSessions: maxSlots, +}); + +// ─── dayKey ───────────────────────────────────────────────────────────────── + +describe("dayKey (default Asia/Kolkata)", () => { + it("keys by the IST calendar day: 19:30Z on Jul 19 is already Jul 20 in IST", () => { + expect(dayKey(new Date("2026-07-19T19:30:00.000Z"))).toBe("2026-07-20"); + }); + + it("18:29Z stays on the same IST day; 18:30Z rolls to the next", () => { + expect(dayKey(new Date("2026-07-19T18:29:00.000Z"))).toBe("2026-07-19"); + expect(dayKey(new Date("2026-07-19T18:30:00.000Z"))).toBe("2026-07-20"); + }); + + it("groups slots that share an IST day even across UTC midnight", () => { + // 23:30Z Jul 19 and 00:30Z Jul 20 are both Jul 20 in IST. + const slots = [ + { startTime: new Date("2026-07-19T23:30:00.000Z") }, + { startTime: new Date("2026-07-20T00:30:00.000Z") }, + ] as TimeSlot[]; + const grouped = groupSlotsByDay(slots); + expect(grouped.size).toBe(1); + expect(Array.from(grouped.keys())[0]).toBe("2026-07-20"); + }); + + it("splits slots that straddle IST midnight even though they share a UTC day", () => { + // 18:00Z and 19:00Z on Jul 19 are 23:30 IST and 00:30 IST — different IST days. + const slots = [ + { startTime: new Date("2026-07-19T18:00:00.000Z") }, + { startTime: new Date("2026-07-19T19:00:00.000Z") }, + ] as TimeSlot[]; + expect(groupSlotsByDay(slots).size).toBe(2); + }); + + it("an explicit timezone overrides the default", () => { + const d = new Date("2026-07-19T19:30:00.000Z"); + expect(dayKey(d, "UTC")).toBe("2026-07-19"); + expect(dayKey(d, "America/New_York")).toBe("2026-07-19"); // 15:30 EDT + expect(dayKey(d, "Asia/Kolkata")).toBe("2026-07-20"); + }); +}); + +// ─── weekKey ──────────────────────────────────────────────────────────────── + +describe("weekKey (default Asia/Kolkata)", () => { + it("Saturday 18:29Z belongs to the ending IST week; 18:30Z starts IST Sunday", () => { + // 2026-07-18 is a Saturday; 18:30Z is 00:00 IST Sunday Jul 19. + expect(weekKey(new Date("2026-07-18T18:29:00.000Z"))).toBe("2026-07-12"); + expect(weekKey(new Date("2026-07-18T18:30:00.000Z"))).toBe("2026-07-19"); + }); + + it("a slot at 00:30 IST Sunday counts toward the NEW IST week", () => { + // Sunday 2026-07-19 00:30 IST = Saturday 2026-07-18 19:00Z. + expect(weekKey(new Date("2026-07-18T19:00:00.000Z"))).toBe("2026-07-19"); + }); + + it("matches startOfWeekSundayInTz exactly", () => { + const d = new Date("2026-07-15T10:00:00.000Z"); + const weekStartInstant = SlotCalculationService.startOfWeekSundayInTz(d); + // The instant is Sunday 00:00 IST = Saturday 18:30Z. + expect(weekStartInstant.toISOString()).toBe("2026-07-11T18:30:00.000Z"); + expect(SlotCalculationService.dayKey(weekStartInstant)).toBe(weekKey(d)); + }); + + it("handles a DST-observing timezone (Europe/London week boundary)", () => { + // Sunday 2026-03-29 is the BST switch day. 23:30Z Saturday Mar 28 is + // 23:30 GMT (winter time still) → Saturday → week of Mar 22. + expect(weekKey(new Date("2026-03-28T23:30:00.000Z"), "Europe/London")).toBe( + "2026-03-22", + ); + // 00:30Z Sunday Mar 29 is 00:30 GMT → Sunday → week of Mar 29. + expect(weekKey(new Date("2026-03-29T00:30:00.000Z"), "Europe/London")).toBe( + "2026-03-29", + ); + }); +}); + +// ─── client/server bucketing parity ───────────────────────────────────────── + +describe("client/server bucketing parity at day boundaries", () => { + it("a session crossing UTC midnight within one IST day is ONE complete call", () => { + // 23:30Z–00:30Z = 05:00–06:00 IST on Jul 21: one 1-hour session, one IST day. + const slots = makeConsecutiveTimeSlots( + "2026-07-20T23:30:00.000Z", + 2, + ) as TimeSlot[]; + const result = validateSubscriptionSlots( + slots, + { callsPerWeek: 1, sessionDurationInHours: 1 }, + limits(2, 4), + ); + expect(result.dailyCallsValid).toBe(true); + expect(result.weeklyCallsValid).toBe(true); + expect(result.incompleteCallWarning).toBeUndefined(); + }); + + it("a session straddling IST midnight is split across two IST days (no complete call)", () => { + // 18:00Z–19:00Z on Mon Jul 20 = 23:30 IST Mon → 00:30 IST Tue. + const slots = makeConsecutiveTimeSlots( + "2026-07-20T18:00:00.000Z", + 2, + ) as TimeSlot[]; + // Each IST day holds one lone slot — the pair never forms a complete + // same-day call, so it can't consume a weekly-limit unit. + const byDay = groupSlotsByDay(slots); + expect(byDay.size).toBe(2); + expect(countCompleteCallsInMap(byDay, 2)).toBe(0); + }); + + it("weekly limit counts a Sunday-01:00-IST session in the NEW IST week", () => { + // callsPerWeek = 1. Session A: Wed Jul 15 10:00Z. Session B starts + // Sat Jul 18 19:30Z = Sunday 01:00 IST — a NEW IST week, so both fit. + const slots = [ + ...makeConsecutiveTimeSlots("2026-07-15T10:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-07-18T19:30:00.000Z", 2), + ] as TimeSlot[]; + const result = validateSubscriptionSlots( + slots, + { callsPerWeek: 1, sessionDurationInHours: 1 }, + limits(2, 4), + ); + expect(result.weeklyCallsValid).toBe(true); + }); + + it("weekly limit blocks two sessions inside the same IST week", () => { + // Wed Jul 15 and Sat Jul 18 17:00Z (22:30 IST Saturday — same IST week). + const slots = [ + ...makeConsecutiveTimeSlots("2026-07-15T10:00:00.000Z", 2), + ...makeConsecutiveTimeSlots("2026-07-18T17:00:00.000Z", 2), + ] as TimeSlot[]; + const result = validateSubscriptionSlots( + slots, + { callsPerWeek: 1, sessionDurationInHours: 1 }, + limits(2, 4), + ); + expect(result.weeklyCallsValid).toBe(false); + expect(result.weeklyCallsError).toContain("Maximum 1 calls per week"); + }); + + it("client groupSlotsByDay and SlotCalculationService.groupSlotsByDay agree on boundary slots", () => { + const boundarySlots = [ + { startTime: new Date("2026-07-19T18:00:00.000Z"), endTime: new Date("2026-07-19T18:30:00.000Z") }, + { startTime: new Date("2026-07-19T18:30:00.000Z"), endTime: new Date("2026-07-19T19:00:00.000Z") }, + { startTime: new Date("2026-07-20T00:00:00.000Z"), endTime: new Date("2026-07-20T00:30:00.000Z") }, + ] as TimeSlot[]; + const clientKeys = Array.from(groupSlotsByDay(boundarySlots).keys()).sort(); + const serverKeys = Array.from( + SlotCalculationService.groupSlotsByDay( + boundarySlots as unknown as Parameters< + typeof SlotCalculationService.groupSlotsByDay + >[0], + ).keys(), + ).sort(); + expect(clientKeys).toEqual(serverKeys); + }); +}); diff --git a/__tests__/booking-algorithm/slotCalculationService.test.ts b/__tests__/booking-algorithm/slotCalculationService.test.ts index d61054721..d2a3cf396 100644 --- a/__tests__/booking-algorithm/slotCalculationService.test.ts +++ b/__tests__/booking-algorithm/slotCalculationService.test.ts @@ -488,35 +488,35 @@ describe("SlotCalculationService.groupSlotsByWeek", () => { expect(grouped.size).toBe(2); }); - it("should use UTC-based week boundaries (not local timezone)", () => { + it("should use scheduling-timezone week boundaries (default Asia/Kolkata), not the process timezone", () => { // Saturday Jan 4 23:30 UTC = Sunday Jan 5 in UTC+1 or later timezones // Sunday Jan 5 00:30 UTC = same week as Saturday in a Sunday-start system // Both should be in the same UTC week (week starting Sunday Jan 5 would be wrong) - const saturdayLateUTC = { - startTime: new Date("2025-01-04T23:30:00Z"), // Saturday UTC - endTime: new Date("2025-01-05T00:00:00Z"), + // ADR B9 — buckets are scheduling-timezone (default Asia/Kolkata) weeks. + // 18:29Z Saturday is 23:59 IST Saturday (old week); 18:30Z is 00:00 IST + // Sunday (new week). + const saturdayLateIST = { + startTime: new Date("2025-01-04T18:29:00Z"), + endTime: new Date("2025-01-04T18:59:00Z"), isAvailable: true, isBooked: false, }; - const sundayEarlyUTC = { - startTime: new Date("2025-01-05T00:30:00Z"), // Sunday UTC - endTime: new Date("2025-01-05T01:00:00Z"), + const sundayEarlyIST = { + startTime: new Date("2025-01-04T18:30:00Z"), + endTime: new Date("2025-01-04T19:00:00Z"), isAvailable: true, isBooked: false, }; const grouped = SlotCalculationService.groupSlotsByWeek([ - saturdayLateUTC, - sundayEarlyUTC, + saturdayLateIST, + sundayEarlyIST, ]); - // Saturday belongs to week starting Dec 29 (Sun), Sunday starts new week Jan 5 + // Saturday belongs to week of Dec 29 (Sun), Sunday starts new week Jan 5 expect(grouped.size).toBe(2); - // Verify week keys use UTC dates - const keys = Array.from(grouped.keys()); - expect(keys.every((k) => k.includes("2024") || k.includes("2025"))).toBe( - true, - ); + const keys = Array.from(grouped.keys()).sort(); + expect(keys).toEqual(["2024-12-29", "2025-01-05"]); }); }); diff --git a/__tests__/booking-algorithm/subscriptionValidation.test.ts b/__tests__/booking-algorithm/subscriptionValidation.test.ts index 63ed7226c..7094eb216 100644 --- a/__tests__/booking-algorithm/subscriptionValidation.test.ts +++ b/__tests__/booking-algorithm/subscriptionValidation.test.ts @@ -114,9 +114,9 @@ describe("Bug B Fix: Appointment counting (1 appointment = 1 call)", () => { const result = await service.validateSubscriptionSlots("sub-1", []); - // The week containing Jan 6 should show 1 existing call, not 2 - // Use startOfWeekSunday to compute expected week start (local midnight, not UTC) - const expectedWeekStart = SlotCalculationService.startOfWeekSunday( + // The week containing Jan 6 should show 1 existing call, not 2. + // Weeks are scheduling-timezone Sundays (ADR B9) — match the instant. + const expectedWeekStart = SlotCalculationService.startOfWeekSundayInTz( new Date("2025-01-06T10:00:00.000Z"), ); const weekOfJan5 = result.weeklyInfo.find( @@ -193,9 +193,9 @@ describe("Bug B Fix: Appointment counting (1 appointment = 1 call)", () => { ); const result = await service.validateSubscriptionSlots("sub-1", []); - // Should use 13:30 (earliest) to determine week - // Use startOfWeekSunday to compute expected week start (local midnight, not UTC) - const expectedWeekStart = SlotCalculationService.startOfWeekSunday( + // Should use 13:30 (earliest) to determine week. + // Weeks are scheduling-timezone Sundays (ADR B9) — match the instant. + const expectedWeekStart = SlotCalculationService.startOfWeekSundayInTz( new Date("2025-01-06T13:30:00.000Z"), ); const weekOfJan5 = result.weeklyInfo.find( @@ -425,7 +425,13 @@ describe("Weekly info generation", () => { const result = await service.validateSubscriptionSlots("sub-1", []); expect(result.weeklyInfo.length).toBe(1); - expect(result.weeklyInfo[0].weekStart.getDay()).toBe(0); // Sunday + // weekStart is Sunday 00:00 in the SCHEDULING timezone (ADR B9) — assert + // via Intl, not local getDay(), so the test passes on any CI timezone. + const weekdayInSchedulingTz = new Intl.DateTimeFormat("en-US", { + timeZone: SlotCalculationService.DEFAULT_SCHEDULING_TIMEZONE, + weekday: "short", + }).format(result.weeklyInfo[0].weekStart); + expect(weekdayInSchedulingTz).toBe("Sun"); expect(result.weeklyInfo[0].maxCalls).toBe(1); }); diff --git a/__tests__/booking-algorithm/toast-queue.test.ts b/__tests__/booking-algorithm/toast-queue.test.ts new file mode 100644 index 000000000..605b31276 --- /dev/null +++ b/__tests__/booking-algorithm/toast-queue.test.ts @@ -0,0 +1,58 @@ +/** + * Toast queue reducer for the allocation hook. The old single `pendingToast` + * slot let a later same-tick toast clobber an earlier one; the queue keeps + * order and only drops consecutive duplicates (which also absorbs React + * StrictMode double-invoked updaters). + */ + +import "./setup"; + +import { enqueueToast } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation"; +import type { AllocationToast } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationMessages"; + +const msg = (title: string, description = "d"): AllocationToast => ({ + title, + description, + variant: "default", +}); + +describe("enqueueToast", () => { + it("appends messages in order", () => { + let q: AllocationToast[] = []; + q = enqueueToast(q, msg("one")); + q = enqueueToast(q, msg("two")); + q = enqueueToast(q, msg("three")); + expect(q.map((m) => m.title)).toEqual(["one", "two", "three"]); + }); + + it("drops a consecutive duplicate", () => { + let q: AllocationToast[] = []; + q = enqueueToast(q, msg("same")); + q = enqueueToast(q, msg("same")); + expect(q).toHaveLength(1); + }); + + it("keeps identical messages that are NOT consecutive", () => { + let q: AllocationToast[] = []; + q = enqueueToast(q, msg("same")); + q = enqueueToast(q, msg("other")); + q = enqueueToast(q, msg("same")); + expect(q.map((m) => m.title)).toEqual(["same", "other", "same"]); + }); + + it("treats a different variant or description as a different message", () => { + let q: AllocationToast[] = []; + q = enqueueToast(q, msg("same", "a")); + q = enqueueToast(q, msg("same", "b")); + q = enqueueToast(q, { ...msg("same", "b"), variant: "destructive" }); + expect(q).toHaveLength(3); + }); + + it("does not mutate the input queue", () => { + const original: AllocationToast[] = [msg("one")]; + const next = enqueueToast(original, msg("two")); + expect(original).toHaveLength(1); + expect(next).toHaveLength(2); + expect(next).not.toBe(original); + }); +}); diff --git a/app/api/bookings/classes/[classId]/allocate/route.ts b/app/api/bookings/classes/[classId]/allocate/route.ts index fd8da34ec..a128946c6 100644 --- a/app/api/bookings/classes/[classId]/allocate/route.ts +++ b/app/api/bookings/classes/[classId]/allocate/route.ts @@ -80,6 +80,7 @@ export async function PATCH( // #837 — client dedupe key; a double-submit with the same value returns // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, + initialAllocation: body.initialAllocation, }); const duration = Date.now() - startTime; diff --git a/app/api/bookings/consultations/[consultationId]/allocate/route.ts b/app/api/bookings/consultations/[consultationId]/allocate/route.ts index 2eeb721a9..28506c1ad 100644 --- a/app/api/bookings/consultations/[consultationId]/allocate/route.ts +++ b/app/api/bookings/consultations/[consultationId]/allocate/route.ts @@ -81,6 +81,7 @@ export async function PATCH( // #837 — client dedupe key; a double-submit with the same value returns // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, + initialAllocation: body.initialAllocation, }); const duration = Date.now() - startTime; diff --git a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts index 1569cc141..ef7483749 100644 --- a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts +++ b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts @@ -80,6 +80,7 @@ export async function PATCH( // #837 — client dedupe key; a double-submit with the same value returns // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, + initialAllocation: body.initialAllocation, }); const duration = Date.now() - startTime; diff --git a/app/api/bookings/webinars/[webinarId]/allocate/route.ts b/app/api/bookings/webinars/[webinarId]/allocate/route.ts index fc781f377..49631a94b 100644 --- a/app/api/bookings/webinars/[webinarId]/allocate/route.ts +++ b/app/api/bookings/webinars/[webinarId]/allocate/route.ts @@ -80,6 +80,7 @@ export async function PATCH( // #837 — client dedupe key; a double-submit with the same value returns // the first batch instead of allocating twice. idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, + initialAllocation: body.initialAllocation, }); const duration = Date.now() - startTime; diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx index 6e0ce1521..dc9b28965 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx @@ -23,7 +23,7 @@ import { toast } from "@/components/ui/use-toast"; import { AppointmentsType, AppointmentStatus } from "@prisma/client"; import { AlertTriangle, CheckCircle2, RefreshCw } from "lucide-react"; import { useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { RequestedSlotsDialog } from "./components/RequestedSlotsDialog"; import { PaymentRequiredBadge } from "./components/PaymentRequiredBadge"; import { SafeUnifiedCalendar } from "../shared/components/SafeUnifiedCalendar"; @@ -33,19 +33,16 @@ import { SubscriptionApiResponse, } from "./types"; import { countSundayWeeksInclusive } from "../shared/utils/calendarUtils"; - -// --- API Response Type Definitions --- -// interface UserInfo { ... } // Removed -// interface RequestedBy { ... } // Removed -// interface ConsultationPlanInfo { ... } // Removed -// interface SubscriptionPlanInfo { ... } // Removed -// interface AppointmentSlot { ... } // Removed (part of SlotInterval now) -// interface AppointmentInfo { ... } // Removed -// interface ConsultationApiResponse { ... } // Removed -// interface SubscriptionApiResponse { ... } // Removed -// interface AvailabilityApiResponse extends AppointmentSlot { } // Removed -// interface ConsultantApiResponse { ... } // Removed -// --- End API Response Type Definitions --- +import { + allocatedElsewhere, + allocationFailed, + planConfigIncomplete, +} from "../shared/utils/allocationMessages"; +import { + computeAttemptFingerprint, + resolveAttemptKey, + type AllocationAttemptKey, +} from "../shared/hooks/useSlotAllocation"; // Slot with tentative status for reschedule visibility interface RequestedSlot { @@ -62,7 +59,9 @@ interface Request { requestedTimes?: string[]; // Kept for backward compatibility requestedSlots?: RequestedSlot[]; // New: includes isTentative flag status: AppointmentStatus; - requiredSlots: number; + /** undefined = plan data is incomplete (no totalSessions AND no scheduling + * period) — the server would reject any allocation, so actions are disabled. */ + requiredSlots?: number; allocatedSlots?: string[]; durationInMonths?: number; callsPerWeek?: number; @@ -70,6 +69,8 @@ interface Request { durationInHours?: number; startDate?: Date; endDate?: Date; + /** Limit day/week bucket timezone (ADR B9); Subscription column default. */ + schedulingTimezone?: string; bookingSource?: "DIRECT_CHECKOUT" | "REQUEST_SUBMITTED"; // Booking source - direct checkout or request submitted totalSessions?: number; // Authoritative session count from plan (overrides weeks × callsPerWeek) // Reschedule info @@ -287,13 +288,21 @@ export function RequestSlotAllocationTab({ ); return weeks * callsPerWeek * slotsPerSession; } - return ( - callsPerWeek * - 4 * - (subscription.subscriptionPlan?.durationInMonths ?? - 0) * - slotsPerSession || 0 + // No totalSessions AND no period: the server throws for + // such subscriptions, so any client guess (the old + // callsPerWeek×4×months) produced an allocation the + // server rejected. Surface a degraded state instead. + Sentry.captureMessage( + "Subscription plan missing totalSessions and scheduling period", + { + tags: { + subsystem: "client", + feature: "slot-allocation", + }, + extra: { subscriptionId: subscription.id }, + }, ); + return undefined; })(), totalSessions: tentativeCount > 0 @@ -309,6 +318,7 @@ export function RequestSlotAllocationTab({ endDate: subscription.schedulingPeriodEndsAt ? new Date(subscription.schedulingPeriodEndsAt) : undefined, + schedulingTimezone: subscription.schedulingTimezone, bookingSource: subscription.bookingSource, tentativeSlotCount: tentativeCount, totalSlotCount: totalCount, @@ -349,9 +359,39 @@ export function RequestSlotAllocationTab({ if (!document.hidden) fetchData(); }, REQUEST_POLL_INTERVAL); - return () => clearInterval(interval); + // Multi-tab self-heal: a tab returning to focus refetches so requests + // allocated/declined elsewhere disappear without waiting for the poll. + const onFocus = () => fetchData(); + window.addEventListener("focus", onFocus); + + return () => { + clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; }, [fetchData]); + // Idempotency key for the requested-times flow; a retry of the same request + // reuses the key so the server replays instead of double-booking (#837). + // A ref, not state — two clicks before a rerender must see the same key. + const attemptKeyRef = useRef(null); + + /** Shared 409 handling: another session already allocated this request. */ + const handleConflict = useCallback( + (requestId?: string) => { + toast(allocatedElsewhere()); + setDialogOpen(false); + setSelectedRequest(null); + setRequestedSlotsDialogOpen(false); + setSelectedRequestForDialog(null); + if (requestId) { + setRequests((prev) => prev.filter((r) => r.id !== requestId)); + } + fetchData(); + onUpdate(); + }, + [fetchData, onUpdate], + ); + const handleRequestedAllocation = async (override: boolean) => { if (!selectedRequestForDialog) return; @@ -361,20 +401,41 @@ export function RequestSlotAllocationTab({ ? `/api/bookings/subscriptions/${selectedRequestForDialog.id}/allocate` : `/api/bookings/consultations/${selectedRequestForDialog.id}/allocate`; + const attempt = resolveAttemptKey( + attemptKeyRef.current, + computeAttemptFingerprint( + "requested", + selectedRequestForDialog.id, + [], + ), + ); + attemptKeyRef.current = attempt; + const response = await fetch(endpoint, { method: "PATCH", headers: { "Content-Type": "application/json", + "Idempotency-Key": attempt.key, }, body: JSON.stringify({ isAuto: false, useRequestedSlots: true, override, + // Fresh allocations only — partial reschedules legitimately have + // confirmed slots and must not trip the already-allocated guard. + initialAllocation: + (selectedRequestForDialog.tentativeSlotCount ?? 0) === 0 || + undefined, }), }); const data = await response.json(); + if (response.status === 409) { + handleConflict(selectedRequestForDialog.id); + return; + } + if (!response.ok) { throw new Error(data.error || "Failed to allocate slots"); } @@ -398,13 +459,12 @@ export function RequestSlotAllocationTab({ // Notify parent onUpdate(); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "client" } }); - toast({ - title: "Error", - description: + Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "client", feature: "slot-allocation" } }); + toast( + allocationFailed( error instanceof Error ? error.message : "Failed to allocate slots", - variant: "destructive", - }); + ), + ); } }; @@ -642,7 +702,7 @@ export function RequestSlotAllocationTab({ header: "Required Slots", headClassName: "w-[90px] text-center", className: "text-center", - cell: (request) => request.requiredSlots, + cell: (request) => request.requiredSlots ?? "—", }, { key: "status", @@ -666,33 +726,41 @@ export function RequestSlotAllocationTab({ cell: (request) => request.status === AppointmentStatus.PENDING ? (
- {/* Hide "Use Requested Times" for directly booked consultations (Bug #8 fix) */} - {request.requestedTimes && - request.requestedTimes.length > 0 && - request.bookingSource === "REQUEST_SUBMITTED" && ( + {request.requiredSlots === undefined ? ( +

+ {planConfigIncomplete().description} +

+ ) : ( + <> + {/* Hide "Use Requested Times" for directly booked consultations (Bug #8 fix) */} + {request.requestedTimes && + request.requestedTimes.length > 0 && + request.bookingSource === "REQUEST_SUBMITTED" && ( + + )} - )} - + + )} {request.type === AppointmentsType.CONSULTATION && (