import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; async function source(relativePath: string): Promise { 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/); });