Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions __tests__/booking-algorithm/reschedule-affordance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* The Reschedule menu item's slot-derived gate.
*
* Both sides now share one predicate because they drifted: the consultee's had
* the zero-slot and in-flight checks, the consultant's had neither, so a
* consultant could open a picker for a booking with nothing to move or one
* already awaiting a new time — reaching a 409 or PROPOSAL_WINDOW_CLOSED that
* the menu should never have offered.
*/

import { slotsAllowReschedule } from "@/lib/appointments/slots";

describe("slotsAllowReschedule", () => {
it("allows a booking with confirmed sessions", () => {
expect(
slotsAllowReschedule([
{ isTentative: false, completionStatus: "SCHEDULED" },
{ isTentative: false, completionStatus: "SCHEDULED" },
]),
).toBe(true);
});

it("refuses an approved booking with nothing allocated", () => {
// "Not scheduled · 0/0" — the proposal window is derived from the earliest
// released session, so there is nothing to derive it from.
expect(slotsAllowReschedule([])).toBe(false);
});

it("refuses a request still awaiting its first allocation", () => {
expect(
slotsAllowReschedule([{ isTentative: true, completionStatus: null }]),
).toBe(false);
});

it("refuses while a reschedule is already in flight", () => {
// openForAppointmentId is a nullable-unique, so a second live request is
// impossible — offering the action again only earns a 409.
expect(
slotsAllowReschedule([
{ isTentative: false, completionStatus: "SCHEDULED" },
{ isTentative: true, completionStatus: "RESCHEDULED" },
]),
).toBe(false);
});

it("stays permissive about sessions that merely finished", () => {
// COMPLETED is not RESCHEDULED: a subscription with past sessions and
// future ones is still movable.
expect(
slotsAllowReschedule([
{ isTentative: false, completionStatus: "COMPLETED" },
{ isTentative: false, completionStatus: "SCHEDULED" },
]),
).toBe(true);
});
});
70 changes: 70 additions & 0 deletions __tests__/booking-algorithm/reschedule-proposal-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Proposed times are 30-minute atoms (ADR B1), enforced at the contract edge.
*
* This is not style. Auto-confirm hands the allocator `startsAt` alone and
* manual mode reads each string as ONE 30-minute start, so `endsAt` is never
* consulted — a 60-minute proposal books 30 minutes. The count check passes
* (one proposed row per released slot), which is exactly what makes it
* invisible: the consultee asks to move a 1-hour session and silently gets
* half of one.
*/

import { RescheduleProposalSchema } from "@/schemas/appointments";

const at = (iso: string) => new Date(iso).toISOString();

describe("RescheduleProposalSchema", () => {
it("accepts a 30-minute proposal", () => {
const parsed = RescheduleProposalSchema.safeParse({
proposedSlots: [
{ startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T09:30:00Z") },
],
});
expect(parsed.success).toBe(true);
});

it("accepts consecutive atoms — a 1-hour session is two rows, not one long one", () => {
const parsed = RescheduleProposalSchema.safeParse({
proposedSlots: [
{ startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T09:30:00Z") },
{ startsAt: at("2026-09-01T09:30:00Z"), endsAt: at("2026-09-01T10:00:00Z") },
],
});
expect(parsed.success).toBe(true);
});

it("rejects a 60-minute row, which would book 30", () => {
const parsed = RescheduleProposalSchema.safeParse({
proposedSlots: [
{ startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T10:00:00Z") },
],
});
expect(parsed.success).toBe(false);
});

it("rejects a row shorter than an atom", () => {
const parsed = RescheduleProposalSchema.safeParse({
proposedSlots: [
{ startsAt: at("2026-09-01T09:00:00Z"), endsAt: at("2026-09-01T09:15:00Z") },
],
});
expect(parsed.success).toBe(false);
});

it("still rejects an end at or before its start", () => {
for (const endsAt of ["2026-09-01T09:00:00Z", "2026-08-01T09:00:00Z"]) {
const parsed = RescheduleProposalSchema.safeParse({
proposedSlots: [{ startsAt: at("2026-09-01T09:00:00Z"), endsAt: at(endsAt) }],
});
expect(parsed.success).toBe(false);
}
});

it("keeps 'any time works' valid — no proposed times at all", () => {
// Releasing without naming a time predates proposals and is still the whole
// contract for group events.
expect(RescheduleProposalSchema.safeParse({ slotIds: ["a"] }).success).toBe(
true,
);
});
});
12 changes: 2 additions & 10 deletions __tests__/booking-algorithm/reschedule-proposals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@

import "./setup";
import {
MAX_PROPOSAL_ROUNDS,
PROPOSAL_MAX_LIFETIME_HOURS,
computeProposalExpiry,
mayAutoConfirm,
mayCounter,
proposalCountMatches,
supportsProposals,
} from "../../lib/booking/reschedule-proposals";
Expand Down Expand Up @@ -90,11 +88,6 @@ describe("scope and shape guards", () => {
expect(proposalCountMatches(2, 1)).toBe(false);
expect(proposalCountMatches(2, 3)).toBe(false);
});

it("permits exactly one counter-round", () => {
expect(mayCounter(1)).toBe(true);
expect(mayCounter(MAX_PROPOSAL_ROUNDS)).toBe(false);
});
});

describe("RESCHEDULE_ALLOWED_FROM state machine", () => {
Expand Down Expand Up @@ -134,9 +127,8 @@ describe("RESCHEDULE_ALLOWED_FROM state machine", () => {

describe("allocationRequestSchema carries override", () => {
it("no longer strips the field the Override button sends", async () => {
const { allocationRequestSchema } = await import(
"../../schemas/slotAllocation/validationSchemas"
);
const { allocationRequestSchema } =
await import("../../schemas/slotAllocation/validationSchemas");

// Before this, `override` was absent from the schema and a plain (non-
// passthrough) Zod object dropped it, so "Override and Allocate" sent a
Expand Down
Loading
Loading