// 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__ 前缀表') }) // ── 判据 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}`) })