273 lines
18 KiB
TypeScript
273 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
import type pg from 'pg';
|
|
import { ArrangementLedger, ARRANGEMENT_LABELS, arrangementEventFromSuccess, arrangementGroupNumber,
|
|
arrangementSummary, currentArrangements, type ArrangementAction, type ArrangementEvent } from '../src/arrangement-ledger.js';
|
|
import { loadConfig } from '../src/config.js';
|
|
import { decryptText, encryptText } from '../src/crypto.js';
|
|
import { closePool, getPool } from '../src/db.js';
|
|
import { TaskService, successReceiptFromResult } from '../src/task-service.js';
|
|
import { progressMemory } from './support/team-progress-memory.js';
|
|
import { buildCustomerSuccessReceipt, replyTextFromReceipt } from '../src/reply-contract.js';
|
|
|
|
const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 29).toString('base64'),
|
|
DATABASE_URL: 'postgresql://fixture:fixture@127.0.0.1:1/fixture' });
|
|
type Json = Record<string, any>;
|
|
const scope = { organizationId: 'org-a', userId: 'user-a' };
|
|
|
|
function fixture(action: ArrangementAction = 'arrangement_hotel', mode: 'create' | 'update' | 'clear' = 'create',
|
|
rowId = '100', executionId = 'execution-1', time = '2026-09-16T01:00:00.000Z') {
|
|
const operation: Json = { action, data: { existing_refs: { identifier: 'LW-260901VIP-A' }, arrangement: {
|
|
mode, resource: { name: '搜索词TWN' }, start_date: '2026-09-04', end_date: '2026-09-05', room_count: 12,
|
|
target: { row_id: rowId, slot_index: 4 }
|
|
} } };
|
|
const result: Json = { status: 'completed', execution_id: executionId, write_attempted: true, no_erp_write: false,
|
|
report: { status: 'lifecycle_completed', manual_review_required: false, blockers: [],
|
|
resolved_refs: { kind: 'independent_order', identifier: 'LW-260901VIP-A', tid: '900' },
|
|
server_response: { completed: true }, requery: { matched: true, subpage: { matched: true,
|
|
arrangement_target: { row_id: rowId, slot_index: 0, resource_id: 'hotel-1', item_value: 'TWN H', quantity_value: '12', cleared: mode === 'clear' },
|
|
checks: [
|
|
{ field: 'danwei0', actual: '实际酒店', expected: '实际酒店', matched: true },
|
|
{ field: 'riqi0', actual: '2026-9-4', matched: true },
|
|
{ field: 'riqis0', actual: '2026-9-5', matched: true },
|
|
{ field: 'beizhu0', actual: '原生备注', matched: true }
|
|
]
|
|
} }
|
|
} };
|
|
return { taskId: `TASK-${executionId}`, executionId, effectiveAt: time, recordedAt: time,
|
|
operation, result, receipt: successReceiptFromResult(result) };
|
|
}
|
|
function event(...args: Parameters<typeof fixture>): ArrangementEvent {
|
|
const value = arrangementEventFromSuccess(fixture(...args));
|
|
assert.ok(value);
|
|
return value;
|
|
}
|
|
|
|
test('successful arrangements capture actual linked fields and retain separate original input/evidence', () => {
|
|
const item = event();
|
|
assert.equal(item.details.resource_name, '实际酒店');
|
|
assert.equal((item.requested.resource as Json).name, '搜索词TWN');
|
|
assert.equal(item.details.start_date, '2026-09-04');
|
|
assert.equal(item.details.end_date, '2026-09-05');
|
|
assert.equal(item.details.item, 'TWN H');
|
|
assert.equal(item.details.quantity, 12);
|
|
assert.equal(item.details.remark, '原生备注');
|
|
assert.equal(item.record_key, 'row:100');
|
|
assert.equal(item.evidence.requery_matched, true);
|
|
const dated = arrangementEventFromSuccess({ ...fixture(), effectiveAt: new Date('2026-09-16T01:00:00.123Z') });
|
|
assert.equal(dated?.effective_at, '2026-09-16T01:00:00.123Z', 'pg Date values retain sub-second ordering');
|
|
});
|
|
|
|
test('parser, failed, queued, dry-run, uncertain and incomplete proof never change arrangement state', () => {
|
|
for (const status of ['confirmed', 'queued', 'running', 'blocked', 'failed', 'dry_run', 'reconciliation_pending']) {
|
|
const input = fixture(); input.result.status = status;
|
|
assert.equal(arrangementEventFromSuccess(input), null, status);
|
|
}
|
|
for (const patch of [
|
|
(i: ReturnType<typeof fixture>) => { i.result.report.server_response.completed = false; },
|
|
(i: ReturnType<typeof fixture>) => { i.result.report.requery.matched = false; },
|
|
(i: ReturnType<typeof fixture>) => { i.result.report.requery.skipped = true; },
|
|
(i: ReturnType<typeof fixture>) => { i.result.report.blockers = ['blocked']; },
|
|
(i: ReturnType<typeof fixture>) => { i.result.manual_review_required = true; },
|
|
(i: ReturnType<typeof fixture>) => { i.result.uncertain = true; },
|
|
(i: ReturnType<typeof fixture>) => { i.receipt = null; }
|
|
]) { const input = fixture(); patch(input); assert.equal(arrangementEventFromSuccess(input), null); }
|
|
});
|
|
|
|
test('full group identity is normalized without collapsing suffixes or accepting a different ERP group', () => {
|
|
assert.equal(arrangementGroupNumber(' lw-260901vip-a '), 'LW-260901VIP-A');
|
|
const input = fixture(); input.result.report.resolved_refs.identifier = 'LW-260901VIP-C';
|
|
assert.equal(arrangementEventFromSuccess(input), null);
|
|
const summary = arrangementSummary([event()], 'LW-260901VIP-C', 'now');
|
|
assert.ok(summary.items.every(item => !item.arranged));
|
|
});
|
|
|
|
test('five arrangement types have independent status and guides remain singleton after replacement', () => {
|
|
const items = (Object.keys(ARRANGEMENT_LABELS) as ArrangementAction[]).map((action, i) => event(action, 'create', '100', `e-${i}`));
|
|
items.push(event('arrangement_guide', 'update', 'different', 'e-guide-update', '2026-09-16T02:00:00Z'));
|
|
assert.equal(currentArrangements(items).length, 5);
|
|
assert.ok(arrangementSummary(items, items[0].group_number, 'now').items.every(item => item.arranged && item.count === 1));
|
|
});
|
|
|
|
test('update and clear target stable row IDs, preserving other rows regardless of slot renumbering', () => {
|
|
const items = [event(), event('arrangement_hotel', 'create', '200', 'e-2')];
|
|
const update = event('arrangement_hotel', 'update', '100', 'e-3', '2026-09-16T02:00:00Z');
|
|
update.details.quantity = 8;
|
|
items.push(update, update);
|
|
assert.equal(currentArrangements(items).length, 2);
|
|
assert.equal(currentArrangements(items).find(item => item.record_key === 'row:100')?.details.quantity, 8);
|
|
items.push(event('arrangement_hotel', 'clear', '100', 'e-4', '2026-09-16T03:00:00Z'));
|
|
assert.deepEqual(currentArrangements(items).map(item => item.record_key), ['row:200']);
|
|
items.push(event('arrangement_hotel', 'clear', '200', 'e-5', '2026-09-16T04:00:00Z'));
|
|
assert.ok(arrangementSummary(items, items[0].group_number, 'now').items.every(item => !item.arranged));
|
|
assert.equal(items.length, 6, 'history remains intact');
|
|
});
|
|
|
|
test('late reconciliation replays by original execution time rather than overwriting newer arrangements', () => {
|
|
const early = event(); early.recorded_at = '2026-09-17T01:00:00.000Z';
|
|
const clear = event('arrangement_hotel', 'clear', '100', 'e-clear', '2026-09-16T02:00:00Z');
|
|
assert.equal(currentArrangements([clear, early]).length, 0);
|
|
});
|
|
|
|
test('narrow hotel updates retain existing resource/date details but honor a verified cleared remark', () => {
|
|
const original = event();
|
|
const input = fixture('arrangement_hotel', 'update', '100', 'e-narrow', '2026-09-16T02:00:00Z');
|
|
input.operation.data.arrangement = { mode: 'update', changes: { room_count: 8 } };
|
|
input.result.report.requery.subpage.arrangement_target.quantity_value = '8';
|
|
input.result.report.requery.subpage.checks = [{ field: 'beizhu0', actual: '', matched: true }];
|
|
const update = arrangementEventFromSuccess(input)!;
|
|
const current = currentArrangements([original, update])[0];
|
|
assert.equal(current.details.resource_name, '实际酒店');
|
|
assert.equal(current.details.start_date, '2026-09-04');
|
|
assert.equal(current.details.end_date, '2026-09-05');
|
|
assert.equal(current.details.quantity, 8);
|
|
assert.equal(current.details.remark, '');
|
|
assert.equal(original.details.quantity, 12, 'original history is immutable');
|
|
});
|
|
|
|
test('clearing external/untracked rows does not erase this account recorded arrangements', () => {
|
|
assert.equal(currentArrangements([event(), event('arrangement_hotel', 'clear', '900', 'other-clear')]).length, 1);
|
|
});
|
|
|
|
test('receipt summary is a frozen snapshot and remains single when public receipts are rebuilt', () => {
|
|
const input = fixture(); const first = event();
|
|
const base = { ...input.receipt, arrangement_summary: arrangementSummary([first], first.group_number, first.recorded_at) };
|
|
const receipt = buildCustomerSuccessReceipt(base, input.result, input.operation);
|
|
const reply = replyTextFromReceipt(receipt);
|
|
assert.match(reply, /NEW BOOKING/);
|
|
assert.match(reply, /导游:未安排[\s\S]*酒店:已安排/);
|
|
assert.match(reply, /当前账号/);
|
|
const newer = arrangementSummary([first, event('arrangement_guide', 'create', '', 'e-guide')], first.group_number, 'later');
|
|
assert.equal(newer.items[0].arranged, true);
|
|
const rebuilt = buildCustomerSuccessReceipt(receipt, input.result, input.operation);
|
|
assert.equal(replyTextFromReceipt(rebuilt), reply);
|
|
assert.equal(reply.match(/本系统安排情况/g)?.length, 1);
|
|
assert.ok(!replyTextFromReceipt(buildCustomerSuccessReceipt({ success: true }, {}, { action: 'order_cancel' })).includes('本系统安排情况'));
|
|
});
|
|
|
|
function memoryClient() {
|
|
const progress = progressMemory(config);
|
|
const owners = new Map<string, Json>();
|
|
const records: Json[] = [], tasks: Json[] = [];
|
|
const queries: string[] = [];
|
|
const answer = (rows: Json[] = []) => ({ rowCount: rows.length, rows });
|
|
const client = { release() {}, async query(sql: string, p: any[] = []) {
|
|
queries.push(sql);
|
|
if (sql.includes('team_progress_') || (sql.includes('FROM tasks t JOIN task_attempts') && sql.includes('COALESCE'))) return progress.query(sql, p);
|
|
const ownerKey = `${p[0]}/${p[1]}`;
|
|
if (/INSERT INTO arrangement_ledger_owners/.test(sql)) { if (!owners.has(ownerKey)) owners.set(ownerKey, {}); return answer(); }
|
|
if (/SELECT backfilled_at/.test(sql)) { assert.match(sql, /FOR UPDATE/); return answer([owners.get(ownerKey)!]); }
|
|
if (/UPDATE arrangement_ledger_owners/.test(sql)) { owners.get(ownerKey)!.backfilled_at = 'done'; return answer(); }
|
|
if (/FROM tasks t JOIN task_attempts/.test(sql)) {
|
|
assert.match(sql, /t.organization_id = \$1 AND t.assigned_user_id = \$2/);
|
|
return answer(tasks.filter(t => t.organization_id === p[0] && t.assigned_user_id === p[1] && t.status === 'completed'
|
|
&& t.task_id > p[2] && p[3].includes(t.operation.action)).sort((a,b) => a.task_id.localeCompare(b.task_id)).slice(0, 200));
|
|
}
|
|
if (/INSERT INTO arrangement_ledger_events/.test(sql)) {
|
|
assert.match(sql, /ON CONFLICT \(organization_id, owner_user_id, execution_id\) DO NOTHING/);
|
|
if (!records.some(r => r.organization_id === p[0] && r.owner_user_id === p[1] && r.execution_id === p[5]))
|
|
records.push({ organization_id: p[0], owner_user_id: p[1], group_key: p[2], action: p[3], source_task_id: p[4],
|
|
execution_id: p[5], effective_at: p[6], recorded_at: p[7], detail_ciphertext: p[8], source_task_deleted_at: null });
|
|
return answer();
|
|
}
|
|
if (/SELECT detail_ciphertext/.test(sql)) {
|
|
assert.match(sql, /organization_id = \$1 AND owner_user_id = \$2 AND group_key = \$3/);
|
|
return answer(records.filter(r => r.organization_id === p[0] && r.owner_user_id === p[1] && r.group_key === p[2]));
|
|
}
|
|
if (/UPDATE arrangement_ledger_events/.test(sql)) {
|
|
for (const r of records) if (r.organization_id === p[0] && r.owner_user_id === p[1] && p[2].includes(r.source_task_id)) r.source_task_deleted_at = '2026-09-17T01:00:00Z';
|
|
return answer();
|
|
}
|
|
if (['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql)) return answer();
|
|
throw new Error(`Unexpected SQL: ${sql}`);
|
|
} };
|
|
return { client: client as unknown as pg.PoolClient, raw: client, owners, records, tasks, queries };
|
|
}
|
|
|
|
test('ledger storage encrypts details, deduplicates and scopes reads to both organization and account', async () => {
|
|
const db = memoryClient(), ledger = new ArrangementLedger(config), original = event();
|
|
await ledger.prepare(db.client, scope, successReceiptFromResult);
|
|
await ledger.insert(db.client, scope, original);
|
|
await ledger.insert(db.client, scope, original);
|
|
assert.equal(db.records.length, 1);
|
|
assert.ok(!db.records[0].detail_ciphertext.includes('实际酒店'));
|
|
assert.equal(JSON.parse(decryptText(config, db.records[0].detail_ciphertext)).details.resource_name, '实际酒店');
|
|
assert.equal((await ledger.history(db.client, scope, original.group_number)).length, 1);
|
|
assert.equal((await ledger.history(db.client, { ...scope, userId: 'user-b' }, original.group_number)).length, 0);
|
|
assert.equal((await ledger.history(db.client, { ...scope, organizationId: 'org-b' }, original.group_number)).length, 0);
|
|
await ledger.markTasksDeleted(db.client, scope, [original.source_task_id]);
|
|
const history = await ledger.history(db.client, scope, original.group_number);
|
|
assert.ok(history[0].source_task_deleted_at);
|
|
assert.equal(history[0].details.resource_name, '实际酒店');
|
|
assert.equal(currentArrangements(history).length, 1);
|
|
});
|
|
|
|
test('backfill includes archived successes and skips failed/other-account tasks, preserving encrypted evidence', async () => {
|
|
const db = memoryClient(), ledger = new ArrangementLedger(config), input = fixture();
|
|
db.tasks.push({ task_id: input.taskId, execution_id: input.executionId, operation: { action: input.operation.action },
|
|
operation_ciphertext: encryptText(config, JSON.stringify(input.operation)),
|
|
execution_result_ciphertext: encryptText(config, JSON.stringify(input.result)),
|
|
organization_id: scope.organizationId, assigned_user_id: scope.userId, status: 'completed', archived_at: 'archived',
|
|
effective_at: input.effectiveAt, success_receipt_at: input.recordedAt });
|
|
db.tasks.push({ ...db.tasks[0], task_id: 'TASK-failed', execution_id: 'failed', status: 'failed' },
|
|
{ ...db.tasks[0], task_id: 'TASK-other', execution_id: 'other', assigned_user_id: 'other' });
|
|
await ledger.prepare(db.client, scope, successReceiptFromResult);
|
|
assert.equal(db.records.length, 1);
|
|
assert.equal(db.records[0].source_task_id, input.taskId);
|
|
await ledger.prepare(db.client, scope, successReceiptFromResult);
|
|
assert.equal(db.queries.filter(sql => /FROM tasks t JOIN task_attempts/.test(sql)).length, 1);
|
|
});
|
|
|
|
test('ledger migration has no cascading source-task dependency and blocks admin ownership', async () => {
|
|
const sql = await readFile(new URL('../migrations/025_arrangement_ledger.sql', import.meta.url), 'utf8');
|
|
assert.doesNotMatch(sql, /REFERENCES\s+(tasks|task_attempts)\b/i);
|
|
assert.match(sql, /detail_ciphertext text NOT NULL/);
|
|
assert.match(sql, /UNIQUE \(organization_id, owner_user_id, execution_id\)/);
|
|
assert.match(sql, /reject_admin_task_principal/);
|
|
});
|
|
|
|
test('real success-result transaction saves summary/history once and force delete preserves details', async t => {
|
|
const db = memoryClient(), input = fixture(), pool = getPool(config);
|
|
const context = { ...scope, role: 'user' as const, requestId: 'ledger-test' };
|
|
let row: Json = { id: 'row-1', task_id: input.taskId, organization_id: scope.organizationId, assigned_user_id: scope.userId,
|
|
status: 'running', operation: input.operation, lease_owner: 'browser:connection-1' };
|
|
const attempt: Json = { id: input.executionId, started_at: input.effectiveAt, status: 'running', details: { connection_id: 'connection-1' } };
|
|
const events: Json[] = [];
|
|
const delegate = db.raw.query.bind(db.raw);
|
|
db.raw.query = async (sql: string, p: any[] = []) => {
|
|
if (/SELECT \* FROM task_attempts/.test(sql)) return { rowCount: 1, rows: [attempt] };
|
|
if (/UPDATE tasks\s/.test(sql)) {
|
|
row = { ...row, execution_result_ciphertext: p[0], execution_result: p[1], status: p[2], stage: p[3], message: p[4], success_receipt: p[9] };
|
|
return { rowCount: 1, rows: [row] };
|
|
}
|
|
if (/UPDATE task_attempts/.test(sql)) { attempt.status = p[0]; attempt.response_hash = p[1]; return { rowCount: 1, rows: [] }; }
|
|
if (/SELECT \* FROM tasks/.test(sql)) return { rowCount: 1, rows: [row] };
|
|
if (/FROM task_artifacts artifact|DELETE FROM outbox_events/.test(sql)) return { rowCount: 0, rows: [] };
|
|
if (/DELETE FROM tasks/.test(sql)) { const deleted = row; row = {}; return { rowCount: 1, rows: [deleted] }; }
|
|
return delegate(sql, p);
|
|
};
|
|
t.mock.method(pool, 'connect', async () => db.client);
|
|
t.after(async () => { t.mock.restoreAll(); await closePool(); });
|
|
const service = new TaskService(config);
|
|
Object.assign(service, { lockTaskForAccess: async () => row, requireBrowserConnection: async () => {}, getTask: async () => row,
|
|
emitEvent: async (_client: unknown, _row: unknown, event: Json) => { events.push(event); return event; },
|
|
audit: async () => {}, notify: () => {}, notifyBrowserCommand: () => {} });
|
|
await service.recordExecutionResult(context, input.taskId, input.result, 'connection-1', input.executionId, { skipArtifactPersistence: true });
|
|
assert.equal(row.status, 'completed');
|
|
assert.equal(db.records.length, 1);
|
|
assert.match(replyTextFromReceipt(row.success_receipt), /酒店:已安排/);
|
|
assert.deepEqual(events[0].payload.success_receipt, row.success_receipt);
|
|
await service.recordExecutionResult(context, input.taskId, input.result, 'connection-1', input.executionId, { skipArtifactPersistence: true });
|
|
assert.equal(db.records.length, 1);
|
|
assert.equal(events.length, 1);
|
|
const deleted = await service.hardDeleteTask(context, input.taskId);
|
|
assert.equal(deleted.deleted, true);
|
|
assert.deepEqual(row, {});
|
|
const history = await new ArrangementLedger(config).history(db.client, scope, 'LW-260901VIP-A');
|
|
assert.ok(history[0].source_task_deleted_at);
|
|
assert.equal(currentArrangements(history).length, 1);
|
|
assert.match(replyTextFromReceipt(buildCustomerSuccessReceipt({ success: true, arrangement_summary:
|
|
arrangementSummary(history, 'LW-260901VIP-A', 'now') }, input.result, input.operation)), /酒店:已安排/);
|
|
});
|