diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 0e8b731c..b95e67ff 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -51,7 +51,8 @@ test.describe('frontend smoke', () => { await expect(page.getByLabel('Page URL')).toBeVisible(); await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'More' })).toBeVisible(); + await expect(page.getByLabel('Utilities')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible(); await page.getByLabel('Page URL').fill('https://example.com/articles'); await page.getByRole('button', { name: 'Generate feed URL' }).click(); @@ -63,6 +64,7 @@ test.describe('frontend smoke', () => { await page.getByRole('button', { name: 'Back' }).click(); await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'More' })).toBeVisible(); + await expect(page.getByLabel('Utilities')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible(); }); }); diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 6bb75741..7002e692 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -1,18 +1,43 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; import { http, HttpResponse } from 'msw'; -import { server, buildFeedResponse } from './mocks/server'; +import { server, buildFeedResponse, buildStructuredErrorResponse } from './mocks/server'; import { App } from '../components/App'; describe('App contract', () => { const token = 'contract-token'; - const authenticate = () => { - globalThis.localStorage.setItem('html2rss_access_token', token); - }; + beforeEach(() => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create'); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); + globalThis.sessionStorage.setItem('html2rss_access_token', token); + }); + + it('shows feed result when the API returns structured create payload and preview feed', async () => { + const nativeFetch = globalThis.fetch; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + if (String(input).endsWith('/api/v1/feeds/generated-token.json')) { + expect((init?.headers as Record | undefined)?.Accept).toBe('application/feed+json'); + return Promise.resolve( + new Response( + JSON.stringify({ + items: [ + { + title: 'Contract Item', + content_text: 'Contract preview excerpt.', + url: 'https://example.com/contract-item', + date_published: '2024-01-01T00:00:00Z', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/feed+json' } } + ) + ); + } - it('shows feed result when API responds with success', async () => { - authenticate(); + return nativeFetch(input, init); + }); server.use( http.post('/api/v1/feeds', async ({ request }) => { @@ -27,10 +52,11 @@ describe('App contract', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', - }) + }), + { status: 201 } ); }), - http.get('/api/v1/feeds/generated-token.json', ({ request }) => { + http.get('http://localhost:3000/api/v1/feeds/generated-token.json', ({ request }) => { expect(request.headers.get('accept')).toBe('application/feed+json'); return HttpResponse.json( @@ -48,108 +74,67 @@ describe('App contract', () => { headers: { 'content-type': 'application/feed+json' }, } ); + }), + http.get('/api/v1/feeds/generated-token.json', ({ request }) => { + expect(request.headers.get('accept')).toBe('application/feed+json'); + + return HttpResponse.json({ + items: [ + { + title: 'Contract Item', + content_text: 'Contract preview excerpt.', + url: 'https://example.com/contract-item', + date_published: '2024-01-01T00:00:00Z', + }, + ], + }); }) ); render(); - await screen.findByLabelText('Page URL'); await waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement; fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'result'); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( - 'href', - 'http://localhost:3000/api/v1/feeds/generated-token.json' - ); expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.getByText('Preview')).toBeInTheDocument(); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); - expect(screen.getByText('Contract Item')).toBeInTheDocument(); }); + fetchSpy.mockRestore(); }); - it('loads instance metadata from /api/v1 without trailing slash', async () => { - let slashlessMetadataRequests = 0; - let trailingSlashMetadataRequests = 0; - - server.use( - http.get('/api/v1', () => { - slashlessMetadataRequests += 1; - - return HttpResponse.json({ - success: true, - data: { - api: { - name: 'html2rss-web API', - description: 'RESTful API for converting websites to RSS feeds', - openapi_url: 'http://example.test/openapi.yaml', - }, - instance: { - feed_creation: { - enabled: true, - access_token_required: true, - }, - featured_feeds: [], - }, - }, - }); - }), - http.get('/api/v1/', () => { - trailingSlashMetadataRequests += 1; - - return HttpResponse.text('', { status: 404 }); - }) - ); - - render(); - - await screen.findByLabelText('Page URL'); - - expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument(); - expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument(); - expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1); - expect(trailingSlashMetadataRequests).toBe(0); - }); - - it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => { - server.use( - http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })), - http.get('/api/v1/', () => HttpResponse.text('', { status: 404 })) - ); - - render(); - - await screen.findByText('Instance metadata unavailable'); - - expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument(); - }); - - it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => { - authenticate(); - + it('reopens token recovery when a saved token is rejected by structured auth metadata', async () => { server.use( http.post('/api/v1/feeds', async () => - HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 }) + HttpResponse.json( + buildStructuredErrorResponse({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', + }), + { status: 401 } + ) ) ); render(); - await screen.findByLabelText('Page URL'); await waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); }); fireEvent.input(screen.getByLabelText('Page URL'), { @@ -160,7 +145,7 @@ describe('App contract', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); expect(screen.getByText('Enter access token')).toBeInTheDocument(); - expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); + expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument(); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); }); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 5f58cfa3..e6d129a0 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -14,19 +14,31 @@ vi.mock('../hooks/useApiMetadata', () => ({ useApiMetadata: vi.fn(), })); -vi.mock('../hooks/useStrategies', () => ({ - useStrategies: vi.fn(), -})); - import { useAccessToken } from '../hooks/useAccessToken'; import { useApiMetadata } from '../hooks/useApiMetadata'; import { useFeedConversion } from '../hooks/useFeedConversion'; -import { useStrategies } from '../hooks/useStrategies'; const mockUseAccessToken = useAccessToken as any; const mockUseApiMetadata = useApiMetadata as any; const mockUseFeedConversion = useFeedConversion as any; -const mockUseStrategies = useStrategies as any; +const mockCreatedFeedResult = { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + }, + preview: { + items: [], + error: undefined, + isLoading: true, + }, + workflowState: 'created' as const, + warnings: [], + retry: undefined, +}; describe('App', () => { const mockSaveToken = vi.fn(); @@ -34,11 +46,13 @@ describe('App', () => { const mockConvertFeed = vi.fn(); const mockClearConversionError = vi.fn(); const mockClearResult = vi.fn(); - const mockRetryReadinessCheck = vi.fn(); + const mockRetryPreviewFetch = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create'); + globalThis.localStorage.clear(); + mockConvertFeed.mockResolvedValue(mockCreatedFeedResult); mockUseAccessToken.mockReturnValue({ token: undefined, @@ -75,16 +89,7 @@ describe('App', () => { convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, - retryReadinessCheck: mockRetryReadinessCheck, - }); - - mockUseStrategies.mockReturnValue({ - strategies: [ - { id: 'faraday', name: 'faraday', display_name: 'Default' }, - { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, - ], - isLoading: false, - error: undefined, + retryPreviewFetch: mockRetryPreviewFetch, }); }); @@ -92,10 +97,12 @@ describe('App', () => { render(); expect(screen.getByLabelText('html2rss')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'html2rss' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'html2rss' })).toHaveAttribute('href', '/#/create'); expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Utilities')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument(); + expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'create'); }); it('keeps the page url field permissive enough for hostname-only input', () => { @@ -116,29 +123,29 @@ describe('App', () => { }); }); - it('prefers faraday as the default strategy when available', () => { - render(); - - return waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); - }); - }); - - it('falls back to the first available strategy when browserless is unavailable', () => { - mockUseStrategies.mockReturnValue({ - strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }], + it('submits create requests without exposing strategy selection', async () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, isLoading: false, error: undefined, }); render(); - return waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await waitFor(() => { + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); }); }); - it('auto-submits a prefilled url using the resolved default strategy', async () => { + it('auto-submits a prefilled url without persisting strategy state', async () => { mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -156,10 +163,20 @@ describe('App', () => { render(); await waitFor(() => { - expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'saved-token'); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); + expect(globalThis.location.hash).toBe('#/result/generated-token'); }); }); + it('shows the create flow when opening a stale result deep link', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/result/generated-token'); + + render(); + + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Saved result unavailable' })).not.toBeInTheDocument(); + }); + it('shows inline token prompt when submitting without a token', async () => { render(); @@ -169,9 +186,11 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); expect(screen.getByText('Enter access token')).toBeInTheDocument(); + expect(globalThis.location.hash).toMatch(/^#\/token/); + expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'token_prompt'); expect(screen.getByLabelText('Page URL')).toBeDisabled(); - expect(screen.getByRole('combobox')).toBeDisabled(); - expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Utilities')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument(); expect(screen.getByText('Required by this instance.')).toBeInTheDocument(); expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument(); @@ -218,6 +237,7 @@ describe('App', () => { }); it('renders the result panel when a feed is available', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/result/example-token'); mockUseFeedConversion.mockReturnValue({ isConverting: false, result: { @@ -225,48 +245,69 @@ describe('App', () => { id: 'feed-123', name: 'Example Feed', url: 'https://example.com/articles', - strategy: 'faraday', feed_token: 'example-token', public_url: '/api/v1/feeds/example-token', json_public_url: '/api/v1/feeds/example-token.json', }, preview: { items: [], - error: 'Preview unavailable right now.', isLoading: false, }, - readinessPhase: 'preview_unavailable', + workflowState: 'preview_failed' as const, + warnings: [ + { + code: 'preview_unavailable', + message: 'Preview unavailable right now.', + retryable: false, + nextAction: 'none', + }, + ], retry: undefined, }, error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, - retryReadinessCheck: mockRetryReadinessCheck, + retryPreviewFetch: mockRetryPreviewFetch, }); render(); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'result'); expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); + expect(screen.getByText('Feed link created')).toBeInTheDocument(); expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); + return waitFor(() => { + expect(globalThis.location.hash).toMatch(/^#\/create/); + }); }); it('surfaces conversion errors to the user', () => { mockUseFeedConversion.mockReturnValue({ isConverting: false, result: undefined, - error: 'Access denied', + error: { + kind: 'auth', + code: 'UNAUTHORIZED', + retryable: false, + nextAction: 'enter_token', + retryAction: 'none', + message: 'Access denied', + }, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, - retryReadinessCheck: mockRetryReadinessCheck, + retryPreviewFetch: mockRetryPreviewFetch, }); render(); - expect(screen.getByText('Could not create feed link')).toBeInTheDocument(); + expect(screen.getByText("Couldn't create feed yet")).toBeInTheDocument(); expect(screen.getByText('Access denied')).toBeInTheDocument(); }); @@ -278,13 +319,12 @@ describe('App', () => { convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, - retryReadinessCheck: mockRetryReadinessCheck, + retryPreviewFetch: mockRetryPreviewFetch, }); render(); expect(screen.getByText('Creating feed link')).toBeInTheDocument(); - expect(screen.getByText('Checking readiness now.')).toBeInTheDocument(); }); it('clears stored token from instance info', () => { @@ -299,8 +339,7 @@ describe('App', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); - fireEvent.click(screen.getByRole('button', { name: 'Clear saved token' })); + fireEvent.click(screen.getByRole('button', { name: 'Logout' })); expect(mockClearToken).toHaveBeenCalled(); }); @@ -317,8 +356,6 @@ describe('App', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityItems = [ ...screen .getByLabelText('Utilities') @@ -328,10 +365,10 @@ describe('App', () => { expect(utilityItems).toEqual([ 'Try included feeds', 'Bookmarklet', + 'Logout', + 'Install from Docker Hub', 'OpenAPI spec', 'Source code', - 'Install from Docker Hub', - 'Clear saved token', ]); }); @@ -348,7 +385,7 @@ describe('App', () => { await waitFor(() => { expect(mockSaveToken).toHaveBeenCalledWith('token-123'); - expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'token-123'); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'token-123'); }); }); @@ -361,7 +398,13 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); + mockConvertFeed.mockRejectedValueOnce( + Object.assign(new Error('Unauthorized'), { + code: 'UNAUTHORIZED', + status: 401, + kind: 'auth', + }) + ); render(); @@ -389,7 +432,13 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); + mockConvertFeed.mockRejectedValueOnce( + Object.assign(new Error('Unauthorized'), { + code: 'UNAUTHORIZED', + status: 401, + kind: 'auth', + }) + ); render(); @@ -401,7 +450,10 @@ describe('App', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); fireEvent.click(screen.getByRole('button', { name: 'Back' })); - expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); + await waitFor(() => { + expect(globalThis.location.hash).toMatch(/^#\/create/); + }); + expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument(); expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument(); }); @@ -423,12 +475,11 @@ describe('App', () => { }); it('builds a bookmarklet that returns to the root app entry', () => { - globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create'); render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); const bookmarklet = screen.getByRole('link', { name: 'Bookmarklet' }); - expect(bookmarklet.getAttribute('href')).toContain('/?url='); + expect(bookmarklet.getAttribute('href')).toContain('/#/create?url='); expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent'); }); @@ -438,11 +489,28 @@ describe('App', () => { render(); await screen.findByText('Enter access token'); + expect(globalThis.location.hash).toMatch(/^#\/token/); expect(screen.getByLabelText('Page URL')).toHaveValue('https://example.com/articles'); expect(mockConvertFeed).not.toHaveBeenCalled(); }); - it('offers a direct alternate strategy retry after conversion failure', async () => { + it('shows generic retry action for alternate retry metadata and reruns create', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'server', + code: 'INTERNAL_SERVER_ERROR', + retryable: true, + nextAction: 'retry', + retryAction: 'alternate', + message: 'Browserless failed.', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryPreviewFetch: mockRetryPreviewFetch, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -451,34 +519,38 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed - .mockRejectedValueOnce( - Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - manualRetryStrategy: 'browserless', - }) - ) - .mockResolvedValueOnce(); render(); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); - await screen.findByRole('button', { name: 'Retry with browserless' }); - fireEvent.click(screen.getByRole('button', { name: 'Retry with browserless' })); + expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); await waitFor(() => { - expect(mockConvertFeed).toHaveBeenLastCalledWith( - 'https://example.com/articles', - 'browserless', - 'saved-token' - ); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); }); }); - it('does not offer a duplicate retry action after automatic fallback already failed', async () => { + it('shows Try again for primary retry metadata and reruns the create flow', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'server', + code: 'INTERNAL_SERVER_ERROR', + retryable: true, + nextAction: 'retry', + retryAction: 'primary', + message: 'Browserless failed.', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryPreviewFetch: mockRetryPreviewFetch, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -487,24 +559,36 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce( - Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - manualRetryStrategy: '', - }) - ); render(); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); - await screen.findByText('Tried faraday first, then browserless. Browserless failed.'); - expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); + }); }); it('does not treat non-token forbidden failures as token rejection or strategy-recovery UX', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'server', + code: 'FORBIDDEN', + retryable: false, + nextAction: 'none', + retryAction: 'none', + message: 'URL not allowed for this account', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryPreviewFetch: mockRetryPreviewFetch, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -513,33 +597,58 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce( - Object.assign(new Error('URL not allowed for this account'), { - manualRetryStrategy: 'browserless', - }) - ); render(); - fireEvent.input(screen.getByLabelText('Page URL'), { - target: { value: 'https://example.com/articles' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - await screen.findByText('URL not allowed for this account'); expect(mockClearToken).not.toHaveBeenCalled(); expect(screen.queryByText('Enter access token')).not.toBeInTheDocument(); expect( screen.queryByText('Access token was rejected. Paste a valid token to continue.') ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); }); - it('shows the utility links in a user-focused order', () => { - globalThis.history.replaceState({}, '', 'http://localhost:3000/#result'); + it('keeps extraction-empty failures generic and input-corrective', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'input', + code: 'NO_FEED_ITEMS_EXTRACTED', + retryable: false, + nextAction: 'correct_input', + retryAction: 'none', + message: 'Could not extract feed items. Try a more specific listing URL or explicit selectors.', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryPreviewFetch: mockRetryPreviewFetch, + }); + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); + await screen.findByText( + 'Could not extract feed items. Try a more specific listing URL or explicit selectors.' + ); + expect(screen.queryByText('Enter access token')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(mockClearToken).not.toHaveBeenCalled(); + }); + + it('shows the utility links in a user-focused order', () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create'); + render(); const utilityLinks = [ ...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'), @@ -547,9 +656,9 @@ describe('App', () => { expect(utilityLinks).toEqual([ 'Try included feeds', 'Bookmarklet', + 'Install from Docker Hub', 'OpenAPI spec', 'Source code', - 'Install from Docker Hub', ]); expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute( @@ -586,13 +695,21 @@ describe('App', () => { error: undefined, }); - globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create'); render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute( 'href', 'http://localhost:3000/openapi.yaml' ); }); + + it('shows footer utilities on result routes', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/#/result/generated-token'); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Utilities')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index d830ac69..9efe051d 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -4,16 +4,17 @@ import { ResultDisplay } from '../components/ResultDisplay'; describe('ResultDisplay', () => { const mockOnCreateAnother = vi.fn(); - const mockOnRetryReadiness = vi.fn(); + const mockOnRetryPreview = vi.fn(); const mockResult = { feed: { id: 'test-id', name: 'Test Feed', url: 'https://example.com', - strategy: 'faraday', feed_token: 'test-feed-token', public_url: 'https://example.com/feed.xml', json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', }, preview: { items: [ @@ -23,160 +24,136 @@ describe('ResultDisplay', () => { url: 'https://example.com/item-one', publishedLabel: 'Jan 1, 2024', }, - { - title: '56 points by canpan 1 hour ago | hide | 18 comments', - excerpt: '', - publishedLabel: 'Jan 2, 2024', - }, - { - title: 'Item Two', - excerpt: '', - url: 'https://example.com/item-two', - publishedLabel: 'Jan 3, 2024', - }, ], - error: undefined, isLoading: false, }, - readinessPhase: 'feed_ready' as const, - retry: undefined, + workflowState: 'preview_ready' as const, + warnings: [], }; beforeEach(() => { vi.clearAllMocks(); }); - it('renders the success state actions and richer preview cards', async () => { + it('renders ready feed actions and preview cards', async () => { + const resultWithMultiplePreviewItems = { + ...mockResult, + preview: { + items: [ + { + title: 'Item One', + excerpt: 'First preview item with markup.', + url: 'https://example.com/item-one', + publishedLabel: 'Jan 1, 2024', + }, + { + title: 'Item Two', + excerpt: 'Second preview item with markup.', + url: 'https://example.com/item-two', + publishedLabel: 'Jan 2, 2024', + }, + { + title: 'Item Three', + excerpt: 'Third preview item with markup.', + url: 'https://example.com/item-three', + publishedLabel: 'Jan 3, 2024', + }, + { + title: 'Item Four', + excerpt: 'Fourth preview item with markup.', + url: 'https://example.com/item-four', + publishedLabel: 'Jan 4, 2024', + }, + ], + isLoading: false, + }, + }; + render( ); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'result'); expect(screen.getByText('Feed ready')).toBeInTheDocument(); - expect(screen.getByText('Test Feed')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open feed' })).toHaveClass('btn--primary'); expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'https://example.com/feed.json' ); - expect(screen.getByRole('link', { name: 'Open in feed reader' })).toHaveAttribute( - 'href', - 'feed:https://example.com/feed.xml' - ); await waitFor(() => { expect(screen.getByText('Item One')).toBeInTheDocument(); - expect(screen.getByText('First preview item with markup.')).toBeInTheDocument(); - expect(screen.getByText(/points by canpan/i)).toBeInTheDocument(); - expect(screen.getByText('Item Two')).toBeInTheDocument(); - expect(screen.getAllByText('Open original').length).toBeGreaterThan(0); - expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); - }); - }); - - it('surfaces feed-not-ready state with a readiness retry action', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Feed still warming up')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Try readiness check again' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); - expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getByText('Item Four')).toBeInTheDocument(); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); + expect(screen.queryByRole('button', { name: /show all .* items/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show fewer items' })).not.toBeInTheDocument(); }); - it('keeps result shell visible while readiness check is in progress', async () => { + it('renders preview loading as frontend-owned progress', () => { render( ); - await waitFor(() => { - expect(screen.getByText('Feed created')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Checking readiness…' })).toBeDisabled(); - expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); - expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument(); - }); + expect(screen.getByText('Checking preview')).toBeInTheDocument(); + expect(screen.getByText('Checking preview...')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); }); - it('shows an automatic retry notice when fallback strategy succeeded', async () => { + it('lets retryable preview failures retry preview only', () => { render( ); - await waitFor(() => { - expect( - screen.getByText('Retried automatically with browserless after faraday could not finish the page.') - ).toBeInTheDocument(); - }); + expect(screen.getByText('Feed link created')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Check preview again' })); + expect(mockOnRetryPreview).toHaveBeenCalled(); }); - it('calls onCreateAnother when the reset button is clicked', () => { + it('calls onCreateAnother and copies feed URL', async () => { render( ); fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); - expect(mockOnCreateAnother).toHaveBeenCalled(); - }); - - it('calls onRetryReadiness when the readiness action is clicked', () => { - render( - - ); - - fireEvent.click(screen.getByRole('button', { name: 'Try readiness check again' })); - expect(mockOnRetryReadiness).toHaveBeenCalled(); - }); - - it('copies feed URL to clipboard when copy button is clicked', async () => { - render( - - ); fireEvent.click(screen.getByRole('button', { name: 'Copy feed URL' })); - await waitFor(() => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://example.com/feed.xml'); }); diff --git a/frontend/src/__tests__/feedWorkflowStorage.test.ts b/frontend/src/__tests__/feedWorkflowStorage.test.ts new file mode 100644 index 00000000..c0374a4d --- /dev/null +++ b/frontend/src/__tests__/feedWorkflowStorage.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { clearFeedDraftState, loadFeedDraftState, saveFeedDraftState } from '../utils/feedWorkflowStorage'; + +describe('feedWorkflowStorage', () => { + beforeEach(() => { + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); + }); + + it('persists and hydrates the create draft state from the url only', () => { + saveFeedDraftState({ url: 'https://example.com/articles' }); + + expect(loadFeedDraftState()).toEqual({ + url: 'https://example.com/articles', + }); + expect(globalThis.localStorage.getItem('html2rss_feed_draft_state')).toBe( + JSON.stringify({ url: 'https://example.com/articles' }) + ); + + clearFeedDraftState(); + expect(loadFeedDraftState()).toBeUndefined(); + }); + + it('ignores extra draft properties beyond the canonical shape', () => { + globalThis.localStorage.setItem( + 'html2rss_feed_draft_state', + JSON.stringify({ + url: 'https://example.com/articles', + extra: 'ignored', + }) + ); + + expect(loadFeedDraftState()).toEqual({ + url: 'https://example.com/articles', + }); + }); +}); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 00359bc6..dc05e610 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -20,26 +20,6 @@ export const server = setupServer( }, }, }); - }), - http.get('/api/v1/strategies', () => { - return HttpResponse.json({ - success: true, - data: { - strategies: [ - { - id: 'faraday', - name: 'faraday', - display_name: 'Default', - }, - { - id: 'browserless', - name: 'browserless', - display_name: 'JavaScript pages (recommended)', - }, - ], - }, - meta: { total: 2 }, - }); }) ); @@ -47,7 +27,6 @@ export interface FeedResponseOverrides { id?: string; name?: string; url?: string; - strategy?: string; feed_token?: string; public_url?: string; json_public_url?: string; @@ -55,9 +34,17 @@ export interface FeedResponseOverrides { updated_at?: string; } +export interface StructuredErrorOverrides { + code?: string; + message?: string; + kind?: 'auth' | 'input' | 'network' | 'server'; + retryable?: boolean; + next_action?: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; + retry_action?: 'alternate' | 'primary' | 'none'; +} + export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { const timestamp = overrides.created_at ?? new Date('2024-01-01T00:00:00Z').toISOString(); - return { success: true, data: { @@ -65,7 +52,6 @@ export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { id: overrides.id ?? 'feed-123', name: overrides.name ?? 'Example Feed', url: overrides.url ?? 'https://example.com/articles', - strategy: overrides.strategy ?? 'faraday', feed_token: overrides.feed_token ?? 'example-token', public_url: overrides.public_url ?? '/api/v1/feeds/example-token', json_public_url: overrides.json_public_url ?? '/api/v1/feeds/example-token.json', @@ -76,3 +62,17 @@ export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { meta: { created: true }, }; } + +export function buildStructuredErrorResponse(overrides: StructuredErrorOverrides = {}) { + return { + success: false, + error: { + code: overrides.code ?? 'INTERNAL_SERVER_ERROR', + message: overrides.message ?? 'Internal Server Error', + kind: overrides.kind ?? 'server', + retryable: overrides.retryable ?? false, + next_action: overrides.next_action ?? 'none', + retry_action: overrides.retry_action ?? 'none', + }, + }; +} diff --git a/frontend/src/__tests__/useAccessToken.test.ts b/frontend/src/__tests__/useAccessToken.test.ts index 29f1f59b..b399f849 100644 --- a/frontend/src/__tests__/useAccessToken.test.ts +++ b/frontend/src/__tests__/useAccessToken.test.ts @@ -4,12 +4,11 @@ import { useAccessToken } from '../hooks/useAccessToken'; describe('useAccessToken', () => { beforeEach(() => { - globalThis.localStorage.clear(); globalThis.sessionStorage.clear(); }); - it('loads the persisted token from localStorage', async () => { - globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); + it('loads the persisted token from sessionStorage', async () => { + globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token'); const { result } = renderHook(() => useAccessToken()); @@ -19,18 +18,7 @@ describe('useAccessToken', () => { expect(result.current.error).toBeUndefined(); }); - it('migrates a legacy session token into localStorage', async () => { - globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); - - const { result } = renderHook(() => useAccessToken()); - - expect(result.current.isLoading).toBe(false); - expect(result.current.token).toBe('legacy-token'); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('legacy-token'); - expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); - }); - - it('saves new tokens to the persistent storage path', async () => { + it('saves new tokens to sessionStorage only', async () => { const { result } = renderHook(() => useAccessToken()); await act(async () => { @@ -39,13 +27,11 @@ describe('useAccessToken', () => { expect(result.current.token).toBe('new-token'); expect(result.current.hasToken).toBe(true); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('new-token'); - expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBe('new-token'); }); - it('clears both persistent and legacy token copies', async () => { - globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); - globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); + it('clears the canonical session token copy', async () => { + globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token'); const { result } = renderHook(() => useAccessToken()); @@ -55,7 +41,47 @@ describe('useAccessToken', () => { expect(result.current.token).toBeUndefined(); expect(result.current.hasToken).toBe(false); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); + + it('falls back to in-memory token when sessionStorage write is unavailable', async () => { + globalThis.sessionStorage.setItem.mockImplementationOnce(() => { + throw new Error('blocked'); + }); + + const { result } = renderHook(() => useAccessToken()); + + await act(async () => { + await result.current.saveToken('memory-token'); + }); + + expect(result.current.token).toBe('memory-token'); + expect(result.current.hasToken).toBe(true); + }); + + it('loads from in-memory fallback when sessionStorage read is unavailable', async () => { + globalThis.sessionStorage.setItem.mockImplementationOnce(() => { + throw new Error('blocked'); + }); + + const seeded = renderHook(() => useAccessToken()); + await act(async () => { + await seeded.result.current.saveToken('memory-only'); + }); + seeded.unmount(); + + globalThis.sessionStorage.getItem.mockImplementationOnce(() => { + throw new Error('blocked'); + }); + + const { result } = renderHook(() => useAccessToken()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.token).toBe('memory-only'); + expect(result.current.hasToken).toBe(true); + expect(result.current.error).toBeUndefined(); + act(() => { + result.current.clearToken(); + }); + }); }); diff --git a/frontend/src/__tests__/useAuth.test.ts b/frontend/src/__tests__/useAuth.test.ts deleted file mode 100644 index 02ea4b2d..00000000 --- a/frontend/src/__tests__/useAuth.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook, act } from '@testing-library/preact'; -import { useAuth } from '../hooks/useAuth'; - -type MockedStorage = Storage & { - getItem: ReturnType; - setItem: ReturnType; - removeItem: ReturnType; - clear: ReturnType; -}; - -const createStorageMock = (): MockedStorage => { - return { - length: 0, - clear: vi.fn(), - getItem: vi.fn(), - key: vi.fn(), - removeItem: vi.fn(), - setItem: vi.fn(), - } as unknown as MockedStorage; -}; - -let localStorageMock: MockedStorage; -let sessionStorageMock: MockedStorage; - -describe('useAuth', () => { - beforeEach(() => { - localStorageMock = createStorageMock(); - sessionStorageMock = createStorageMock(); - Object.defineProperty(globalThis, 'localStorage', { - value: localStorageMock, - configurable: true, - writable: true, - }); - Object.defineProperty(globalThis, 'sessionStorage', { - value: sessionStorageMock, - configurable: true, - writable: true, - }); - vi.clearAllMocks(); - }); - - it('should initialize with unauthenticated state', () => { - localStorageMock.getItem.mockReturnValue(); - - const { result } = renderHook(() => useAuth()); - - expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeUndefined(); - expect(result.current.token).toBeUndefined(); - }); - - it('should load auth state from sessionStorage on mount', () => { - localStorageMock.getItem - .mockReturnValueOnce('testuser') // username - .mockReturnValueOnce('testtoken'); // token - - const { result } = renderHook(() => useAuth()); - - expect(result.current.isAuthenticated).toBe(true); - expect(result.current.username).toBe('testuser'); - expect(result.current.token).toBe('testtoken'); - expect(localStorageMock.getItem).toHaveBeenCalledWith('html2rss_username'); - expect(localStorageMock.getItem).toHaveBeenCalledWith('html2rss_token'); - }); - - it('should login and store credentials', async () => { - localStorageMock.getItem.mockReturnValue(); - - const { result } = renderHook(() => useAuth()); - - await act(async () => { - result.current.login('newuser', 'newtoken'); - }); - - expect(result.current.isAuthenticated).toBe(true); - expect(result.current.username).toBe('newuser'); - expect(result.current.token).toBe('newtoken'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('html2rss_username', 'newuser'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('html2rss_token', 'newtoken'); - }); - - it('should logout and clear credentials', () => { - localStorageMock.getItem.mockReturnValueOnce('testuser').mockReturnValueOnce('testtoken'); - - const { result } = renderHook(() => useAuth()); - - act(() => { - result.current.logout(); - }); - - expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeUndefined(); - expect(result.current.token).toBeUndefined(); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_username'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_token'); - }); -}); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 13d646e9..26e2b55b 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -1,12 +1,25 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/preact'; import { http, HttpResponse } from 'msw'; -import { server, buildFeedResponse } from './mocks/server'; +import { server, buildFeedResponse, buildStructuredErrorResponse } from './mocks/server'; import { useFeedConversion } from '../hooks/useFeedConversion'; describe('useFeedConversion contract', () => { - it('sends feed creation request with bearer token', async () => { + it('sends feed creation requests with bearer auth and hydrates preview from json_public_url', async () => { let receivedAuthorization: string | undefined; + const nativeFetch = globalThis.fetch; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + if (String(input).endsWith('/api/v1/feeds/generated-token.json')) { + return Promise.resolve( + new Response(JSON.stringify({ items: [{ title: 'Preview', content_text: 'Text' }] }), { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + }) + ); + } + + return nativeFetch(input, init); + }); server.use( http.post('/api/v1/feeds', async ({ request }) => { @@ -25,48 +38,43 @@ describe('useFeedConversion contract', () => { { status: 201 } ); }), - http.get('/api/v1/feeds/generated-token.json', ({ request }) => { - expect(request.headers.get('accept')).toBe('application/feed+json'); - - return HttpResponse.json({ - items: [ - { - title: 'Generated item', - content_text: 'Contract preview', - url: 'https://example.com/items/generated', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }); - }) + http.get('http://localhost:3000/api/v1/feeds/generated-token.json', () => + HttpResponse.json({ items: [{ title: 'Preview', content_text: 'Text' }] }) + ), + http.get('http://localhost/api/v1/feeds/generated-token.json', () => + HttpResponse.json({ items: [{ title: 'Preview', content_text: 'Text' }] }) + ), + http.get('/api/v1/feeds/generated-token.json', () => + HttpResponse.json({ items: [{ title: 'Preview', content_text: 'Text' }] }) + ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'test-token-123'); + await result.current.convertFeed('https://example.com/articles', 'test-token-123'); }); expect(receivedAuthorization).toBe('Bearer test-token-123'); expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); - expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token'); - expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json'); - expect(result.current.result?.readinessPhase).toBe('link_created'); - await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('feed_ready'); - expect(result.current.result?.preview.error).toBeUndefined(); - expect(result.current.result?.preview.isLoading).toBe(false); - expect(result.current.result?.preview.items).toHaveLength(1); - }); + await waitFor(() => expect(result.current.result?.workflowState).toBe('preview_ready')); + fetchSpy.mockRestore(); }); - it('propagates API validation errors', async () => { + it('propagates structured auth failures without parsing the message text', async () => { server.use( http.post('/api/v1/feeds', async () => HttpResponse.json( - { success: false, error: { message: 'URL parameter is required' } }, - { status: 400 } + buildStructuredErrorResponse({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', + }), + { status: 401 } ) ) ); @@ -74,70 +82,136 @@ describe('useFeedConversion contract', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'token') - ).rejects.toThrow('URL parameter is required'); + await expect(result.current.convertFeed('https://example.com/articles', 'token')).rejects.toMatchObject( + { + message: 'Authentication required', + } + ); }); expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('URL parameter is required'); + expect(result.current.error).toMatchObject({ + kind: 'auth', + code: 'UNAUTHORIZED', + nextAction: 'enter_token', + retryAction: 'none', + retryable: false, + message: 'Authentication required', + }); }); - it('normalizes malformed successful responses', async () => { + it('treats extraction-empty failures as corrective input errors without strategy metadata', async () => { server.use( http.post('/api/v1/feeds', async () => - HttpResponse.text('not-json', { - status: 200, - headers: { 'content-type': 'application/json' }, - }) + HttpResponse.json( + buildStructuredErrorResponse({ + code: 'NO_FEED_ITEMS_EXTRACTED', + message: 'Could not extract feed items. Try a more specific listing URL or explicit selectors.', + kind: 'input', + retryable: false, + next_action: 'correct_input', + retry_action: 'none', + }), + { status: 422 } + ) ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'token') - ).rejects.toThrow('Invalid response format from feed creation API'); + await expect(result.current.convertFeed('https://example.com/articles', 'token')).rejects.toMatchObject( + { + kind: 'input', + code: 'NO_FEED_ITEMS_EXTRACTED', + nextAction: 'correct_input', + retryAction: 'none', + retryable: false, + message: 'Could not extract feed items. Try a more specific listing URL or explicit selectors.', + } + ); }); - - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Invalid response format from feed creation API'); }); - it('marks the feed as not-ready-yet when preview endpoint keeps returning 5xx', async () => { + it('marks preview failure from the feed json response without status polling', async () => { + const nativeFetch = globalThis.fetch; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + if (String(input).endsWith('/api/v1/feeds/generated-token.json')) { + return Promise.resolve(new Response('No feed items', { status: 422 })); + } + + return nativeFetch(input, init); + }); + server.use( - http.post('/api/v1/feeds', async () => - HttpResponse.json( + http.post('/api/v1/feeds', async ({ request }) => { + const body = (await request.json()) as { url: string }; + + return HttpResponse.json( buildFeedResponse({ + url: body.url, feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', }), { status: 201 } - ) + ); + }), + http.get('http://localhost:3000/api/v1/feeds/generated-token.json', () => + HttpResponse.text('No feed items', { status: 422 }) ), - http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(undefined, { status: 502 })) + http.get('http://localhost/api/v1/feeds/generated-token.json', () => + HttpResponse.text('No feed items', { status: 422 }) + ), + http.get('/api/v1/feeds/generated-token.json', () => + HttpResponse.text('No feed items', { status: 422 }) + ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'token'); + await result.current.convertFeed('https://example.com/articles', 'token'); }); - expect(result.current.error).toBeUndefined(); - expect(result.current.result?.feed.feed_token).toBe('generated-token'); - await waitFor( - () => { - expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); - expect(result.current.result?.preview.items).toEqual([]); - expect(result.current.result?.preview.error).toBe( - 'Feed is still preparing. Try again in a few seconds.' - ); - expect(result.current.result?.preview.isLoading).toBe(false); - }, - { timeout: 6000 } + await waitFor(() => { + expect(result.current.result?.workflowState).toBe('preview_failed'); + expect(result.current.result?.warnings[0]?.code).toBe('PREVIEW_HTTP_422'); + }); + fetchSpy.mockRestore(); + }); + + it('rejects camelCase-only create payloads to enforce canonical snake_case contract', async () => { + server.use( + http.post('/api/v1/feeds', async () => + HttpResponse.json( + { + success: true, + data: { + feed: { + id: 'feed-1', + name: 'Example Feed', + url: 'https://example.com/articles', + feedToken: 'generated-token', + publicUrl: '/api/v1/feeds/generated-token', + jsonPublicUrl: '/api/v1/feeds/generated-token.json', + }, + }, + }, + { status: 201 } + ) + ) ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect(result.current.convertFeed('https://example.com/articles', 'token')).rejects.toMatchObject( + { + kind: 'server', + code: 'INVALID_RESPONSE', + } + ); + }); }); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index ce00639b..12726b09 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -2,21 +2,40 @@ import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } fro import { renderHook, act, waitFor } from '@testing-library/preact'; import { useFeedConversion } from '../hooks/useFeedConversion'; -const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; -const SHORT_SETTLE_MS = 50; -const FULL_SETTLE_MS = 100; +const mockFeed = { + id: 'feed-1', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'feed-token-1', + public_url: '/api/v1/feeds/feed-token-1', + json_public_url: '/api/v1/feeds/feed-token-1.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; -const sumDelays = (delays: readonly number[]) => delays.reduce((total, delay) => total + delay, 0); +function createResponse(status = 201) { + return new Response(JSON.stringify({ success: true, data: { feed: mockFeed } }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} -const advanceAfterRetries = async (delays: readonly number[], settleMs: number) => { - await vi.advanceTimersByTimeAsync(sumDelays(delays) + settleMs); -}; +function previewResponse(status = 200) { + return new Response( + JSON.stringify({ + items: [{ title: 'Preview item', content_text: 'Preview excerpt', date_published: '2024-01-02' }], + }), + { status, headers: { 'Content-Type': 'application/feed+json' } } + ); +} describe('useFeedConversion', () => { let fetchMock: SpyInstance; beforeEach(() => { vi.clearAllMocks(); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); fetchMock = vi.spyOn(globalThis, 'fetch'); }); @@ -24,856 +43,87 @@ describe('useFeedConversion', () => { fetchMock.mockRestore(); }); - it('should initialize with default state', () => { - const { result } = renderHook(() => useFeedConversion()); - - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBeUndefined(); - }); - - it('should handle successful conversion', async () => { - const mockFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: mockFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview item', - content_text: 'Preview excerpt', - url: 'https://example.com/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; - - await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); - - expect(result.current.isConverting).toBe(false); - expect(conversionResult).toEqual({ - feed: mockFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, - }); - await waitFor(() => { - expect(result.current.result).toEqual({ - feed: mockFeed, - preview: { - items: [ - { - title: 'Preview item', - excerpt: 'Preview excerpt', - publishedLabel: 'Jan 2, 2024', - url: 'https://example.com/item', - }, - ], - error: undefined, - isLoading: false, - }, - readinessPhase: 'feed_ready', - retry: undefined, - }); - }); - expect(result.current.error).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('should handle conversion error', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Bad Request' }, - }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( - 'Bad Request' - ); - }); - - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toContain('Bad Request'); - }); - - it('should handle network errors gracefully', async () => { - fetchMock.mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( - 'Network error' - ); - }); - - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Network error'); - }); - - it('preserves the created feed when preview loading fails after feed creation', async () => { - vi.useFakeTimers(); - try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValue(new Response('nope', { status: 502 })); - - const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; - - await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); - }); - - expect(result.current.isConverting).toBe(false); - expect(conversionResult).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, - }); - await waitFor(() => { - expect(result.current.result).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: 'Feed is still preparing. Try again in a few seconds.', - isLoading: false, - }, - readinessPhase: 'feed_not_ready_yet', - retry: undefined, - }); - }); - expect(result.current.error).toBeUndefined(); - } finally { - vi.useRealTimers(); - } - }); - - it('publishes link_created before readiness is confirmed', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - let resolvePreviewResponse: ((value: Response) => void) | undefined; - const previewResponse = new Promise((resolve) => { - resolvePreviewResponse = resolve; - }); - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: createdFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockReturnValueOnce(previewResponse as Promise); + it('creates a feed from metadata and hydrates preview from json_public_url', async () => { + fetchMock.mockResolvedValueOnce(createResponse()).mockResolvedValueOnce(previewResponse()); const { result } = renderHook(() => useFeedConversion()); - - let conversionResult: Awaited> | undefined; await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); - - expect(conversionResult).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toEqual(conversionResult); - resolvePreviewResponse?.( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview item', - content_text: 'Preview excerpt', - url: 'https://example.com/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/v1/feeds'); + expect(String(fetchMock.mock.calls[1]?.[0])).toMatch(/\/api\/v1\/feeds\/feed-token-1\.json$/); + expect(fetchMock.mock.calls).toHaveLength(2); await waitFor(() => { - expect(result.current.result?.preview).toEqual({ - items: [ - { - title: 'Preview item', - excerpt: 'Preview excerpt', - publishedLabel: 'Jan 2, 2024', - url: 'https://example.com/item', - }, - ], - error: undefined, - isLoading: false, - }); - expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.workflowState).toBe('preview_ready'); + expect(result.current.result?.preview.items[0]?.title).toBe('Preview item'); }); }); - it('retries readiness checks after transient preview failures and eventually becomes ready', async () => { + it('retries only preview fetches on transient preview failure', async () => { vi.useFakeTimers(); - try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: createdFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce(new Response('temporary-failure', { status: 500 })) - .mockResolvedValueOnce(new Response('still-warming-up', { status: 503 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - items: [ - { - title: 'Recovered item', - content_text: 'Recovered preview excerpt', - url: 'https://example.com/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS.slice(0, 2), SHORT_SETTLE_MS); - }); - - await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('feed_ready'); - expect(result.current.result?.preview.items[0]?.title).toBe('Recovered item'); - }); - expect(fetchMock).toHaveBeenCalledTimes(4); - } finally { - vi.useRealTimers(); - } - }); - - it('stops readiness retries after the configured limit and marks feed_not_ready_yet', async () => { - vi.useFakeTimers(); - try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: createdFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValue(new Response('temporary-failure', { status: 500 })); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); - }); - - await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); - expect(result.current.result?.preview.error).toBe( - 'Feed is still preparing. Try again in a few seconds.' - ); - }); - expect(fetchMock).toHaveBeenCalledTimes(6); - } finally { - vi.useRealTimers(); - } - }); - - it('marks preview_unavailable for non-retryable preview responses', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: createdFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + .mockResolvedValueOnce(createResponse()) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(previewResponse()); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); - - await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); - expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); - }); - }); - - it('normalizes hostname-only input before creating a feed', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com/articles', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - }) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('example.com/articles', 'faraday', 'testtoken'); - }); - - const firstRequest = fetchMock.mock.calls[0]?.[0] as Request; - expect(firstRequest instanceof Request ? firstRequest.url : String(firstRequest)).toContain( - '/api/v1/feeds' - ); - expect(await firstRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - }); - - it('automatically retries browserless after a faraday failure', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com/articles', - strategy: 'browserless', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Upstream timeout' }, - }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - }) - ); - - const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + await vi.advanceTimersByTimeAsync(260); }); - const retryRequest = fetchMock.mock.calls[1]?.[0] as Request; - expect(await retryRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', - }); - expect(result.current.result?.retry).toEqual({ - automatic: true, - from: 'faraday', - to: 'browserless', - }); await waitFor(() => { - expect(result.current.result?.preview.isLoading).toBe(false); - }); - }); - - it('does not auto-retry browserless for unauthorized faraday failures', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Unauthorized' }, - }), - { - status: 401, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') - ).rejects.toThrow('Unauthorized'); - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Unauthorized'); - }); - - it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { code: 'BAD_REQUEST', message: 'Input rejected' }, - }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') - ).rejects.toThrow('Input rejected'); + expect(result.current.result?.workflowState).toBe('preview_ready'); }); + expect(fetchMock.mock.calls.filter((call) => String(call[0]) === '/api/v1/feeds').length).toBe(1); + expect( + fetchMock.mock.calls.filter((call) => String(call[0]).endsWith('/api/v1/feeds/feed-token-1.json')) + .length + ).toBe(2); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Input rejected'); + vi.useRealTimers(); }); - it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com/articles', - strategy: 'browserless', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - + it('marks preview failed for non-transient preview responses', async () => { fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to fetch https://example.com/articles', - }, - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - }) - ); + .mockResolvedValueOnce(createResponse()) + .mockResolvedValueOnce(new Response('', { status: 422 })); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - const retryRequest = fetchMock.mock.calls[1]?.[0] as Request; - expect(await retryRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', - }); - expect(result.current.result?.retry).toEqual({ - automatic: true, - from: 'faraday', - to: 'browserless', - }); - }); - - it('does not offer a duplicate manual retry after automatic fallback also fails', async () => { - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Upstream timeout' }, - }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Browserless also failed' }, - }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - let thrownError: (Error & { manualRetryStrategy?: string }) | undefined; - await act(async () => { - try { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); - } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string }; - } + await waitFor(() => { + expect(result.current.result?.workflowState).toBe('preview_failed'); + expect(result.current.result?.warnings[0]).toMatchObject({ + code: 'PREVIEW_HTTP_422', + retryable: false, + nextAction: 'wait', + }); }); - - expect(thrownError?.message).toBe( - 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' - ); - expect(thrownError?.manualRetryStrategy).toBeUndefined(); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe( - 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' - ); }); - it('ignores stale preview updates from an earlier conversion request', async () => { - const feedA = { - id: 'feed-a-id', - name: 'Feed A', - url: 'https://example.com/a', - strategy: 'faraday', - feed_token: 'feed-a-token', - public_url: 'https://example.com/feed-a', - json_public_url: 'https://example.com/feed-a.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - const feedB = { - id: 'feed-b-id', - name: 'Feed B', - url: 'https://example.com/b', - strategy: 'faraday', - feed_token: 'feed-b-token', - public_url: 'https://example.com/feed-b', - json_public_url: 'https://example.com/feed-b.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - let resolvePreviewA: ((value: Response) => void) | undefined; - const previewAPromise = new Promise((resolve) => { - resolvePreviewA = resolve; - }); - let resolvePreviewB: ((value: Response) => void) | undefined; - const previewBPromise = new Promise((resolve) => { - resolvePreviewB = resolve; - }); - + it('retries preview without recreating the feed', async () => { fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: feedA }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockReturnValueOnce(previewAPromise as Promise) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: feedB }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockReturnValueOnce(previewBPromise as Promise); + .mockResolvedValueOnce(createResponse()) + .mockResolvedValueOnce(new Response('', { status: 422 })); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com/a', 'faraday', 'testtoken'); - }); - await act(async () => { - await result.current.convertFeed('https://example.com/b', 'faraday', 'testtoken'); - }); - - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - - resolvePreviewB?.( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview B', - content_text: 'Current preview item', - url: 'https://example.com/b/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); - - await waitFor(() => { - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); + await waitFor(() => expect(result.current.result?.workflowState).toBe('preview_failed')); - resolvePreviewA?.( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview A', - content_text: 'Stale preview item', - url: 'https://example.com/a/item', - date_published: '2024-01-03T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); + fetchMock.mockResolvedValueOnce(previewResponse()); + act(() => result.current.retryPreviewFetch()); - await waitFor(() => { - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); - }); + await waitFor(() => expect(result.current.result?.workflowState).toBe('preview_ready')); + expect(fetchMock.mock.calls.filter((call) => String(call[0]) === '/api/v1/feeds').length).toBe(1); }); }); diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index d4fed873..917d568b 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -1,7 +1,20 @@ -import type { CreateFeedResponses, GetApiMetadataResponses, ListStrategiesResponses } from './generated'; +import type { GetApiMetadataResponses } from './generated'; + +export interface FeedRecord { + id: string; + name: string; + url: string; + feed_token: string; + public_url: string; + json_public_url: string; + created_at: string; + updated_at: string; +} + +export type FeedWorkflowState = 'created' | 'preview_loading' | 'preview_ready' | 'preview_failed'; +export type FeedRetryAction = 'alternate' | 'primary' | 'none'; +export type FeedNextAction = 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; -export type FeedRecord = CreateFeedResponses[201]['data']['feed']; -export type StrategyRecord = ListStrategiesResponses[200]['data']['strategies'][number]; export interface FeedPreviewItem { title: string; excerpt: string; @@ -9,25 +22,33 @@ export interface FeedPreviewItem { url?: string; } +export interface FeedPreviewWarning { + code: string; + message: string; + retryable: boolean; + nextAction: FeedNextAction; +} + export interface FeedPreviewState { items: FeedPreviewItem[]; - error?: string; isLoading: boolean; } -export type FeedReadinessPhase = 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; - -export interface FeedRetryState { - automatic: boolean; - from: string; - to: string; -} - export interface CreatedFeedResult { feed: FeedRecord; preview: FeedPreviewState; - readinessPhase: FeedReadinessPhase; - retry?: FeedRetryState; + workflowState: FeedWorkflowState; + warnings: FeedPreviewWarning[]; +} + +export interface FeedCreationError { + kind: 'auth' | 'input' | 'network' | 'server'; + code: string; + retryable: boolean; + nextAction: FeedNextAction; + retryAction: FeedRetryAction; + message: string; + status?: number; } export interface ApiMetadataRecord { diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index b1adfcaf..e734849b 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,65 +1,62 @@ import { useEffect, useRef, useState } from 'preact/hooks'; +import type { JSX } from 'preact'; import { ResultDisplay } from './ResultDisplay'; -import { CreateFeedPanel, UtilityStrip, type Strategy } from './AppPanels'; +import { CreateFeedPanel, UtilityStrip } from './AppPanels'; import { useAccessToken } from '../hooks/useAccessToken'; import { useApiMetadata } from '../hooks/useApiMetadata'; import { useFeedConversion } from '../hooks/useFeedConversion'; -import { useStrategies } from '../hooks/useStrategies'; +import { useAppRoute } from '../routes/appRoute'; +import { clearFeedDraftState, loadFeedDraftState, saveFeedDraftState } from '../utils/feedWorkflowStorage'; import { normalizeUserUrl } from '../utils/url'; +import type { WorkflowState } from './AppPanels'; +import type { FeedCreationError } from '../api/contracts'; const EMPTY_FEED_ERRORS = { url: '', form: '' }; const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; -const preferredStrategy = (strategies: { id: string }[]) => - strategies.find((strategy) => strategy.id === 'faraday')?.id ?? strategies[0]?.id; -function strategyHint(strategy: Strategy) { - if (strategy.id === 'faraday') return 'Best for most pages.'; - if (strategy.id === 'browserless') return 'Use when the page needs JavaScript to load content.'; - return strategy.name; -} - -function isAccessTokenError(message: string) { - const normalized = message.toLowerCase(); - const mentionsAuthToken = - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bearer'); - - return ( - normalized.includes('unauthorized') || - normalized.includes('invalid token') || - normalized.includes('token rejected') || - normalized.includes('authentication') || - (normalized.includes('forbidden') && mentionsAuthToken) - ); -} - -function isActionableStrategySwitch(message: string, currentStrategy: string, retryStrategy: string) { - if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; +function deriveWorkflowState({ + conversionError, + feedFieldErrors, + isConverting, + routeKind, + tokenError, + tokenStateError, + metadataError, +}: { + conversionError?: FeedCreationError; + feedFieldErrors: { url: string; form: string }; + isConverting: boolean; + routeKind: string; + tokenError: string; + tokenStateError?: string; + metadataError?: string; +}): WorkflowState { + if (tokenStateError || metadataError) return 'error'; + if (routeKind === 'token' || tokenError) return 'token_prompt'; + if (conversionError?.nextAction === 'enter_token' || conversionError?.kind === 'auth') + return 'token_prompt'; + if (routeKind === 'result') return 'result'; + if (feedFieldErrors.url || feedFieldErrors.form || conversionError?.nextAction === 'correct_input') { + return 'error'; + } + if (isConverting) return 'submitting'; - const normalized = message.toLowerCase(); - return !( - normalized.includes('unauthorized') || - normalized.includes('forbidden') || - normalized.includes('not allowed') || - normalized.includes('disabled') || - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bad request') || - normalized.includes('url') || - normalized.includes('unsupported strategy') - ); -} + if (conversionError) return 'error'; -interface ConversionErrorWithMeta extends Error { - manualRetryStrategy?: string; + return 'create'; } -function BrandLockup() { +function BrandLockup({ onNavigateHome }: { onNavigateHome: () => void }) { return ( - + { + event.preventDefault(); + onNavigateHome(); + }} + >