170 lines
13 KiB
JavaScript
170 lines
13 KiB
JavaScript
// 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();
|
||
}
|