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>> { const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; const events: Array> = []; 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'); }); });