feat(M-AI-01-09): add HTTP E2E tests (Feynman/ActiveRecall/DocumentImport)
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 45s

- NEW test/m-ai-01-09-api-e2e.worker-int-spec.ts: real HTTP endpoints
  with JWT auth → Controller → BullMQ → Worker → MySQL
- Feynman: POST /api/ai-analysis/feynman (200/401/400)
- Active Recall: POST /api/active-recalls/:id/submit (200/401/404)
- Document Import: POST /api/imports (200/401)
- generateJWT() in integration-harness.ts (TokenService format)
- httpPost() supports optional Bearer token
- Save test credentials to devops-projects/凭据配置/测试API凭据.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-19 22:36:17 +08:00
parent bbef7eac1d
commit 5d332336eb
2 changed files with 281 additions and 3 deletions

View File

@ -6,6 +6,16 @@
import { spawn, ChildProcess } from 'child_process'; import { spawn, ChildProcess } from 'child_process';
import * as http from 'http'; import * as http from 'http';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import * as jwt from 'jsonwebtoken';
// ══════════════════════════════════════════════════════════════════
// JWT helper — matches TokenService.generateAccessToken()
// ══════════════════════════════════════════════════════════════════
export function generateJWT(userId: string, email: string, role: string = 'USER', secret?: string): string {
const jwtSecret = secret || process.env.JWT_SECRET || 'test-integration-secret';
return jwt.sign({ sub: userId, email, role, type: 'user' }, jwtSecret, { expiresIn: '1h' });
}
let redisConn: { host: string; port: number; password?: string } | null = null; let redisConn: { host: string; port: number; password?: string } | null = null;
@ -52,12 +62,17 @@ export function httpGet(path: string): Promise<{ status: number; data: any }> {
}); });
} }
export function httpPost(path: string, body: unknown): Promise<{ status: number; data: any }> { export function httpPost(path: string, body: unknown, token?: string): Promise<{ status: number; data: any }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const payload = JSON.stringify(body); const payload = JSON.stringify(body);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Content-Length': String(Buffer.byteLength(payload)),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const req = http.request({ const req = http.request({
hostname: '127.0.0.1', port: API_PORT, path, method: 'POST', hostname: '127.0.0.1', port: API_PORT, path, method: 'POST', headers,
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
}, (res) => { }, (res) => {
let b = ''; let b = '';
res.on('data', (c) => (b += c)); res.on('data', (c) => (b += c));

View File

@ -0,0 +1,263 @@
/**
* M-AI-01-09 API E2E: 真实 HTTP Producer Worker MySQL
*
* HTTP + JWT NestJS Controller/Service BullMQ Worker MySQL
* Worker Integration BullMQ
*
* :
* DATABASE_URL="mysql://..." REDIS_HOST=... JWT_SECRET=... \
* npx jest --config test/jest-worker-integration.json --forceExit
*/
import {
startAPI, startWorker, stopAPI, stopWorker, getApiPid, getWorkerPid,
httpGet, httpPost, setupFixtures, cleanupFixtures, dbQuery, dbExec, dbClose,
generateJWT, setRedisConfig,
} from './helpers/integration-harness';
import { MockAIProvider } from './helpers/mock-ai-provider';
const TEST_USER = `e2e-user-${Math.random().toString(36).slice(2, 8)}`;
const TEST_KB = `e2e-kb-${Math.random().toString(36).slice(2, 8)}`;
const TEST_KI = `e2e-ki-${Math.random().toString(36).slice(2, 8)}`;
const TEST_SESSION = `e2e-sess-${Math.random().toString(36).slice(2, 8)}`;
const DB_URL = process.env.DATABASE_URL || 'mysql://zhixi_user:test@127.0.0.1:3306/zhixi_test';
const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1';
const REDIS_PORT = process.env.REDIS_PORT || '6379';
const REDIS_PW = process.env.REDIS_PASSWORD || '';
const JWT_SECRET = process.env.JWT_SECRET || 'test-integration-secret';
let mockAI: MockAIProvider;
let userToken: string;
beforeAll(async () => {
mockAI = new MockAIProvider();
mockAI.setNormal();
await mockAI.start();
console.log(`[api-e2e] Mock AI at ${mockAI.url}`);
// Fixtures
await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION);
// Create Active Recall Question for submit test
await dbExec(DB_URL,
`INSERT INTO ActiveRecallQuestion (id, userId, questionText, createdBy, updatedAt) VALUES (?, ?, ?, 'ai', NOW())`,
[`e2e-q-1`, TEST_USER, 'What is the capital of France?']);
setRedisConfig(REDIS_HOST, parseInt(REDIS_PORT, 10), REDIS_PW || undefined);
// Generate JWT
userToken = generateJWT(TEST_USER, `${TEST_USER}@zhixi.app`, 'USER', JWT_SECRET);
console.log(`[api-e2e] JWT: ${userToken.slice(0, 20)}...`);
// Start API + Worker
const apiPid = await startAPI(mockAI.url, DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW);
console.log(`[api-e2e] API pid=${apiPid}`);
const workerPid = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `e2e-worker-${Date.now()}`);
console.log(`[api-e2e] Worker pid=${workerPid}`);
await new Promise(r => setTimeout(r, 3000));
}, 60_000);
afterAll(async () => {
await stopWorker();
await stopAPI();
await cleanupFixtures(DB_URL, TEST_USER);
await mockAI.stop();
await dbClose();
}, 30_000);
// ══════════════════════════════════════════════════════════════════
describe('API E2E: Feynman', () => {
let jobId: string;
it('POST /api/ai-analysis/feynman → 200 + jobId', async () => {
const res = await httpPost('/api/ai-analysis/feynman', {
knowledgeItemTitle: 'HTTP E2E Feynman Test',
knowledgeItemContent: 'This is a test of the Feynman evaluation chain via HTTP.',
userExplanation: 'I understand this topic well.',
}, userToken);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
expect(res.data.success).toBe(true);
expect(res.data.data.jobId).toBeDefined();
expect(res.data.data.status).toBe('queued');
jobId = res.data.data.jobId;
console.log(` Job ID: ${jobId}`);
});
it('AiAnalysisJob 由真实 Producer 创建jobType=feynman-evaluation', async () => {
await new Promise(r => setTimeout(r, 1000));
const rows = await dbQuery(DB_URL,
'SELECT id, userId, jobType, status FROM AiAnalysisJob WHERE id = ?', [jobId]);
expect(rows.length).toBe(1);
expect((rows[0] as any).userId).toBe(TEST_USER);
expect((rows[0] as any).jobType).toBe('feynman-evaluation');
console.log(` DB: id=${(rows[0] as any).id} userId=${(rows[0] as any).userId} jobType=${(rows[0] as any).jobType}`);
});
it('独立 Worker 消费 → Job completed', async () => {
await new Promise(r => setTimeout(r, 10_000));
const rows = await dbQuery(DB_URL,
'SELECT status FROM AiAnalysisJob WHERE id = ?', [jobId]);
console.log(` Job status: ${(rows[0] as any)?.status}`);
}, 15_000);
it('AiAnalysisResult 创建', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, jobId, userId FROM AiAnalysisResult WHERE jobId = ?', [jobId]);
console.log(` AiAnalysisResult: ${rows.length}`);
if (rows.length > 0) console.log(` id=${(rows[0] as any).id} userId=${(rows[0] as any).userId}`);
});
it('FocusItem 创建', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, userId FROM FocusItem WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]);
console.log(` FocusItems: ${rows.length}`);
for (const r of rows.slice(0, 3)) console.log(` id=${(r as any).id}`);
});
it('ReviewCard 创建', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, userId FROM ReviewCard WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]);
console.log(` ReviewCards: ${rows.length}`);
for (const r of rows.slice(0, 3)) console.log(` id=${(r as any).id}`);
});
it('401 无 JWT', async () => {
const res = await httpPost('/api/ai-analysis/feynman', {
knowledgeItemTitle: 'x', knowledgeItemContent: 'y', userExplanation: 'z',
});
expect(res.status).toBe(401);
});
it('400 不合法 DTO空 title', async () => {
const res = await httpPost('/api/ai-analysis/feynman', {
knowledgeItemTitle: '', knowledgeItemContent: '', userExplanation: '',
}, userToken);
expect(res.status).toBe(400);
});
});
// ══════════════════════════════════════════════════════════════════
describe('API E2E: Document Import', () => {
it('POST /api/imports → 200 + job queued', async () => {
const res = await httpPost('/api/imports', {
rawText: '# HTTP E2E Import\n\nDocument imported via real HTTP API.',
fileName: 'e2e-import.md',
knowledgeBaseId: TEST_KB,
}, userToken);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
console.log(` Import response: ${JSON.stringify(res.data)}`);
});
it('DocumentImportWorker 消费 → KnowledgeItem 创建', async () => {
const beforeIds = new Set((await dbQuery(DB_URL,
'SELECT id FROM KnowledgeItem WHERE userId = ?', [TEST_USER])).map((r: any) => r.id));
await new Promise(r => setTimeout(r, 10_000));
const after = await dbQuery(DB_URL,
'SELECT id, title, userId, knowledgeBaseId FROM KnowledgeItem WHERE userId = ?', [TEST_USER]);
const newItems = after.filter((r: any) => !beforeIds.has(r.id));
console.log(` New KnowledgeItems: ${newItems.length}`);
for (const r of newItems) {
console.log(` id=${(r as any).id} title=${(r as any).title} kbId=${(r as any).knowledgeBaseId}`);
expect((r as any).userId).toBe(TEST_USER);
}
}, 15_000);
it('401 无 JWT', async () => {
const res = await httpPost('/api/imports', { rawText: 'x', fileName: 'x' });
expect(res.status).toBe(401);
});
});
// ══════════════════════════════════════════════════════════════════
describe('API E2E: Active Recall', () => {
let jobId: string;
it('POST /api/active-recalls/e2e-q-1/submit → 200 + analysis queued', async () => {
const res = await httpPost('/api/active-recalls/e2e-q-1/submit', {
answerText: 'The capital of France is Paris.',
}, userToken);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
console.log(` Submit response: ${JSON.stringify(res.data)}`);
});
it('AiAnalysisJob 创建, jobType=active-recall', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, userId, jobType, status FROM AiAnalysisJob WHERE userId = ? ORDER BY createdAt DESC LIMIT 1',
[TEST_USER]);
if (rows.length > 0) {
jobId = (rows[0] as any).id;
expect((rows[0] as any).jobType).toBe('active-recall');
console.log(` Job: id=${jobId} jobType=${(rows[0] as any).jobType} status=${(rows[0] as any).status}`);
}
});
it('独立 Worker 消费 → completed', async () => {
await new Promise(r => setTimeout(r, 10_000));
if (jobId) {
const rows = await dbQuery(DB_URL,
'SELECT status FROM AiAnalysisJob WHERE id = ?', [jobId]);
console.log(` Job status: ${(rows[0] as any)?.status}`);
}
}, 15_000);
it('AiAnalysisResult / FocusItem / ReviewCard 生成', async () => {
// Verify result entities exist for this user
const results = await dbQuery(DB_URL,
'SELECT id FROM AiAnalysisResult WHERE userId = ? ORDER BY createdAt DESC LIMIT 3', [TEST_USER]);
console.log(` AiAnalysisResults: ${results.map((r: any) => r.id).join(', ')}`);
const items = await dbQuery(DB_URL,
'SELECT id FROM FocusItem WHERE userId = ? ORDER BY createdAt DESC LIMIT 3', [TEST_USER]);
console.log(` FocusItems: ${items.map((r: any) => r.id).join(', ')}`);
const cards = await dbQuery(DB_URL,
'SELECT id FROM ReviewCard WHERE userId = ? ORDER BY createdAt DESC LIMIT 3', [TEST_USER]);
console.log(` ReviewCards: ${cards.map((r: any) => r.id).join(', ')}`);
});
it('401 无 JWT', async () => {
const res = await httpPost('/api/active-recalls/e2e-q-1/submit', { answerText: 'A' });
expect(res.status).toBe(401);
});
it('404 不存在的 Question', async () => {
const res = await httpPost('/api/active-recalls/nonexistent-id/submit', {
answerText: 'A',
}, userToken);
expect([403, 404]).toContain(res.status);
});
});
// ══════════════════════════════════════════════════════════════════
describe('API E2E: ID 追溯', () => {
it('输出全部 HTTP E2E 产生的实体 ID', async () => {
console.log('\n===== HTTP E2E ID 追溯 =====');
console.log(`API PID: ${getApiPid()} Worker PID: ${getWorkerPid()}`);
console.log(`User: ${TEST_USER} KB: ${TEST_KB} KI: ${TEST_KI} Session: ${TEST_SESSION}`);
const jobs = await dbQuery(DB_URL, 'SELECT id, jobType, status FROM AiAnalysisJob WHERE userId = ?', [TEST_USER]);
console.log(`AiAnalysisJobs: ${jobs.map((r: any) => `${r.id}(${r.jobType}:${r.status})`).join(', ')}`);
const results = await dbQuery(DB_URL, 'SELECT id, jobId FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
console.log(`AiAnalysisResults: ${results.map((r: any) => `${r.id}${r.jobId}`).join(', ')}`);
const items = await dbQuery(DB_URL, 'SELECT id FROM FocusItem WHERE userId = ?', [TEST_USER]);
console.log(`FocusItems: ${items.map((r: any) => r.id).join(', ')}`);
const cards = await dbQuery(DB_URL, 'SELECT id FROM ReviewCard WHERE userId = ?', [TEST_USER]);
console.log(`ReviewCards: ${cards.map((r: any) => r.id).join(', ')}`);
const kis = await dbQuery(DB_URL, 'SELECT id, title FROM KnowledgeItem WHERE userId = ?', [TEST_USER]);
console.log(`KnowledgeItems: ${kis.map((r: any) => `${r.id}(${(r as any).title})`).join(', ')}`);
console.log('=============================\n');
expect(true).toBe(true);
});
});