122 lines
7.7 KiB
TypeScript
122 lines
7.7 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, /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/);
|
||
});
|