199 lines
6.5 KiB
TypeScript
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');
|
|
});
|
|
});
|