Files
openmaic/OpenMAIC/tests/qa/qa-route.test.ts
2026-08-16 14:58:47 +08:00

199 lines
6.5 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import type { NextRequest } from 'next/server';
const mocks = vi.hoisted(() => ({
runQaAgent: vi.fn(),
loadKnowledge: vi.fn(),
retrieveChunks: vi.fn(),
resolveModel: vi.fn(),
resolveSearchConfig: vi.fn(),
}));
vi.mock('@/lib/qa/agent', () => ({
runQaAgent: (...args: unknown[]) => mocks.runQaAgent(...args),
}));
vi.mock('@/lib/qa/knowledge', () => ({
loadCoursewareKnowledge: (...args: unknown[]) => mocks.loadKnowledge(...args),
retrieveChunks: (...args: unknown[]) => mocks.retrieveChunks(...args),
}));
vi.mock('@/lib/server/resolve-model', () => ({
resolveModel: (...args: unknown[]) => mocks.resolveModel(...args),
}));
vi.mock('@/lib/server/web-search-config', () => ({
resolveClassroomWebSearchConfig: (...args: unknown[]) => mocks.resolveSearchConfig(...args),
}));
let recordsDir: string;
beforeAll(async () => {
recordsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qa-records-'));
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(recordsDir, { recursive: true, force: true });
await fs.mkdir(recordsDir, { recursive: true });
});
async function postQa(body: unknown) {
const { POST } = await import('@/app/api/qa/route');
const request = new Request('http://localhost/api/qa', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '198.51.100.1' },
body: JSON.stringify(body),
});
return POST(request as unknown as NextRequest);
}
/** Collect `data: {...}` SSE frames from a streaming response. */
async function readSse(response: Response): Promise<Array<Record<string, unknown>>> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const events: Array<Record<string, unknown>> = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (line) events.push(JSON.parse(line.slice(5).trim()));
}
}
return events;
}
async function seedRecord(coursewareId: string, version = 1) {
const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store');
const repo = createFileCoursewareRepo(recordsDir);
await repo.saveRecord({
coursewareId,
version,
title: '示例课件',
language: 'zh-CN',
status: 'published',
publishedAt: '2026-08-15T00:00:00.000Z',
contentHash: 'ab'.repeat(32),
byteSize: 100,
entryCount: 5,
sceneCount: 3,
quizSceneCount: 1,
knowledgeVersion: 1,
complete: true,
bundleUrl: `http://localhost/api/coursewares/${coursewareId}/bundles/${version}/download`,
storageKey: `${coursewareId}/v${version}.zip`,
});
}
describe('POST /api/qa', () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('COURSEWARE_DATA_DIR', recordsDir);
vi.stubEnv('QA_RATE_LIMIT_PER_MIN', '1000');
mocks.runQaAgent.mockReset();
mocks.loadKnowledge.mockReset();
mocks.retrieveChunks.mockReset().mockReturnValue([]);
mocks.resolveModel.mockReset().mockResolvedValue({
model: {},
modelString: 'test/model',
providerId: 'test',
modelId: 'model',
apiKey: 'k',
thinkingConfig: undefined,
});
mocks.resolveSearchConfig.mockReset().mockReturnValue(undefined);
});
test('rejects missing fields and unknown coursewares', async () => {
const noId = await postQa({ messages: [{ role: 'user', content: 'hi' }] });
expect(noId.status).toBe(400);
const unknown = await postQa({
coursewareId: 'does-not-exist',
messages: [{ role: 'user', content: 'hi' }],
});
expect(unknown.status).toBe(404);
});
test('streams agent events as SSE with knowledge context', async () => {
await seedRecord('cw-qa-1');
mocks.loadKnowledge.mockResolvedValue({
coursewareId: 'cw-qa-1',
version: 1,
language: 'zh-CN',
title: '示例课件',
knowledge: { coursewareId: 'cw-qa-1', scenes: [] },
quiz: { scenes: [] },
});
mocks.runQaAgent.mockImplementation(async function* () {
yield { type: 'text', delta: '你好,' };
yield { type: 'text', delta: '光合作用发生在叶绿体。' };
yield { type: 'done', sources: [], toolCalls: 0 };
});
const response = await postQa({
coursewareId: 'cw-qa-1',
messages: [{ role: 'user', content: '光合作用发生在哪里?' }],
userProfile: '初二学生',
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/event-stream');
const events = await readSse(response);
const text = events
.filter((e) => e.type === 'text')
.map((e) => e.delta)
.join('');
expect(text).toBe('你好,光合作用发生在叶绿体。');
expect(events.some((e) => e.type === 'done')).toBe(true);
expect(events.at(-1)).toEqual({ type: 'streamEnd' });
// The agent received the courseware knowledge + retrieved chunks.
const agentArgs = mocks.runQaAgent.mock.calls[0][0] as {
courseware: { coursewareId: string };
userProfile: string;
};
expect(agentArgs.courseware.coursewareId).toBe('cw-qa-1');
expect(agentArgs.userProfile).toBe('初二学生');
});
test('409 when the published version has no knowledge pack', async () => {
await seedRecord('cw-qa-2');
mocks.loadKnowledge.mockResolvedValue(null);
const response = await postQa({
coursewareId: 'cw-qa-2',
messages: [{ role: 'user', content: 'hi' }],
});
expect(response.status).toBe(409);
});
test('surfaces agent errors as SSE error events', async () => {
await seedRecord('cw-qa-3');
mocks.loadKnowledge.mockResolvedValue({
coursewareId: 'cw-qa-3',
version: 1,
title: '示例课件',
knowledge: { coursewareId: 'cw-qa-3', scenes: [] },
quiz: { scenes: [] },
});
mocks.runQaAgent.mockImplementation(async function* () {
throw new Error('model unavailable');
});
const response = await postQa({
coursewareId: 'cw-qa-3',
messages: [{ role: 'user', content: 'hi' }],
});
const events = await readSse(response);
const errorEvent = events.find((e) => e.type === 'error');
expect(errorEvent?.error).toContain('model unavailable');
});
});