Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
ee95455
feat(moderation): wire real side-effects into staff moderation action…
teetangh Jul 11, 2026
fde3490
fix(stream): authz guards on token providers and addMemberToChannel (…
teetangh Jul 11, 2026
de0b0bc
perf(webhooks): narrow notification fetches from deep includes to sel…
teetangh Jul 11, 2026
e9471ae
chore(tasks)+docs: retire the tasks/ folder; record moderation + peer…
teetangh Jul 11, 2026
49ea52b
fix(moderation): CI type errors — use extended-client Tx type, es5-sa…
teetangh Jul 11, 2026
e154607
fix(moderation): review-comment triage — clearable suspension input, …
teetangh Jul 11, 2026
5987e85
fix(moderation): CodeRabbit triage — honor Novu TriggerResult, surfac…
teetangh Jul 11, 2026
ea5749f
fix(stream): session-bind token actions and demote default Stream role
teetangh Jul 11, 2026
665ca5b
fix(enterprise): enforce ADR-18 program allowlist + exclusive-engagem…
teetangh Jul 11, 2026
36dea1e
fix(reviews): enforce review ownership + booking eligibility, unique …
teetangh Jul 11, 2026
d400e4b
fix(stream): grant channel-scoped host/moderator on event + meeting c…
teetangh Jul 11, 2026
a76e371
fix(stream): enqueue permanent-recording transfer on ready, stream up…
teetangh Jul 11, 2026
183d0e7
chore(payments): remove Lemon Squeezy + XFlow, add post-MVP DODO_PAYM…
teetangh Jul 11, 2026
0539a30
feat(waitlist): soft-hold seat on accept, transactional join with fri…
teetangh Jul 11, 2026
ebd9f86
fix(booking): scope subscription reschedule status, wire allocation i…
teetangh Jul 11, 2026
fe8d808
fix(trust): referral stash (#891), Novu idempotency, support dedup, c…
teetangh Jul 11, 2026
043550f
fix(payments): auto-refund amount-mismatch + double-booking loser, fr…
teetangh Jul 11, 2026
5e171b0
fix(enterprise): CAS onboarding submit + SSO settings, gate SCIM seat…
teetangh Jul 11, 2026
a663356
fix(enterprise): rescope PENDING_TRUST to sponsor org, park consultan…
teetangh Jul 11, 2026
3ed84ce
feat(booking): consultant no-show detection with auto-refund and noti…
teetangh Jul 11, 2026
1ce9888
feat(payments): add BATCHED earnings status so PAID means cash moved
teetangh Jul 11, 2026
44a52a9
fix(payments): close the checkout↔ledger earnings-journal consistency…
teetangh Jul 11, 2026
eeb72f7
fix(booking): address review — guard undefined appointmentId filter, …
teetangh Jul 11, 2026
5f5fe88
fix(trust): address review — strip internal fields from ticket dedup …
teetangh Jul 11, 2026
08a8843
fix(payments): address review — add WalletFrozenError httpStatus
teetangh Jul 11, 2026
cc55e96
fix(payments): address review — BigInt-safe Sentry context, clear man…
teetangh Jul 11, 2026
0dd75bf
fix(booking): address review — reconcile detector excludes in-flight …
teetangh Jul 11, 2026
c45f17b
fix(reviews): address review — validate PUT body, serialize rating re…
teetangh Jul 11, 2026
a89117a
fix(stream): address review — don't grant channel-moderator on direct…
teetangh Jul 11, 2026
44b4f12
fix(stream): address review — make recording enqueue durable, bound r…
teetangh Jul 11, 2026
34fb1cc
fix(enterprise): address review — always bump SSO version, idempotent…
teetangh Jul 11, 2026
bf57e0a
Merge remote-tracking branch 'origin/fix/enterprise-invoice-trust' in…
teetangh Jul 11, 2026
57bfd00
Merge remote-tracking branch 'origin/feat/payout-batched-status' into…
teetangh Jul 11, 2026
c376d09
Merge remote-tracking branch 'origin/fix/checkout-earnings-atomicity'…
teetangh Jul 11, 2026
fef5643
Merge remote-tracking branch 'origin/feat/noshow-automation' into int…
teetangh Jul 11, 2026
970c03e
Merge remote-tracking branch 'origin/fix/booking-correctness' into in…
teetangh Jul 11, 2026
4d935ff
Merge remote-tracking branch 'origin/fix/reviews-integrity' into inte…
teetangh Jul 11, 2026
cd5a27a
Merge remote-tracking branch 'origin/chore/gateway-cleanup' into inte…
teetangh Jul 11, 2026
c4fbbc6
Merge remote-tracking branch 'origin/fix/onboarding-identity-races' i…
teetangh Jul 11, 2026
2405974
Merge remote-tracking branch 'origin/fix/trust-safety-sweep' into int…
teetangh Jul 11, 2026
379b78f
Merge remote-tracking branch 'origin/fix/stream-security' into integr…
teetangh Jul 11, 2026
103cf5d
Merge remote-tracking branch 'origin/fix/adr18-allowlist-enforcement'…
teetangh Jul 11, 2026
0b38132
Merge remote-tracking branch 'origin/feat/waitlist-seat-hold' into in…
teetangh Jul 11, 2026
2613e83
Merge remote-tracking branch 'origin/fix/recordings-pipeline' into in…
teetangh Jul 11, 2026
d4a6e68
docs: reconcile documentation with the CTO audit fix wave
teetangh Jul 11, 2026
44205cd
fix(integration): resolve type errors from the merge — narrow checkou…
teetangh Jul 11, 2026
85a8f35
fix(integration): address review — INVOICE-gate PENDING_TRUST, surfac…
teetangh Jul 11, 2026
6accddb
test(integration): update booking/payments/payout tests for the fix-w…
teetangh Jul 12, 2026
2289847
Merge remote-tracking branch 'origin/dev' into HEAD
teetangh Jul 16, 2026
6a2cb59
fix(moderation): unblock next build for /api/admin/analytics + update…
teetangh Jul 16, 2026
888a7ae
fix(auth): define STAFF/ADMIN admin roles so the BetterAuth admin plu…
teetangh Jul 16, 2026
ed0f994
revert(moderation): drop unnecessary force-dynamic on analytics route
teetangh Jul 16, 2026
431a823
merge(#974): reconcile moderation branch into the audit wave
teetangh Jul 16, 2026
d126d30
docs+chore(moderation): clarify sideEffects recovery guarantee, Numbe…
teetangh Jul 16, 2026
b58923a
Merge remote-tracking branch 'origin/feature/moderation-actions-693' …
teetangh Jul 16, 2026
8776d31
fix(novu): give toSorted a localeCompare comparator (Sonar S2871)
teetangh Jul 16, 2026
d3e3161
chore(moderation): Number.parseInt (Sonar S7773)
teetangh Jul 16, 2026
0ee07a0
chore(sonar): node:fs import + Number.parseInt (S7772/S7773)
teetangh Jul 16, 2026
48fda59
fix(moderation): reject decimal suspension durations instead of trunc…
teetangh Jul 16, 2026
7c96868
refactor(sonar): reduce cognitive complexity in SSO/onboarding/cleanu…
teetangh Jul 16, 2026
fb3fbfc
Merge remote-tracking branch 'origin/feature/moderation-actions-693' …
teetangh Jul 16, 2026
1d36220
refactor(moderation): reduce cognitive complexity in side-effects/act…
teetangh Jul 16, 2026
7a93861
Merge remote-tracking branch 'origin/feature/moderation-actions-693' …
teetangh Jul 16, 2026
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
2 changes: 0 additions & 2 deletions .github/workflows/cleanup-abandoned-payments.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ jobs:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
RAZORPAY_KEY_ID: ${{ secrets.RAZORPAY_KEY_ID }}
RAZORPAY_SECRET: ${{ secrets.RAZORPAY_SECRET }}
LEMON_SQUEEZY_API_KEY: ${{ secrets.LEMON_SQUEEZY_API_KEY }}
XFLOW_SECRET_KEY: ${{ secrets.XFLOW_SECRET_KEY }}

steps:
- name: Checkout code
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/detect-consultant-no-shows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Detect Consultant No-Shows

on:
schedule:
# Run hourly (offset to avoid colliding with other :00/:07 crons)
- cron: "17 * * * *"
workflow_dispatch: # Allow manual triggering

jobs:
detect-consultant-no-shows:
runs-on: ubuntu-latest
timeout-minutes: 10

env:
# Database connection (required for Prisma)
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DIRECT_URL: ${{ secrets.DIRECT_URL }}
# #476 cron locks load lib/redis at import — every job entry needs these
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }}
UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }}
# #471 auto-refund reuses refundPayment (#990) — gateway creds for parity
# with the other money jobs (reconcile-pending-refunds).
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
RAZORPAY_KEY_ID: ${{ secrets.RAZORPAY_KEY_ID }}
RAZORPAY_SECRET: ${{ secrets.RAZORPAY_SECRET }}
# Both-party no-show + refund notifications
NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }}
NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }}

steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "22"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npx prisma generate

- name: Detect consultant no-shows
run: npx tsx jobs/appointments/detect-consultant-no-shows.ts

- name: Notify on failure
if: failure()
env:
SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }}
run: bash scripts/ci/notify-ops-failure.sh "detect-consultant-no-shows"
4 changes: 4 additions & 0 deletions __tests__/booking-algorithm/rescheduleCancel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@
update: jest.fn(),
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
// #448 — a PARTIAL (slotIds) subscription reschedule only terminal-guards
// via count (no status write); a positive count means the from-state is
// still reschedulable so the route proceeds without flipping to PENDING.
count: jest.fn().mockResolvedValue(1),
},
webinar: {
update: jest.fn(),
Expand Down Expand Up @@ -319,7 +323,7 @@
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe("SCHEDULE_CONFLICT");

Check failure on line 326 in __tests__/booking-algorithm/rescheduleCancel.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
}
});

Expand Down Expand Up @@ -348,7 +352,7 @@
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.notes).toBe(

Check failure on line 355 in __tests__/booking-algorithm/rescheduleCancel.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
"Need to cancel due to scheduling conflict",
);
}
Expand Down
4 changes: 4 additions & 0 deletions __tests__/booking-algorithm/rescheduleResponses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ function makeMockTx(appointmentData: any) {
update: jest.fn(),
// B2 — the cancel/reschedule CAS guards use updateMany.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
// #448 — a PARTIAL (slotIds) subscription reschedule only terminal-guards
// via count (no status write); positive count keeps the route on the
// happy path without flipping the whole subscription to PENDING.
count: jest.fn().mockResolvedValue(1),
},
webinar: {
update: jest.fn(),
Expand Down
8 changes: 6 additions & 2 deletions __tests__/booking-algorithm/slotAllocationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
AppointmentsType,
AppointmentStatus,
} from "@prisma/client";
import {

Check failure on line 73 in __tests__/booking-algorithm/slotAllocationService.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Mocks should not be manually imported from a __mocks__ directory. Instead use `jest.mock` and import from the original module path
makeWeeklyAvailabilitySlot,
makeCustomAvailabilitySlot,
} from "./__mocks__/booking.mockData";
Expand Down Expand Up @@ -2213,8 +2213,12 @@
slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"],
});

