247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
// 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)
|
||
})
|