Files
dsh_shenxian/test/im-store.test.mjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(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/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

572 lines
30 KiB
JavaScript
Raw Permalink 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)—— A 单 `交接单/IM群组-A-房间内核与DB.md` §六 + §六.1。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// 覆盖面:迁移 v14 / 幂等 · 内容哈希去重 · 乱序收敛 · 游标补拉 · 权限 · 写放大 ·
// 归属列 NOT NULL · 区字段预留 · 无 OFFSET(走索引)· 预算降级 · 慢查询标记 ·
// `?`→`$n` 翻译。
// ⚠️ Postgres 段自跳过:未设 `DSHS_TEST_DB_URL` 即 skip(与 `test/db.test.mjs` 同约定)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { openImSqlite, openImPg, toPgPlaceholders } from '../lib/im/db.js'
import { ImStore, MAX_LIST_LIMIT, SLOW_QUERY_MARKER } from '../lib/im/store.js'
import { RoomClock, mergeMessages, orderDrafts, orderMessages } from '../lib/im/clock.js'
import { canonicalJson, messageId } from '../lib/im/types.js'
const PG_URL = process.env.DSHS_TEST_DB_URL
/** 打开一个内存库 + 内核,并在用例结束时关库。 */
function freshStore(t, options = {}) {
const db = openImSqlite(':memory:')
t.after(() => db.close())
return { db, store: new ImStore(db, options) }
}
/** 计数端口:记录每次 `INSERT`(判据 6 / 15 的写放大断言靠它)。 */
function countingDb(db) {
const counts = { inserts: 0, messageInserts: 0, calls: [] }
return {
counts,
db: {
dialect: db.dialect,
all: (sql, params) => {
counts.calls.push(sql.trim().split(/\s+/).slice(0, 3).join(' '))
return db.all(sql, params)
},
run: (sql, params) => {
const upper = sql.trim().toUpperCase()
counts.calls.push(upper.split(/\s+/).slice(0, 3).join(' '))
if (upper.startsWith('INSERT')) {
counts.inserts += 1
if (/INSERT\s+INTO\s+MESSAGES/.test(upper)) counts.messageInserts += 1
}
return db.run(sql, params)
},
exec: (sql) => db.exec(sql),
close: () => db.close(),
},
}
}
/** 建一个房里塞 n 个成员的脚手架。 */
async function makeRoom(store, roomId, ownerId = 'u-owner') {
await store.upsertRoom({ id: roomId, roomType: 'group', name: roomId, ownerId })
await store.addMember({ roomId, memberId: ownerId, kind: 'human', role: 'owner' })
return roomId
}
// ── 判据 1 / 19:迁移 v14 落地 + 幂等 ────────────────────────────────────────
//
// ⚠️ 计数断言从「= 14」改成「≥ 14 且**含** v14」:后续单子允许加迁移
//(v15 = 插件数据面台账),而本条的**本意**是「v14 落地了 + 既有迁移没被改」。
// ⛔ 不要把下界改成等号(那会把后续合法迁移卡红);也⛔不要删掉 v14 的存在性断言。
test('判据1/19 迁移:新库跑到 v14;同一文件重开版本不变(幂等)', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'im-v14-'))
const path = join(dir, 'control.db')
t.after(() => rmSync(dir, { recursive: true, force: true }))
const first = openImSqlite(path)
const rowsFirst = await first.all('SELECT version FROM schema_migrations ORDER BY version')
await first.close()
assert.ok(rowsFirst.length >= 14, `至少 14 条迁移记录(实际 ${rowsFirst.length})`)
assert.equal(
rowsFirst.some((r) => Number(r.version) === 14),
true,
'v14(A 单三张表)必须已应用',
)
assert.equal(Number(rowsFirst.at(0).version), 1, '首条仍 = v1(⛔ 未改既有迁移)')
// 迁移号必须**连续无洞**(新增只许追加,⛔ 不许跳号/插号)。
const versions = rowsFirst.map((r) => Number(r.version))
assert.deepEqual(
versions,
Array.from({ length: versions.length }, (_, i) => i + 1),
'迁移号连续(1..N,无跳号)',
)
const second = openImSqlite(path)
const rowsSecond = await second.all('SELECT version FROM schema_migrations')
const tables = await second.all("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
await second.close()
// 幂等的**本意**是"重开不再登记一遍" ⇒ 判据用**首开 vs 重开的计数相等**,
// ⛔ 不要写死数字(写死会把后续合法迁移卡红,且完全没验到幂等性)。
assert.equal(rowsSecond.length, rowsFirst.length, '重开不重复登记(幂等)')
for (const name of ['rooms', 'room_members', 'messages']) {
assert.ok(tables.some((r) => r.name === name), `表 ${name} 存在`)
}
})
test('判据1 三表已建但⛔ 无插件前缀(内核表用保留名)', async (t) => {
const { db } = freshStore(t)
const rows = await db.all("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
const names = rows.map((r) => r.name)
assert.ok(names.includes('rooms') && names.includes('room_members') && names.includes('messages'))
assert.equal(names.filter((n) => n.startsWith('p_')).length, 0, '⛔ 无 p_<pluginId>_ 前缀表')
})
// ── 判据 2 / 9:内容哈希去重 ────────────────────────────────────────────────
test('判据2/9 去重:同房同作者同 payload 重投 3 次 ⇒ messages 只 1 行,id 稳定', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
const payload = { text: 'hello', n: 1 }
const a = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
const b = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
const c = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
assert.equal(a.inserted, true, '首次 = 真插入')
assert.equal(b.inserted, false, '第二次 = 命中去重')
assert.equal(c.inserted, false, '第三次 = 命中去重')
assert.equal(await store.countMessages('r1'), 1, '恰好 1 行(三表新增 1 行)')
assert.equal(a.message.id, b.message.id, 'id 稳定')
assert.equal(a.message.id, c.message.id, 'id 稳定')
assert.equal(a.message.id, messageId(payload, 'u-owner'), 'id = 内容哈希')
assert.equal(a.message.id.length, 64, 'sha256 hex')
assert.equal(a.message.seq, b.message.seq, '命中去重返回既有 seq')
})
test('判据2 自决项:不同作者同 payload ⇒ 各自成行(⛔ 不互相吞)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.addMember({ roomId: 'r1', memberId: 'bot-1', kind: 'bot' })
const payload = { text: '收到' }
const h = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
const b = await store.appendMessage({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', payload })
assert.notEqual(h.message.id, b.message.id, 'id 含作者 ⇒ 不同作者不同 id')
assert.equal(await store.countMessages('r1'), 2, '两条各自的同文消息都留下')
})
test('判据2 id 与键序无关:payload 键序不同 ⇒ 同 id', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
const one = { a: 1, b: { d: 4, c: 3 } }
const two = { b: { c: 3, d: 4 }, a: 1 }
assert.equal(canonicalJson(one), canonicalJson(two), 'canonicalJson 递归排序键')
const first = await store.appendMessage({ roomId: 'r1', authorId: 'u', authorKind: 'human', payload: one })
const second = await store.appendMessage({ roomId: 'r1', authorId: 'u', authorKind: 'human', payload: two })
assert.equal(first.message.id, second.message.id)
assert.equal(second.inserted, false, '键序不同但内容相同 ⇒ 仍去重')
})
// ── 判据 3 / 10:乱序收敛到同一顺序 ───────────────────────────────────────
test('判据3/10 收敛:同一草稿集合的 3 种到达序 ⇒ orderDrafts 输出同一个数组', async () => {
const drafts = [
{ id: 'm1', roomId: 'r1', ts: 1000, authorId: 'a', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 1 }, lamport: 3 },
{ id: 'm2', roomId: 'r1', ts: 1200, authorId: 'b', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 2 }, lamport: 1 },
{ id: 'm3', roomId: 'r1', ts: 1100, authorId: 'a', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 3 }, lamport: 2 },
{ id: 'm4', roomId: 'r1', ts: 900, authorId: 'b', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 4 }, lamport: 4 },
]
const orders = [
drafts,
[...drafts].reverse(),
[drafts[2], drafts[0], drafts[3], drafts[1]],
]
const out = orders.map((o) => orderDrafts(o).map((d) => d.id))
assert.deepEqual(out[0], ['m2', 'm3', 'm1', 'm4'], '规范序 = lamport 升序')
assert.deepEqual(out[1], out[0], '逆序到达 ⇒ 同一个数组')
assert.deepEqual(out[2], out[0], '第三种到达序 ⇒ 同一个数组')
})
test('判据3 收敛(落库往返):同一批消息以 2 种到达序落库 ⇒ 规范序同一个数组', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'rA')
await makeRoom(store, 'rB')
const lamportOf = new Map()
const batch = []
for (let i = 1; i <= 6; i += 1) {
const payload = { t: i }
const id = messageId(payload, i % 2 === 0 ? 'w-a' : 'w-b')
lamportOf.set(id, i)
batch.push({ payload, authorId: i % 2 === 0 ? 'w-a' : 'w-b' })
}
await store.addMember({ roomId: 'rA', memberId: 'w-a' })
await store.addMember({ roomId: 'rB', memberId: 'w-a' })
await store.addMember({ roomId: 'rA', memberId: 'w-b' })
await store.addMember({ roomId: 'rB', memberId: 'w-b' })
const write = async (roomId, order) => {
for (const i of order) {
const m = batch[i]
await store.appendMessage({ roomId, authorId: m.authorId, authorKind: 'human', payload: m.payload })
}
const rows = await store.listMessages(roomId, 0, 100)
// 落库后的行没有 lamport 列 ⇒ 用写入前的映射还原草稿,再喂给规范序函数。
return orderDrafts(
rows.map((r) => ({
id: r.id, roomId: r.roomId, ts: r.ts, authorId: r.authorId, authorKind: r.authorKind,
via: r.via, visibility: r.visibility, payload: r.payload, lamport: lamportOf.get(r.id),
})),
).map((d) => d.id)
}
const forward = await write('rA', [0, 1, 2, 3, 4, 5])
const backward = await write('rB', [5, 4, 3, 2, 1, 0])
assert.deepEqual(backward, forward, '两种到达序 ⇒ 规范化后同一个数组')
// 到达序只改变 seq 的**分配**,不改变集合与单调性(§四-6 既定设计)。
const seqA = (await store.listMessages('rA', 0, 100)).map((m) => m.seq)
const seqB = (await store.listMessages('rB', 0, 100)).map((m) => m.seq)
assert.deepEqual(seqA, [1, 2, 3, 4, 5, 6], '恒为紧凑 1..N')
assert.deepEqual(seqB, [1, 2, 3, 4, 5, 6], '另一到达序同样紧凑')
const idsA = new Set((await store.listMessages('rA', 0, 100)).map((m) => m.id))
const idsB = new Set((await store.listMessages('rB', 0, 100)).map((m) => m.id))
assert.deepEqual([...idsB].sort(), [...idsA].sort(), 'id 集合相等')
})
test('判据3 listMessages 恒按 seq 严格递增(乱序投递后)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.addMember({ roomId: 'r1', memberId: 'w2' })
const clock = new RoomClock()
const payloads = [3, 1, 4, 1, 5, 9, 2, 6]
for (const n of payloads) {
const lamport = clock.issue('r1')
await store.appendMessage({
roomId: 'r1', authorId: n % 2 === 0 ? 'u-owner' : 'w2', authorKind: 'human',
payload: { n, seen: `${payloads.join(',')}` }, lamport,
})
}
const list = await store.listMessages('r1', 0, 100)
for (let i = 1; i < list.length; i += 1) {
assert.ok(list[i].seq > list[i - 1].seq, 'seq 严格递增')
}
assert.deepEqual(orderMessages(list).map((m) => m.id), list.map((m) => m.id), '已是权威序')
})
// ── 判据 4 / 11 / 14:游标补拉,不多不少 ────────────────────────────────────
test('判据4/11/14 补拉:断线期写 100 条 ⇒ 带 since=K 恰好补 100−K 条,无重复', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
for (let i = 1; i <= 100; i += 1) {
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i } })
}
assert.equal(await store.latestSeq('r1'), 100, '100 条 ⇒ 最大 seq = 100')
const K = 40
const backlog = await store.listMessages('r1', K, 500)
assert.equal(backlog.length, 100 - K, '恰好 100−K 条')
assert.equal(backlog[0].seq, K + 1, '首条 = K+1')
assert.equal(backlog.at(-1).seq, 100, '末条 = 100')
const again = await store.listMessages('r1', K, 500)
assert.deepEqual(again.map((m) => m.id), backlog.map((m) => m.id), '重复拉取幂等(无重复无遗漏)')
assert.equal(new Set(backlog.map((m) => m.id)).size, backlog.length, '拉回的 id 互不重复')
// 与"实时推送"合并 ⇒ 按 id 去重后条数相等(离线补齐判据)。
const realtime = await store.listMessages('r1', 95, 500)
const merged = mergeMessages(backlog, realtime)
assert.equal(merged.length, 100 - K, '补拉 + 实时并集去重后 = 100−K,⛔ 不多')
assert.equal(new Set(merged.map((m) => m.seq)).size, merged.length, '合并后无重复 seq')
})
test('判据4 分页批拉:limit 生效且不必一次拉完', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
for (let i = 1; i <= 25; i += 1) {
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i } })
}
const page1 = await store.listMessages('r1', 0, 10)
const page2 = await store.listMessages('r1', page1.at(-1).seq, 10)
const page3 = await store.listMessages('r1', page2.at(-1).seq, 10)
assert.deepEqual([page1.length, page2.length, page3.length], [10, 10, 5])
assert.equal(new Set([...page1, ...page2, ...page3].map((m) => m.id)).size, 25, '三页拼齐 25 条')
assert.ok(MAX_LIST_LIMIT >= 1000, '硬顶常量存在(保护性上限)')
})
// ── 判据 5 / 17:权限与归属列 ───────────────────────────────────────────────
test('判据5 权限:非成员不可见(canSee=false + 列表空集)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { text: 'hi' } })
const message = (await store.listMessages('r1', 0, 10))[0]
assert.equal(await store.canSee('r1', 'u-owner', message), true, '成员可见')
assert.equal(await store.canSee('r1', 'outsider', message), false, '非成员不可见')
assert.deepEqual(await store.listMessages('r1', 0, 10, { viewerId: 'outsider' }), [], '非成员拉取 ⇒ 空集')
assert.equal((await store.listMessages('r1', 0, 10, { viewerId: 'u-owner' })).length, 1, '成员拉取 ⇒ 有')
})
test('判据5 权限:private 消息不进公共列表,只有作者本人看得到', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.addMember({ roomId: 'r1', memberId: 'u-2' })
const pub = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { text: 'public' } })
const priv = await store.appendMessage({
roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { text: 'secret' }, visibility: 'private',
})
const common = await store.listMessages('r1', 0, 10, { viewerId: 'u-2' })
assert.deepEqual(common.map((m) => m.id), [pub.message.id], 'private 不进公共列表')
const own = await store.listMessages('r1', 0, 10, { viewerId: 'u-owner' })
assert.deepEqual(own.map((m) => m.id), [pub.message.id, priv.message.id], '作者本人两条都能看')
assert.equal(await store.canSee('r1', 'u-2', priv.message), false, '他人对 private = 不可见')
assert.equal(await store.canSee('r1', 'u-owner', priv.message), true, '作者对 private = 可见')
// 无视角(内核/agent 拉取路径)⇒ 不做过滤,两条都在(过滤是调用方的选择)。
assert.equal((await store.listMessages('r1', 0, 10)).length, 2, '无视角 ⇒ 全量读出')
})
test('判据17 归属列:owner_id / member_id / author_id / room_id 强制 NOT NULL', async (t) => {
const { db, store } = freshStore(t)
await makeRoom(store, 'r1')
const notNull = async (table) => {
const rows = await db.all(`PRAGMA table_info(${table})`)
return new Map(rows.map((r) => [r.name, Number(r.notnull)]))
}
assert.equal((await notNull('rooms')).get('owner_id'), 1, 'rooms.owner_id NOT NULL')
assert.equal((await notNull('room_members')).get('member_id'), 1, 'room_members.member_id NOT NULL')
assert.equal((await notNull('room_members')).get('room_id'), 1, 'room_members.room_id NOT NULL')
assert.equal((await notNull('messages')).get('author_id'), 1, 'messages.author_id NOT NULL')
assert.equal((await notNull('messages')).get('room_id'), 1, 'messages.room_id NOT NULL')
await assert.rejects(
() => db.run('INSERT INTO rooms (id, room_type, name, owner_id, created_at) VALUES (?,?,?,?,?)', ['x', 't', 'n', null, 1]),
/NOT NULL/i, 'NULL owner_id 被拒',
)
await assert.rejects(
() => db.run(
'INSERT INTO messages (id, room_id, seq, ts, author_id, author_kind, via, visibility, payload_json) VALUES (?,?,?,?,?,?,?,?,?)',
['m', 'r1', 999, 1, null, 'human', 'api', 'room', '{}'],
),
/NOT NULL/i, 'NULL author_id 被拒',
)
})
// ── 判据 6 / 15:写放大 ────────────────────────────────────────────────────
test('判据6/15 写放大:一条消息 = 1 次 messages INSERT,且与成员数无关', async (t) => {
const inner = openImSqlite(':memory:')
t.after(() => inner.close())
const { db, counts } = countingDb(inner)
const store = new ImStore(db)
await makeRoom(store, 'r1')
counts.messageInserts = 0
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 1 } })
assert.equal(counts.messageInserts, 1, '1 条消息 = 1 次 INSERT')
for (let i = 0; i < 50; i += 1) await store.addMember({ roomId: 'r1', memberId: `m${i}` })
assert.equal(await store.countMembers('r1'), 51, '51 个成员')
counts.messageInserts = 0
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 2 } })
assert.equal(counts.messageInserts, 1, '成员数 ×51 后仍是 1 次(⛔ 不按成员循环插入)')
assert.equal(await store.countMessages('r1'), 2, '库里 2 行')
counts.messageInserts = 0
const dedup = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 2 } })
assert.equal(dedup.inserted, false, '命中去重')
assert.equal(counts.messageInserts, 1, '去重仍**只发 1 条** INSERT 语句(ON CONFLICT DO NOTHING 空转)')
assert.equal(await store.countMessages('r1'), 2, '⛔ 行数不变(0 行被插入)')
})
// ── 判据 18:游标分页无 OFFSET(走索引) ───────────────────────────────────
test('判据18 无 OFFSET:WHERE room_id=? AND seq>? 走索引,不 SCAN messages', async (t) => {
const { db, store } = freshStore(t)
await makeRoom(store, 'r1')
for (let i = 1; i <= 30; i += 1) {
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i } })
}
const plan = await db.all(
'EXPLAIN QUERY PLAN SELECT id, room_id, seq FROM messages WHERE room_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?',
['r1', 5, 10],
)
const details = plan.map((r) => String(r.detail))
assert.ok(details.some((d) => /USING (COVERING )?INDEX/.test(d)), `走索引:${details.join(' | ')}`)
assert.ok(!details.some((d) => /SCAN messages/.test(d)), `⛔ 不得顺序扫描:${details.join(' | ')}`)
})
// ── 判据 13(限速面):具名降级策略 ────────────────────────────────────────
test('判据13 预算:具名降级(rate_limited / silence_period / room_full / ok)', async (t) => {
const tight = new ImStore(openImSqlite(':memory:'), {
limits: { maxMembers: 10, agentQuotaPer10Min: 2, silencePeriodMs: 30_000 },
})
t.after(() => undefined)
await makeRoom(tight, 'r1')
await tight.addMember({ roomId: 'r1', memberId: 'bot-1', kind: 'bot' })
const T = 1_700_000_000_000
assert.equal(await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T }), 'ok')
await tight.appendMessage({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', payload: { i: 1 }, ts: T })
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T + 10_000 }),
'silence_period', '距上一条 10 s(静默期 30 s)⇒ 具名 silence_period',
)
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T + 40_000 }),
'ok', '距上一条 40 s(> 静默期 30 s)⇒ ok',
)
await tight.appendMessage({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', payload: { i: 2 }, ts: T + 60_000 })
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T + 120_000 }),
'rate_limited', '10 min 内第 3 条(配额 2)⇒ 具名 rate_limited',
)
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', at: T + 120_000 }),
'ok', '人类成员不受 agent 限速约束',
)
const full = new ImStore(openImSqlite(':memory:'), { limits: { maxMembers: 1, agentQuotaPer10Min: 10, silencePeriodMs: 0 } })
await full.upsertRoom({ id: 'r2', roomType: 'group', name: 'r2', ownerId: 'o' })
await full.addMember({ roomId: 'r2', memberId: 'o' })
await full.addMember({ roomId: 'r2', memberId: 'o2' })
assert.equal(await full.decideWrite({ roomId: 'r2', authorId: 'o', authorKind: 'bot' }), 'room_full')
})
test('判据13 预算默认值 = §四-7 的待标定初值(可配置)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
const m = await store.addMember({ roomId: 'r1', memberId: 'bot-1', kind: 'bot', autoReply: true, agentRef: 'agent://x' })
assert.equal(m.autoReply, true, 'auto_reply 落库')
assert.equal(m.agentRef, 'agent://x', 'agent_ref 落库')
assert.equal(await store.setMemberAgent('r1', 'bot-1', null), true, '解绑成功')
assert.equal((await store.findMember('r1', 'bot-1')).agentRef, null, '解绑后为 null')
assert.equal(await store.setMemberAgent('r1', 'nobody', 'agent://y'), false, '不存在的成员 ⇒ false')
})
// ── 判据 21:慢查询可观测 ─────────────────────────────────────────────────
test('判据21 慢查询标记:超阈值 ⇒ onSlowQuery(marker=im_slow_query)', async (t) => {
const inner = openImSqlite(':memory:')
t.after(() => inner.close())
const slow = []
const slowDb = {
dialect: inner.dialect,
all: async (sql, params) => {
await new Promise((r) => setTimeout(r, 15))
return inner.all(sql, params)
},
run: (sql, params) => inner.run(sql, params),
exec: (sql) => inner.exec(sql),
close: () => inner.close(),
}
const store = new ImStore(slowDb, { slowQueryMs: 5, onSlowQuery: (info) => slow.push(info) })
await makeRoom(store, 'r1')
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 1 } })
await store.listMessages('r1', 0, 10)
assert.ok(slow.length > 0, '有慢查询被记录')
assert.equal(slow[0].marker, SLOW_QUERY_MARKER, '标记可 grep')
assert.ok(typeof slow[0].sql === 'string' && slow[0].ms >= 5, `记下 SQL 与耗时(${slow[0].ms} ms)`)
})
// ── 判据 22 / 23:区字段预留 + 无跨区同步路径 ──────────────────────────────
test('判据22 区/网预留字段:rooms 有 region/network 且可读写', async (t) => {
const { db, store } = freshStore(t)
const cols = (await db.all('PRAGMA table_info(rooms)')).map((r) => r.name)
assert.ok(cols.includes('region') && cols.includes('network'), `rooms 含 region/network:${cols.join(',')}`)
await store.upsertRoom({ id: 'r1', roomType: 'group', name: 'n', ownerId: 'o', region: 'cn-east', network: 'net-1' })
const room = await store.findRoom('r1')
assert.equal(room.region, 'cn-east')
assert.equal(room.network, 'net-1')
// 默认(未标定)⇒ 空串而非 NULL(便于二期按字段过滤,⛔ 不用 NULL 判空)。
await store.upsertRoom({ id: 'r2', roomType: 'group', name: 'n', ownerId: 'o' })
const bare = await store.findRoom('r2')
assert.equal(bare.region, '')
assert.equal(bare.network, '')
// ⛔ room_members / messages 不冗余存区(经 room_id 推出即可)。
const memberCols = (await db.all('PRAGMA table_info(room_members)')).map((r) => r.name)
const messageCols = (await db.all('PRAGMA table_info(messages)')).map((r) => r.name)
assert.ok(!memberCols.includes('region') && !messageCols.includes('region'), '子表不冗余区字段')
})
// ── 房间 / 成员的读写面 ───────────────────────────────────────────────────
test('读写面:upsertRoom 幂等、listRooms 过滤与稳定序、addMember 幂等不降权', async (t) => {
const { store } = freshStore(t)
const first = await store.upsertRoom({ id: 'r1', roomType: 'group', name: '旧名', ownerId: 'o' })
const second = await store.upsertRoom({ id: 'r1', roomType: 'group', name: '新名', ownerId: 'o' })
assert.equal(second.name, '新名', 'name 被更新')
assert.equal(second.createdAt, first.createdAt, 'created_at ⛔ 不被覆盖')
await store.upsertRoom({ id: 'r2', roomType: 'dm', name: 'b', ownerId: 'o' })
await store.upsertRoom({ id: 'r3', roomType: 'group', name: 'c', ownerId: 'other' })
assert.deepEqual((await store.listRooms({ ownerId: 'o' })).map((r) => r.id), ['r1', 'r2'], '按房主过滤')
assert.deepEqual((await store.listRooms({ roomType: 'group' })).map((r) => r.id).sort(), ['r1', 'r3'], '按类型过滤')
await store.addMember({ roomId: 'r1', memberId: 'o', role: 'owner' })
const again = await store.addMember({ roomId: 'r1', memberId: 'o', role: 'member' })
assert.equal(again.role, 'owner', '⛔ 重复 addMember 不把 owner 降成 member')
assert.equal(await store.countMembers('r1'), 1, '仍 1 个成员')
await store.setMemberAutoReply('r1', 'o', true)
assert.equal((await store.findMember('r1', 'o')).autoReply, true)
})
test('外键:房间不存在 ⇒ 投消息 / 加成员被拒(归属不悬空)', async (t) => {
const { store } = freshStore(t)
await assert.rejects(
() => store.appendMessage({ roomId: 'no-such-room', authorId: 'u', authorKind: 'human', payload: { i: 1 } }),
)
await assert.rejects(() => store.addMember({ roomId: 'no-such-room', memberId: 'u' }))
})
// ── 判据 22-adjacent:NOT/EXPLAIN 辅助函数 ────────────────────────────────
test('toPgPlaceholders:? → $n,并跳过字符串字面量里的 ?', () => {
assert.equal(
toPgPlaceholders('SELECT * FROM rooms WHERE id = ? AND name = ?'),
'SELECT * FROM rooms WHERE id = $1 AND name = $2',
)
assert.equal(
toPgPlaceholders("SELECT * FROM rooms WHERE name = 'a?b' AND id = ?"),
"SELECT * FROM rooms WHERE name = 'a?b' AND id = $1",
)
assert.equal(
toPgPlaceholders("SELECT 'it''s ?' AS x, ? AS y"),
"SELECT 'it''s ?' AS x, $1 AS y",
)
})
// ── Postgres 段(未设 DSHS_TEST_DB_URL ⇒ 自跳过,与 test/db.test.mjs 同约定) ──
test('PG:v14 + 去重 + 游标补拉 + EXPLAIN 无 Seq Scan', { skip: PG_URL === undefined ? 'DSHS_TEST_DB_URL 未设置' : false }, async (t) => {
const db = await openImPg(PG_URL, { migrate: true })
t.after(() => db.close())
const store = new ImStore(db)
const roomId = `r-${Date.now()}`
const rows = await db.all('SELECT MAX(version) AS v FROM schema_migrations')
// ⚠️ 从「= 14」放开为「≥ 14」:后续单子允许加迁移(v15 插件数据面、v16 E 单房间可见性列),
// 本条**本意**是「v14 已应用」,⛔ 不是"锁死版本号"。
assert.ok(Number(rows[0].v) >= 14, `PG 侧最新 ≥ v14(实测 ${rows[0].v})`)
await store.upsertRoom({ id: roomId, roomType: 'group', name: 'pg', ownerId: 'o' })
const a = await store.appendMessage({ roomId, authorId: 'o', authorKind: 'human', payload: { text: 'dup' } })
const b = await store.appendMessage({ roomId, authorId: 'o', authorKind: 'human', payload: { text: 'dup' } })
assert.equal(a.inserted, true)
assert.equal(b.inserted, false, 'PG 侧同样命中去重')
assert.equal(await store.countMessages(roomId), 1)
for (let i = 1; i <= 10; i += 1) {
await store.appendMessage({ roomId, authorId: 'o', authorKind: 'human', payload: { i } })
}
const pulled = await store.listMessages(roomId, 4, 100)
assert.equal(pulled.length, 7, '恰好补 10−4+1 条')
// 小表下 PG 规划器会偏好顺序扫描 ⇒ 关掉 enable_seqscan 才是"索引可用性"的判据。
await db.exec('SET enable_seqscan = off')
const plan = await db.all(
'EXPLAIN SELECT id FROM messages WHERE room_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?',
[roomId, 1, 10],
)
const text = plan.map((r) => String(Object.values(r)[0])).join('\n')
assert.ok(!/Seq Scan on messages/.test(text), `⛔ 不得 Seq Scan:\n${text}`)
})