// Lock should have been acquired with the consultant profile ID
expect(lockAutoAllocate).toHaveBeenCalledWith("consultant-profile-1");
// Lock should have been acquired with the consultant profile ID,
// day-sharded (#860) by the earliest target slot's day.
expect(lockAutoAllocate).toHaveBeenCalledWith(
"consultant-profile-1",
"2025-01-06",
);
// Lock should have been released in finally block
expect(unlockAutoAllocate).toHaveBeenCalled();
});
Expand Down
6 changes: 4 additions & 2 deletions __tests__/enterprise/live-payout-submission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,12 @@ describe("processOrgPayout — live submission gating", () => {
}),
);

// Earnings release: PAID → READY, orgPayoutId nulled.
// #993 — a PROCESSING→FAILED submission never reached PAID: batch creation
// staged the earnings READY→BATCHED, so the failure release is BATCHED→READY
// (orgPayoutId nulled), not PAID→READY.
expect(mockedPrisma.organizationEarnings.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { orgPayoutId: PAYOUT_ID, status: "PAID" },
where: { orgPayoutId: PAYOUT_ID, status: "BATCHED" },
data: { status: "READY", orgPayoutId: null },
}),
);
Expand Down
77 changes: 64 additions & 13 deletions __tests__/payments/capture-amount-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
*/

