Skip to content
Open
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
4 changes: 3 additions & 1 deletion __tests__/payments/idempotency-minting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ describe("idempotency keys are always minted (#1093 §3)", () => {
// Live, and below the banner — these are the ones the old split lost.
expect(names).toContain("appointment_doc_thread_version_unique");
expect(names).toContain("onboarding_draft_payload_size");
expect(names).toContain("consultant_review_legacy_pair_key");
// `consultant_review_legacy_pair_key` used to live here. Reviews are now one
// per (consultant, consultee), which Prisma expresses as a real @@unique, so
// the sidecar's partial index was retired rather than left shadowing it.
// `IF NOT EXISTS` must not be captured as an index name.
expect(names).not.toContain("IF");
});
Expand Down
82 changes: 82 additions & 0 deletions __tests__/reviews/review-privacy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @jest-environment node
*/

/**
* #705 — anonymity has to hold in the PAYLOAD, not in the component.
*
* `isAnonymous` is a display choice, but stripping the name only where it is
* rendered would still ship it over the wire — and the public review feed is
* CDN-cached and readable by anyone with devtools. The reviewer's protection is
* only real if the server never sends the name.
*/

import {
stripAnonymousReviewer,
stripAnonymousReviewers,
} from "@/lib/data/review-privacy";

const named = {
isAnonymous: false,
rating: 5,
consulteeProfile: {
id: "consultee-profile-1",
userId: "user-1",
user: { name: "Priya S.", image: "https://x/y.png" },
},
};
const anon = { ...named, isAnonymous: true };

