refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
This commit was merged in pull request #1172.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user