Files
LWLT-AIBOT/tools/arrangement-ledger-postgres-smoke.mjs
2026-09-23 12:02:56 +08:00

170 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Explicit, disposable local PostgreSQL only. Never reads .env or uses the app DB.
// LTJT_ARRANGEMENT_TEST_DATABASE_URL=postgresql://...@127.0.0.1:PORT/ledger_test \
// node --import tsx tools/arrangement-ledger-postgres-smoke.mjs
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFile, readdir } from 'node:fs/promises';
import { loadConfig } from '../control-plane/src/config.ts';
import { getPool, closePool, quoteIdentifier } from '../control-plane/src/db.ts';
import { encryptText } from '../control-plane/src/crypto.ts';
import { ArrangementLedger, currentArrangements } from '../control-plane/src/arrangement-ledger.ts';
import { TaskService } from '../control-plane/src/task-service.ts';
import { TeamProgressLedger } from '../control-plane/src/team-progress.ts';
import { taskResultText } from '../control-plane/src/agentbus.ts';
const rawUrl = process.env.LTJT_ARRANGEMENT_TEST_DATABASE_URL;
if (!rawUrl) throw new Error('Set LTJT_ARRANGEMENT_TEST_DATABASE_URL to a disposable local ledger_test database.');
const url = new URL(rawUrl);
assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) && url.pathname === '/ledger_test');
const schema = `ledger_smoke_${randomUUID().replaceAll('-', '')}`;
const config = loadConfig({ NODE_ENV: 'test', DATABASE_URL: rawUrl, DATABASE_SCHEMA: schema,
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 31).toString('base64') });
const pool = getPool(config);
const ledger = new ArrangementLedger(config);
const progress = new TeamProgressLedger(config);
let created = false;
try {
await pool.query(`CREATE SCHEMA ${quoteIdentifier(schema)}`);
created = true;
const migrationDir = new URL('../control-plane/migrations/', import.meta.url);
const migrations = (await readdir(migrationDir)).filter(name => /^\d+_.+\.sql$/.test(name)).sort();
for (const name of migrations) await pool.query(await readFile(new URL(name, migrationDir), 'utf8'));
const org = (await pool.query(`INSERT INTO organizations (slug, name) VALUES ('ledger-fixture', 'Ledger fixture') RETURNING id`)).rows[0].id;
const otherOrg = (await pool.query(`INSERT INTO organizations (slug, name) VALUES ('ledger-other', 'Other fixture') RETURNING id`)).rows[0].id;
const users = (await pool.query(`INSERT INTO users (organization_id, username, password_hash, role)
VALUES ($1,'ledger-a','fixture','user'),($1,'ledger-b','fixture','user'),($1,'ledger-admin','fixture','admin') RETURNING id, username`, [org])).rows;
const context = name => ({ organizationId: org, userId: users.find(u => u.username === name).id, role: 'user', requestId: 'ledger-smoke' });
const ownerA = context('ledger-a'), ownerB = context('ledger-b');
const service = new TaskService(config);
// No browser or ERP is involved. All remaining persistence and auth paths are real.
service.requireBrowserConnection = async () => {};
async function task(owner, action = 'arrangement_hotel', mode = 'create', rowId = '100', group = 'LW-TEST-A') {
const taskId = `TASK-${randomUUID()}`, executionId = randomUUID();
const operation = { action, data: { existing_refs: { identifier: group }, arrangement: {
mode, target: { row_id: rowId }, start_date: '2026-09-16', end_date: '2026-09-17', room_count: 8
} } };
const result = { status: 'completed', execution_id: executionId, no_erp_write: false, write_attempted: true,
report: { status: 'lifecycle_completed', resolved_refs: { identifier: group, kind: 'independent_order' },
server_response: { completed: true }, requery: { matched: true, subpage: { matched: true,
arrangement_target: { row_id: rowId, resource_id: '700', item_value: 'TWN', quantity_value: '8', cleared: mode === 'clear' },
checks: [{ field: action === 'arrangement_guide' ? 'daoyou0' : 'danwei0', matched: true, actual: '实际资源' }]
} } } };
const inserted = await pool.query(`INSERT INTO tasks
(organization_id, task_id, created_by, assigned_user_id, status, stage, lease_owner, operation, operation_ciphertext)
VALUES ($1,$2,$3,$3,'running','lifecycle_live','browser:fixture',$4,$5) RETURNING id`,
[org, taskId, owner.userId, { action }, encryptText(config, JSON.stringify(operation))]);
await pool.query(`INSERT INTO task_attempts (id, organization_id, task_id, attempt_no, phase, status, details)
VALUES ($1,$2,$3,1,'erp','running',$4)`, [executionId, org, inserted.rows[0].id, { connection_id: 'fixture' }]);
return { taskId, executionId, operation, result };
}
const complete = (owner, item) => service.recordExecutionResult(owner, item.taskId, item.result, 'fixture', item.executionId, { skipArtifactPersistence: true });
const first = await task(ownerA);
const receipt = await complete(ownerA, first);
assert.match(receipt.important_message.text, /酒店:已安排/);
assert.equal(taskResultText(receipt), receipt.important_message.text, 'AgentBus preserves the summary');
await complete(ownerA, first);
assert.equal((await ledger.history(pool, ownerA, 'LW-TEST-A')).length, 1, 'idempotent result ingestion');
assert.equal((await ledger.history(pool, ownerB, 'LW-TEST-A')).length, 0);
assert.equal((await ledger.history(pool, { ...ownerA, organizationId: otherOrg }, 'LW-TEST-A')).length, 0);
const guide = await task(ownerA, 'arrangement_guide');
const next = await complete(ownerA, guide);
assert.match(next.important_message.text, /导游:已安排[\s\S]*酒店:已安排/);
assert.match((await service.getTask(org, first.taskId, ownerA)).important_message.text, /导游:未安排/, 'old receipt stays frozen');
await service.hardDeleteTask(ownerA, first.taskId);
const retained = await ledger.history(pool, ownerA, 'LW-TEST-A');
assert.ok(retained.find(e => e.source_task_id === first.taskId).source_task_deleted_at);
assert.equal(retained.find(e => e.source_task_id === first.taskId).details.resource_name, '实际资源');
assert.equal((await pool.query('SELECT 1 FROM task_attempts WHERE id = $1', [first.executionId])).rowCount, 0);
assert.equal(currentArrangements(retained).length, 2, 'task cascade leaves ledger intact');
assert.ok((await progress.history(pool, ownerA, 'LW-TEST-A')).find(e => e.source_task_id === first.taskId).source_task_deleted_at);
const secondHotel = await task(ownerA, 'arrangement_hotel', 'create', '200');
await complete(ownerA, secondHotel);
await complete(ownerA, await task(ownerA, 'arrangement_hotel', 'clear', '100'));
assert.equal(currentArrangements(await ledger.history(pool, ownerA, 'LW-TEST-A')).filter(e => e.action === 'arrangement_hotel').length, 1);
// A failure after ledger insertion must roll back both task outcome and ledger.
const rollback = await task(ownerA, 'arrangement_vehicle');
const emit = service.emitEvent;
service.emitEvent = async () => { throw new Error('injected-event-failure'); };
await assert.rejects(complete(ownerA, rollback), /injected-event-failure/);
service.emitEvent = emit;
assert.equal((await pool.query('SELECT status FROM tasks WHERE task_id = $1', [rollback.taskId])).rows[0].status, 'running');
assert.ok(!(await ledger.history(pool, ownerA, 'LW-TEST-A')).some(e => e.execution_id === rollback.executionId));
assert.ok(!(await progress.history(pool, ownerA, 'LW-TEST-A')).some(e => e.execution_id === rollback.executionId));
// Deleting an archived, pre-feature success as the first interaction backfills it.
const historic = await task(ownerB, 'arrangement_transport');
await pool.query(`UPDATE tasks SET status = 'completed', archived_at = now(), success_receipt_at = now(),
execution_result_ciphertext = $2, execution_result = $3 WHERE task_id = $1`, [historic.taskId, encryptText(config, JSON.stringify(historic.result)), {execution_id:historic.executionId}]);
await service.hardDeleteTask(ownerB, historic.taskId);
const backfill = await ledger.history(pool, ownerB, 'LW-TEST-A');
assert.equal(backfill.length, 1);
assert.equal(backfill[0].action, 'arrangement_transport');
assert.equal((await progress.history(pool, ownerB, 'LW-TEST-A')).length, 1);
assert.ok(backfill[0].source_task_deleted_at);
assert.ok(!(await ledger.history(pool, ownerA, 'LW-TEST-A')).some(e => e.execution_id === historic.executionId));
await assert.rejects(pool.query(`INSERT INTO arrangement_ledger_owners (organization_id, owner_user_id) VALUES ($1,$2)`,
[org, context('ledger-admin').userId]), e => e.code === '23514');
await assert.rejects(pool.query(`INSERT INTO arrangement_ledger_owners (organization_id, owner_user_id) VALUES ($1,$2)`,
[otherOrg, ownerA.userId]), e => e.code === '23503');
// Exercise the same transactional API for non-arrangement operations.
async function business(owner, action, data, refs, extra = {}) {
const item = await task(owner, action);
item.operation = {action, data};
item.result = {...item.result, summary:{action}, report:{...item.result.report, resolved_refs:refs}, ...extra};
await pool.query('UPDATE tasks SET operation = $2, operation_ciphertext = $3 WHERE task_id = $1',
[item.taskId,{action},encryptText(config,JSON.stringify(item.operation))]);
return item;
}
const parentRefs = {kind:'shared_plan',identifier:'LW-PROGRESS'};
const childRefs = {kind:'shared_child_order',identifier:'D300',parent_group_no:'LW-PROGRESS'};
const plan = await business(ownerA,'shared_plan_create',{},parentRefs);
await complete(ownerA,plan);
const child = await business(ownerA,'shared_child_order_create',{},childRefs,{erp_receipt:{order_number:'D300',parent_group_no:'LW-PROGRESS'}});
await complete(ownerA,child);
for (const count of [20,18]) {
const item = await business(ownerA,'passenger_list_import',{passenger_list:{row_count:count,tsv:'PRIVATE ROSTER'}},childRefs);
const outcome = await complete(ownerA,item);
assert.match(outcome.important_message.text,new RegExp('D300 名单:已导入,最近一次 '+count+' 人'));
assert.doesNotMatch(outcome.important_message.text,/PRIVATE ROSTER/);
if(count===18) await service.hardDeleteTask(ownerA,item.taskId);
}
const cancel = await business(ownerA,'order_cancel',{transition:{to_status:'已取消'}},parentRefs);
cancel.result.report.requery={matched:true,child_status_propagation_matched:true,child_refs:[{ddid:'300'}]};
await complete(ownerA,cancel);
const restore = await business(ownerA,'order_restore',{transition:{to_status:'已恢复'}},parentRefs);
const restored = await complete(ownerA,restore);
assert.match(restored.important_message.text,/订单状态:已恢复/);
assert.match(restored.important_message.text,/子单 D300.*已取消/);
assert.match(restored.important_message.text,/最近一次 18 人/,'latest roster survives deletion');
assert.match((await service.getTask(org,plan.taskId,ownerA)).important_message.text,/本系统记录子单:0个/,'old snapshot stays frozen');
assert.equal((await progress.history(pool,ownerB,'LW-PROGRESS')).length,0);
assert.equal((await progress.history(pool,{...ownerA,organizationId:otherOrg},'LW-PROGRESS')).length,0);
const partial = await business(ownerA,'shared_child_order_batch_create',{},parentRefs,{
status:'reconciliation_pending',uncertain:true,report:{status:'split_child_batch_stopped_after_write',batch_results:[
{status:'completed',adapter_status:'split_child_completed',parent_group_no:'LW-PARTIAL',child_order_no:'D401',blockers:[]},
{status:'uncertain',parent_group_no:'LW-PARTIAL',child_order_no:'D402'}]}
});
const partialOutcome = await complete(ownerA,partial);
assert.equal(partialOutcome.status,'reconciliation_pending');
assert.deepEqual((await progress.history(pool,ownerA,'LW-PARTIAL')).map(e=>e.subject_number),['D401']);
assert.ok(!partialOutcome.success_receipt,'partial progress does not produce a success receipt');
await assert.rejects(pool.query('INSERT INTO team_progress_owners (organization_id, owner_user_id) VALUES ($1,$2)',
[org,context('ledger-admin').userId]),e=>e.code==='23514');
await assert.rejects(pool.query('INSERT INTO team_progress_owners (organization_id, owner_user_id) VALUES ($1,$2)',
[otherOrg,ownerA.userId]),e=>e.code==='23503');
console.log(JSON.stringify({ ok: true, migrations: migrations.length, verified: [
'real migrations', 'result ingestion and public/AgentBus receipts', 'deduplication', 'account/org isolation',
'frozen receipts', 'force-delete retention', 'row-specific clear', 'transaction rollback',
'archived historical backfill before delete', 'admin and cross-org ownership constraints',
'non-arrangement receipts', 'per-child latest roster without private data', 'retained roster after delete', 'parent restore preserves child cancellation',
'partial batch proven rows without false task success'
] }));
} finally {
if (created) await pool.query(`DROP SCHEMA ${quoteIdentifier(schema)} CASCADE`);
await closePool();
}