add sprint 4 share link failure fallback
This commit is contained in:
37
apps/web/e2e/share-link-failure-fallback.spec.ts
Normal file
37
apps/web/e2e/share-link-failure-fallback.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* V10 · Sprint 4 · T-B-41 · e2e: ShareLink failure fallback
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Share Link failure fallback (V10 Sprint 4 · T-B-41)', () => {
|
||||
test('share dialog shows retryable fallback when create endpoint fails', async ({ page }) => {
|
||||
await page.route((url) => url.pathname === '/api/share-link/latest', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: 'null',
|
||||
});
|
||||
});
|
||||
|
||||
await page.route((url) => url.pathname === '/api/share-link', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'share service unavailable' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route((url) => url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/share-link'), async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
await page.goto('/share');
|
||||
|
||||
await expect(page.getByRole('heading', { name: '分享管理' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '创建分享链接' }).click();
|
||||
await page.getByRole('button', { name: '创建分享链接(30天有效)' }).click();
|
||||
|
||||
await expect(page.getByRole('alert').filter({ hasText: '分享链接创建失败' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '重试创建分享链接' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { ShareDialog } from './ShareDialog';
|
||||
import { renderWithProviders } from '@/test/renderWithProviders';
|
||||
import { server } from '@/test/mocks/server';
|
||||
|
||||
describe('ShareDialog', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
@@ -25,4 +27,19 @@ describe('ShareDialog', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a retryable fallback when share-link creation fails', async () => {
|
||||
server.use(
|
||||
http.post('/api/share-link', () => {
|
||||
return HttpResponse.json({ message: 'share service unavailable' }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ShareDialog planId="p1" planTitle="测试" open onClose={() => {}} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '创建分享链接(30天有效)' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('分享链接创建失败');
|
||||
expect(screen.getByRole('button', { name: '重试创建分享链接' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,13 +92,18 @@ export function ShareDialog({ planId, planTitle, open, onClose }: Props) {
|
||||
|
||||
{!create.data ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{create.isError && (
|
||||
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
||||
分享链接创建失败,请稍后重试。
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={create.isPending}
|
||||
className="w-full min-h-[48px] py-3 rounded-xl bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{create.isPending ? '创建中…' : '创建分享链接(30天有效)'}
|
||||
{create.isPending ? '创建中…' : create.isError ? '重试创建分享链接' : '创建分享链接(30天有效)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -132,6 +137,12 @@ export function ShareDialog({ planId, planTitle, open, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats.isError && (
|
||||
<div className="w-full rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-700" role="status">
|
||||
统计暂不可用,链接仍可正常访问。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.data && (
|
||||
<div className="w-full grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-gray-50 px-2 py-2">
|
||||
@@ -158,9 +169,14 @@ export function ShareDialog({ planId, planTitle, open, onClose }: Props) {
|
||||
>
|
||||
撤销分享链接
|
||||
</button>
|
||||
{del.isError && (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
撤销失败,请稍后重试。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { StatsCard } from './StatsCard';
|
||||
import { renderWithProviders } from '@/test/renderWithProviders';
|
||||
import { server } from '@/test/mocks/server';
|
||||
|
||||
describe('StatsCard', () => {
|
||||
it('renders 3 stat cards', () => {
|
||||
@@ -18,4 +20,16 @@ describe('StatsCard', () => {
|
||||
renderWithProviders(<StatsCard code="XYZ" />);
|
||||
expect(screen.getByRole('list', { name: '分享统计' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a fallback when stats are unavailable', async () => {
|
||||
server.use(
|
||||
http.get('/api/share-link/:code/stats', () => {
|
||||
return HttpResponse.json({ message: 'stats unavailable' }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<StatsCard code="ABC123" />);
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('统计暂不可用');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Card {
|
||||
}
|
||||
|
||||
export function StatsCard({ code }: Props) {
|
||||
const { data, isLoading } = useShareLinkStatsQuery(code);
|
||||
const { data, isError, isLoading } = useShareLinkStatsQuery(code);
|
||||
|
||||
const cards: ReadonlyArray<Card> = [
|
||||
{
|
||||
@@ -37,6 +37,14 @@ export function StatsCard({ code }: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-700" role="status">
|
||||
统计暂不可用,链接状态将在后台继续同步。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3" role="list" aria-label="分享统计">
|
||||
{cards.map((card) => (
|
||||
@@ -54,4 +62,4 @@ export function StatsCard({ code }: Props) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useShareLinkCreate, useShareLinkDelete, useShareLinkStatsQuery } from './useShareLink';
|
||||
import { server } from '@/test/mocks/server';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({
|
||||
@@ -30,6 +32,36 @@ describe('useShareLink hooks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a transient create failure once before succeeding', async () => {
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.post('/api/share-link', () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
return HttpResponse.json({ message: 'temporary unavailable' }, { status: 503 });
|
||||
}
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 'RETRY1',
|
||||
share_url: 'https://example.test/s/RETRY1',
|
||||
target_id: 'portal-token-1',
|
||||
expires_at_iso: null,
|
||||
revoked: false,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useShareLinkCreate(), { wrapper });
|
||||
|
||||
result.current.mutate({ planId: 'portal-token-1', expiresIn: 30 });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.code).toBe('RETRY1');
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('revokes by code through the backend revoke endpoint', async () => {
|
||||
const { result } = renderHook(() => useShareLinkDelete(), { wrapper });
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export const SHARE_LINK_RETRY_DELAY_MS = 100;
|
||||
|
||||
export const ShareLinkCreateInputSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
expiresIn: z.union([z.literal(7), z.literal(30), z.literal('forever')]),
|
||||
@@ -58,6 +60,16 @@ export const shareLinkKeys = {
|
||||
stats: (code: string) => [...shareLinkKeys.all, 'stats', code] as const,
|
||||
};
|
||||
|
||||
function isRetryableShareLinkError(error: Error): boolean {
|
||||
const status = (error as { status?: unknown }).status;
|
||||
if (typeof status !== 'number') return true;
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function retryShareLinkOnce(failureCount: number, error: Error): boolean {
|
||||
return failureCount < 1 && isRetryableShareLinkError(error);
|
||||
}
|
||||
|
||||
export function useShareLinkCreate() {
|
||||
return useMutation<ShareLinkResponse, Error, ShareLinkCreateInput>({
|
||||
mutationFn: (input) =>
|
||||
@@ -72,6 +84,8 @@ export function useShareLinkCreate() {
|
||||
},
|
||||
ShareLinkResponseSchema,
|
||||
),
|
||||
retry: retryShareLinkOnce,
|
||||
retryDelay: SHARE_LINK_RETRY_DELAY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,6 +99,8 @@ export function useShareLinkDelete() {
|
||||
);
|
||||
return { success: res.revoked };
|
||||
},
|
||||
retry: retryShareLinkOnce,
|
||||
retryDelay: SHARE_LINK_RETRY_DELAY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,6 +109,8 @@ export function useShareLinkLatestQuery() {
|
||||
queryKey: shareLinkKeys.latest(),
|
||||
queryFn: () => apiClient.get<ShareLinkResponse | null>('/share-link/latest', ShareLinkLatestResponseSchema),
|
||||
staleTime: 60 * 1000,
|
||||
retry: retryShareLinkOnce,
|
||||
retryDelay: SHARE_LINK_RETRY_DELAY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,5 +120,7 @@ export function useShareLinkStatsQuery(code: string | null) {
|
||||
queryFn: () => apiClient.get<ShareLinkStatsResponse>(`/share-link/${code}/stats`, ShareLinkStatsResponseSchema),
|
||||
enabled: Boolean(code),
|
||||
refetchInterval: 30_000,
|
||||
retry: retryShareLinkOnce,
|
||||
retryDelay: SHARE_LINK_RETRY_DELAY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { ShareDialogPage } from './ShareDialogPage';
|
||||
import { renderWithProviders } from '@/test/renderWithProviders';
|
||||
import { server } from '@/test/mocks/server';
|
||||
|
||||
describe('ShareDialogPage', () => {
|
||||
it('renders the latest share-link status panel with real stats', async () => {
|
||||
@@ -18,4 +20,17 @@ describe('ShareDialogPage', () => {
|
||||
expect(within(panel).getByText('5')).toBeInTheDocument();
|
||||
expect(within(panel).getByText(/2026\/7\/3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a fallback when the latest share-link status fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/share-link/latest', () => {
|
||||
return HttpResponse.json({ message: 'share status unavailable' }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ShareDialogPage />);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('分享状态暂不可用');
|
||||
expect(screen.getByRole('button', { name: '创建分享链接' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,13 @@ export function ShareDialogPage() {
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-8 text-center">
|
||||
<BarChart3 className="w-12 h-12 mx-auto text-gray-300" aria-hidden="true" />
|
||||
<p className="mt-2 text-sm text-gray-500">暂无分享链接</p>
|
||||
{latest.isError ? (
|
||||
<p className="mt-2 text-sm text-amber-700" role="alert">
|
||||
分享状态暂不可用,请稍后刷新重试。
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-500">暂无分享链接</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
Reference in New Issue
Block a user