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/;交接单不入库(政策)。
572 lines
30 KiB
JavaScript
572 lines
30 KiB
JavaScript
// 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}`)
|
||
})
|