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
1 change: 1 addition & 0 deletions frontend/src/app/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ afterEach(() => {
cleanup()
window.localStorage.clear()
vi.useRealTimers()
vi.restoreAllMocks()
})

describe('AppRoutes authentication boundary', () => {
Expand Down
25 changes: 20 additions & 5 deletions frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,25 @@ const creditAccount: CreditAccount = {
}

function createQuotaMock(): QuotaApis & {
getBalance: ReturnType<typeof vi.fn>
listTransactions: ReturnType<typeof vi.fn>
[K in keyof QuotaApis]: ReturnType<typeof vi.fn>
} {
return {
getBalance: vi.fn(async () => creditAccount),
listTransactions: vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 })),
getInviteCode: vi.fn(async () => ({
code: 'AB23CD45',
usedCount: 0,
expiresAt: '2026-09-16T01:02:03Z',
createdAt: '2026-08-17T01:02:03Z',
updatedAt: '2026-08-17T01:02:03Z',
})),
generateInviteCode: vi.fn(async () => ({
code: 'XY89KL23',
usedCount: 0,
expiresAt: '2026-09-16T01:02:03Z',
createdAt: '2026-08-17T01:02:03Z',
updatedAt: '2026-08-17T01:02:03Z',
})),
}
}

Expand Down Expand Up @@ -204,7 +217,7 @@ describe('AppHeader', () => {
it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => {
renderHeader('/quick-start?mode=fast#brief')

const entry = await screen.findByRole('link', { name: '登录' })
const entry = await screen.findByRole('link', { name: '登录 / 注册' })
expect(entry.getAttribute('href')).toBe(
'/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief',
)
Expand All @@ -220,7 +233,7 @@ describe('AppHeader', () => {
fireEvent.click(screen.getByRole('button', { name: '退出登录' }))

await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/'))
expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy()
expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy()
expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token')
})

Expand All @@ -240,6 +253,8 @@ describe('AppHeader', () => {
renderHeader('/workspace')

const hint = await screen.findByRole('status', { name: '邀请奖励提示' })
expect(screen.getByText('每日前 3 位好友,你各得 200 积分')).toBeTruthy()
expect(screen.getByText('好友注册共得 500 积分')).toBeTruthy()
expect(screen.getByRole('link', { name: '去看看邀请奖励' }).getAttribute('href')).toBe(
'/account?section=invite',
)
Expand Down Expand Up @@ -280,7 +295,7 @@ describe('AppHeader', () => {
fireEvent.click(screen.getByRole('button', { name: '退出登录' }))

await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/'))
expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy()
expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy()
})

it('没有昵称时使用邮箱展示账号身份', async () => {
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,10 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {})
) : session.state.status === 'guest' ? (
<Link
to={accountEntry}
aria-label="登录"
aria-label="登录 / 注册"
className="inline-flex min-h-10 items-center rounded-lg border border-app-ink/14 bg-app-surface-raised/45 px-3 text-[13px] font-medium whitespace-nowrap text-app-ink-soft transition-colors hover:bg-app-surface-raised/75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-accent"
>
{/* 内测关闭公开注册。重新开放时改回「登录 / 注册」。 */}
<span className="hidden sm:inline">登录</span>
<span className="hidden sm:inline">登录 / 注册</span>
<span className="sm:hidden">登录</span>
</Link>
) : (
Expand All @@ -246,7 +245,10 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {})
<div className="relative flex items-start gap-3">
<div className="min-w-0">
<p className="text-[13px] font-medium leading-5 text-app-ink-soft">
邀请好友,双方各得 200 积分
每日前 3 位好友,你各得 200 积分
</p>
<p className="mt-0.5 text-[11px] leading-4 text-app-faint">
好友注册共得 500 积分
</p>
<Link
to="/account?section=invite"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
CreateQuotaApisOptions,
CreditAccount,
CreditTransaction,
InviteCode,
QuotaApis,
QuotaTransactionPageQuery,
} from './quota'
Expand Down
76 changes: 66 additions & 10 deletions frontend/src/entities/quota/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,72 @@ describe('createQuotaApis', () => {
})
})

it('读取并映射当前用户的邀请码', async () => {
request.mockResolvedValue({
code: 'AB23CD45',
used_count: 3,
expires_at: '2026-09-11T01:02:03Z',
create_at: '2026-08-12T01:02:03Z',
update_at: '2026-08-17T01:02:03Z',
})

await expect(createQuotaApis({ client }).getInviteCode()).resolves.toEqual({
code: 'AB23CD45',
usedCount: 3,
expiresAt: '2026-09-11T01:02:03Z',
createdAt: '2026-08-12T01:02:03Z',
updatedAt: '2026-08-17T01:02:03Z',
})
expect(request).toHaveBeenCalledWith('/quota/invite/code')
})

it('轮换邀请码并映射新的有效期', async () => {
request.mockResolvedValueOnce({
code: 'XY89KL23',
used_count: 0,
expires_at: '2026-09-16T03:00:00Z',
create_at: '2026-08-17T03:00:00Z',
update_at: '2026-08-17T03:00:00Z',
})

const apis = createQuotaApis({ client })
await expect(apis.generateInviteCode()).resolves.toMatchObject({
code: 'XY89KL23',
usedCount: 0,
expiresAt: '2026-09-16T03:00:00Z',
})
expect(request).toHaveBeenNthCalledWith(1, '/quota/invite/generate', { method: 'POST' })
})

it('默认适配器读取环境地址并携带当前登录凭证', async () => {
vi.resetModules()
const fetchFn = vi.fn<typeof fetch>(async (input) => {
const url = String(input)
const body = url.includes('/quota/transactions')
? {
code: 200,
message: 'ok',
data: [],
total: 0,
page: 1,
page_size: 20,
}
: { code: 200, message: 'ok', data: accountResponse }
let body: unknown
if (url.includes('/quota/transactions')) {
body = {
code: 200,
message: 'ok',
data: [],
total: 0,
page: 1,
page_size: 20,
}
} else if (url.includes('/quota/invite/')) {
body = {
code: 200,
message: 'ok',
data: {
code: 'AB23CD45',
used_count: 3,
expires_at: '2026-09-11T01:02:03Z',
create_at: '2026-08-12T01:02:03Z',
update_at: '2026-08-17T01:02:03Z',
},
}
} else {
body = { code: 200, message: 'ok', data: accountResponse }
}
return Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))
})
vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test')
Expand All @@ -118,6 +170,8 @@ describe('createQuotaApis', () => {
items: [],
total: 0,
})
await expect(quotaApis.getInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' })
await expect(quotaApis.generateInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' })
expect(fetchFn).toHaveBeenCalledWith(
'https://api.windup.test/quota/balance',
expect.objectContaining({
Expand All @@ -129,6 +183,8 @@ describe('createQuotaApis', () => {
expect(fetchFn.mock.calls[1]?.[0]).toBe(
'https://api.windup.test/quota/transactions?page=1&page_size=20',
)
expect(fetchFn.mock.calls[2]?.[0]).toBe('https://api.windup.test/quota/invite/code')
expect(fetchFn.mock.calls[3]?.[0]).toBe('https://api.windup.test/quota/invite/generate')
} finally {
unregister()
vi.unstubAllGlobals()
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/entities/quota/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
CreditAccount,
CreditTransaction,
InviteCode,
QuotaApis,
QuotaTransactionPageQuery,
} from './types'
Expand Down Expand Up @@ -30,6 +31,14 @@ interface CreditTransactionDto {
create_at: string
}

interface InviteCodeDto {
code: string
used_count: number
expires_at: string
create_at: string
update_at: string
}

export interface CreateQuotaApisOptions extends ApiClientOptions {
client?: ApiClient
}
Expand Down Expand Up @@ -60,6 +69,16 @@ function toCreditTransaction(dto: CreditTransactionDto): CreditTransaction {
}
}

function toInviteCode(dto: InviteCodeDto): InviteCode {
return {
code: dto.code,
usedCount: dto.used_count,
expiresAt: dto.expires_at,
createdAt: dto.create_at,
updatedAt: dto.update_at,
}
}

export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis {
const { client, ...clientOptions } = options
const protectedClient =
Expand All @@ -82,6 +101,14 @@ export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis
)
return { ...result, items: result.items.map(toCreditTransaction) }
},
async getInviteCode() {
return toInviteCode(await protectedClient.request<InviteCodeDto>('/quota/invite/code'))
},
async generateInviteCode() {
return toInviteCode(
await protectedClient.request<InviteCodeDto>('/quota/invite/generate', { method: 'POST' }),
)
},
}
}

Expand All @@ -96,4 +123,6 @@ function getDefaultApis(): QuotaApis {
export const quotaApis: QuotaApis = {
getBalance: () => getDefaultApis().getBalance(),
listTransactions: (query) => getDefaultApis().listTransactions(query),
getInviteCode: () => getDefaultApis().getInviteCode(),
generateInviteCode: () => getDefaultApis().generateInviteCode(),
}
1 change: 1 addition & 0 deletions frontend/src/entities/quota/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type { CreateQuotaApisOptions } from './api'
export type {
CreditAccount,
CreditTransaction,
InviteCode,
QuotaApis,
QuotaTransactionPageQuery,
} from './types'
11 changes: 11 additions & 0 deletions frontend/src/entities/quota/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,18 @@ export interface CreditTransaction {

export type QuotaTransactionPageQuery = PageQuery

/** 当前登录用户未过期的邀请码;usedCount 只统计当前码的成功注册次数。 */
export interface InviteCode {
code: string
usedCount: number
expiresAt: string
createdAt: string
updatedAt: string
}

export interface QuotaApis {
getBalance(): Promise<CreditAccount>
listTransactions(query?: QuotaTransactionPageQuery): Promise<Paged<CreditTransaction>>
getInviteCode(): Promise<InviteCode>
generateInviteCode(): Promise<InviteCode>
}
26 changes: 26 additions & 0 deletions frontend/src/entities/user/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('createUserApis', () => {
password: 'password-123',
code: '123456',
nickname: 'Reader',
inviteCode: 'AB23CD45',
})
await apis.login({
email: 'reader@example.com',
Expand Down Expand Up @@ -79,6 +80,7 @@ describe('createUserApis', () => {
email: 'reader@example.com',
password: 'password-123',
code: '123456',
invite_code: 'AB23CD45',
nickname: 'Reader',
},
},
Expand Down Expand Up @@ -185,6 +187,7 @@ describe('createUserApis', () => {
password: 'password-123',
code: '123456',
nickname: '',
inviteCode: 'AB23CD45',
})

expect(request).toHaveBeenCalledWith('/auth/register', {
Expand All @@ -193,10 +196,33 @@ describe('createUserApis', () => {
email: 'reader@example.com',
password: 'password-123',
code: '123456',
invite_code: 'AB23CD45',
},
})
})

it('omits the optional invite code from public registration', async () => {
request.mockResolvedValue(tokenResponse)
const apis = createUserApis({ client })

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

expect(request).toHaveBeenCalledWith('/auth/register', {
method: 'POST',
json: {
email: 'reader@example.com',
password: 'password-123',
code: '123456',
},
})
const options = request.mock.calls[0]?.[1] as { json?: object } | undefined
expect(Object.hasOwn(options?.json ?? {}, 'invite_code')).toBe(false)
})

it('disables global unauthorized recovery for authentication requests', async () => {
const recover = vi.fn(async () => true)
const unregister = registerApiUnauthorizedRecovery(recover)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/entities/user/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function createUserApis(options: CreateUserApisOptions = {}): UserApis {
email: input.email,
password: input.password,
code: input.code,
...(input.inviteCode ? { invite_code: input.inviteCode } : {}),
...(input.nickname ? { nickname: input.nickname } : {}),
}
return toAuthTokens(
Expand Down
1 change: 1 addition & 0 deletions frontend/src/entities/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface UserApis {
password: string
code: string
nickname?: string
inviteCode?: string
}): Promise<AuthTokens>
/** 密码登录不带验证码;验证码只用于注册、免密登录与重设密码。 */
login(input: { email: string; password: string }): Promise<AuthTokens>
Expand Down
Loading
Loading