feat: deliver leader summaries by webhook
This commit is contained in:
1 parent
515b545b32
commit
be17f6c361
21 files changed
+1260
-1269
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { buildServer } from '../src/server.js';
|
||||
import {
|
||||
EXTERNAL_WEBHOOK_CONFIG_ID,
|
||||
ExternalWebhookClient,
|
||||
type ExternalWebhookFetch
|
||||
} from '../src/external-webhook-client.js';
|
||||
|
||||
const webhookUrl = 'https://gateway.example.test/wechat/webhookInfo/sendMessageByOut';
|
||||
const webhookToken = 't'.repeat(32);
|
||||
|
||||
test('external webhook request uses the confirmed fixed contract exactly', async () => {
|
||||
let requestInput: string | URL | Request | undefined;
|
||||
let requestInit: RequestInit | undefined;
|
||||
const fetchImpl: ExternalWebhookFetch = async (input, init) => {
|
||||
requestInput = input;
|
||||
requestInit = init;
|
||||
return new Response(JSON.stringify({ code: 0, data: true, msg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
};
|
||||
const client = new ExternalWebhookClient({
|
||||
url: webhookUrl,
|
||||
token: webhookToken,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
const result = await client.send('【员工任务摘要】\n员工:employee-a');
|
||||
|
||||
assert.deepEqual(result, { outcome: 'accepted', httpStatus: 200, errorCode: null });
|
||||
assert.equal(requestInput, webhookUrl);
|
||||
assert.equal(requestInit?.method, 'POST');
|
||||
assert.deepEqual(requestInit?.headers, {
|
||||
'Content-Type': 'application/json',
|
||||
'x-token': webhookToken
|
||||
});
|
||||
assert.deepEqual(JSON.parse(String(requestInit?.body)), {
|
||||
id: EXTERNAL_WEBHOOK_CONFIG_ID,
|
||||
content: '【员工任务摘要】\n员工:employee-a'
|
||||
});
|
||||
assert.equal(EXTERNAL_WEBHOOK_CONFIG_ID, '9999');
|
||||
assert.ok(requestInit?.signal instanceof AbortSignal);
|
||||
});
|
||||
|
||||
test('external webhook accepts only HTTP 200 with code zero and data true', async () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
response?: Response;
|
||||
error?: Error;
|
||||
expected: Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: 'business rejection in HTTP 200',
|
||||
response: new Response(JSON.stringify({ code: 1, data: false, msg: 'rejected' }), { status: 200 }),
|
||||
expected: { outcome: 'rejected', httpStatus: 200, errorCode: 'webhook_business_rejected' }
|
||||
},
|
||||
{
|
||||
name: 'invalid request',
|
||||
response: new Response(JSON.stringify({ code: 400, data: false, msg: 'id and content must not be blank' }), { status: 400 }),
|
||||
expected: { outcome: 'rejected', httpStatus: 400, errorCode: 'webhook_invalid_request' }
|
||||
},
|
||||
{
|
||||
name: 'invalid token',
|
||||
response: new Response(JSON.stringify({ code: 401, data: false, msg: 'Invalid token' }), { status: 401 }),
|
||||
expected: { outcome: 'rejected', httpStatus: 401, errorCode: 'webhook_invalid_token' }
|
||||
},
|
||||
{
|
||||
name: 'missing webhook configuration',
|
||||
response: new Response(JSON.stringify({ code: 404, data: false, msg: 'Webhook configuration not found' }), { status: 404 }),
|
||||
expected: { outcome: 'rejected', httpStatus: 404, errorCode: 'webhook_configuration_not_found' }
|
||||
},
|
||||
{
|
||||
name: 'server error is uncertain',
|
||||
response: new Response(JSON.stringify({ code: 500, data: false, msg: 'error' }), { status: 500 }),
|
||||
expected: { outcome: 'uncertain', httpStatus: 500, errorCode: 'webhook_http_500_uncertain' }
|
||||
},
|
||||
{
|
||||
name: 'invalid success response is uncertain',
|
||||
response: new Response('not-json', { status: 200 }),
|
||||
expected: { outcome: 'uncertain', httpStatus: 200, errorCode: 'webhook_response_invalid_json' }
|
||||
},
|
||||
{
|
||||
name: 'network failure is uncertain',
|
||||
error: new Error('connection reset'),
|
||||
expected: { outcome: 'uncertain', httpStatus: null, errorCode: 'webhook_request_uncertain' }
|
||||
},
|
||||
{
|
||||
name: 'timeout is uncertain',
|
||||
error: new DOMException('timed out', 'TimeoutError'),
|
||||
expected: { outcome: 'uncertain', httpStatus: null, errorCode: 'webhook_request_timeout' }
|
||||
}
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const client = new ExternalWebhookClient({
|
||||
url: webhookUrl,
|
||||
token: webhookToken,
|
||||
fetchImpl: async () => {
|
||||
if (item.error) throw item.error;
|
||||
return item.response as Response;
|
||||
}
|
||||
});
|
||||
assert.deepEqual(await client.send('summary'), item.expected, item.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('webhook configuration is fail-closed and independent from AgentBus configuration', () => {
|
||||
const encryptionKey = Buffer.alloc(32, 23).toString('base64');
|
||||
const disabled = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: encryptionKey });
|
||||
assert.equal(disabled.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(disabled.agentBusEnabled, false);
|
||||
|
||||
const configured = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: webhookUrl,
|
||||
WEBHOOK_EXTERNAL_TOKEN: webhookToken
|
||||
});
|
||||
assert.equal(configured.leaderSummaryWebhookEnabled, true);
|
||||
assert.equal(configured.leaderSummaryWebhookConfigurationError, null);
|
||||
assert.equal(configured.agentBusEnabled, false);
|
||||
|
||||
const incomplete = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_EXTERNAL_TOKEN: webhookToken
|
||||
});
|
||||
assert.equal(incomplete.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(incomplete.leaderSummaryWebhookConfigurationError, 'webhook_configuration_incomplete');
|
||||
assert.equal(incomplete.agentBusEnabled, false);
|
||||
|
||||
const shortToken = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: webhookUrl,
|
||||
WEBHOOK_EXTERNAL_TOKEN: 'too-short'
|
||||
});
|
||||
assert.equal(shortToken.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(shortToken.leaderSummaryWebhookConfigurationError, 'webhook_token_length_invalid');
|
||||
|
||||
const paddedToken = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: webhookUrl,
|
||||
WEBHOOK_EXTERNAL_TOKEN: ` ${webhookToken}`
|
||||
});
|
||||
assert.equal(paddedToken.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(paddedToken.leaderSummaryWebhookConfigurationError, 'webhook_token_length_invalid');
|
||||
|
||||
const whitespaceToken = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: webhookUrl,
|
||||
WEBHOOK_EXTERNAL_TOKEN: ` ${'t'.repeat(31)}`
|
||||
});
|
||||
assert.equal(whitespaceToken.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(whitespaceToken.leaderSummaryWebhookConfigurationError, 'webhook_token_whitespace_invalid');
|
||||
|
||||
const unconfirmedRoute = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: 'https://gateway.example.test/wechat/another-route',
|
||||
WEBHOOK_EXTERNAL_TOKEN: webhookToken
|
||||
});
|
||||
assert.equal(unconfirmedRoute.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(unconfirmedRoute.leaderSummaryWebhookConfigurationError, 'webhook_route_unconfirmed');
|
||||
|
||||
const insecureProductionUrl = loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: 'http://gateway.example.test/wechat/webhookInfo/sendMessageByOut',
|
||||
WEBHOOK_EXTERNAL_TOKEN: webhookToken
|
||||
});
|
||||
assert.equal(insecureProductionUrl.leaderSummaryWebhookEnabled, false);
|
||||
assert.equal(insecureProductionUrl.leaderSummaryWebhookConfigurationError, 'webhook_https_required');
|
||||
});
|
||||
|
||||
test('client rejects blank content and malformed tokens before making a request', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl: ExternalWebhookFetch = async () => {
|
||||
calls += 1;
|
||||
return new Response('{}', { status: 200 });
|
||||
};
|
||||
await assert.rejects(
|
||||
new ExternalWebhookClient({ url: webhookUrl, token: webhookToken, fetchImpl }).send(' '),
|
||||
/must not be blank/
|
||||
);
|
||||
await assert.rejects(
|
||||
new ExternalWebhookClient({ url: webhookUrl, token: 'short', fetchImpl }).send('summary'),
|
||||
/exactly 32 non-whitespace characters/
|
||||
);
|
||||
await assert.rejects(
|
||||
new ExternalWebhookClient({ url: webhookUrl, token: ` ${'t'.repeat(31)}`, fetchImpl }).send('summary'),
|
||||
/exactly 32 non-whitespace characters/
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('invalid configuration or webhook initialization failure does not block the normal HTTP service', async () => {
|
||||
const encryptionKey = Buffer.alloc(32, 29).toString('base64');
|
||||
const configs = [
|
||||
loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_EXTERNAL_TOKEN: webhookToken,
|
||||
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
|
||||
}),
|
||||
loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
FIELD_ENCRYPTION_KEY: encryptionKey,
|
||||
WEBHOOK_SEND_URL: webhookUrl,
|
||||
WEBHOOK_EXTERNAL_TOKEN: webhookToken,
|
||||
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
|
||||
})
|
||||
];
|
||||
|
||||
for (const config of configs) {
|
||||
const { app } = await buildServer({ config, startParserLoop: false });
|
||||
try {
|
||||
const response = await app.inject({ method: 'GET', url: '/health/live' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().ok, true);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user