Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */

/* 用户 —— 认证传输与稳定会话身份 */
export { createUserApis, userApis } from './user'
export type { AuthTokens, CreateUserApisOptions, SendCodePurpose, User, UserApis } from './user'

/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */
export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project'
export { projectApis } from './project'
Expand Down
198 changes: 198 additions & 0 deletions frontend/src/entities/user/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { registerApiUnauthorizedRecovery, type ApiClient } from '@/shared/api'

import { createUserApis } from './api'

const tokenResponse = {
access_token: 'access-token',
refresh_token: 'refresh-token',
token_type: 'bearer',
expires_in: 900,
user: {
id: 7,
email: 'reader@example.com',
nickname: 'Reader',
email_verified_at: '2026-08-07T01:02:03Z',
status: 37,
last_login_at: '2026-08-07T01:02:03Z',
create_at: '2026-08-01T01:02:03Z',
update_at: '2026-08-07T01:02:03Z',
},
}

describe('createUserApis', () => {
let request: ReturnType<typeof vi.fn>
let client: ApiClient

beforeEach(() => {
request = vi.fn()
client = {
request: request as unknown as ApiClient['request'],
requestList: vi.fn() as unknown as ApiClient['requestList'],
}
})

it('maps every authentication command to its exact backend path and request body', async () => {
request
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(tokenResponse)
.mockResolvedValueOnce(tokenResponse)
.mockResolvedValueOnce(tokenResponse)
.mockResolvedValueOnce(tokenResponse)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)

const apis = createUserApis({ client })

await apis.sendCode({ email: 'reader@example.com', purpose: 'reset_password' })
await apis.register({
email: 'reader@example.com',
password: 'password-123',
code: '123456',
nickname: 'Reader',
})
await apis.login({
email: 'reader@example.com',
password: 'password-123',
code: '123456',
})
await apis.loginByCode({ email: 'reader@example.com', code: '123456' })
await apis.refresh('refresh-token')
await apis.logout('refresh-token')
await apis.changePassword({ oldPassword: 'password-123', newPassword: 'new-password-123' })

expect(request.mock.calls).toEqual([
[
'/auth/send-code',
{
method: 'POST',
json: { email: 'reader@example.com', purpose: 'reset_password' },
},
],
[
'/auth/register',
{
method: 'POST',
json: {
email: 'reader@example.com',
password: 'password-123',
code: '123456',
nickname: 'Reader',
},
},
],
[
'/auth/login',
{
method: 'POST',
json: {
email: 'reader@example.com',
password: 'password-123',
code: '123456',
},
},
],
[
'/auth/login-by-code',
{
method: 'POST',
json: { email: 'reader@example.com', code: '123456' },
},
],
['/auth/refresh', { method: 'POST', json: { refresh_token: 'refresh-token' } }],
['/auth/logout', { method: 'POST', json: { refresh_token: 'refresh-token' } }],
[
'/auth/change-password',
{
method: 'POST',
json: { old_password: 'password-123', new_password: 'new-password-123' },
},
],
])
})

it('maps token and current-user payloads while preserving an unknown numeric status', async () => {
request.mockResolvedValueOnce(tokenResponse).mockResolvedValueOnce({
id: 7,
email: 'reader@example.com',
nickname: null,
email_verified_at: null,
status: 37,
})

const apis = createUserApis({ client })

await expect(
apis.loginByCode({ email: 'reader@example.com', code: '123456' }),
).resolves.toEqual({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: {
id: '7',
email: 'reader@example.com',
nickname: 'Reader',
emailVerifiedAt: '2026-08-07T01:02:03Z',
statusCode: 37,
},
})
await expect(apis.me()).resolves.toEqual({
id: '7',
email: 'reader@example.com',
nickname: null,
emailVerifiedAt: null,
statusCode: 37,
})
expect(request).toHaveBeenLastCalledWith('/auth/me')
})

it.each([
['missing id', { ...tokenResponse, user: { ...tokenResponse.user, id: null } }],
['missing email', { ...tokenResponse, user: { ...tokenResponse.user, email: null } }],
])('rejects a successful token response with %s', async (_label, response) => {
request.mockResolvedValue(response)
const apis = createUserApis({ client })

await expect(apis.refresh('refresh-token')).rejects.toMatchObject({
name: 'ApiError',
kind: 'invalid-response',
})
})

it('omits an empty optional nickname', async () => {
request.mockResolvedValue(tokenResponse)
const apis = createUserApis({ client })

await apis.register({
email: 'reader@example.com',
password: 'password-123',
code: '123456',
nickname: '',
})

expect(request).toHaveBeenCalledWith('/auth/register', {
method: 'POST',
json: {
email: 'reader@example.com',
password: 'password-123',
code: '123456',
},
})
})

it('disables global unauthorized recovery for authentication requests', async () => {
const recover = vi.fn(async () => true)
const unregister = registerApiUnauthorizedRecovery(recover)
const apis = createUserApis({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(JSON.stringify({ code: 401, message: 'refresh rejected', data: null }), {
status: 200,
}),
})

await expect(apis.refresh('expired-refresh-token')).rejects.toMatchObject({ code: 401 })
expect(recover).not.toHaveBeenCalled()
unregister()
})
})
163 changes: 163 additions & 0 deletions frontend/src/entities/user/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { AuthTokens, User, UserApis } from '.'

