build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
548 lines
25 KiB
JavaScript
548 lines
25 KiB
JavaScript
// IM **连接层后端抽象**回归测试(node:test)—— IM 线 · 第 12 棒。
|
||
//
|
||
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号写在用例名里 ⇒
|
||
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
|
||
//
|
||
// ## 覆盖策略
|
||
// ① **等价性判据**(最重要):native 后端 / 未注入扇出端口时,`ImHub.deliver` 与
|
||
// `flushPresence` 的行为必须与第 6 棒落地时**逐字相同**(帧文本、`delivered` 口径、
|
||
// "无人订阅则不产帧")。这是"零行为变化"的可执行证据。
|
||
// ② **能力自述判据**:两种后端的 `capabilities` 逐项对照 + 结构性约束(不发心跳
|
||
// 却不声明需要垫片 ⇒ 构造即抛 —— 把 E-2 的「26 s 全断」钉成编译期/构造期约束)。
|
||
// ③ **自建保活判据**(硬实现项 ①):计时起点**必须**绑该连接自己的认证完成时刻;
|
||
// 并对"整进程统一计时"这个**结构性反面**做对照断言(不编造掉线数,只断死线差异)。
|
||
// ④ **gateway 判据**:发布一次 / 帧原样装信封 / 配置缺项与背压都**具名**且**不抛** /
|
||
// 失败只计数 / `presenceIngest` 自曝未接线。
|
||
// ⑤ **不变量判据**:`/api/im/ws` 路径常量、帧上限、`DSH_IM_WS=0` 回滚开关、
|
||
// 「后端概念不得泄漏进内核文件」的源码级断言。
|
||
//
|
||
// ⚠️ 本文件**不碰网络**(gateway 用注入的 `fetchImpl`)、**不碰 DB**(store 用桩)。
|
||
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import {
|
||
BACKEND_KINDS,
|
||
IM_BACKEND_ENV,
|
||
IM_GATEWAY_OBSERVED_IDLE_MS,
|
||
assertCapabilities,
|
||
assertKeepalivePlan,
|
||
planKeepalive,
|
||
selectConnectionBackend,
|
||
} from '../lib/im/connection-backend.js'
|
||
import {
|
||
GATEWAY_CAPABILITIES,
|
||
GATEWAY_PRESENCE_INGEST,
|
||
IM_GATEWAY_DEFAULT_CHANNEL_PREFIX,
|
||
channelFor,
|
||
createGatewayConnectionBackend,
|
||
gatewayPayloadOf,
|
||
} from '../lib/im/backends/gateway.js'
|
||
import { NATIVE_CAPABILITIES, createNativeConnectionBackend } from '../lib/im/backends/native.js'
|
||
import { ImHub } from '../lib/im/hub.js'
|
||
import { syntheticConnection } from '../lib/im/ws.js'
|
||
import { ImKeepaliveShim } from '../lib/im/keepalive.js'
|
||
|
||
const ROOT = process.cwd()
|
||
const readSrc = (rel) => readFileSync(join(ROOT, rel), 'utf8')
|
||
|
||
/** 最小 store 桩(hub 只在 `send` / `resume` 路径上用真 store;本文件不碰)。 */
|
||
const storeStub = () => ({})
|
||
/** runtime 桩(native / gateway 后端只转发它的三个方法)。 */
|
||
const runtimeStub = (over = {}) => ({
|
||
dispatch: () => false,
|
||
connectionCount: () => 0,
|
||
close: () => undefined,
|
||
...over,
|
||
})
|
||
|
||
function hubWith(options = {}) {
|
||
return new ImHub(storeStub(), options)
|
||
}
|
||
|
||
// ── 判据 1–3 · 后端选择(纯函数 ⇒ 直接断)─────────────────────────────────────
|
||
|
||
test('B-1 · 后端选择:未声明 ⇒ native/default(⛔ 不静默切到外部层)', () => {
|
||
assert.deepEqual(selectConnectionBackend({}), { kind: 'native', reason: 'default' })
|
||
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: '' }), { kind: 'native', reason: 'default' })
|
||
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: ' ' }), { kind: 'native', reason: 'default' })
|
||
})
|
||
|
||
test('B-2 · 后端选择:显式声明 ⇒ explicit(大小写不敏感)', () => {
|
||
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: 'native' }), { kind: 'native', reason: 'explicit' })
|
||
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: 'gateway' }), { kind: 'gateway', reason: 'explicit' })
|
||
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: ' GateWay ' }), { kind: 'gateway', reason: 'explicit' })
|
||
assert.deepEqual(BACKEND_KINDS, ['native', 'gateway'])
|
||
})
|
||
|
||
test('B-3 · 后端选择:不认识的值 ⇒ 退回 native 但**带回原值**(调用方必须落具名告警)', () => {
|
||
const sel = selectConnectionBackend({ [IM_BACKEND_ENV]: 'centrifugo-direct' })
|
||
assert.equal(sel.kind, 'native')
|
||
assert.equal(sel.reason, 'unknown-fallback')
|
||
assert.equal(sel.raw, 'centrifugo-direct')
|
||
})
|
||
|
||
// ── 判据 4–7 · 能力自述与结构性约束 ──────────────────────────────────────────
|
||
|
||
test('B-4 · native 能力自述:自己终结客户端、自己发 WS ping、扇出在本进程、⛔ 不需要垫片', () => {
|
||
assert.deepEqual(NATIVE_CAPABILITIES, {
|
||
terminatesClients: true,
|
||
providesServerHeartbeat: true,
|
||
fanoutOffsite: false,
|
||
needsKeepaliveShim: false,
|
||
presenceSource: 'local',
|
||
})
|
||
const backend = createNativeConnectionBackend({ hub: hubWith(), runtime: runtimeStub() })
|
||
assert.equal(backend.kind, 'native')
|
||
assert.deepEqual(backend.capabilities, NATIVE_CAPABILITIES)
|
||
})
|
||
|
||
test('B-5 · gateway 能力自述:扇出外移、**会**发协议级 ping(第 16 棒修正)⇒ 仍须声明垫片、在线态取自后端', () => {
|
||
assert.deepEqual(GATEWAY_CAPABILITIES, {
|
||
terminatesClients: false,
|
||
// 🔴 第 16 棒修正:原 `false` 出自对 `wsPingRecv=0` 的误读 —— 那条读数数的是
|
||
// **WS 控制帧**,而外部连接层发的是**协议级 JSON ping**(空对象)。改判依据 =
|
||
// 选型单 §15.11.3 / §15.11.4 在 47 就地部署实例上的三向实测(off 断 / conn 掉 0 / global 断)。
|
||
providesServerHeartbeat: true,
|
||
fanoutOffsite: true,
|
||
needsKeepaliveShim: true,
|
||
presenceSource: 'backend',
|
||
})
|
||
})
|
||
|
||
test('B-6 · 结构性约束:① 不发心跳却不说需要垫片 ⇒ 抛 ② **扇出外移却不说需要垫片 ⇒ 抛**(第 16 棒新增)③ 自相矛盾 ⇒ 抛', () => {
|
||
assert.throws(
|
||
() => assertCapabilities('native', { ...NATIVE_CAPABILITIES, providesServerHeartbeat: false, needsKeepaliveShim: false }),
|
||
/backend-without-heartbeat-needs-shim/,
|
||
)
|
||
// 🔴 第 16 棒新增:`providesServerHeartbeat` 改 `true` 后,上一条的网关分支不再生效
|
||
// (`true` + 任意 shim 值都过)⇒ 必须补这一条,否则"外移却不下发保活要求"会静默通过。
|
||
assert.throws(
|
||
() => assertCapabilities('gateway', { ...GATEWAY_CAPABILITIES, needsKeepaliveShim: false }),
|
||
/backend-fanout-offsite-needs-shim/,
|
||
)
|
||
assert.throws(
|
||
() => assertCapabilities('gateway', { ...GATEWAY_CAPABILITIES, terminatesClients: true }),
|
||
/backend-fanout-offsite-cannot-terminate-clients/,
|
||
)
|
||
// 合规的两组不抛(正向对照)
|
||
assert.doesNotThrow(() => assertCapabilities('native', NATIVE_CAPABILITIES))
|
||
assert.doesNotThrow(() => assertCapabilities('gateway', GATEWAY_CAPABILITIES))
|
||
})
|
||
|
||
test('B-7 · 保活计划:默认由实测空窗反推,锚点**只能是**每连接认证完成时刻', () => {
|
||
const plan = planKeepalive()
|
||
assert.equal(plan.idleTimeoutMs, IM_GATEWAY_OBSERVED_IDLE_MS)
|
||
assert.equal(plan.idleTimeoutMs, 26_000)
|
||
assert.equal(plan.intervalMs, Math.floor(26_000 / 3))
|
||
assert.ok(plan.intervalMs > 0 && plan.intervalMs < plan.idleTimeoutMs, '间隔必须严格小于空窗')
|
||
assert.ok(plan.intervalMs * 2 < plan.idleTimeoutMs, '丢一次 ping 也不该被判死')
|
||
assert.equal(plan.anchor, 'per-connection-auth-complete')
|
||
// 篡改锚点 / 把间隔调到 ≥ 空窗 ⇒ 必须抛(⛔ 不带病上线)
|
||
assert.throws(() => assertKeepalivePlan({ ...plan, anchor: 'process-start' }), /keepalive-anchor-invalid/)
|
||
assert.throws(() => assertKeepalivePlan({ ...plan, intervalMs: plan.idleTimeoutMs }), /keepalive-interval-invalid/)
|
||
assert.doesNotThrow(() => assertKeepalivePlan(plan))
|
||
})
|
||
|
||
// ── 判据 8–11 · 内核等价性("零行为变化"的可执行证据)─────────────────────────
|
||
|
||
test('B-8 · 等价性(native/未注入端口):`deliver` 读数 = 在线连接数;**无人订阅则不产帧**', () => {
|
||
const hub = hubWith()
|
||
const a = syntheticConnection('c1', 'u1')
|
||
const b = syntheticConnection('c2', 'u1')
|
||
hub.attach(a)
|
||
hub.attach(b)
|
||
|
||
// 无人订阅该房 ⇒ delivered/skipped 恒 0(与第 6 棒逐字相同)
|
||
const empty = hub.deliver('room-x', { id: 'm1', seq: 1 })
|
||
assert.deepEqual(empty, { roomId: 'room-x', messageId: 'm1', delivered: 0, skipped: 0 })
|
||
|
||
const sent = []
|
||
const conn = { ...syntheticConnection('c3', 'u2'), send: (t) => (sent.push(t), true) }
|
||
hub.attach(conn)
|
||
hub.subscribe(conn, 'room-1')
|
||
const r = hub.deliver('room-1', { id: 'm2', seq: 2 })
|
||
assert.equal(r.delivered, 1)
|
||
assert.equal(r.skipped, 0)
|
||
// 帧文本与改动前逐字相同(客户端只有一种解包路径)
|
||
assert.equal(sent[0], JSON.stringify({ op: 'message', roomId: 'room-1', messages: [{ id: 'm2', seq: 2 }] }))
|
||
})
|
||
|
||
test('B-9 · 等价性(native/未注入端口):`deliver` 一条消息对**同一连接只推 1 次**', () => {
|
||
const hub = hubWith()
|
||
const sent = []
|
||
const conn = { ...syntheticConnection('c1', 'u1'), send: (t) => (sent.push(t), true) }
|
||
hub.attach(conn)
|
||
hub.subscribe(conn, 'room-1')
|
||
hub.subscribe(conn, 'room-1') // 幂等:重复订阅不增加投递
|
||
const r = hub.deliver('room-1', { id: 'm1', seq: 1 })
|
||
assert.equal(r.delivered, 1)
|
||
assert.equal(sent.length, 1)
|
||
})
|
||
|
||
test('B-10 · 等价性(native/未注入端口):`flushPresence` 每房每窗口 1 帧/连接,无人订阅不产帧', () => {
|
||
// ⚠️ `now: () => 0` + `presenceBatchMs: 0`:把批窗口闸门打开且**不依赖真实时间**
|
||
// (否则 `lastFlushAt` 取真实 Date.now ⇒ 首帧被闸门吃掉,判据会假绿)。
|
||
const hub = hubWith({ now: () => 0, presenceBatchMs: 0 })
|
||
const frames1 = []
|
||
const c1 = { ...syntheticConnection('c1', 'u1'), send: (t) => (frames1.push(t), true) }
|
||
hub.attach(c1)
|
||
hub.notePresence('room-none', 'u9', 'online', 900)
|
||
assert.equal(hub.flushPresence(1000), 0, '无人订阅的房:⛔ 不产帧')
|
||
|
||
hub.subscribe(c1, 'room-1', false) // ⛔ 不因订阅本身记一次 online(否则成员数组会多一条)
|
||
hub.notePresence('room-1', 'u2', 'online', 1500)
|
||
assert.equal(hub.flushPresence(2000), 1)
|
||
assert.equal(frames1.length, 1)
|
||
assert.equal(
|
||
frames1[0],
|
||
JSON.stringify({ op: 'presence', roomId: 'room-1', at: 2000, members: [{ memberId: 'u2', state: 'online', at: 1500 }] }),
|
||
)
|
||
})
|
||
|
||
test('B-11 · 注入扇出端口(外移口径):即使本进程无人订阅也**照发**,且帧文本与 native 逐字相同', () => {
|
||
const seen = []
|
||
const hub = hubWith({
|
||
now: () => 0,
|
||
presenceBatchMs: 0,
|
||
fanout: { deliverFrame: (roomId, text) => (seen.push([roomId, text]), { delivered: 1, skipped: 0 }) },
|
||
})
|
||
// 本进程零连接
|
||
const r = hub.deliver('room-9', { id: 'm9', seq: 9 })
|
||
assert.equal(r.delivered, 1, '外移口径下必须照发(否则一条都送不出去)')
|
||
assert.equal(seen.length, 1)
|
||
assert.equal(seen[0][0], 'room-9')
|
||
assert.equal(seen[0][1], JSON.stringify({ op: 'message', roomId: 'room-9', messages: [{ id: 'm9', seq: 9 }] }))
|
||
// presence 也走端口
|
||
hub.notePresence('room-9', 'u1', 'online', 4000)
|
||
assert.equal(hub.flushPresence(5000), 1)
|
||
assert.equal(seen.length, 2)
|
||
assert.match(seen[1][1], /"op":"presence"/)
|
||
})
|
||
|
||
// ── 判据 12–16 · 自建保活垫片(硬实现项 ①)──────────────────────────────────
|
||
|
||
test('B-12 · 保活锚点:两条相隔 5000 ms 认证的连接 ⇒ 死线之差**恰为** 5000 ms(⛔ 不是同一条死线)', () => {
|
||
const pings = []
|
||
const shim = new ImKeepaliveShim({ sink: { ping: (id) => pings.push(id) }, now: () => 0, sweepMs: 3600_000 })
|
||
try {
|
||
shim.start('c-early', 1000)
|
||
shim.start('c-late', 6000)
|
||
const [early, late] = shim.anchors()
|
||
assert.equal(early.connId, 'c-early')
|
||
assert.equal(late.connId, 'c-late')
|
||
assert.equal(early.anchorAt, 1000)
|
||
assert.equal(late.anchorAt, 6000)
|
||
assert.equal(late.nextPingAt - early.nextPingAt, 5000, '死线必须跟着各连接自己的认证时刻走')
|
||
assert.equal(early.nextPingAt, 1000 + shim.planRef.intervalMs)
|
||
assert.equal(late.nextPingAt, 6000 + shim.planRef.intervalMs)
|
||
} finally {
|
||
shim.close()
|
||
}
|
||
})
|
||
|
||
test('B-13 · 保活锚点(结构性反面):整进程统一计时会给**同一条死线** —— 两种模型必须可区分', () => {
|
||
// 对照模型(**不是**本实现,只用来证明"两种模型不同"):死线由进程起点决定,与认证时刻无关。
|
||
const sharedModel = (t0) => (authTimes) => authTimes.map(() => t0 + 1)
|
||
const perConn = (authTimes, interval) => authTimes.map((t) => t + interval)
|
||
const authTimes = [1000, 6000, 11000]
|
||
assert.equal(new Set(sharedModel(0)(authTimes)).size, 1, '统一计时 ⇒ 死线全相等(这正是 E-2 的缺陷形态)')
|
||
assert.equal(new Set(perConn(authTimes, 8000)).size, 3, '每连接计时 ⇒ 死线各不相同')
|
||
|
||
// 本实现落在"每连接"那一侧
|
||
const shim = new ImKeepaliveShim({ sink: { ping: () => undefined }, now: () => 0, sweepMs: 3600_000 })
|
||
try {
|
||
for (const t of authTimes) shim.start(`c-${t}`, t)
|
||
assert.equal(new Set(shim.anchors().map((a) => a.nextPingAt)).size, 3)
|
||
} finally {
|
||
shim.close()
|
||
}
|
||
})
|
||
|
||
test('B-14 · 保活节奏:到点发 ping、每连接各自计数;入向流量刷新 lastSeen ⇒ 不误判死', () => {
|
||
const pings = []
|
||
const shim = new ImKeepaliveShim({ sink: { ping: (id) => pings.push(id) }, now: () => 0, sweepMs: 3600_000 })
|
||
try {
|
||
const iv = shim.planRef.intervalMs
|
||
shim.start('c1', 0)
|
||
shim.tick(iv - 1)
|
||
assert.equal(pings.length, 0, '未到死线不发')
|
||
shim.tick(iv)
|
||
assert.deepEqual(pings, ['c1'])
|
||
shim.tick(iv * 2)
|
||
assert.deepEqual(pings, ['c1', 'c1'])
|
||
assert.equal(shim.anchors()[0].pingsSent, 2)
|
||
// 持续有入向流量 ⇒ 永不过期
|
||
shim.noteSeen('c1', iv * 2)
|
||
shim.tick(iv * 2 + shim.planRef.idleTimeoutMs - 1)
|
||
assert.equal(shim.stats().expired, 0)
|
||
assert.equal(shim.size(), 1)
|
||
} finally {
|
||
shim.close()
|
||
}
|
||
})
|
||
|
||
test('B-15 · 保活判死:静默超过空窗 ⇒ 恰好 expire 一次(⛔ 不重复计数)+ 报出 idleMs', () => {
|
||
const expired = []
|
||
const shim = new ImKeepaliveShim({
|
||
sink: { ping: () => undefined },
|
||
now: () => 0,
|
||
sweepMs: 3600_000,
|
||
onExpire: (id, idleMs) => expired.push([id, idleMs]),
|
||
})
|
||
try {
|
||
shim.start('c1', 0)
|
||
shim.tick(shim.planRef.idleTimeoutMs) // 恰好等于空窗 ⇒ 不超 ⇒ 不判死
|
||
assert.equal(expired.length, 0)
|
||
shim.tick(shim.planRef.idleTimeoutMs + 1)
|
||
assert.equal(expired.length, 1)
|
||
assert.equal(expired[0][0], 'c1')
|
||
assert.equal(expired[0][1], shim.planRef.idleTimeoutMs + 1)
|
||
shim.tick(shim.planRef.idleTimeoutMs + 5000)
|
||
assert.equal(expired.length, 1, '已回收的连接不得重复计数')
|
||
assert.equal(shim.size(), 0)
|
||
assert.equal(shim.stats().expired, 1)
|
||
} finally {
|
||
shim.close()
|
||
}
|
||
})
|
||
|
||
test('B-16 · 保活收尾:stop/close 幂等,close 之后 tick 不再动', () => {
|
||
const pings = []
|
||
const shim = new ImKeepaliveShim({ sink: { ping: (id) => pings.push(id) }, now: () => 0, sweepMs: 3600_000 })
|
||
shim.start('c1', 0)
|
||
shim.start('c2', 0)
|
||
shim.stop('c1')
|
||
assert.equal(shim.size(), 1)
|
||
shim.stop('c1')
|
||
assert.equal(shim.size(), 1, 'stop 幂等')
|
||
shim.close()
|
||
assert.equal(shim.size(), 0)
|
||
shim.tick(shim.planRef.intervalMs * 10)
|
||
assert.equal(pings.length, 0, 'close 之后不再发 ping')
|
||
})
|
||
|
||
// ── 判据 17–22 · gateway 后端 ────────────────────────────────────────────────
|
||
|
||
test('B-17 · 频道名归一:前缀允许 `:`(命名空间分隔符),房间 id **只放行** `[A-Za-z0-9_-]`', () => {
|
||
assert.equal(channelFor('imr_abc-123', 'im-room-'), 'im-room-imr_abc-123')
|
||
// 前缀里的 `:` 与 `.` 保留(否则运维没法按命名空间配置);空格仍被换掉
|
||
assert.equal(channelFor('room/with:bad chars', 'p x:'), 'p_x:room_with_bad_chars')
|
||
assert.equal(channelFor('imr_1', 'im:'), 'im:imr_1')
|
||
// 🔴 房间 id 里的 `:` 必须被挡掉 —— 否则能借它把帧推进**另一个命名空间**(越权面)
|
||
assert.equal(channelFor('imr_1:other-ns', 'im:'), 'im:imr_1_other-ns')
|
||
assert.equal(channelFor('r1'), `${IM_GATEWAY_DEFAULT_CHANNEL_PREFIX}r1`)
|
||
})
|
||
|
||
test('B-18 · 帧格式不变:应用层帧**原样**装进 `{frame}`(客户端 parse 出来与 native 逐字相同)', () => {
|
||
const text = JSON.stringify({ op: 'message', roomId: 'r', messages: [{ id: 'm' }] })
|
||
const payload = gatewayPayloadOf(text)
|
||
assert.deepEqual(payload, { frame: text })
|
||
assert.equal(JSON.parse(JSON.stringify(payload)).frame, text)
|
||
})
|
||
|
||
test('B-19 · gateway 配置缺项:**具名降级**且不抛 —— `{0,1}` + `degraded` + 只警告一次', () => {
|
||
const warns = []
|
||
const noFetch = () => {
|
||
throw new Error('不该发网络请求')
|
||
}
|
||
const backend = createGatewayConnectionBackend({
|
||
endpoint: '',
|
||
runtime: runtimeStub(),
|
||
fetchImpl: noFetch,
|
||
log: { info: () => undefined, warn: (f, m) => warns.push([m, f]) },
|
||
})
|
||
assert.deepEqual(backend.deliverFrame('r1', 'x'), { delivered: 0, skipped: 1 })
|
||
assert.deepEqual(backend.deliverFrame('r1', 'x'), { delivered: 0, skipped: 1 })
|
||
assert.equal(backend.stats().degraded, 'missing-endpoint')
|
||
assert.equal(backend.stats().publishes, 0)
|
||
assert.equal(warns.filter((w) => w[0] === 'im_gateway_not_ready').length, 1, '⛔ 不刷屏')
|
||
// 有 URL 无 KEY ⇒ 另一个具名原因
|
||
const backend2 = createGatewayConnectionBackend({ endpoint: 'http://127.0.0.1:1', runtime: runtimeStub(), fetchImpl: noFetch })
|
||
backend2.deliverFrame('r1', 'x')
|
||
assert.equal(backend2.stats().degraded, 'missing-key')
|
||
})
|
||
|
||
test('B-20 · gateway 发布:一次 `deliverFrame` ⇒ 恰一次调用,频道与信封正确,计数 +1', async () => {
|
||
const calls = []
|
||
const backend = createGatewayConnectionBackend({
|
||
endpoint: 'http://gw.test:9/',
|
||
key: 'k-1',
|
||
runtime: runtimeStub(),
|
||
fetchImpl: async (url, init) => {
|
||
calls.push({ url, init })
|
||
return { ok: true, status: 200, text: async () => '' }
|
||
},
|
||
})
|
||
const text = JSON.stringify({ op: 'message', roomId: 'imr_1', messages: [{ id: 'm' }] })
|
||
assert.deepEqual(backend.deliverFrame('imr_1', text), { delivered: 1, skipped: 0 })
|
||
assert.equal(calls.length, 1)
|
||
assert.equal(calls[0].url, 'http://gw.test:9/api/publish', '尾斜杠必须被剥掉')
|
||
assert.equal(calls[0].init.headers.authorization, 'apikey k-1')
|
||
const body = JSON.parse(calls[0].init.body)
|
||
assert.equal(body.channel, 'im-room-imr_1')
|
||
assert.deepEqual(body.data, { frame: text })
|
||
// 发布是**异步受理** ⇒ 等一拍再读计数
|
||
await new Promise((r) => setImmediate(r))
|
||
const s = backend.stats()
|
||
assert.equal(s.publishes, 1)
|
||
assert.equal(s.publishFailures, 0)
|
||
assert.equal(s.inflight, 0)
|
||
assert.ok(s.lastPublishMs >= 0)
|
||
assert.equal(s.hasKey, true)
|
||
assert.equal(s.endpoint, 'http://gw.test:9')
|
||
assert.equal(s.degraded, '')
|
||
})
|
||
|
||
test('B-21 · gateway 发布失败:只计数 + 具名日志,**⛔ 不抛**(一条坏消息不得掀掉房间)', async () => {
|
||
const warns = []
|
||
const backend = createGatewayConnectionBackend({
|
||
endpoint: 'http://gw.test:9',
|
||
key: 'k',
|
||
runtime: runtimeStub(),
|
||
log: { info: () => undefined, warn: (f, m) => warns.push([m, f]) },
|
||
fetchImpl: async () => ({ ok: false, status: 502, text: async () => 'bad gateway' }),
|
||
})
|
||
assert.doesNotThrow(() => backend.deliverFrame('r1', 'x'))
|
||
await new Promise((r) => setImmediate(r))
|
||
assert.equal(backend.stats().publishFailures, 1)
|
||
assert.equal(backend.stats().lastError, 'http-502')
|
||
assert.ok(warns.some((w) => w[0] === 'im_gateway_publish_failed'))
|
||
|
||
const backend2 = createGatewayConnectionBackend({
|
||
endpoint: 'http://gw.test:9',
|
||
key: 'k',
|
||
runtime: runtimeStub(),
|
||
fetchImpl: async () => {
|
||
throw new Error('ECONNREFUSED')
|
||
},
|
||
})
|
||
assert.doesNotThrow(() => backend2.deliverFrame('r1', 'x'))
|
||
await new Promise((r) => setImmediate(r))
|
||
assert.equal(backend2.stats().publishFailures, 1)
|
||
assert.match(backend2.stats().lastError, /ECONNREFUSED/)
|
||
})
|
||
|
||
test('B-22 · gateway 背压:在途满 ⇒ 立刻 `{0,1}` + `publishDropped`(⛔ 不无限堆积)', async () => {
|
||
let release
|
||
const gate = new Promise((r) => {
|
||
release = r
|
||
})
|
||
const backend = createGatewayConnectionBackend({
|
||
endpoint: 'http://gw.test:9',
|
||
key: 'k',
|
||
runtime: runtimeStub(),
|
||
maxInflight: 1,
|
||
fetchImpl: async () => {
|
||
await gate
|
||
return { ok: true, status: 200, text: async () => '' }
|
||
},
|
||
})
|
||
assert.deepEqual(backend.deliverFrame('r1', 'a'), { delivered: 1, skipped: 0 })
|
||
assert.deepEqual(backend.deliverFrame('r1', 'b'), { delivered: 0, skipped: 1 })
|
||
assert.equal(backend.stats().publishDropped, 1)
|
||
assert.equal(backend.stats().degraded, 'backpressure')
|
||
assert.equal(backend.stats().inflight, 1)
|
||
release()
|
||
await new Promise((r) => setImmediate(r))
|
||
assert.equal(backend.stats().inflight, 0)
|
||
assert.equal(backend.stats().publishes, 1)
|
||
})
|
||
|
||
test('B-23 · gateway 通道与就绪:平台侧 `/api/im/ws` 保留(dispatch 同一函数对象)、`health()` 缺配置不发请求', async () => {
|
||
const dispatch = () => true
|
||
let fetchCount = 0
|
||
const backend = createGatewayConnectionBackend({
|
||
endpoint: 'http://gw.test:9',
|
||
key: 'k',
|
||
runtime: runtimeStub({ dispatch, connectionCount: () => 7 }),
|
||
fetchImpl: async () => {
|
||
fetchCount += 1
|
||
return { ok: true, status: 200, text: async () => '{}' }
|
||
},
|
||
})
|
||
assert.equal(backend.dispatch, dispatch, '两种后端的 dispatch 都来自 runtime(实例内通道 + 回滚路径)')
|
||
assert.equal(backend.connectionCount(), 7)
|
||
assert.equal(backend.stats().presenceIngest, GATEWAY_PRESENCE_INGEST)
|
||
assert.equal(GATEWAY_PRESENCE_INGEST, 'not-wired')
|
||
|
||
const missing = createGatewayConnectionBackend({ endpoint: '', runtime: runtimeStub(), fetchImpl: async () => ({ ok: true, status: 200, text: async () => '' }) })
|
||
assert.deepEqual(await missing.health(), { ok: false, reason: 'missing-endpoint' })
|
||
assert.equal(fetchCount, 0, '缺配置的健康检查不得发网络请求')
|
||
|
||
const ok = await backend.health()
|
||
assert.equal(ok.ok, true)
|
||
assert.equal(fetchCount, 1, 'health() ⛔ 不改任何发设计数')
|
||
assert.equal(backend.stats().publishes, 0)
|
||
})
|
||
|
||
test('B-24 · native 后端:dispatch / connectionCount / close 全部转发给 runtime(纯包装)', () => {
|
||
const dispatch = () => false
|
||
let closed = 0
|
||
const runtime = runtimeStub({ dispatch, connectionCount: () => 3, close: () => (closed += 1) })
|
||
const backend = createNativeConnectionBackend({ hub: hubWith(), runtime })
|
||
assert.equal(backend.dispatch, dispatch)
|
||
assert.equal(backend.connectionCount(), 3)
|
||
assert.equal(backend.stats().keepaliveShim, false)
|
||
backend.close()
|
||
assert.equal(closed, 1)
|
||
})
|
||
|
||
// ── 判据 25–28 · 不变量(协议 / 开关 / 概念边界)─────────────────────────────
|
||
|
||
test('B-25 · 协议不变量:`/api/im/ws` 路径与帧上限常量一字未变', async () => {
|
||
const hub = await import('../lib/im/hub.js')
|
||
assert.equal(hub.IM_WS_PATH, '/api/im/ws')
|
||
assert.equal(hub.IM_API_PREFIX, '/api/im/')
|
||
assert.equal(hub.IM_HEARTBEAT_MS, 25_000)
|
||
assert.equal(hub.IM_PRESENCE_BATCH_MS, 1000)
|
||
const ws = await import('../lib/im/ws.js')
|
||
assert.equal(ws.IM_MAX_FRAME_BYTES, 1 << 20)
|
||
assert.equal(ws.IM_MAX_PAYLOAD_BYTES, 64 << 10)
|
||
})
|
||
|
||
test('B-26 · 回滚开关不变量:`DSH_IM_WS=0` 的早退仍在,且仍在插件最早处', () => {
|
||
const src = readSrc('src/web/routes/im.ts')
|
||
assert.match(src, /process\.env\.DSH_IM_WS === '0'/, '回滚开关必须保留')
|
||
const idxGuard = src.indexOf("DSH_IM_WS === '0'")
|
||
// ⚠️ 必须找**调用点**而不是名字 —— `import { createNativeConnectionBackend }` 在文件顶部,
|
||
// 按名字搜会误判成"后端在开关之前建"(首版踩过)。
|
||
const idxBackend = src.indexOf('createNativeConnectionBackend({ hub')
|
||
assert.ok(idxGuard > 0 && idxBackend > idxGuard, '开关必须在建后端之前 ⇒ 关掉时一行都不装')
|
||
})
|
||
|
||
test('B-27 · 概念边界:内核文件里**不得出现**外部连接层的产品名 / 专属配置键', () => {
|
||
// ⚠️ 必须带词边界:`openImPg(` 里含子串 "openim" ⇒ 裸 /openim/i 会假红(首版踩过)。
|
||
const banned = /\b(?:centrifugo|centrifuge|ejabberd|openim)\b/i
|
||
for (const f of [
|
||
'src/im/hub.ts',
|
||
'src/im/ws.ts',
|
||
'src/im/store.ts',
|
||
'src/im/types.ts',
|
||
'src/im/clock.ts',
|
||
'src/im/db.ts',
|
||
'src/im/agent-bridge.ts',
|
||
'src/im/instance-token.ts',
|
||
'src/im/connection-backend.ts',
|
||
'src/im/keepalive.ts',
|
||
'src/im/backends/native.ts',
|
||
'src/im/backends/gateway.ts',
|
||
]) {
|
||
assert.doesNotMatch(readSrc(f), banned, `${f} 不得泄漏外部组件概念(命名必须中性)`)
|
||
}
|
||
// 参考部署的专属配置只允许出现在 poc/ 与文档里
|
||
assert.doesNotMatch(readSrc('src/web/routes/im.ts'), /centrifugo|centrifuge/i)
|
||
})
|
||
|
||
test('B-28 · 文件名中性:`backends/` 下只有 native 与 gateway 两个后端实现', () => {
|
||
const native_ = readSrc('src/im/backends/native.ts')
|
||
const gateway = readSrc('src/im/backends/gateway.ts')
|
||
assert.match(native_, /export const NATIVE_CAPABILITIES/)
|
||
assert.match(gateway, /export const GATEWAY_CAPABILITIES/)
|
||
// 接口单一定义源:扇出端口只在 hub.ts 定义,connection-backend 只再导出
|
||
const cb = readSrc('src/im/connection-backend.ts')
|
||
assert.match(cb, /export type \{ ImFanoutPort, ImFanoutResult \}/)
|
||
assert.doesNotMatch(cb, /export interface ImFanoutPort/)
|
||
})
|