mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
feat: add proactive delivery HTTP endpoint
This commit is contained in:
parent
31792d7bc8
commit
b90cb805c7
11 changed files with 703 additions and 294 deletions
180
test/delivery-http.test.mjs
Normal file
180
test/delivery-http.test.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DELIVERY_HTTP_PATH,
|
||||
createDeliveryHttpHandler,
|
||||
installDeliveryHttp,
|
||||
} from '../plugin-src/host/delivery-http.mjs';
|
||||
|
||||
function serviceFixture() {
|
||||
const calls = [];
|
||||
const service = {};
|
||||
for (const method of [
|
||||
'send',
|
||||
'listTargets',
|
||||
'listSuggestions',
|
||||
'createTarget',
|
||||
'updateTarget',
|
||||
'deleteTarget',
|
||||
]) {
|
||||
service[method] = async (...args) => {
|
||||
calls.push([method, ...args]);
|
||||
return method === 'send' ? { sent: true } : { method };
|
||||
};
|
||||
}
|
||||
return { service, calls };
|
||||
}
|
||||
|
||||
async function withServer(handler, callback) {
|
||||
const server = createServer(handler);
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
try {
|
||||
const address = server.address();
|
||||
await callback(`http://127.0.0.1:${address.port}${DELIVERY_HTTP_PATH}`);
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
}
|
||||
}
|
||||
|
||||
async function request(url, { method = 'POST', body, contentType = 'application/json' } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: contentType === undefined ? {} : { 'content-type': contentType },
|
||||
body,
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
allow: response.headers.get('allow'),
|
||||
body: await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
test('delivery HTTP POST forwards the exact public payload to the shared service', async () => {
|
||||
const { service, calls } = serviceFixture();
|
||||
await withServer(createDeliveryHttpHandler(service), async (url) => {
|
||||
const result = await request(url, {
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
body: JSON.stringify({
|
||||
botId: 'bot_one',
|
||||
targetId: 'daily-report',
|
||||
text: '测试消息',
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
status: 200,
|
||||
allow: null,
|
||||
body: { sent: true },
|
||||
});
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], 'send');
|
||||
assert.deepEqual(calls[0].slice(1, 4), ['bot_one', 'daily-report', '测试消息']);
|
||||
});
|
||||
|
||||
test('delivery HTTP rejects unsupported methods, media types, JSON, fields, and oversized bodies', async () => {
|
||||
const { service, calls } = serviceFixture();
|
||||
await withServer(createDeliveryHttpHandler(service), async (url) => {
|
||||
const method = await request(url, { method: 'GET' });
|
||||
assert.equal(method.status, 405);
|
||||
assert.equal(method.allow, 'POST');
|
||||
assert.equal(method.body.error.code, 'method-not-allowed');
|
||||
|
||||
const media = await request(url, { contentType: 'text/plain', body: '{}' });
|
||||
assert.equal(media.status, 415);
|
||||
assert.equal(media.body.error.code, 'unsupported-media-type');
|
||||
|
||||
const malformed = await request(url, { body: '{' });
|
||||
assert.equal(malformed.status, 400);
|
||||
assert.equal(malformed.body.error.code, 'bad-request');
|
||||
|
||||
const extra = await request(url, {
|
||||
body: JSON.stringify({
|
||||
botId: 'bot_one', targetId: 'target', text: 'hello', sessionId: 'unstable',
|
||||
}),
|
||||
});
|
||||
assert.equal(extra.status, 400);
|
||||
assert.equal(extra.body.error.code, 'bad-request');
|
||||
|
||||
const oversized = await request(url, {
|
||||
body: JSON.stringify({
|
||||
botId: 'bot_one', targetId: 'target', text: 'x'.repeat(1024 * 1024),
|
||||
}),
|
||||
});
|
||||
assert.equal(oversized.status, 413);
|
||||
assert.equal(oversized.body.error.code, 'payload-too-large');
|
||||
});
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('delivery HTTP maps only stable delivery errors to HTTP status codes', async () => {
|
||||
const expected = new Map([
|
||||
['bad-request', 400],
|
||||
['unknown-bot', 404],
|
||||
['unknown-target', 404],
|
||||
['target-conflict', 409],
|
||||
['invalid-target', 422],
|
||||
['bot-not-connected', 503],
|
||||
['target-rejected', 422],
|
||||
['delivery-failed', 502],
|
||||
['cancelled', 408],
|
||||
]);
|
||||
const { service } = serviceFixture();
|
||||
let code = 'delivery-failed';
|
||||
service.send = async () => {
|
||||
const error = new Error(`private detail for ${code}`);
|
||||
error.code = code;
|
||||
throw error;
|
||||
};
|
||||
await withServer(createDeliveryHttpHandler(service), async (url) => {
|
||||
for (const [candidate, status] of expected) {
|
||||
code = candidate;
|
||||
const result = await request(url, {
|
||||
body: JSON.stringify({ botId: 'bot_one', targetId: 'target', text: 'hello' }),
|
||||
});
|
||||
assert.equal(result.status, status);
|
||||
assert.deepEqual(result.body, {
|
||||
error: { code: candidate, message: candidate, details: {} },
|
||||
});
|
||||
}
|
||||
|
||||
code = 'private-internal-error';
|
||||
const hidden = await request(url, {
|
||||
body: JSON.stringify({ botId: 'bot_one', targetId: 'target', text: 'hello' }),
|
||||
});
|
||||
assert.equal(hidden.status, 502);
|
||||
assert.deepEqual(hidden.body, {
|
||||
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('delivery HTTP installs one exact WebServer route with Cordis lifecycle ownership', () => {
|
||||
const { service } = serviceFixture();
|
||||
const registrations = [];
|
||||
const effects = [];
|
||||
const dispose = () => {};
|
||||
const ctx = {
|
||||
webServer: {
|
||||
register(route) {
|
||||
registrations.push(route);
|
||||
return dispose;
|
||||
},
|
||||
},
|
||||
effect(factory, label) {
|
||||
effects.push(label);
|
||||
return factory();
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(installDeliveryHttp(ctx, service), dispose);
|
||||
assert.deepEqual(effects, [`dsh-im: ${DELIVERY_HTTP_PATH}`]);
|
||||
assert.equal(registrations.length, 1);
|
||||
assert.equal(registrations[0].kind, 'exact');
|
||||
assert.equal(registrations[0].path, DELIVERY_HTTP_PATH);
|
||||
assert.equal(typeof registrations[0].handler, 'function');
|
||||
});
|
||||
|
|
@ -68,6 +68,7 @@ test('Host provides #65 and installs #84 with the same delivery service', async
|
|||
};
|
||||
const provided = [];
|
||||
const rpc = [];
|
||||
const http = [];
|
||||
const channelServices = [];
|
||||
const internals = Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
applyName,
|
||||
|
|
@ -79,9 +80,12 @@ test('Host provides #65 and installs #84 with the same delivery service', async
|
|||
createDeliveryService: () => deliveryService,
|
||||
installUpdateRpc: () => {},
|
||||
installDeliveryRpc: (...args) => rpc.push(args),
|
||||
installDeliveryHttp: (...args) => http.push(args),
|
||||
});
|
||||
const ctx = {
|
||||
connection: { rpc: {} },
|
||||
webServer: { register() {} },
|
||||
effect() {},
|
||||
provide: (...args) => provided.push(args),
|
||||
};
|
||||
|
||||
|
|
@ -90,6 +94,7 @@ test('Host provides #65 and installs #84 with the same delivery service', async
|
|||
assert.equal(provided[0][0], 'dshIm');
|
||||
assert.equal(rpc[0][1], deliveryService);
|
||||
assert.deepEqual(rpc[0][2], { authority: 'trusted-host' });
|
||||
assert.equal(http[0][1], deliveryService);
|
||||
assert.ok(channelServices.every((service) => service === deliveryService));
|
||||
assert.deepEqual(await provided[0][1].listTargets('bot_one'), [{ targetId: 'target' }]);
|
||||
assert.deepEqual(await provided[0][1].send('bot_one', 'target', 'hello'), { sent: true });
|
||||
|
|
@ -164,7 +169,7 @@ test('Host waits for apiProxy on legacy Harness and Controllers on modern Harnes
|
|||
typertGateway: modern ? { stream() {} } : { invoke() {} },
|
||||
inject(dependencies, callback) {
|
||||
injections.push(dependencies);
|
||||
if (dependencies.includes('tools')) return {};
|
||||
if (dependencies.includes('tools') || dependencies.includes('webServer')) return {};
|
||||
return {
|
||||
then(resolve, reject) {
|
||||
Promise.resolve(callback(ctx)).then(resolve, reject);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue