Files
LWLT-AIBOT/control-plane/test/leader-notification-contract.test.ts
2026-09-10 15:58:22 +08:00

122 lines
7.7 KiB
TypeScript
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.

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, /task\.original_text_ciphertext,/);
assert.match(service, /originalText: readLeaderSummaryOriginalText\(this\.config, row\.original_text_ciphertext\)/);
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/);
});