Files
LWLT-AIBOT/control-plane/test/leader-notification-contract.test.ts
T

120 lines
7.6 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(new URL(relativePath, import.meta.url), 'utf8');
}
test('migration retires only the legacy leader-summary transport and creates an encrypted webhook outbox', async () => {
const sql = await source('../migrations/023_leader_summary_webhook_delivery.sql');
assert.match(sql, /UPDATE leader_task_summary_subscriptions[\s\S]*?SET enabled = false/);
assert.match(sql, /UPDATE leader_task_summary_deliveries[\s\S]*?delivery_status = 'cancelled'/);
assert.match(sql, /WHERE delivery_status IN \('pending', 'sending', 'failed'\)/);
assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_webhook_state/);
assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_webhook_deliveries/);
assert.match(sql, /configuration_fingerprint text NOT NULL/);
assert.match(sql, /payload_ciphertext text NOT NULL/);
assert.match(sql, /payload_fingerprint text NOT NULL/);
assert.match(sql, /delivery_status IN \('pending', 'sending', 'accepted', 'failed', 'uncertain', 'cancelled'\)/);
assert.match(sql, /CHECK \(attempt_count BETWEEN 0 AND 1\)/);
assert.match(sql, /UNIQUE \(organization_id, webhook_revision, task_id, milestone\)/);
assert.match(sql, /FOREIGN KEY \(organization_id, task_id\)[\s\S]*?REFERENCES tasks \(organization_id, id\)/);
const newOutbox = sql.slice(sql.indexOf('CREATE TABLE IF NOT EXISTS leader_task_summary_webhook_deliveries'));
assert.doesNotMatch(newOutbox, /\b(channel_id|leader_user_id|recipient_address|conversation_id|token|webhook_url)\b/i);
assert.doesNotMatch(sql, /INSERT\s+INTO\s+leader_task_summary_webhook_deliveries/i,
'schema migration must not backfill historical employee activity');
assert.doesNotMatch(sql, /UPDATE\s+(tasks|task_events|task_attempts|agentbus_deliveries)\b/i,
'transport migration must not mutate normal task or employee AgentBus state');
});
test('webhook worker is independent from normal task execution and AgentBus delivery', async () => {
const service = await source('../src/leader-notification-service.ts');
const server = await source('../src/server.ts');
const agentbus = await source('../src/agentbus.ts');
const channels = await source('../src/agentbus-channels.ts');
assert.match(service, /new ExternalWebhookClient\(/);
assert.match(service, /config\.leaderSummaryWebhookEnabled/);
assert.match(server, /if \(config\.leaderSummaryWebhookEnabled\)[\s\S]*?leaderNotificationService\.startProjector/);
assert.doesNotMatch(server, /new AgentBusManager\([\s\S]*?leaderNotifications:/);
assert.doesNotMatch(agentbus, /LeaderNotificationGateway|createLeaderTaskSummaryFrame|flushLeaderDeliveries|sendLeaderDelivery|observeLeaderRoute/);
assert.doesNotMatch(channels, /leaderNotifications:/);
const flushStart = agentbus.indexOf('private async flushOutboundDeliveries');
const flushEnd = agentbus.indexOf('private async sendDurableDelivery', flushStart);
const employeeFlush = agentbus.slice(flushStart, flushEnd);
assert.match(employeeFlush, /await this\.flushDurableDeliveries\(\)/);
assert.doesNotMatch(employeeFlush, /leader|webhook/i);
const ignoreStart = agentbus.indexOf('function inboundFrameIgnoreReason');
const ignoreEnd = agentbus.indexOf('export function parseAgentBusFrame', ignoreStart);
assert.match(agentbus.slice(ignoreStart, ignoreEnd), /'task\.summary'/);
assert.match(agentbus.slice(ignoreStart, ignoreEnd), /reserved_event/);
});
test('projection is organization-scoped, future-only, role-safe, source-limited and encrypted', async () => {
const service = await source('../src/leader-notification-service.ts');
assert.match(service, /event\.topic = 'task\.updated'/);
assert.match(service, /event\.created_at >= state\.starts_at/);
assert.match(service, /task\.assigned_user_id IS NOT NULL/);
assert.match(service, /assignee\.organization_id = task\.organization_id/);
assert.match(service, /assignee\.role <> 'admin'/);
assert.match(service, /task\.source IN \('manual', 'agentbus'\)/);
assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s);
assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s);
assert.match(service, /pg_try_advisory_xact_lock/);
assert.match(service, /VALUES \(\$1::uuid, NULL, \$2, 'leader_task_summary_webhook', \$1::uuid::text, NULL, \$3\)/,
'organization IDs must be explicitly cast when shared by UUID and text audit columns');
assert.match(service, /encryptText\(this\.config, payloadText\)/);
assert.match(service, /sha256Text\(payloadText\)/);
assert.match(service, /ON CONFLICT \(organization_id, webhook_revision, task_id, milestone\) DO NOTHING/);
assert.doesNotMatch(service, /UPDATE\s+(tasks|task_events|task_attempts|agentbus_deliveries)\b/i,
'summary projection must never write normal task or employee reply state');
});
test('delivery makes one attempt and preserves ambiguous outcomes instead of retrying', async () => {
const service = await source('../src/leader-notification-service.ts');
assert.match(service, /delivery_status = 'sending'[\s\S]*?updated_at < now\(\) - interval '1 minute'/);
assert.match(service, /SET delivery_status = 'uncertain'[\s\S]*?发送租约过期/);
assert.match(service, /delivery\.attempt_count = 0/);
assert.match(service, /attempt_count = attempt_count \+ 1/);
assert.match(service, /FOR UPDATE OF delivery SKIP LOCKED/);
assert.match(service, /result\.outcome === 'accepted'[\s\S]*?'accepted'[\s\S]*?'failed'[\s\S]*?'uncertain'/);
assert.match(service, /automatic retry disabled/);
const outcomeStart = service.indexOf('private async markDeliveryOutcome');
const outcomeEnd = service.indexOf('private async dispatchPending', outcomeStart);
const outcomeWriter = service.slice(outcomeStart, outcomeEnd);
assert.ok(outcomeStart > 0 && outcomeEnd > outcomeStart);
assert.doesNotMatch(outcomeWriter, /'pending'/,
'terminal delivery outcomes must never be placed back into the pending queue');
});
test('HTTP exposes administrator-only status and no mutation or live test-send endpoint', async () => {
const server = await source('../src/server.ts');
const routeStart = server.indexOf("app.get('/api/settings/leader-summary-webhook'");
const routeEnd = server.indexOf("app.post('/api/auth/logout'", routeStart);
const routes = server.slice(routeStart, routeEnd);
assert.ok(routeStart > 0 && routeEnd > routeStart);
assert.match(routes, /requireAdminSession/);
assert.match(routes, /getWebhookStatus/);
assert.doesNotMatch(routes, /app\.(put|post|patch|delete)/);
assert.doesNotMatch(server, /leader-summary-webhook.*test-send|leader-summary-webhook.*test\/send/s);
});
test('administrator UI explains the isolated fixed webhook and accepted-not-delivered semantics', async () => {
const html = await source('../../LianSyn-platform/index.html');
const app = await source('../../LianSyn-platform/app.js');
assert.match(html, /组长任务摘要推送/);
assert.match(html, /不再依赖组长 AgentBus 账号或渠道/);
assert.match(html, /WEBHOOK_SEND_URL/);
assert.match(html, /WEBHOOK_EXTERNAL_TOKEN/);
assert.match(html, /只处理配置生效后的新结果,不补发历史任务/);
assert.match(html, /已受理.*不代表微信已送达/);
assert.match(html, /不会自动重试/);
assert.match(app, /\/api\/settings\/leader-summary-webhook/);
assert.match(app, /人工任务、AgentBus 任务/);
assert.match(app, /已使用独立外部 Webhook,不受该渠道影响/);
assert.doesNotMatch(app, /leader-summary-subscriptions/);
});