161 lines
9.5 KiB
TypeScript
161 lines
9.5 KiB
TypeScript
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> = {}): 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<typeof appFor>, 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<QuestionCompletion>(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<void>(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();
|
|
});
|