Files
dsh_shenxian/test/tenant-members.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

253 lines
11 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.
/**
* 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')
})