Files
dsh_shenxian/test/im-store.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

604 lines
31 KiB
JavaScript
Raw 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` 翻译。
// 🔴 后端只有 PostgreSQL(SQLite 后端已删除)⇒ 全文件只有 PG 一条通路:连接串取
// `DSHS_TEST_DB_URL`(**必须指向专用测试库** —— 每个用例前清空 public 下的表);
// 未设 ⇒ 全部用例**显式跳过并打印原因**(⛔ 不是静默通过)。
import { test as nodeTest } from 'node:test'
import assert from 'node:assert/strict'
import { 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'
import { PG_URL, PG_SKIP_REASON, announceSkip, ensureMigrated, reportSkipSummary, resetPg } from './pg-test-db.mjs'
/** 缺连接串 ⇒ 每条用例都**显式跳过**(原因会出现在 `# SKIP` 行里)。 */
const SKIP = PG_URL === undefined || PG_URL === '' ? PG_SKIP_REASON : false
const test = (name, fn) => nodeTest(name, { skip: SKIP }, fn)
if (SKIP !== false) {
announceSkip('test/im-store.test.mjs')
reportSkipSummary('test/im-store.test.mjs')
} else {
// 迁移先跑一次 ⇒ 后面每个用例的 TRUNCATE 才有表可清。
await ensureMigrated(PG_URL)
}
/** 打开一个**干净**的 PG 端口 + 内核,并在用例结束时关库。
* ⚠️ 每个用例前清空 public 下的表 —— SQLite 时代每条用例一个 `:memory:` 库(天然隔离),
* PG 上同一文件的用例共用一个库 ⇒ 不清理就会互相污染。 */
async function freshImDb(t) {
await resetPg(PG_URL)
const db = await openImPg(PG_URL, { migrate: true })
if (t !== undefined) t.after(() => db.close())
return db
}
/** 同上,外加一个内核实例。 */
async function freshStore(t, options = {}) {
const db = await freshImDb(t)
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 first = await openImPg(PG_URL, { migrate: true })
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 = await openImPg(PG_URL, { migrate: true })
const rowsSecond = await second.all('SELECT version FROM schema_migrations')
const tables = await second.all(
"SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
)
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 } = await freshStore(t)
const rows = await db.all(
"SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
)
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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await freshStore(t)
await makeRoom(store, 'r1')
const notNull = async (table) => {
const rows = await db.all(
"SELECT column_name AS name, is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position",
[table],
)
// PG:`is_nullable` = 'NO' 才是 NOT NULL(对应 SQLite 的 `notnull=1`)。
return new Map(rows.map((r) => [r.name, r.is_nullable === 'NO' ? 1 : 0]))
}
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]),
// ⚠️ 文案按方言放宽:SQLite 是 `NOT NULL constraint failed`,PG 是 `violates not-null constraint`
// (带连字符)⇒ 判据写成 `/not[- ]null/i`,⛔ 不要写死某一种文案。
/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 = await freshImDb(t)
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>? 走索引,不 Seq Scan messages', async (t) => {
const { db, store } = await 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 } })
}
// ⚠️ 小表下 PG 规划器会偏好顺序扫描 ⇒ 关掉 `enable_seqscan` 才是"索引可用性"的判据
// (与文件末尾那节 PG 用例同一口径)。`EXPLAIN` 的返回列名是 `QUERY PLAN`,
// ⛔ 不是 SQLite 的 `detail`。
await db.exec('SET enable_seqscan = off')
const plan = await db.all(
'EXPLAIN SELECT id, room_id, seq FROM messages WHERE room_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?',
['r1', 5, 10],
)
const text = plan.map((r) => String(Object.values(r)[0])).join('\n')
assert.ok(/Index/.test(text), `走索引:\n${text}`)
assert.ok(!/Seq Scan on messages/.test(text), `⛔ 不得顺序扫描:\n${text}`)
})
// ── 判据 13(限速面):具名降级策略 ────────────────────────────────────────
test('判据13 预算:具名降级(rate_limited / silence_period / room_full / ok)', async (t) => {
const tight = new ImStore(await freshImDb(t), {
limits: { maxMembers: 10, agentQuotaPer10Min: 2, silencePeriodMs: 30_000 },
})
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(await freshImDb(t), { 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 } = await 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 = await freshImDb(t)
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 } = await freshStore(t)
/** 表列清单(PG:`information_schema`,按列序)。 */
const columnNames = async (table) =>
(await db.all(
"SELECT column_name AS name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position",
[table],
)).map((r) => r.name)
const cols = await columnNames('rooms')
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 columnNames('room_members')
const messageCols = await columnNames('messages')
assert.ok(!memberCols.includes('region') && !messageCols.includes('region'), '子表不冗余区字段')
})
// ── 房间 / 成员的读写面 ───────────────────────────────────────────────────
test('读写面:upsertRoom 幂等、listRooms 过滤与稳定序、addMember 幂等不降权', async (t) => {
const { store } = await 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 } = await 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",
)
})
// ── PG 专项段(本仓后端只有 PG ⇒ 与上面同一通路,只是这里的库要显式自检) ──
test('PG:v14 + 去重 + 游标补拉 + EXPLAIN 无 Seq Scan', 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}`)
})