/**
* #677 — defence-in-depth capture-amount parity in handlePaymentSuccess.
* #677 / #990 — defence-in-depth capture-amount parity in handlePaymentSuccess.
*
* The gateway order is created at checkout for exactly Payment.amount and the
* webhook is HMAC-verified, so a captured amount that differs is a gateway
* anomaly or our-own bug. handlePaymentSuccess must NOT silently confirm the
* booking — it marks the payment REQUIRES_MANUAL_RECOVERY, pages (Sentry), and
* returns before confirming. (The matching-amount happy path is exercised
* end-to-end by the live signed-webhook verification; here we pin the new guard,
* whose mismatch branch returns early — before any Phase-2 work.)
* anomaly or our-own bug. handlePaymentSuccess must NOT confirm the booking.
* #990 changed the remediation: Phase 1 pages (Sentry, fatal) and stamps
* REQUIRES_MANUAL_RECOVERY as a FALLBACK marker, then Phase 2 AUTO-REFUNDS the
* wrong-amount capture via refundPayment and clears the marker. The manual
* marker only survives if the refund call itself throws. Either way the booking
* is never confirmed (no appointment lookup, no earnings, no Phase-2 confirm
* work). The matching-amount happy path is inert on the guard.
*/

const captureException = jest.fn();
Expand All @@ -35,9 +37,17 @@ const txStub = {
payment: { findUnique: paymentFindUnique, update: paymentUpdate },
appointment: { findUnique: appointmentFindUnique },
};
// #990 — the Phase-2 clear-marker write runs on the base client (outside the
// tx). Give it its own update mock so the auto-refund success path completes.
const prismaPaymentUpdate = jest.fn(
async (_args: { where: unknown; data: { description?: string } }) => ({}),
);
jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: { $transaction: async (fn: (tx: unknown) => unknown) => fn(txStub) },
default: {
$transaction: async (fn: (tx: unknown) => unknown) => fn(txStub),
payment: { update: (...a: unknown[]) => prismaPaymentUpdate(...(a as [never])) },
},
}));

