From 2b5974cf4ad0564fc22fb262c5f35d64eca06e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:24:20 +0200 Subject: [PATCH 1/3] fix(coach): enforce the module gate on coach chat and list routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming POST and the conversation-list GET enforced only the legacy operator assistant flag (requireAssistantSurface("coach")), missing the per-user disableCoach opt-out and the operator module-availability layer. Both handlers now run the two-layer requireModuleEnabled(userId, "coach") gate right after auth, returning 403 module.disabled when the account has Coach turned off — even with a valid Bearer token. The legacy assistant-flag check stays in place. --- src/app/api/insights/chat/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 73bf1c728..df06d2c80 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -35,6 +35,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { @@ -151,6 +152,10 @@ function buildHistoryWindow( async function handleChatRequest(request: NextRequest): Promise { const auth = await requireAuth(); + // v1.18.0 — two-layer module gate (operator availability + per-user + // disableCoach) on top of the legacy assistant flag. 403 module.disabled. + const gate = await requireModuleEnabled(auth.user.id, "coach"); + if (!gate.enabled) return gate.response; // v1.4.31 — operator can disable the Coach surface app-wide. // Throws AssistantDisabledError → apiHandler returns 403 + // `errorCode: "assistant.disabled.coach"` per the iOS contract. @@ -687,6 +692,9 @@ export const POST = apiHandler(handleChatRequest); */ export const GET = apiHandler(async (request: NextRequest) => { const auth = await requireAuth(); + // v1.18.0 — same two-layer module gate as the SSE POST. + const gate = await requireModuleEnabled(auth.user.id, "coach"); + if (!gate.enabled) return gate.response; // v1.4.31 — same gate as the SSE POST. Hiding the rail when // the operator has disabled Coach matches the FAB suppression // on the client. From 064ed92071271acc17c1f16419da748e33cb9d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:25:51 +0200 Subject: [PATCH 2/3] fix(coach): enforce the module gate on the about-me routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured self-context endpoints — about-me GET/PUT, the adopt POST, and the clarifying-questions GET/DELETE — read and wrote Coach self-context with no module check. Each handler now runs the two-layer requireModuleEnabled(userId, "coach") gate right after auth, returning 403 module.disabled when the operator availability layer or the per-user disableCoach opt-out has Coach turned off. The adopt route test mocks the gate to stay focused on the locked read-modify-write. --- src/app/api/coach/about-me/adopt/__tests__/route.test.ts | 3 +++ src/app/api/coach/about-me/adopt/route.ts | 4 ++++ src/app/api/coach/about-me/questions/route.ts | 7 +++++++ src/app/api/coach/about-me/route.ts | 7 +++++++ 4 files changed, 21 insertions(+) diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts index f25792089..90db34e4a 100644 --- a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -43,6 +43,9 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextForUser: vi.fn(), })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index 5f8c1d8e5..5ffb8692d 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -41,6 +41,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { ABOUT_ME_FIELD_MAX_CHARS, ABOUT_ME_MAX_CHARS, @@ -93,6 +94,9 @@ function normalise(s: string): string { export const POST = apiHandler(async (req: Request) => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const rl = await checkRateLimit( `coach-about-me:adopt:${user.id}`, diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts index 1c4094d21..58841bd91 100644 --- a/src/app/api/coach/about-me/questions/route.ts +++ b/src/app/api/coach/about-me/questions/route.ts @@ -24,6 +24,7 @@ import { PENDING_QUESTION_MAX_CHARS, setPendingQuestionsForUser, } from "@/lib/ai/coach/about-me"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const dismissSchema = z.object({ /** Exact question text to dismiss. Omitted = dismiss all. */ @@ -32,6 +33,9 @@ const dismissSchema = z.object({ export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const questions = await getPendingQuestionsForUser(user.id); annotate({ action: { name: "coach.about_me.questions.get" }, @@ -42,6 +46,9 @@ export const GET = apiHandler(async () => { export const DELETE = apiHandler(async (req: Request) => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const { data: body, error: jsonError } = await safeJson(req, { maxBytes: 4 * 1024, diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 3c2b18bc2..b594e772d 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -43,6 +43,7 @@ import { setPendingQuestionsForUser, } from "@/lib/ai/coach/about-me"; import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { ABOUT_ME_FIELD_MAX_CHARS, ABOUT_ME_MAX_CHARS, @@ -54,6 +55,9 @@ const PUT_WINDOW_MS = 60_000; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const [ctx, pendingQuestions, row] = await Promise.all([ getSelfContextForUser(user.id), @@ -89,6 +93,9 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (req: Request) => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const rl = await checkRateLimit( `coach-about-me:put:${user.id}`, From 85b0b635f26a5a65f9b09621c90c8ab25c825959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:28:28 +0200 Subject: [PATCH 3/3] test(coach): cover the module gate on the chat and about-me routes Per-route tests assert the two-layer Coach module gate: a disabled module (operator availability OR per-user disableCoach) yields 403 module.disabled before any Coach work runs, and an enabled module falls through to the normal path. Covers the chat POST + list GET, about-me GET/PUT, the questions GET/DELETE, and the adopt POST short-circuit ahead of the locked read-modify-write. --- .../__tests__/route-module-gate.test.ts | 133 +++++++++++++ .../about-me/adopt/__tests__/route.test.ts | 32 +++- .../__tests__/route-module-gate.test.ts | 98 ++++++++++ .../chat/__tests__/route-module-gate.test.ts | 178 ++++++++++++++++++ 4 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 src/app/api/coach/about-me/__tests__/route-module-gate.test.ts create mode 100644 src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts create mode 100644 src/app/api/insights/chat/__tests__/route-module-gate.test.ts diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts new file mode 100644 index 000000000..3a9123095 --- /dev/null +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.18.0 — the about-me self-context GET/PUT enforce the two-layer + * Coach module gate (operator availability AND the per-user + * `disableCoach` opt-out) right after auth. A disabled module returns + * the 403 `module.disabled` envelope before any self-context read or + * write; an enabled module proceeds to the normal 200 path. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), +})); + +const { requireModuleEnabled } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); + +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + getClientIp: () => "127.0.0.1", + returnAllZodIssues: (_e: unknown, status: number) => ({ + data: null, + error: "validation", + status, + }), + safeJson: async (req: Request) => ({ data: await req.json(), error: null }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + userHealthProfile: { + findUnique: vi.fn(async () => ({ updatedAt: new Date(0) })), + upsert: vi.fn(async () => ({ updatedAt: new Date(0) })), + }, + }, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => Buffer.from(s), +})); +const emptyCtx = { + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, +}; +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextForUser: vi.fn(async () => emptyCtx), + getPendingQuestionsForUser: vi.fn(async () => []), + setPendingQuestionsForUser: vi.fn(), +})); +vi.mock("@/lib/ai/coach/self-context-questions", () => ({ + deriveClarifyingQuestions: vi.fn(async () => ({ + questions: [], + source: "none", + })), +})); + +import { GET, PUT } from "../route"; + +type Envelope = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string; module?: string }; +}; +const get = GET as unknown as () => Promise; +const put = PUT as unknown as (req: Request) => Promise; + +const disabledResponse: Envelope = { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, +}; + +function putReq(): Request { + return new Request("http://localhost/api/coach/about-me", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aboutMe: "I run daily" }), + }); +} + +describe("about-me module gate", () => { + beforeEach(() => vi.clearAllMocks()); + + it("GET returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await get(); + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + }); + + it("GET returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await get(); + expect(res.status).toBe(200); + }); + + it("PUT returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await put(putReq()); + expect(res.status).toBe(403); + expect(res.meta?.module).toBe("coach"); + }); + + it("PUT returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await put(putReq()); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts index 90db34e4a..6879dae80 100644 --- a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -49,7 +49,12 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), - apiError: (error: string, status: number) => ({ data: null, error, status }), + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), getClientIp: () => "127.0.0.1", returnAllZodIssues: (_e: unknown, status: number) => ({ data: null, @@ -62,9 +67,11 @@ vi.mock("@/lib/api-response", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const transaction = prisma.$transaction as ReturnType; const getCtx = getSelfContextForUser as ReturnType; +const gate = requireModuleEnabled as ReturnType; function adoptRequest(body: { question?: string; answer: string }): Request { return new Request("http://localhost/api/coach/about-me/adopt", { @@ -90,6 +97,29 @@ describe("POST /api/coach/about-me/adopt", () => { tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" }); tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() }); tx.$queryRaw.mockResolvedValue([{ id: "p1" }]); + gate.mockResolvedValue({ enabled: true }); + }); + + // v1.18.0 — Coach module gate (operator availability OR per-user + // disableCoach) short-circuits before the locked read-modify-write. + it("returns 403 module.disabled when Coach is disabled, without a write", async () => { + gate.mockResolvedValue({ + enabled: false, + response: { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, + }, + }); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "evening walks" }), + ); + + expect(res.status).toBe(403); + expect(gate).toHaveBeenCalledWith("u1", "coach"); + expect(transaction).not.toHaveBeenCalled(); }); it("runs the read-modify-write inside one transaction with a row lock", async () => { diff --git a/src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts new file mode 100644 index 000000000..6bbfb46da --- /dev/null +++ b/src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.18.0 — the clarifying-questions GET/DELETE enforce the two-layer + * Coach module gate (operator availability AND the per-user + * `disableCoach` opt-out) right after auth. A disabled module returns + * the 403 `module.disabled` envelope before any pending-question read or + * mutation; an enabled module proceeds to the normal 200 path. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +const { requireModuleEnabled } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + returnAllZodIssues: (_e: unknown, status: number) => ({ + data: null, + error: "validation", + status, + }), + safeJson: async () => ({ data: {}, error: null }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getPendingQuestionsForUser: vi.fn(async () => ["q1"]), + setPendingQuestionsForUser: vi.fn(), + PENDING_QUESTION_MAX_CHARS: 200, +})); + +import { GET, DELETE } from "../route"; + +type Envelope = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string; module?: string }; +}; +const get = GET as unknown as () => Promise; +const del = DELETE as unknown as (req: Request) => Promise; + +const disabledResponse: Envelope = { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, +}; + +function delReq(): Request { + return new Request("http://localhost/api/coach/about-me/questions", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); +} + +describe("about-me questions module gate", () => { + beforeEach(() => vi.clearAllMocks()); + + it("GET returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await get(); + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + }); + + it("GET returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await get(); + expect(res.status).toBe(200); + }); + + it("DELETE returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await del(delReq()); + expect(res.status).toBe(403); + expect(res.meta?.module).toBe("coach"); + }); + + it("DELETE returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await del(delReq()); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts new file mode 100644 index 000000000..9ab9c6793 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both + * enforce the two-layer module gate (operator availability AND the + * per-user `disableCoach` opt-out) right after auth. A disabled module + * returns the 403 `module.disabled` envelope verbatim before any other + * Coach dependency runs; an enabled module falls through to the existing + * assistant-flag check. The suite pins both branches so a refactor can't + * quietly drop the gate. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); + +// The legacy assistant-flag gate the handler reaches AFTER the module +// gate. We throw a sentinel so an enabled module is observable by the +// control reaching this call. +class AssistantSentinel extends Error {} + +const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(), + requireAssistantSurface: vi.fn(), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); +vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface })); + +// Everything else the module imports — stubbed harmlessly; none of it is +// reached on the disabled branch, and the enabled branch stops at the +// assistant sentinel above. +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/coach/types", () => ({ + coachChatRequestSchema: { safeParse: () => ({ success: false }) }, +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), + fetchConversationWithMessages: vi.fn(), + listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() })); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(), + enforceBudget: vi.fn(), + recordSpend: vi.fn(), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn() })); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() })); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() })); +vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() })); + +import { POST, GET } from "../route"; + +type Envelope = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string; module?: string }; +}; +const post = POST as unknown as (req: Request) => Promise; +const get = GET as unknown as (req: Request) => Promise; + +const disabledResponse: Envelope = { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, +}; + +function chatPostReq(): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); +} + +function listReq(): Request { + return new Request("http://localhost/api/insights/chat", { method: "GET" }); +} + +describe("coach chat module gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + requireAssistantSurface.mockImplementation(async () => { + throw new AssistantSentinel("reached assistant surface"); + }); + }); + + it("POST returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + + const res = await post(chatPostReq()); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(res.meta?.module).toBe("coach"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + // The gate short-circuits before the legacy assistant flag. + expect(requireAssistantSurface).not.toHaveBeenCalled(); + }); + + it("POST falls through to the assistant flag when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + + await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel); + expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + }); + + it("GET returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + + const res = await get(listReq()); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + expect(requireAssistantSurface).not.toHaveBeenCalled(); + }); + + it("GET reaches 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + requireAssistantSurface.mockResolvedValue(undefined); + + const res = await get(listReq()); + + expect(res.status).toBe(200); + expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + }); +});