Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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