Files
dsh_shenxian/test/tenant-members.test.mjs
T

252 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 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')
})