// Side-effectful import graph — present only so the module loads; the mismatch
Expand All @@ -46,7 +56,10 @@ const createEarningsFromPayment = jest.fn();
jest.mock("../../lib/payments/payouts", () => ({
createEarningsFromPayment: (...a: unknown[]) => createEarningsFromPayment(...a),
}));
jest.mock("../../lib/payments/operations/refund", () => ({ refundPayment: jest.fn() }));
const refundPayment = jest.fn();
jest.mock("../../lib/payments/operations/refund", () => ({
refundPayment: (...a: unknown[]) => refundPayment(...a),
}));
jest.mock("../../lib/email", () => ({
sendPaymentSuccessEmail: jest.fn(),
sendPaymentFailedEmail: jest.fn(),
Expand Down Expand Up @@ -92,22 +105,60 @@ beforeEach(() => {
});
});

describe("#677 — handlePaymentSuccess capture-amount parity", () => {
it("blocks confirmation + pages when the captured amount ≠ Payment.amount", async () => {
describe("#677 / #990 — handlePaymentSuccess capture-amount parity", () => {
it("blocks confirmation, pages, and AUTO-REFUNDS when captured amount ≠ Payment.amount", async () => {
refundPayment.mockResolvedValue({ id: "rfnd1" });

await handlePaymentSuccess("order1", { appointmentType: "CONSULTATION" }, 9999);

// Paged with the mismatch error.
// Paged exactly once with the mismatch error (the fatal Phase-1 page). No
// second page fires because the refund succeeds.
expect(captureException).toHaveBeenCalledTimes(1);
expect(String(captureException.mock.calls[0][0])).toContain(
"Capture amount mismatch",
);

// Marked for manual recovery (and NOT a normal confirmation).
// Phase 1 stamped the REQUIRES_MANUAL_RECOVERY fallback marker (in-tx).
expect(paymentUpdate).toHaveBeenCalledTimes(1);
const update = paymentUpdate.mock.calls[0][0];
expect(update.data.description).toContain("REQUIRES_MANUAL_RECOVERY");

// Returned before confirming the booking or doing any Phase-2 work.
// #990 — Phase 2 auto-refunded the wrong-amount capture for this payment.
expect(refundPayment).toHaveBeenCalledTimes(1);
expect(refundPayment.mock.calls[0][0]).toMatchObject({
paymentId: "pay1",
initiatedByUserId: null,
});

// …then cleared the fallback marker on the base client (refund succeeded).
expect(prismaPaymentUpdate).toHaveBeenCalledTimes(1);
expect(prismaPaymentUpdate.mock.calls[0][0].data.description).toContain(
"Auto-refunded",
);

// Booking was never confirmed and no Phase-2 confirm work ran.
expect(appointmentFindUnique).not.toHaveBeenCalled();
expect(createEarningsFromPayment).not.toHaveBeenCalled();
});

it("keeps REQUIRES_MANUAL_RECOVERY + pages twice when the auto-refund itself fails", async () => {
// #990 fallback: if refundPayment throws, the manual-recovery marker is NOT
// cleared (no clear-marker write) and the refund failure is paged too.
refundPayment.mockRejectedValue(new Error("gateway 500"));

await handlePaymentSuccess("order1", { appointmentType: "CONSULTATION" }, 9999);

// Two pages: the Phase-1 mismatch page + the Phase-2 refund-failure page.
expect(captureException).toHaveBeenCalledTimes(2);
expect(String(captureException.mock.calls[0][0])).toContain(
"Capture amount mismatch",
);

// The REQUIRES_MANUAL_RECOVERY marker survives (clear-marker write skipped).
expect(refundPayment).toHaveBeenCalledTimes(1);
expect(prismaPaymentUpdate).not.toHaveBeenCalled();

// Still no booking confirmation.
expect(appointmentFindUnique).not.toHaveBeenCalled();
expect(createEarningsFromPayment).not.toHaveBeenCalled();
});
Expand Down
12 changes: 8 additions & 4 deletions __tests__/payments/stuck-payouts-money-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ const PAYOUT: Row = {
tdsDeducted: 1000,
tdsRateAppliedBps: 100,
tdsFinancialYear: "2026-27",
earnings: [{ id: "ce_1", payoutId: "po_1", status: "READY" }],
// #993 — a PROCESSING payout's earnings were staged READY→BATCHED at batch
// creation; the COMPLETED webhook flips BATCHED→PAID.
earnings: [{ id: "ce_1", payoutId: "po_1", status: "BATCHED" }],
};

let payoutRow: Row;
Expand Down Expand Up @@ -96,7 +98,7 @@ import { handlePayoutWebhook } from "../../lib/payments/payouts/payout-service";

beforeEach(() => {
jest.clearAllMocks();
payoutRow = { ...PAYOUT, earnings: [{ id: "ce_1", payoutId: "po_1", status: "READY" }] };
payoutRow = { ...PAYOUT, earnings: [{ id: "ce_1", payoutId: "po_1", status: "BATCHED" }] };
});

describe("PM-15 — handlePayoutWebhook records TDS + ledger on COMPLETED", () => {
Expand All @@ -119,11 +121,13 @@ describe("PM-15 — handlePayoutWebhook records TDS + ledger on COMPLETED", () =
idempotencyKey: "payout:po_1",
});

// Canonical state landed: COMPLETED + earnings PAID.
// Canonical state landed: COMPLETED + earnings BATCHED→PAID. #993 — the PAID
// flip is CAS-guarded on the BATCHED from-state (staged at batch creation),
// so only genuinely-batched earnings settle at completion.
expect(payoutRow.status).toBe("COMPLETED");
expect(prismaStub.consultantEarnings.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { payoutId: "po_1" },
where: { payoutId: "po_1", status: "BATCHED" },
data: expect.objectContaining({ status: "PAID" }),
}),
);
Expand Down
2 changes: 1 addition & 1 deletion __tests__/stream/__mocks__/stream-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const createMockRoleMapper = () => ({
ADMIN: "admin",
CONSULTANT: "user",
CONSULTEE: "user",
STAFF: "user",
STAFF: "admin",
};
return mapping[role] || "user";
}),
Expand Down
76 changes: 76 additions & 0 deletions __tests__/stream/channel-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,27 @@ jest.mock("../../actions/stream/chat/user.action", () => ({
upsertUsersToStream: jest.fn().mockResolvedValue({ users: {} }),
}));

// #899 — addMemberToChannel is session-gated; mocking auth-server also keeps
// jest away from lib/auth's better-auth ESM imports. Default: privileged.
const mockGetSession = jest.fn();
jest.mock("../../lib/auth-server", () => ({
getSession: () => mockGetSession(),
}));

// auth-helpers imports next/server (NextResponse), which needs the fetch
// globals jest's node env lacks — mirror the real one-liner instead.
jest.mock("../../lib/auth-helpers", () => ({
isPrivileged: (role?: string | null) => role === "ADMIN" || role === "STAFF",
}));

describe("Channel Actions", () => {
beforeEach(() => {
jest.clearAllMocks();
mockStreamClient.channel.mockReturnValue(mockChannel);
mockStreamClient.queryChannels.mockResolvedValue([]);
mockGetSession.mockResolvedValue({
user: { id: "staff-user", role: "ADMIN" },
});
});

describe("createChannel", () => {
Expand Down Expand Up @@ -264,6 +280,63 @@ describe("Channel Actions", () => {
await expect(addMemberToChannel("", "user")).rejects.toThrow();
await expect(addMemberToChannel("channel", "")).rejects.toThrow();
});

// #899 — server-side Stream calls bypass Stream's permission system, so
// the app-layer guard is the only gate.
it("should reject unauthenticated callers", async () => {
mockGetSession.mockResolvedValueOnce(null);

const { addMemberToChannel } =
await import("../../actions/stream/chat/channel.action");

await expect(
addMemberToChannel("consultation-123", "new-user-id"),
).rejects.toThrow("Unauthorized");
expect(mockChannel.addMembers).not.toHaveBeenCalled();
});

it("should reject a non-privileged caller who is not the creator", async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: "random-user", role: "CONSULTEE" },
});
// mockReset flushes unconsumed query Onces leaked from earlier tests
// (clearAllMocks doesn't), which would otherwise shift this value
mockChannel.query.mockReset();
mockChannel.query.mockResolvedValue({
channel: { created_by: { id: "someone-else" } },
});

const { addMemberToChannel } =
await import("../../actions/stream/chat/channel.action");

await expect(
addMemberToChannel("consultation-123", "new-user-id"),
).rejects.toThrow("Forbidden");
expect(mockChannel.addMembers).not.toHaveBeenCalled();
expect(mockChannel.create).not.toHaveBeenCalled();
});

it("should allow the channel creator without lazy channel creation", async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: "creator-user", role: "CONSULTANT" },
});
mockChannel.query.mockReset();
mockChannel.query.mockResolvedValue({
channel: { created_by: { id: "creator-user" } },
});

const { addMemberToChannel } =
await import("../../actions/stream/chat/channel.action");

const result = await addMemberToChannel(
"consultation-123",
"new-user-id",
);

expect(result.success).toBe(true);
expect(mockChannel.addMembers).toHaveBeenCalledWith(["new-user-id"]);
expect(mockChannel.create).not.toHaveBeenCalled();
});
});
});

Expand Down Expand Up @@ -637,6 +710,9 @@ describe("addMemberToChannel error handling", () => {
beforeEach(() => {
jest.clearAllMocks();
mockStreamClient.channel.mockReturnValue(mockChannel);
mockGetSession.mockResolvedValue({
user: { id: "staff-user", role: "ADMIN" },
});
});

it("should throw error when addMembers fails", async () => {
Expand Down
Loading
Loading