Files
dsh_shenxian/test/im-plugin-bridge-callback.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

247 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IM 反向通道(平台 → 插件)回调总线回归测试(node:test)—— 2026-09-26「IM 反向通道棒」。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build);插件侧 SDK 直接 import 源文件(纯 ESM)。
//
// 覆盖面(接续包 §4-3 点名的五项 + 三条具名拒绝):
// ① 命中:插件回 deny ⇒ 写路径被拒(经 `attachImBridge` 复合生效)
// ② 超时 ⇒ 按 onTimeout 放行 + `bridgeTimeouts` +1
// ③ `onTimeout:'deny'` ⇒ 超时即拒(回合制/限速型的落点)
// ④ 队列满 ⇒ 丢最旧并计 `bridgeDropped`(⛔ 不无界增长)
// ⑤ 计数(poll / settle / awaiting)
// ⑥ 幂等:重复回投 ⇒ `unknown-call-id`;冒领 ⇒ `plugin-id-mismatch`
// ⑦ 异步事件:afterAppend ⇒ 入队 ⇒ poll 取到 ⇒(私聊不派发)
// ⑧ 复合后守卫不漏:只有实例内插件登记 ⇒ `hasPlugins()` 亦为真
//
// ⚠️ 本文件**不碰真实网络**(总线是内存桩;注册表只做归属判定)⇒ 判据可复现。
// 真 HTTP 端到端在 `tmp/im-bridge-e2e-20260926.mjs`(那份才是"决定性的那一项")。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createImPluginBinding } from '../lib/im/sdk/binding.js'
import { ImPluginBridgeRegistry } from '../lib/im/sdk/registry.js'
import {
attachImBridge,
bridgeEventTargets,
bridgeSpeakTargets,
ImBridgeCallbackBus,
} from '../lib/im/sdk/callback-bus.js'
const CIRCUIT = { threshold: 3, cooldownMs: 1000 }
/** 一个最小可登记清单(声明面:⛔ 不含函数)。 */
function decl(extra = {}) {
return {
pluginId: 'im_plugin_demo',
label: '示例',
packageName: '@dsh-local/im-plugin-demo',
version: '0.1.0',
roomTypes: [{ type: 'demo', label: '示例房', transport: 'chat', timeoutMs: 50, circuit: CIRCUIT }],
...extra,
}
}
/** 装一套"接线器 + 注册表 + 总线"(与 `routes/im.ts` 的装配顺序**逐字同序**)。 */
function makeWire({ busOptions = {}, manifest = decl(), instanceKey = 'u:1' } = {}) {
const binding = createImPluginBinding()
const registry = new ImPluginBridgeRegistry({ findRoom: () => ({ roomType: 'demo' }) })
const bus = new ImBridgeCallbackBus({ maxSpeakWaitMs: 300, ...busOptions })
attachImBridge(binding, { registry, bus })
const reg = registry.register(instanceKey, manifest)
assert.equal(reg.ok, true, '前置:登记必须成功')
bus.remember(instanceKey, manifest)
return { binding, registry, bus, instanceKey }
}
/** 一条待判定的发言(形状与同进程 `SpeakRuleInput` 一致)。 */
function speakInput(text = 'hello') {
return {
roomId: 'imr_1',
roomType: 'demo',
config: {},
authorId: 'u_1',
authorKind: 'human',
envelope: { payload: { kind: 'text', text }, visibility: 'room', via: 'web' },
at: 1,
}
}
function envelope(id = 'm1', visibility = 'room') {
return {
id,
seq: 1,
roomId: 'imr_1',
authorId: 'u_1',
authorKind: 'human',
via: 'web',
visibility,
payload: { kind: 'text', text: 'hi' },
ts: 1,
}
}
/** 模拟"实例内的长轮询 + 回投":取一批工作项,按 `decide` 决定怎么回。 */
async function serveOnce(instanceKey, bus, decide, { waitMs = 200, pluginId = 'im_plugin_demo' } = {}) {
const res = await bus.poll(instanceKey, { waitMs })
const handled = []
for (const item of res.items) {
const verdict = await decide(item)
const settled = bus.settle(instanceKey, pluginId, {
callId: item.callId,
ok: verdict.ok,
...(verdict.reason === undefined ? {} : { reason: verdict.reason }),
...(verdict.detail === undefined ? {} : { detail: verdict.detail }),
})
handled.push({ item, settled })
}
return handled
}
test('① 命中:插件回 deny ⇒ 写路径被拒,且原因透传', async () => {
const { binding, bus, instanceKey } = makeWire({
manifest: decl({
speakRules: [{ rule: 'turn-based', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }],
}),
})
const serving = serveOnce(instanceKey, bus, () => ({ ok: false, reason: 'outbound-denied', detail: '还没轮到你' }))
const verdict = await binding.beforeWrite(speakInput())
await serving
assert.equal(verdict.ok, false, '插件拒绝 ⇒ 写路径必须拒')
assert.equal(verdict.detail, '还没轮到你', 'detail 必须原样透传给调用方(用户要看得懂)')
assert.equal(verdict.reason, 'outbound-denied')
})
test('② 超时 ⇒ 按 onTimeout 缺省 allow 放行,且 bridgeTimeouts +1(⛔ 不静默)', async () => {
const { binding, bus } = makeWire({
manifest: decl({
speakRules: [{ rule: 'turn-based', roomTypes: ['demo'], timeoutMs: 30, circuit: CIRCUIT }],
}),
})
// 故意**没人**长轮询(= 实例不在线)
const verdict = await binding.beforeWrite(speakInput())
assert.equal(verdict.ok, true, '实例不可达 ⇒ 放行(与同进程"插件故障 ⇒ 放行"逐字一致)')
assert.equal(bus.stats().bridgeTimeouts, 1, '超时必须被计数')
assert.equal(bus.stats().awaiting, 0, '超时后不许留下挂起的等待')
})
test('③ onTimeout=deny ⇒ 超时即拒(回合制/限速型的落点)', async () => {
const { binding, bus } = makeWire({
manifest: decl({
speakRules: [{ rule: 'rate-limited', roomTypes: ['demo'], timeoutMs: 30, circuit: CIRCUIT, onTimeout: 'deny' }],
}),
})
const verdict = await binding.beforeWrite(speakInput())
assert.equal(verdict.ok, false, 'onTimeout=deny ⇒ 超时必须拒(否则超时即破规则)')
assert.equal(verdict.reason, 'timeout')
assert.equal(bus.stats().bridgeTimeouts, 1)
})
test('④ 队列满 ⇒ 丢最旧事件并计 bridgeDropped(有界,⛔ 不无界增长)', async () => {
const { registry, bus, instanceKey } = makeWire({
busOptions: { eventQueueMax: 2 },
manifest: decl({
events: [{ events: ['message.created'], roomTypes: ['demo'], timeoutMs: 50, circuit: CIRCUIT }],
}),
})
const event = { name: 'message.created', roomId: 'imr_1', roomType: 'demo', at: 1, data: { seq: 1 } }
for (const target of bridgeEventTargets(registry, bus, event)) {
bus.deliverEvent(target, event)
bus.deliverEvent(target, event)
bus.deliverEvent(target, event)
}
assert.equal(bus.stats().bridgeDropped, 1, '第 3 条入队时队列已满 ⇒ 丢最旧并计数')
const pulled = await bus.poll(instanceKey, { waitMs: 0 })
assert.equal(pulled.items.length, 2, '队列恒不超过上限')
})
test('⑤ 计数:poll / settle / awaiting 都上观测面', async () => {
const { binding, bus, instanceKey } = makeWire({
manifest: decl({
speakRules: [{ rule: 'free', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }],
}),
})
const serving = serveOnce(instanceKey, bus, () => ({ ok: true }))
const verdict = await binding.beforeWrite(speakInput())
assert.equal(verdict.ok, true)
const handled = await serving
assert.equal(handled[0].settled.accepted, true, '回投必须被接受')
const stats = bus.stats()
assert.equal(stats.polls, 1)
assert.equal(stats.settled, 1)
assert.equal(stats.awaiting, 0)
assert.equal(stats.bridgeTimeouts, 0)
})
test('⑥ 幂等与冒领:重复回投 ⇒ unknown-call-id;别的插件 ⇒ plugin-id-mismatch', async () => {
const { binding, bus, instanceKey } = makeWire({
manifest: decl({
speakRules: [{ rule: 'free', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }],
}),
})
const pending = binding.beforeWrite(speakInput())
const pulled = await bus.poll(instanceKey, { waitMs: 200 })
assert.equal(pulled.items.length, 1, '前置:同步项已入队')
const callId = pulled.items[0].callId
// 同实例、**别的**插件冒领 ⇒ 拒(防同实例内 A 插件替 B 插件作答)
const stolen = bus.settle(instanceKey, 'im_plugin_other', { callId, ok: false, reason: 'outbound-denied' })
assert.equal(stolen.accepted, false)
assert.equal(stolen.reason, 'plugin-id-mismatch')
// 正主回投 ⇒ 接受
const good = bus.settle(instanceKey, 'im_plugin_demo', { callId, ok: true })
assert.equal(good.accepted, true)
await pending
// 已结算过的 callId 再投 ⇒ 幂等拒绝(⛔ 不重复 resolve)
const again = bus.settle(instanceKey, 'im_plugin_demo', { callId, ok: true })
assert.equal(again.accepted, false)
assert.equal(again.reason, 'unknown-call-id')
})
test('⑦ 异步事件:afterAppend ⇒ 入队 ⇒ poll 取到;私聊不派发', async () => {
const { binding, bus, instanceKey, registry } = makeWire({
manifest: decl({
events: [{ events: ['message.created'], roomTypes: ['demo'], timeoutMs: 50, circuit: CIRCUIT }],
}),
})
// 房间消息 ⇒ 派发
binding.afterAppend({ envelope: envelope('m1', 'room'), roomType: 'demo' })
const pulled = await bus.poll(instanceKey, { waitMs: 0 })
assert.equal(pulled.items.length, 1)
assert.equal(pulled.items[0].kind, 'event.deliver')
assert.equal(pulled.items[0].event.name, 'message.created')
assert.equal(pulled.items[0].event.data.payload.text, 'hi')
// 私聊 ⇒ **不出门**(越权内容不派发,与 binding.ts 纪律 ② 同源)
binding.afterAppend({ envelope: envelope('m2', 'private'), roomType: 'demo' })
const none = await bus.poll(instanceKey, { waitMs: 0 })
assert.equal(none.items.length, 0, 'private 消息不许跨进程派发')
assert.equal(bridgeEventTargets(registry, bus, pulled.items[0].event).length, 1)
})
test('⑧ 复合后守卫不漏:只有实例内插件登记,hasPlugins() 亦为真', async () => {
const { binding, registry, bus, instanceKey } = makeWire({
manifest: decl({
speakRules: [{ rule: 'free', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }],
}),
})
assert.equal(binding.host.hasPlugins(), true, '写路径的守卫必须被跨进程登记点亮,否则 speak.check 永不触发')
assert.equal(bridgeSpeakTargets(registry, bus, 'demo').length, 1)
assert.equal(bridgeSpeakTargets(registry, bus, 'nobody').length, 0, '没被占用的房型不许路由')
// 注销 ⇒ 收摊(队列与登记副本一起清)
registry.unregisterInstance(instanceKey)
bus.forget(instanceKey)
assert.equal(bridgeSpeakTargets(registry, bus, 'demo').length, 0)
assert.equal(bus.stats().instances, 0)
})
test('⑨ 未接线(无任何登记)⇒ 行为与改造前逐字相同:放行、无额外等待', async () => {
const binding = createImPluginBinding()
const registry = new ImPluginBridgeRegistry({})
const bus = new ImBridgeCallbackBus()
attachImBridge(binding, { registry, bus })
assert.equal(binding.host.hasPlugins(), false, '零登记 ⇒ 守卫恒假(存量路径零开销)')
const started = Date.now()
const verdict = await binding.beforeWrite(speakInput())
assert.equal(verdict.ok, true, '无插件 ⇒ 自由并发')
assert.ok(Date.now() - started < 50, '无插件时不许引入等待')
assert.equal(bus.stats().polls, 0)
assert.equal(bus.stats().bridgeTimeouts, 0)
})