-
Notifications
You must be signed in to change notification settings - Fork 4
fix(booking): Pre-MVP algorithm integrity — #1071 #1012 #1005 #1091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0c2cacc
fix(booking): close Pre-MVP algorithm integrity gaps
teetangh 94cb046
fix(booking): resolve PrismaLike tx typing for contiguous slot rewrite
teetangh 30ba4d7
fix(booking): address PR #1091 review blockers and Novu inbox sizing
teetangh d5c0bf7
fix(booking): clear PR #1091 round-2 blockers and sync docs
teetangh 43bc1c1
fix(booking): re-assert expectedTentativeSlotCount inside allocate txns
teetangh 717e55e
fix(booking): address CodeRabbit round-3 nits on PR #1091
teetangh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { | ||
| consulteeDestructiveAction, | ||
| consulteeMayReschedule, | ||
| } from "@/lib/appointments/consultee-affordances"; | ||
|
|
||
| describe("#1005 consultee affordances", () => { | ||
| it("allows reschedule only for 1:1 kinds", () => { | ||
| expect(consulteeMayReschedule("CONSULTATION")).toBe(true); | ||
| expect(consulteeMayReschedule("SUBSCRIPTION")).toBe(true); | ||
| expect(consulteeMayReschedule("WEBINAR")).toBe(false); | ||
| expect(consulteeMayReschedule("CLASS")).toBe(false); | ||
| expect(consulteeMayReschedule("TRIAL")).toBe(false); | ||
| }); | ||
|
|
||
| it("maps destructive actions by kind", () => { | ||
| expect(consulteeDestructiveAction("CONSULTATION")).toBe("cancel-booking"); | ||
| expect(consulteeDestructiveAction("SUBSCRIPTION")).toBe("cancel-booking"); | ||
| expect(consulteeDestructiveAction("TRIAL")).toBe("cancel-trial"); | ||
| expect(consulteeDestructiveAction("WEBINAR")).toBe("leave-event"); | ||
| expect(consulteeDestructiveAction("CLASS")).toBe("leave-event"); | ||
| }); | ||
| }); |
363 changes: 363 additions & 0 deletions
363
__tests__/booking-algorithm/contiguous-slot-run.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,363 @@ | ||
| /** | ||
| * #1071 — contiguous N×30min slot runs for planner create/update. | ||
| */ | ||
|
|
||
| import "./setup"; | ||
|
|
||
| import { | ||
| assertSingleContiguousLiveRun, | ||
| buildContiguousSlotAtoms, | ||
| replaceContiguousSlotRun, | ||
| SLOT_DURATION_MS, | ||
| } from "@/lib/appointments/contiguous-slot-run"; | ||
|
|
||
| describe("buildContiguousSlotAtoms", () => { | ||
| const startsAt = new Date("2026-08-10T10:00:00.000Z"); | ||
|
|
||
| it("creates one atom for a 30-minute session", () => { | ||
| const atoms = buildContiguousSlotAtoms({ | ||
| startsAt, | ||
| durationInHours: 0.5, | ||
| consultantProfileId: "cp_1", | ||
| }); | ||
| expect(atoms).toHaveLength(1); | ||
| expect(atoms[0].startsAt.toISOString()).toBe("2026-08-10T10:00:00.000Z"); | ||
| expect(atoms[0].endsAt.toISOString()).toBe("2026-08-10T10:30:00.000Z"); | ||
| }); | ||
|
|
||
| it("creates four contiguous atoms for a 2-hour session", () => { | ||
| const atoms = buildContiguousSlotAtoms({ | ||
| startsAt, | ||
| durationInHours: 2, | ||
| consultantProfileId: "cp_1", | ||
| isTentative: false, | ||
| }); | ||
| expect(atoms).toHaveLength(4); | ||
| for (let i = 0; i < atoms.length; i++) { | ||
| const expectedStart = startsAt.getTime() + i * SLOT_DURATION_MS; | ||
| expect(atoms[i].startsAt.getTime()).toBe(expectedStart); | ||
| expect(atoms[i].endsAt.getTime()).toBe(expectedStart + SLOT_DURATION_MS); | ||
| } | ||
| for (let i = 1; i < atoms.length; i++) { | ||
| expect(atoms[i].startsAt.getTime()).toBe(atoms[i - 1].endsAt.getTime()); | ||
| } | ||
| }); | ||
|
|
||
| it("creates two atoms for a 60-minute session (allocator parity)", () => { | ||
| const atoms = buildContiguousSlotAtoms({ | ||
| startsAt, | ||
| durationInHours: 1, | ||
| consultantProfileId: "cp_1", | ||
| }); | ||
| expect(atoms).toHaveLength(2); | ||
| expect(atoms[1].endsAt.getTime() - atoms[0].startsAt.getTime()).toBe( | ||
| 60 * 60 * 1000, | ||
| ); | ||
| }); | ||
|
|
||
| it("attaches user connects when userIds are provided", () => { | ||
| const atoms = buildContiguousSlotAtoms({ | ||
| startsAt, | ||
| durationInHours: 1, | ||
| consultantProfileId: "cp_1", | ||
| userIds: ["u1", "u2", "u1"], | ||
| }); | ||
| expect(atoms[0].user?.connect).toEqual([{ id: "u1" }, { id: "u2" }]); | ||
| }); | ||
|
|
||
| it("rejects non-positive duration", () => { | ||
| expect(() => | ||
| buildContiguousSlotAtoms({ | ||
| startsAt, | ||
| durationInHours: 0, | ||
| consultantProfileId: "cp_1", | ||
| }), | ||
| ).toThrow(/durationInHours/); | ||
| }); | ||
| }); | ||
|
|
||
| describe("assertSingleContiguousLiveRun", () => { | ||
| it("accepts a contiguous 2-hour run", () => { | ||
| const startsAt = new Date("2026-08-10T10:00:00.000Z"); | ||
| const atoms = buildContiguousSlotAtoms({ | ||
| startsAt, | ||
| durationInHours: 2, | ||
| consultantProfileId: "cp_1", | ||
| }); | ||
| expect(() => | ||
| assertSingleContiguousLiveRun( | ||
| atoms.map((a, i) => ({ | ||
| id: `s${i}`, | ||
| appointmentId: "a1", | ||
| startsAt: a.startsAt, | ||
| endsAt: a.endsAt, | ||
| isTentative: false, | ||
| completionStatus: "SCHEDULED", | ||
| })), | ||
| ), | ||
| ).not.toThrow(); | ||
| }); | ||
|
|
||
| it("rejects the old #1071 failure mode (first atom moved, rest stranded)", () => { | ||
| expect(() => | ||
| assertSingleContiguousLiveRun([ | ||
| { | ||
| id: "s0", | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-14T10:00:00.000Z"), | ||
| endsAt: new Date("2026-08-14T10:30:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| }, | ||
| { | ||
| id: "s1", | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-10T10:30:00.000Z"), | ||
| endsAt: new Date("2026-08-10T11:00:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| }, | ||
| { | ||
| id: "s2", | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-10T11:00:00.000Z"), | ||
| endsAt: new Date("2026-08-10T11:30:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| }, | ||
| ]), | ||
| ).toThrow(/exactly one contiguous run/); | ||
| }); | ||
|
|
||
| it("ignores CANCELLED / RESCHEDULED rows when checking contiguity", () => { | ||
| expect(() => | ||
| assertSingleContiguousLiveRun([ | ||
| { | ||
| id: "dead", | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-01T10:00:00.000Z"), | ||
| endsAt: new Date("2026-08-01T10:30:00.000Z"), | ||
| completionStatus: "RESCHEDULED", | ||
| }, | ||
| { | ||
| id: "s0", | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-10T10:00:00.000Z"), | ||
| endsAt: new Date("2026-08-10T10:30:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| }, | ||
| { | ||
| id: "s1", | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-10T10:30:00.000Z"), | ||
| endsAt: new Date("2026-08-10T11:00:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| }, | ||
| ]), | ||
| ).not.toThrow(); | ||
| }); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| describe("replaceContiguousSlotRun", () => { | ||
| function stubTx(liveRows: Array<Record<string, unknown>>) { | ||
| const updates: Array<{ id: string; data: Record<string, unknown> }> = []; | ||
| const creates: Array<Record<string, unknown>> = []; | ||
| const updateManyCalls: Array<{ | ||
| where: { id: { in: string[] } }; | ||
| data: Record<string, unknown>; | ||
| }> = []; | ||
| let findManyCalls = 0; | ||
| return { | ||
| updates, | ||
| creates, | ||
| updateManyCalls, | ||
| tx: { | ||
| slotOfAppointment: { | ||
| findMany: jest.fn(async () => { | ||
| findManyCalls += 1; | ||
| if (findManyCalls === 1) return liveRows; | ||
| // Post-write live read — synthesise from creates + updated times. | ||
| const retired = new Set( | ||
| updates | ||
| .filter((u) => u.data.completionStatus === "RESCHEDULED") | ||
| .map((u) => u.id), | ||
| ); | ||
| const isLive = (r: Record<string, unknown>) => | ||
| !r.deletedAt && | ||
| r.completionStatus !== "CANCELLED" && | ||
| r.completionStatus !== "RESCHEDULED" && | ||
| !retired.has(r.id as string); | ||
| const kept = liveRows.filter(isLive).map((r) => { | ||
| const upd = [...updates].reverse().find((u) => u.id === r.id); | ||
| return upd ? { ...r, ...upd.data } : r; | ||
| }); | ||
| return [...kept, ...creates].sort( | ||
| (a, b) => | ||
| new Date(a.startsAt as Date).getTime() - | ||
| new Date(b.startsAt as Date).getTime(), | ||
| ); | ||
| }), | ||
| updateMany: jest.fn( | ||
| async ({ | ||
| where, | ||
| data, | ||
| }: { | ||
| where: { id: { in: string[] } }; | ||
| data: Record<string, unknown>; | ||
| }) => { | ||
| updateManyCalls.push({ where, data }); | ||
| for (const id of where.id.in) { | ||
| updates.push({ id, data }); | ||
| } | ||
| return { count: where.id.in.length }; | ||
| }, | ||
| ), | ||
| update: jest.fn( | ||
| async ({ | ||
| where, | ||
| data, | ||
| }: { | ||
| where: { id: string }; | ||
| data: Record<string, unknown>; | ||
| }) => { | ||
| updates.push({ id: where.id, data }); | ||
| return {}; | ||
| }, | ||
| ), | ||
| create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => { | ||
| creates.push({ id: `new-${creates.length}`, ...data }); | ||
| return {}; | ||
| }), | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| it("updates overlapping live rows in place and soft-retires surplus", async () => { | ||
| const startsAt = new Date("2026-08-10T10:00:00.000Z"); | ||
| const liveRows = [ | ||
| { | ||
| id: "s0", | ||
| startsAt, | ||
| endsAt: new Date("2026-08-10T10:30:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| deletedAt: null, | ||
| user: [{ id: "u1" }], | ||
| }, | ||
| { | ||
| id: "s1", | ||
| startsAt: new Date("2026-08-10T10:30:00.000Z"), | ||
| endsAt: new Date("2026-08-10T11:00:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| deletedAt: null, | ||
| user: [{ id: "u1" }], | ||
| }, | ||
| { | ||
| id: "dead", | ||
| startsAt: new Date("2026-08-01T10:00:00.000Z"), | ||
| endsAt: new Date("2026-08-01T10:30:00.000Z"), | ||
| completionStatus: "RESCHEDULED", | ||
| deletedAt: null, | ||
| user: [{ id: "u2" }], | ||
| }, | ||
| ]; | ||
| const { tx, updates, creates } = stubTx(liveRows); | ||
|
|
||
| const result = await replaceContiguousSlotRun(tx as never, { | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-14T12:00:00.000Z"), | ||
| durationInHours: 0.5, | ||
| consultantProfileId: "cp_1", | ||
| }); | ||
|
|
||
| // One live atom kept (updated), one surplus soft-retired, dead untouched. | ||
| // Assert startsAt — updateMany's tentative pre-pass also touches s0. | ||
| expect( | ||
| updates.some( | ||
| (u) => | ||
| u.id === "s0" && | ||
| (u.data.startsAt as Date | undefined)?.toISOString() === | ||
| "2026-08-14T12:00:00.000Z", | ||
| ), | ||
| ).toBe(true); | ||
| expect( | ||
| updates.some( | ||
| (u) => u.id === "s1" && u.data.completionStatus === "RESCHEDULED", | ||
| ), | ||
| ).toBe(true); | ||
| expect(updates.some((u) => u.id === "dead")).toBe(false); | ||
| expect(creates).toHaveLength(0); | ||
| expect(result.preservedUserIds).toEqual(["u1"]); | ||
| expect(result.createdCount).toBe(1); | ||
| }); | ||
|
|
||
| it("creates extra atoms when duration grows and preserves user ids", async () => { | ||
| const liveRows = [ | ||
| { | ||
| id: "s0", | ||
| startsAt: new Date("2026-08-10T10:00:00.000Z"), | ||
| endsAt: new Date("2026-08-10T10:30:00.000Z"), | ||
| completionStatus: "SCHEDULED", | ||
| deletedAt: null, | ||
| user: [{ id: "host" }, { id: "buyer" }], | ||
| }, | ||
| ]; | ||
| const { tx, updates, creates } = stubTx(liveRows); | ||
|
|
||
| const result = await replaceContiguousSlotRun(tx as never, { | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-10T10:00:00.000Z"), | ||
| durationInHours: 1, | ||
| consultantProfileId: "cp_1", | ||
| }); | ||
|
|
||
| expect( | ||
| updates.some( | ||
| (u) => | ||
| u.id === "s0" && | ||
| (u.data.startsAt as Date | undefined)?.toISOString() === | ||
| "2026-08-10T10:00:00.000Z", | ||
| ), | ||
| ).toBe(true); | ||
| expect(creates).toHaveLength(1); | ||
| expect(result.preservedUserIds.sort()).toEqual(["buyer", "host"]); | ||
| expect(result.createdCount).toBe(2); | ||
| }); | ||
|
|
||
| it("tentative-flips the whole live run before an overlapping forward shift", async () => { | ||
| // 2h @ 10:00 → 11:00: without the pre-pass, updating s0 to [11:00,11:30) | ||
| // collides with s2 still holding that window under slot_no_confirmed_overlap. | ||
| const liveRows = [0, 1, 2, 3].map((i) => ({ | ||
| id: `s${i}`, | ||
| startsAt: new Date(`2026-08-10T${10 + Math.floor(i / 2)}:${i % 2 === 0 ? "00" : "30"}:00.000Z`), | ||
| endsAt: new Date( | ||
| `2026-08-10T${10 + Math.floor((i + 1) / 2)}:${(i + 1) % 2 === 0 ? "00" : "30"}:00.000Z`, | ||
| ), | ||
| completionStatus: "SCHEDULED", | ||
| deletedAt: null, | ||
| user: [], | ||
| })); | ||
| const { tx, updateManyCalls, updates } = stubTx(liveRows); | ||
|
|
||
| await replaceContiguousSlotRun(tx as never, { | ||
| appointmentId: "a1", | ||
| startsAt: new Date("2026-08-10T11:00:00.000Z"), | ||
| durationInHours: 2, | ||
| consultantProfileId: "cp_1", | ||
| isTentative: false, | ||
| }); | ||
|
|
||
| expect(updateManyCalls).toHaveLength(1); | ||
| expect(updateManyCalls[0].data).toEqual({ isTentative: true }); | ||
| expect(updateManyCalls[0].where.id.in.sort()).toEqual([ | ||
| "s0", | ||
| "s1", | ||
| "s2", | ||
| "s3", | ||
| ]); | ||
| // Per-row restores must follow the tentative pre-pass (first 4 updates | ||
| // are the updateMany fan-out in the stub). | ||
| const restoreIdx = updates.findIndex( | ||
| (u) => u.id === "s0" && u.data.startsAt instanceof Date, | ||
| ); | ||
| expect(restoreIdx).toBeGreaterThanOrEqual(4); | ||
| expect(updates[restoreIdx].data.isTentative).toBe(false); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.