task-23(会话/菜单/权限): 实现当前用户请求适配 + 真后端 live 测试
session-model.parseCurrentUser 兼容 data:{item}/{item}/直接对象信封并校验 id;
session.ts fetchCurrentUser 复用;启用 allowImportingTsExtensions;npm run test:live
用 admin 账号真实登录打 18080 验证 current-user 与菜单树(023/024 live 全绿)。
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node --test tests/*.test.ts",
|
"test": "node --test tests/*.test.ts",
|
||||||
|
"test:live": "node --test tests/live/*.test.ts",
|
||||||
"test:browser": "playwright test"
|
"test:browser": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { unwrap } from './envelope.ts'
|
||||||
|
import type { AdminMenuNode, AdminUser } from '../types/admin'
|
||||||
|
|
||||||
|
/** 解析当前用户响应:支持 data:{item} 与 {item}/{直接对象} 信封。 */
|
||||||
|
export function parseCurrentUser(payload: unknown): AdminUser {
|
||||||
|
const value = unwrap<AdminUser | { item?: AdminUser }>(payload)
|
||||||
|
const user =
|
||||||
|
value && typeof value === 'object' && 'item' in value && value.item
|
||||||
|
? value.item
|
||||||
|
: (value as AdminUser)
|
||||||
|
if (!user || typeof user.id !== 'number') {
|
||||||
|
throw new Error('当前用户响应缺少有效 id')
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析菜单树响应:支持 data:{items} 与 {items}/数组 信封。
|
||||||
|
* 返回后端树形节点;空/缺省按 [] 处理,与“接口失败”(抛错) 区分开。
|
||||||
|
*/
|
||||||
|
export function parseMenuTree(payload: unknown): AdminMenuNode[] {
|
||||||
|
const value = unwrap<AdminMenuNode[] | { items?: AdminMenuNode[] }>(payload)
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
return (value && Array.isArray(value.items) ? value.items : []) as AdminMenuNode[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { parseCurrentUser, parseMenuTree } from './session-model'
|
||||||
|
import type { AdminMenuNode, AdminUser, ApiEnvelope } from '../types/admin'
|
||||||
|
|
||||||
|
export async function fetchCurrentUser(): Promise<AdminUser> {
|
||||||
|
const { data } = await http.get<ApiEnvelope<{ item: AdminUser }> | AdminUser>('/api/admin/current-user')
|
||||||
|
return parseCurrentUser(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminMenuTree(): Promise<AdminMenuNode[]> {
|
||||||
|
const { data } = await http.get<
|
||||||
|
ApiEnvelope<{ items: AdminMenuNode[] }> | { items: AdminMenuNode[] } | AdminMenuNode[]
|
||||||
|
>('/api/admin/current-user/menus')
|
||||||
|
return parseMenuTree(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await http.post('/api/admin/logout', undefined, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { parseCurrentUser, parseMenuTree } from '../../src/api/session-model.ts'
|
||||||
|
|
||||||
|
const BASE = process.env.AIIMAGE_LIVE_BASE || 'http://127.0.0.1:18080'
|
||||||
|
const USER = process.env.AIIMAGE_LIVE_USER || ''
|
||||||
|
const PASS = process.env.AIIMAGE_LIVE_PASS || ''
|
||||||
|
const DEVICE = 'claude-live-device'
|
||||||
|
|
||||||
|
const HAS_CREDS = Boolean(USER && PASS)
|
||||||
|
|
||||||
|
async function loginToken(): Promise<string> {
|
||||||
|
const res = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: USER, password: PASS, deviceId: DEVICE }),
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as { success?: boolean; message?: string; data?: { token?: string } }
|
||||||
|
if (!body.success || !body.data?.token) throw new Error(body.message || 'login failed')
|
||||||
|
return body.data.token
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(token: string): Record<string, string> {
|
||||||
|
return { Authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test_live_023_current_user_real_request', { skip: !HAS_CREDS }, async () => {
|
||||||
|
const token = await loginToken()
|
||||||
|
const res = await fetch(`${BASE}/api/admin/current-user`, { headers: authHeaders(token) })
|
||||||
|
const body = await res.json()
|
||||||
|
const user = parseCurrentUser(body)
|
||||||
|
assert.equal(typeof user.id, 'number')
|
||||||
|
assert.ok(user.username.length > 0)
|
||||||
|
assert.ok(['super_admin', 'admin', 'normal'].includes(user.role))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_live_024_menu_tree_real_request', { skip: !HAS_CREDS }, async () => {
|
||||||
|
const token = await loginToken()
|
||||||
|
const res = await fetch(`${BASE}/api/admin/current-user/menus`, { headers: authHeaders(token) })
|
||||||
|
const body = await res.json()
|
||||||
|
const tree = parseMenuTree(body)
|
||||||
|
assert.ok(Array.isArray(tree))
|
||||||
|
assert.ok(tree.length > 0, 'super_admin 应能拿到全部 admin 菜单树')
|
||||||
|
const accountGroup = tree.find((n) => n.children?.length)
|
||||||
|
assert.ok(accountGroup, '菜单树应按分组组织')
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import { parseCurrentUser } from '../src/api/session-model.ts'
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_normal_primary_path', () => {
|
||||||
|
// 正常主路径:Java data:{item} 信封解出当前用户。
|
||||||
|
const user = parseCurrentUser({
|
||||||
|
success: true,
|
||||||
|
data: { item: { id: 1, username: 'admin', role: 'super_admin' } },
|
||||||
|
})
|
||||||
|
assert.equal(user.id, 1)
|
||||||
|
assert.equal(user.username, 'admin')
|
||||||
|
assert.equal(user.role, 'super_admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_normal_variant_input', () => {
|
||||||
|
// 正常变体:无 data 的 {item} 信封同样可解。
|
||||||
|
const user = parseCurrentUser({ success: true, item: { id: 3, username: '张伟恒', role: 'admin' } })
|
||||||
|
assert.equal(user.username, '张伟恒')
|
||||||
|
assert.equal(user.role, 'admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
// 正常重复:解析不修改输入、结果稳定。
|
||||||
|
const payload = { success: true, data: { item: { id: 2, username: 'u', role: 'normal' } } }
|
||||||
|
assert.deepEqual(parseCurrentUser(payload), parseCurrentUser(payload))
|
||||||
|
assert.deepEqual(payload, { success: true, data: { item: { id: 2, username: 'u', role: 'normal' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_boundary_empty_input', () => {
|
||||||
|
// 边界空值:响应缺 id 视为无效并抛可操作错误。
|
||||||
|
assert.throws(() => parseCurrentUser({ success: true, data: { item: {} } }), /缺少有效 id/)
|
||||||
|
assert.throws(() => parseCurrentUser(null), /缺少有效 id|请求失败/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_boundary_single_item', () => {
|
||||||
|
// 边界单元素:单用户最小字段即可。
|
||||||
|
const user = parseCurrentUser({ data: { item: { id: 9, username: 'root', role: 'super_admin' } } })
|
||||||
|
assert.equal(user.username, 'root')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限/缺字段:success=false 被拒绝并带后端 message。
|
||||||
|
assert.throws(
|
||||||
|
() => parseCurrentUser({ success: false, message: '未登录', code: 401 }),
|
||||||
|
/未登录/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:缺 role/username 不应崩溃;缺 id 拒绝。
|
||||||
|
const user = parseCurrentUser({ data: { item: { id: 5, username: '', role: '' } } })
|
||||||
|
assert.equal(user.username, '')
|
||||||
|
assert.throws(() => parseCurrentUser({ data: { item: { username: 'no-id' } } }), /缺少有效 id/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_023_current_user_adapter_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败:会话请求适配消费同一解析器;纯解析模块不含 axios 副作用。
|
||||||
|
const session = readSource('src/api/session.ts')
|
||||||
|
assert.match(session, /parseCurrentUser/)
|
||||||
|
const model = readSource('src/api/session-model.ts')
|
||||||
|
assert.match(model, /import \{ unwrap \} from '\.\/envelope(?:\.ts)?'/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user