import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChatRuntimeMode } from '../chat/chat-runtime.js'; import { ConversationsController } from './conversations.controller.js'; /** * Task 5 harness fence for the conversations REST write path. * * Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the * legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a * fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write. * Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`. * * Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution, * injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from * `process.env` at request time. The two "env is flipped after construction" tests below are the * load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn * RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`. */ const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; const USER = { id: 'user-1' }; function sendMessageDto() { return { role: 'user' as const, content: 'hello from the legacy REST write path', metadata: undefined, }; } function brainWithMessageSpy() { const addMessage = vi.fn().mockResolvedValue({ id: 'message-1', conversationId: CONVERSATION_ID, role: 'user', content: 'hello from the legacy REST write path', }); return { brain: { conversations: { addMessage } } as never, addMessage, }; } /** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */ function routerFixedTo(mode: ChatRuntimeMode) { return { runtimeMode: mode }; } let priorMode: string | undefined; describe('conversations REST write path — Task 5 harness fence', () => { beforeEach(() => { priorMode = process.env['CHAT_HARNESS_RUNTIME']; }); afterEach(() => { if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME']; else process.env['CHAT_HARNESS_RUNTIME'] = priorMode; }); it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => { const { brain, addMessage } = brainWithMessageSpy(); const controller = new ConversationsController(brain, routerFixedTo('pi-rpc')); await expect( controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER), ).rejects.toMatchObject({ code: 'runtime_unsupported' }); // Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be // written, so no duplicate message can be produced. expect(addMessage).not.toHaveBeenCalled(); }); it('writes through the repository when the router resolved legacy (GREEN control)', async () => { const { brain, addMessage } = brainWithMessageSpy(); const controller = new ConversationsController(brain, routerFixedTo('legacy')); const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER); expect(addMessage).toHaveBeenCalledWith( { conversationId: CONVERSATION_ID, role: 'user', content: 'hello from the legacy REST write path', metadata: undefined, }, USER.id, ); expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID }); }); it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => { // The runtime mode is fixed at module init. A later env mutation must not reopen the fence: // a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write. process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const { brain, addMessage } = brainWithMessageSpy(); const controller = new ConversationsController(brain, routerFixedTo('pi-rpc')); await expect( controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER), ).rejects.toMatchObject({ code: 'runtime_unsupported' }); expect(addMessage).not.toHaveBeenCalled(); }); it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => { // Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a // request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write. process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; const { brain, addMessage } = brainWithMessageSpy(); const controller = new ConversationsController(brain, routerFixedTo('legacy')); await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER); expect(addMessage).toHaveBeenCalledTimes(1); }); });