207 lines
9.9 KiB
TypeScript
207 lines
9.9 KiB
TypeScript
// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。
|
|
// node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { Project } from '../src/contracts'
|
|
import {
|
|
INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS,
|
|
SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage,
|
|
memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
|
|
type RdmAccountPage, type RdmMemberRow,
|
|
} from '../src/rdmManagement'
|
|
|
|
const row = (owner: string, name?: string): RdmMemberRow =>
|
|
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
|
|
|
|
describe('accountListParams', () => {
|
|
it('passes mode, searchFields, roleId and cursor to rdm.account.list', () => {
|
|
expect(accountListParams('p1', {
|
|
query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'],
|
|
})).toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'] })
|
|
})
|
|
|
|
it('passes docId for document duty candidates', () => {
|
|
expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] }))
|
|
.toEqual({ projectId: 'p1', query: 'li', limit: 20, mode: 'fuzzy', searchFields: ['owner', 'name'], docId: 'd1' })
|
|
})
|
|
|
|
it('never sends the legacy match alias next to mode', () => {
|
|
const params = accountListParams('p1', { query: 'li', limit: 20, mode: 'exact' })
|
|
expect(params).not.toHaveProperty('match')
|
|
expect(params.mode).toBe('exact')
|
|
})
|
|
|
|
it('omits empty filters, defaults to fuzzy and caps the page at 50', () => {
|
|
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 200, roleId: '', docId: '', searchFields: [] }))
|
|
.toEqual({ projectId: 'p1', limit: 50, mode: 'fuzzy' })
|
|
})
|
|
|
|
it('labels both match modes and the search field choices in Chinese', () => {
|
|
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
|
|
expect(SEARCH_FIELDS.both.fields).toEqual(['owner', 'name'])
|
|
expect(SEARCH_FIELDS.owner.fields).toEqual(['owner'])
|
|
expect(SEARCH_FIELDS.name.fields).toEqual(['name'])
|
|
})
|
|
})
|
|
|
|
describe('accountCandidatePager', () => {
|
|
const recorder = (pages: RdmAccountPage[]) => {
|
|
const calls: Record<string, unknown>[] = []
|
|
const run = async (params: Record<string, unknown>) => { calls.push(params); return pages.shift() ?? {} }
|
|
return { calls, run }
|
|
}
|
|
const acct = (userId: string, owner: string) => ({ userId, owner, valid: true, joined: false })
|
|
|
|
it('does not query the directory for an empty search', async () => {
|
|
const { calls, run } = recorder([])
|
|
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
|
|
expect(await page(' ', '', 20)).toEqual({ items: [] })
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('pages with the opaque next cursor under the same query and options', async () => {
|
|
const { calls, run } = recorder([{ accounts: [acct('u1', 'a1')], next: 'n1' }, { accounts: [acct('u2', 'a2')] }])
|
|
const page = accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner'], docId: 'd1' })
|
|
const first = await page('zh', '', 20)
|
|
expect(first.nextCursor).toBe('n1')
|
|
await page('zh', 'n1', 20)
|
|
expect(calls).toEqual([
|
|
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1' },
|
|
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1', cursor: 'n1' },
|
|
])
|
|
})
|
|
|
|
it('resets the cursor when the query changes', async () => {
|
|
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }])
|
|
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
|
|
await page('zh', '', 20)
|
|
await page('zhang', 'n1', 20)
|
|
expect(calls[1]).not.toHaveProperty('cursor')
|
|
})
|
|
|
|
it('resets the cursor when mode or search fields change (new pager, new reload key)', async () => {
|
|
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }, { accounts: [] }])
|
|
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['owner', 'name'] })('zh', '', 20)
|
|
await accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner', 'name'] })('zh', 'n1', 20)
|
|
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['name'] })('zh', 'n1', 20)
|
|
expect(calls[1]).not.toHaveProperty('cursor')
|
|
expect(calls[2]).not.toHaveProperty('cursor')
|
|
const key = (mode: 'fuzzy' | 'exact', fields: ('owner' | 'name')[], docId?: string) =>
|
|
accountSearchKey('p1', { mode, searchFields: fields, docId })
|
|
expect(new Set([key('fuzzy', ['owner', 'name']), key('exact', ['owner', 'name']), key('fuzzy', ['name']),
|
|
key('fuzzy', ['owner', 'name'], 'd1')]).size).toBe(4)
|
|
})
|
|
|
|
it('maps duty candidates so only current members are selectable', async () => {
|
|
const { run } = recorder([{ accounts: [
|
|
{ userId: 'u1', owner: 'm1', name: '成员', valid: true, joined: true },
|
|
{ userId: 'u2', owner: 'x1', valid: true, joined: false },
|
|
] }])
|
|
const { items } = await accountCandidatePager(run, 'p1', { docId: 'd1' })('m', '', 20)
|
|
expect(items).toEqual([
|
|
{ id: 'u1', owner: 'm1', title: '成员', subtitle: 'm1' },
|
|
{ id: 'u2', owner: 'x1', title: 'x1', subtitle: 'x1', disabled: true, disabledReason: NOT_MEMBER_REASON },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('memberListParams', () => {
|
|
it('sends query, roleId and cursor with a page size of 50', () => {
|
|
expect(memberListParams('p1', { query: 'li', roleId: 'r-qa', cursor: 'n1' }))
|
|
.toEqual({ projectId: 'p1', query: 'li', roleId: 'r-qa', cursor: 'n1', limit: MEMBER_PAGE_LIMIT })
|
|
expect(MEMBER_PAGE_LIMIT).toBe(50)
|
|
})
|
|
|
|
it('loads the unfiltered first page when no filter is set', () => {
|
|
expect(memberListParams('p1', { query: '', roleId: '', cursor: '' })).toEqual({ projectId: 'p1', limit: 50 })
|
|
})
|
|
|
|
it('forwards the optional match and projectRole filters', () => {
|
|
expect(memberListParams('p1', { match: 'exact', projectRole: 'owner', limit: 10 }))
|
|
.toEqual({ projectId: 'p1', limit: 10, match: 'exact', projectRole: 'owner' })
|
|
})
|
|
})
|
|
|
|
describe('roleOptionsPayload', () => {
|
|
it('asks for member-purpose role options', () => {
|
|
expect(roleOptionsPayload('p1')).toEqual({ projectId: 'p1', purpose: 'members' })
|
|
})
|
|
})
|
|
|
|
describe('accountsToPickItems', () => {
|
|
it('keys candidates by stable userId and drops rows without one', () => {
|
|
const items = accountsToPickItems([
|
|
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false },
|
|
{ userId: '', owner: 'anon', valid: true, joined: false },
|
|
])
|
|
expect(items).toEqual([{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a' }])
|
|
})
|
|
|
|
it('shows invalid accounts as not selectable', () => {
|
|
const [it] = accountsToPickItems([{ userId: 'u-g', owner: 'gone', name: '离职', valid: false, joined: false }])
|
|
expect(it).toMatchObject({ id: 'u-g', disabled: true, disabledReason: INVALID_REASON })
|
|
})
|
|
|
|
it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
|
|
const items = accountsToPickItems([
|
|
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true },
|
|
{ userId: 'u-b', owner: 'b', valid: true, joined: false },
|
|
{ userId: 'u-c', owner: 'c', name: '丙', valid: true },
|
|
], ['b'])
|
|
expect(items).toEqual([
|
|
{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
|
|
{ id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
|
|
{ id: 'u-c', owner: 'c', title: '丙', subtitle: 'c' },
|
|
])
|
|
expect(items.some(it => 'badge' in it)).toBe(false)
|
|
})
|
|
|
|
it('keeps name as title and account as subtitle so the selection shows both', () => {
|
|
const [it] = accountsToPickItems([{ userId: 'u-z', owner: 'zhangsan', name: '张三', valid: true, joined: false }])
|
|
expect(it.title).toBe('张三')
|
|
expect(it.subtitle).toBe('zhangsan')
|
|
})
|
|
|
|
it('tolerates a missing account list', () => {
|
|
expect(accountsToPickItems(undefined)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('personLabel', () => {
|
|
it('renders name · account', () => {
|
|
expect(personLabel({ owner: 'zhangsan', name: '张三' })).toBe('张三 · zhangsan')
|
|
})
|
|
|
|
it('falls back to the account when there is no name', () => {
|
|
expect(personLabel({ owner: 'zhangsan' })).toBe('zhangsan')
|
|
expect(personLabel({ owner: 'zhangsan', name: ' ' })).toBe('zhangsan')
|
|
expect(personLabel({ owner: 'zhangsan', name: 'zhangsan' })).toBe('zhangsan')
|
|
})
|
|
})
|
|
|
|
describe('appendMemberPage', () => {
|
|
it('appends the next page in order without duplicating accounts', () => {
|
|
const first = [row('a', '甲'), row('b')]
|
|
const got = appendMemberPage(first, [row('b', '乙-重复'), row('c', '丙')])
|
|
expect(got.map(m => m.owner)).toEqual(['a', 'b', 'c'])
|
|
expect(got[1].name).toBeUndefined()
|
|
expect(first.map(m => m.owner)).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('starts a fresh list from null and dedupes within a page', () => {
|
|
expect(appendMemberPage(null, [row('a'), row('a')]).map(m => m.owner)).toEqual(['a'])
|
|
expect(appendMemberPage(null, undefined)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('rdm.role.options command gating', () => {
|
|
it('is a project read command, so archived projects can still load role filters', () => {
|
|
const archived = { id: 'p1', settings: { 'rdm.state': 'archived', 'rdm.projectId': 'rdm-1' } } as unknown as Project
|
|
expect(rdmProjectCommandError(archived, 'rdm.role.options')).toBeNull()
|
|
})
|
|
|
|
it('does not gate member page readiness', () => {
|
|
expect(RDM_MEMBER_COMMANDS).not.toContain('rdm.role.options')
|
|
expect(rdmMembersReady(RDM_MEMBER_COMMANDS.map(id => ({ id, enabled: true })))).toBe(true)
|
|
})
|
|
})
|