From c9554597b8be3504ef2fc514b750588097d5b685 Mon Sep 17 00:00:00 2001 From: Frontend Developer Date: Sat, 4 Jul 2026 16:56:34 +0800 Subject: [PATCH] add sprint 4 share link failure fallback --- .../e2e/share-link-failure-fallback.spec.ts | 37 +++++++++++++++++++ apps/web/src/components/ShareDialog.test.tsx | 19 +++++++++- apps/web/src/components/ShareDialog.tsx | 20 +++++++++- apps/web/src/components/StatsCard.test.tsx | 16 +++++++- apps/web/src/components/StatsCard.tsx | 12 +++++- apps/web/src/hooks/useShareLink.test.tsx | 32 ++++++++++++++++ apps/web/src/hooks/useShareLink.ts | 20 ++++++++++ apps/web/src/pages/ShareDialogPage.test.tsx | 15 ++++++++ apps/web/src/pages/ShareDialogPage.tsx | 8 +++- 9 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 apps/web/e2e/share-link-failure-fallback.spec.ts diff --git a/apps/web/e2e/share-link-failure-fallback.spec.ts b/apps/web/e2e/share-link-failure-fallback.spec.ts new file mode 100644 index 0000000..6a6c524 --- /dev/null +++ b/apps/web/e2e/share-link-failure-fallback.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/src/components/ShareDialog.test.tsx b/apps/web/src/components/ShareDialog.test.tsx index 645c65d..d1f79c5 100644 --- a/apps/web/src/components/ShareDialog.test.tsx +++ b/apps/web/src/components/ShareDialog.test.tsx @@ -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); }); -}); \ No newline at end of file + + 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( {}} />); + + await userEvent.click(screen.getByRole('button', { name: '创建分享链接(30天有效)' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('分享链接创建失败'); + expect(screen.getByRole('button', { name: '重试创建分享链接' })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ShareDialog.tsx b/apps/web/src/components/ShareDialog.tsx index 466799e..cf3cb5e 100644 --- a/apps/web/src/components/ShareDialog.tsx +++ b/apps/web/src/components/ShareDialog.tsx @@ -92,13 +92,18 @@ export function ShareDialog({ planId, planTitle, open, onClose }: Props) { {!create.data ? (
+ {create.isError && ( +
+ 分享链接创建失败,请稍后重试。 +
+ )}
+ {stats.isError && ( +
+ 统计暂不可用,链接仍可正常访问。 +
+ )} + {stats.data && (
@@ -158,9 +169,14 @@ export function ShareDialog({ planId, planTitle, open, onClose }: Props) { > 撤销分享链接 + {del.isError && ( +

+ 撤销失败,请稍后重试。 +

+ )}
)}
); -} \ No newline at end of file +} diff --git a/apps/web/src/components/StatsCard.test.tsx b/apps/web/src/components/StatsCard.test.tsx index 9bb975c..d24e1d4 100644 --- a/apps/web/src/components/StatsCard.test.tsx +++ b/apps/web/src/components/StatsCard.test.tsx @@ -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(); expect(screen.getByRole('list', { name: '分享统计' })).toBeInTheDocument(); }); -}); \ No newline at end of file + + 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(); + + expect(await screen.findByRole('status')).toHaveTextContent('统计暂不可用'); + }); +}); diff --git a/apps/web/src/components/StatsCard.tsx b/apps/web/src/components/StatsCard.tsx index 92557d3..c55eff8 100644 --- a/apps/web/src/components/StatsCard.tsx +++ b/apps/web/src/components/StatsCard.tsx @@ -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 = [ { @@ -37,6 +37,14 @@ export function StatsCard({ code }: Props) { }, ]; + if (isError) { + return ( +
+ 统计暂不可用,链接状态将在后台继续同步。 +
+ ); + } + return (
{cards.map((card) => ( @@ -54,4 +62,4 @@ export function StatsCard({ code }: Props) { ))}
); -} \ No newline at end of file +} diff --git a/apps/web/src/hooks/useShareLink.test.tsx b/apps/web/src/hooks/useShareLink.test.tsx index f7a11d9..b85da59 100644 --- a/apps/web/src/hooks/useShareLink.test.tsx +++ b/apps/web/src/hooks/useShareLink.test.tsx @@ -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 }); diff --git a/apps/web/src/hooks/useShareLink.ts b/apps/web/src/hooks/useShareLink.ts index ef9efc9..a876a76 100644 --- a/apps/web/src/hooks/useShareLink.ts +++ b/apps/web/src/hooks/useShareLink.ts @@ -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({ 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('/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(`/share-link/${code}/stats`, ShareLinkStatsResponseSchema), enabled: Boolean(code), refetchInterval: 30_000, + retry: retryShareLinkOnce, + retryDelay: SHARE_LINK_RETRY_DELAY_MS, }); } diff --git a/apps/web/src/pages/ShareDialogPage.test.tsx b/apps/web/src/pages/ShareDialogPage.test.tsx index a32762e..25bcfba 100644 --- a/apps/web/src/pages/ShareDialogPage.test.tsx +++ b/apps/web/src/pages/ShareDialogPage.test.tsx @@ -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(); + + expect(await screen.findByRole('alert')).toHaveTextContent('分享状态暂不可用'); + expect(screen.getByRole('button', { name: '创建分享链接' })).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/pages/ShareDialogPage.tsx b/apps/web/src/pages/ShareDialogPage.tsx index e6428e5..ee87fa6 100644 --- a/apps/web/src/pages/ShareDialogPage.tsx +++ b/apps/web/src/pages/ShareDialogPage.tsx @@ -55,7 +55,13 @@ export function ShareDialogPage() { ) : (