Files
dsh_ai1net_server/test/im-presence-ingest.test.mjs
T

277 lines
14 KiB
JavaScript
Raw Normal View History

// IM **在线态取数接线**回归测试(node:test)—— IM 线 · 第 15 棒。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// ## 覆盖策略
// ① **具名取值判据**:`'local-hub'` / `'gateway-join-leave'` / `'not-wired'` 三个取值
// 互不相同,且"没接"这件事**必须**仍然可读出来(⛔ 不许把哨兵改掉)。
// ② **接线判据**:注入端口 ⇒ `stats().presenceIngest` 具名;未注入 ⇒ 仍是哨兵。
// ③ **落到内核判据**(最重要):事件必须走进 `hub.notePresence()` ⇒ 批合并 / 收敛 /
// 帧文本 / "只推订阅该房的连接"**全部与 native 逐字相同**(本模块不自己造帧)。
// ④ **不信任外部输入**:`apply()` 丢弃并计数(不抛);`parsePresenceEvents()` 整批拒收
// (fail-closed,具名原因)。
// ⑤ **回调面结构性判据**(源码级):fail-closed 的 404 分支、常量时间比对、key 不进读数。
//
// ⚠️ 本文件**不碰网络**、**不碰 DB**(hub 用桩 store,连接用 `syntheticConnection`)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { ImHub } from '../lib/im/hub.js'
import {
PRESENCE_EVENT_KINDS,
PRESENCE_INGEST_GATEWAY,
PRESENCE_INGEST_LOCAL,
PRESENCE_INGEST_NOT_WIRED,
createHubPresenceIngest,
parsePresenceEvents,
secretEquals,
} from '../lib/im/presence-ingest.js'
import {
GATEWAY_PRESENCE_INGEST,
GATEWAY_PRESENCE_INGEST_WIRED,
createGatewayConnectionBackend,
} from '../lib/im/backends/gateway.js'
import { createNativeConnectionBackend } from '../lib/im/backends/native.js'
import { syntheticConnection } from '../lib/im/ws.js'
const ROOT = process.cwd()
const readSrc = (rel) => readFileSync(join(ROOT, rel), 'utf8')
/** 最小 store 桩(本文件不碰 send / resume 路径)。 */
const storeStub = () => ({})
/** runtime 桩(native / gateway 后端只转发它的三个方法)。 */
const runtimeStub = (over = {}) => ({
dispatch: () => false,
connectionCount: () => 0,
close: () => undefined,
...over,
})
/**
* 批窗口闸门打开的 hub:`now: () => 0` + `presenceBatchMs: 0` —— ⛔ 不依赖真实时间
* (否则 `lastFlushAt` 取真实 `Date.now` ⇒ 首帧被闸门吃掉,判据会**假绿**;第 12 棒踩过)。
*/
const hubOpenBatch = () => new ImHub(storeStub(), { now: () => 0, presenceBatchMs: 0 })
// ── 判据 ① · 具名取值 ─────────────────────────────────────────────────────────
test('P-1 · 取数路径三个取值互不相同;哨兵仍是 `not-wired`(⛔ 不许把"没接"伪装成"接了")', () => {
const all = [PRESENCE_INGEST_NOT_WIRED, PRESENCE_INGEST_LOCAL, PRESENCE_INGEST_GATEWAY]
assert.equal(new Set(all).size, 3)
assert.equal(PRESENCE_INGEST_NOT_WIRED, 'not-wired')
assert.equal(PRESENCE_INGEST_LOCAL, 'local-hub')
assert.equal(PRESENCE_INGEST_GATEWAY, 'gateway-join-leave')
// gateway 模块的导出与内核常量**同源**(⛔ 不是两处各写一遍字面量)
assert.equal(GATEWAY_PRESENCE_INGEST, PRESENCE_INGEST_NOT_WIRED)
assert.equal(GATEWAY_PRESENCE_INGEST_WIRED, PRESENCE_INGEST_GATEWAY)
assert.deepEqual(PRESENCE_EVENT_KINDS, ['join', 'leave', 'snapshot'])
})
// ── 判据 ② · 接线与否在 stats 里自曝 ──────────────────────────────────────────
test('P-2 · 未注入取数端口 ⇒ gateway 后端自曝 `not-wired`,且不挂 `presenceIngest` 字段', () => {
const backend = createGatewayConnectionBackend({ endpoint: 'http://127.0.0.1:1', key: 'k', runtime: runtimeStub() })
assert.equal(backend.stats().presenceIngest, PRESENCE_INGEST_NOT_WIRED)
assert.equal(backend.presenceIngest, undefined)
})
test('P-3 · 注入取数端口 ⇒ `stats().presenceIngest` 具名,且后端把同一端口挂出来', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub, { source: PRESENCE_INGEST_GATEWAY })
const backend = createGatewayConnectionBackend({
endpoint: 'http://127.0.0.1:1',
key: 'k',
runtime: runtimeStub(),
presence: port,
})
assert.equal(backend.stats().presenceIngest, PRESENCE_INGEST_GATEWAY)
assert.equal(backend.presenceIngest, port)
assert.equal(backend.presenceIngest.source, PRESENCE_INGEST_GATEWAY)
})
test('P-9 · native 后端如实报 `local-hub`(在线态权威就在本进程 ⇒ ⛔ 不再是 not-wired)', () => {
const hub = hubOpenBatch()
const backend = createNativeConnectionBackend({ hub, runtime: runtimeStub() })
assert.equal(backend.stats().presenceIngest, PRESENCE_INGEST_LOCAL)
})
// ── 判据 ③ · 事件真的落进内核(两条读数对照)──────────────────────────────────
test('P-4 · 两条读数对照:取数前该成员不在快照里 ⇒ 取数后为 `online`', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
// 读数 1(取数前):快照里没有任何人,在线聚合数 = 0
const before = port.snapshot('room-1')
assert.deepEqual(before, {})
assert.equal(port.onlineCount('room-1'), 0)
assert.equal(port.ledgerOnline(), 0)
// 喂两条 join(不同成员,同一房)
const r = port.apply([
{ roomId: 'room-1', memberId: 'u1', kind: 'join' },
{ roomId: 'room-1', memberId: 'u2', kind: 'join' },
])
assert.deepEqual(r, { accepted: 2, ignored: 0, rooms: 1, reason: '' })
// 读数 2(取数后)
const after = port.snapshot('room-1')
assert.deepEqual(after, { u1: 'online', u2: 'online' })
assert.equal(port.onlineCount('room-1'), 2)
assert.equal(port.ledgerOnline(), 2)
assert.notDeepEqual(before, after)
})
test('P-5 · `leave` 才是离线;`snapshot` **只做加法**(⛔ 不把"缺席"当"离开")', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
port.apply([{ roomId: 'room-1', memberId: 'u1', kind: 'join' }])
port.apply([{ roomId: 'room-1', memberId: 'u2', kind: 'snapshot' }])
assert.deepEqual(port.snapshot('room-1'), { u1: 'online', u2: 'online' })
// 显式 leave ⇒ 从**在线快照**里消失(账本记 offline,但快照只回在线 —— 与 hub 口径一致)
port.apply([{ roomId: 'room-1', memberId: 'u1', kind: 'leave' }])
assert.deepEqual(port.snapshot('room-1'), { u2: 'online' })
assert.equal(port.onlineCount('room-1'), 1)
// ⚠️ 快照只含 u2 ⇒ u1 **不因缺席被改写**(谁在线由显式 leave / join 决定)
const r = port.apply([{ roomId: 'room-1', memberId: 'u2', kind: 'snapshot' }])
assert.equal(r.accepted, 1)
assert.deepEqual(port.snapshot('room-1'), { u2: 'online' })
})
test('P-6 · 批合并 + 帧文本:一个窗口内同成员多次变更 ⇒ 每房每连接**恰 1 帧**,且只推订阅者', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
const got = []
const sub = { ...syntheticConnection('c1', 'u1'), send: (t) => (got.push(t), true) }
const other = { ...syntheticConnection('c2', 'u2'), send: () => true }
hub.attach(sub)
hub.attach(other)
hub.subscribe(sub, 'room-1', false) // ⛔ 订阅本身不记 online(否则成员数组会多一条)
// 同一批里 u2 三次变更(join → leave → join)⇒ 收敛成最后一次
const r = port.apply([
{ roomId: 'room-1', memberId: 'u2', kind: 'join' },
{ roomId: 'room-1', memberId: 'u2', kind: 'leave' },
{ roomId: 'room-1', memberId: 'u2', kind: 'join' },
])
assert.equal(r.accepted, 3)
const frames = hub.flushPresence()
assert.equal(frames, 1, '一个窗口 ⇒ 每房每连接恰 1 帧')
assert.equal(got.length, 1)
assert.deepEqual(JSON.parse(got[0]), {
op: 'presence',
roomId: 'room-1',
at: 0,
members: [{ memberId: 'u2', state: 'online', at: 0 }],
})
// ⛔ 没订阅该房的连接一帧都收不到
assert.equal(got.length, 1)
})
test('P-6b · 无人订阅该房 ⇒ 事件照收(进快照),但**不产帧**', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
port.apply([{ roomId: 'room-silent', memberId: 'u9', kind: 'join' }])
assert.equal(hub.flushPresence(), 0)
assert.deepEqual(port.snapshot('room-silent'), { u9: 'online' })
})
test('P-6c · native 口径(`ownsPresence=false`):快照 / 在线人数**直通 hub**,账本不启用', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub, { source: PRESENCE_INGEST_LOCAL })
assert.equal(port.ownsPresence, false)
// hub 里有一条真连接并订阅该房 ⇒ 快照能看到它
const conn = { ...syntheticConnection('c1', 'u1'), send: () => true }
hub.attach(conn)
hub.subscribe(conn, 'room-1', false)
assert.deepEqual(port.snapshot('room-1'), { u1: 'online' })
assert.equal(port.onlineCount('room-1'), 1)
// ⛔ 喂事件**不会**写进账本(native 的权威是 hub 自己)
port.apply([{ roomId: 'room-1', memberId: 'u9', kind: 'join' }])
assert.deepEqual(port.snapshot('room-1'), { u1: 'online' })
assert.equal(port.onlineCount('room-1'), 1)
assert.equal(port.ledgerOnline(), hub.stats().connections)
})
// ── 判据 ④ · 不信任外部输入 ───────────────────────────────────────────────────
test('P-7 · `apply()` 对畸形/超量**丢弃并计数**,⛔ 不抛(一条坏事件不得掀掉整批)', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub, { maxEvents: 2 })
const r = port.apply([
{ roomId: 'room-1', memberId: 'u1', kind: 'join' }, // 处理 1
{ roomId: '', memberId: 'u2', kind: 'join' }, // 处理 2,畸形 ⇒ 丢
{ roomId: 'room-1', memberId: 'u3', kind: 'join' }, // 超量 ⇒ 丢(上限 = 已处理条数)
])
assert.equal(r.accepted, 1)
assert.equal(r.ignored, 2)
assert.equal(r.reason, 'too-many-events')
assert.deepEqual(port.snapshot('room-1'), { u1: 'online' })
})
test('P-8 · `parsePresenceEvents()` **整批拒收**(fail-closed)+ 具名原因', () => {
assert.deepEqual(parsePresenceEvents({ events: [] }), { events: [] })
assert.deepEqual(
parsePresenceEvents({ events: [{ roomId: 'r', memberId: 'u', kind: 'join' }] }),
{ events: [{ roomId: 'r', memberId: 'u', kind: 'join' }] },
)
assert.deepEqual(parsePresenceEvents(null), { error: 'bad-body' })
assert.deepEqual(parsePresenceEvents('{"events":[]}'), { error: 'bad-body' })
assert.deepEqual(parsePresenceEvents({}), { error: 'bad-events' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: 'r', memberId: 'u' }] }), { error: 'bad-kind' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: '', memberId: 'u', kind: 'join' }] }), { error: 'bad-room-id' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: 'r', memberId: '', kind: 'join' }] }), { error: 'bad-member-id' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: 'r', memberId: 'u', kind: 'pong' }] }), { error: 'bad-kind' })
assert.deepEqual(parsePresenceEvents({ events: ['x'] }), { error: 'bad-event' })
assert.deepEqual(parsePresenceEvents({ events: [[]] }), { error: 'bad-event' })
assert.deepEqual(parsePresenceEvents([]), { error: 'bad-body' })
assert.deepEqual(
parsePresenceEvents({ events: new Array(3).fill({ roomId: 'r', memberId: 'u', kind: 'join' }) }, 2),
{ error: 'too-many-events' },
)
})
test('P-11 · `secretEquals()`:长度不等先拒、逐字符比较、空期望值恒拒', () => {
assert.equal(secretEquals('abc', 'abc'), true)
assert.equal(secretEquals('abc', 'abd'), false)
assert.equal(secretEquals('abc', 'abcd'), false)
assert.equal(secretEquals('', ''), false, '期望值为空 ⇒ 恒拒(⛔ 不许"空对空"放行)')
assert.equal(secretEquals('', 'x'), false)
})
// ── 判据 ⑤ · 回调面的结构性判据(源码级,⛔ 不靠"读代码猜")──────────────────
test('P-10 · 回调面:非 gateway 模式 / 未配 key ⇒ 恒 404;鉴别走常量时间比对;key 不进读数', () => {
const src = readSrc('src/web/routes/im.ts')
// ① fail-closed 的 404 分支存在,且判据同时含"模式"与"key 非空"
assert.match(src, /app\.post\('\/api\/im\/gateway\/presence'/)
assert.match(src, /if \(selection\.kind !== 'gateway' \|\| gatewayKey === ''\)/)
assert.match(src, /reply\.code\(404\)\.send\(\{ error: 'not-found' \}\)/)
// ② 鉴别用 secretEquals(⛔ 不是 ===)
assert.match(src, /secretEquals\(supplied, gatewayKey\)/)
assert.match(src, /x-dsh-im-gateway-key/)
// ③ key 只从 env 读,且不出现在 stats 的返回里
assert.match(src, /const gatewayKey = process\.env\[IM_GATEWAY_KEY_ENV\] \?\? ''/)
const statsBlock = src.slice(src.indexOf("app.get('/api/im/stats'"))
assert.ok(!statsBlock.includes('gatewayKey'), 'stats 读数里 ⛔ 不得出现 key')
assert.match(src, /presenceIngest: presenceIngest\.source/)
assert.match(src, /presenceOnline: presenceIngest\.ledgerOnline\(\)/)
// ⑤ 读"谁在线"的两处也走端口(⛔ 否则 gateway 模式下恒空 / 恒 0,读数会骗人)
assert.match(src, /presence: presenceIngest\.snapshot\(ctx\.roomId\)/)
assert.match(src, /online: presenceIngest\.onlineCount\(ctx\.roomId\)/)
// ④ 事件落点是 hub.notePresence(⛔ 内核不新增第二套在线态)
const ingestSrc = readSrc('src/im/presence-ingest.ts')
assert.match(ingestSrc, /hub\.notePresence\(event\.roomId, event\.memberId, state\)/)
assert.ok(!ingestSrc.includes('pendingPresence'), '⛔ 不得碰 hub 的内部结构')
})