import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api'
import type { ApiClient, ApiClientOptions } from '@/shared/api'

interface UserDto {
id: number | null
email: string | null
nickname: string | null
email_verified_at: string | null
status: number
[key: string]: unknown
}

interface AuthTokensDto {
access_token: string
refresh_token: string
user: UserDto
[key: string]: unknown
}

export interface CreateUserApisOptions extends ApiClientOptions {
client?: ApiClient
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function invalidResponse(data: unknown): never {
throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data })
}

function toUser(value: unknown): User {
if (
!isRecord(value) ||
!Number.isInteger(value.id) ||
(value.id as number) <= 0 ||
typeof value.email !== 'string' ||
(typeof value.nickname !== 'string' && value.nickname !== null) ||
(typeof value.email_verified_at !== 'string' && value.email_verified_at !== null) ||
!Number.isInteger(value.status)
) {
invalidResponse(value)
}

const dto = value as unknown as UserDto
return {
id: String(dto.id),
email: dto.email as string,
nickname: dto.nickname,
emailVerifiedAt: dto.email_verified_at,
statusCode: dto.status,
}
}

function toAuthTokens(value: unknown): AuthTokens {
if (
!isRecord(value) ||
typeof value.access_token !== 'string' ||
typeof value.refresh_token !== 'string' ||
!Object.hasOwn(value, 'user')
) {
invalidResponse(value)
}

const dto = value as unknown as AuthTokensDto
return {
accessToken: dto.access_token,
refreshToken: dto.refresh_token,
user: toUser(dto.user),
}
}

export function createUserApis(options: CreateUserApisOptions = {}): UserApis {
const { client, ...clientOptions } = options
const apiClient =
client ??
createApiClient({
...clientOptions,
getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken,
recoverUnauthorized: false,
})

return {
async sendCode(input) {
await apiClient.request<null>('/auth/send-code', { method: 'POST', json: input })
},

async register(input) {
const json = {
email: input.email,
password: input.password,
code: input.code,
...(input.nickname ? { nickname: input.nickname } : {}),
}
return toAuthTokens(
await apiClient.request<AuthTokensDto>('/auth/register', { method: 'POST', json }),
)
},

async login(input) {
return toAuthTokens(
await apiClient.request<AuthTokensDto>('/auth/login', { method: 'POST', json: input }),
)
},

async loginByCode(input) {
return toAuthTokens(
await apiClient.request<AuthTokensDto>('/auth/login-by-code', {
method: 'POST',
json: input,
}),
)
},

async refresh(refreshToken) {
return toAuthTokens(
await apiClient.request<AuthTokensDto>('/auth/refresh', {
method: 'POST',
json: { refresh_token: refreshToken },
}),
)
},

async logout(refreshToken) {
await apiClient.request<null>('/auth/logout', {
method: 'POST',
json: { refresh_token: refreshToken },
})
},

async me() {
return toUser(await apiClient.request<UserDto>('/auth/me'))
},

async changePassword(input) {
await apiClient.request<null>('/auth/change-password', {
method: 'POST',
json: { old_password: input.oldPassword, new_password: input.newPassword },
})
},
}
}

let defaultApis: UserApis | undefined

function getDefaultApis(): UserApis {
defaultApis ??= createUserApis()
return defaultApis
}

/** 延迟创建默认 client,避免仅导入 entities 时要求运行环境已经配置 API 地址。 */
export const userApis: UserApis = {
sendCode: (input) => getDefaultApis().sendCode(input),
register: (input) => getDefaultApis().register(input),
login: (input) => getDefaultApis().login(input),
loginByCode: (input) => getDefaultApis().loginByCode(input),
refresh: (refreshToken) => getDefaultApis().refresh(refreshToken),
logout: (refreshToken) => getDefaultApis().logout(refreshToken),
me: () => getDefaultApis().me(),
changePassword: (input) => getDefaultApis().changePassword(input),
}
Loading