diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index 33c363bc..4f920c0f 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -1,19 +1,75 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { MemoryRouter } from 'react-router' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import type { AuthTokens, UserApis } from '@/entities' +import { AuthSessionProvider } from '@/features/auth-session' import { AppHeader } from './app-header' -afterEach(cleanup) +const user = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 0, +} + +function tokens(): AuthTokens { + return { accessToken: 'access-token', refreshToken: 'rotated-refresh-token', user } +} + +function createApis(): UserApis & Record> { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens()), + login: vi.fn(async () => tokens()), + loginByCode: vi.fn(async () => tokens()), + refresh: vi.fn(async () => tokens()), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + } +} + +function LocationProbe() { + const location = useLocation() + return ( + {`${location.pathname}${location.search}${location.hash}`} + ) +} + +function renderHeader(entry = '/', apis = createApis()) { + return { + apis, + ...render( + + + + + + + + } + /> + + + , + ), + } +} + +afterEach(() => { + cleanup() + window.localStorage.clear() +}) describe('AppHeader', () => { it('保留三个产品入口,并将工作流路由归入创作', () => { - render( - - - , - ) + renderHeader('/workflow-editor/run-1') expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/') expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects') @@ -22,14 +78,31 @@ describe('AppHeader', () => { }) it('在首页只高亮首页一项', () => { - render( - - - , - ) + renderHeader() expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page') expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull() expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull() }) + + it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => { + renderHeader('/quick-start?mode=fast#brief') + + const entry = await screen.findByRole('link', { name: '登录 / 注册' }) + expect(entry.getAttribute('href')).toBe( + '/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief', + ) + }) + + it('显示登录用户并在登出后回到首页访客态', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + const { apis } = renderHeader('/projects') + + expect(await screen.findByText('Reader')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '退出登录' })) + + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) + expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() + expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') + }) }) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 4ad4db7b..ce068398 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -1,4 +1,6 @@ -import { Link, useLocation } from 'react-router' +import { Link, useLocation, useNavigate } from 'react-router' + +import { useAuthSession } from '@/features/auth-session' interface ProductNavigationItem { to: string @@ -53,8 +55,19 @@ function getWorkspaceLabel(pathname: string): { title: string; detail: string } * 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。 */ export function AppHeader() { - const { pathname } = useLocation() + const { pathname, search, hash } = useLocation() + const navigate = useNavigate() + const session = useAuthSession() const workspace = getWorkspaceLabel(pathname) + const accountEntry = `/?${new URLSearchParams({ + account: 'login', + returnTo: `${pathname}${search}${hash}`, + })}` + + function signOut() { + const returnHome = () => navigate('/', { replace: true }) + void session.logout().then(returnHome, returnHome) + } return (
@@ -62,10 +75,10 @@ export function AppHeader() { - Windup + Windup @@ -74,38 +87,76 @@ export function AppHeader() { - + ) : ( +
+ + {session.state.user.nickname || session.state.user.email} + + +
+ )} + +
) } diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx index 8a9bfed7..eedfcb8c 100644 --- a/frontend/src/features/account-panel/index.test.tsx +++ b/frontend/src/features/account-panel/index.test.tsx @@ -275,13 +275,10 @@ describe('AppShell account panel host', () => { it('does not require an auth context while the panel is closed', () => { render( - -

当前页面

-
+
, ) - expect(screen.getByText('当前页面')).toBeTruthy() expect(screen.queryByRole('dialog')).toBeNull() }) diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx index 148dc8e6..4b056935 100644 --- a/frontend/src/pages/playtest/index.test.tsx +++ b/frontend/src/pages/playtest/index.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { AppRoutes } from '@/app' +import { GuestAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { @@ -26,9 +27,11 @@ function renderPlaytest(path: string, fetchFn?: typeof globalThis.fetch) { vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', fetchFn ?? createProjectAssetsBackend().fetch) return render( - - - , + + + + + , ) } diff --git a/frontend/src/pages/project-create/index.test.tsx b/frontend/src/pages/project-create/index.test.tsx index b9b05f11..a32b9ed7 100644 --- a/frontend/src/pages/project-create/index.test.tsx +++ b/frontend/src/pages/project-create/index.test.tsx @@ -4,14 +4,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' import { AppRoutes } from '@/app' -import { registerApiAccessTokenProvider } from '@/shared/api' +import { AuthenticatedAuthSession, GuestAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' -const revokeProviders: Array<() => void> = [] - afterEach(() => { cleanup() - while (revokeProviders.length) revokeProviders.pop()?.() + window.localStorage.clear() vi.unstubAllEnvs() vi.unstubAllGlobals() }) @@ -23,17 +21,17 @@ function installBackend() { return backend } -/** 登录模块尚未落地,测试里用同一个 provider 边界冒充已签发的 access token。 */ -function signIn(accessToken = 'access-token-for-test') { - revokeProviders.push(registerApiAccessTokenProvider(() => accessToken)) -} - -function renderProjectCreate() { - return render( - - - , +async function renderProjectCreate(authenticated = true) { + const Session = authenticated ? AuthenticatedAuthSession : GuestAuthSession + const result = render( + + + + + , ) + if (authenticated) await screen.findByText('Reader') + return result } function creationRequests(backend: ReturnType) { @@ -45,8 +43,7 @@ function creationRequests(backend: ReturnType describe('ProjectCreatePage', () => { it('按项目契约提交表单并进入创建出的项目', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.change(screen.getByLabelText('游戏视角'), { target: { value: 'top-down' } }) @@ -73,18 +70,19 @@ describe('ProjectCreatePage', () => { it('没有登录凭证时禁用创建并说明原因', async () => { const backend = installBackend() - renderProjectCreate() + await renderProjectCreate(false) const submit = await screen.findByRole('button', { name: '创建项目' }) expect(submit.hasAttribute('disabled')).toBe(true) - expect(screen.getByText(/登录/)).toBeTruthy() + expect( + screen.getByText('创建项目需要先登录。登录模块尚未接入,创建入口暂时保持关闭。'), + ).toBeTruthy() expect(creationRequests(backend)).toHaveLength(0) }) it('名称超过 20 字时在提交前拦下', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾'.repeat(21) } }) fireEvent.click(screen.getByRole('button', { name: '创建项目' })) @@ -95,8 +93,7 @@ describe('ProjectCreatePage', () => { it('名称重复时保留已填内容并显示后端给出的原因', async () => { installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '点灯人 · MVP' } }) fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } }) @@ -108,8 +105,7 @@ describe('ProjectCreatePage', () => { it('连续点击创建只发出一次请求', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) const submit = screen.getByRole('button', { name: '创建项目' }) @@ -121,8 +117,7 @@ describe('ProjectCreatePage', () => { }) it('名称留空时在提交前拦下', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.click(screen.getByRole('button', { name: '创建项目' })) @@ -132,8 +127,7 @@ describe('ProjectCreatePage', () => { it('精灵宽高越界时在提交前拦下', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.change(screen.getByLabelText('宽度(像素)'), { target: { value: '16' } }) @@ -148,8 +142,7 @@ describe('ProjectCreatePage', () => { it('传输失败时收敛成一句统一文案', async () => { installBackend() vi.stubGlobal('fetch', () => Promise.reject(new TypeError('offline'))) - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.click(screen.getByRole('button', { name: '创建项目' })) @@ -159,8 +152,7 @@ describe('ProjectCreatePage', () => { it('改动任一字段后撤掉上一次的错误', async () => { installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.click(screen.getByRole('button', { name: '创建项目' })) expect(await screen.findByRole('alert')).toBeTruthy() @@ -171,8 +163,7 @@ describe('ProjectCreatePage', () => { }) it('点尺寸预设后撤掉上一次的错误', async () => { installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.change(screen.getByLabelText('宽度(像素)'), { target: { value: '16' } }) diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index c9fd6388..4a2f59d0 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' import { AppRoutes } from '@/app' +import { GuestAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { @@ -23,9 +24,11 @@ describe('ProjectsPage', () => { it('renders backend Projects as the first browsing level', async () => { installBackend() const { container } = render( - - - , + + + + + , ) expect(await screen.findByRole('heading', { name: '项目中心' })).toBeTruthy() @@ -41,9 +44,11 @@ describe('ProjectsPage', () => { it('sends creation to the project create page and deletes through the Project API', async () => { const backend = installBackend() render( - - - , + + + + + , ) expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) @@ -70,9 +75,11 @@ describe('ProjectsPage', () => { vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', backend.fetch) render( - - - , + + + + + , ) expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(12) diff --git a/frontend/src/test/auth-session.tsx b/frontend/src/test/auth-session.tsx new file mode 100644 index 00000000..ca4cb20d --- /dev/null +++ b/frontend/src/test/auth-session.tsx @@ -0,0 +1,66 @@ +/* oxlint-disable react/only-export-components -- 测试会话组件与配套 fixture 共用一个入口。 */ +import type { ReactNode } from 'react' + +import type { AuthTokens, User, UserApis } from '@/entities' +import { AuthSessionProvider, useAuthSession } from '@/features/auth-session' + +export const testUser: User = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 0, +} + +function tokens(): AuthTokens { + return { + accessToken: 'access-token-for-test', + refreshToken: 'rotated-refresh-token', + user: testUser, + } +} + +export function createAuthenticatedTestApis(): UserApis { + return { + sendCode: async () => undefined, + register: async () => tokens(), + login: async () => tokens(), + loginByCode: async () => tokens(), + refresh: async () => tokens(), + logout: async () => undefined, + me: async () => testUser, + changePassword: async () => undefined, + } +} + +const guestApis: UserApis = { + sendCode: async () => undefined, + register: async () => Promise.reject(new Error('guest test session does not register')), + login: async () => Promise.reject(new Error('guest test session does not log in')), + loginByCode: async () => Promise.reject(new Error('guest test session does not log in')), + refresh: async () => Promise.reject(new Error('guest test session has no refresh token')), + logout: async () => undefined, + me: async () => Promise.reject(new Error('guest test session has no current user')), + changePassword: async () => + Promise.reject(new Error('guest test session cannot change password')), +} + +/** 为直接渲染 AppRoutes 的页面测试补齐生产组合根中的访客会话。 */ +export function GuestAuthSession({ children }: { children: ReactNode }) { + return {children} +} + +export function AuthenticatedAuthSession({ children }: { children: ReactNode }) { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + return ( + + {children} + + ) +} + +/** 业务页面只在 access token 已恢复后挂载,避免把启动中的空 token 固化进首次 render。 */ +function AuthenticatedChildren({ children }: { children: ReactNode }) { + const session = useAuthSession() + return session.state.status === 'authenticated' ? children : null +}