fix: close auth, permission, contract and e2e review blockers
This commit is contained in:
@@ -18,6 +18,7 @@ import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf'
|
||||
import type { TokenBundle } from '@/types'
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
let inFlightRefreshBundle: Promise<TokenBundle> | null = null
|
||||
|
||||
function isFormDataBody(body: unknown): body is FormData {
|
||||
return typeof FormData !== 'undefined' && body instanceof FormData
|
||||
@@ -145,6 +146,40 @@ async function refreshAccessToken(): Promise<TokenBundle> {
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function performTokenRefresh(): Promise<TokenBundle> {
|
||||
if (inFlightRefreshBundle) {
|
||||
return inFlightRefreshBundle
|
||||
}
|
||||
|
||||
startRefreshing()
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const tokenBundle = await refreshAccessToken()
|
||||
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
|
||||
setRefreshToken(tokenBundle.refresh_token)
|
||||
return tokenBundle
|
||||
} finally {
|
||||
endRefreshing()
|
||||
clearRefreshPromise()
|
||||
inFlightRefreshBundle = null
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightRefreshBundle = promise
|
||||
setRefreshPromise(
|
||||
promise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function refreshSessionBundle(): Promise<TokenBundle> {
|
||||
return await performTokenRefresh()
|
||||
}
|
||||
|
||||
async function performRefresh(): Promise<string> {
|
||||
if (isRefreshing()) {
|
||||
const promise = getRefreshPromise()
|
||||
@@ -160,26 +195,8 @@ async function performRefresh(): Promise<string> {
|
||||
return token
|
||||
}
|
||||
|
||||
startRefreshing()
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const tokenBundle = await refreshAccessToken()
|
||||
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
|
||||
setRefreshToken(tokenBundle.refresh_token)
|
||||
return tokenBundle.access_token
|
||||
} finally {
|
||||
endRefreshing()
|
||||
clearRefreshPromise()
|
||||
}
|
||||
})()
|
||||
|
||||
setRefreshPromise(
|
||||
promise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return promise
|
||||
const tokenBundle = await performTokenRefresh()
|
||||
return tokenBundle.access_token
|
||||
}
|
||||
|
||||
async function resolveAuthorizationHeader(auth: boolean): Promise<string | null> {
|
||||
|
||||
@@ -345,14 +345,12 @@ export function ContactBindingsSection({
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入验证码"
|
||||
addonAfter={
|
||||
<Button type="link" size="small" loading={sendCodeLoading} onClick={handleSendCode}>
|
||||
发送验证码
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input placeholder="请输入验证码" />
|
||||
<Button type="link" loading={sendCodeLoading} onClick={handleSendCode}>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="current_password" label="当前密码">
|
||||
|
||||
@@ -29,7 +29,7 @@ const authContextValue: AuthContextValue = {
|
||||
|
||||
function renderBootstrapAdminPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/bootstrap-admin']}>
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }} initialEntries={['/bootstrap-admin']}>
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BootstrapAdminPage />
|
||||
</AuthContext.Provider>
|
||||
@@ -88,7 +88,8 @@ describe('BootstrapAdminPage', () => {
|
||||
|
||||
await user.type(screen.getByPlaceholderText('管理员用户名'), 'bootstrap_admin')
|
||||
await user.type(screen.getByPlaceholderText('管理员昵称(选填)'), 'Bootstrap Admin')
|
||||
await user.type(screen.getByPlaceholderText('管理员邮箱(选填)'), 'bootstrap_admin@example.com')
|
||||
await user.type(screen.getByPlaceholderText('管理员邮箱'), 'bootstrap_admin@example.com')
|
||||
await user.type(screen.getByPlaceholderText('Bootstrap Secret'), 'bootstrap-secret-demo')
|
||||
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
|
||||
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
|
||||
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
|
||||
@@ -99,6 +100,7 @@ describe('BootstrapAdminPage', () => {
|
||||
nickname: 'Bootstrap Admin',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
password: 'Bootstrap123!@#',
|
||||
bootstrap_secret: 'bootstrap-secret-demo',
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const DEFAULT_CAPABILITIES: AuthCapabilities = {
|
||||
type BootstrapAdminFormValues = {
|
||||
username: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
email: string
|
||||
bootstrapSecret: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
@@ -71,7 +72,8 @@ export function BootstrapAdminPage() {
|
||||
const tokenBundle = await bootstrapAdmin({
|
||||
username: values.username.trim(),
|
||||
nickname: values.nickname?.trim() || undefined,
|
||||
email: values.email?.trim() || undefined,
|
||||
email: values.email!.trim(),
|
||||
bootstrap_secret: values.bootstrapSecret!.trim(),
|
||||
password: values.password,
|
||||
})
|
||||
await onLoginSuccess(tokenBundle)
|
||||
@@ -110,7 +112,7 @@ export function BootstrapAdminPage() {
|
||||
初始化首个管理员账号
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
当前版本不内置默认账号。首次部署时,请先创建首个管理员账号,初始化完成后系统会自动关闭该入口。
|
||||
当前版本不内置默认账号。首次部署时,请提供 Bootstrap Secret 并创建首个管理员账号,初始化完成后系统会自动关闭该入口。
|
||||
</Paragraph>
|
||||
|
||||
<Alert
|
||||
@@ -143,15 +145,29 @@ export function BootstrapAdminPage() {
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
rules={[
|
||||
{ required: true, message: '请输入管理员邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="管理员邮箱(选填)"
|
||||
placeholder="管理员邮箱"
|
||||
size="large"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bootstrapSecret"
|
||||
rules={[{ required: true, message: '请输入 Bootstrap Secret' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="Bootstrap Secret"
|
||||
size="large"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入管理员密码' }]}
|
||||
|
||||
@@ -41,16 +41,13 @@ const defaultCapabilities: AuthCapabilities = {
|
||||
}
|
||||
|
||||
const activeRegisterResponse: RegisterResponse = {
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'new-user',
|
||||
email: 'new-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'New User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
message: 'registered successfully',
|
||||
id: 2,
|
||||
username: 'new-user',
|
||||
email: 'new-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'New User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
}
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
@@ -61,7 +58,7 @@ vi.mock('@/services/auth', () => ({
|
||||
|
||||
function renderRegisterPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }} initialEntries={['/register']}>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -321,16 +318,13 @@ describe('RegisterPage', () => {
|
||||
email_activation: true,
|
||||
})
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 3,
|
||||
username: 'inactive-user',
|
||||
email: 'inactive-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'Inactive User',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, please check your email to activate the account',
|
||||
id: 3,
|
||||
username: 'inactive-user',
|
||||
email: 'inactive-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'Inactive User',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
@@ -350,16 +344,13 @@ describe('RegisterPage', () => {
|
||||
|
||||
it('shows the generic activation summary when the new inactive account has no email address', async () => {
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 4,
|
||||
username: 'inactive-without-email',
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, activation required',
|
||||
id: 4,
|
||||
username: 'inactive-without-email',
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
@@ -38,10 +38,10 @@ type RegisterFormValues = {
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
function buildRegisterSummary(result: RegisterResponse) {
|
||||
if (result.user.status === 0) {
|
||||
if (result.user.email) {
|
||||
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
|
||||
function buildRegisterSummary(user: RegisterResponse) {
|
||||
if (user.status === 0) {
|
||||
if (user.email) {
|
||||
return `账号已创建,激活邮件会发送到 ${user.email}。请完成激活后再登录。`
|
||||
}
|
||||
return '账号已创建,请按页面提示完成激活后再登录。'
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function RegisterPage() {
|
||||
form.resetFields()
|
||||
setSmsCountdown(0)
|
||||
setSubmitted(result)
|
||||
message.success(result.user.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
|
||||
message.success(result.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '注册失败,请检查输入信息后重试'))
|
||||
} finally {
|
||||
@@ -137,7 +137,7 @@ export function RegisterPage() {
|
||||
}, [capabilities.sms_code, form])
|
||||
|
||||
if (submitted) {
|
||||
const activationEmail = submitted.user.email?.trim()
|
||||
const activationEmail = submitted.email?.trim()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
@@ -146,7 +146,7 @@ export function RegisterPage() {
|
||||
title="注册成功"
|
||||
subTitle={(
|
||||
<Paragraph>
|
||||
<Text strong>{submitted.user.username}</Text>
|
||||
<Text strong>{submitted.username}</Text>
|
||||
{' '}
|
||||
{buildRegisterSummary(submitted)}
|
||||
</Paragraph>
|
||||
@@ -155,7 +155,7 @@ export function RegisterPage() {
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
submitted.user.status === 0 && activationEmail && capabilities.email_activation ? (
|
||||
submitted.status === 0 && activationEmail && capabilities.email_activation ? (
|
||||
<Link key="activation" to={`/activate-account?email=${encodeURIComponent(activationEmail)}`}>
|
||||
<Button>重新发送激活邮件</Button>
|
||||
</Link>
|
||||
|
||||
@@ -2,17 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const refreshSessionBundleMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
refreshSessionBundle: refreshSessionBundleMock,
|
||||
}))
|
||||
|
||||
describe('auth service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
refreshSessionBundleMock.mockReset()
|
||||
postMock.mockResolvedValue(undefined)
|
||||
refreshSessionBundleMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('loads public auth capabilities without auth headers', async () => {
|
||||
@@ -84,6 +88,28 @@ describe('auth service', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('verifies password-login totp with the temporary challenge token', async () => {
|
||||
const { verifyTOTPAfterPasswordLogin } = await import('./auth')
|
||||
|
||||
await verifyTOTPAfterPasswordLogin({
|
||||
user_id: 42,
|
||||
code: '123456',
|
||||
device_id: 'device-1',
|
||||
temp_token: 'temp-token-demo',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/login/totp-verify',
|
||||
{
|
||||
user_id: 42,
|
||||
code: '123456',
|
||||
device_id: 'device-1',
|
||||
temp_token: 'temp-token-demo',
|
||||
},
|
||||
{ auth: false, credentials: 'include' },
|
||||
)
|
||||
})
|
||||
|
||||
it('submits public registration without auth headers', async () => {
|
||||
const { register } = await import('./auth')
|
||||
|
||||
@@ -106,7 +132,7 @@ describe('auth service', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('submits first-admin bootstrap without auth headers', async () => {
|
||||
it('submits first-admin bootstrap with bootstrap secret header', async () => {
|
||||
const { bootstrapAdmin } = await import('./auth')
|
||||
|
||||
await bootstrapAdmin({
|
||||
@@ -114,6 +140,7 @@ describe('auth service', () => {
|
||||
password: 'Bootstrap123!@#',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
nickname: 'Bootstrap Admin',
|
||||
bootstrap_secret: 'bootstrap-secret-demo',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
@@ -124,7 +151,13 @@ describe('auth service', () => {
|
||||
email: 'bootstrap_admin@example.com',
|
||||
nickname: 'Bootstrap Admin',
|
||||
},
|
||||
{ auth: false, credentials: 'include' },
|
||||
{
|
||||
auth: false,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-Bootstrap-Secret': 'bootstrap-secret-demo',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -192,12 +225,13 @@ describe('auth service', () => {
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/logout', undefined, { credentials: 'include' })
|
||||
})
|
||||
|
||||
it('refreshes the session with credentials even when no body token is supplied', async () => {
|
||||
it('refreshes the session through the shared refresh single-flight when no body token is supplied', async () => {
|
||||
const { refreshSession } = await import('./auth')
|
||||
|
||||
await refreshSession()
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
expect(refreshSessionBundleMock).toHaveBeenCalledTimes(1)
|
||||
expect(postMock).not.toHaveBeenCalledWith(
|
||||
'/auth/refresh',
|
||||
undefined,
|
||||
{ auth: false, credentials: 'include' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get, post } from '@/lib/http/client'
|
||||
import { refreshSessionBundle } from '@/lib/http/client'
|
||||
import type {
|
||||
ActionMessageResponse,
|
||||
AuthCapabilities,
|
||||
@@ -59,7 +60,14 @@ export function register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
}
|
||||
|
||||
export function bootstrapAdmin(data: BootstrapAdminRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/bootstrap-admin', data, { auth: false, credentials: 'include' })
|
||||
const { bootstrap_secret, ...payload } = data
|
||||
return post<TokenBundle>('/auth/bootstrap-admin', payload, {
|
||||
auth: false,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-Bootstrap-Secret': bootstrap_secret,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function activateEmail(token: string): Promise<ActionMessageResponse> {
|
||||
@@ -81,8 +89,11 @@ export function sendSmsCode(data: SendSmsCodeRequest): Promise<void> {
|
||||
}
|
||||
|
||||
export function refreshSession(refreshToken?: string | null): Promise<TokenBundle> {
|
||||
const body = refreshToken ? { refresh_token: refreshToken } : undefined
|
||||
return post<TokenBundle>('/auth/refresh', body, { auth: false, credentials: 'include' })
|
||||
if (!refreshToken) {
|
||||
return refreshSessionBundle()
|
||||
}
|
||||
|
||||
return post<TokenBundle>('/auth/refresh', { refresh_token: refreshToken }, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function getOAuthAuthorizationUrl(
|
||||
|
||||
@@ -28,6 +28,29 @@ describe('social account service', () => {
|
||||
expect(getMock).toHaveBeenCalledWith('/users/me/social-accounts')
|
||||
})
|
||||
|
||||
it('normalizes object-wrapped social account payloads', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
social_accounts: [
|
||||
{
|
||||
provider: 'github',
|
||||
provider_user_id: '123',
|
||||
provider_username: 'octocat',
|
||||
bound_at: '2026-03-27 20:00:00',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { listSocialAccounts } = await import('./social-accounts')
|
||||
const result = await listSocialAccounts()
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: 'github',
|
||||
provider_username: 'octocat',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('starts social binding with the current verification payload', async () => {
|
||||
const { startSocialBinding } = await import('./social-accounts')
|
||||
|
||||
|
||||
@@ -6,8 +6,35 @@ import type {
|
||||
SocialBindingStartResponse,
|
||||
} from '@/types'
|
||||
|
||||
export function listSocialAccounts(): Promise<SocialAccountInfo[]> {
|
||||
return get<SocialAccountInfo[]>('/users/me/social-accounts')
|
||||
interface SocialAccountsResponse {
|
||||
items?: SocialAccountInfo[]
|
||||
accounts?: SocialAccountInfo[]
|
||||
social_accounts?: SocialAccountInfo[]
|
||||
}
|
||||
|
||||
function normalizeSocialAccounts(payload: SocialAccountInfo[] | SocialAccountsResponse): SocialAccountInfo[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.items)) {
|
||||
return payload.items
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.accounts)) {
|
||||
return payload.accounts
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.social_accounts)) {
|
||||
return payload.social_accounts
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function listSocialAccounts(): Promise<SocialAccountInfo[]> {
|
||||
const payload = await get<SocialAccountInfo[] | SocialAccountsResponse>('/users/me/social-accounts')
|
||||
return normalizeSocialAccounts(payload)
|
||||
}
|
||||
|
||||
export function startSocialBinding(
|
||||
|
||||
@@ -20,6 +20,52 @@ describe('users service', () => {
|
||||
delMock.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes backend user list payloads that use users/limit/offset fields', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 7,
|
||||
username: 'e2e_admin',
|
||||
email: 'admin@example.com',
|
||||
nickname: '管理员',
|
||||
status: '1',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const { listUsers } = await import('./users')
|
||||
const result = await listUsers({ page: 1, page_size: 20 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/users', { page: 1, page_size: 20 })
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
username: 'e2e_admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '',
|
||||
nickname: '管理员',
|
||||
avatar: '',
|
||||
gender: 0,
|
||||
birthday: '',
|
||||
region: '',
|
||||
bio: '',
|
||||
status: 1,
|
||||
last_login_at: '',
|
||||
last_login_ip: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a user through the protected users endpoint', async () => {
|
||||
const payload = {
|
||||
username: 'new-user',
|
||||
|
||||
@@ -17,12 +17,59 @@ import type {
|
||||
AssignUserRolesRequest,
|
||||
} from '@/types/user'
|
||||
|
||||
interface RawUserListResponse {
|
||||
items?: Partial<User>[]
|
||||
users?: Partial<User>[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
function normalizeUser(user: Partial<User>): User {
|
||||
const numericStatus = typeof user.status === 'string' ? Number(user.status) : user.status
|
||||
return {
|
||||
id: user.id ?? 0,
|
||||
username: user.username ?? '',
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
nickname: user.nickname ?? '',
|
||||
avatar: user.avatar ?? '',
|
||||
gender: user.gender ?? 0,
|
||||
birthday: user.birthday ?? '',
|
||||
region: user.region ?? '',
|
||||
bio: user.bio ?? '',
|
||||
status: (typeof numericStatus === 'number' && !Number.isNaN(numericStatus) ? numericStatus : 0) as UserStatus,
|
||||
last_login_at: user.last_login_at ?? '',
|
||||
last_login_ip: user.last_login_ip ?? '',
|
||||
created_at: user.created_at ?? '',
|
||||
updated_at: user.updated_at ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUserListResponse(result?: RawUserListResponse | null): PaginatedData<User> {
|
||||
const payload = result ?? {}
|
||||
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.users) ? payload.users : []
|
||||
const pageSize = payload.page_size ?? payload.limit ?? items.length
|
||||
const offset = payload.offset ?? 0
|
||||
const page = payload.page ?? (pageSize > 0 ? Math.floor(offset / pageSize) + 1 : 1)
|
||||
|
||||
return {
|
||||
items: items.map(normalizeUser),
|
||||
total: payload.total ?? items.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* GET /api/v1/users
|
||||
*/
|
||||
export function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
|
||||
return get<PaginatedData<User>>('/users', params as Record<string, string | number | boolean | undefined>)
|
||||
export async function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
|
||||
const result = await get<RawUserListResponse>('/users', params as Record<string, string | number | boolean | undefined>)
|
||||
return normalizeUserListResponse(result)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,44 @@ describe('webhooks service', () => {
|
||||
expect(result.data[2].events).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes backend webhook list payloads that use items/limit/offset fields', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 11,
|
||||
name: 'Compat Hook',
|
||||
url: 'https://example.com/compat',
|
||||
events: '["user.updated"]',
|
||||
status: 1,
|
||||
max_retries: 3,
|
||||
timeout_sec: 10,
|
||||
created_by: 1,
|
||||
created_at: '2026-03-27 20:20:00',
|
||||
updated_at: '2026-03-27 20:20:00',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const { listWebhooks } = await import('./webhooks')
|
||||
const result = await listWebhooks({ page: 1, page_size: 20 })
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
id: 11,
|
||||
name: 'Compat Hook',
|
||||
events: ['user.updated'],
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends create, update, delete, and delivery requests through the HTTP client', async () => {
|
||||
postMock.mockResolvedValue({
|
||||
id: 1,
|
||||
|
||||
@@ -33,18 +33,42 @@ function normalizeWebhook(webhook: RawWebhook): Webhook {
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data?: T[]
|
||||
items?: T[]
|
||||
webhooks?: T[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
function normalizeWebhookList(result: PaginatedResponse<RawWebhook>): { data: Webhook[]; total: number; page: number; page_size: number } {
|
||||
const rawItems = Array.isArray(result.data)
|
||||
? result.data
|
||||
: Array.isArray(result.items)
|
||||
? result.items
|
||||
: Array.isArray(result.webhooks)
|
||||
? result.webhooks
|
||||
: []
|
||||
const data = rawItems.map(normalizeWebhook)
|
||||
const pageSize = result.page_size ?? result.limit ?? data.length
|
||||
const offset = result.offset ?? 0
|
||||
const page = result.page ?? (pageSize > 0 ? Math.floor(offset / pageSize) + 1 : 1)
|
||||
|
||||
return {
|
||||
data,
|
||||
total: result.total ?? data.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWebhooks(
|
||||
params?: WebhookListParams,
|
||||
): Promise<{ data: Webhook[]; total: number; page: number; page_size: number }> {
|
||||
const result = await get<PaginatedResponse<RawWebhook>>('/webhooks', params as Record<string, string | number | boolean | undefined>)
|
||||
const webhooks = result.data.map(normalizeWebhook)
|
||||
return { data: webhooks, total: result.total, page: result.page, page_size: result.page_size }
|
||||
return normalizeWebhookList(result)
|
||||
}
|
||||
|
||||
export function createWebhook(data: CreateWebhookRequest): Promise<Webhook> {
|
||||
|
||||
Reference in New Issue
Block a user