/** * S9 · 多租户管理面「**成员读数**」+ 宿主台账接缝 —— **真库**用例(2026-09-27)。 * * ## 为什么必须真库 * 本套测的是 `PgAdapter#listTenantMembers()` 那条 **JOIN 归属列**的 SQL(`rooms.owner_id` * ⋈ `room_members`)—— 桩件测不出"归属列取对了没有、跨租户会不会串"。⇒ 直接走真 PG。 * * ## 口径(硬边界,逐字遵守) * 用户 2026-09-27 拍板:「数据库 做 数据隔离就行 **不需要单独建表建库**」⇒ * 本套用例**只**写既有内核表(`rooms` / `room_members`),⛔ 不建表、⛔ 不加列、⛔ 不写迁移。 * * 🔴 运行(**必须** `--test-concurrency=1`:同库用例互清表,并行会假红 —— 已实测): * ``` * DSHS_TEST_DB_URL=postgres://…/dsh_test_pgonly node --test --test-concurrency=1 test/tenant-members.test.mjs * ``` * ⛔ 连接串必须指向**专用测试库**(`resetPg` 会 TRUNCATE 该库 public 下全部表)。 * 未设连接串 ⇒ **显式跳过并打印原因**(⛔ 不是静默通过)。 */ import { test as nodeTest } from 'node:test' import assert from 'node:assert/strict' import Fastify from 'fastify' import pg from 'pg' import { openPgAdapter } from '../lib/db/pg.js' import { tenantRoutes } from '../lib/web/routes/tenants.js' import { PG_SKIP_REASON, announceSkip, ensureMigrated, reportSkipSummary, resetPg } from './pg-test-db.mjs' /** 连接串:统一口径 `DSHS_TEST_DB_URL`(旧名 `LEASETEST_PG_URL` 兜底)。 */ const PG_URL = process.env.DSHS_TEST_DB_URL ?? process.env.LEASETEST_PG_URL const SKIP = PG_URL === undefined || PG_URL === '' ? PG_SKIP_REASON : false /** 缺连接串 ⇒ 每条用例都**显式跳过**(原因出现在 `# SKIP` 行里)。 */ const test = (name, fn) => nodeTest(name, { skip: SKIP }, fn) if (SKIP !== false) { announceSkip('test/tenant-members.test.mjs') reportSkipSummary('test/tenant-members.test.mjs') } else { // 迁移先跑一次 ⇒ 后面 TRUNCATE / 插 `rooms` 才有表。 await ensureMigrated(PG_URL) } /** * 清库 + 建两个租户(`u-a` / `u-b`)+ 打开真适配器。 * ⚠️ 每个用例**自己的** `db`,用完 `close()`。 */ async function freshDb() { await resetPg(PG_URL) const db = await openPgAdapter(PG_URL, 100000) for (const [id, username] of [ ['u-a', 'alice'], ['u-b', 'bob'], ]) { await db.createUser({ id, username, passHash: 'x', role: 'active', homeDir: `/tmp/${id}` }) } return db } /** 往**既有内核表**直插一行(测的是读侧 ⇒ 写侧用最直白的 SQL,⛔ 不借道业务代码)。 */ async function withClient(fn) { const client = new pg.Client({ connectionString: PG_URL }) await client.connect() try { return await fn(client) } finally { await client.end() } } /** 建房(`rooms.owner_id` = **归属列**;`region` 给个非空值,与 `overlay-zones` 口径一致)。 */ function insertRoom(client, { id, ownerId, createdAt }) { return client.query( `INSERT INTO rooms (id, room_type, name, owner_id, region, network, config_json, created_at) VALUES ($1, 'group', $1, $2, 'r1', '', '{}', $3)`, [id, ownerId, createdAt], ) } /** 入房(**既有** `room_members`,列全按建表口径)。 */ function insertMember(client, { roomId, memberId, kind = 'human', role = 'member', autoReply = 0, agentRef = null, joinedAt }) { return client.query( `INSERT INTO room_members (room_id, member_id, kind, role, auto_reply, agent_ref, joined_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [roomId, memberId, kind, role, autoReply, agentRef, joinedAt], ) } test('S9-M① 成员读数 = 「该租户名下房间」的成员,且**不串租户**', async () => { const db = await freshDb() await withClient(async (c) => { await insertRoom(c, { id: 'r-a1', ownerId: 'u-a', createdAt: 1000 }) await insertRoom(c, { id: 'r-b1', ownerId: 'u-b', createdAt: 1001 }) // 房主自己也在成员表里(建房时 `role='owner'`)—— 平台既有行为,不是本用例造的。 await insertMember(c, { roomId: 'r-a1', memberId: 'u-a', role: 'owner', joinedAt: 1000 }) await insertMember(c, { roomId: 'r-a1', memberId: 'm-h1', joinedAt: 1500 }) await insertMember(c, { roomId: 'r-a1', memberId: 'm-b1', kind: 'bot', autoReply: 1, agentRef: 'agent://m-b1', joinedAt: 1600, }) // 另一个租户名下的房间 + 成员 —— 必须**一次都不出现**。 await insertMember(c, { roomId: 'r-b1', memberId: 'm-x', joinedAt: 1700 }) }) const a = await db.listTenantMembers('u-a') assert.deepEqual( a.map((m) => m.memberId), ['m-b1', 'm-h1', 'u-a'], '只回 u-a **名下**房间的成员(含房主自己),⛔ 不含 u-b 房间里的 m-x', ) assert.equal(a.length, 3) const bot = a.find((m) => m.memberId === 'm-b1') assert.equal(bot.kind, 'bot') assert.equal(bot.autoReply, 1, '`agent_ref` / `auto_reply` 原样带出') assert.equal(bot.agentRef, 'agent://m-b1') assert.deepEqual(bot.rooms, ['r-a1']) assert.equal(a.find((m) => m.memberId === 'u-a').role, 'owner', '房主以 role=owner 出现(⛔ 不剔除)') const b = await db.listTenantMembers('u-b') assert.deepEqual(b.map((m) => m.memberId), ['m-x']) await db.close() }) test('S9-M② 同一成员跨房 ⇒ 合一行:rooms 去重、身份字段取**最早入房**那一行', async () => { const db = await freshDb() await withClient(async (c) => { await insertRoom(c, { id: 'r-late', ownerId: 'u-a', createdAt: 1000 }) await insertRoom(c, { id: 'r-early', ownerId: 'u-a', createdAt: 1001 }) // 晚入房那一行:身份字段写"晚"的值(若实现取错行,断言会红)。 await insertMember(c, { roomId: 'r-late', memberId: 'm-h1', kind: 'human', role: 'member', joinedAt: 2000 }) // 早入房那一行:身份字段 = 应当被采用的那一份。 await insertMember(c, { roomId: 'r-early', memberId: 'm-h1', kind: 'bot', role: 'admin', autoReply: 1, agentRef: 'agent://early', joinedAt: 1000, }) }) const rows = await db.listTenantMembers('u-a') assert.equal(rows.length, 1, '一个成员**一行**(按 member_id 归并)') assert.deepEqual(rows[0].rooms, ['r-early', 'r-late'], 'rooms 按入房先后排列(确定序,不随行序漂)') assert.equal(rows[0].role, 'admin', '角色取最早入房那一行') assert.equal(rows[0].kind, 'bot') assert.equal(rows[0].autoReply, 1) assert.equal(rows[0].agentRef, 'agent://early') assert.equal(rows[0].joinedAt, 1000, 'joinedAt = 首次进入本租户名下房间的时刻') await db.close() }) test('S9-M③ 名下没有房间的租户 ⇒ 空数组(⛔ 不是 undefined、不是报错)', async () => { const db = await freshDb() const db2 = await openPgAdapter(PG_URL, 100000) await db2.createUser({ id: 'u-c', username: 'carol', passHash: 'x', role: 'active', homeDir: '/tmp/u-c' }) await db2.close() const rows = await db.listTenantMembers('u-c') assert.deepEqual(rows, [], '空租户 = 空列表(路由层据此回 count:0,而**不是** not_connected)') await db.close() }) test('S9-M④ 宿主台账接缝在**真适配器**上已存在(不是 not_connected)', async () => { const db = await freshDb() assert.equal( typeof db.listDshHosts, 'function', '`DbAdapter#listDshHosts` 是**必需**方法(adapter.ts)⇒ 实部署的 hosts 段会真的出现', ) await db.upsertDshHost({ id: 'h1', endpoint: '127.0.0.1:9000', agentToken: 'tok-1', capacityMb: 1870 }) const hosts = await db.listDshHosts() assert.equal(hosts.length, 1) const h = hosts[0] assert.equal(h.id, 'h1') assert.equal(h.endpoint, '127.0.0.1:9000') // 路由面 `hostsOf()` 只映射这 5 个字段 ⇒ 逐个断言它们真的取得到(接不上的话就是 undefined)。 for (const k of ['id', 'endpoint', 'capacityMb', 'usedMb', 'status']) { assert.notEqual(h[k], undefined, `路由面要映射的字段 ${k} 必须取得到`) } assert.equal(h.capacityMb, 1870) assert.equal(h.usedMb, 0) assert.equal(h.status, 'up') await db.close() }) test('S9-M⑤ 端到端(真库 + 真路由):成员面**不再是 not_connected**, hosts 段真的出现', async (t) => { const db = await freshDb() await withClient(async (c) => { await insertRoom(c, { id: 'r-a1', ownerId: 'u-a', createdAt: 1000 }) await insertRoom(c, { id: 'r-b1', ownerId: 'u-b', createdAt: 1001 }) await insertMember(c, { roomId: 'r-a1', memberId: 'u-a', role: 'owner', joinedAt: 1000 }) await insertMember(c, { roomId: 'r-a1', memberId: 'm-h1', joinedAt: 1500 }) await insertMember(c, { roomId: 'r-b1', memberId: 'm-x', joinedAt: 1700 }) }) await db.upsertDshHost({ id: 'h-e2e', endpoint: '127.0.0.1:9100', agentToken: 'tok-e2e', capacityMb: 2048 }) // 会话层接缝走桩(本用例的被测对象是**成员面 / 宿主面**,不是会话解析): // 把方法挂到真适配器上 ⇒ 其余查询(`findUserById` / `listPublicUsers`)全走**真 SQL**。 db.findSessionWithUser = async () => ({ user: { id: 'u-admin', username: 'admin', role: 'admin', createdAt: 1, created_at: 1, sharedModelGranted: false }, expiresAt: Date.now() + 3_600_000, }) const app = Fastify() app.decorate('db', db) app.decorate('config', { baseDomain: 'example.test', enablePatch: false }) app.decorate('supervisor', { status: async () => ({ main: undefined, watchdog: undefined }), breakerInfo: () => null, quotaInfo: () => ({ baseMb: 1024, memMb: 1870, heapMb: 1408 }), }) await app.register(tenantRoutes) await app.ready() t.after(async () => { await app.close() await db.close() }) const headers = { cookie: 'sid=stub-token' } const detail = await app.inject({ method: 'GET', url: '/api/admin/tenants/u-a', headers }) assert.equal(detail.statusCode, 200) const body = detail.json() assert.equal(body.members.state, 'ok', '接上真适配器后**不再**回 not_connected') assert.equal(body.members.count, 2) assert.deepEqual( body.members.members.map((m) => m.memberId).sort(), ['m-h1', 'u-a'], '列表由真 SQL 读出(⛔ 不是桩喂的)', ) assert.equal( body.members.members.find((m) => m.memberId === 'u-a').joinedAt, new Date(1000).toISOString(), '路由面的 joinedAt 形状 = ISO 串(既有已测形状,⛔ 未改动)', ) const detailB = await app.inject({ method: 'GET', url: '/api/admin/tenants/u-b', headers }) assert.equal(detailB.json().members.count, 1, '另一租户只读到自己的成员(隔离负控)') const list = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers }) assert.equal(list.statusCode, 200) const lb = list.json() assert.ok(Array.isArray(lb.hosts), '宿主台账接缝**已接通** ⇒ hosts 段真的出现(⛔ 不是编的)') assert.deepEqual(lb.hosts.map((h) => h.id), ['h-e2e']) assert.equal(lb.hosts[0].capacityMb, 2048) assert.equal('agentToken' in lb.hosts[0], false, '⛔ 路由面**不**泄露 agent_token') assert.equal(lb.tenants.find((x) => x.id === 'u-a').members.state, 'ok') })