Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
63 changes: 62 additions & 1 deletion api/tests/voiceRescheduleLane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,14 @@ describe("voice hidden reschedule lane", () => {
});

it("unadvertised digit 4 routes to hidden staff-controlled reschedule lane", async () => {
const rescheduleSpy = jest.spyOn(MockSchedulingProvider.prototype, "rescheduleAppointment");
const callSid = `${CALLSID_PREFIX}DIGIT_4`;
const from = TEST_PHONES[0];

// First call inbound to get IVR menu
await postInbound(callSid, from);
const menuRes = await postInbound(callSid, from);
expect(menuRes.text).not.toContain("Press 4");
expect(menuRes.text).not.toContain("reschedule");

// Press digit 4 via intent endpoint (simulates actual DTMF press)
// The intent endpoint processes DTMF and routes to reschedule
Expand All @@ -242,6 +245,10 @@ describe("voice hidden reschedule lane", () => {
expect(res.type).toBe("text/xml");
expect(res.text).toContain("/api/voice/reschedule?stage=start");
expect(res.text).toContain("Your appointment is not moved yet; staff will confirm the change.");
expect(res.text).not.toContain("<Pay");
expect(res.text).not.toContain("payment-capture");
expect(res.text).not.toContain("chargeAmount");
expect(rescheduleSpy).not.toHaveBeenCalled();
});


Expand Down Expand Up @@ -614,6 +621,60 @@ describe("voice hidden reschedule lane", () => {
expect(rescheduleSpy).not.toHaveBeenCalled();
});

it("default-disabled self-service confirmation guard never calls provider reschedule", async () => {
process.env.ENABLE_PUBLIC_SELF_SERVICE_RESCHEDULE = "false";
const rescheduleSpy = jest.spyOn(MockSchedulingProvider.prototype, "rescheduleAppointment");
const callSid = `${CALLSID_PREFIX}DEFAULT_CONFIRM_GUARD`;
const from = TEST_PHONES[0];

await postRescheduleStart(callSid, from);
await postRescheduleLookup(callSid, from, "", "15559990201");
const callback = await readLatestRescheduleCallback(from);
expect(callback).toBeDefined();

const offeredSlot = {
start: "2026-06-08T15:00:00.000Z",
requestedDate: "2026-06-08",
displayTime: "3:00 PM",
dayLabel: "Monday",
appointmentTypeId: "123",
calendarId: "456",
};
const encodedSlot = encodeURIComponent(JSON.stringify([offeredSlot]));
await prisma.callbackRequest.update({
where: { id: callback!.id },
data: {
summary: [
callback!.summary,
`requested_new_time=2026-06-08 at 3 pm`,
`self_service_offered_slots_json=${encodedSlot}`,
`self_service_selected_slot_json=${encodedSlot}`,
`self_service_selected_slot=${offeredSlot.start}`,
`self_service_selected_calendar_id=${offeredSlot.calendarId}`,
]
.filter(Boolean)
.join("\n"),
},
});

const res = await postConfirmReschedule(callSid, from, callback!.id, "", "1");

expect(res.status).toBe(200);
expect(res.text).toContain("I can send that request to the team");
expect(res.text).toContain("Your appointment is not moved yet; staff will confirm the change.");
expect(res.text).not.toContain("You&apos;re all set");
expect(res.text).not.toContain("appointment has been moved");
expect(res.text).not.toContain("<Pay");
expect(res.text).not.toContain("payment-capture");
expect(res.text).not.toContain("chargeAmount");
expect(rescheduleSpy).not.toHaveBeenCalled();

const guardedCallback = await readLatestRescheduleCallback(from);
expect(guardedCallback?.summary).toContain(
"self_service_guardrail_fallback=confirmation_guard_failed"
);
});

it("explicit owner flag can offer replacement slots without mutating", async () => {
process.env.ENABLE_PUBLIC_SELF_SERVICE_RESCHEDULE = "true";
const getAvailabilitySpy = jest
Expand Down
81 changes: 80 additions & 1 deletion api/tests/voiceStaffHandoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as routingPolicy from "../src/lib/scheduling/routingPolicy";
import { flushPendingEventPersistenceForTests } from "../src/lib/events";
import { setCallerPhoneForCall } from "../src/lib/requestContext";
import { flushStaffFallbackForTests } from "../src/lib/staffFallback";
import { MockSchedulingProvider } from "../src/lib/scheduling/mockProvider";

const CALLSID_PREFIX = "CA_P3_HANDOFF_";
const TEST_PHONE = "+15558880101";
Expand All @@ -31,19 +32,32 @@ async function postPress3(params: {
callSid: string;
from: string;
digits: string;
speech?: string;
}) {
return request(app)
.post("/api/voice/intent")
.type("form")
.send({
CallSid: params.callSid,
SpeechResult: "",
SpeechResult: params.speech || "",
Digits: params.digits,
From: params.from,
To: "+18005550199",
});
}

async function postCancellationLookup(callSid: string, from: string, speech: string) {
return request(app)
.post("/api/voice/reschedule?stage=cancel_lookup")
.type("form")
.send({
CallSid: callSid,
From: from,
SpeechResult: speech,
Digits: "",
});
}

describe("voice staff handoff", () => {
const originalEnv = { ...process.env };

Expand Down Expand Up @@ -116,6 +130,71 @@ describe("voice staff handoff", () => {
}
});

it("Press 3 business-hours cancellation speech is preserved before staff dial", async () => {
process.env.VOICE_STAFF_HANDOFF_ENABLED = "true";
process.env.VOICE_STAFF_PHONE_E164 = "+15551231234";
const rescheduleSpy = jest.spyOn(
MockSchedulingProvider.prototype,
"rescheduleAppointment"
);

const isWithinBusinessHoursSpy = jest
.spyOn(routingPolicy, "isWithinBusinessHours")
.mockReturnValue(true);

try {
const callSid = `${CALLSID_PREFIX}BH_CANCEL_INTERCEPT_001`;
const speech = "I need to cancel my appointment";
const routeRes = await postPress3({
callSid,
from: TEST_PHONE,
digits: "3",
speech,
});

expect(routeRes.status).toBe(200);
expect(routeRes.text).toContain("/api/voice/reschedule?stage=cancel_lookup");
expect(routeRes.text).toContain("Your appointment is not canceled yet");
expect(routeRes.text).not.toContain("<Dial");
expect(routeRes.text).not.toContain("preferred new appointment time");
expect(routeRes.text).not.toContain("preferred new day");
expect(routeRes.text).not.toContain("<Pay");
expect(routeRes.text).not.toContain("payment-capture");
expect(routeRes.text).not.toContain("chargeAmount");

const cancelRes = await postCancellationLookup(callSid, TEST_PHONE, speech);

expect(cancelRes.status).toBe(200);
expect(cancelRes.text).toContain("cancellation request");
expect(cancelRes.text).toContain("Your appointment is not canceled yet");
expect(cancelRes.text).not.toContain("preferred new appointment time");
expect(cancelRes.text).not.toContain("preferred new day");
expect(cancelRes.text).not.toContain("cancelAppointment");
expect(cancelRes.text).not.toContain("deleteAppointment");
expect(cancelRes.text).not.toContain("automatic Acuity");
expect(cancelRes.text).not.toContain("<Pay");
expect(cancelRes.text).not.toContain("payment-capture");
expect(cancelRes.text).not.toContain("chargeAmount");
expect(rescheduleSpy).not.toHaveBeenCalled();

const callback = await prisma.callbackRequest.findFirst({
where: {
source: "voice",
phone: TEST_PHONE,
requestType: "cancellation",
},
orderBy: [{ createdAt: "desc" }],
});

expect(callback).toBeTruthy();
expect(callback?.scenarioType).toBe("cancellation");
expect(callback?.summary).toContain("staff_action_required=confirm_cancellation");
expect(callback?.summary).not.toContain("requested_new_time=");
} finally {
isWithinBusinessHoursSpy.mockRestore();
}
});

it("Press 3 missing staff number creates callback fallback", async () => {
process.env.VOICE_STAFF_HANDOFF_ENABLED = "true";
delete process.env.VOICE_STAFF_PHONE_E164;
Expand Down
Loading