import assert from 'node:assert/strict'; import test from 'node:test'; import { Hono } from 'hono'; import pool from '../../db.js'; import router from './index.js'; import { questionLog, questionOutcome, businessTypes, finishQuestionLog, redactQuestion, type QuestionCompletion } from './question-log.js'; import { vehicleRepository } from '../vehicles/repository.js'; import { gapStore } from './gaps.js'; import { acquire } from './session.js'; import type { AuthUser } from '../../auth/types.js'; import type { AssistantResult } from '../../../shared/assets/assistant.js'; const user: AuthUser = { userId: 'log-test', userName: '测试用户', loginName: 'test', roles: [], permissionLevel: 'personal', depCode: 'D', depName: '测试部门' }; const result = (extra: Partial = {}): AssistantResult => ({ plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'table', filters: {} }, title: '库存', summary: '库存共 2 台', rows: [], groups: [], metrics: [], source: 'test', fetchedAt: '', ...extra, }); function appFor(u = user) { const app = new Hono<{ Variables: { user: AuthUser } }>(); app.use('*', async (c, next) => { c.set('user', u); await next(); }); app.route('/ai', router); return app; } function ask(app: ReturnType, body: object = { message: '库存有多少车?', subject: null }) { return app.request('/ai/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } test('each question has its own durable ID, full text and attributed preview identity; SQL is parameterized', async t => { const calls: { sql: string; values: unknown[] }[] = []; t.mock.method(pool, 'query', async (options: { sql: string }, values: unknown[]) => { calls.push({ sql: options.sql, values }); return [{ affectedRows: 1 }, []]; }); const preview = { ...user, preview: { actorId: 'real-actor', sessionId: 'preview-session' } }; const message = '按区域统计库存'.repeat(100) + " ' OR 1=1 --"; const first = await questionLog.start(preview, { message, subject: '公司甲' }); const second = await questionLog.start(preview, { message, subject: '公司甲' }); assert.notEqual(first, second); assert.equal(calls[0].values[1], 'real-actor'); assert.equal(calls[0].values[2], user.userId); assert.equal(calls[0].values[6], 1); assert.equal(calls[0].values[12], message); assert.ok(!calls[0].sql.includes(message)); await questionLog.finish(first, { outcome: 'success', sessionId: 'session', result: result() }); assert.match(calls[2].sql, /outcome='processing'/); assert.equal(calls[2].values[4], '["inventory"]'); assert.match(String(calls[2].values[6]), /库存共 2 台/); assert.ok(!String(calls[2].values[6]).includes('rows')); }); test('outcomes and business categories separate partial, help, empty, unsupported and failure', () => { assert.equal(questionOutcome(result()), 'success'); for (const value of ['partial', 'empty', 'unsupported', 'help', 'clarification'] as const) { const r = result({ outcome: value }); if (value === 'clarification') r.plan.kind = 'clarify'; assert.equal(questionOutcome(r), value); } assert.equal(questionOutcome(result(), 'generation'), 'failed'); assert.deepEqual(businessTypes(result({ sections: [ { id: 'a', title: '库存', question: '库存', display: 'table', result: result() }, { id: 'b', title: '交车', question: '交车', display: 'table', result: result({ plan: { ...result().plan, kind: 'flow' } }) }, ] })), ['inventory', 'flow']); assert.equal(redactQuestion('库存 sk-12345678 password=secret123 Bearer token123'), '库存 [凭据已隐藏] password=[凭据已隐藏] Bearer [凭据已隐藏]'); }); test('storage outage rejects the request before querying data or invoking AI', async t => { t.mock.method(questionLog, 'start', async () => { throw new Error('private DB error'); }); const query = t.mock.method(vehicleRepository, 'getVehicles', async () => []); const response = await ask(appFor()); assert.equal(response.status, 503); assert.equal(query.mock.callCount(), 0); assert.ok(!(await response.text()).includes('private DB')); }); test('concurrency rejection and expired session are still durable classified questions', async t => { const start = t.mock.method(questionLog, 'start', async () => 'id'); const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {}); const release = acquire(user.userId)!; try { assert.equal((await ask(appFor())).status, 429); } finally { release(); } assert.equal((await ask(appFor(), { message: '追问', subject: null, sessionId: 'missing' })).status, 409); assert.equal(start.mock.callCount(), 2); assert.equal(finish.mock.calls[0].arguments[1].reason, 'busy'); assert.equal(finish.mock.calls[1].arguments[1].reason, 'session_expired'); }); test('failed queries are logged for preview users without creating gap feedback', async t => { const start = t.mock.method(questionLog, 'start', async () => 'id'); const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {}); const gap = t.mock.method(gapStore, 'record', async () => {}); t.mock.method(vehicleRepository, 'getVehicles', async () => { throw new Error('private failure'); }); const response = await ask(appFor({ ...user, preview: { actorId: 'actor', sessionId: 'preview' } })); const text = await response.text(); assert.match(text, /查询未完成/); assert.ok(!text.includes('private failure')); assert.equal(start.mock.callCount(), 1); assert.equal(finish.mock.calls[0].arguments[1].outcome, 'failed'); assert.equal(gap.mock.callCount(), 0); }); test('finalization retries transient failures and reports persistent failures', async t => { let calls = 0, alwaysFail = false; t.mock.method(questionLog, 'finish', async () => { if (++calls === 1 || alwaysFail) throw new Error('transient'); }); assert.equal(await finishQuestionLog('id', { outcome: 'success' }), true); assert.equal(calls, 2); alwaysFail = true; const warning = t.mock.method(console, 'error', () => {}); assert.equal(await finishQuestionLog('id', { outcome: 'failed' }), false); assert.deepEqual(warning.mock.calls[0].arguments, ['asset_ai_question_completion_failed', 'id']); }); test('successful questions, follow-ups, actions and feedback keep distinct operation types', async t => { const statements: unknown[][] = []; t.mock.method(pool, 'query', async (options: { sql?: string } | string, values: unknown[]) => { if (typeof options !== 'string' && options.sql?.includes('bi_asset_ai_questions')) statements.push(values); return [[], []]; }); t.mock.method(vehicleRepository, 'getVehicles', async () => []); t.mock.method(gapStore, 'record', async () => {}); const old = process.env.ASSET_AI_EXTERNAL_ENABLED; process.env.ASSET_AI_EXTERNAL_ENABLED = '0'; t.after(() => { if (old === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = old; }); const app = appFor({ ...user, userId: 'success-test' }); const response = await ask(app); const events = (await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5))); const r = events.find(event => event.type === 'result')?.result as AssistantResult; assert.ok(r, JSON.stringify(events)); assert.equal(statements[0][11], 'question'); assert.ok(['success', 'empty'].includes(String(statements[1][0]))); assert.equal(statements[1][2], r.sessionId); assert.equal(statements[1][3], r.resultId); assert.deepEqual(JSON.parse(String(statements[1][4])), ['inventory']); await questionLog.start(user, { message: '按区域看', subject: null, sessionId: r.sessionId }); assert.equal(statements.at(-1)![11], 'followup'); await questionLog.start(user, { subject: null, sessionId: r.sessionId, baseResultId: r.resultId, action: { type: 'category', category: 'abnormal' } }); assert.equal(statements.at(-1)![11], 'action'); assert.deepEqual(JSON.parse(String(statements.at(-1)![13])), { type: 'category', category: 'abnormal' }); const feedback = await app.request('/ai/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: r.sessionId, resultId: r.resultId, subject: null }) }); assert.equal(feedback.status, 200); assert.equal(statements.at(-2)![11], 'feedback'); assert.equal(statements.at(-1)![0], 'feedback'); }); test('client cancellation finalizes the durable question and releases the user query lock', async t => { t.mock.method(questionLog, 'start', async () => 'cancel-id'); let complete!: (value: QuestionCompletion) => void; const completed = new Promise(resolve => { complete = resolve; }); t.mock.method(questionLog, 'finish', async (_id: string, completion: QuestionCompletion) => { complete(completion); }); let continueQuery!: () => void; t.mock.method(vehicleRepository, 'getVehicles', async () => { await new Promise(resolve => { continueQuery = resolve; }); return []; }); const cancelledUser = { ...user, userId: 'cancel-test' }; const response = await ask(appFor(cancelledUser)); const reader = response.body!.getReader(); await reader.read(); await reader.cancel(); continueQuery(); const completion = await completed; assert.equal(completion.outcome, 'cancelled'); // Finalizer releases the lock after the asynchronous database update. await new Promise(resolve => setImmediate(resolve)); const release = acquire(cancelledUser.userId); assert.ok(release); release(); });