TalkingBot/chat-engine/tests/api.test.js

208 lines
7.3 KiB
JavaScript

const http = require('http');
const { buildApp } = require('../src/server');
const { MemorySessionStore } = require('../src/sessions');
const { createEngine } = require('../src/engine');
function fakeEngine() {
const sessions = new Map();
const api = {
sessions,
aborted: false,
startSession: async (input) => {
if (input.bookId === 'draft') {
const error = new Error('Book draft is not ready');
error.statusCode = 409;
throw error;
}
const session = {
sessionId: 'session-1',
bookId: String(input.bookId),
mode: 'HYBRID_AGENTIC',
channel: input.channel,
reason: 'default_hybrid',
bookTitle: 'River',
vocabularyName: null,
openingLine: 'River. Ask me anything about it.',
history: [],
};
sessions.set(session.sessionId, session);
return session;
},
answer: async function* answer(input) {
const session = sessions.get(input.sessionId);
if (input.userText === 'hang') {
yield { type: 'text_delta', text: 'Hello' };
await new Promise((resolve) => {
if (input.signal.aborted) {
api.aborted = true;
return resolve();
}
input.signal.addEventListener('abort', () => {
api.aborted = true;
resolve();
});
});
return;
}
yield { type: 'text_delta', text: 'Hello' };
yield { type: 'final', text: 'Hello', citations: [], usage: {}, interrupted: false };
if (session) {
session.history.push({ role: 'user', text: input.userText });
session.history.push({ role: 'assistant', text: 'Hello', interrupted: false });
}
},
markInterrupted: async (sessionId, spokenText) => {
const session = sessions.get(sessionId);
const turn = session.history.filter((item) => item.role === 'assistant').pop();
turn.text = spokenText;
turn.interrupted = true;
return { ok: true };
},
endSession: async (sessionId) => {
sessions.get(sessionId).endedAt = '2026-01-01T00:00:00.000Z';
return { ok: true, usage: {} };
},
};
return api;
}
function auth(token = 'secret') {
return { authorization: `Bearer ${token}` };
}
describe('internal API', () => {
test('missing token is rejected and a valid token reaches startSession', async () => {
const app = buildApp({ token: 'secret', previousToken: 'old-secret', engine: fakeEngine() });
const denied = await app.inject({
method: 'POST',
url: '/internal/sessions',
payload: { bookId: '3', channel: 'chat' },
});
expect(denied.statusCode).toBe(401);
const health = await app.inject({ method: 'GET', url: '/health' });
expect(health.statusCode).toBe(200);
const ready = await app.inject({ method: 'GET', url: '/ready' });
expect(ready.statusCode).toBe(200);
const created = await app.inject({
method: 'POST',
url: '/internal/sessions',
headers: auth('old-secret'),
payload: { bookId: '3', userId: 7, channel: 'chat' },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ sessionId: 'session-1', bookTitle: 'River', openingLine: expect.stringContaining('River') });
await app.close();
});
test('rejects a book that is not ready', async () => {
const app = buildApp({ token: 'secret', engine: fakeEngine() });
const response = await app.inject({
method: 'POST',
url: '/internal/sessions',
headers: auth(),
payload: { bookId: 'draft', channel: 'chat' },
});
expect(response.statusCode).toBe(409);
expect(response.json().error).toMatch(/not ready/);
await app.close();
});
test('frames SSE, heartbeats are comments, and a second message is rejected', async () => {
const engine = fakeEngine();
const app = buildApp({ token: 'secret', engine });
await app.listen({ host: '127.0.0.1', port: 0 });
const address = app.server.address();
const body = JSON.stringify({ text: 'hang' });
const first = http.request({
host: '127.0.0.1',
port: address.port,
method: 'POST',
path: '/internal/sessions/session-1/messages',
headers: { ...auth(), 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
});
const received = await new Promise((resolve, reject) => {
first.on('error', reject);
first.on('response', (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('text_delta')) resolve({ response, data, first });
});
});
first.end(body);
});
expect(received.response.headers['content-type']).toMatch(/text\/event-stream/);
expect(received.data).toContain('data: {"type":"text_delta","text":"Hello"}');
const conflict = await app.inject({
method: 'POST',
url: '/internal/sessions/session-1/messages',
headers: auth(),
payload: { text: 'again' },
});
expect(conflict.statusCode).toBe(409);
first.destroy();
await new Promise((resolve) => setTimeout(resolve, 80));
expect(engine.aborted).toBe(true);
await app.close();
});
test('replaces the last assistant turn when interrupted', async () => {
const engine = fakeEngine();
await engine.startSession({ bookId: '3', channel: 'voice' });
const events = [];
for await (const event of engine.answer({ sessionId: 'session-1', userText: 'Hi' })) events.push(event);
expect(events.map((event) => event.type)).toEqual(['text_delta', 'final']);
const app = buildApp({ token: 'secret', engine });
const response = await app.inject({
method: 'POST',
url: '/internal/sessions/session-1/interrupted',
headers: auth(),
payload: { spokenText: 'Hel' },
});
expect(response.statusCode).toBe(200);
const turn = engine.sessions.get('session-1').history.find((item) => item.role === 'assistant');
expect(turn).toMatchObject({ text: 'Hel', interrupted: true });
const ended = await app.inject({
method: 'POST',
url: '/internal/sessions/session-1/end',
headers: auth(),
});
expect(ended.json().ok).toBe(true);
await app.close();
});
});
describe('opening line persistence through the engine', () => {
test('markInterrupted keeps only the spoken text', async () => {
const { memoryStorage, chatConfig } = require('./fixture');
const sessions = new MemorySessionStore();
const engine = createEngine({
config: chatConfig(),
storage: memoryStorage('3'),
books: { async get() { return { bookId: 3, tokenCount: 10, status: 'completed', title: 'River' }; } },
sessions,
bedrock: null,
opensearch: null,
});
const session = await sessions.create({
bookId: '3',
mode: 'HYBRID_AGENTIC',
channel: 'voice',
history: [
{ role: 'user', text: 'Hi' },
{ role: 'assistant', text: 'Hello there', interrupted: false },
],
});
await engine.markInterrupted(session.sessionId, 'Hello');
const saved = await sessions.get(session.sessionId);
expect(saved.history[1]).toMatchObject({ text: 'Hello', interrupted: true });
const ended = await engine.endSession(session.sessionId);
expect(ended.ok).toBe(true);
expect((await sessions.get(session.sessionId)).endedAt).toBeTruthy();
});
});