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
80 changes: 75 additions & 5 deletions __tests__/enterprise/consent-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,35 @@
*/

const mockFindFirst = jest.fn();
const mockUpdateMany = jest.fn();

jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: { consentArtifact: { findFirst: (...a: unknown[]) => mockFindFirst(...a) } },
default: {
consentArtifact: {
findFirst: (...a: unknown[]) => mockFindFirst(...a),
updateMany: (...a: unknown[]) => mockUpdateMany(...a),
},
},
}));

import { checkConsent } from "@/lib/compliance/dpdp";
import { PURPOSE_CODES } from "@/lib/compliance/purpose-codes";
// withdrawConsent fires a SESSION_BOOKING cascade event through a dynamic
// import; the gate under test does not depend on it.
jest.mock("../../lib/enterprise/system-events", () => ({
__esModule: true,
recordSystemEvent: jest.fn(),
}));

import { checkConsent, withdrawConsent } from "@/lib/compliance/dpdp";
import {
PURPOSE_CODES,
purposeCodeAliases,
} from "@/lib/compliance/purpose-codes";

beforeEach(() => mockFindFirst.mockReset());
beforeEach(() => {
mockFindFirst.mockReset();
mockUpdateMany.mockReset();
});

describe("checkConsent — fail-closed (#701)", () => {
it("is true when a live artifact for the purpose exists", async () => {
Expand All @@ -31,7 +50,8 @@ describe("checkConsent — fail-closed (#701)", () => {
// The query must filter withdrawn + expired out and match the purpose code.
const where = mockFindFirst.mock.calls[0][0].where;
expect(where.userId).toBe("u1");
expect(where.purposeCodes).toEqual({ has: PURPOSE_CODES.SESSION_BOOKING });
// #1472 — the canonical code plus its legacy aliases, not an exact match.
expect(where.purposeCodes.hasSome).toContain(PURPOSE_CODES.SESSION_BOOKING);
expect(where.withdrawnAt).toBeNull();
expect(where.auditRetainedUntil.gt).toBeInstanceOf(Date);
});
Expand All @@ -50,3 +70,53 @@ describe("checkConsent — fail-closed (#701)", () => {
expect(PURPOSE_CODES.PRIMARY_PROCESSING).toBe("PRIMARY_PROCESSING");
});
});

/**
* #1472 — the pre-taxonomy kebab-case codes are still on disk (no backfill:
* pre-MVP reset). A consent record is a legal artifact, so the gate that
* decides whether a consultant can be booked must recognise every code the
* platform ever wrote for that purpose, and a narrow withdrawal must reach the
* same rows the gate reads.
*/
describe("#1472 legacy purpose codes are recognised by the runtime gates", () => {
/** Match an artifact's stored codes against a `hasSome` clause, as PG would. */
const matches = (
where: { purposeCodes: { hasSome: string[] } },
stored: string[],
) => stored.some((code) => where.purposeCodes.hasSome.includes(code));

it("lets a `session-booking` artifact satisfy a SESSION_BOOKING check", async () => {
mockFindFirst.mockResolvedValue({ id: "legacy-artifact" });
await checkConsent({
userId: "u1",
purposeCode: PURPOSE_CODES.SESSION_BOOKING,
});

const where = mockFindFirst.mock.calls[0][0].where;
expect(matches(where, ["session-booking"])).toBe(true);
// An unrelated legacy code is NOT swept in by the same alias set.
expect(matches(where, ["marketing"])).toBe(false);
});

it("withdraws a `session-booking` artifact on a SESSION_BOOKING withdrawal", async () => {
mockUpdateMany.mockResolvedValue({ count: 1 });
await withdrawConsent({
userId: "u1",
purposeCode: PURPOSE_CODES.SESSION_BOOKING,
});

const where = mockUpdateMany.mock.calls[0][0].where;
expect(matches(where, ["session-booking"])).toBe(true);
expect(matches(where, ["third-party-sharing-with-stream"])).toBe(false);
});

it("resolves aliases per purpose, never across purposes", () => {
expect(purposeCodeAliases(PURPOSE_CODES.SESSION_BOOKING)).toEqual([
"SESSION_BOOKING",
"session-booking",
]);
expect(purposeCodeAliases(PURPOSE_CODES.MARKETING_COMMS)).not.toContain(
"session-booking",
);
});
});
13 changes: 11 additions & 2 deletions __tests__/payments/checkout-pool-1-nesting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const mockTxClient = {
slotOfAppointment: {
findFirst: jest.fn(async () => null),
},
// #1463 — the self-hold lookup is a fourth read on this helper's path, and
// it must ride the transaction client like every other one.
appointment: {
findMany: jest.fn(async (): Promise<never[]> => []),
},
};

jest.mock("../../lib/prisma", () => {
Expand Down Expand Up @@ -124,7 +129,9 @@ function slotInput(): CheckoutInput {
}

/** The shape every real caller uses: the helper runs inside an open tx. */
function validateInsideTransaction(): Promise<void> {
function validateInsideTransaction(): Promise<{
selfHoldAppointmentIds: string[];
}> {
return prisma.$transaction(async (tx) =>
validateSlotAvailability(
tx as unknown as Tx,
Expand All @@ -145,7 +152,9 @@ describe("#1421 checkout does not starve the single-connection pool", () => {
});

it("runs the consent gate on the transaction client, not the global one", async () => {
await expect(validateInsideTransaction()).resolves.toBeUndefined();
await expect(validateInsideTransaction()).resolves.toEqual({
selfHoldAppointmentIds: [],
});

expect(mockTxClient.consentArtifact.findFirst).toHaveBeenCalledTimes(1);
expect(mockGlobalTouches).toEqual([]);
Expand Down
247 changes: 247 additions & 0 deletions __tests__/payments/checkout-self-hold-resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* @jest-environment node
*/

/**
* #1463 — a buyer who closes the gateway modal and clicks Pay again used to be
* blocked by their OWN tentative hold. `validateSlotAvailability` ran before
* the open-order resume (`findReusablePendingOrderPayment`, "Rec C") and threw
* "Time slot is already booked", so the documented same-order resume was
* unreachable and the buyer waited for the hold to expire.
*
* The exclusion has to be exactly as narrow as the resume gate it feeds, which
* is what this pin holds in place: the same buyer, the same plan, the same
* gateway and the exact same window passes; a different buyer on the same slot
* still blocks; the same buyer on an overlapping-but-different window still
* blocks; and (#1465-triage) so does a hold minted on a different gateway,
* which `findReusablePendingOrderPayment` could neither resume nor supersede.
*
* The transaction client below evaluates the two blocking predicates against an
* in-memory hold rather than asserting on query shape, so the self-hold
* exclusion has to actually work for these to pass. Exactness in particular is
* real: a booked window is stored as N contiguous 30-minute atoms, and the
* helper decides coverage from the run's first start and last end.
*/

// Boundary mocks. `lib/payments/operations/checkout` transitively imports the
// auth stack through the payouts barrel, which is ESM-only under this Jest
// transform; none of it is reached by the availability helper, which is handed
// its own transaction client.
jest.mock("../../lib/prisma", () => ({ __esModule: true, default: {} }));

jest.mock("../../lib/payments/payouts", () => ({
__esModule: true,
createEarningsFromPayment: jest.fn(),
}));

jest.mock("../../lib/payments/index", () => ({
__esModule: true,
createPaymentIntent: jest.fn(),
cancelPaymentIntent: jest.fn(),
}));

jest.mock("../../utils/appointmentlock", () => ({
__esModule: true,
CHECKOUT_WAIT_RETRY_CONFIG: { retryCount: 5 },
CHECKOUT_LOCK_TTL_MS: {},
EventFullError: class extends Error {},
lockSlotBooking: jest.fn(),
unlockSlotBooking: jest.fn(),
lockEventCheckout: jest.fn(),
unlockEventCheckout: jest.fn(),
lockConsulteeBooking: jest.fn(),
unlockConsulteeBooking: jest.fn(),
extendLock: jest.fn(),
extendSlotInterval: jest.fn(),
}));

jest.mock("../../lib/compliance/dpdp", () => ({
__esModule: true,
checkConsent: jest.fn(async () => true),
PURPOSE_CODES: { SESSION_BOOKING: "SESSION_BOOKING" },
}));

import type { Tx } from "../../lib/prisma";
import type { CheckoutInput } from "../../schemas/checkout";
import { validateSlotAvailability } from "../../lib/payments/operations/checkout";

const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;

const BUYER = "buyer-user-1";
const CONSULTANT = "consultant-user-1";
const PLAN = "plan-1";

/** The held window: 48 h out, one hour long, stored as two 30-minute atoms. */
const WINDOW_START = new Date(Date.now() + 48 * HOUR_MS);
const WINDOW_END = new Date(WINDOW_START.getTime() + HOUR_MS);

interface HeldSlot {
appointmentId: string;
startsAt: Date;
endsAt: Date;
isTentative: boolean;
}

/** One live tentative hold, minted by a previous checkout attempt. */
const HOLD_APPOINTMENT_ID = "appt-hold";
const heldSlots: HeldSlot[] = [
{
appointmentId: HOLD_APPOINTMENT_ID,
startsAt: WINDOW_START,
endsAt: new Date(WINDOW_START.getTime() + 30 * MINUTE_MS),
isTentative: true,
},
{
appointmentId: HOLD_APPOINTMENT_ID,
startsAt: new Date(WINDOW_START.getTime() + 30 * MINUTE_MS),
endsAt: WINDOW_END,
isTentative: true,
},
];

/** The hold's live PENDING payment belongs to the buyer, and to nobody else. */
const HOLD_OWNER = BUYER;

/** ...and it was minted on the gateway the resume gate would look for. */
const HOLD_GATEWAY = "RAZORPAY";

/** The `AND` terms of the two blocking slot queries this suite discriminates. */
interface SlotWhereTerm {
NOT?: SlotWhereTerm;
appointmentId?: { in: string[] };
startsAt?: { lt: Date };
endsAt?: { gt: Date };
isTentative?: boolean;
}

/** The self-hold lookup's `where`, as far as this suite reads it. */
interface SelfHoldWhere {
payment?: {
some?: {
userId?: string;
paymentGateway?: string;
organizationId?: string | null;
};
};
consultation?: { consultationPlanId?: string };
slotsOfAppointment?: { some?: { startsAt?: Date } };
}

/**
* Evaluate one term of the blocking queries' `AND` array against a held slot.
* Only the terms this suite can discriminate are modelled; the relation terms
* (consultant membership, occupancy, the live-payment join) are true for the
* single fixture hold by construction.
*/
function termMatches(slot: HeldSlot, term: SlotWhereTerm): boolean {
if (term.NOT) return !termMatches(slot, term.NOT);
if (term.appointmentId?.in) {
return term.appointmentId.in.includes(slot.appointmentId);
}
if (term.startsAt?.lt) return slot.startsAt < term.startsAt.lt;
if (term.endsAt?.gt) return slot.endsAt > term.endsAt.gt;
if (term.isTentative !== undefined) {
return slot.isTentative === term.isTentative;
}
return true;
}

const tx = {
// The self-hold lookup: same buyer, same plan, an atom starting at the
// requested window's start. Exact coverage is decided by the helper itself.
appointment: {
findMany: async ({ where }: { where: SelfHoldWhere }) => {
const wantedStart = where.slotsOfAppointment?.some?.startsAt;
if (where.payment?.some?.userId !== HOLD_OWNER) return [];
// #1465-triage — the resume gate's own scope, and therefore this
// exclusion's: a hold on another gateway or another org is not adoptable.
if (where.payment?.some?.paymentGateway !== HOLD_GATEWAY) return [];
if ((where.payment?.some?.organizationId ?? null) !== null) return [];
if (where.consultation?.consultationPlanId !== PLAN) return [];
if (
!wantedStart ||
!heldSlots.some((s) => s.startsAt.getTime() === wantedStart.getTime())
) {
return [];
}
return [
{
id: HOLD_APPOINTMENT_ID,
slotsOfAppointment: heldSlots.map((s) => ({
startsAt: s.startsAt,
endsAt: s.endsAt,
})),
},
];
},
},
slotOfAppointment: {
findFirst: async ({ where }: { where: { AND: SlotWhereTerm[] } }) =>
heldSlots.find((slot) =>
where.AND.every((term) => termMatches(slot, term)),
) ?? null,
},
} as unknown as Tx;

function checkoutInput(
startsAt: Date,
endsAt: Date,
paymentGateway: string = HOLD_GATEWAY,
): CheckoutInput {
return {
appointmentType: "CONSULTATION",
planId: PLAN,
paymentGateway,
startsAt: startsAt.toISOString(),
endsAt: endsAt.toISOString(),
} as unknown as CheckoutInput;
}

describe("#1463 the buyer's own live hold does not block their resume", () => {
it("lets the same buyer, same plan and exact window through to the open-order resume", async () => {
const result = await validateSlotAvailability(
tx,
checkoutInput(WINDOW_START, WINDOW_END),
BUYER,
CONSULTANT,
);

expect(result.selfHoldAppointmentIds).toEqual([HOLD_APPOINTMENT_ID]);
});

it("still blocks a different buyer on the same slot", async () => {
await expect(
validateSlotAvailability(
tx,
checkoutInput(WINDOW_START, WINDOW_END),
"other-buyer",
CONSULTANT,
),
).rejects.toThrow("Time slot is already booked");
});

it("still blocks a hold this request could not resume (other gateway)", async () => {
await expect(
validateSlotAvailability(
tx,
checkoutInput(WINDOW_START, WINDOW_END, "STRIPE"),
BUYER,
CONSULTANT,
),
).rejects.toThrow("Time slot is already booked");
});

it("still blocks the same buyer on an overlapping but different window", async () => {
const shifted = new Date(WINDOW_START.getTime() + 30 * MINUTE_MS);

await expect(
validateSlotAvailability(
tx,
checkoutInput(shifted, new Date(shifted.getTime() + HOUR_MS)),
BUYER,
CONSULTANT,
),
).rejects.toThrow("Time slot is already booked");
});
});
Loading
Loading