describe("anonymous reviewers", () => {
it("drops the whole profile, not just the name and avatar", () => {
const out = stripAnonymousReviewer(anon);
expect(out.consulteeProfile).toBeNull();
});

it("does not leak a stable id that could re-identify the reviewer", () => {
// The real hazard is CORRELATION, not the name. Review one expert under
// your name and another anonymously, and a shared consulteeProfile.id in
// both public payloads joins the two and unmasks the anonymous one. An
// opaque id stops being opaque the second time it appears.
const serialised = JSON.stringify(stripAnonymousReviewer(anon));
expect(serialised).not.toContain("consultee-profile-1");
expect(serialised).not.toContain("user-1");
expect(serialised).not.toContain("Priya");
expect(serialised).not.toContain("y.png");
});

it("leaves a named review completely untouched", () => {
expect(stripAnonymousReviewer(named)).toEqual(named);
});

it("keeps everything that is not identifying", () => {
// The rating still has to count and the review still has to render.
expect(stripAnonymousReviewer(anon).rating).toBe(5);
expect(stripAnonymousReviewer(anon).isAnonymous).toBe(true);
});

it("still names a NAMED reviewer \u2014 the strip is opt-in, not blanket", () => {
const out = stripAnonymousReviewer(named);
expect(JSON.stringify(out)).toContain("Priya S.");
});

it("does not mutate the row it was given", () => {
const row = {
...anon,
consulteeProfile: { ...anon.consulteeProfile },
};
stripAnonymousReviewer(row);
expect(row.consulteeProfile).not.toBeNull();
});

it("handles a review with no consultee profile at all", () => {
const orphan = { isAnonymous: true, consulteeProfile: null };
expect(() => stripAnonymousReviewer(orphan)).not.toThrow();
});

it("strips a mixed list, one by one", () => {
const out = stripAnonymousReviewers([named, anon]);
expect(JSON.stringify(out[0])).toContain("Priya S.");
expect(out[1].consulteeProfile).toBeNull();
});
});
81 changes: 69 additions & 12 deletions __tests__/support/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ jest.mock("../../lib/prisma", () => ({
findUniqueOrThrow: jest.fn(),
},
supportMessage: { create: jest.fn() },
supportTicket: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
supportTicket: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
supportTicketCounter: { upsert: jest.fn() },
user: { findMany: jest.fn() },
$transaction: jest.fn(),
Expand All @@ -51,7 +55,11 @@ const mockPrisma = prisma as unknown as {
findUniqueOrThrow: jest.Mock;
};
supportMessage: { create: jest.Mock };
supportTicket: { create: jest.Mock; findUnique: jest.Mock; update: jest.Mock };
supportTicket: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
supportTicketCounter: { upsert: jest.Mock };
user: { findMany: jest.Mock };
$transaction: jest.Mock;
Expand Down Expand Up @@ -111,7 +119,9 @@ beforeEach(() => {
mockPrisma.appointmentSupportThread.update.mockResolvedValue({
messageSeq: 0,
});
mockPrisma.appointmentSupportThread.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.appointmentSupportThread.updateMany.mockResolvedValue({
count: 1,
});
mockPrisma.appointmentSupportThread.findUniqueOrThrow.mockResolvedValue({
status: "ESCALATED",
messageSeq: 0,
Expand All @@ -125,15 +135,19 @@ beforeEach(() => {
describe("runSupportTurn", () => {
it("returns 404-null when the appointment is gone", async () => {
mockPrisma.appointment.findUnique.mockResolvedValueOnce(null);
const r = await runSupportTurn("missing", "user1", { category: "CANCEL_REFUND" });
const r = await runSupportTurn("missing", "user1", {
category: "CANCEL_REFUND",
});
expect(r).toBeNull();
});

it("presents the entry prompt on the first turn (IN_PROGRESS, no action)", async () => {
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ currentNodeId: null }),
);
const r = await runSupportTurn("appt1", "user1", { category: "CANCEL_REFUND" });
const r = await runSupportTurn("appt1", "user1", {
category: "CANCEL_REFUND",
});
expect(r?.status).toBe("IN_PROGRESS");
expect(r?.currentNodeId).toBe("start");
expect(r?.escalated).toBe(false);
Expand All @@ -145,8 +159,12 @@ describe("runSupportTurn", () => {
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ currentNodeId: "start" }),
);
const r = await runSupportTurn("appt1", "user1", { chosenOptionId: "cancel" });
expect(r?.actions).toEqual([{ kind: "OFFER_CANCEL_REFUND", refundPct: 100 }]);
const r = await runSupportTurn("appt1", "user1", {
chosenOptionId: "cancel",
});
expect(r?.actions).toEqual([
{ kind: "OFFER_CANCEL_REFUND", refundPct: 100 },
]);
});

it("escalates on a human keyword: creates a SupportTicket and flips to HUMAN", async () => {
Expand All @@ -166,7 +184,10 @@ describe("runSupportTurn", () => {
expect(mockPrisma.supportTicket.create).toHaveBeenCalledTimes(1);
expect(mockPrisma.appointmentSupportThread.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "ESCALATED", activeChannel: "HUMAN" }),
data: expect.objectContaining({
status: "ESCALATED",
activeChannel: "HUMAN",
}),
}),
);
});
Expand All @@ -175,7 +196,9 @@ describe("runSupportTurn", () => {
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ activeChannel: "HUMAN", supportTicketId: "ticket-existing" }),
);
const r = await runSupportTurn("appt1", "user1", { userMessage: "any update?" });
const r = await runSupportTurn("appt1", "user1", {
userMessage: "any update?",
});
expect(r?.activeChannel).toBe("HUMAN");
expect(r?.supportTicketId).toBe("ticket-existing");
expect(mockPrisma.supportTicket.create).not.toHaveBeenCalled();
Expand All @@ -197,7 +220,9 @@ describe("runSupportTurn", () => {
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ category: "CANCEL_REFUND", currentNodeId: "ghost-node" }),
);
const r = await runSupportTurn("appt1", "user1", { chosenOptionId: "cancel" });
const r = await runSupportTurn("appt1", "user1", {
chosenOptionId: "cancel",
});
expect(r?.currentNodeId).toBe("start"); // presented the CURRENT entry…
expect(r?.escalated).toBe(false); // …not failed safe to a human
expect(mockPrisma.supportMessage.create).toHaveBeenCalledTimes(1);
Expand All @@ -207,7 +232,9 @@ describe("runSupportTurn", () => {
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ category: "CANCEL_REFUND", currentNodeId: "start" }),
);
const r = await runSupportTurn("appt1", "user1", { chosenOptionId: "bogus" });
const r = await runSupportTurn("appt1", "user1", {
chosenOptionId: "bogus",
});
expect(r?.currentNodeId).toBe("start"); // cursor did not move
// Exactly one bubble, and it is NOT a verbatim repeat of the prompt: this
// used to persist nothing at all, so a user who typed at a prompt watched
Expand Down Expand Up @@ -240,11 +267,41 @@ describe("runSupportTurn", () => {
expect(written[1].sender).toBe("BOT");
});

it("does not scold the user for typing the word the nudge told them to type", async () => {
// "agent" matches no option, so the walk emits "I didn't catch that…" —
// which is the very copy telling them to type "agent". Escalating while
// persisting that message left the transcript contradicting itself one line
// above the hand-off.
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ category: "CANCEL_REFUND", currentNodeId: "start" }),
);
mockPrisma.supportTicket.create.mockResolvedValue({
id: "t-kw",
title: "T",
organizationId: null,
referenceNumber: "FAM-2026-000001",
});
const r = await runSupportTurn("appt1", "user1", { userMessage: "agent" });
expect(r?.escalated).toBe(true);

const bodies = mockPrisma.supportMessage.create.mock.calls.map(
(c) => c[0].data,
);
// The user's own words are still recorded…
expect(bodies.some((b) => b.sender === "USER" && b.body === "agent")).toBe(
true,
);
// …but nothing tells them it wasn't understood.
expect(bodies.some((b) => /didn't catch that/i.test(b.body))).toBe(false);
});

it("clicking the active intent chip restarts the flow at its entry", async () => {
mockPrisma.appointmentSupportThread.upsert.mockResolvedValue(
threadRow({ category: "CANCEL_REFUND", currentNodeId: "confirm" }),
);
const r = await runSupportTurn("appt1", "user1", { category: "CANCEL_REFUND" });
const r = await runSupportTurn("appt1", "user1", {
category: "CANCEL_REFUND",
});
expect(r?.currentNodeId).toBe("start");
expect(r?.status).toBe("IN_PROGRESS");
});
Expand Down
Loading
Loading