Files
nanoclaw/src/webhook-server.test.ts
T
gavrielc ab6ab6936c feat(channels): per-instance Chat SDK state namespaces and webhook routes
ChatSdkBridgeConfig gains `instance`. The bridge keeps channelType =
adapter.name (semantic platform identity is untouched) and threads the
instance into three places:

- Registry identity: bridge.name / bridge.instance follow config.instance.
- Chat SDK state: SqliteStateAdapter takes an optional namespace and
  prefixes every key at a single choke point (k()). All bridges share the
  chat_sdk_* tables and two same-platform instances see identical
  thread/message ids — without the namespace, the SDK's
  dedupe:${adapter.name}:${message.id} key makes the second bot silently
  drop every message the first processed, locks serialize across bots, and
  subscriptions leak engagement. The namespace applies ONLY when instance
  is set AND differs from adapter.name: the default instance stays on the
  legacy UNPREFIXED keyspace byte-identically, so live installs' existing
  subscriptions/kv/locks/lists rows are never orphaned. enqueue does not
  prefix (appendToList does) — layout is ns:queue:<tid>; acquireLock
  returns the raw threadId and release/extend re-apply k() at their SQL
  sites.
- Webhook route: registerWebhookAdapter(chat, adapterName, routingPath =
  adapterName) splits the URL segment from the chat.webhooks handler key,
  so each same-platform instance gets its own URL (and signing secret).
  Signature adopted verbatim from PR #2617 (credit @davekim917's #1804
  prototype); the handler body needed zero change — dispatch already read
  entry.adapterName, not the route key.

Instance names are validated URL-safe (no '/', '?', ':' or whitespace) at
bridge construction: the route regex is [^/?]+ and ':' is the namespace
delimiter. The Chat instance's inner adapters map stays keyed adapter.name
(the SDK resolves adapters via channelId.split(':')[0] and serializes by
adapter.name) — instance identity lives entirely outside the Chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:57:33 +03:00

107 lines
3.8 KiB
TypeScript

/**
* Webhook server route/handler split tests.
*
* The route key (URL segment, `/webhook/<routingPath>`) and the handler key
* (`chat.webhooks[adapterName]`) are independent: a named adapter instance
* registers its own Chat under its own URL while dispatching to the same
* SDK adapter name. The 2-arg default keeps the historical single-instance
* route byte-identical. Conventions follow PR #2617: real HTTP server on a
* fixed WEBHOOK_PORT, real fetch.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Chat } from 'chat';
import { registerWebhookAdapter, stopWebhookServer } from './webhook-server.js';
const PORT = 3917;
const BASE = `http://127.0.0.1:${PORT}`;
/** Minimal Chat stand-in: only `webhooks` is touched by the server. */
function stubChat(tag: string, adapterName = 'slack'): { chat: Chat; calls: string[] } {
const calls: string[] = [];
const chat = {
webhooks: {
[adapterName]: async (req: Request) => {
calls.push(await req.text());
return new Response(JSON.stringify({ via: tag }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
},
} as unknown as Chat;
return { chat, calls };
}
async function post(path: string, body: string): Promise<Response> {
// The server starts listening asynchronously after registration — retry
// briefly on connection refusal instead of sleeping a fixed amount.
for (let attempt = 0; ; attempt++) {
try {
return await fetch(`${BASE}${path}`, { method: 'POST', body });
} catch (err) {
if (attempt >= 20) throw err;
await new Promise((r) => setTimeout(r, 25));
}
}
}
beforeEach(() => {
process.env.WEBHOOK_PORT = String(PORT);
});
afterEach(async () => {
await stopWebhookServer();
delete process.env.WEBHOOK_PORT;
});
describe('registerWebhookAdapter — route/handler split', () => {
it('2-arg default: /webhook/<adapterName> dispatches to chat.webhooks[adapterName]', async () => {
const { chat, calls } = stubChat('default');
registerWebhookAdapter(chat, 'slack');
const res = await post('/webhook/slack', 'payload-default');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ via: 'default' });
expect(calls).toEqual(['payload-default']);
});
it('3-arg: routes by routingPath, dispatches by adapterName; the bare route stays unregistered', async () => {
const { chat, calls } = stubChat('tester');
registerWebhookAdapter(chat, 'slack', 'slack-tester');
const res = await post('/webhook/slack-tester', 'payload-tester');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ via: 'tester' });
expect(calls).toEqual(['payload-tester']);
// Only the routed entry exists — /webhook/slack must 404, not leak into
// the named instance's Chat.
const miss = await post('/webhook/slack', 'stray');
expect(miss.status).toBe(404);
expect(calls).toEqual(['payload-tester']);
});
it('two same-adapterName registrations under distinct paths hit their own Chat instances', async () => {
const worker = stubChat('worker');
const tester = stubChat('tester');
registerWebhookAdapter(worker.chat, 'slack');
registerWebhookAdapter(tester.chat, 'slack', 'slack-tester');
const r1 = await post('/webhook/slack', 'to-worker');
const r2 = await post('/webhook/slack-tester', 'to-tester');
expect(await r1.json()).toEqual({ via: 'worker' });
expect(await r2.json()).toEqual({ via: 'tester' });
expect(worker.calls).toEqual(['to-worker']);
expect(tester.calls).toEqual(['to-tester']);
});
it('unregistered path 404s', async () => {
const { chat } = stubChat('only');
registerWebhookAdapter(chat, 'slack');
const res = await post('/webhook/nope', 'x');
expect(res.status).toBe(404);
});
});