From 8b2da8135bbba1709210b9ef0793b9edafc7c848 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 09:47:36 +0200 Subject: [PATCH 01/22] feat(spa): add route-driven flow and session recovery --- app/web/routes/feed_pages.rb | 13 ++ docs/20260405-preact-router.md | 168 ++++++++++++++++++ frontend/src/__tests__/App.test.tsx | 99 ++++++++++- .../src/__tests__/feedSessionStorage.test.ts | 63 +++++++ frontend/src/__tests__/useAuth.test.ts | 98 ---------- .../src/__tests__/useFeedConversion.test.ts | 23 +++ frontend/src/components/App.tsx | 118 +++++++++--- frontend/src/hooks/useAccessToken.ts | 40 +---- frontend/src/hooks/useAuth.ts | 134 -------------- frontend/src/hooks/useFeedConversion.ts | 24 +++ frontend/src/routes/appRoute.ts | 145 +++++++++++++++ frontend/src/utils/feedSessionStorage.ts | 126 +++++++++++++ frontend/src/utils/persistentStorage.ts | 33 ++++ spec/html2rss/web/app_integration_spec.rb | 26 +++ 14 files changed, 809 insertions(+), 301 deletions(-) create mode 100644 docs/20260405-preact-router.md create mode 100644 frontend/src/__tests__/feedSessionStorage.test.ts delete mode 100644 frontend/src/__tests__/useAuth.test.ts delete mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/routes/appRoute.ts create mode 100644 frontend/src/utils/feedSessionStorage.ts create mode 100644 frontend/src/utils/persistentStorage.ts diff --git a/app/web/routes/feed_pages.rb b/app/web/routes/feed_pages.rb index 535329f6..aa5ab189 100644 --- a/app/web/routes/feed_pages.rb +++ b/app/web/routes/feed_pages.rb @@ -6,6 +6,9 @@ module Routes ## # Mounts the root page and legacy feed paths. module FeedPages + SPA_APP_PATHS = %w[create token result].freeze + SPA_APP_PREFIXES = ['result/'].freeze + class << self # @param router [Roda::RodaRequest] # @param index_renderer [#call] @@ -18,6 +21,10 @@ def call(router, index_renderer:) router.get do feed_name = requested_feed_name(router) next if feed_name.empty? + if spa_app_path?(feed_name) + index_renderer.call(router) + next + end next if feed_name.include?('.') && !feed_name.end_with?('.json', '.xml', '.rss') RequestTarget.mark!(router, RequestTarget::FEED) @@ -32,6 +39,12 @@ def call(router, index_renderer:) def requested_feed_name(router) router.path_info.to_s.delete_prefix('/') end + + # @param path [String] + # @return [Boolean] + def spa_app_path?(path) + SPA_APP_PATHS.include?(path) || SPA_APP_PREFIXES.any? { |prefix| path.start_with?(prefix) } + end end end end diff --git a/docs/20260405-preact-router.md b/docs/20260405-preact-router.md new file mode 100644 index 00000000..2547822b --- /dev/null +++ b/docs/20260405-preact-router.md @@ -0,0 +1,168 @@ +# 2026-04-05 Preact Router Reliability Plan + +## Summary +Upgrade the SPA from a transient single-view flow to a route-driven, recoverable tool experience. +Scope is frontend-only in this pass. Delivery is one broad pass, split into bounded workstreams with strict reviewer `accept` gates before merge. + +## Decisions Locked +- Output: agent-ready execution packet. +- Scope shape: single large pass. +- Boundary: frontend-only (no backend/OpenAPI changes). +- Route model: three routes. +- Priority: reliability mechanics first (no polish stream). +- Review gate: strict `accept` only. + +## Route Contract (Target) +- `/create` +- `/token` +- `/result/:feedToken` +- Preserve `?url=` prefill support for create flow bootstrap. + +## Workstream A: Router + Navigation Skeleton +Goal: replace implicit internal screen switching with explicit route transitions. + +In scope: +- Add Preact router integration in app root. +- Migrate create/token/result transitions to route transitions. +- Ensure browser back/forward/reload behavior is deterministic. + +Out of scope: +- Backend API changes. +- Visual redesign. + +Primary files: +- `frontend/src/main.tsx` +- `frontend/src/components/App.tsx` + +Acceptance criteria: +- Direct navigation to each route renders correct state. +- Back/forward does not break task continuity. +- Reload keeps user on intended state frame. + +## Workstream B: Durable Session and Recovery +Goal: make flow recoverable after refresh without server-side persistence. + +In scope: +- Persist local draft state (url, strategy). +- Persist latest successful result snapshot keyed by `feed_token`. +- Hydrate route state from local snapshot on load. +- Resolve unused auth hook surface (`useAuth`) explicitly (retain with rationale or retire). + +Out of scope: +- Server-side history/status endpoints. + +Primary files: +- `frontend/src/hooks/useFeedConversion.ts` +- `frontend/src/hooks/useAccessToken.ts` +- new local session helper module(s) + +Acceptance criteria: +- Refresh on `/result/:feedToken` restores result view from local snapshot. +- Refresh on `/create` restores draft input and selected strategy. +- No stale error banners shown unless still applicable. + +## Workstream C: Reliability State Machine and Error Taxonomy +Goal: convert ad-hoc boolean/error handling into explicit operational states. + +In scope: +- Introduce explicit state machine: + - `idle`, `validating`, `submitting`, `token_required`, `warming`, `ready`, `failed` +- Classify errors into actionable categories: + - `auth`, `input`, `readiness`, `network`, `server` +- Enforce one canonical primary action per state. + +Out of scope: +- New endpoint design. + +Primary files: +- `frontend/src/components/AppPanels.tsx` +- `frontend/src/components/ResultDisplay.tsx` + +Acceptance criteria: +- Every failure state has one clear primary recovery action. +- Token rejection flow is deterministic and route-safe. +- Strategy fallback/retry behavior is explicit and test-covered. + +## Workstream D: Test Expansion for State Parity and Durability +Goal: lock behavior with tests so reliability regressions are caught. + +In scope: +- Add tests for deep links and direct route loads. +- Add tests for refresh/back-forward across create/token/result. +- Add parity assertions for affected states (guest/token/result). +- Add uniqueness checks: one canonical primary action per state. + +Primary files: +- `frontend/src/__tests__/App.test.tsx` +- `frontend/src/__tests__/App.contract.test.tsx` +- `frontend/e2e/smoke.spec.ts` + +Acceptance criteria: +- Route and recovery regressions fail tests. +- E2E smoke validates primary user journey with parity checks. + +## Verification Requirements +Required per workstream: +- `make ready` +- Relevant frontend tests (`pnpm run test:ci`) +- E2E smoke (`pnpm run test:e2e`) where route/state behavior changes + +Required manual smoke: +- Run app in Dev Container (`make dev`) +- Validate in chrome-devtools at `http://127.0.0.1:4001/` +- Check create/token/result states for: + - state parity + - one primary action per outcome + - proper focus behavior on transitions + +## Agent Workflow (Execution + Review) +Execution order: +1. Start A and B in parallel. +2. Land C after A/B route/session primitives are stable. +3. Finalize D with comprehensive assertions after A/B/C complete. + +Review policy: +- Separate reviewer pass per workstream. +- Reviewer output must include: + 1. findings first (severity ordered, file/line) + 2. residual risks + 3. verdict (`accept` or `needs fix`) +- Merge only when verdict is `accept` and required checks are green. + +## Implementation Prompt Template (for each agent) +Read: +- `/Users/gil/versioned/html2rss/html2rss-web/AGENTS.md` +- `/Users/gil/versioned/html2rss/html2rss-web/docs/design-system.md` +- this plan file (`docs/20260405-preact-router.md`) + +Task: +Deliver only assigned workstream. + +Constraints: +- Frontend-only. +- Preserve design-system grammar. +- No cross-workstream scope drift. + +Required outputs: +1. Implement slice. +2. Report changed files and acceptance criteria status. +3. Report blockers/risk notes. + +Required verification: +- `make ready` +- relevant frontend tests +- manual state checks in chrome-devtools at `http://127.0.0.1:4001/` + +## Reviewer Prompt Template +Review assigned workstream for production readiness. + +Focus: +- behavioral regressions +- routing/state durability +- action uniqueness per state +- missing tests and edge cases + +Output: +1. findings (severity ordered, file/line) +2. residual risks +3. verdict: `accept` or `needs fix` diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 5f58cfa3..2cbe27b6 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -27,6 +27,24 @@ 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', + strategy: 'faraday', + 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, + }, + readinessPhase: 'link_created', + retry: undefined, +}; describe('App', () => { const mockSaveToken = vi.fn(); @@ -35,10 +53,13 @@ describe('App', () => { const mockClearConversionError = vi.fn(); const mockClearResult = vi.fn(); const mockRetryReadinessCheck = vi.fn(); + const mockRestoreResult = 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, @@ -76,6 +97,7 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); mockUseStrategies.mockReturnValue({ @@ -92,7 +114,7 @@ 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(); @@ -157,6 +179,56 @@ describe('App', () => { await waitFor(() => { expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'saved-token'); + expect(globalThis.location.pathname).toBe('/result/generated-token'); + }); + }); + + it('restores result state from local snapshot when opening a result deep link', async () => { + globalThis.localStorage.setItem( + 'html2rss_feed_result_snapshot:generated-token', + JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + strategy: 'faraday', + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + }, + preview: { + items: [], + isLoading: false, + }, + readinessPhase: 'feed_ready', + }, + }) + ); + globalThis.history.replaceState({}, '', 'http://localhost:3000/result/generated-token'); + + render(); + + await waitFor(() => { + expect(mockRestoreResult).toHaveBeenCalledTimes(1); + expect(mockRestoreResult).toHaveBeenCalledWith( + expect.objectContaining({ + feed: expect.objectContaining({ feed_token: 'generated-token' }), + readinessPhase: 'feed_ready', + }) + ); + }); + }); + + it('shows a recovery notice when result deep link has no snapshot to restore', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/result/missing-token'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Saved result unavailable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go to create' })).toBeInTheDocument(); }); }); @@ -169,6 +241,7 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); expect(screen.getByText('Enter access token')).toBeInTheDocument(); + expect(globalThis.location.pathname).toBe('/token'); expect(screen.getByLabelText('Page URL')).toBeDisabled(); expect(screen.getByRole('combobox')).toBeDisabled(); expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); @@ -218,6 +291,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: { @@ -243,6 +317,7 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); render(); @@ -251,6 +326,11 @@ describe('App', () => { expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); + return waitFor(() => { + expect(globalThis.location.pathname).toBe('/create'); + }); }); it('surfaces conversion errors to the user', () => { @@ -262,6 +342,7 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); render(); @@ -279,6 +360,7 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); render(); @@ -349,6 +431,7 @@ describe('App', () => { await waitFor(() => { expect(mockSaveToken).toHaveBeenCalledWith('token-123'); expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'token-123'); + expect(globalThis.location.pathname).toBe('/result/generated-token'); }); }); @@ -401,6 +484,9 @@ describe('App', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); fireEvent.click(screen.getByRole('button', { name: 'Back' })); + await waitFor(() => { + expect(globalThis.location.pathname).toBe('/create'); + }); expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument(); }); @@ -423,7 +509,7 @@ 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' })); @@ -438,6 +524,7 @@ describe('App', () => { render(); await screen.findByText('Enter access token'); + expect(globalThis.location.pathname).toBe('/token'); expect(screen.getByLabelText('Page URL')).toHaveValue('https://example.com/articles'); expect(mockConvertFeed).not.toHaveBeenCalled(); }); @@ -457,7 +544,7 @@ describe('App', () => { manualRetryStrategy: 'browserless', }) ) - .mockResolvedValueOnce(); + .mockResolvedValueOnce(mockCreatedFeedResult); render(); @@ -536,7 +623,7 @@ describe('App', () => { }); it('shows the utility links in a user-focused order', () => { - globalThis.history.replaceState({}, '', 'http://localhost:3000/#result'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); @@ -586,7 +673,7 @@ 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' })); diff --git a/frontend/src/__tests__/feedSessionStorage.test.ts b/frontend/src/__tests__/feedSessionStorage.test.ts new file mode 100644 index 00000000..f535ed35 --- /dev/null +++ b/frontend/src/__tests__/feedSessionStorage.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearFeedDraftState, + clearFeedResultSnapshot, + loadFeedDraftState, + loadFeedResultSnapshot, + loadFeedResultState, + saveFeedDraftState, + saveFeedResultSnapshot, +} from '../utils/feedSessionStorage'; + +describe('feedSessionStorage', () => { + beforeEach(() => { + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); + }); + + it('persists and hydrates the create draft state', () => { + saveFeedDraftState({ url: 'https://example.com/articles', strategy: 'faraday' }); + + expect(loadFeedDraftState()).toEqual({ + url: 'https://example.com/articles', + strategy: 'faraday', + }); + + clearFeedDraftState(); + expect(loadFeedDraftState()).toBeUndefined(); + }); + + it('persists and hydrates the latest feed result snapshot by token', () => { + const result = { + feed: { + 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', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: [], + error: undefined, + isLoading: true, + }, + readinessPhase: 'link_created' as const, + retry: undefined, + }; + + saveFeedResultSnapshot(result); + + expect(loadFeedResultSnapshot('example-token')).toMatchObject({ + savedAt: expect.any(String), + result, + }); + expect(loadFeedResultState('example-token')).toMatchObject(result); + + clearFeedResultSnapshot('example-token'); + expect(loadFeedResultSnapshot('example-token')).toBeUndefined(); + }); +}); 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.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 7f55096d..2f828814 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/preact'; import { useFeedConversion } from '../hooks/useFeedConversion'; +import { loadFeedResultSnapshot } from '../utils/feedSessionStorage'; const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; const SHORT_SETTLE_MS = 50; @@ -17,6 +18,8 @@ describe('useFeedConversion', () => { beforeEach(() => { vi.clearAllMocks(); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); fetchMock = vi.spyOn(globalThis, 'fetch'); }); @@ -115,6 +118,26 @@ describe('useFeedConversion', () => { }); expect(result.current.error).toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(2); + expect(loadFeedResultSnapshot('test-token')).toMatchObject({ + savedAt: expect.any(String), + result: { + 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, + }, + }); }); it('should handle conversion error', async () => { diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index b1adfcaf..bbf9687c 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -5,6 +5,13 @@ 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, + loadFeedResultState, + saveFeedDraftState, +} from '../utils/feedSessionStorage'; import { normalizeUserUrl } from '../utils/url'; const EMPTY_FEED_ERRORS = { url: '', form: '' }; @@ -57,9 +64,17 @@ interface ConversionErrorWithMeta extends Error { manualRetryStrategy?: string; } -function BrandLockup() { +function BrandLockup({ onNavigateHome }: { onNavigateHome: () => void }) { return ( - + { + event.preventDefault(); + onNavigateHome(); + }} + >
- + navigate({ kind: 'create' })} />
)} - {activeResult ? ( + {resultRouteRestorePending ? ( +
+
+ ) : activeResult ? ( + ) : missingResultRoute ? ( + ) : ( <> - {missingResultRoute && ( - - )} +
+ diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index 07417edd..c67a69a2 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -1,14 +1,21 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import type { CreatedFeedResult } from '../api/contracts'; +import type { WorkflowState } from './AppPanels'; import { DominantField } from './DominantField'; interface ResultDisplayProperties { result: CreatedFeedResult; + workflowState: WorkflowState; onCreateAnother: () => void; onRetryReadiness: () => void; } -export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: ResultDisplayProperties) { +export function ResultDisplay({ + result, + workflowState, + onCreateAnother, + onRetryReadiness, +}: ResultDisplayProperties) { const [copyNotice, setCopyNotice] = useState(''); const [showAllPreviewItems, setShowAllPreviewItems] = useState(false); const copyResetReference = useRef(undefined); @@ -63,7 +70,7 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res }; return ( -
+
- {showReadinessAction && ( - +
+ + ); + } else { + bodyContent = ( + <> + { + setTokenDraft(value); + setTokenError(''); + clearError(); + }} + onSaveToken={handleSaveToken} + onCancelTokenPrompt={() => { + setTokenError(''); + clearError(); + navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); + }} + manualRetryStrategy={manualRetryStrategy} + onRetryWithStrategy={handleRetryWithStrategy} + strategyHint={strategyHint} + /> +
- {showReadinessAction && ( -
)} - {conversionError && ( + {failureMessage && ( )} @@ -276,19 +247,6 @@ export function CreateFeedPanel({

Checking readiness now.

)} - - {feedFieldErrors.form && ( - - )}
); } @@ -306,7 +264,6 @@ export function UtilityStrip({ openapiUrl, onClearToken, }: UtilityStripProperties) { - const [isOpen, setIsOpen] = useState(false); const normalizedOpenapiUrl = normalizeLocalOriginUrl(openapiUrl); const includedFeedsHref = (() => { const directoryUrl = new URL('https://html2rss.github.io/feed-directory/'); @@ -321,53 +278,43 @@ export function UtilityStrip({ return (
- - {isOpen && ( -
- - Try included feeds - - - {openapiUrl && ( - - OpenAPI spec - - )} +
+ + Try included feeds + + + {openapiUrl && ( - Source code + OpenAPI spec - - Install from Docker Hub - - {hasAccessToken && ( - - )} -
- )} + )} + + Source code + + + Install from Docker Hub + + {hasAccessToken && ( + + )} +
); } diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index cdfeed17..eb366aae 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -3,6 +3,8 @@ } .page-shell { + --footer-nav-reserve: calc(5.25rem + env(safe-area-inset-bottom, 0px)); + min-height: 100vh; display: grid; grid-template-rows: minmax(0, 1fr) auto; @@ -12,7 +14,8 @@ width: 100%; max-width: var(--layout-page-max-width); margin: 0 auto; - padding: clamp(0.85rem, 3vh, 2rem) clamp(var(--space-3), 3vw, var(--space-4)) var(--space-5); + padding: clamp(0.85rem, 3vh, 2rem) clamp(var(--space-3), 3vw, var(--space-4)) + calc(var(--space-5) + var(--footer-nav-reserve)); } /* Layout Engine */ @@ -51,6 +54,14 @@ justify-items: center; } +.workspace-content { + width: 100%; + display: grid; + gap: var(--space-4); + align-content: center; + min-height: clamp(20rem, 52vh, 36rem); +} + .workspace-hero { gap: var(--space-1); text-align: center; @@ -609,14 +620,17 @@ a:focus-visible { } .utility-strip { - gap: var(--space-2); width: 100%; - max-width: var(--layout-field-max-width); + max-width: var(--layout-page-max-width); } .utility-strip__items { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + align-items: center; + justify-content: center; gap: var(--space-2); - justify-items: center; } .utility-link, @@ -646,18 +660,42 @@ a:focus-visible { text-transform: uppercase; } +.app-footer { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 20; + display: grid; + justify-items: center; + padding: var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom, 0px)); + background: linear-gradient(180deg, transparent 0%, rgb(var(--color-rgb-black) / 68%) 56%); + backdrop-filter: blur(4px); +} + +.app-footer[data-visible="false"] { + display: none; +} + +.app-footer__inner { + width: 100%; + max-width: var(--layout-page-max-width); +} + @media (width >= 48rem) { .page-main { padding-top: clamp(1rem, 6vh, 4rem); } .utility-strip__items { - grid-auto-flow: column; gap: var(--space-4); } } @media (width < 48rem) { + .page-shell { + --footer-nav-reserve: calc(12rem + env(safe-area-inset-bottom, 0px)); + } + .page-main, .workspace-shell { width: 100%; @@ -669,6 +707,10 @@ a:focus-visible { gap: var(--space-4); } + .workspace-content { + min-height: clamp(16rem, 42vh, 26rem); + } + .form-shell--minimal, .result-shell { padding: var(--space-4); @@ -717,6 +759,18 @@ a:focus-visible { min-width: 4rem; padding-inline: var(--space-3); } + + .app-footer { + padding-top: var(--space-3); + background: linear-gradient(180deg, transparent 0%, rgb(var(--color-rgb-black) / 76%) 46%); + } + + .utility-strip__items { + grid-auto-flow: row; + grid-auto-columns: 1fr; + justify-items: center; + gap: var(--space-1); + } } @keyframes spin { diff --git a/frontend/src/utils/feedSessionStorage.ts b/frontend/src/utils/feedSessionStorage.ts index b3abf74f..3fa30fa8 100644 --- a/frontend/src/utils/feedSessionStorage.ts +++ b/frontend/src/utils/feedSessionStorage.ts @@ -6,7 +6,7 @@ const FEED_RESULT_KEY_PREFIX = 'html2rss_feed_result_snapshot'; export interface FeedDraftState { url: string; - strategy: string; + strategy?: string; } export interface FeedResultSnapshot { @@ -15,7 +15,12 @@ export interface FeedResultSnapshot { } export function loadFeedDraftState(): FeedDraftState | undefined { - return parseJson(getPersistentStorage().getItem(FEED_DRAFT_KEY), isFeedDraftState); + const storedState = parseJson( + getPersistentStorage().getItem(FEED_DRAFT_KEY), + isFeedDraftState + ); + + return storedState ? normalizeFeedDraftState(storedState) : undefined; } export function saveFeedDraftState(state: FeedDraftState): void { @@ -73,17 +78,16 @@ function buildFeedResultKey(feedToken: string): string | undefined { function normalizeFeedDraftState(state: FeedDraftState): FeedDraftState | undefined { const url = state.url.trim(); - const strategy = state.strategy.trim(); - if (!url || !strategy) return undefined; + if (!url) return undefined; - return { url, strategy }; + return { url }; } function isFeedDraftState(value: unknown): value is FeedDraftState { if (!value || typeof value !== 'object') return false; const candidate = value as Partial; - return typeof candidate.url === 'string' && typeof candidate.strategy === 'string'; + return typeof candidate.url === 'string'; } function isFeedResultSnapshot(value: unknown): value is FeedResultSnapshot { diff --git a/public/shared-ui.css b/public/shared-ui.css index a17b0206..a1dcd12a 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -102,12 +102,6 @@ body { margin: 0; min-width: 20rem; color: var(--text-body); - font-family: var(--font-family-ui); - font-size: var(--font-size-0); - line-height: var(--line-height-base); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizelegibility; background: transparent; } @@ -116,7 +110,12 @@ button, input, select, textarea { - font: inherit; + font-family: var(--font-family-ui); + font-size: var(--font-size-0); + line-height: var(--line-height-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizelegibility; } a { From 755eef16bc91eb80a4ad4507fbfb930c9bac98b3 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 12:54:50 +0200 Subject: [PATCH 10/22] Improve mobile footer flow and degraded-feed messaging --- app/web/rendering/feed_notice_text.rb | 14 ++++---- app/web/rendering/json_feed_builder.rb | 2 +- app/web/rendering/xml_builder.rb | 2 +- frontend/src/__tests__/ResultDisplay.test.tsx | 24 +++++++++++++ frontend/src/components/ResultDisplay.tsx | 6 ++++ frontend/src/styles/main.css | 29 +++++++++++++--- public/rss.xsl | 2 +- spec/html2rss/web/feed_notice_text_spec.rb | 20 +++++++++++ spec/html2rss/web/json_feed_builder_spec.rb | 34 +++++++++++++++++++ spec/html2rss/web/xml_builder_spec.rb | 33 ++++++++++++++++++ 10 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 spec/html2rss/web/feed_notice_text_spec.rb create mode 100644 spec/html2rss/web/json_feed_builder_spec.rb create mode 100644 spec/html2rss/web/xml_builder_spec.rb diff --git a/app/web/rendering/feed_notice_text.rb b/app/web/rendering/feed_notice_text.rb index ec92ac3c..ed34d3ad 100644 --- a/app/web/rendering/feed_notice_text.rb +++ b/app/web/rendering/feed_notice_text.rb @@ -6,19 +6,17 @@ module Web # Shared copy helpers for rendered feed warnings and fallback documents. module FeedNoticeText EMPTY_FEED_DESCRIPTION_TEMPLATE = <<~DESC - Unable to extract content from %s using the %s strategy. - The site may rely on JavaScript, block automated requests, or expose a structure that needs a different parser. + We could not extract entries from %s right now. + The source may block automated requests, require dynamic rendering, or be temporarily unavailable. DESC EMPTY_FEED_ITEM_TEMPLATE = <<~DESC No entries were extracted from %s. - Possible causes: - - JavaScript-heavy site (try the browserless strategy) - - Anti-bot protection - - Complex or changing markup - - Site blocking automated requests - Try another strategy or reach out to the site owner. + What you can do: + - Try again in a few moments + - Open the original page to confirm content is available + - Reach out to the site owner if access is restricted DESC class << self diff --git a/app/web/rendering/json_feed_builder.rb b/app/web/rendering/json_feed_builder.rb index 0c1e3187..727c4b99 100644 --- a/app/web/rendering/json_feed_builder.rb +++ b/app/web/rendering/json_feed_builder.rb @@ -75,7 +75,7 @@ def build_single_item(item) # @return [Hash{Symbol=>String}] def empty_feed_item(url) { - title: 'Content Extraction Failed', + title: 'Preview unavailable for this source', content_text: FeedNoticeText.empty_feed_item(url: url), url: url } diff --git a/app/web/rendering/xml_builder.rb b/app/web/rendering/xml_builder.rb index 399a4d39..057e997b 100644 --- a/app/web/rendering/xml_builder.rb +++ b/app/web/rendering/xml_builder.rb @@ -53,7 +53,7 @@ def build_empty_feed_warning(url:, strategy:, site_title: nil) build_single_item_feed( title: FeedNoticeText.empty_feed_title(site_title), description: FeedNoticeText.empty_feed_description(url: url, strategy: strategy), - item: { title: 'Content Extraction Failed', description: FeedNoticeText.empty_feed_item(url: url), + item: { title: 'Preview unavailable for this source', description: FeedNoticeText.empty_feed_item(url: url), link: url }, link: url ) diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index 6298474d..fc056347 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -102,6 +102,30 @@ describe('ResultDisplay', () => { }); }); + it('keeps feed-ready actions visible while showing preview degradation warning copy', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Feed ready')).toBeInTheDocument(); + expect( + screen.getByText('Feed link is usable, but preview content could not be verified yet.') + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); + expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + }); + }); + it('keeps result shell visible while readiness check is in progress', async () => { render( 3; @@ -83,6 +84,11 @@ export function ResultDisplay({

{statusTitle}

{feed.name}

{statusMessage}

+ {hasPreviewDegradation && ( +

+ Feed link is usable, but preview content could not be verified yet. +

+ )}
diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index eb366aae..30fbce0d 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -11,6 +11,9 @@ } .page-main { + min-height: 100%; + display: flex; + flex-direction: column; width: 100%; max-width: var(--layout-page-max-width); margin: 0 auto; @@ -191,6 +194,10 @@ color: var(--danger); } +.field-help--warning { + color: var(--text-body); +} + .input { width: 100%; min-width: 0; @@ -693,7 +700,7 @@ a:focus-visible { @media (width < 48rem) { .page-shell { - --footer-nav-reserve: calc(12rem + env(safe-area-inset-bottom, 0px)); + --footer-nav-reserve: 0px; } .page-main, @@ -761,15 +768,29 @@ a:focus-visible { } .app-footer { - padding-top: var(--space-3); - background: linear-gradient(180deg, transparent 0%, rgb(var(--color-rgb-black) / 76%) 46%); + position: static; + inset-inline: auto; + bottom: auto; + z-index: auto; + margin-top: auto; + padding: var(--space-5) var(--space-3) calc(var(--space-3) + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--border-subtle); + background: transparent; + backdrop-filter: none; } .utility-strip__items { grid-auto-flow: row; grid-auto-columns: 1fr; justify-items: center; - gap: var(--space-1); + gap: calc(var(--space-1) * 0.9); + } + + .utility-link, + .utility-button { + color: var(--text-faint); + font-size: var(--font-size-00); + opacity: 0.92; } } diff --git a/public/rss.xsl b/public/rss.xsl index 02d8cb25..792de60e 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -361,7 +361,7 @@
- +

diff --git a/spec/html2rss/web/feed_notice_text_spec.rb b/spec/html2rss/web/feed_notice_text_spec.rb new file mode 100644 index 00000000..37649748 --- /dev/null +++ b/spec/html2rss/web/feed_notice_text_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::FeedNoticeText do + describe '.empty_feed_item' do + subject(:message) { described_class.empty_feed_item(url: 'https://example.com/articles') } + + it 'includes actionable product guidance' do + expect(message).to include('What you can do:') + expect(message).to include('Try again in a few moments') + end + + it 'does not mention hidden strategy controls' do + expect(message).not_to include('browserless strategy') + expect(message).not_to include('Try another strategy') + end + end +end diff --git a/spec/html2rss/web/json_feed_builder_spec.rb b/spec/html2rss/web/json_feed_builder_spec.rb new file mode 100644 index 00000000..5aede1fe --- /dev/null +++ b/spec/html2rss/web/json_feed_builder_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::JsonFeedBuilder do + describe '.build_empty_feed_warning' do + subject(:payload) do + JSON.parse( + described_class.build_empty_feed_warning( + url: 'https://example.com/articles', + strategy: 'faraday', + site_title: 'Example Site' + ) + ) + end + + it 'uses updated channel description copy' do + expect(payload.fetch('description')).to include('We could not extract entries') + expect(payload.fetch('description')).not_to include('different parser') + end + + it 'uses updated item title and content text' do + first_item = payload.fetch('items').first + expect(first_item.fetch('title')).to eq('Preview unavailable for this source') + expect(first_item.fetch('content_text')).to include('What you can do:') + end + + it 'does not mention hidden strategy controls in item text' do + first_item = payload.fetch('items').first + expect(first_item.fetch('content_text')).not_to include('browserless strategy') + end + end +end diff --git a/spec/html2rss/web/xml_builder_spec.rb b/spec/html2rss/web/xml_builder_spec.rb new file mode 100644 index 00000000..a06d9047 --- /dev/null +++ b/spec/html2rss/web/xml_builder_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'nokogiri' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::XmlBuilder do + describe '.build_empty_feed_warning' do + subject(:xml_doc) do + xml = described_class.build_empty_feed_warning( + url: 'https://example.com/articles', + strategy: 'faraday', + site_title: 'Example Site' + ) + Nokogiri::XML(xml) + end + + it 'uses updated channel description copy' do + description = xml_doc.at_xpath('//channel/description').text + expect(description).to include('We could not extract entries') + expect(description).not_to include('different parser') + end + + it 'uses updated item title and content text' do + expect(xml_doc.at_xpath('//item/title').text).to eq('Preview unavailable for this source') + expect(xml_doc.at_xpath('//item/description').text).to include('What you can do:') + end + + it 'does not mention hidden strategy controls in item text' do + expect(xml_doc.at_xpath('//item/description').text).not_to include('browserless strategy') + end + end +end From a05c774a5e68e782ac28720cec1ce973f5323f18 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 13:08:13 +0200 Subject: [PATCH 11/22] Harden create-flow retry recovery with explicit retry actions --- frontend/src/__tests__/App.test.tsx | 5 +- .../src/__tests__/useFeedConversion.test.ts | 41 +++++++--- frontend/src/components/App.tsx | 43 +++++----- frontend/src/hooks/useFeedConversion.ts | 79 ++++++++++++++++++- 4 files changed, 129 insertions(+), 39 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 54e821f0..561c02fc 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -574,6 +574,7 @@ describe('App', () => { mockConvertFeed .mockRejectedValueOnce( Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { + retryAction: 'alternate', manualRetryStrategy: 'browserless', }) ) @@ -610,7 +611,7 @@ describe('App', () => { mockConvertFeed .mockRejectedValueOnce( Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - manualRetryStrategy: '', + retryAction: 'primary', }) ) .mockResolvedValueOnce(mockCreatedFeedResult); @@ -646,7 +647,7 @@ describe('App', () => { }); mockConvertFeed.mockRejectedValueOnce( Object.assign(new Error('URL not allowed for this account'), { - manualRetryStrategy: 'browserless', + retryAction: undefined, }) ); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 2f828814..0400e1b4 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -171,16 +171,22 @@ describe('useFeedConversion', () => { fetchMock.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useFeedConversion()); + let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; await act(async () => { - await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( - 'Network error' - ); + try { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + } catch (error) { + thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + } }); expect(result.current.isConverting).toBe(false); expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Network error'); + expect(thrownError?.message).toBe('Network error'); + expect(thrownError?.manualRetryStrategy).toBe('browserless'); + expect(thrownError?.retryAction).toBe('alternate'); }); it('preserves the created feed when preview loading fails after feed creation', async () => { @@ -627,16 +633,22 @@ describe('useFeedConversion', () => { ); const { result } = renderHook(() => useFeedConversion()); + let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') - ).rejects.toThrow('Unauthorized'); + try { + await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + } catch (error) { + thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + } }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Unauthorized'); + expect(thrownError?.message).toBe('Unauthorized'); + expect(thrownError?.manualRetryStrategy).toBeUndefined(); + expect(thrownError?.retryAction).toBeUndefined(); }); it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => { @@ -654,16 +666,22 @@ describe('useFeedConversion', () => { ); const { result } = renderHook(() => useFeedConversion()); + let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') - ).rejects.toThrow('Input rejected'); + try { + await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + } catch (error) { + thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + } }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Input rejected'); + expect(thrownError?.message).toBe('Input rejected'); + expect(thrownError?.manualRetryStrategy).toBeUndefined(); + expect(thrownError?.retryAction).toBeUndefined(); }); it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => { @@ -763,12 +781,12 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); - let thrownError: (Error & { manualRetryStrategy?: string }) | undefined; + let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; await act(async () => { try { await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string }; + thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; } }); @@ -776,6 +794,7 @@ describe('useFeedConversion', () => { 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' ); expect(thrownError?.manualRetryStrategy).toBeUndefined(); + expect(thrownError?.retryAction).toBe('primary'); 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' diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 84b3f06a..4d29f1f0 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -39,26 +39,9 @@ function isAccessTokenError(message: string) { ); } -function isActionableStrategySwitch(message: string, currentStrategy: string, retryStrategy: string) { - if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; - - 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') - ); -} - interface ConversionErrorWithMeta extends Error { manualRetryStrategy?: string; + retryAction?: 'alternate' | 'primary'; } function classifyWorkflowError(message?: string): WorkflowErrorKind | undefined { @@ -206,6 +189,7 @@ export function App() { const [tokenDraft, setTokenDraft] = useState(''); const [tokenError, setTokenError] = useState(''); const [manualRetryStrategy, setManualRetryStrategy] = useState(''); + const [showPrimaryRetry, setShowPrimaryRetry] = useState(false); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); const [resultRouteRecoveryAttempted, setResultRouteRecoveryAttempted] = useState(false); const autoSubmitUrlReference = useRef(route.prefillUrl); @@ -271,6 +255,7 @@ export function App() { }); setFeedFieldErrors((previous) => ({ ...previous, url: '', form: '' })); setManualRetryStrategy(''); + setShowPrimaryRetry(false); clearError(); }; @@ -315,14 +300,16 @@ export function App() { } catch (submitError) { const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.'; const retryStrategy = (submitError as ConversionErrorWithMeta).manualRetryStrategy ?? ''; - setManualRetryStrategy( - isActionableStrategySwitch(message, strategy, retryStrategy) ? retryStrategy : '' - ); + const retryAction = (submitError as ConversionErrorWithMeta).retryAction; + setManualRetryStrategy(retryAction === 'alternate' ? retryStrategy : ''); + setShowPrimaryRetry(retryAction === 'primary'); if (feedCreation.access_token_required && isAccessTokenError(message)) { clearToken(); clearError(); setTokenDraft(''); + setManualRetryStrategy(''); + setShowPrimaryRetry(false); if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); setTokenError('Access token was rejected. Paste a valid token to continue.'); setFeedFieldErrors(EMPTY_FEED_ERRORS); @@ -341,12 +328,16 @@ export function App() { const handleFeedSubmit = async (event: Event) => { event.preventDefault(); setFeedFieldErrors(EMPTY_FEED_ERRORS); + setManualRetryStrategy(''); + setShowPrimaryRetry(false); await attemptFeedCreation(token ?? ''); }; const handleSaveToken = async () => { try { const normalizedToken = tokenDraft.trim(); + setManualRetryStrategy(''); + setShowPrimaryRetry(false); await saveToken(normalizedToken); setTokenError(''); const created = await attemptFeedCreation(normalizedToken); @@ -359,6 +350,7 @@ export function App() { const handleCreateAnother = () => { clearResult(); setManualRetryStrategy(''); + setShowPrimaryRetry(false); setFocusCreateComposerKey((current) => current + 1); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }; @@ -366,6 +358,8 @@ export function App() { const handleRetryCreation = () => { setFeedFieldErrors(EMPTY_FEED_ERRORS); clearError(); + setManualRetryStrategy(''); + setShowPrimaryRetry(false); void attemptFeedCreation(token ?? ''); }; @@ -374,6 +368,7 @@ export function App() { setFeedFieldErrors(EMPTY_FEED_ERRORS); clearError(); + setShowPrimaryRetry(false); void attemptFeedCreation(token ?? '', manualRetryStrategy); }; @@ -496,9 +491,7 @@ export function App() { tokenDraft={tokenDraft} tokenError={tokenError} showTokenPrompt={isTokenRoute} - showPrimaryRetry={Boolean( - (conversionError || feedFieldErrors.form)?.startsWith('Tried ') && !manualRetryStrategy - )} + showPrimaryRetry={showPrimaryRetry} onFeedSubmit={handleFeedSubmit} onFeedFieldChange={setFeedField} onTokenDraftChange={(value) => { @@ -510,6 +503,8 @@ export function App() { onCancelTokenPrompt={() => { setTokenError(''); clearError(); + setManualRetryStrategy(''); + setShowPrimaryRetry(false); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }} manualRetryStrategy={manualRetryStrategy} diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index e9347a2d..e4da15e3 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -26,8 +26,14 @@ interface ConversionState { interface ConversionError extends Error { manualRetryStrategy?: string; + retryAction?: 'alternate' | 'primary'; + code?: string; + status?: number; + kind?: ConversionFailureKind; } +export type ConversionFailureKind = 'auth' | 'input' | 'network' | 'server'; + const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; const FEED_NOT_READY_MESSAGE = 'Feed is still preparing. Try again in a few seconds.'; const NON_RETRYABLE_ERROR_CODES = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN']); @@ -98,12 +104,24 @@ export function useFeedConversion() { requestedStrategy, fallbackStrategy ); - failConversion(setState, message, { manualRetryStrategy: undefined }); + failConversion(setState, message, { + kind: classifyConversionFailure(firstError), + retryAction: 'primary', + ...extractFailureMetadata(secondError), + }); } } const message = toErrorMessage(firstError); - failConversion(setState, message, { manualRetryStrategy: alternateStrategy(requestedStrategy) }); + const retryStrategy = shouldOfferManualRetry(requestedStrategy, fallbackStrategy, firstError) + ? alternateStrategy(requestedStrategy) + : undefined; + failConversion(setState, message, { + ...extractFailureMetadata(firstError), + kind: classifyConversionFailure(firstError), + manualRetryStrategy: retryStrategy, + retryAction: retryStrategy ? 'alternate' : undefined, + }); } }; @@ -368,6 +386,63 @@ function shouldAutoRetry( return retryableForFallback(error); } +function shouldOfferManualRetry( + strategy: string, + fallbackStrategy: string | undefined, + error: unknown +): fallbackStrategy is string { + if (strategy !== 'faraday' || !fallbackStrategy) return false; + + const details = extractErrorDetails(error); + const errorCode = details?.code?.toUpperCase(); + if (errorCode && NON_RETRYABLE_ERROR_CODES.has(errorCode)) return false; + + const status = details?.status; + if (status && status < 500) return false; + + const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); + if ( + message.includes('unauthorized') || + message.includes('forbidden') || + message.includes('not allowed') || + message.includes('disabled') || + message.includes('access token') || + message.includes('token') || + message.includes('authentication') || + message.includes('bad request') || + message.includes('url') || + message.includes('unsupported strategy') || + message.includes('invalid response format') || + message.includes('not valid json') || + message.includes('unexpected token') + ) { + return false; + } + + return true; +} + +function extractFailureMetadata(error: unknown): { code?: string; status?: number } { + const details = extractErrorDetails(error); + return { + code: details?.code, + status: details?.status, + }; +} + +function classifyConversionFailure(error: unknown): ConversionFailureKind { + const details = extractErrorDetails(error); + const code = details?.code?.toUpperCase(); + const status = details?.status; + const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); + + if (code === 'UNAUTHORIZED' || status === 401) return 'auth'; + if (code === 'BAD_REQUEST' || status === 400) return 'input'; + if (message.includes('network') || message.includes('failed to fetch')) return 'network'; + + return 'server'; +} + function buildRetryFailureMessage( firstError: unknown, secondError: unknown, From 500103f5cb5d198408efd9f93be1e72f32f30007 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 13:08:21 +0200 Subject: [PATCH 12/22] Add DSN-gated Sentry breadcrumbs and triage runbook --- app/web/boot/sentry.rb | 15 +++++ app/web/telemetry/app_logger.rb | 4 +- app/web/telemetry/sentry_logs.rb | 63 ++++++++++++++++++++ docs/README.md | 19 ++++++ spec/html2rss/web/boot/sentry_spec.rb | 83 +++++++++++++++++++++++++++ spec/html2rss/web/sentry_logs_spec.rb | 56 ++++++++++++++++++ 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 spec/html2rss/web/boot/sentry_spec.rb diff --git a/app/web/boot/sentry.rb b/app/web/boot/sentry.rb index e69dc82b..a07c5150 100644 --- a/app/web/boot/sentry.rb +++ b/app/web/boot/sentry.rb @@ -28,6 +28,7 @@ def initialize_sentry! ::Sentry.init do |config| apply_settings(config) end + apply_scope_tags! end # @param config [Object] @@ -45,6 +46,20 @@ def release_name "#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}" end + # @return [void] + def apply_scope_tags! + return unless defined?(::Sentry) && ::Sentry.respond_to?(:configure_scope) + + ::Sentry.configure_scope do |scope| + scope.set_tags( + release: release_name, + environment: RuntimeEnv.rack_env + ) + end + rescue StandardError + nil + end + # @return [Boolean] def sentry_initialized? defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized? diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb index 8724bab6..88d686ad 100644 --- a/app/web/telemetry/app_logger.rb +++ b/app/web/telemetry/app_logger.rb @@ -3,11 +3,8 @@ require 'json' require 'logger' require 'time' - module Html2rss module Web - ## - # Shared structured logger for application and middleware runtime events. module AppLogger class << self # @return [Logger] @@ -103,6 +100,7 @@ def normalize_logfmt_value(raw_value) def emit_to_sentry(payload) return unless sentry_payload?(payload) + SentryLogs.record_breadcrumb(payload) SentryLogs.emit(payload) rescue StandardError nil diff --git a/app/web/telemetry/sentry_logs.rb b/app/web/telemetry/sentry_logs.rb index f6d55353..33839677 100644 --- a/app/web/telemetry/sentry_logs.rb +++ b/app/web/telemetry/sentry_logs.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative '../security/log_sanitizer' + module Html2rss module Web ## @@ -9,8 +11,26 @@ module SentryLogs OMIT = Object.new.freeze ALLOWED_LEVELS = %i[debug info warn error fatal].freeze SENSITIVE_ATTRIBUTE_KEYS = %w[actor email ip remote_ip user_agent username x_forwarded_for].freeze + BREADCRUMB_KEYS = %i[event_name security_event outcome request_id route_group strategy component details].freeze + BREADCRUMB_CATEGORY_KEYS = %i[event_name security_event component].freeze + BREADCRUMB_MESSAGE_KEYS = %i[message event_name security_event component].freeze class << self + # @param payload [Hash{Symbol=>Object}] + # @return [void] + def record_breadcrumb(payload) + return unless breadcrumb_enabled? + + ::Sentry.add_breadcrumb( + category: breadcrumb_category(payload), + message: breadcrumb_message(payload), + level: breadcrumb_level(payload), + data: breadcrumb_data(payload) + ) + rescue StandardError + nil + end + # @param payload [Hash{Symbol=>Object}] # @return [void] def emit(payload) @@ -31,6 +51,13 @@ def enabled? !logger.nil? end + # @return [Boolean] + def breadcrumb_enabled? + RuntimeEnv.sentry_enabled? && + defined?(::Sentry) && + ::Sentry.respond_to?(:add_breadcrumb) + end + # @return [Object, nil] def logger return unless defined?(::Sentry) && ::Sentry.respond_to?(:logger) @@ -54,6 +81,34 @@ def message(payload) payload[:component] || 'html2rss-web log' end + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def breadcrumb_category(payload) + breadcrumb_label(payload, 'html2rss-web', BREADCRUMB_CATEGORY_KEYS) + end + + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def breadcrumb_message(payload) + breadcrumb_label(payload, 'html2rss-web log', BREADCRUMB_MESSAGE_KEYS) + end + + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def breadcrumb_level(payload) + requested_level = payload.fetch(:level, 'info').to_s.downcase + return 'warning' if requested_level == 'warn' + return requested_level if ALLOWED_LEVELS.map(&:to_s).include?(requested_level) + + 'info' + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def breadcrumb_data(payload) + LogSanitizer.sanitize_details(payload).slice(*BREADCRUMB_KEYS) + end + # @param payload [Hash{Symbol=>Object}] # @return [Hash{Symbol=>Object}] def attributes(payload) @@ -98,6 +153,14 @@ def sanitize_array(key, values) def sensitive_key?(key) SENSITIVE_ATTRIBUTE_KEYS.include?(key.to_s) end + + # @param payload [Hash{Symbol=>Object}] + # @param fallback [String] + # @param keys [Array] + # @return [String] + def breadcrumb_label(payload, fallback, keys) + keys.lazy.map { |key| payload[key] }.find(&:itself) || fallback + end end end end diff --git a/docs/README.md b/docs/README.md index 478d1299..ff674af1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -197,6 +197,25 @@ Canonical event fields: `event_name`, `schema_version`, `request_id`, `route_gro Critical-path event families: auth, feed create, feed render, request errors. +## Sentry Runbook + +When `SENTRY_DSN` is present, Sentry is enabled. `BUILD_TAG` and `GIT_SHA` become the release identifier, and +`RACK_ENV` becomes the environment tag. + +First-15-minute triage checklist: + +1. Open the newest `feed.create`, `feed.render`, and `request.error` events. +2. Confirm the release tag matches the deployed build. +3. Check the breadcrumb trail for the failing path, strategy, and outcome. +4. Decide whether the failure is retryable or terminal before paging the user-facing incident path. + +Alert baseline: + +1. Page on sustained `request.error` spikes in production. +2. Page on a repeated `feed.render` failure burst, especially when success drops to zero for a route or strategy. +3. Track the first recovery signal after fallback or retry succeeds so the incident can be closed quickly. +4. Keep the initial threshold simple; tune after a few real incidents instead of pre-optimizing for every edge case. + --- ## Documentation Policy diff --git a/spec/html2rss/web/boot/sentry_spec.rb b/spec/html2rss/web/boot/sentry_spec.rb new file mode 100644 index 00000000..4db6b041 --- /dev/null +++ b/spec/html2rss/web/boot/sentry_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Boot::Sentry do + let(:sentry_dsn) { 'https://example@sentry.invalid/1' } + let(:captured_config) { Struct.new(:dsn, :environment, :enable_logs, :send_default_pii, :release).new } + let(:captured_scope) do + Class.new do + attr_reader :tags + + def initialize + @tags = {} + end + + def set_tags(**tags) + @tags = tags + end + end.new + end + let(:fake_sentry) do + config = captured_config + scope = captured_scope + + Module.new.tap do |mod| + mod.define_singleton_method(:initialized?) { false } + mod.define_singleton_method(:init) do |&block| + block.call(config) + end + mod.define_singleton_method(:configure_scope) do |&block| + block.call(scope) + end + end + end + + before do + stub_const('Sentry', fake_sentry) + end + + it 'configures release, environment, and scope tags when a dsn is present', :aggregate_failures do + stub_runtime_env_for_sentry('production') + described_class.send(:initialize_sentry!) + + expect_sentry_configuration + end + + it 'does nothing when a dsn is not present' do + allow(Html2rss::Web::RuntimeEnv).to receive(:sentry_enabled?).and_return(false) + + expect(described_class.send(:configure?)).to be(false) + end + + def stub_runtime_env_for_sentry(rack_env) + allow(Html2rss::Web::RuntimeEnv).to receive_messages( + sentry_enabled?: true, + sentry_dsn: sentry_dsn, + rack_env: rack_env, + build_tag: '2026-03-27', + git_sha: 'abc1234', + sentry_logs_enabled?: false + ) + end + + def expect_sentry_configuration + expect(captured_config).to have_attributes( + dsn: sentry_dsn, + environment: 'production', + enable_logs: false, + send_default_pii: false, + release: '2026-03-27+abc1234' + ) + expect_sentry_scope_tags + end + + def expect_sentry_scope_tags + expect(captured_scope.tags).to eq( + release: '2026-03-27+abc1234', + environment: 'production' + ) + end +end diff --git a/spec/html2rss/web/sentry_logs_spec.rb b/spec/html2rss/web/sentry_logs_spec.rb index 27d5bb28..2e4a46eb 100644 --- a/spec/html2rss/web/sentry_logs_spec.rb +++ b/spec/html2rss/web/sentry_logs_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' require_relative '../../../app/web/config/runtime_env' +require_relative '../../../app/web/telemetry/app_logger' require_relative '../../../app/web/telemetry/sentry_logs' RSpec.describe Html2rss::Web::SentryLogs do @@ -23,6 +24,7 @@ let(:fake_sentry) do Module.new.tap do |mod| mod.define_singleton_method(:logger) { sentry_logger } + mod.define_singleton_method(:add_breadcrumb) { |**| nil } end end let(:raw_payload) do @@ -65,6 +67,22 @@ expect(captured_call).to eq({}) end + it 'adds breadcrumbs for request-critical structured logs even when sentry logs are disabled', :aggregate_failures do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: false) + allow(Sentry).to receive(:add_breadcrumb) + + Html2rss::Web::AppLogger.send( + :format_entry, + 'INFO', + Time.now.utc, + nil, + breadcrumb_payload.to_json + ) + + expect(Sentry).to have_received(:add_breadcrumb).with(expected_breadcrumb) + end + it 'falls back to info when an unsupported level is requested', :aggregate_failures do stub_const('Sentry', fake_sentry) allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true) @@ -80,6 +98,44 @@ def build_sentry_logger logger_class.new(captured_call) end + def breadcrumb_payload + { + event_name: 'feed.create', + outcome: 'failure', + request_id: 'req-123', + route_group: 'api_v1', + strategy: 'faraday', + details: { url: 'https://example.com/articles', fallback: 'browserless' } + } + end + + def expected_breadcrumb + include( + category: 'feed.create', + message: 'feed.create', + level: 'info', + data: breadcrumb_data_matcher + ) + end + + def breadcrumb_data_matcher + include( + event_name: 'feed.create', + outcome: 'failure', + request_id: 'req-123', + route_group: 'api_v1', + strategy: 'faraday', + details: breadcrumb_details_matcher + ) + end + + def breadcrumb_details_matcher + include( + url: include(host: 'example.com', scheme: 'https'), + fallback: 'browserless' + ) + end + def expect_forwarded_payload expect(captured_call).to include(:message, :attributes) expect_forwarded_message From 74867f3324ddc943d25f32d1912ec369d6da2a24 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 13:13:00 +0200 Subject: [PATCH 13/22] Move retry classification upstream --- frontend/src/__tests__/App.test.tsx | 25 +++- .../src/__tests__/useFeedConversion.test.ts | 58 +++++++-- frontend/src/components/App.tsx | 121 ++++-------------- frontend/src/hooks/useFeedConversion.ts | 26 +++- 4 files changed, 123 insertions(+), 107 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 561c02fc..ab25efd7 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -478,7 +478,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(); @@ -506,7 +512,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(); @@ -574,6 +586,9 @@ describe('App', () => { mockConvertFeed .mockRejectedValueOnce( Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { + code: 'INTERNAL_SERVER_ERROR', + status: 502, + kind: 'server', retryAction: 'alternate', manualRetryStrategy: 'browserless', }) @@ -611,6 +626,9 @@ describe('App', () => { mockConvertFeed .mockRejectedValueOnce( Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { + code: 'INTERNAL_SERVER_ERROR', + status: 502, + kind: 'server', retryAction: 'primary', }) ) @@ -647,6 +665,9 @@ describe('App', () => { }); mockConvertFeed.mockRejectedValueOnce( Object.assign(new Error('URL not allowed for this account'), { + code: 'FORBIDDEN', + status: 403, + kind: 'server', retryAction: undefined, }) ); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 0400e1b4..041b913d 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -171,13 +171,21 @@ describe('useFeedConversion', () => { fetchMock.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useFeedConversion()); - let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; + let thrownError: + | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | undefined; await act(async () => { try { await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + thrownError = error as Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }; } }); @@ -187,6 +195,7 @@ describe('useFeedConversion', () => { expect(thrownError?.message).toBe('Network error'); expect(thrownError?.manualRetryStrategy).toBe('browserless'); expect(thrownError?.retryAction).toBe('alternate'); + expect(thrownError?.kind).toBe('network'); }); it('preserves the created feed when preview loading fails after feed creation', async () => { @@ -623,7 +632,7 @@ describe('useFeedConversion', () => { new Response( JSON.stringify({ success: false, - error: { message: 'Unauthorized' }, + error: { code: 'UNAUTHORIZED', message: 'Unauthorized' }, }), { status: 401, @@ -633,13 +642,21 @@ describe('useFeedConversion', () => { ); const { result } = renderHook(() => useFeedConversion()); - let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; + let thrownError: + | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | undefined; await act(async () => { try { await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + thrownError = error as Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }; } }); @@ -649,6 +666,9 @@ describe('useFeedConversion', () => { expect(thrownError?.message).toBe('Unauthorized'); expect(thrownError?.manualRetryStrategy).toBeUndefined(); expect(thrownError?.retryAction).toBeUndefined(); + expect(thrownError?.kind).toBe('auth'); + expect(thrownError?.code).toBe('UNAUTHORIZED'); + expect(thrownError?.status).toBeUndefined(); }); it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => { @@ -666,13 +686,21 @@ describe('useFeedConversion', () => { ); const { result } = renderHook(() => useFeedConversion()); - let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; + let thrownError: + | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | undefined; await act(async () => { try { await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + thrownError = error as Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }; } }); @@ -682,6 +710,9 @@ describe('useFeedConversion', () => { expect(thrownError?.message).toBe('Input rejected'); expect(thrownError?.manualRetryStrategy).toBeUndefined(); expect(thrownError?.retryAction).toBeUndefined(); + expect(thrownError?.kind).toBe('input'); + expect(thrownError?.code).toBe('BAD_REQUEST'); + expect(thrownError?.status).toBeUndefined(); }); it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => { @@ -781,12 +812,20 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); - let thrownError: (Error & { manualRetryStrategy?: string; retryAction?: string }) | undefined; + let thrownError: + | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | undefined; await act(async () => { try { await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string; retryAction?: string }; + thrownError = error as Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }; } }); @@ -795,6 +834,7 @@ describe('useFeedConversion', () => { ); expect(thrownError?.manualRetryStrategy).toBeUndefined(); expect(thrownError?.retryAction).toBe('primary'); + expect(thrownError?.kind).toBe('server'); 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' diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 4d29f1f0..ac3f9ed1 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -5,6 +5,7 @@ import { CreateFeedPanel, UtilityStrip } from './AppPanels'; import { useAccessToken } from '../hooks/useAccessToken'; import { useApiMetadata } from '../hooks/useApiMetadata'; import { useFeedConversion } from '../hooks/useFeedConversion'; +import type { ConversionFailureKind } from '../hooks/useFeedConversion'; import { useStrategies } from '../hooks/useStrategies'; import { useAppRoute } from '../routes/appRoute'; import { @@ -14,7 +15,7 @@ import { saveFeedDraftState, } from '../utils/feedSessionStorage'; import { normalizeUserUrl } from '../utils/url'; -import type { WorkflowErrorKind, WorkflowState } from './AppPanels'; +import type { WorkflowState } from './AppPanels'; import type { CreatedFeedResult } from '../api/contracts'; const EMPTY_FEED_ERRORS = { url: '', form: '' }; @@ -22,89 +23,18 @@ 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 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) - ); -} - interface ConversionErrorWithMeta extends Error { manualRetryStrategy?: string; retryAction?: 'alternate' | 'primary'; -} - -function classifyWorkflowError(message?: string): WorkflowErrorKind | undefined { - if (!message) return undefined; - - const normalized = message.toLowerCase(); - const mentionsToken = normalized.includes('access token') || normalized.includes('token'); - - if ( - normalized.includes('unauthorized') || - normalized.includes('invalid token') || - normalized.includes('token rejected') || - normalized.includes('authentication') || - (normalized.includes('forbidden') && mentionsToken) - ) { - return 'auth'; - } - - if ( - normalized.includes('still preparing') || - normalized.includes('warming') || - normalized.includes('not ready') || - normalized.includes('readiness') || - normalized.includes('preview unavailable') - ) { - return 'readiness'; - } - - if ( - normalized.includes('network') || - normalized.includes('fetch') || - normalized.includes('timeout') || - normalized.includes('offline') || - normalized.includes('failed to fetch') - ) { - return 'network'; - } - - if ( - normalized.includes('not allowed') || - normalized.includes('disabled') || - (normalized.includes('forbidden') && !mentionsToken) || - normalized.includes('access denied') - ) { - return 'server'; - } - - if ( - normalized.includes('url') || - normalized.includes('strategy') || - normalized.includes('bad request') || - normalized.includes('unsupported strategy') || - normalized.includes('invalid response format') - ) { - return 'input'; - } - - return 'server'; + code?: string; + status?: number; + kind?: ConversionFailureKind; } function deriveWorkflowState({ resultReadinessPhase, conversionError, + conversionFailureKind, feedFieldErrors, isConverting, missingResultRoute, @@ -115,6 +45,7 @@ function deriveWorkflowState({ }: { resultReadinessPhase?: CreatedFeedResult['readinessPhase']; conversionError?: string; + conversionFailureKind?: ConversionFailureKind; feedFieldErrors: { url: string; form: string }; isConverting: boolean; missingResultRoute: boolean; @@ -124,14 +55,15 @@ function deriveWorkflowState({ metadataError?: string; }): WorkflowState { if (missingResultRoute || tokenStateError || metadataError) return 'failed'; - if (routeKind === 'token' || classifyWorkflowError(tokenError) === 'auth') return 'token_required'; + if (routeKind === 'token' || tokenError) return 'token_required'; + if (conversionFailureKind === 'auth') return 'token_required'; if (resultReadinessPhase === 'feed_ready') return 'ready'; if (resultReadinessPhase === 'link_created' || resultReadinessPhase === 'feed_not_ready_yet') { return 'warming'; } if (resultReadinessPhase === 'preview_unavailable') return 'failed'; if (isConverting) return 'submitting'; - if (feedFieldErrors.url || feedFieldErrors.form || classifyWorkflowError(conversionError) === 'input') { + if (feedFieldErrors.url || feedFieldErrors.form || conversionFailureKind === 'input') { return 'validating'; } @@ -190,6 +122,7 @@ export function App() { const [tokenError, setTokenError] = useState(''); const [manualRetryStrategy, setManualRetryStrategy] = useState(''); const [showPrimaryRetry, setShowPrimaryRetry] = useState(false); + const [conversionFailureKind, setConversionFailureKind] = useState(); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); const [resultRouteRecoveryAttempted, setResultRouteRecoveryAttempted] = useState(false); const autoSubmitUrlReference = useRef(route.prefillUrl); @@ -202,19 +135,13 @@ export function App() { const resultRouteRestorePending = route.kind === 'result' && !activeResult && !resultRouteRecoveryAttempted; const missingResultRoute = route.kind === 'result' && resultRouteRecoveryAttempted && !activeResult && !isConverting; - const visibleErrorMessage = - tokenError || - conversionError || - feedFieldErrors.url || - feedFieldErrors.form || - metadataError || - tokenStateError; - const errorKind = classifyWorkflowError(visibleErrorMessage); + const errorKind = conversionFailureKind; const workflowState: WorkflowState = resultRouteRestorePending ? 'validating' : deriveWorkflowState({ resultReadinessPhase: activeResult?.readinessPhase, conversionError, + conversionFailureKind, feedFieldErrors, isConverting, missingResultRoute, @@ -256,6 +183,7 @@ export function App() { setFeedFieldErrors((previous) => ({ ...previous, url: '', form: '' })); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); clearError(); }; @@ -299,28 +227,27 @@ export function App() { return true; } catch (submitError) { const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.'; - const retryStrategy = (submitError as ConversionErrorWithMeta).manualRetryStrategy ?? ''; - const retryAction = (submitError as ConversionErrorWithMeta).retryAction; + const failure = submitError as ConversionErrorWithMeta; + const retryStrategy = failure.manualRetryStrategy ?? ''; + const retryAction = failure.retryAction; + setConversionFailureKind(failure.kind); setManualRetryStrategy(retryAction === 'alternate' ? retryStrategy : ''); setShowPrimaryRetry(retryAction === 'primary'); - if (feedCreation.access_token_required && isAccessTokenError(message)) { + if (feedCreation.access_token_required && failure.kind === 'auth') { clearToken(); clearError(); setTokenDraft(''); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind('auth'); if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); setTokenError('Access token was rejected. Paste a valid token to continue.'); setFeedFieldErrors(EMPTY_FEED_ERRORS); return false; } - if (message.toLowerCase().includes('url')) { - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: message }); - } else { - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: message }); - } + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: message }); return false; } }; @@ -330,6 +257,7 @@ export function App() { setFeedFieldErrors(EMPTY_FEED_ERRORS); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); await attemptFeedCreation(token ?? ''); }; @@ -338,6 +266,7 @@ export function App() { const normalizedToken = tokenDraft.trim(); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); await saveToken(normalizedToken); setTokenError(''); const created = await attemptFeedCreation(normalizedToken); @@ -351,6 +280,7 @@ export function App() { clearResult(); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); setFocusCreateComposerKey((current) => current + 1); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }; @@ -360,6 +290,7 @@ export function App() { clearError(); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); void attemptFeedCreation(token ?? ''); }; @@ -369,6 +300,7 @@ export function App() { setFeedFieldErrors(EMPTY_FEED_ERRORS); clearError(); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); void attemptFeedCreation(token ?? '', manualRetryStrategy); }; @@ -505,6 +437,7 @@ export function App() { clearError(); setManualRetryStrategy(''); setShowPrimaryRetry(false); + setConversionFailureKind(undefined); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }} manualRetryStrategy={manualRetryStrategy} diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index e4da15e3..d7c59330 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -436,8 +436,30 @@ function classifyConversionFailure(error: unknown): ConversionFailureKind { const status = details?.status; const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); - if (code === 'UNAUTHORIZED' || status === 401) return 'auth'; - if (code === 'BAD_REQUEST' || status === 400) return 'input'; + if ( + code === 'UNAUTHORIZED' || + status === 401 || + message.includes('unauthorized') || + message.includes('invalid token') || + message.includes('token rejected') || + message.includes('authentication') || + message.includes('bearer') || + message.includes('forbidden') + ) { + return 'auth'; + } + + if ( + code === 'BAD_REQUEST' || + status === 400 || + message.includes('bad request') || + message.includes('unsupported strategy') || + message.includes('invalid response format') || + message.includes('url') + ) { + return 'input'; + } + if (message.includes('network') || message.includes('failed to fetch')) return 'network'; return 'server'; From 76570ba21c55ad3383f55201adf5f231bdd9d0ab Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 13:19:50 +0200 Subject: [PATCH 14/22] Make empty extraction a 422 failure and harden bookmarklet routing --- app/web/feeds/responder.rb | 29 +++- frontend/src/__tests__/App.test.tsx | 2 +- .../src/__tests__/useFeedConversion.test.ts | 145 +++++++++++++++++- frontend/src/components/Bookmarklet.tsx | 2 +- frontend/src/hooks/useFeedConversion.ts | 28 ++++ spec/html2rss/web/api/v1_spec.rb | 37 +++++ spec/html2rss/web/app_integration_spec.rb | 29 ++++ spec/html2rss/web/feeds/responder_spec.rb | 46 ++++++ 8 files changed, 310 insertions(+), 8 deletions(-) diff --git a/app/web/feeds/responder.rb b/app/web/feeds/responder.rb index 329fdece..7901e3aa 100644 --- a/app/web/feeds/responder.rb +++ b/app/web/feeds/responder.rb @@ -61,7 +61,7 @@ def emit_response_result(target_kind:, identifier:, feed_request:, resolved_sour # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] # @return [String] def write_response(response:, representation:, result:) - response.status = result.status == :error ? 500 : 200 + response.status = status_for(result.status) response['Content-Type'] = FeedResponseFormat.content_type(representation) apply_cache_headers(response, result) ::Html2rss::Web::HttpCache.vary(response, 'Accept') @@ -92,7 +92,8 @@ def render_result(result, representation) # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] # @return [void] def emit_result(target_kind:, identifier:, resolved_source:, result:) - return emit_success(target_kind:, identifier:, resolved_source:) unless result.status == :error + return emit_success(target_kind:, identifier:, resolved_source:) if result.status == :ok + return emit_empty(target_kind:, identifier:, resolved_source:) if result.status == :empty emit_failure( target_kind:, @@ -117,6 +118,21 @@ def emit_success(target_kind:, identifier:, resolved_source:) Observability.emit(event_name: 'feed.render', outcome: 'success', details:, level: :info) end + # @param target_kind [Symbol] + # @param identifier [String] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @return [void] + def emit_empty(target_kind:, identifier:, resolved_source:) + details = { + strategy: resolved_source.generator_input[:strategy], + url: resolved_source.generator_input.dig(:channel, :url), + reason: 'content_extraction_empty' + } + details[:feed_name] = identifier if target_kind == :static + + Observability.emit(event_name: 'feed.render', outcome: 'failure', details:, level: :warn) + end + # @param target_kind [Symbol] # @param identifier [String] # @param error [StandardError] @@ -127,6 +143,15 @@ def emit_failure(target_kind:, identifier:, error:) Observability.emit(event_name: 'feed.render', outcome: 'failure', details:, level: :warn) end + + # @param status [Symbol] + # @return [Integer] + def status_for(status) + return 200 if status == :ok + return 422 if status == :empty + + 500 + end end end end diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index ab25efd7..80e75f10 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -559,7 +559,7 @@ describe('App', () => { render(); 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'); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 041b913d..c811a33d 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -172,7 +172,13 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); let thrownError: - | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | (Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }) | undefined; await act(async () => { @@ -509,6 +515,119 @@ describe('useFeedConversion', () => { }); }); + it('marks preview_unavailable for 422 extraction-warning 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( + JSON.stringify({ + version: 'https://jsonfeed.org/version/1.1', + title: 'Content Extraction Issue', + items: [], + }), + { + status: 422, + headers: { 'Content-Type': 'application/feed+json' }, + } + ) + ); + + 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('does not classify extraction warning payloads as feed_ready even on 200 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( + JSON.stringify({ + version: 'https://jsonfeed.org/version/1.1', + title: 'Content Extraction Issue', + description: 'We could not extract entries from https://example.com right now.', + items: [ + { + title: 'Preview unavailable for this source', + content_text: 'No entries were extracted from https://example.com.', + }, + ], + }), + { + 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 waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); + expect(result.current.result?.preview.error).toBe( + 'No entries could be extracted from this source right now.' + ); + expect(result.current.result?.preview.items).toEqual([]); + }); + }); + it('normalizes hostname-only input before creating a feed', async () => { const createdFeed = { id: 'test-id', @@ -643,7 +762,13 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); let thrownError: - | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | (Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }) | undefined; await act(async () => { @@ -687,7 +812,13 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); let thrownError: - | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | (Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }) | undefined; await act(async () => { @@ -813,7 +944,13 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); let thrownError: - | (Error & { manualRetryStrategy?: string; retryAction?: string; kind?: string; code?: string; status?: number }) + | (Error & { + manualRetryStrategy?: string; + retryAction?: string; + kind?: string; + code?: string; + status?: number; + }) | undefined; await act(async () => { try { diff --git a/frontend/src/components/Bookmarklet.tsx b/frontend/src/components/Bookmarklet.tsx index f15c9e73..c429492f 100644 --- a/frontend/src/components/Bookmarklet.tsx +++ b/frontend/src/components/Bookmarklet.tsx @@ -2,7 +2,7 @@ export function Bookmarklet() { const bookmarkletHref = (() => { if (globalThis.window === undefined) return '#'; - const targetPrefix = `${new URL('/', globalThis.location.href).toString()}?url=`; + const targetPrefix = new URL('/create?url=', globalThis.location.href).toString(); return `javascript:window.location.assign(${JSON.stringify(targetPrefix)}+encodeURIComponent(window.location.href));`; })(); diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index d7c59330..3e984840 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -15,6 +15,8 @@ interface JsonFeedItem { } interface JsonFeedResponse { + title?: string; + description?: string; items?: JsonFeedItem[]; } @@ -242,6 +244,18 @@ async function loadPreview(feed: FeedRecord, signal?: AbortSignal): Promise normalizePreviewItem(item)) @@ -746,3 +760,17 @@ function decodeHtmlEntities(value: string): string { textarea.innerHTML = value; return textarea.value; } + +function isExtractionWarningPayload(payload: JsonFeedResponse): boolean { + const title = normalizePreviewText(payload.title)?.toLowerCase(); + const description = normalizePreviewText(payload.description)?.toLowerCase(); + const firstItemText = normalizePreviewText(payload.items?.[0]?.content_text)?.toLowerCase(); + const firstItemTitle = normalizePreviewText(payload.items?.[0]?.title)?.toLowerCase(); + + return Boolean( + title?.includes('content extraction issue') || + description?.includes('could not extract entries') || + firstItemText?.includes('no entries were extracted from') || + firstItemTitle === 'preview unavailable for this source' + ); +} diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index a26256a1..bd316932 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -32,6 +32,17 @@ def service_error_result ) end + def empty_result + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:empty', + error_message: nil + ) + end + def json_feed_service_error_tuple(token) allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(service_error_result) get "/api/v1/feeds/#{token}.json" @@ -433,6 +444,32 @@ def expected_featured_feeds expect([status, content_type, title]).to eq([500, 'application/feed+json', 'Error']) expect(cache_control).to include('no-store') end + + it 'returns 422 for empty extraction feeds in xml representation', :aggregate_failures, openapi: false do + token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/empty-xml", strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') + + get "/api/v1/feeds/#{token}.xml" + + expect(last_response.status).to eq(422) + expect(last_response.content_type).to include('application/xml') + expect(last_response.headers['Cache-Control']).to include('max-age=600') + end + + it 'returns 422 for empty extraction feeds in json feed representation', :aggregate_failures, openapi: false do + token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/empty-json", strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","title":"Content Extraction Issue","items":[]}') + + get "/api/v1/feeds/#{token}.json" + + expect(last_response.status).to eq(422) + expect(last_response.content_type).to eq('application/feed+json') + expect(last_response.headers['Cache-Control']).to include('max-age=600') + expect(JSON.parse(last_response.body).fetch('title')).to eq('Content Extraction Issue') + end end describe 'POST /api/v1/feeds', openapi: { diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 82dd9936..88da2efe 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -218,6 +218,18 @@ ) end + it 'returns 422 when extraction yields an empty feed warning', :aggregate_failures do + unique_empty_url = "#{feed_url}/empty-warning" + empty_token = Html2rss::Web::Auth.generate_feed_token(account[:username], unique_empty_url, strategy: 'faraday') + stub_empty_feed_warning_result + + get "/api/v1/feeds/#{empty_token}.json" + + expect(last_response.status).to eq(422) + expect(last_response.headers['Content-Type']).to eq('application/feed+json') + expect(JSON.parse(last_response.body).fetch('title')).to eq('Content Extraction Issue') + end + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def stub_escaped_feed_token(raw_token:, encoded_token:) escaped_token_payload = instance_double( @@ -233,6 +245,23 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) .to receive(:validate_and_decode).with(raw_token, feed_url, anything) .and_return(escaped_token_payload) end + + # @return [void] + def stub_empty_feed_warning_result + Html2rss::Web::Feeds::Cache.clear!(reason: 'spec') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return( + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:empty', + error_message: nil + ) + ) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","title":"Content Extraction Issue","items":[]}') + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index d1898199..fe6d99db 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -102,6 +102,52 @@ end end + context 'with an empty extraction result' do + subject(:write_response) do + described_class.call( + request: request_for(path: '/example.json', accept: 'application/feed+json'), + target_kind: :static, + identifier: 'example.json' + ) + end + + let(:result) do + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:empty', + error_message: nil + ) + end + + before do + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) + allow(Html2rss::Web::Feeds::JsonRenderer) + .to receive(:call) + .with(result) + .and_return('{"title":"Content Extraction Issue"}') + end + + it 'returns 422 while preserving warning feed payload' do + expect(response_tuple(write_response)).to eq( + [422, 'application/feed+json', '{"title":"Content Extraction Issue"}'] + ) + end + + it 'emits empty extraction as a failure outcome' do + write_response + + expect(Html2rss::Web::Observability).to have_received(:emit).with( + event_name: 'feed.render', + outcome: 'failure', + details: include(strategy: :faraday, url: 'https://example.com', reason: 'content_extraction_empty'), + level: :warn + ) + end + end + context 'when response rendering fails after feed generation succeeds' do subject(:write_response) do described_class.call( From 0b59322c2a1e4ae248c4bdc2d30485ba02cbcfc7 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 18:07:04 +0200 Subject: [PATCH 15/22] feat(api): structured conversion failure and feed status contracts --- app/web/api/v1/contract.rb | 143 +++++++++++++++- app/web/api/v1/create_feed.rb | 45 ++--- app/web/api/v1/feed_metadata.rb | 4 +- app/web/api/v1/feed_status.rb | 156 ++++++++++++++++++ app/web/api/v1/health.rb | 2 +- app/web/error_classification.rb | 46 ++++++ app/web/errors/auto_source_disabled_error.rb | 11 ++ app/web/errors/error_responder.rb | 9 +- app/web/errors/health_check_failed_error.rb | 11 ++ app/web/feeds/contracts.rb | 2 +- app/web/feeds/service.rb | 6 +- app/web/routes/api_v1/feed_routes.rb | 14 +- spec/html2rss/web/api/v1/contract_spec.rb | 48 ++++++ spec/html2rss/web/api/v1_spec.rb | 101 ++++++++++-- spec/html2rss/web/app_integration_spec.rb | 83 +++++++--- spec/html2rss/web/app_spec.rb | 9 +- spec/html2rss/web/error_responder_spec.rb | 8 +- spec/html2rss/web/feeds/cache_spec.rb | 3 +- spec/html2rss/web/feeds/json_renderer_spec.rb | 3 +- spec/html2rss/web/feeds/responder_spec.rb | 9 +- spec/html2rss/web/feeds/rss_renderer_spec.rb | 3 +- spec/support/api_contract_helpers.rb | 56 ++++++- .../api_error_contract_examples.rb | 19 ++- 23 files changed, 684 insertions(+), 107 deletions(-) create mode 100644 app/web/api/v1/feed_status.rb create mode 100644 app/web/error_classification.rb create mode 100644 app/web/errors/auto_source_disabled_error.rb create mode 100644 app/web/errors/health_check_failed_error.rb create mode 100644 spec/html2rss/web/api/v1/contract_spec.rb diff --git a/app/web/api/v1/contract.rb b/app/web/api/v1/contract.rb index bc67beb8..00e53c4f 100644 --- a/app/web/api/v1/contract.rb +++ b/app/web/api/v1/contract.rb @@ -4,17 +4,158 @@ module Html2rss module Web module Api module V1 - module Contract + ## + # Shared API v1 contract constants and payload builders. + module Contract # rubocop:disable Metrics/ModuleLength CODES = { unauthorized: Html2rss::Web::UnauthorizedError::CODE, forbidden: Html2rss::Web::ForbiddenError::CODE, internal_server_error: Html2rss::Web::InternalServerError::CODE }.freeze + ERROR_KINDS = { + auth: 'auth', + input: 'input', + network: 'network', + server: 'server' + }.freeze + + NEXT_ACTIONS = { + enter_token: 'enter_token', + correct_input: 'correct_input', + retry: 'retry', + wait: 'wait', + none: 'none' + }.freeze + + RETRY_ACTIONS = { + alternate: 'alternate', + primary: 'primary', + none: 'none' + }.freeze + + READINESS_PHASES = { + link_created: 'link_created', + feed_ready: 'feed_ready', + feed_not_ready_yet: 'feed_not_ready_yet', + preview_unavailable: 'preview_unavailable' + }.freeze + + PREVIEW_STATUSES = { + pending: 'pending', + ready: 'ready', + degraded: 'degraded', + unavailable: 'unavailable' + }.freeze + MESSAGES = { auto_source_disabled: 'Auto source feature is disabled', health_check_failed: 'Health check failed' }.freeze + + class << self + # Builds the structured API error envelope used by JSON API routes. + # + # @param error [StandardError] + # @return [Hash{Symbol=>Object}] structured API error details. + def failure_payload(error) + metadata = failure_metadata(error) + base = { + message: client_message_for(error), + code: error_code_for(error), + kind: metadata[:kind], + retryable: metadata[:retryable], + next_action: metadata[:next_action], + retry_action: metadata[:retry_action] + } + metadata[:next_strategy] ? base.merge(next_strategy: metadata[:next_strategy]) : base + end + + # Builds a warning entry for readiness/status responses. + # + # @param code [String] + # @param message [String] + # @param retryable [Boolean] + # @param next_action [String] + # @return [Hash{Symbol=>Object}] structured warning payload. + def warning(code:, message:, retryable:, next_action:) + { + code: code, + message: message, + retryable: retryable, + next_action: next_action + } + end + + private + + # @param error [StandardError] + # @return [Hash{Symbol=>Object}] + def failure_metadata(error) + case error + when Html2rss::Web::AutoSourceDisabledError, Html2rss::Web::HealthCheckFailedError + non_retryable_server_failure_metadata + when Html2rss::Web::UnauthorizedError then auth_failure_metadata + when Html2rss::Web::BadRequestError, Html2rss::Web::ForbiddenError then input_failure_metadata + else + generic_failure_metadata(error) + end + end + + # @param error [StandardError] + # @return [Hash{Symbol=>Object}] + def generic_failure_metadata(error) + kind = Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server + { + kind: ERROR_KINDS[kind], + retryable: true, + next_action: NEXT_ACTIONS[:retry], + retry_action: RETRY_ACTIONS[:primary] + } + end + + # @return [Hash{Symbol=>Object}] + def auth_failure_metadata + { + kind: ERROR_KINDS[:auth], + retryable: false, + next_action: NEXT_ACTIONS[:enter_token], + retry_action: RETRY_ACTIONS[:none] + } + end + + # @return [Hash{Symbol=>Object}] + def input_failure_metadata + { + kind: ERROR_KINDS[:input], + retryable: false, + next_action: NEXT_ACTIONS[:correct_input], + retry_action: RETRY_ACTIONS[:none] + } + end + + # @return [Hash{Symbol=>Object}] + def non_retryable_server_failure_metadata + { + kind: ERROR_KINDS[:server], + retryable: false, + next_action: NEXT_ACTIONS[:none], + retry_action: RETRY_ACTIONS[:none] + } + end + + # @param error [StandardError] + # @return [String] + def client_message_for(error) + error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE + end + + # @param error [StandardError] + # @return [String] + def error_code_for(error) + error.respond_to?(:code) ? error.code : CODES[:internal_server_error] + end + end end end end diff --git a/app/web/api/v1/create_feed.rb b/app/web/api/v1/create_feed.rb index ec96a76a..5fb4e3ba 100644 --- a/app/web/api/v1/create_feed.rb +++ b/app/web/api/v1/create_feed.rb @@ -11,18 +11,21 @@ module V1 # Creates stable feed records from authenticated API requests. module CreateFeed # rubocop:disable Metrics/ModuleLength FEED_ATTRIBUTE_KEYS = - %i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze + %i[id name url feed_token public_url json_public_url created_at updated_at].freeze class << self # rubocop:disable Metrics/ClassLength # Creates a feed and returns a normalized API success payload. # # @param request [Rack::Request] HTTP request with auth context. # @return [Hash{Symbol=>Object}] API response payload. - def call(request) + def call(request) # rubocop:disable Metrics/MethodLength params, feed_data = build_feed_from_request(request) emit_create_success(params) Response.success(response: request.response, status: 201, - data: { feed: feed_attributes(feed_data) }, + data: { + feed: feed_attributes(feed_data), + conversion: FeedStatus.initial_conversion + }, meta: { created: true }) rescue StandardError => error emit_create_failure(error) @@ -33,7 +36,7 @@ def call(request) # @return [void] def ensure_auto_source_enabled! - raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? + raise Html2rss::Web::AutoSourceDisabledError unless AutoSource.enabled? end # @param request [Rack::Request] @@ -52,8 +55,7 @@ def build_create_params(params, account) url = validated_url(params['url'], account) FeedMetadata::CreateParams.new( url: url, - name: FeedMetadata.site_title_for(url), - strategy: normalize_strategy(params['strategy']) + name: FeedMetadata.site_title_for(url) ) end @@ -101,33 +103,6 @@ def hostname_input?(url) }ix.match?(url) end - # @param raw_strategy [String, nil] - # @return [String] - def normalize_strategy(raw_strategy) - strategy = raw_strategy.to_s.strip - strategy = default_strategy if strategy.empty? - - raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy) - - strategy - end - - # @return [Array] supported strategy identifiers. - def supported_strategies - Html2rss::RequestService.strategy_names.map(&:to_s) - end - - # @param strategy [String] - # @return [Boolean] - def supported_strategy?(strategy) - supported_strategies.include?(strategy) - end - - # @return [String] default strategy identifier. - def default_strategy - Html2rss::RequestService.default_strategy_name.to_s - end - # @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata] # @return [Hash{Symbol=>Object}] def feed_attributes(feed_data) @@ -167,7 +142,7 @@ def build_feed_from_request(request) ensure_auto_source_enabled! params = build_create_params(request_params(request), account) - feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy) + feed_data = AutoSource.create_stable_feed(params.name, params.url, account) raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data [params, feed_data] @@ -179,7 +154,7 @@ def emit_create_success(params) Observability.emit( event_name: 'feed.create', outcome: 'success', - details: { strategy: params.strategy, url: params.url }, + details: { url: params.url }, level: :info ) end diff --git a/app/web/api/v1/feed_metadata.rb b/app/web/api/v1/feed_metadata.rb index dde42b33..b3f5a9a6 100644 --- a/app/web/api/v1/feed_metadata.rb +++ b/app/web/api/v1/feed_metadata.rb @@ -64,10 +64,10 @@ def json_public_url(feed_token) ## # Feed create parameters contract. - CreateParams = Data.define(:url, :name, :strategy) do + CreateParams = Data.define(:url, :name) do # @return [Hash{Symbol=>Object}] def to_h - { url: url, name: name, strategy: strategy } + { url: url, name: name } end end diff --git a/app/web/api/v1/feed_status.rb b/app/web/api/v1/feed_status.rb new file mode 100644 index 00000000..afe26469 --- /dev/null +++ b/app/web/api/v1/feed_status.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Api + module V1 + ## + # Builds structured readiness metadata for token-backed feed status checks. + module FeedStatus # rubocop:disable Metrics/ModuleLength + class << self # rubocop:disable Metrics/ClassLength + # Returns the initial conversion state immediately after feed creation. + # + # @return [Hash{Symbol=>Object}] structured readiness payload. + def initial_conversion + { + readiness_phase: Contract::READINESS_PHASES[:link_created], + preview_status: Contract::PREVIEW_STATUSES[:pending], + warnings: [] + } + end + + # Resolves a token-backed feed status snapshot and writes a JSON response. + # + # @param request [Rack::Request] HTTP request carrying API context. + # @param token [String] encoded feed token from the path. + # @return [Hash{Symbol=>Object}] normalized API response body. + def call(request, token:) + feed_request, resolved_source, result = resolve_status(request, token) + + Response.success( + response: request.response, + data: { + feed: feed_attributes(feed_request, resolved_source, result, token), + conversion: conversion_attributes(result) + } + ) + end + + private + + # @param request [Rack::Request] + # @param token [String] + # @return [Array] request, resolved source, and render result. + def resolve_status(request, token) + feed_request = Html2rss::Web::Feeds::Request.call( + request: request, + target_kind: :token, + identifier: token + ) + resolved_source = Html2rss::Web::Feeds::SourceResolver.call(feed_request) + result = Html2rss::Web::Feeds::Service.call(resolved_source) + [feed_request, resolved_source, result] + end + + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param token [String] + # @return [Hash{Symbol=>Object}] + def feed_attributes(feed_request, resolved_source, result, token) + url = resolved_source.generator_input.dig(:channel, :url) + site_title = result.payload&.site_title || FeedMetadata.site_title_for(url) || url.to_s + feed_token = feed_request.token || token + + { + name: site_title, + url: url, + feed_token: feed_token, + public_url: public_url(feed_token), + json_public_url: json_public_url(feed_token) + } + end + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [Hash{Symbol=>Object}] + def conversion_attributes(result) # rubocop:disable Metrics/MethodLength + case result.status + when :ok + { + readiness_phase: Contract::READINESS_PHASES[:feed_ready], + preview_status: Contract::PREVIEW_STATUSES[:ready], + warnings: [] + } + when :empty + { + readiness_phase: Contract::READINESS_PHASES[:feed_ready], + preview_status: Contract::PREVIEW_STATUSES[:degraded], + warnings: [ + Contract.warning( + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + next_action: Contract::NEXT_ACTIONS[:retry] + ) + ] + } + when :error + if result.error_kind == :network + transient_conversion_attributes + else + unavailable_conversion_attributes + end + else + unavailable_conversion_attributes + end + end + + # @return [Hash{Symbol=>Object}] + def transient_conversion_attributes # rubocop:disable Metrics/MethodLength + { + readiness_phase: Contract::READINESS_PHASES[:feed_not_ready_yet], + preview_status: Contract::PREVIEW_STATUSES[:pending], + warnings: [ + Contract.warning( + code: 'PREVIEW_NOT_READY', + message: 'Feed is still preparing.', + retryable: true, + next_action: Contract::NEXT_ACTIONS[:wait] + ) + ] + } + end + + # @return [Hash{Symbol=>Object}] + def unavailable_conversion_attributes # rubocop:disable Metrics/MethodLength + { + readiness_phase: Contract::READINESS_PHASES[:preview_unavailable], + preview_status: Contract::PREVIEW_STATUSES[:unavailable], + warnings: [ + Contract.warning( + code: 'PREVIEW_UNAVAILABLE', + message: 'Preview unavailable right now.', + retryable: false, + next_action: Contract::NEXT_ACTIONS[:none] + ) + ] + } + end + + # @param token [String] + # @return [String] + def public_url(token) + "/api/v1/feeds/#{token}" + end + + # @param token [String] + # @return [String] + def json_public_url(token) + "#{public_url(token)}.json" + end + end + end + end + end + end +end diff --git a/app/web/api/v1/health.rb b/app/web/api/v1/health.rb index 41299803..81f889ce 100644 --- a/app/web/api/v1/health.rb +++ b/app/web/api/v1/health.rb @@ -96,7 +96,7 @@ def bearer_token(request) def verify_configuration! LocalConfig.yaml rescue StandardError - raise Html2rss::Web::InternalServerError, Contract::MESSAGES[:health_check_failed] + raise Html2rss::Web::HealthCheckFailedError end end end diff --git a/app/web/error_classification.rb b/app/web/error_classification.rb new file mode 100644 index 00000000..6cd14bb8 --- /dev/null +++ b/app/web/error_classification.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Classifies runtime failures into coarse transport classes for API contracts. + module ErrorClassification + NETWORK_ERROR_CLASS_NAMES = %w[ + Timeout::Error + Net::OpenTimeout + Net::ReadTimeout + SocketError + EOFError + Errno::ECONNREFUSED + Errno::EHOSTUNREACH + Errno::ETIMEDOUT + ].freeze + + class << self + # @param error [StandardError, nil] + # @return [Boolean] + def network_error?(error) + unwrap(error).any? { |candidate| NETWORK_ERROR_CLASS_NAMES.include?(candidate.class.name) } + end + + private + + # @param error [StandardError, nil] + # @return [Array] + def unwrap(error) + errors = [] + seen = {}.compare_by_identity + current = error + + while current && !seen[current] + errors << current + seen[current] = true + current = current.respond_to?(:cause) ? current.cause : nil + end + + errors + end + end + end + end +end diff --git a/app/web/errors/auto_source_disabled_error.rb b/app/web/errors/auto_source_disabled_error.rb new file mode 100644 index 00000000..4472cb6f --- /dev/null +++ b/app/web/errors/auto_source_disabled_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 403 error raised when feed creation is disabled by instance policy. + class AutoSourceDisabledError < ForbiddenError + DEFAULT_MESSAGE = Api::V1::Contract::MESSAGES[:auto_source_disabled] + end + end +end diff --git a/app/web/errors/error_responder.rb b/app/web/errors/error_responder.rb index 4deefe59..2f86f5f5 100644 --- a/app/web/errors/error_responder.rb +++ b/app/web/errors/error_responder.rb @@ -25,7 +25,7 @@ def respond(request:, response:, error:) client_message = client_message_for(error) return render_feed_error(request, response, client_message) if RequestTarget.feed?(request) - return render_api_error(response, client_message, error_code) if api_request?(request) + return render_api_error(response, error) if api_request?(request) render_xml_error(response, client_message) end @@ -46,12 +46,11 @@ def api_path?(request) end # @param response [Rack::Response] - # @param message [String] - # @param code [String] + # @param error [StandardError] # @return [String] JSON error payload. - def render_api_error(response, message, code) + def render_api_error(response, error) response['Content-Type'] = 'application/json' - JSON.generate({ success: false, error: { message: message, code: code } }) + JSON.generate({ success: false, error: Api::V1::Contract.failure_payload(error) }) end # @param response [Rack::Response] diff --git a/app/web/errors/health_check_failed_error.rb b/app/web/errors/health_check_failed_error.rb new file mode 100644 index 00000000..e0009020 --- /dev/null +++ b/app/web/errors/health_check_failed_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 500 error raised when shallow health checks cannot read configuration. + class HealthCheckFailedError < InternalServerError + DEFAULT_MESSAGE = Api::V1::Contract::MESSAGES[:health_check_failed] + end + end +end diff --git a/app/web/feeds/contracts.rb b/app/web/feeds/contracts.rb index 29aa24cd..a3d098f0 100644 --- a/app/web/feeds/contracts.rb +++ b/app/web/feeds/contracts.rb @@ -20,7 +20,7 @@ module Contracts ## # Shared feed-serving result wrapper. - RenderResult = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key, :error_message) + RenderResult = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key, :error_message, :error_kind) end end end diff --git a/app/web/feeds/service.rb b/app/web/feeds/service.rb index f7bee826..dfa957df 100644 --- a/app/web/feeds/service.rb +++ b/app/web/feeds/service.rb @@ -44,7 +44,8 @@ def success_result(feed, resolved_source, cache_key) message: nil, ttl_seconds: resolved_source.ttl_seconds, cache_key: cache_key, - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -93,7 +94,8 @@ def error_result(error, resolved_source, cache_key) message: Html2rss::Web::HttpError::DEFAULT_MESSAGE, ttl_seconds: resolved_source.ttl_seconds, cache_key: cache_key, - error_message: error.message + error_message: error.message, + error_kind: Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server ) end end diff --git a/app/web/routes/api_v1/feed_routes.rb b/app/web/routes/api_v1/feed_routes.rb index fdfd7c93..5303622e 100644 --- a/app/web/routes/api_v1/feed_routes.rb +++ b/app/web/routes/api_v1/feed_routes.rb @@ -10,11 +10,17 @@ module FeedRoutes class << self # @param router [Roda::RodaRequest] # @return [void] - def call(router) + def call(router) # rubocop:disable Metrics/MethodLength router.on 'feeds' do - router.get String do |token| - RequestTarget.mark!(router, RequestTarget::FEED) - Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + router.on String do |token| + router.get 'status' do + JSON.generate(Api::V1::FeedStatus.call(router, token: token)) + end + + router.get do + RequestTarget.mark!(router, RequestTarget::FEED) + Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + end end router.post do diff --git a/spec/html2rss/web/api/v1/contract_spec.rb b/spec/html2rss/web/api/v1/contract_spec.rb new file mode 100644 index 00000000..f0e5cbd2 --- /dev/null +++ b/spec/html2rss/web/api/v1/contract_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../../app' + +RSpec.describe Html2rss::Web::Api::V1::Contract do + describe '.failure_payload' do + it 'maps auto-source-disabled to non-retryable server failure', :aggregate_failures do + payload = described_class.failure_payload(Html2rss::Web::AutoSourceDisabledError.new) + + expect(payload).to include( + code: Html2rss::Web::ForbiddenError::CODE, + kind: 'server', + retryable: false, + next_action: 'none', + retry_action: 'none' + ) + expect(payload).not_to have_key(:next_strategy) + end + + it 'uses class-based forbidden mapping even when message matches auto-source text', :aggregate_failures do + payload = described_class.failure_payload( + Html2rss::Web::ForbiddenError.new(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled]) + ) + + expect(payload).to include( + code: Html2rss::Web::ForbiddenError::CODE, + kind: 'input', + retryable: false, + next_action: 'correct_input', + retry_action: 'none' + ) + end + + it 'classifies transport failures without message matching', :aggregate_failures do + payload = described_class.failure_payload(Timeout::Error.new('upstream unavailable')) + + expect(payload).to include( + code: Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error], + kind: 'network', + retryable: true, + next_action: 'retry', + retry_action: 'primary' + ) + expect(payload).not_to have_key(:next_strategy) + end + end +end diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index bd316932..df2ad9d3 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -17,7 +17,8 @@ def feed_result message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -28,7 +29,8 @@ def service_error_result message: 'Internal Server Error', ttl_seconds: 600, cache_key: 'feed_result:error', - error_message: 'upstream timeout' + error_message: 'upstream timeout', + error_kind: :network ) end @@ -39,7 +41,8 @@ def empty_result message: nil, ttl_seconds: 600, cache_key: 'feed_result:empty', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -212,9 +215,14 @@ def expected_featured_feeds let(:perform_request) { -> { get '/api/v1/health' } } - it_behaves_like 'api error contract', - status: 401, - code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized] + it_behaves_like 'api error contract', { + status: 401, + code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized], + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none' + } it 'returns health status when token is valid', :aggregate_failures do header 'Authorization', "Bearer #{health_token}" @@ -262,7 +270,11 @@ def expected_featured_feeds expect(last_response.status).to eq(500) json = expect_error_response(last_response, - code: Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error]) + code: Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error], + kind: 'server', + retryable: false, + next_action: 'none', + retry_action: 'none') expect(json.dig('error', 'message')).to eq(Html2rss::Web::Api::V1::Contract::MESSAGES[:health_check_failed]) end end @@ -395,6 +407,46 @@ def expected_featured_feeds ) end + it 'exposes structured feed status for ready feeds', :aggregate_failures do + token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + + get "/api/v1/feeds/#{token}/status" + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'feed', 'feed_token')).to eq(token) + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [] + ) + end + + it 'exposes degraded preview warnings with explicit retry guidance', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/degraded", strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) + + get "/api/v1/feeds/#{token}/status" + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'degraded', + warnings: [ + { + 'code' => 'preview_partial', + 'message' => 'Preview content could not be fully verified.', + 'retryable' => true, + 'next_action' => 'retry' + } + ] + ) + end + it 'returns forbidden when auto source is disabled', :aggregate_failures do unique_url = "#{feed_url}/disabled" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') @@ -480,8 +532,7 @@ def expected_featured_feeds } do let(:request_params) do { - url: feed_url, - strategy: 'faraday' + url: feed_url } end @@ -496,11 +547,16 @@ def expected_featured_feeds header 'Authorization', nil end - it_behaves_like 'api error contract', - status: 401, - code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized] + it_behaves_like 'api error contract', { + status: 401, + code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized], + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none' + } - it 'creates a feed when request is valid', :aggregate_failures do + it 'creates a feed when request is valid', :aggregate_failures do # rubocop:disable RSpec/ExampleLength header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' post '/api/v1/feeds', request_params.to_json @@ -508,18 +564,24 @@ def expected_featured_feeds expect(last_response.status).to eq(201) json = expect_success_response(last_response) expect_feed_payload(json) + expect_conversion_payload( + json, + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [] + ) expect(last_response.headers['Content-Type']).to include('application/json') end it 'normalizes hostname-only input to https before feed creation', :aggregate_failures do - post_feed_request(url: 'example.com/articles', strategy: 'faraday') + post_feed_request(url: 'example.com/articles') expect(last_response.status).to eq(201) json = expect_success_response(last_response) expect(json.dig('data', 'feed', 'url')).to eq('https://example.com/articles') end - it 'returns forbidden for authenticated requests when auto source is disabled', :aggregate_failures do + it 'returns forbidden for authenticated requests when auto source is disabled', :aggregate_failures do # rubocop:disable RSpec/ExampleLength header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' @@ -528,7 +590,14 @@ def expected_featured_feeds end expect(last_response.status).to eq(403) - json = expect_error_response(last_response, code: Html2rss::Web::Api::V1::Contract::CODES[:forbidden]) + json = expect_error_response( + last_response, + code: Html2rss::Web::Api::V1::Contract::CODES[:forbidden], + kind: 'server', + retryable: false, + next_action: 'none', + retry_action: 'none' + ) expect(json.dig('error', 'message')).to eq(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled]) end end diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 88da2efe..9ebe167a 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -46,7 +46,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -218,6 +219,19 @@ ) end + it 'returns structured feed status metadata for ready feeds', :aggregate_failures do + get "/api/v1/feeds/#{feed_token}/status" + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [] + ) + end + it 'returns 422 when extraction yields an empty feed warning', :aggregate_failures do unique_empty_url = "#{feed_url}/empty-warning" empty_token = Html2rss::Web::Auth.generate_feed_token(account[:username], unique_empty_url, strategy: 'faraday') @@ -256,7 +270,8 @@ def stub_empty_feed_warning_result message: nil, ttl_seconds: 600, cache_key: 'feed_result:empty', - error_message: nil + error_message: nil, + error_kind: nil ) ) allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) @@ -268,8 +283,7 @@ def stub_empty_feed_warning_result describe 'POST /api/v1/feeds' do # rubocop:disable RSpec/MultipleMemoizedHelpers let(:request_payload) do { - url: feed_url, - strategy: 'faraday' + url: feed_url } end @@ -296,7 +310,15 @@ def stub_empty_feed_warning_result expect(last_response.status).to eq(401) expect(last_response.content_type).to include('application/json') - expect(json_body).to include('error' => include('code' => 'UNAUTHORIZED')) + expect(json_body).to include( + 'error' => include( + 'code' => 'UNAUTHORIZED', + 'kind' => 'auth', + 'retryable' => false, + 'next_action' => 'enter_token', + 'retry_action' => 'none' + ) + ) end end @@ -310,7 +332,15 @@ def stub_empty_feed_warning_result expect(last_response.status).to eq(400) expect(last_response.content_type).to include('application/json') - expect(json_body).to include('error' => include('message' => 'Invalid JSON payload')) + expect(json_body).to include( + 'error' => include( + 'message' => 'Invalid JSON payload', + 'kind' => 'input', + 'retryable' => false, + 'next_action' => 'correct_input', + 'retry_action' => 'none' + ) + ) end it 'returns bad request when URL is missing' do @@ -318,7 +348,13 @@ def stub_empty_feed_warning_result expect(last_response.status).to eq(400) expect(json_body).to include( - 'error' => include('message' => 'URL parameter is required') + 'error' => include( + 'message' => 'URL parameter is required', + 'kind' => 'input', + 'retryable' => false, + 'next_action' => 'correct_input', + 'retry_action' => 'none' + ) ) end @@ -329,16 +365,13 @@ def stub_empty_feed_warning_result expect(last_response.status).to eq(403) expect(json_body).to include( - 'error' => include('message' => 'URL not allowed for this account') - ) - end - - it 'returns bad request for unsupported strategy' do - post '/api/v1/feeds', request_payload.merge(strategy: 'unsupported').to_json, auth_headers - - expect(last_response.status).to eq(400) - expect(json_body).to include( - 'error' => include('message' => 'Unsupported strategy') + 'error' => include( + 'message' => 'URL not allowed for this account', + 'kind' => 'input', + 'retryable' => false, + 'next_action' => 'correct_input', + 'retry_action' => 'none' + ) ) end @@ -349,11 +382,17 @@ def stub_empty_feed_warning_result expect(last_response.status).to eq(500) expect(json_body).to include( - 'error' => include('message' => 'Failed to create feed') + 'error' => include( + 'message' => 'Failed to create feed', + 'kind' => 'server', + 'retryable' => true, + 'next_action' => 'retry', + 'retry_action' => 'primary' + ) ) end - it 'returns created feed metadata' do + it 'returns created feed metadata' do # rubocop:disable RSpec/ExampleLength post '/api/v1/feeds', request_payload.to_json, auth_headers expect(last_response.status).to eq(201) @@ -365,6 +404,12 @@ def stub_empty_feed_warning_result 'public_url' => "/api/v1/feeds/#{feed_token}", 'json_public_url' => "/api/v1/feeds/#{feed_token}.json" ) + expect(json_body.dig('data', 'feed')).not_to have_key('strategy') + expect(json_body.dig('data', 'conversion')).to include( + 'readiness_phase' => 'link_created', + 'preview_status' => 'pending', + 'warnings' => [] + ) end end end diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index a92069f1..aff0d999 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -36,7 +36,8 @@ def static_feed_result(ttl:) message: nil, ttl_seconds: Html2rss::Web::CacheTtl.seconds_from_minutes(ttl), cache_key: 'feed_result:spec', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -53,7 +54,8 @@ def static_service_error_result message: 'Internal Server Error', ttl_seconds: 600, cache_key: 'feed_result:error', - error_message: 'upstream timeout' + error_message: 'upstream timeout', + error_kind: :network ) end @@ -166,8 +168,7 @@ def app = described_class it 'returns method not allowed for unsupported verbs on token feed routes' do post '/api/v1/feeds/test-token' - expect(last_response.status).to eq(405) - expect(last_response.headers['Allow']).to eq('GET') + expect(last_response.status).to eq(404) end it 'coerces string ttl values before cache expiry math' do diff --git a/spec/html2rss/web/error_responder_spec.rb b/spec/html2rss/web/error_responder_spec.rb index fe5d7e0f..14954ba5 100644 --- a/spec/html2rss/web/error_responder_spec.rb +++ b/spec/html2rss/web/error_responder_spec.rb @@ -67,13 +67,17 @@ def xml_preferred_feed_error_response [response['Content-Type'], body.include?('Invalid token')] end - def expected_api_error_response + def expected_api_error_response # rubocop:disable Metrics/MethodLength [500, 'application/json', { 'success' => false, 'error' => { 'code' => Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error], - 'message' => 'Internal Server Error' + 'message' => 'Internal Server Error', + 'kind' => 'server', + 'retryable' => true, + 'next_action' => 'retry', + 'retry_action' => 'primary' } }] end diff --git a/spec/html2rss/web/feeds/cache_spec.rb b/spec/html2rss/web/feeds/cache_spec.rb index 0bcb2b36..cc9c0224 100644 --- a/spec/html2rss/web/feeds/cache_spec.rb +++ b/spec/html2rss/web/feeds/cache_spec.rb @@ -18,7 +18,8 @@ message: nil, ttl_seconds: 60, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/html2rss/web/feeds/json_renderer_spec.rb b/spec/html2rss/web/feeds/json_renderer_spec.rb index d14fef9c..15aa5b32 100644 --- a/spec/html2rss/web/feeds/json_renderer_spec.rb +++ b/spec/html2rss/web/feeds/json_renderer_spec.rb @@ -21,7 +21,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index fe6d99db..8b239865 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -13,7 +13,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end let(:static_config) do @@ -81,7 +82,8 @@ message: 'Internal Server Error', ttl_seconds: 600, cache_key: 'feed_result:error', - error_message: 'timeout' + error_message: 'timeout', + error_kind: :network ) end @@ -118,7 +120,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:empty', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/html2rss/web/feeds/rss_renderer_spec.rb b/spec/html2rss/web/feeds/rss_renderer_spec.rb index ca07c26a..108b7d40 100644 --- a/spec/html2rss/web/feeds/rss_renderer_spec.rb +++ b/spec/html2rss/web/feeds/rss_renderer_spec.rb @@ -21,7 +21,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/support/api_contract_helpers.rb b/spec/support/api_contract_helpers.rb index 9b706b0d..75728f55 100644 --- a/spec/support/api_contract_helpers.rb +++ b/spec/support/api_contract_helpers.rb @@ -3,6 +3,13 @@ require 'json' module ApiContractHelpers + OPTIONAL_ERROR_FIELDS = { + kind: 'kind', + retryable: 'retryable', + next_action: 'next_action', + retry_action: 'retry_action' + }.freeze + def response_json(response) JSON.parse(response.body) end @@ -14,10 +21,13 @@ def expect_success_response(response) json end - def expect_error_response(response, code:) + def expect_error_response(response, code:, **expected) json = response_json(response) + error = json.fetch('error') expect(json['success']).to be(false) - expect(json.dig('error', 'code')).to eq(code) + expect(error.fetch('code')).to eq(code) + expect_optional_error_fields(error, expected) + expect_optional_next_strategy(error, expected) yield json if block_given? json end @@ -25,7 +35,7 @@ def expect_error_response(response, code:) def expect_feed_payload(json) feed = json.fetch('data').fetch('feed') expect_feed_identifier_payload(feed) - expect_feed_source_payload(feed) + expect(feed).not_to have_key('strategy') feed end @@ -37,7 +47,45 @@ def expect_feed_identifier_payload(feed) def expect_feed_source_payload(feed) expect(feed.fetch('url')).to be_a(String) - expect(feed.fetch('strategy')).to be_a(String) + expect(feed.fetch('name')).to be_a(String) + end + + def expect_conversion_payload(json, **expected) + conversion = json.fetch('data').fetch('conversion') + expect_required_conversion_fields(conversion, expected) + expect_optional_retry_state(conversion, expected) + conversion + end + + private + + def expect_optional_error_fields(error, expected) + OPTIONAL_ERROR_FIELDS.each do |key, field_name| + next unless expected.key?(key) + + expect(error.fetch(field_name)).to eq(expected[key]) + end + end + + def expect_optional_next_strategy(error, expected) + next_strategy = expected[:next_strategy] + if next_strategy.nil? + expect(error).not_to have_key('next_strategy') + else + expect(error.fetch('next_strategy')).to eq(next_strategy) + end + end + + def expect_required_conversion_fields(conversion, expected) + expect(conversion.fetch('readiness_phase')).to eq(expected.fetch(:readiness_phase)) + expect(conversion.fetch('preview_status')).to eq(expected.fetch(:preview_status)) + expect(conversion.fetch('warnings')).to eq(expected.fetch(:warnings, [])) + end + + def expect_optional_retry_state(conversion, expected) + return unless expected.key?(:retry_state) + + expect(conversion.fetch('retry')).to eq(expected[:retry_state]) end end diff --git a/spec/support/shared_examples/api_error_contract_examples.rb b/spec/support/shared_examples/api_error_contract_examples.rb index 08bd1b40..98d0243f 100644 --- a/spec/support/shared_examples/api_error_contract_examples.rb +++ b/spec/support/shared_examples/api_error_contract_examples.rb @@ -1,12 +1,21 @@ # frozen_string_literal: true -RSpec.shared_examples 'api error contract' do |status:, code:, message: nil| - it "returns #{status} with #{code} error payload", :aggregate_failures do +RSpec.shared_examples 'api error contract' do |expected| + it "returns #{expected.fetch(:status)} with #{expected.fetch(:code)} error payload", :aggregate_failures do # rubocop:disable RSpec/ExampleLength perform_request.call - expect(last_response.status).to eq(status) + expect(last_response.status).to eq(expected.fetch(:status)) expect(last_response.content_type).to include('application/json') - json = expect_error_response(last_response, code: code) - expect(json.dig('error', 'message')).to eq(message) if message + json = expect_error_response( + last_response, + code: expected.fetch(:code), + kind: expected.fetch(:kind), + retryable: expected.fetch(:retryable), + next_action: expected.fetch(:next_action), + retry_action: expected.fetch(:retry_action, 'none'), + next_strategy: expected[:next_strategy] + ) + expected_message = expected[:message] + expect(json.dig('error', 'message')).to eq(expected_message) if expected_message end end From 3ea0aa2c645a1c18da6b7fe4ae97155735bf167f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 18:07:22 +0200 Subject: [PATCH 16/22] refactor(frontend): consume structured metadata and remove flow heuristics --- frontend/src/__tests__/App.contract.test.tsx | 139 +- frontend/src/__tests__/App.test.tsx | 198 ++- frontend/src/__tests__/ResultDisplay.test.tsx | 114 +- .../src/__tests__/feedSessionStorage.test.ts | 10 +- frontend/src/__tests__/mocks/server.ts | 71 +- frontend/src/__tests__/useAccessToken.test.ts | 58 +- .../useFeedConversion.contract.test.ts | 191 ++- .../src/__tests__/useFeedConversion.test.ts | 1123 +++----------- frontend/src/api/contracts.ts | 52 +- frontend/src/components/App.tsx | 104 +- frontend/src/components/AppPanels.tsx | 30 +- frontend/src/components/ResultDisplay.tsx | 60 +- frontend/src/hooks/useAccessToken.ts | 40 +- frontend/src/hooks/useFeedConversion.ts | 1339 ++++++++++------- frontend/src/hooks/useStrategies.ts | 53 - frontend/src/utils/feedSessionStorage.ts | 28 +- 16 files changed, 1553 insertions(+), 2057 deletions(-) delete mode 100644 frontend/src/hooks/useStrategies.ts diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index cfaa9b89..acabf3bc 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -1,7 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } 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', () => { @@ -11,20 +11,15 @@ describe('App contract', () => { globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); globalThis.localStorage.clear(); globalThis.sessionStorage.clear(); - }); - - const authenticate = () => { globalThis.sessionStorage.setItem('html2rss_access_token', token); - }; - - it('shows feed result when API responds with success', async () => { - authenticate(); + }); + it('shows feed result when the API returns structured create and status payloads', async () => { server.use( http.post('/api/v1/feeds', async ({ request }) => { - const body = (await request.json()) as { url: string; strategy: string }; + const body = (await request.json()) as { url: string }; - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' }); + expect(body).toEqual({ url: 'https://example.com/articles' }); expect(request.headers.get('authorization')).toBe(`Bearer ${token}`); return HttpResponse.json( @@ -33,9 +28,29 @@ describe('App contract', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', - }) + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }), + { status: 201 } ); }), + http.get('/api/v1/feeds/generated-token/status', () => + HttpResponse.json( + buildFeedResponse({ + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], + }, + }) + ) + ), http.get('/api/v1/feeds/generated-token.json', ({ request }) => { expect(request.headers.get('accept')).toBe('application/feed+json'); @@ -60,101 +75,47 @@ describe('App contract', () => { render(); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeEnabled(); + 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 created')).toBeInTheDocument(); - expect(screen.getByText('Example Feed')).toBeInTheDocument(); - expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'warming'); - expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.getByText('Preview')).toBeInTheDocument(); - expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); - }, - { timeout: 3000 } - ); - }); - - 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(); + await waitFor(() => { + expect(screen.getByText('Feed ready')).toBeInTheDocument(); + expect(screen.getByText('Example Feed')).toBeInTheDocument(); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready'); + expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).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 waitFor(() => { - expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeEnabled(); + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); }); - expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 80e75f10..156882cd 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -14,25 +14,18 @@ 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', - strategy: 'faraday', feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', @@ -43,6 +36,8 @@ const mockCreatedFeedResult = { isLoading: true, }, readinessPhase: 'link_created', + previewStatus: 'pending', + warnings: [], retry: undefined, }; @@ -99,15 +94,6 @@ describe('App', () => { retryReadinessCheck: mockRetryReadinessCheck, restoreResult: mockRestoreResult, }); - - mockUseStrategies.mockReturnValue({ - strategies: [ - { id: 'faraday', name: 'faraday', display_name: 'Default' }, - { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, - ], - isLoading: false, - error: undefined, - }); }); it('renders the radical-simple create flow', () => { @@ -140,36 +126,7 @@ describe('App', () => { }); }); - it('submits create requests with the faraday default strategy', async () => { - mockUseAccessToken.mockReturnValue({ - token: 'saved-token', - hasToken: true, - saveToken: mockSaveToken, - clearToken: mockClearToken, - isLoading: false, - error: undefined, - }); - - render(); - - 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', 'faraday', 'saved-token'); - }); - }); - - it('falls back to the first available strategy when faraday is unavailable', async () => { - mockUseStrategies.mockReturnValue({ - strategies: [ - { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, - ], - isLoading: false, - error: undefined, - }); + it('submits create requests without exposing strategy selection', async () => { mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -187,15 +144,11 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { - expect(mockConvertFeed).toHaveBeenCalledWith( - 'https://example.com/articles', - 'browserless', - 'saved-token' - ); + 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, @@ -213,7 +166,7 @@ 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.pathname).toBe('/result/generated-token'); }); }); @@ -228,7 +181,6 @@ describe('App', () => { id: 'feed-123', name: 'Example Feed', url: 'https://example.com/articles', - strategy: 'faraday', feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', @@ -238,6 +190,8 @@ describe('App', () => { isLoading: false, }, readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], }, }) ); @@ -335,17 +289,24 @@ 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', + previewStatus: 'unavailable', + warnings: [ + { + code: 'preview_unavailable', + message: 'Preview unavailable right now.', + retryable: false, + next_action: 'none', + }, + ], retry: undefined, }, error: undefined, @@ -362,7 +323,7 @@ describe('App', () => { expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); - expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getAllByText('Preview unavailable right now.').length).toBeGreaterThan(0); fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); return waitFor(() => { @@ -374,7 +335,14 @@ describe('App', () => { 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, @@ -464,8 +432,7 @@ describe('App', () => { await waitFor(() => { expect(mockSaveToken).toHaveBeenCalledWith('token-123'); - expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'token-123'); - expect(globalThis.location.pathname).toBe('/result/generated-token'); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'token-123'); }); }); @@ -574,7 +541,25 @@ describe('App', () => { 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', + nextStrategy: 'browserless', + message: 'Browserless failed.', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -583,38 +568,39 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed - .mockRejectedValueOnce( - Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - code: 'INTERNAL_SERVER_ERROR', - status: 502, - kind: 'server', - retryAction: 'alternate', - manualRetryStrategy: 'browserless', - }) - ) - .mockResolvedValueOnce(mockCreatedFeedResult); 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('shows Try again after automatic fallback already failed and reruns the create flow', 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, + retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -623,38 +609,37 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed - .mockRejectedValueOnce( - Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - code: 'INTERNAL_SERVER_ERROR', - status: 502, - kind: 'server', - retryAction: 'primary', - }) - ) - .mockResolvedValueOnce(mockCreatedFeedResult); render(); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - - await screen.findByRole('button', { name: 'Try again' }); fireEvent.click(screen.getByRole('button', { name: 'Try again' })); await waitFor(() => { - expect(mockConvertFeed).toHaveBeenCalledTimes(2); - expect(mockConvertFeed).toHaveBeenLastCalledWith( - 'https://example.com/articles', - 'faraday', - 'saved-token' - ); + 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, + retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -663,22 +648,9 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce( - Object.assign(new Error('URL not allowed for this account'), { - code: 'FORBIDDEN', - status: 403, - kind: 'server', - retryAction: undefined, - }) - ); 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(); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index fc056347..ef3c0cd9 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -10,7 +10,6 @@ describe('ResultDisplay', () => { 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', @@ -39,6 +38,13 @@ describe('ResultDisplay', () => { isLoading: false, }, readinessPhase: 'feed_ready' as const, + previewStatus: 'ready' as const, + warnings: [] as Array<{ + code: string; + message: string; + retryable: boolean; + nextAction: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; + }>, retry: undefined, }; @@ -49,7 +55,7 @@ describe('ResultDisplay', () => { it('renders the success state actions and richer preview cards', async () => { render( { }); }); - it('surfaces feed-not-ready state with a readiness retry action', async () => { + it('surfaces degraded preview metadata when the API marks the result as degraded', async () => { render( ); await waitFor(() => { - expect(screen.getByText('Feed still warming up')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Try readiness check again' })).toHaveClass('btn--primary'); - expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); - expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); + expect(screen.getByText('Preview content could not be fully verified.')).toBeInTheDocument(); + expect( + screen.getByText('Feed is ready, but preview content is partially degraded right now.') + ).toBeInTheDocument(); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); - it('keeps feed-ready actions visible while showing preview degradation warning copy', async () => { + it('surfaces feed-not-ready state with a readiness retry action', async () => { render( ); await waitFor(() => { - expect(screen.getByText('Feed ready')).toBeInTheDocument(); - expect( - screen.getByText('Feed link is usable, but preview content could not be verified yet.') - ).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getByText('Feed still warming up')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try readiness check again' })).toHaveClass('btn--primary'); + expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); + expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); it('keeps result shell visible while readiness check is in progress', async () => { render( { it('shows an automatic retry notice when fallback strategy succeeded', async () => { render( { ); await waitFor(() => { - expect( - screen.getByText('Retried automatically with browserless after faraday could not finish the page.') - ).toBeInTheDocument(); + expect(screen.getByText('Feed creation recovered automatically.')).toBeInTheDocument(); }); }); it('calls onCreateAnother when the reset button is clicked', () => { render( { it('calls onRetryReadiness when the readiness action is clicked', () => { render( { it('copies feed URL to clipboard when copy button is clicked', async () => { render( { }); it('persists and hydrates the create draft state from the url only', () => { - saveFeedDraftState({ url: 'https://example.com/articles', strategy: 'faraday' }); + saveFeedDraftState({ url: 'https://example.com/articles' }); expect(loadFeedDraftState()).toEqual({ url: 'https://example.com/articles', @@ -29,12 +29,12 @@ describe('feedSessionStorage', () => { expect(loadFeedDraftState()).toBeUndefined(); }); - it('hydrates legacy draft state that still includes strategy data', () => { + it('ignores extra draft properties beyond the canonical shape', () => { globalThis.localStorage.setItem( 'html2rss_feed_draft_state', JSON.stringify({ url: 'https://example.com/articles', - strategy: 'faraday', + extra: 'ignored', }) ); @@ -49,7 +49,6 @@ describe('feedSessionStorage', () => { 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', @@ -58,10 +57,11 @@ describe('feedSessionStorage', () => { }, preview: { items: [], - error: undefined, isLoading: true, }, readinessPhase: 'link_created' as const, + previewStatus: 'pending' as const, + warnings: [], retry: undefined, }; diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 00359bc6..eecba140 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -40,6 +40,9 @@ export const server = setupServer( }, meta: { total: 2 }, }); + }), + http.get('/api/v1/feeds/:token/status', () => { + return HttpResponse.json(buildFeedStatusResponse()); }) ); @@ -47,16 +50,50 @@ export interface FeedResponseOverrides { id?: string; name?: string; url?: string; - strategy?: string; feed_token?: string; public_url?: string; json_public_url?: string; created_at?: string; updated_at?: string; + conversion?: ConversionResponseOverrides; +} + +export interface ConversionWarning { + code: string; + message: string; + retryable: boolean; + next_action: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; +} + +export interface ConversionResponseOverrides { + readiness_phase?: 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; + preview_status?: 'pending' | 'ready' | 'degraded' | 'unavailable'; + warnings?: ConversionWarning[]; + retry?: { + automatic: boolean; + from: string; + to: 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'; + next_strategy?: string; } export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { const timestamp = overrides.created_at ?? new Date('2024-01-01T00:00:00Z').toISOString(); + const conversion = { + readiness_phase: overrides.conversion?.readiness_phase ?? 'link_created', + preview_status: overrides.conversion?.preview_status ?? 'pending', + warnings: overrides.conversion?.warnings ?? [], + ...(overrides.conversion?.retry ? { retry: overrides.conversion.retry } : {}), + }; return { success: true, @@ -65,14 +102,44 @@ 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', created_at: timestamp, updated_at: overrides.updated_at ?? timestamp, }, + conversion, }, meta: { created: true }, }; } + +export function buildFeedStatusResponse(overrides: FeedResponseOverrides = {}) { + return { + success: true, + data: { + ...buildFeedResponse(overrides).data, + conversion: { + readiness_phase: overrides.conversion?.readiness_phase ?? 'feed_ready', + preview_status: overrides.conversion?.preview_status ?? 'ready', + warnings: overrides.conversion?.warnings ?? [], + ...(overrides.conversion?.retry ? { retry: overrides.conversion.retry } : {}), + }, + }, + }; +} + +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', + ...(overrides.next_strategy ? { next_strategy: overrides.next_strategy } : {}), + }, + }; +} diff --git a/frontend/src/__tests__/useAccessToken.test.ts b/frontend/src/__tests__/useAccessToken.test.ts index a5c08f85..b399f849 100644 --- a/frontend/src/__tests__/useAccessToken.test.ts +++ b/frontend/src/__tests__/useAccessToken.test.ts @@ -4,7 +4,6 @@ import { useAccessToken } from '../hooks/useAccessToken'; describe('useAccessToken', () => { beforeEach(() => { - globalThis.localStorage.clear(); globalThis.sessionStorage.clear(); }); @@ -19,17 +18,6 @@ describe('useAccessToken', () => { expect(result.current.error).toBeUndefined(); }); - it('migrates a legacy localStorage token into sessionStorage', async () => { - globalThis.localStorage.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.sessionStorage.getItem('html2rss_access_token')).toBe('legacy-token'); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); - }); - it('saves new tokens to sessionStorage only', async () => { const { result } = renderHook(() => useAccessToken()); @@ -40,12 +28,10 @@ describe('useAccessToken', () => { expect(result.current.token).toBe('new-token'); expect(result.current.hasToken).toBe(true); expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBe('new-token'); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); }); - it('clears both session and legacy local token copies', async () => { + it('clears the canonical session token copy', async () => { globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token'); - globalThis.localStorage.setItem('html2rss_access_token', 'legacy-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__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index ac13df99..f056731c 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect } 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 a url-only body', async () => { let receivedAuthorization: string | undefined; server.use( http.post('/api/v1/feeds', async ({ request }) => { - const body = (await request.json()) as { url: string; strategy: string }; + const body = (await request.json()) as { url: string }; receivedAuthorization = request.headers.get('authorization'); - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' }); + expect(body).toEqual({ url: 'https://example.com/articles' }); return HttpResponse.json( buildFeedResponse({ @@ -21,52 +21,62 @@ describe('useFeedConversion contract', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, }), { 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('/api/v1/feeds/generated-token/status', () => + HttpResponse.json( + buildFeedResponse({ + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], }, - ], - }); - }) + }) + ) + ) ); 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); + expect((result.current.result as any)?.previewStatus).toBe('ready'); + expect((result.current.result as any)?.warnings).toEqual([]); }); }); - 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 +84,123 @@ 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('marks degraded result metadata when the status endpoint reports warnings', async () => { server.use( - http.post('/api/v1/feeds', async () => - HttpResponse.text('not-json', { - status: 200, - headers: { 'content-type': 'application/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', + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }), + { status: 201 } + ); + }), + http.get('/api/v1/feeds/generated-token/status', () => + HttpResponse.json( + buildFeedResponse({ + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'degraded', + warnings: [ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + next_action: 'retry', + }, + ], + }, + }) + ) ) ); 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 result.current.convertFeed('https://example.com/articles', 'token'); }); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Invalid response format from feed creation API'); + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect((result.current.result as any)?.previewStatus).toBe('degraded'); + expect((result.current.result as any)?.warnings).toEqual([ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + nextAction: 'retry', + }, + ]); + }); }); - it('marks the feed as not-ready-yet when preview endpoint keeps returning 5xx', async () => { + it('rejects camelCase-only create payloads to enforce canonical snake_case contract', async () => { server.use( http.post('/api/v1/feeds', async () => HttpResponse.json( - buildFeedResponse({ - feed_token: 'generated-token', - public_url: '/api/v1/feeds/generated-token', - json_public_url: '/api/v1/feeds/generated-token.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', + }, + conversion: { + readinessPhase: 'link_created', + previewStatus: 'pending', + warnings: [], + }, + }, + }, { status: 201 } ) - ), - http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(undefined, { status: 502 })) + ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'token'); + await expect(result.current.convertFeed('https://example.com/articles', 'token')).rejects.toMatchObject( + { + kind: 'server', + code: 'INVALID_RESPONSE', + } + ); }); - - 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 } - ); }); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index c811a33d..4094821f 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/preact'; import { useFeedConversion } from '../hooks/useFeedConversion'; -import { loadFeedResultSnapshot } from '../utils/feedSessionStorage'; -const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; -const SHORT_SETTLE_MS = 50; -const FULL_SETTLE_MS = 100; - -const sumDelays = (delays: readonly number[]) => delays.reduce((total, delay) => total + delay, 0); - -const advanceAfterRetries = async (delays: readonly number[], settleMs: number) => { - await vi.advanceTimersByTimeAsync(sumDelays(delays) + settleMs); +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', }; describe('useFeedConversion', () => { @@ -27,7 +27,7 @@ describe('useFeedConversion', () => { fetchMock.mockRestore(); }); - it('should initialize with default state', () => { + it('initializes with empty state', () => { const { result } = renderHook(() => useFeedConversion()); expect(result.current.isConverting).toBe(false); @@ -35,844 +35,154 @@ describe('useFeedConversion', () => { 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); - expect(loadFeedResultSnapshot('test-token')).toMatchObject({ - savedAt: expect.any(String), - result: { - 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, - }, - }); - }); - - 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()); - let thrownError: - | (Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }) - | undefined; - - await act(async () => { - try { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - } catch (error) { - thrownError = error as Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }; - } - }); - - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Network error'); - expect(thrownError?.message).toBe('Network error'); - expect(thrownError?.manualRetryStrategy).toBe('browserless'); - expect(thrownError?.retryAction).toBe('alternate'); - expect(thrownError?.kind).toBe('network'); - }); - - 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); - - 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, - }); - 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' }, - } - ) - ); - - 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'); - }); - }); - - it('retries readiness checks after transient preview failures and eventually becomes ready', 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', - }; - + it('creates a feed with a url-only payload and hydrates ready result metadata', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ success: true, - data: { feed: createdFeed }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 201, headers: { 'Content-Type': 'application/json' } } ) ) - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })); - - 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('marks preview_unavailable for 422 extraction-warning 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 }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( new Response( JSON.stringify({ - version: 'https://jsonfeed.org/version/1.1', - title: 'Content Extraction Issue', - items: [], + items: [{ title: 'Preview item', content_text: 'Preview excerpt', date_published: '2024-01-02' }], }), - { - status: 422, - headers: { 'Content-Type': 'application/feed+json' }, - } + { 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 waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); - expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - }); - - it('does not classify extraction warning payloads as feed_ready even on 200 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( - JSON.stringify({ - version: 'https://jsonfeed.org/version/1.1', - title: 'Content Extraction Issue', - description: 'We could not extract entries from https://example.com right now.', - items: [ - { - title: 'Preview unavailable for this source', - content_text: 'No entries were extracted from https://example.com.', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); + const createRequest = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(createRequest?.method).toBe('POST'); + expect(JSON.parse(String(createRequest?.body))).toEqual({ url: 'https://example.com/articles' }); await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); - expect(result.current.result?.preview.error).toBe( - 'No entries could be extracted from this source right now.' - ); - expect(result.current.result?.preview.items).toEqual([]); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.previewStatus).toBe('ready'); + expect(result.current.result?.warnings).toEqual([]); + expect(result.current.result?.preview.items[0]?.title).toBe('Preview item'); }); }); - 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', - }; - + it('returns structured auth failure metadata without text parsing', async () => { fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ - success: true, - data: { - feed: createdFeed, + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 401, 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', - strategy: 'faraday', - }); - }); - - 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 expect( + result.current.convertFeed('https://example.com/articles', 'token-123') + ).rejects.toMatchObject({ + kind: 'auth', + code: 'UNAUTHORIZED', + nextAction: 'enter_token', + retryAction: 'none', + }); }); - const retryRequest = fetchMock.mock.calls[1]?.[0] as Request; - expect(await retryRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', - strategy: 'browserless', - }); - expect(result.current.result?.retry).toEqual({ - automatic: true, - from: 'faraday', - to: 'browserless', - }); - await waitFor(() => { - expect(result.current.result?.preview.isLoading).toBe(false); + expect(result.current.error).toMatchObject({ + kind: 'auth', + code: 'UNAUTHORIZED', + nextAction: 'enter_token', + retryAction: 'none', + retryable: false, }); }); - it('does not auto-retry browserless for unauthorized faraday failures', async () => { + it('stores retryable primary intent for double-failure create responses', async () => { fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ success: false, - error: { code: 'UNAUTHORIZED', message: 'Unauthorized' }, - }), - { - status: 401, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - let thrownError: - | (Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }) - | undefined; - - await act(async () => { - try { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); - } catch (error) { - thrownError = error as Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }; - } - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Unauthorized'); - expect(thrownError?.message).toBe('Unauthorized'); - expect(thrownError?.manualRetryStrategy).toBeUndefined(); - expect(thrownError?.retryAction).toBeUndefined(); - expect(thrownError?.kind).toBe('auth'); - expect(thrownError?.code).toBe('UNAUTHORIZED'); - expect(thrownError?.status).toBeUndefined(); - }); - - 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' }, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Browserless also failed', + kind: 'server', + retryable: true, + next_action: 'retry', + retry_action: 'primary', + }, }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - } + { status: 502, headers: { 'Content-Type': 'application/json' } } ) ); const { result } = renderHook(() => useFeedConversion()); - let thrownError: - | (Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }) - | undefined; await act(async () => { - try { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); - } catch (error) { - thrownError = error as Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }; - } + await expect( + result.current.convertFeed('https://example.com/articles', 'token-123') + ).rejects.toMatchObject({ + kind: 'server', + retryable: true, + nextAction: 'retry', + retryAction: 'primary', + }); }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Input rejected'); - expect(thrownError?.message).toBe('Input rejected'); - expect(thrownError?.manualRetryStrategy).toBeUndefined(); - expect(thrownError?.retryAction).toBeUndefined(); - expect(thrownError?.kind).toBe('input'); - expect(thrownError?.code).toBe('BAD_REQUEST'); - expect(thrownError?.status).toBeUndefined(); }); - 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('hydrates degraded preview state from status warnings', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ - success: false, - error: { - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to fetch https://example.com/articles', + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, }, }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } + { status: 201, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( @@ -880,222 +190,131 @@ describe('useFeedConversion', () => { JSON.stringify({ success: true, data: { - feed: createdFeed, + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'degraded', + warnings: [ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + next_action: 'retry', + }, + ], + }, }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - }) + 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 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', - strategy: 'browserless', - }); - expect(result.current.result?.retry).toEqual({ - automatic: true, - from: 'faraday', - to: 'browserless', + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.previewStatus).toBe('degraded'); + expect(result.current.result?.warnings).toEqual([ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + nextAction: 'retry', + }, + ]); }); }); - it('does not offer a duplicate manual retry after automatic fallback also fails', async () => { + it('retries readiness checks from the current result token', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ - success: false, - error: { message: 'Upstream timeout' }, + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } + { status: 201, 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; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }) - | undefined; - await act(async () => { - try { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); - } catch (error) { - thrownError = error as Error & { - manualRetryStrategy?: string; - retryAction?: string; - kind?: string; - code?: string; - status?: number; - }; - } - }); - - 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(thrownError?.retryAction).toBe('primary'); - expect(thrownError?.kind).toBe('server'); - 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; - }); - - fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ success: true, - data: { feed: feedA }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_not_ready_yet', + preview_status: 'pending', + warnings: [ + { + code: 'PREVIEW_NOT_READY', + message: 'Feed is still preparing.', + retryable: true, + next_action: 'wait', + }, + ], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) - .mockReturnValueOnce(previewAPromise as Promise) .mockResolvedValueOnce( new Response( JSON.stringify({ success: true, - data: { feed: feedB }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) - .mockReturnValueOnce(previewBPromise as Promise); + .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/a', 'faraday', 'testtoken'); - }); await act(async () => { - await result.current.convertFeed('https://example.com/b', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - 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'); + expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); }); - 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' }, - } - ) - ); + await act(async () => { + result.current.retryReadinessCheck(); + }); await waitFor(() => { - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); }); }); }); diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index d4fed873..13a9e984 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -1,7 +1,21 @@ -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 FeedReadinessPhase = 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; +export type FeedPreviewStatus = 'pending' | 'ready' | 'degraded' | 'unavailable'; +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,27 +23,49 @@ export interface FeedPreviewItem { url?: string; } +export interface FeedStatusWarning { + 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 FeedConversionState { + readinessPhase: FeedReadinessPhase; + previewStatus: FeedPreviewStatus; + warnings: FeedStatusWarning[]; + retry?: FeedRetryState; } export interface CreatedFeedResult { feed: FeedRecord; preview: FeedPreviewState; readinessPhase: FeedReadinessPhase; + previewStatus: FeedPreviewStatus; + warnings: FeedStatusWarning[]; retry?: FeedRetryState; } +export interface FeedCreationError { + kind: 'auth' | 'input' | 'network' | 'server'; + code: string; + retryable: boolean; + nextAction: FeedNextAction; + retryAction: FeedRetryAction; + nextStrategy?: string; + message: string; + status?: number; +} + export interface ApiMetadataRecord { api: GetApiMetadataResponses[200]['data']['api']; instance: { diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index ac3f9ed1..6104e2aa 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -5,8 +5,6 @@ import { CreateFeedPanel, UtilityStrip } from './AppPanels'; import { useAccessToken } from '../hooks/useAccessToken'; import { useApiMetadata } from '../hooks/useApiMetadata'; import { useFeedConversion } from '../hooks/useFeedConversion'; -import type { ConversionFailureKind } from '../hooks/useFeedConversion'; -import { useStrategies } from '../hooks/useStrategies'; import { useAppRoute } from '../routes/appRoute'; import { clearFeedDraftState, @@ -16,25 +14,14 @@ import { } from '../utils/feedSessionStorage'; import { normalizeUserUrl } from '../utils/url'; import type { WorkflowState } from './AppPanels'; -import type { CreatedFeedResult } from '../api/contracts'; +import type { CreatedFeedResult, 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; - -interface ConversionErrorWithMeta extends Error { - manualRetryStrategy?: string; - retryAction?: 'alternate' | 'primary'; - code?: string; - status?: number; - kind?: ConversionFailureKind; -} function deriveWorkflowState({ resultReadinessPhase, conversionError, - conversionFailureKind, feedFieldErrors, isConverting, missingResultRoute, @@ -44,8 +31,7 @@ function deriveWorkflowState({ metadataError, }: { resultReadinessPhase?: CreatedFeedResult['readinessPhase']; - conversionError?: string; - conversionFailureKind?: ConversionFailureKind; + conversionError?: FeedCreationError; feedFieldErrors: { url: string; form: string }; isConverting: boolean; missingResultRoute: boolean; @@ -56,14 +42,15 @@ function deriveWorkflowState({ }): WorkflowState { if (missingResultRoute || tokenStateError || metadataError) return 'failed'; if (routeKind === 'token' || tokenError) return 'token_required'; - if (conversionFailureKind === 'auth') return 'token_required'; + if (conversionError?.nextAction === 'enter_token' || conversionError?.kind === 'auth') + return 'token_required'; if (resultReadinessPhase === 'feed_ready') return 'ready'; if (resultReadinessPhase === 'link_created' || resultReadinessPhase === 'feed_not_ready_yet') { return 'warming'; } if (resultReadinessPhase === 'preview_unavailable') return 'failed'; if (isConverting) return 'submitting'; - if (feedFieldErrors.url || feedFieldErrors.form || conversionFailureKind === 'input') { + if (feedFieldErrors.url || feedFieldErrors.form || conversionError?.nextAction === 'correct_input') { return 'validating'; } @@ -114,15 +101,11 @@ export function App() { retryReadinessCheck, restoreResult, } = useFeedConversion(); - const { strategies, isLoading: strategiesLoading } = useStrategies(); const [feedFormData, setFeedFormData] = useState(() => loadFeedDraftState() ?? { url: '' }); const [feedFieldErrors, setFeedFieldErrors] = useState(EMPTY_FEED_ERRORS); const [tokenDraft, setTokenDraft] = useState(''); const [tokenError, setTokenError] = useState(''); - const [manualRetryStrategy, setManualRetryStrategy] = useState(''); - const [showPrimaryRetry, setShowPrimaryRetry] = useState(false); - const [conversionFailureKind, setConversionFailureKind] = useState(); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); const [resultRouteRecoveryAttempted, setResultRouteRecoveryAttempted] = useState(false); const autoSubmitUrlReference = useRef(route.prefillUrl); @@ -135,13 +118,11 @@ export function App() { const resultRouteRestorePending = route.kind === 'result' && !activeResult && !resultRouteRecoveryAttempted; const missingResultRoute = route.kind === 'result' && resultRouteRecoveryAttempted && !activeResult && !isConverting; - const errorKind = conversionFailureKind; const workflowState: WorkflowState = resultRouteRestorePending ? 'validating' : deriveWorkflowState({ resultReadinessPhase: activeResult?.readinessPhase, conversionError, - conversionFailureKind, feedFieldErrors, isConverting, missingResultRoute, @@ -167,8 +148,7 @@ export function App() { const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; const featuredFeeds = metadata?.instance.featured_feeds ?? []; - const submitDisabled = isConverting || strategiesLoading || !feedCreation.enabled || isTokenRoute; - const selectedStrategy = preferredStrategy(strategies) || ''; + const submitDisabled = isConverting || !feedCreation.enabled || isTokenRoute; const setFeedField = (key: 'url', value: string) => { setFeedFormData((previous) => { @@ -181,14 +161,10 @@ export function App() { return next; }); setFeedFieldErrors((previous) => ({ ...previous, url: '', form: '' })); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); clearError(); }; - const attemptFeedCreation = async (accessToken: string, strategyOverride?: string) => { - const strategy = strategyOverride || selectedStrategy; + const attemptFeedCreation = async (accessToken: string) => { const normalizedUrl = normalizeUserUrl(feedFormData.url); if (!normalizedUrl) { @@ -196,11 +172,6 @@ export function App() { return false; } - if (!strategy) { - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: 'Strategy is required' }); - return false; - } - if (!feedCreation.enabled) { setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, @@ -219,35 +190,30 @@ export function App() { try { setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); - const createdResult = await convertFeed(normalizedUrl, strategy, accessToken); + const createdResult = await convertFeed(normalizedUrl, accessToken); clearFeedDraftState(); navigate({ kind: 'result', feedToken: createdResult.feed.feed_token }); setTokenError(''); - setManualRetryStrategy(''); return true; } catch (submitError) { - const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.'; - const failure = submitError as ConversionErrorWithMeta; - const retryStrategy = failure.manualRetryStrategy ?? ''; - const retryAction = failure.retryAction; - setConversionFailureKind(failure.kind); - setManualRetryStrategy(retryAction === 'alternate' ? retryStrategy : ''); - setShowPrimaryRetry(retryAction === 'primary'); - - if (feedCreation.access_token_required && failure.kind === 'auth') { + const failure = submitError as FeedCreationError; + + if (failure.kind === 'auth' || failure.nextAction === 'enter_token') { clearToken(); clearError(); setTokenDraft(''); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind('auth'); if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); setTokenError('Access token was rejected. Paste a valid token to continue.'); setFeedFieldErrors(EMPTY_FEED_ERRORS); return false; } - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: message }); + if (failure.nextAction === 'correct_input') { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: failure.message }); + return false; + } + + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: failure.message }); return false; } }; @@ -255,18 +221,12 @@ export function App() { const handleFeedSubmit = async (event: Event) => { event.preventDefault(); setFeedFieldErrors(EMPTY_FEED_ERRORS); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); await attemptFeedCreation(token ?? ''); }; const handleSaveToken = async () => { try { const normalizedToken = tokenDraft.trim(); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); await saveToken(normalizedToken); setTokenError(''); const created = await attemptFeedCreation(normalizedToken); @@ -278,9 +238,6 @@ export function App() { const handleCreateAnother = () => { clearResult(); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); setFocusCreateComposerKey((current) => current + 1); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }; @@ -288,22 +245,9 @@ export function App() { const handleRetryCreation = () => { setFeedFieldErrors(EMPTY_FEED_ERRORS); clearError(); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); void attemptFeedCreation(token ?? ''); }; - const handleRetryWithStrategy = () => { - if (!manualRetryStrategy) return; - - setFeedFieldErrors(EMPTY_FEED_ERRORS); - clearError(); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); - void attemptFeedCreation(token ?? '', manualRetryStrategy); - }; - useEffect(() => { setResultRouteRecoveryAttempted(false); }, [routeFeedToken]); @@ -328,8 +272,8 @@ export function App() { useEffect(() => { const autoSubmitUrl = autoSubmitUrlReference.current; if (!autoSubmitUrl || hasAutoSubmittedReference.current) return; - if (strategiesLoading || metadataLoading || tokenLoading) return; - if (feedFormData.url !== autoSubmitUrl || !selectedStrategy) return; + if (metadataLoading || tokenLoading) return; + if (feedFormData.url !== autoSubmitUrl) return; if (feedCreation.access_token_required && !token) { hasAutoSubmittedReference.current = true; @@ -348,8 +292,6 @@ export function App() { feedCreation.access_token_required, feedFormData.url, metadataLoading, - selectedStrategy, - strategiesLoading, navigate, route.kind, token, @@ -415,7 +357,7 @@ export function App() { feedFormData={feedFormData} feedFieldErrors={feedFieldErrors} conversionError={conversionError} - errorKind={errorKind} + errorKind={conversionError?.kind} isConverting={isConverting} submitDisabled={submitDisabled} feedCreationEnabled={feedCreation.enabled} @@ -423,7 +365,6 @@ export function App() { tokenDraft={tokenDraft} tokenError={tokenError} showTokenPrompt={isTokenRoute} - showPrimaryRetry={showPrimaryRetry} onFeedSubmit={handleFeedSubmit} onFeedFieldChange={setFeedField} onTokenDraftChange={(value) => { @@ -435,13 +376,8 @@ export function App() { onCancelTokenPrompt={() => { setTokenError(''); clearError(); - setManualRetryStrategy(''); - setShowPrimaryRetry(false); - setConversionFailureKind(undefined); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }} - manualRetryStrategy={manualRetryStrategy} - onRetryWithStrategy={handleRetryWithStrategy} onRetryCreate={handleRetryCreation} /> ); diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 2b63849f..0e4542ad 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef } from 'preact/hooks'; import { Bookmarklet } from './Bookmarklet'; import { DominantField } from './DominantField'; +import type { FeedCreationError } from '../api/contracts'; export interface FeedFormData { url: string; @@ -20,14 +21,14 @@ export type WorkflowState = | 'ready' | 'failed'; -export type WorkflowErrorKind = 'auth' | 'input' | 'readiness' | 'network' | 'server'; +export type WorkflowErrorKind = FeedCreationError['kind']; interface CreateFeedPanelProperties { focusComposerKey: number; workflowState: WorkflowState; feedFormData: FeedFormData; feedFieldErrors: FeedFieldErrors; - conversionError?: string; + conversionError?: FeedCreationError; errorKind?: WorkflowErrorKind; isConverting: boolean; submitDisabled: boolean; @@ -36,14 +37,11 @@ interface CreateFeedPanelProperties { tokenDraft: string; tokenError: string; showTokenPrompt: boolean; - manualRetryStrategy: string; - showPrimaryRetry: boolean; onFeedSubmit: (event: Event) => void; onFeedFieldChange: (key: 'url', value: string) => void; onTokenDraftChange: (value: string) => void; onSaveToken: () => void; onCancelTokenPrompt: () => void; - onRetryWithStrategy: () => void; onRetryCreate: () => void; } @@ -61,19 +59,19 @@ export function CreateFeedPanel({ tokenDraft, tokenError, showTokenPrompt, - manualRetryStrategy, - showPrimaryRetry, onFeedSubmit, onFeedFieldChange, onTokenDraftChange, onSaveToken, onCancelTokenPrompt, - onRetryWithStrategy, onRetryCreate, }: CreateFeedPanelProperties) { const urlInputReference = useRef(undefined as never); const tokenInputReference = useRef(undefined as never); - const failureMessage = conversionError || feedFieldErrors.form; + const failureMessage = conversionError?.message || feedFieldErrors.form; + const showRetryButton = Boolean( + conversionError && conversionError.nextAction === 'retry' && conversionError.retryAction !== 'none' + ); useLayoutEffect(() => { if (!urlInputReference.current || globalThis.window === undefined) return; @@ -225,17 +223,11 @@ export function CreateFeedPanel({ >

Couldn't create feed yet

{failureMessage}

- {(showPrimaryRetry || manualRetryStrategy) && ( + {showRetryButton && (
- {showPrimaryRetry ? ( - - ) : ( - - )} +
)} diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index aa9d5298..e0711968 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -19,7 +19,7 @@ export function ResultDisplay({ const [copyNotice, setCopyNotice] = useState(''); const [showAllPreviewItems, setShowAllPreviewItems] = useState(false); const copyResetReference = useRef(undefined); - const { feed, preview, readinessPhase } = result; + const { feed, preview, readinessPhase, previewStatus, warnings, retry } = result; const fullUrl = feed.public_url.startsWith('http') ? feed.public_url @@ -30,10 +30,8 @@ export function ResultDisplay({ const subscribeUrl = /^https?:\/\//i.test(fullUrl) ? `feed:${fullUrl}` : undefined; const isFeedReady = readinessPhase === 'feed_ready'; const canManuallyRetryReadiness = - readinessPhase === 'feed_not_ready_yet' || readinessPhase === 'preview_unavailable'; - const isReadinessCheckInProgress = readinessPhase === 'link_created' && preview.isLoading; - const hasPreviewDegradation = readinessPhase === 'feed_ready' && Boolean(preview.error); - const showReadinessAction = canManuallyRetryReadiness || isReadinessCheckInProgress; + readinessPhase !== 'feed_ready' || warnings.some((warning) => warning.retryable); + const isReadinessCheckInProgress = preview.isLoading; const previewItems = showAllPreviewItems ? preview.items : preview.items.slice(0, 3); const hasMorePreviewItems = preview.items.length > 3; const statusTitle = { @@ -48,6 +46,18 @@ export function ResultDisplay({ feed_not_ready_yet: 'The feed endpoint is still warming up. Try checking again in a few seconds.', preview_unavailable: 'We could not verify readiness right now. Try checking again.', }[readinessPhase]; + const previewMessage = { + pending: 'Verifying feed readiness…', + ready: + preview.items.length > 0 + ? '' + : 'Feed is ready. Preview items will appear once the source publishes entries.', + degraded: + preview.items.length > 0 + ? 'Preview content is partially degraded right now.' + : 'Feed is ready, but preview content is partially degraded right now.', + unavailable: 'Preview unavailable right now.', + }[previewStatus]; useEffect(() => { return () => { @@ -84,15 +94,16 @@ export function ResultDisplay({

{statusTitle}

{feed.name}

{statusMessage}

- {hasPreviewDegradation && ( -

- Feed link is usable, but preview content could not be verified yet. + {retry?.automatic &&

Feed creation recovered automatically.

} + {warnings.map((warning) => ( +

+ {warning.message}

- )} + ))}
- {showReadinessAction && ( + {canManuallyRetryReadiness && (
- {result.retry && ( -

- {`Retried automatically with ${result.retry.to} after ${result.retry.from} could not finish the page.`} -

- )} )} - {isFeedReady && preview.items.length > 0 && ( + {!preview.isLoading && preview.items.length > 0 && (

Preview

@@ -190,28 +196,24 @@ export function ResultDisplay({
)} - {isFeedReady && !preview.isLoading && preview.items.length === 0 && !preview.error && ( + {!preview.isLoading && preview.items.length === 0 && previewMessage && (

Preview

Latest items from this feed

-

- Feed is ready. Preview items will appear once the source publishes entries. +

+ {previewMessage}

)} - {!preview.isLoading && preview.error && ( -
-
-

Preview

-

Latest items from this feed

-
-

{preview.error}

-
- )} - {copyNotice && (

{copyNotice}

diff --git a/frontend/src/hooks/useAccessToken.ts b/frontend/src/hooks/useAccessToken.ts index e7534b46..21449fa9 100644 --- a/frontend/src/hooks/useAccessToken.ts +++ b/frontend/src/hooks/useAccessToken.ts @@ -9,26 +9,6 @@ interface AccessTokenState { error?: string; } -const clearLegacySessionToken = () => { - if (globalThis.window === undefined) return; - - try { - globalThis.sessionStorage?.removeItem(ACCESS_TOKEN_KEY); - } catch { - // Ignore restricted sessionStorage access (privacy mode, sandboxed contexts). - } -}; - -const clearLegacyLocalToken = () => { - if (globalThis.window === undefined) return; - - try { - globalThis.localStorage?.removeItem(ACCESS_TOKEN_KEY); - } catch { - // Ignore restricted localStorage access (privacy mode, sandboxed contexts). - } -}; - const readSessionToken = (): string => { if (globalThis.window === undefined) return inMemoryToken; @@ -62,23 +42,9 @@ export function useAccessToken() { useEffect(() => { try { const token = readSessionToken(); - let legacyLocalToken = ''; - if (!token && globalThis.window !== undefined) { - try { - legacyLocalToken = globalThis.localStorage?.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ''; - } catch { - // Treat restricted localStorage access as no legacy token. - legacyLocalToken = ''; - } - } - - if (!token && legacyLocalToken) { - writeSessionToken(legacyLocalToken); - clearLegacyLocalToken(); - } setState({ - token: token || legacyLocalToken || undefined, + token: token || undefined, isLoading: false, }); } catch { @@ -94,8 +60,6 @@ export function useAccessToken() { if (!normalized) throw new Error('Access token is required'); writeSessionToken(normalized); - clearLegacyLocalToken(); - clearLegacySessionToken(); setState({ token: normalized, @@ -105,8 +69,6 @@ export function useAccessToken() { const clearToken = () => { writeSessionToken(''); - clearLegacyLocalToken(); - clearLegacySessionToken(); setState({ isLoading: false, diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index 3e984840..6e5c7012 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -1,140 +1,144 @@ import { useEffect, useRef, useState } from 'preact/hooks'; -import { createFeed } from '../api/generated'; -import { apiClient } from '../api/client'; -import type { CreatedFeedResult, FeedPreviewItem, FeedReadinessPhase, FeedRecord } from '../api/contracts'; +import type { + CreatedFeedResult, + FeedConversionState, + FeedCreationError, + FeedNextAction, + FeedPreviewItem, + FeedPreviewStatus, + FeedRecord, + FeedReadinessPhase, + FeedRetryAction, + FeedRetryState, + FeedStatusWarning, +} from '../api/contracts'; import { saveFeedResultSnapshot } from '../utils/feedSessionStorage'; import { normalizeUserUrl } from '../utils/url'; -interface JsonFeedItem { - title?: string; - content_text?: string; - content_html?: string; - url?: string; - external_url?: string; - date_published?: string; +interface ConversionState { + isConverting: boolean; + result?: CreatedFeedResult; + error?: FeedCreationError; +} + +interface RawFeedRecord { + id?: unknown; + name?: unknown; + url?: unknown; + feed_token?: unknown; + public_url?: unknown; + json_public_url?: unknown; + created_at?: unknown; + updated_at?: unknown; +} + +interface RawFeedConversionState { + readiness_phase?: unknown; + preview_status?: unknown; + warnings?: unknown; + retry?: unknown; +} + +interface RawFeedPayload { + feed?: RawFeedRecord; + conversion?: RawFeedConversionState; +} + +interface RawApiResponse { + success?: unknown; + data?: RawFeedPayload; + error?: unknown; +} + +interface RawErrorEnvelope { + kind?: unknown; + code?: unknown; + retryable?: unknown; + next_action?: unknown; + retry_action?: unknown; + next_strategy?: unknown; + message?: unknown; } interface JsonFeedResponse { - title?: string; - description?: string; - items?: JsonFeedItem[]; + items?: unknown[]; } -interface ConversionState { - isConverting: boolean; - result?: CreatedFeedResult; - error?: string; +interface PreviewLoadResult { + items: FeedPreviewItem[]; + warnings: FeedStatusWarning[]; + previewStatus: FeedPreviewStatus; } -interface ConversionError extends Error { - manualRetryStrategy?: string; - retryAction?: 'alternate' | 'primary'; - code?: string; - status?: number; - kind?: ConversionFailureKind; +interface FeedStatusLoadResult { + conversion: FeedConversionState; + shouldRetry: boolean; } -export type ConversionFailureKind = 'auth' | 'input' | 'network' | 'server'; +type ResultProgressState = 'none' | 'initial' | 'status' | 'preview'; +const STATUS_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; -const FEED_NOT_READY_MESSAGE = 'Feed is still preparing. Try again in a few seconds.'; -const NON_RETRYABLE_ERROR_CODES = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN']); -const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; +const PREVIEW_DEGRADED_MESSAGE = 'Preview content is partially degraded right now.'; export function useFeedConversion() { const requestIdReference = useRef(0); - const previewAbortControllerReference = useRef(undefined); - const [state, setState] = useState({ - isConverting: false, - }); + const hydrationAbortControllerReference = useRef(undefined); + const resultProgressReference = useRef('none'); + const [state, setState] = useState({ isConverting: false }); - const cancelPreviewHydration = () => { - previewAbortControllerReference.current?.abort(); - previewAbortControllerReference.current = undefined; + const cancelHydration = () => { + hydrationAbortControllerReference.current?.abort(); + hydrationAbortControllerReference.current = undefined; }; useEffect( () => () => { requestIdReference.current += 1; - cancelPreviewHydration(); + cancelHydration(); }, [] ); - const convertFeed = async (url: string, strategy: string, token: string) => { + async function convertFeed(url: string, token: string) { const normalizedUrl = normalizeUserUrl(url); - const requestedStrategy = strategy.trim(); - const fallbackStrategy = requestedStrategy === 'faraday' ? 'browserless' : undefined; - - if (!normalizedUrl) throw new Error('URL is required'); - if (!requestedStrategy) throw new Error('Strategy is required'); - if (!isValidHttpUrl(normalizedUrl)) { - throw new Error('Invalid URL format'); - } + if (!normalizedUrl) throw buildLocalError('Source URL is required.', 'input', 'correct_input'); + if (!isValidHttpUrl(normalizedUrl)) + throw buildLocalError('Invalid URL format.', 'input', 'correct_input'); const requestId = requestIdReference.current + 1; requestIdReference.current = requestId; - markConversionStarted(setState); + resultProgressReference.current = 'none'; + cancelHydration(); + setState((previous) => ({ ...previous, isConverting: true, error: undefined })); try { - const feed = await requestFeedCreation(normalizedUrl, requestedStrategy, token); - return publishCreatedFeed( - feed, - undefined, + const createdFeed = await requestFeedCreation(normalizedUrl, token); + const result = buildCreatedFeedResult(createdFeed.feed, createdFeed.conversion); + publishCreatedFeed(result, requestId, setState, requestIdReference, resultProgressReference); + void hydrateFeedStatus( + createdFeed.feed, requestId, setState, requestIdReference, - previewAbortControllerReference + hydrationAbortControllerReference, + resultProgressReference ); - } catch (firstError) { - if (shouldAutoRetry(requestedStrategy, fallbackStrategy, firstError)) { - try { - const feed = await requestFeedCreation(normalizedUrl, fallbackStrategy, token); - return publishCreatedFeed( - feed, - { automatic: true, from: requestedStrategy, to: fallbackStrategy }, - requestId, - setState, - requestIdReference, - previewAbortControllerReference - ); - } catch (secondError) { - const message = buildRetryFailureMessage( - firstError, - secondError, - requestedStrategy, - fallbackStrategy - ); - failConversion(setState, message, { - kind: classifyConversionFailure(firstError), - retryAction: 'primary', - ...extractFailureMetadata(secondError), - }); - } - } - - const message = toErrorMessage(firstError); - const retryStrategy = shouldOfferManualRetry(requestedStrategy, fallbackStrategy, firstError) - ? alternateStrategy(requestedStrategy) - : undefined; - failConversion(setState, message, { - ...extractFailureMetadata(firstError), - kind: classifyConversionFailure(firstError), - manualRetryStrategy: retryStrategy, - retryAction: retryStrategy ? 'alternate' : undefined, - }); + return result; + } catch (error) { + const structuredError = normalizeFeedCreationError(error); + failConversion(setState, structuredError); + throw structuredError; } - }; + } const clearResult = () => { - globalThis.document.body.scrollIntoView({ behavior: 'smooth', block: 'start' }); + globalThis.document?.body?.scrollIntoView({ behavior: 'smooth', block: 'start' }); requestIdReference.current += 1; - cancelPreviewHydration(); - - setState({ - isConverting: false, - }); + resultProgressReference.current = 'none'; + cancelHydration(); + setState({ isConverting: false }); }; const clearError = () => { @@ -147,12 +151,15 @@ export function useFeedConversion() { const requestId = requestIdReference.current + 1; requestIdReference.current = requestId; - cancelPreviewHydration(); + resultProgressReference.current = 'status'; + cancelHydration(); const resetResult: CreatedFeedResult = { ...currentResult, - readinessPhase: 'link_created', - preview: buildLoadingPreviewState(), + preview: { + items: [], + isLoading: true, + }, }; setState((previous) => ({ @@ -161,23 +168,39 @@ export function useFeedConversion() { error: undefined, result: resetResult, })); - void hydratePreview( + saveFeedResultSnapshot(resetResult); + void hydrateFeedStatus( currentResult.feed, requestId, - currentResult.retry, setState, requestIdReference, - previewAbortControllerReference + hydrationAbortControllerReference, + resultProgressReference ); }; const restoreResult = (result: CreatedFeedResult) => { + resultProgressReference.current = progressFromResult(result); setState((previous) => ({ ...previous, isConverting: false, error: undefined, result, })); + + if (!result.preview.isLoading && result.previewStatus !== 'pending') return; + + const requestId = requestIdReference.current + 1; + requestIdReference.current = requestId; + cancelHydration(); + void hydrateFeedStatus( + result.feed, + requestId, + setState, + requestIdReference, + hydrationAbortControllerReference, + resultProgressReference + ); }; return { @@ -192,585 +215,827 @@ export function useFeedConversion() { }; } -interface PreviewLoadResult { - preview: CreatedFeedResult['preview']; - readinessPhase: FeedReadinessPhase; - shouldRetry: boolean; -} +async function requestFeedCreation( + url: string, + token: string +): Promise<{ feed: FeedRecord; conversion: FeedConversionState }> { + const response = await globalThis.fetch(resolveApiUrl('feeds'), { + method: 'POST', + headers: buildCreateHeaders(token), + body: JSON.stringify({ url }), + }); -async function loadPreview(feed: FeedRecord, signal?: AbortSignal): Promise { - let response: Response; - try { - response = await globalThis.fetch(feed.json_public_url, { - headers: { Accept: 'application/feed+json' }, - signal, - }); - } catch (error) { - if (isAbortError(error)) throw error; - return { - preview: { - items: [], - error: FEED_NOT_READY_MESSAGE, - isLoading: false, - }, - readinessPhase: 'feed_not_ready_yet', - shouldRetry: true, - }; - } + const payload = await readJsonResponse(response); if (!response.ok) { - if (isTransientReadinessStatus(response.status)) { - return { - preview: { - items: [], - error: FEED_NOT_READY_MESSAGE, - isLoading: false, - }, - readinessPhase: 'feed_not_ready_yet', - shouldRetry: true, - }; - } - - return { - preview: { - items: [], - error: PREVIEW_UNAVAILABLE_MESSAGE, - isLoading: false, - }, - readinessPhase: 'preview_unavailable', - shouldRetry: false, - }; + throw normalizeFeedCreationErrorFromResponse(response.status, payload?.error, payload); } - try { - const payload = (await response.json()) as JsonFeedResponse; - if (isExtractionWarningPayload(payload)) { - return { - preview: { - items: [], - error: 'No entries could be extracted from this source right now.', - isLoading: false, - }, - readinessPhase: 'preview_unavailable', - shouldRetry: false, - }; - } - - const items = - payload.items - ?.map((item) => normalizePreviewItem(item)) - .filter((item): item is FeedPreviewItem => item !== undefined) - .slice(0, 5) || []; - - return { - preview: { - items, - error: undefined, - isLoading: false, - }, - readinessPhase: 'feed_ready', - shouldRetry: false, - }; - } catch { - return { - preview: { - items: [], - error: PREVIEW_UNAVAILABLE_MESSAGE, - isLoading: false, - }, - readinessPhase: 'preview_unavailable', - shouldRetry: false, - }; + const feed = normalizeFeedRecord(payload?.data?.feed); + if (!feed) { + throw buildStructuredError( + 'server', + 'INVALID_RESPONSE', + true, + 'retry', + 'primary', + 'Unable to start feed generation.', + response.status + ); } -} -function buildLoadingPreviewState(): CreatedFeedResult['preview'] { return { - items: [], - error: undefined, - isLoading: true, + feed, + conversion: normalizeConversionState(payload?.data?.conversion), }; } -async function hydratePreview( +async function hydrateFeedStatus( feed: FeedRecord, requestId: number, - retry: CreatedFeedResult['retry'], setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, requestIdReference: { current: number }, - previewAbortControllerReference: { current: AbortController | undefined } + hydrationAbortControllerReference: { current: AbortController | undefined }, + resultProgressReference: { current: 'none' | 'initial' | 'status' | 'preview' } ) { - previewAbortControllerReference.current?.abort(); + hydrationAbortControllerReference.current?.abort(); const controller = new AbortController(); - previewAbortControllerReference.current = controller; - const delays = [0, ...PREVIEW_RETRY_DELAYS_MS]; - let lastAttempt: PreviewLoadResult | undefined; + hydrationAbortControllerReference.current = controller; + + const delays = [0, ...STATUS_RETRY_DELAYS_MS]; + let latestConversion: FeedConversionState | undefined; try { for (const [index, delayMs] of delays.entries()) { if (delayMs > 0) await wait(delayMs, controller.signal); if (requestIdReference.current !== requestId) return; - const attempt = await loadPreview(feed, controller.signal); - lastAttempt = attempt; + const statusResult = await requestFeedStatus(feed.feed_token, controller.signal); + latestConversion = statusResult.conversion; if (requestIdReference.current !== requestId) return; + const shouldKeepPreviewLoading = + statusResult.conversion.previewStatus === 'pending' || + statusResult.conversion.previewStatus === 'ready' || + statusResult.conversion.previewStatus === 'degraded'; + + commitResult( + feed, + statusResult.conversion, + [], + shouldKeepPreviewLoading, + requestId, + setState, + requestIdReference, + resultProgressReference + ); + const exhausted = index === delays.length - 1; - if (!attempt.shouldRetry || exhausted) { - setPreviewResult( - feed, - attempt.preview, - attempt.readinessPhase, - retry, - requestId, - setState, - requestIdReference - ); - return; - } + if (!statusResult.shouldRetry || exhausted) break; } + + if (!latestConversion) return; + + const resolvedConversion = latestConversion; + + if (shouldLoadPreviewItems(resolvedConversion.previewStatus)) { + void loadPreviewItems(feed.json_public_url, controller.signal) + .then((previewResult) => { + if (requestIdReference.current !== requestId) return; + + commitResult( + feed, + resolvedConversion, + previewResult.items, + false, + requestId, + setState, + requestIdReference, + resultProgressReference + ); + }) + .catch((error) => { + if (isAbortError(error) || requestIdReference.current !== requestId) return; + + commitResult( + feed, + resolvedConversion, + [], + false, + requestId, + setState, + requestIdReference, + resultProgressReference + ); + }); + return; + } + + commitResult( + feed, + resolvedConversion, + [], + false, + requestId, + setState, + requestIdReference, + resultProgressReference + ); } catch (error) { if (isAbortError(error)) return; - throw error; - } finally { - if (previewAbortControllerReference.current === controller) { - previewAbortControllerReference.current = undefined; - } - } - if (!lastAttempt) { - if (requestIdReference.current !== requestId || controller.signal.aborted) return; - setPreviewResult( + const fallbackConversion = latestConversion ?? buildPendingConversionState(); + const warning = buildFallbackWarning(error); + commitResult( feed, { - items: [], - error: FEED_NOT_READY_MESSAGE, - isLoading: false, + ...fallbackConversion, + previewStatus: warning.retryable ? 'degraded' : 'unavailable', + warnings: [...fallbackConversion.warnings, warning], }, - 'feed_not_ready_yet', - retry, + [], + false, requestId, setState, - requestIdReference + requestIdReference, + resultProgressReference ); + } finally { + if (hydrationAbortControllerReference.current === controller) { + hydrationAbortControllerReference.current = undefined; + } } } -async function requestFeedCreation(url: string, strategy: string, token: string): Promise { - const response = await createFeed({ - client: apiClient, - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - url, - strategy, - }, - throwOnError: true, +async function requestFeedStatus(feedToken: string, signal?: AbortSignal): Promise { + const response = await globalThis.fetch(resolveApiUrl(`feeds/${encodeURIComponent(feedToken)}/status`), { + headers: { Accept: 'application/json' }, + signal, }); - if (!response.data?.success || !response.data.data?.feed) { - throw new Error('Invalid response format'); - } + const payload = await readJsonResponse(response); - return response.data.data.feed; -} + if (!response.ok) { + if (isTransientHttpStatus(response.status)) { + return { + conversion: buildPendingConversionState(), + shouldRetry: true, + }; + } -function isValidHttpUrl(value: string): boolean { - try { - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; + throw normalizeFeedCreationErrorFromResponse(response.status, payload?.error, payload); } -} -function alternateStrategy(strategy: string): string | undefined { - if (strategy === 'faraday') return 'browserless'; - if (strategy === 'browserless') return 'faraday'; - return undefined; -} - -function shouldAutoRetry( - strategy: string, - fallbackStrategy: string | undefined, - error: unknown -): fallbackStrategy is string { - if (strategy !== 'faraday' || !fallbackStrategy) return false; - return retryableForFallback(error); + const conversion = normalizeConversionState(payload?.data?.conversion); + return { + conversion, + shouldRetry: + conversion.readinessPhase === 'link_created' || + conversion.readinessPhase === 'feed_not_ready_yet' || + conversion.previewStatus === 'pending', + }; } -function shouldOfferManualRetry( - strategy: string, - fallbackStrategy: string | undefined, - error: unknown -): fallbackStrategy is string { - if (strategy !== 'faraday' || !fallbackStrategy) return false; - - const details = extractErrorDetails(error); - const errorCode = details?.code?.toUpperCase(); - if (errorCode && NON_RETRYABLE_ERROR_CODES.has(errorCode)) return false; +async function loadPreviewItems(previewUrl: string, signal?: AbortSignal): Promise { + let response: Response; - const status = details?.status; - if (status && status < 500) return false; + try { + response = await globalThis.fetch(previewUrl, { + headers: { Accept: 'application/feed+json' }, + signal, + }); + } catch (error) { + if (isAbortError(error)) throw error; - const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); - if ( - message.includes('unauthorized') || - message.includes('forbidden') || - message.includes('not allowed') || - message.includes('disabled') || - message.includes('access token') || - message.includes('token') || - message.includes('authentication') || - message.includes('bad request') || - message.includes('url') || - message.includes('unsupported strategy') || - message.includes('invalid response format') || - message.includes('not valid json') || - message.includes('unexpected token') - ) { - return false; + return { + items: [], + warnings: [buildPreviewWarning('PREVIEW_NETWORK_ERROR', PREVIEW_UNAVAILABLE_MESSAGE, true, 'retry')], + previewStatus: 'degraded', + }; } - return true; -} - -function extractFailureMetadata(error: unknown): { code?: string; status?: number } { - const details = extractErrorDetails(error); - return { - code: details?.code, - status: details?.status, - }; -} - -function classifyConversionFailure(error: unknown): ConversionFailureKind { - const details = extractErrorDetails(error); - const code = details?.code?.toUpperCase(); - const status = details?.status; - const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); + if (!response.ok) { + if (isTransientHttpStatus(response.status)) { + return { + items: [], + warnings: [ + buildPreviewWarning(`PREVIEW_HTTP_${response.status}`, PREVIEW_DEGRADED_MESSAGE, true, 'retry'), + ], + previewStatus: 'degraded', + }; + } - if ( - code === 'UNAUTHORIZED' || - status === 401 || - message.includes('unauthorized') || - message.includes('invalid token') || - message.includes('token rejected') || - message.includes('authentication') || - message.includes('bearer') || - message.includes('forbidden') - ) { - return 'auth'; + return { + items: [], + warnings: [ + buildPreviewWarning(`PREVIEW_HTTP_${response.status}`, PREVIEW_UNAVAILABLE_MESSAGE, false, 'wait'), + ], + previewStatus: 'unavailable', + }; } - if ( - code === 'BAD_REQUEST' || - status === 400 || - message.includes('bad request') || - message.includes('unsupported strategy') || - message.includes('invalid response format') || - message.includes('url') - ) { - return 'input'; + try { + const payload = (await response.json()) as JsonFeedResponse; + return { + items: normalizePreviewItems(payload.items), + warnings: [], + previewStatus: 'ready', + }; + } catch { + return { + items: [], + warnings: [buildPreviewWarning('PREVIEW_INVALID_JSON', PREVIEW_UNAVAILABLE_MESSAGE, false, 'wait')], + previewStatus: 'unavailable', + }; } - - if (message.includes('network') || message.includes('failed to fetch')) return 'network'; - - return 'server'; } -function buildRetryFailureMessage( - firstError: unknown, - secondError: unknown, - requestedStrategy: string, - fallbackStrategy: string -): string { - const secondMessage = toErrorMessage(secondError); - const firstMessage = toErrorMessage(firstError); - - if (firstMessage === secondMessage) { - return `Tried ${requestedStrategy} first, then ${fallbackStrategy}. ${secondMessage}`; - } +function buildCreatedFeedResult(feed: FeedRecord, conversion?: FeedConversionState): CreatedFeedResult { + const resolvedConversion = conversion ?? buildPendingConversionState(); - return `Tried ${requestedStrategy} first, then ${fallbackStrategy}. First attempt failed with: ${firstMessage}. Second attempt failed with: ${secondMessage}`; + return { + feed, + preview: { + items: [], + isLoading: true, + }, + readinessPhase: resolvedConversion.readinessPhase, + previewStatus: resolvedConversion.previewStatus, + warnings: resolvedConversion.warnings, + retry: resolvedConversion.retry, + }; } -function buildConversionError(message: string, metadata: Partial): ConversionError { - return Object.assign(new Error(message), metadata); +function buildPendingConversionState(): FeedConversionState { + return { + readinessPhase: 'link_created', + previewStatus: 'pending', + warnings: [], + }; } -const toErrorMessage = (error: unknown): string => { - const details = extractErrorDetails(error); - const detailsMessage = details?.message?.toLowerCase(); - if ( - detailsMessage && - (detailsMessage.includes('not valid json') || detailsMessage.includes('unexpected token')) - ) { - return 'Invalid response format from feed creation API'; - } - if (details?.message) return details.message; - if (error instanceof SyntaxError) return 'Invalid response format from feed creation API'; - if (error instanceof Error) { - const normalizedMessage = error.message.toLowerCase(); - if (normalizedMessage.includes('not valid json') || normalizedMessage.includes('unexpected token')) { - return 'Invalid response format from feed creation API'; - } - - return error.message; - } - if (typeof error === 'string' && error.trim()) return error; - return 'An unexpected error occurred'; -}; +function shouldLoadPreviewItems(previewStatus: FeedPreviewStatus): boolean { + return previewStatus === 'ready' || previewStatus === 'degraded'; +} -function setPreviewResult( +function commitResult( feed: FeedRecord, - preview: CreatedFeedResult['preview'], - readinessPhase: FeedReadinessPhase, - retry: CreatedFeedResult['retry'], + conversion: FeedConversionState, + previewItems: FeedPreviewItem[], + isLoading: boolean, requestId: number, setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, - requestIdReference: { current: number } + requestIdReference: { current: number }, + resultProgressReference: { current: 'none' | 'initial' | 'status' | 'preview' } ) { - let shouldPersist = false; + let nextResult: CreatedFeedResult | undefined; + resultProgressReference.current = previewItems.length > 0 ? 'preview' : 'status'; setState((previous) => { - if ( - requestIdReference.current !== requestId || - !previous.result || - previous.result.feed.feed_token !== feed.feed_token - ) { + if (requestIdReference.current !== requestId) { return previous; } - shouldPersist = true; + nextResult = { + feed, + preview: { + items: previewItems, + isLoading, + }, + readinessPhase: conversion.readinessPhase, + previewStatus: conversion.previewStatus, + warnings: conversion.warnings, + retry: conversion.retry, + }; + return { ...previous, - result: { - feed, - preview, - readinessPhase, - retry, - }, + isConverting: false, + result: nextResult, }; }); - if (shouldPersist) { - saveFeedResultSnapshot({ - feed, - preview, - readinessPhase, - retry, - }); - } -} - -function markConversionStarted( - setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void -) { - setState((previous) => ({ ...previous, isConverting: true, error: undefined })); + if (nextResult) saveFeedResultSnapshot(nextResult); } function publishCreatedFeed( - feed: FeedRecord, - retry: CreatedFeedResult['retry'], + result: CreatedFeedResult, requestId: number, setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, requestIdReference: { current: number }, - previewAbortControllerReference: { current: AbortController | undefined } -): CreatedFeedResult { - const result: CreatedFeedResult = { - feed, - preview: buildLoadingPreviewState(), - readinessPhase: 'link_created', - retry, - }; + resultProgressReference: { current: 'none' | 'initial' | 'status' | 'preview' } +) { + resultProgressReference.current = 'initial'; + setState((previous) => { + if (requestIdReference.current !== requestId) return previous; + + if (resultProgressReference.current !== 'initial') { + return { + ...previous, + isConverting: false, + error: undefined, + }; + } - setState((previous) => ({ ...previous, isConverting: false, result, error: undefined })); - saveFeedResultSnapshot(result); - void hydratePreview(feed, requestId, retry, setState, requestIdReference, previewAbortControllerReference); - return result; + saveFeedResultSnapshot(result); + return { + ...previous, + isConverting: false, + error: undefined, + result, + }; + }); } function failConversion( setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, - message: string, - metadata: Partial -): never { + error: FeedCreationError +) { setState((previous) => ({ ...previous, isConverting: false, - error: message, - result: undefined, + error, })); - - throw buildConversionError(message, metadata); } -const extractErrorDetails = ( - error: unknown -): { message?: string; code?: string; status?: number } | undefined => { - if (!error || typeof error !== 'object') return undefined; +function normalizeFeedCreationError(error: unknown): FeedCreationError { + if (isFeedCreationError(error)) return error; - const candidate = error as { - error?: { message?: unknown; code?: unknown; status?: unknown }; - message?: unknown; - code?: unknown; - status?: unknown; - }; + if (error instanceof Error) { + return buildStructuredError( + 'network', + 'NETWORK_ERROR', + true, + 'retry', + 'primary', + error.message || 'Unable to reach the server.' + ); + } - const message = normalizeString(candidate.error?.message ?? candidate.message); - const code = normalizeString(candidate.error?.code ?? candidate.code); - const status = normalizeStatus(candidate.error?.status ?? candidate.status); - return { message, code, status }; -}; + return buildStructuredError( + 'server', + 'UNKNOWN_ERROR', + true, + 'retry', + 'primary', + 'Unable to complete feed creation.' + ); +} -function isTransientReadinessStatus(status: number): boolean { - return status === 408 || status === 425 || status === 429 || status >= 500; +function normalizeFeedCreationErrorFromResponse( + status: number, + errorPayload: unknown, + payload?: RawApiResponse +): FeedCreationError { + const envelope = resolveErrorEnvelope(errorPayload, payload); + + const kind = normalizeErrorKind(envelope?.kind, status); + const retryable = normalizeBoolean(envelope?.retryable, defaultRetryableFromStatus(status, kind)); + const nextAction = normalizeNextAction(envelope?.next_action, kind, retryable); + const retryAction = normalizeRetryAction(envelope?.retry_action, nextAction, retryable); + const code = normalizeString(envelope?.code) || fallbackErrorCode(status, kind); + const message = normalizeString(envelope?.message) || fallbackErrorMessage(status, kind, nextAction); + const nextStrategy = normalizeString(envelope?.next_strategy); + + return buildStructuredError(kind, code, retryable, nextAction, retryAction, message, status, nextStrategy); } -function isAbortError(error: unknown): boolean { - if (error instanceof DOMException) return error.name === 'AbortError'; - if (error instanceof Error) return error.name === 'AbortError'; - return false; +function progressFromResult(result: CreatedFeedResult): ResultProgressState { + if (result.preview.items.length > 0) return 'preview'; + if (result.previewStatus === 'pending') return 'initial'; + return 'status'; } -function makeAbortError(): Error { - return Object.assign(new Error('Aborted'), { name: 'AbortError' }); +function resolveErrorEnvelope(errorPayload: unknown, payload?: RawApiResponse): RawErrorEnvelope | undefined { + if (isErrorEnvelope(errorPayload)) return errorPayload; + if (isErrorEnvelope(payload?.error)) return payload.error; + if (isErrorEnvelope(payload)) return payload; + return undefined; } -async function wait(durationMs: number, signal?: AbortSignal): Promise { - if (!signal) { - await new Promise((resolve) => globalThis.setTimeout(resolve, durationMs)); - return; +function buildStructuredError( + kind: FeedCreationError['kind'], + code: string, + retryable: boolean, + nextAction: FeedNextAction, + retryAction: FeedRetryAction, + message: string, + status?: number, + nextStrategy?: string +): FeedCreationError { + return { + kind, + code, + retryable, + nextAction, + retryAction, + ...(nextStrategy ? { nextStrategy } : {}), + message, + ...(typeof status === 'number' ? { status } : {}), + }; +} + +function buildLocalError( + message: string, + kind: FeedCreationError['kind'], + nextAction: FeedNextAction +): FeedCreationError { + const retryable = nextAction === 'retry'; + return buildStructuredError( + kind, + localErrorCode(kind, nextAction), + retryable, + nextAction, + retryable ? 'primary' : 'none', + message + ); +} + +function buildFallbackWarning(error: unknown): FeedStatusWarning { + if (isFeedCreationError(error)) { + return { + code: error.code, + message: error.message, + retryable: error.retryable, + nextAction: error.nextAction, + }; } - if (signal.aborted) throw makeAbortError(); + return { + code: 'STATUS_CHECK_FAILED', + message: PREVIEW_UNAVAILABLE_MESSAGE, + retryable: true, + nextAction: 'retry', + }; +} - await new Promise((resolve, reject) => { - const timeoutHandle = globalThis.setTimeout(() => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, durationMs); +function buildPreviewWarning( + code: string, + message: string, + retryable: boolean, + nextAction: FeedNextAction +): FeedStatusWarning { + return { code, message, retryable, nextAction }; +} - const onAbort = () => { - globalThis.clearTimeout(timeoutHandle); - signal.removeEventListener('abort', onAbort); - reject(makeAbortError()); - }; +function normalizeFeedRecord(raw?: RawFeedRecord): FeedRecord | undefined { + if (!raw) return undefined; - signal.addEventListener('abort', onAbort, { once: true }); - }); + const feedToken = normalizeString(raw.feed_token); + const publicUrl = normalizeString(raw.public_url); + const jsonPublicUrl = normalizeString(raw.json_public_url); + const url = normalizeString(raw.url); + + if (!feedToken || !publicUrl || !jsonPublicUrl || !url) return undefined; + + return { + id: normalizeString(raw.id) || feedToken, + name: normalizeString(raw.name) || url, + url, + feed_token: feedToken, + public_url: publicUrl, + json_public_url: jsonPublicUrl, + created_at: normalizeString(raw.created_at) || new Date().toISOString(), + updated_at: normalizeString(raw.updated_at) || new Date().toISOString(), + }; } -function retryableForFallback(error: unknown): boolean { - const details = extractErrorDetails(error); - const errorCode = details?.code?.toUpperCase(); - const status = details?.status; - if (errorCode && NON_RETRYABLE_ERROR_CODES.has(errorCode)) return false; - if (status && status < 500) return false; +function normalizeConversionState(raw?: RawFeedConversionState): FeedConversionState { + const readinessPhase = normalizeReadinessPhase(raw?.readiness_phase); + const previewStatus = normalizePreviewStatus(raw?.preview_status, readinessPhase); + const warnings = normalizeWarnings(raw?.warnings); + const retry = normalizeRetryState(raw?.retry); - const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); - if (!details?.code && (message.includes('unauthorized') || message.includes('forbidden'))) return false; - if (!details?.code && message.includes('bad request')) return false; - if (message.includes('access token') || message.includes('authentication')) return false; - if (message.includes('unsupported strategy')) return false; - if (message.includes('invalid response format')) return false; - if (message.includes('not valid json') || message.includes('unexpected token')) return false; - if (message === 'network error') return false; - if (error instanceof SyntaxError) return false; + return { + readinessPhase, + previewStatus, + warnings, + ...(retry ? { retry } : {}), + }; +} + +function normalizeReadinessPhase(value: unknown): FeedReadinessPhase { + if ( + value === 'feed_ready' || + value === 'feed_not_ready_yet' || + value === 'preview_unavailable' || + value === 'link_created' + ) { + return value; + } - if (status && status >= 500) return true; - if (message.includes('failed to fetch http')) return true; - return message.includes('internal server error') || message.includes('upstream timeout'); + return 'link_created'; } -function normalizeString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value : undefined; +function normalizePreviewStatus(value: unknown, readinessPhase: FeedReadinessPhase): FeedPreviewStatus { + if (value === 'pending' || value === 'ready' || value === 'degraded' || value === 'unavailable') { + return value; + } + + if (readinessPhase === 'feed_ready') return 'ready'; + if (readinessPhase === 'preview_unavailable') return 'unavailable'; + return 'pending'; } -function normalizeStatus(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +function normalizeWarnings(rawWarnings: unknown): FeedStatusWarning[] { + if (!Array.isArray(rawWarnings)) return []; + + return rawWarnings + .map((warning) => normalizeWarning(warning)) + .filter((warning): warning is FeedStatusWarning => warning !== undefined); } -function normalizePreviewText(value?: string): string | undefined { - if (!value) return undefined; +function normalizeWarning(rawWarning: unknown): FeedStatusWarning | undefined { + if (!rawWarning || typeof rawWarning !== 'object') return undefined; + + const candidate = rawWarning as { + code?: unknown; + message?: unknown; + retryable?: unknown; + next_action?: unknown; + }; - const normalized = decodeHtmlEntities(value) - .replaceAll(/<[^>]*>/g, ' ') - .replaceAll(/\s+/g, ' ') - .replaceAll(/\s+([!,.:;?])/g, '$1') - .replace(/^\d+\.\s+/, '') - .replace(/\s+\([^)]*\)\s*$/, '') - .trim(); + const code = normalizeString(candidate.code); + const message = normalizeString(candidate.message); + if (!code || !message) return undefined; - return normalized || undefined; + const retryable = normalizeBoolean(candidate.retryable, false); + const nextAction = normalizeWarningNextAction(candidate.next_action, retryable); + + return { + code, + message, + retryable, + nextAction, + }; } -function normalizePreviewItem(item: JsonFeedItem): FeedPreviewItem | undefined { - const excerptSource = item.content_text || item.content_html; - const title = normalizePreviewText(item.title) || normalizePreviewText(excerptSource) || 'Untitled item'; - const excerpt = normalizePreviewExcerpt(excerptSource, title); +function normalizeRetryState(rawRetry: unknown): FeedRetryState | undefined { + if (!rawRetry || typeof rawRetry !== 'object') return undefined; + const candidate = rawRetry as { automatic?: unknown }; return { - title, - excerpt, - publishedLabel: formatPublishedDate(item.date_published), - url: normalizePreviewUrl(item.url || item.external_url), + automatic: normalizeBoolean(candidate.automatic, false), }; } -function normalizePreviewExcerpt(value: string | undefined, title: string): string { - const excerpt = normalizePreviewText(value); - if (!excerpt || excerpt === title) return ''; - return truncateText(excerpt, 220); +function normalizeNextAction( + value: unknown, + kind: FeedCreationError['kind'], + retryable: boolean +): FeedNextAction { + if ( + value === 'enter_token' || + value === 'correct_input' || + value === 'retry' || + value === 'wait' || + value === 'none' + ) { + return value; + } + + if (kind === 'auth') return 'enter_token'; + if (kind === 'input') return 'correct_input'; + if (retryable) return 'retry'; + return 'none'; } -function normalizePreviewUrl(value?: string): string | undefined { - if (!value) return undefined; - if (!/^https?:\/\//i.test(value)) return undefined; - return value; +function normalizeWarningNextAction(value: unknown, retryable: boolean): FeedNextAction { + if (value === 'retry' || value === 'wait' || value === 'none') { + return value; + } + + return retryable ? 'retry' : 'wait'; +} + +function normalizeRetryAction( + value: unknown, + nextAction: FeedNextAction, + retryable: boolean +): FeedRetryAction { + if (value === 'alternate' || value === 'primary' || value === 'none') { + return value; + } + + if (!retryable || nextAction !== 'retry') return 'none'; + return 'primary'; +} + +function normalizeErrorKind(value: unknown, status: number): FeedCreationError['kind'] { + if (value === 'auth' || value === 'input' || value === 'network' || value === 'server') return value; + + if (status === 401 || status === 403) return 'auth'; + if (status === 400 || status === 404 || status === 422) return 'input'; + if (isTransientHttpStatus(status)) return 'network'; + return 'server'; } -function formatPublishedDate(value?: string): string { - if (!value) return ''; +function defaultRetryableFromStatus(status: number, kind: FeedCreationError['kind']): boolean { + if (kind === 'auth' || kind === 'input') return false; + if (kind === 'network') return true; + return isTransientHttpStatus(status) || status >= 500; +} + +function fallbackErrorCode(status: number, kind: FeedCreationError['kind']): string { + if (status === 401) return 'AUTH_REQUIRED'; + if (status === 403) return 'AUTH_FORBIDDEN'; + if (status === 400) return 'INVALID_INPUT'; + if (status === 404) return 'NOT_FOUND'; + if (status === 422) return 'UNPROCESSABLE_INPUT'; + if (isTransientHttpStatus(status)) return 'TRANSIENT_ERROR'; + if (status >= 500) return 'SERVER_ERROR'; + return `${kind.toUpperCase()}_ERROR`; +} + +function fallbackErrorMessage( + status: number, + kind: FeedCreationError['kind'], + nextAction: FeedNextAction +): string { + if (kind === 'auth') return 'Access token is required.'; + if (kind === 'input') return 'Check the URL and try again.'; + if (nextAction === 'wait') return 'The server is still processing the request.'; + if (isTransientHttpStatus(status) || kind === 'network') return 'Unable to reach the server. Try again.'; + return 'Unable to complete feed creation.'; +} + +function localErrorCode(kind: FeedCreationError['kind'], nextAction: FeedNextAction): string { + if (kind === 'auth') return 'AUTH_REQUIRED'; + if (kind === 'input' && nextAction === 'correct_input') return 'INVALID_INPUT'; + return 'LOCAL_VALIDATION_ERROR'; +} + +function isFeedCreationError(value: unknown): value is FeedCreationError { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + (candidate.kind === 'auth' || + candidate.kind === 'input' || + candidate.kind === 'network' || + candidate.kind === 'server') && + typeof candidate.code === 'string' && + typeof candidate.retryable === 'boolean' && + typeof candidate.nextAction === 'string' && + typeof candidate.retryAction === 'string' && + typeof candidate.message === 'string' + ); +} + +function isErrorEnvelope(value: unknown): value is RawErrorEnvelope { + if (!value || typeof value !== 'object') return false; + + const candidate = value as RawErrorEnvelope; + return ( + candidate.kind !== undefined || + candidate.code !== undefined || + candidate.retryable !== undefined || + candidate.next_action !== undefined || + candidate.retry_action !== undefined || + candidate.next_strategy !== undefined || + candidate.message !== undefined + ); +} - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return ''; +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(parsed); +function normalizeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; } -function truncateText(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; +async function readJsonResponse(response: Response): Promise { + const bodyText = await response.text(); + if (!bodyText.trim()) return undefined; - const clipped = value.slice(0, maxLength).trimEnd(); - const safeBoundary = clipped.lastIndexOf(' '); + try { + return JSON.parse(bodyText) as T; + } catch { + return undefined; + } +} - return `${(safeBoundary > maxLength * 0.6 ? clipped.slice(0, safeBoundary) : clipped).trimEnd()}...`; +function resolveApiUrl(path: string): string { + return `/api/v1/${path.replace(/^\/+/, '')}`; } -function decodeHtmlEntities(value: string): string { - if (typeof document === 'undefined') return value; +function buildCreateHeaders(token: string): HeadersInit { + const normalizedToken = token.trim(); + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + + if (normalizedToken) { + headers.Authorization = `Bearer ${normalizedToken}`; + } - const textarea = document.createElement('textarea'); - textarea.innerHTML = value; - return textarea.value; + return headers; } -function isExtractionWarningPayload(payload: JsonFeedResponse): boolean { - const title = normalizePreviewText(payload.title)?.toLowerCase(); - const description = normalizePreviewText(payload.description)?.toLowerCase(); - const firstItemText = normalizePreviewText(payload.items?.[0]?.content_text)?.toLowerCase(); - const firstItemTitle = normalizePreviewText(payload.items?.[0]?.title)?.toLowerCase(); +function isTransientHttpStatus(status: number): boolean { + return ( + status === 408 || + status === 409 || + status === 425 || + status === 429 || + status === 500 || + status === 502 || + status === 503 || + status === 504 + ); +} - return Boolean( - title?.includes('content extraction issue') || - description?.includes('could not extract entries') || - firstItemText?.includes('no entries were extracted from') || - firstItemTitle === 'preview unavailable for this source' +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') ); } + +function isValidHttpUrl(value: string): boolean { + try { + const parsedUrl = new URL(value); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch { + return false; + } +} + +async function wait(delayMs: number, signal?: AbortSignal): Promise { + if (delayMs <= 0) return; + + await new Promise((resolve, reject) => { + const timeoutHandle = globalThis.setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + + const onAbort = () => { + globalThis.clearTimeout(timeoutHandle); + reject(new DOMException('Aborted', 'AbortError')); + }; + + if (signal) { + if (signal.aborted) { + globalThis.clearTimeout(timeoutHandle); + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +function normalizePreviewItems(items: unknown[] | undefined): FeedPreviewItem[] { + if (!Array.isArray(items)) return []; + + return items + .map((item) => normalizePreviewItem(item)) + .filter((item): item is FeedPreviewItem => item !== undefined) + .slice(0, 5); +} + +function normalizePreviewItem(value: unknown): FeedPreviewItem | undefined { + if (!value || typeof value !== 'object') return undefined; + + const candidate = value as { + title?: unknown; + excerpt?: unknown; + description?: unknown; + content_text?: unknown; + contentText?: unknown; + published_label?: unknown; + publishedLabel?: unknown; + date_published?: unknown; + datePublished?: unknown; + date_modified?: unknown; + dateModified?: unknown; + url?: unknown; + }; + + const title = normalizeString(candidate.title); + if (!title) return undefined; + + const url = normalizeString(candidate.url); + + return { + title, + excerpt: + normalizeString( + candidate.excerpt ?? candidate.description ?? candidate.content_text ?? candidate.contentText + ) || '', + publishedLabel: + normalizeString( + candidate.published_label ?? + candidate.publishedLabel ?? + candidate.date_published ?? + candidate.datePublished ?? + candidate.date_modified ?? + candidate.dateModified + ) || '', + ...(url ? { url } : {}), + }; +} diff --git a/frontend/src/hooks/useStrategies.ts b/frontend/src/hooks/useStrategies.ts deleted file mode 100644 index 1054bded..00000000 --- a/frontend/src/hooks/useStrategies.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useState, useEffect } from 'preact/hooks'; -import { listStrategies } from '../api/generated'; -import { apiClient } from '../api/client'; -import type { StrategyRecord } from '../api/contracts'; - -interface StrategiesState { - strategies: StrategyRecord[]; - isLoading: boolean; - error?: string; -} - -export function useStrategies() { - const [state, setState] = useState({ - strategies: [], - isLoading: true, - }); - - const fetchStrategies = async () => { - setState((previous) => ({ ...previous, isLoading: true, error: undefined })); - - try { - const response = await listStrategies({ - client: apiClient, - }); - - if (response.error || !response.data?.success || !response.data.data?.strategies) { - throw new Error('Invalid response format from strategies API'); - } - - setState({ - strategies: response.data.data.strategies, - isLoading: false, - }); - } catch (error) { - setState({ - strategies: [], - isLoading: false, - error: error instanceof Error ? error.message : 'Failed to fetch strategies', - }); - } - }; - - useEffect(() => { - fetchStrategies(); - }, []); - - return { - strategies: state.strategies, - isLoading: state.isLoading, - error: state.error, - refetch: fetchStrategies, - }; -} diff --git a/frontend/src/utils/feedSessionStorage.ts b/frontend/src/utils/feedSessionStorage.ts index 3fa30fa8..a493f024 100644 --- a/frontend/src/utils/feedSessionStorage.ts +++ b/frontend/src/utils/feedSessionStorage.ts @@ -1,4 +1,4 @@ -import type { CreatedFeedResult } from '../api/contracts'; +import type { CreatedFeedResult, FeedPreviewItem, FeedStatusWarning } from '../api/contracts'; import { getPersistentStorage } from './persistentStorage'; const FEED_DRAFT_KEY = 'html2rss_feed_draft_state'; @@ -6,7 +6,6 @@ const FEED_RESULT_KEY_PREFIX = 'html2rss_feed_result_snapshot'; export interface FeedDraftState { url: string; - strategy?: string; } export interface FeedResultSnapshot { @@ -96,6 +95,8 @@ function isFeedResultSnapshot(value: unknown): value is FeedResultSnapshot { const candidate = value as Partial; return ( typeof candidate.savedAt === 'string' && + typeof candidate.result?.readinessPhase === 'string' && + typeof candidate.result?.previewStatus === 'string' && Boolean(candidate.result?.feed?.feed_token) && typeof candidate.result?.feed?.feed_token === 'string' ); @@ -120,11 +121,26 @@ function normalizeFeedResultSnapshot(snapshot: FeedResultSnapshot): FeedResultSn savedAt: snapshot.savedAt, result: { ...snapshot.result, - preview: { - ...snapshot.result.preview, - error: snapshot.result.preview.error, - }, + preview: normalizePreviewState(snapshot.result.preview), + readinessPhase: snapshot.result.readinessPhase, + previewStatus: snapshot.result.previewStatus, + warnings: normalizeWarnings(snapshot.result.warnings), retry: snapshot.result.retry, }, }; } + +function normalizePreviewState(preview: CreatedFeedResult['preview']): CreatedFeedResult['preview'] { + return { + items: normalizePreviewItems(preview.items), + isLoading: preview.isLoading, + }; +} + +function normalizePreviewItems(items: FeedPreviewItem[]): FeedPreviewItem[] { + return items.filter((item) => typeof item.title === 'string'); +} + +function normalizeWarnings(warnings: FeedStatusWarning[] | undefined): FeedStatusWarning[] { + return Array.isArray(warnings) ? warnings : []; +} From c3a9fc692e36b3d3219794cd11a9cbb311759cd3 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 18:07:38 +0200 Subject: [PATCH 17/22] chore(api): regenerate openapi and frontend generated client --- frontend/src/api/generated/index.ts | 4 +- frontend/src/api/generated/sdk.gen.ts | 9 +- frontend/src/api/generated/types.gen.ts | 161 +++++++---- public/openapi.yaml | 362 ++++++++++++++++-------- 4 files changed, 363 insertions(+), 173 deletions(-) diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 2494ebbd..4d2532f4 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; +export { createFeed, getApiMetadata, getFeedStatus, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, FeedConversionState, FeedCreationResponse, FeedMetadata, FeedRetrySummary, FeedStatusResponse, FeedWarning, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusError, GetFeedStatusErrors, GetFeedStatusResponse, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses, StructuredError, StructuredErrorResponse } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 2d508010..987b0a6a 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusErrors, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; export type Options = Options2 & { /** @@ -47,6 +47,13 @@ export const createFeed = (options: Option */ export const renderFeedByToken = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}', ...options }); +/** + * Get feed status + * + * Returns readiness and degradation metadata for a generated feed. + */ +export const getFeedStatus = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}/status', ...options }); + /** * Authenticated health check * diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 88d58560..9b2dbd0c 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -4,6 +4,71 @@ export type ClientOptions = { baseUrl: 'https://api.html2rss.dev/api/v1' | 'http://127.0.0.1:4000/api/v1' | (string & {}); }; +export type StructuredErrorResponse = { + error: StructuredError; + success: boolean; +}; + +export type StructuredError = { + code: string; + kind: 'auth' | 'input' | 'network' | 'server'; + message: string; + next_action: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; + next_strategy?: string; + retry_action: 'alternate' | 'primary' | 'none'; + retryable: boolean; +}; + +export type FeedCreationResponse = { + data: { + conversion: FeedConversionState; + feed: FeedMetadata; + }; + meta: { + created: boolean; + }; + success: boolean; +}; + +export type FeedConversionState = { + preview_status: 'pending' | 'ready' | 'degraded' | 'unavailable'; + readiness_phase: 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; + retry?: FeedRetrySummary; + warnings: Array; +}; + +export type FeedMetadata = { + created_at: string; + feed_token: string; + id: string; + json_public_url: string; + name: string; + public_url: string; + updated_at: string; + url: string; +}; + +export type FeedRetrySummary = { + automatic: boolean; + from: string; + to: string; +}; + +export type FeedStatusResponse = { + data: { + conversion: FeedConversionState; + feed: FeedMetadata; + }; + success: boolean; +}; + +export type FeedWarning = { + code: string; + message: string; + next_action: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; + retryable: boolean; +}; + export type GetApiMetadataData = { body?: never; path?: never; @@ -42,7 +107,6 @@ export type GetApiMetadataResponse = GetApiMetadataResponses[keyof GetApiMetadat export type CreateFeedData = { body?: { - strategy: string; url: string; }; headers: { @@ -54,26 +118,22 @@ export type CreateFeedData = { }; export type CreateFeedErrors = { + /** + * returns bad request for invalid input payloads + */ + 400: StructuredErrorResponse; /** * returns 401 with UNAUTHORIZED error payload */ - 401: { - error: { - code: string; - message: string; - }; - success: boolean; - }; + 401: StructuredErrorResponse; /** * returns forbidden for authenticated requests when auto source is disabled */ - 403: { - error: { - code: string; - message: string; - }; - success: boolean; - }; + 403: StructuredErrorResponse; + /** + * returns error when feed creation fails + */ + 500: StructuredErrorResponse; }; export type CreateFeedError = CreateFeedErrors[keyof CreateFeedErrors]; @@ -82,25 +142,7 @@ export type CreateFeedResponses = { /** * normalizes hostname-only input to https before feed creation */ - 201: { - data: { - feed: { - created_at: string; - feed_token: string; - id: string; - json_public_url: string; - name: string; - public_url: string; - strategy: string; - updated_at: string; - url: string; - }; - }; - meta: { - created: boolean; - }; - success: boolean; - }; + 201: FeedCreationResponse; }; export type CreateFeedResponse = CreateFeedResponses[keyof CreateFeedResponses]; @@ -140,6 +182,41 @@ export type RenderFeedByTokenResponses = { export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses]; +export type GetFeedStatusData = { + body?: never; + path: { + token: string; + }; + query?: never; + url: '/feeds/{token}/status'; +}; + +export type GetFeedStatusErrors = { + /** + * returns unauthorized for invalid tokens + */ + 401: StructuredErrorResponse; + /** + * returns forbidden when auto source is disabled + */ + 403: StructuredErrorResponse; + /** + * returns non-cacheable status errors when feed resolution fails + */ + 500: StructuredErrorResponse; +}; + +export type GetFeedStatusError = GetFeedStatusErrors[keyof GetFeedStatusErrors]; + +export type GetFeedStatusResponses = { + /** + * returns structured feed status and preview metadata + */ + 200: FeedStatusResponse; +}; + +export type GetFeedStatusResponse = GetFeedStatusResponses[keyof GetFeedStatusResponses]; + export type GetHealthStatusData = { body?: never; headers: { @@ -154,23 +231,11 @@ export type GetHealthStatusErrors = { /** * returns 401 with UNAUTHORIZED error payload */ - 401: { - error: { - code: string; - message: string; - }; - success: boolean; - }; + 401: StructuredErrorResponse; /** * returns error when configuration fails */ - 500: { - error: { - code: string; - message: string; - }; - success: boolean; - }; + 500: StructuredErrorResponse; }; export type GetHealthStatusError = GetHealthStatusErrors[keyof GetHealthStatusErrors]; diff --git a/public/openapi.yaml b/public/openapi.yaml index ab990919..5bd358e0 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -103,116 +103,43 @@ paths: application/json: schema: properties: - strategy: - type: string url: type: string required: - url - - strategy type: object responses: '201': content: application/json: schema: - properties: - data: - properties: - feed: - properties: - created_at: - type: string - feed_token: - type: string - id: - type: string - json_public_url: - type: string - name: - type: string - public_url: - type: string - strategy: - type: string - updated_at: - type: string - url: - type: string - required: - - id - - name - - url - - strategy - - feed_token - - public_url - - json_public_url - - created_at - - updated_at - type: object - required: - - feed - type: object - meta: - properties: - created: - type: boolean - required: - - created - type: object - success: - type: boolean - required: - - success - - data - - meta - type: object + "$ref": "#/components/schemas/FeedCreationResponse" description: normalizes hostname-only input to https before feed creation '401': content: application/json: schema: - properties: - error: - properties: - code: - type: string - message: - type: string - required: - - message - - code - type: object - success: - type: boolean - required: - - success - - error - type: object + "$ref": "#/components/schemas/StructuredErrorResponse" description: returns 401 with UNAUTHORIZED error payload + '400': + content: + application/json: + schema: + "$ref": "#/components/schemas/StructuredErrorResponse" + description: returns bad request for invalid input payloads '403': content: application/json: schema: - properties: - error: - properties: - code: - type: string - message: - type: string - required: - - message - - code - type: object - success: - type: boolean - required: - - success - - error - type: object + "$ref": "#/components/schemas/StructuredErrorResponse" description: returns forbidden for authenticated requests when auto source is disabled + '500': + content: + application/json: + schema: + "$ref": "#/components/schemas/StructuredErrorResponse" + description: returns error when feed creation fails security: - BearerAuth: [] summary: Create a feed @@ -281,6 +208,45 @@ paths: summary: Render feed by token tags: - Feeds + "/feeds/{token}/status": + get: + description: Returns readiness and degradation metadata for a generated feed. + operationId: getFeedStatus + parameters: + - in: path + name: token + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + "$ref": "#/components/schemas/FeedStatusResponse" + description: returns structured feed status and preview metadata + '401': + content: + application/json: + schema: + "$ref": "#/components/schemas/StructuredErrorResponse" + description: returns unauthorized for invalid tokens + '403': + content: + application/json: + schema: + "$ref": "#/components/schemas/StructuredErrorResponse" + description: returns forbidden when auto source is disabled + '500': + content: + application/json: + schema: + "$ref": "#/components/schemas/StructuredErrorResponse" + description: returns non-cacheable status errors when feed resolution fails + security: [] + summary: Get feed status + tags: + - Feeds "/health": get: description: Authenticated health check @@ -334,45 +300,13 @@ paths: content: application/json: schema: - properties: - error: - properties: - code: - type: string - message: - type: string - required: - - message - - code - type: object - success: - type: boolean - required: - - success - - error - type: object + "$ref": "#/components/schemas/StructuredErrorResponse" description: returns 401 with UNAUTHORIZED error payload '500': content: application/json: schema: - properties: - error: - properties: - code: - type: string - message: - type: string - required: - - message - - code - type: object - success: - type: boolean - required: - - success - - error - type: object + "$ref": "#/components/schemas/StructuredErrorResponse" description: returns error when configuration fails security: - BearerAuth: [] @@ -512,6 +446,190 @@ paths: tags: - Strategies components: + schemas: + StructuredErrorResponse: + properties: + error: + "$ref": "#/components/schemas/StructuredError" + success: + type: boolean + required: + - success + - error + type: object + StructuredError: + properties: + code: + type: string + kind: + enum: + - auth + - input + - network + - server + type: string + message: + type: string + next_action: + enum: + - enter_token + - correct_input + - retry + - wait + - none + type: string + next_strategy: + type: string + retry_action: + enum: + - alternate + - primary + - none + type: string + retryable: + type: boolean + required: + - message + - code + - kind + - retryable + - next_action + - retry_action + type: object + FeedCreationResponse: + properties: + data: + properties: + conversion: + "$ref": "#/components/schemas/FeedConversionState" + feed: + "$ref": "#/components/schemas/FeedMetadata" + required: + - feed + - conversion + type: object + meta: + properties: + created: + type: boolean + required: + - created + type: object + success: + type: boolean + required: + - success + - data + - meta + type: object + FeedConversionState: + properties: + preview_status: + enum: + - pending + - ready + - degraded + - unavailable + type: string + readiness_phase: + enum: + - link_created + - feed_ready + - feed_not_ready_yet + - preview_unavailable + type: string + retry: + "$ref": "#/components/schemas/FeedRetrySummary" + warnings: + items: + "$ref": "#/components/schemas/FeedWarning" + type: array + required: + - readiness_phase + - preview_status + - warnings + type: object + FeedMetadata: + properties: + created_at: + type: string + feed_token: + type: string + id: + type: string + json_public_url: + type: string + name: + type: string + public_url: + type: string + updated_at: + type: string + url: + type: string + required: + - id + - name + - url + - feed_token + - public_url + - json_public_url + - created_at + - updated_at + type: object + FeedRetrySummary: + properties: + automatic: + type: boolean + from: + type: string + to: + type: string + required: + - automatic + - from + - to + type: object + FeedStatusResponse: + properties: + data: + properties: + conversion: + "$ref": "#/components/schemas/FeedConversionState" + feed: + "$ref": "#/components/schemas/FeedMetadata" + required: + - feed + - conversion + type: object + success: + type: boolean + required: + - success + - data + type: object + FeedWarning: + properties: + code: + type: string + message: + type: string + next_action: + enum: + - enter_token + - correct_input + - retry + - wait + - none + type: string + retryable: + type: boolean + required: + - code + - message + - retryable + - next_action + type: object securitySchemes: BearerAuth: description: Bearer token authentication for API access. From ef5fe59466237bb0de2e8ed652843eec10101789 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 18:07:48 +0200 Subject: [PATCH 18/22] fix(build): make rubocop invocation deterministic in make ready --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 130f89c2..272e2481 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ .PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e +RUBOCOP_FLAGS ?= --cache false + # Default target help: ## Show this help message @echo "html2rss-web Development Commands" @@ -60,7 +62,7 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found @echo "Running RuboCop linting..." - bundle exec rubocop + bundle exec rubocop $(RUBOCOP_FLAGS) @echo "Running Zeitwerk eager-load check..." bundle exec rake zeitwerk:verify @echo "Running YARD public-method docs check..." From 0e4fd7670cc784ee33d0465dea05bb418e3d6db2 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 22:03:55 +0200 Subject: [PATCH 19/22] Fix OpenAPI status path determinism and smoke contract --- frontend/e2e/smoke.spec.ts | 28 +- frontend/src/api/generated/index.ts | 2 +- frontend/src/api/generated/sdk.gen.ts | 6 +- frontend/src/api/generated/types.gen.ts | 186 +++++----- public/openapi.yaml | 456 ++++++++++++------------ spec/html2rss/web/api/v1_spec.rb | 72 +++- 6 files changed, 411 insertions(+), 339 deletions(-) diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index ed9a4eca..677c74c3 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -51,7 +51,6 @@ 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 page.getByLabel('Page URL').fill('https://example.com/articles'); await page.getByRole('button', { name: 'Generate feed URL' }).click(); @@ -63,11 +62,11 @@ 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 page.goBack(); - await expect(page.getByRole('heading', { name: 'Enter access token' })).toBeVisible(); - await expect(page.locator('.form-shell')).toHaveAttribute('data-state', 'token_required'); + await expect(page).toHaveURL(/\/create(?:\?.*)?$/); + await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); + await expect(page.locator('.form-shell')).toHaveAttribute('data-state', 'idle'); }); test('restores result deep links and shows a recovery state when snapshot is missing', async ({ page }) => { @@ -130,12 +129,23 @@ test.describe('frontend smoke', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', + created_at: '2026-04-05T08:59:00.000Z', + updated_at: '2026-04-05T09:00:00.000Z', }, preview: { - items: [], + items: [ + { + title: 'Sample preview item', + excerpt: 'Current restore snapshots include preview content.', + publishedLabel: 'April 5, 2026', + url: 'https://example.com/articles/sample-preview-item', + }, + ], isLoading: false, }, readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], }, }) ); @@ -145,8 +155,13 @@ test.describe('frontend smoke', () => { await expect(page.getByRole('heading', { name: 'Feed ready' })).toBeVisible(); await expect(page.locator('.result-shell')).toHaveAttribute('data-state', 'ready'); + await expect(page.getByText('Example Feed')).toBeVisible(); await expect(page.getByRole('link', { name: 'Open feed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open JSON Feed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open in feed reader' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Create another feed' })).toBeVisible(); + await expect(page.getByText('Sample preview item')).toBeVisible(); + await expect(page.getByText('Current restore snapshots include preview content.')).toBeVisible(); await page.evaluate(() => { localStorage.removeItem('html2rss_feed_result_snapshot:missing-token'); @@ -155,6 +170,9 @@ test.describe('frontend smoke', () => { await page.goto('/result/missing-token'); await expect(page.getByText('Saved result unavailable')).toBeVisible(); + await expect( + page.getByText('We could not restore this feed result. Create a new feed link to continue.') + ).toBeVisible(); await expect(page.getByRole('button', { name: 'Go to create' })).toBeVisible(); await expect(page.locator('.notice')).toHaveAttribute('data-tone', 'error'); }); diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 4d2532f4..f589a710 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { createFeed, getApiMetadata, getFeedStatus, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, FeedConversionState, FeedCreationResponse, FeedMetadata, FeedRetrySummary, FeedStatusResponse, FeedWarning, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusError, GetFeedStatusErrors, GetFeedStatusResponse, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses, StructuredError, StructuredErrorResponse } from './types.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusResponse, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 987b0a6a..c04d92d2 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusErrors, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; export type Options = Options2 & { /** @@ -50,9 +50,9 @@ export const renderFeedByToken = (options: /** * Get feed status * - * Returns readiness and degradation metadata for a generated feed. + * Get feed status */ -export const getFeedStatus = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}/status', ...options }); +export const getFeedStatus = (options?: Options) => (options?.client ?? client).get({ url: '/feeds/{token}/status', ...options }); /** * Authenticated health check diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 9b2dbd0c..1671b767 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -4,71 +4,6 @@ export type ClientOptions = { baseUrl: 'https://api.html2rss.dev/api/v1' | 'http://127.0.0.1:4000/api/v1' | (string & {}); }; -export type StructuredErrorResponse = { - error: StructuredError; - success: boolean; -}; - -export type StructuredError = { - code: string; - kind: 'auth' | 'input' | 'network' | 'server'; - message: string; - next_action: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; - next_strategy?: string; - retry_action: 'alternate' | 'primary' | 'none'; - retryable: boolean; -}; - -export type FeedCreationResponse = { - data: { - conversion: FeedConversionState; - feed: FeedMetadata; - }; - meta: { - created: boolean; - }; - success: boolean; -}; - -export type FeedConversionState = { - preview_status: 'pending' | 'ready' | 'degraded' | 'unavailable'; - readiness_phase: 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; - retry?: FeedRetrySummary; - warnings: Array; -}; - -export type FeedMetadata = { - created_at: string; - feed_token: string; - id: string; - json_public_url: string; - name: string; - public_url: string; - updated_at: string; - url: string; -}; - -export type FeedRetrySummary = { - automatic: boolean; - from: string; - to: string; -}; - -export type FeedStatusResponse = { - data: { - conversion: FeedConversionState; - feed: FeedMetadata; - }; - success: boolean; -}; - -export type FeedWarning = { - code: string; - message: string; - next_action: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; - retryable: boolean; -}; - export type GetApiMetadataData = { body?: never; path?: never; @@ -118,22 +53,34 @@ export type CreateFeedData = { }; export type CreateFeedErrors = { - /** - * returns bad request for invalid input payloads - */ - 400: StructuredErrorResponse; /** * returns 401 with UNAUTHORIZED error payload */ - 401: StructuredErrorResponse; + 401: { + error: { + code: string; + kind: string; + message: string; + next_action: string; + retry_action: string; + retryable: boolean; + }; + success: boolean; + }; /** * returns forbidden for authenticated requests when auto source is disabled */ - 403: StructuredErrorResponse; - /** - * returns error when feed creation fails - */ - 500: StructuredErrorResponse; + 403: { + error: { + code: string; + kind: string; + message: string; + next_action: string; + retry_action: string; + retryable: boolean; + }; + success: boolean; + }; }; export type CreateFeedError = CreateFeedErrors[keyof CreateFeedErrors]; @@ -142,7 +89,29 @@ export type CreateFeedResponses = { /** * normalizes hostname-only input to https before feed creation */ - 201: FeedCreationResponse; + 201: { + data: { + conversion: { + preview_status: string; + readiness_phase: string; + warnings: Array; + }; + feed: { + created_at: string; + feed_token: string; + id: string; + json_public_url: string; + name: string; + public_url: string; + updated_at: string; + url: string; + }; + }; + meta: { + created: boolean; + }; + success: boolean; + }; }; export type CreateFeedResponse = CreateFeedResponses[keyof CreateFeedResponses]; @@ -184,35 +153,32 @@ export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderF export type GetFeedStatusData = { body?: never; - path: { - token: string; - }; + path?: never; query?: never; url: '/feeds/{token}/status'; }; -export type GetFeedStatusErrors = { - /** - * returns unauthorized for invalid tokens - */ - 401: StructuredErrorResponse; - /** - * returns forbidden when auto source is disabled - */ - 403: StructuredErrorResponse; - /** - * returns non-cacheable status errors when feed resolution fails - */ - 500: StructuredErrorResponse; -}; - -export type GetFeedStatusError = GetFeedStatusErrors[keyof GetFeedStatusErrors]; - export type GetFeedStatusResponses = { /** - * returns structured feed status and preview metadata + * Returns readiness and degradation metadata for a generated feed. */ - 200: FeedStatusResponse; + 200: { + data: { + conversion: { + preview_status: string; + readiness_phase: string; + warnings: Array; + }; + feed: { + feed_token: string; + json_public_url: string; + name: string; + public_url: string; + url: string; + }; + }; + success: boolean; + }; }; export type GetFeedStatusResponse = GetFeedStatusResponses[keyof GetFeedStatusResponses]; @@ -231,11 +197,31 @@ export type GetHealthStatusErrors = { /** * returns 401 with UNAUTHORIZED error payload */ - 401: StructuredErrorResponse; + 401: { + error: { + code: string; + kind: string; + message: string; + next_action: string; + retry_action: string; + retryable: boolean; + }; + success: boolean; + }; /** * returns error when configuration fails */ - 500: StructuredErrorResponse; + 500: { + error: { + code: string; + kind: string; + message: string; + next_action: string; + retry_action: string; + retryable: boolean; + }; + success: boolean; + }; }; export type GetHealthStatusError = GetHealthStatusErrors[keyof GetHealthStatusErrors]; diff --git a/public/openapi.yaml b/public/openapi.yaml index 5bd358e0..d7479097 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -113,33 +113,139 @@ paths: content: application/json: schema: - "$ref": "#/components/schemas/FeedCreationResponse" + properties: + data: + properties: + conversion: + properties: + preview_status: + type: string + readiness_phase: + type: string + warnings: + items: {} + type: array + required: + - readiness_phase + - preview_status + - warnings + type: object + feed: + properties: + created_at: + type: string + feed_token: + type: string + id: + type: string + json_public_url: + type: string + name: + type: string + public_url: + type: string + updated_at: + type: string + url: + type: string + required: + - id + - name + - url + - feed_token + - public_url + - json_public_url + - created_at + - updated_at + type: object + required: + - feed + - conversion + type: object + meta: + properties: + created: + type: boolean + required: + - created + type: object + success: + type: boolean + required: + - success + - data + - meta + type: object description: normalizes hostname-only input to https before feed creation '401': content: application/json: schema: - "$ref": "#/components/schemas/StructuredErrorResponse" + properties: + error: + properties: + code: + type: string + kind: + type: string + message: + type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean + required: + - message + - code + - kind + - retryable + - next_action + - retry_action + type: object + success: + type: boolean + required: + - success + - error + type: object description: returns 401 with UNAUTHORIZED error payload - '400': - content: - application/json: - schema: - "$ref": "#/components/schemas/StructuredErrorResponse" - description: returns bad request for invalid input payloads '403': content: application/json: schema: - "$ref": "#/components/schemas/StructuredErrorResponse" + properties: + error: + properties: + code: + type: string + kind: + type: string + message: + type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean + required: + - message + - code + - kind + - retryable + - next_action + - retry_action + type: object + success: + type: boolean + required: + - success + - error + type: object description: returns forbidden for authenticated requests when auto source is disabled - '500': - content: - application/json: - schema: - "$ref": "#/components/schemas/StructuredErrorResponse" - description: returns error when feed creation fails security: - BearerAuth: [] summary: Create a feed @@ -210,39 +316,61 @@ paths: - Feeds "/feeds/{token}/status": get: - description: Returns readiness and degradation metadata for a generated feed. + description: Get feed status operationId: getFeedStatus - parameters: - - in: path - name: token - required: true - schema: - type: string responses: '200': content: application/json: schema: - "$ref": "#/components/schemas/FeedStatusResponse" - description: returns structured feed status and preview metadata - '401': - content: - application/json: - schema: - "$ref": "#/components/schemas/StructuredErrorResponse" - description: returns unauthorized for invalid tokens - '403': - content: - application/json: - schema: - "$ref": "#/components/schemas/StructuredErrorResponse" - description: returns forbidden when auto source is disabled - '500': - content: - application/json: - schema: - "$ref": "#/components/schemas/StructuredErrorResponse" - description: returns non-cacheable status errors when feed resolution fails + properties: + data: + properties: + conversion: + properties: + preview_status: + type: string + readiness_phase: + type: string + warnings: + items: {} + type: array + required: + - readiness_phase + - preview_status + - warnings + type: object + feed: + properties: + feed_token: + type: string + json_public_url: + type: string + name: + type: string + public_url: + type: string + url: + type: string + required: + - name + - url + - feed_token + - public_url + - json_public_url + type: object + required: + - feed + - conversion + type: object + success: + type: boolean + required: + - success + - data + type: object + description: Returns readiness and degradation metadata for a generated + feed. security: [] summary: Get feed status tags: @@ -300,13 +428,69 @@ paths: content: application/json: schema: - "$ref": "#/components/schemas/StructuredErrorResponse" + properties: + error: + properties: + code: + type: string + kind: + type: string + message: + type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean + required: + - message + - code + - kind + - retryable + - next_action + - retry_action + type: object + success: + type: boolean + required: + - success + - error + type: object description: returns 401 with UNAUTHORIZED error payload '500': content: application/json: schema: - "$ref": "#/components/schemas/StructuredErrorResponse" + properties: + error: + properties: + code: + type: string + kind: + type: string + message: + type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean + required: + - message + - code + - kind + - retryable + - next_action + - retry_action + type: object + success: + type: boolean + required: + - success + - error + type: object description: returns error when configuration fails security: - BearerAuth: [] @@ -446,190 +630,6 @@ paths: tags: - Strategies components: - schemas: - StructuredErrorResponse: - properties: - error: - "$ref": "#/components/schemas/StructuredError" - success: - type: boolean - required: - - success - - error - type: object - StructuredError: - properties: - code: - type: string - kind: - enum: - - auth - - input - - network - - server - type: string - message: - type: string - next_action: - enum: - - enter_token - - correct_input - - retry - - wait - - none - type: string - next_strategy: - type: string - retry_action: - enum: - - alternate - - primary - - none - type: string - retryable: - type: boolean - required: - - message - - code - - kind - - retryable - - next_action - - retry_action - type: object - FeedCreationResponse: - properties: - data: - properties: - conversion: - "$ref": "#/components/schemas/FeedConversionState" - feed: - "$ref": "#/components/schemas/FeedMetadata" - required: - - feed - - conversion - type: object - meta: - properties: - created: - type: boolean - required: - - created - type: object - success: - type: boolean - required: - - success - - data - - meta - type: object - FeedConversionState: - properties: - preview_status: - enum: - - pending - - ready - - degraded - - unavailable - type: string - readiness_phase: - enum: - - link_created - - feed_ready - - feed_not_ready_yet - - preview_unavailable - type: string - retry: - "$ref": "#/components/schemas/FeedRetrySummary" - warnings: - items: - "$ref": "#/components/schemas/FeedWarning" - type: array - required: - - readiness_phase - - preview_status - - warnings - type: object - FeedMetadata: - properties: - created_at: - type: string - feed_token: - type: string - id: - type: string - json_public_url: - type: string - name: - type: string - public_url: - type: string - updated_at: - type: string - url: - type: string - required: - - id - - name - - url - - feed_token - - public_url - - json_public_url - - created_at - - updated_at - type: object - FeedRetrySummary: - properties: - automatic: - type: boolean - from: - type: string - to: - type: string - required: - - automatic - - from - - to - type: object - FeedStatusResponse: - properties: - data: - properties: - conversion: - "$ref": "#/components/schemas/FeedConversionState" - feed: - "$ref": "#/components/schemas/FeedMetadata" - required: - - feed - - conversion - type: object - success: - type: boolean - required: - - success - - data - type: object - FeedWarning: - properties: - code: - type: string - message: - type: string - next_action: - enum: - - enter_token - - correct_input - - retry - - wait - - none - type: string - retryable: - type: boolean - required: - - code - - message - - retryable - - next_action - type: object securitySchemes: BearerAuth: description: Bearer token authentication for API access. diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index df2ad9d3..e5a5738e 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -86,6 +86,50 @@ def json_feed_response_for(token) json_feed_headers_tuple end + def stub_feed_status_openapi_example + stub_feed_status_dependencies + allow(Rack::MockRequest).to receive(:env_for).and_wrap_original do |original, uri = '', opts = {}| + env = original.call(uri, opts) + rewrite_openapi_placeholder_path!(env, uri.to_s) + end + end + + def stub_feed_status_dependencies + stub_feed_status_request + stub_feed_status_source + stub_feed_status_metadata + stub_feed_status_service + end + + def stub_feed_status_request + allow(Html2rss::Web::Feeds::Request).to receive(:call).and_return(Struct.new(:token).new('{token}')) + end + + def stub_feed_status_source + allow(Html2rss::Web::Feeds::SourceResolver).to receive(:call).and_return( + Struct.new(:generator_input).new({ channel: { url: feed_url } }) + ) + end + + def stub_feed_status_metadata + allow(Html2rss::Web::Api::V1::FeedMetadata).to receive(:site_title_for).and_return('Example Feed') + end + + def stub_feed_status_service + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + end + + def rewrite_openapi_placeholder_path!(env, request_uri) + return env unless request_uri.include?('/api/v1/feeds/%7Btoken%7D/status') + + raw_status_path = '/api/v1/feeds/{token}/status' + env['PATH_INFO'] = raw_status_path + env['REQUEST_URI'] = raw_status_path + env['ORIGINAL_FULLPATH'] = raw_status_path + env['ORIGINAL_PATH_INFO'] = raw_status_path + env + end + def stub_json_feed_success allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) @@ -407,7 +451,31 @@ def expected_featured_feeds ) end - it 'exposes structured feed status for ready feeds', :aggregate_failures do + describe 'GET /api/v1/feeds/{token}/status', openapi: { + summary: 'Get feed status', + description: 'Returns readiness and degradation metadata for a generated feed.', + operation_id: 'getFeedStatus', + tags: ['Feeds'], + security: [] + } do + it 'documents structured feed status for ready feeds', :aggregate_failures do + stub_feed_status_openapi_example + + get '/api/v1/feeds/%7Btoken%7D/status' + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'feed', 'feed_token')).to eq('{token}') + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [] + ) + end + end + + it 'exposes structured feed status for ready feeds', :aggregate_failures, openapi: false do token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) @@ -424,7 +492,7 @@ def expected_featured_feeds ) end - it 'exposes degraded preview warnings with explicit retry guidance', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'exposes degraded preview warnings with explicit retry guidance', :aggregate_failures, openapi: false do # rubocop:disable RSpec/ExampleLength token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/degraded", strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) From 4a98d1710afe0ebaca85cd9661412787d1f17017 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 22:04:11 +0200 Subject: [PATCH 20/22] Harden conversion hydration and snapshot restore handling --- .../src/__tests__/feedSessionStorage.test.ts | 108 +++++++++++++ .../src/__tests__/useFeedConversion.test.ts | 148 +++++++++++++++++- frontend/src/hooks/useFeedConversion.ts | 13 +- frontend/src/utils/feedSessionStorage.ts | 93 +++++++++-- 4 files changed, 347 insertions(+), 15 deletions(-) diff --git a/frontend/src/__tests__/feedSessionStorage.test.ts b/frontend/src/__tests__/feedSessionStorage.test.ts index 293e95a0..bdf77b6c 100644 --- a/frontend/src/__tests__/feedSessionStorage.test.ts +++ b/frontend/src/__tests__/feedSessionStorage.test.ts @@ -76,4 +76,112 @@ describe('feedSessionStorage', () => { clearFeedResultSnapshot('example-token'); expect(loadFeedResultSnapshot('example-token')).toBeUndefined(); }); + + it.each([ + { + name: 'invalid JSON', + value: '{not-json', + }, + { + name: 'missing preview', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + { + name: 'non-array preview items', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: undefined, + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + { + name: 'malformed preview item', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: [undefined], + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + { + name: 'malformed feed shape', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: [], + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + ])('rejects $name snapshots without throwing', ({ value }) => { + globalThis.localStorage.setItem('html2rss_feed_result_snapshot:example-token', value); + + expect(() => loadFeedResultSnapshot('example-token')).not.toThrow(); + expect(loadFeedResultSnapshot('example-token')).toBeUndefined(); + expect(loadFeedResultState('example-token')).toBeUndefined(); + }); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 4094821f..dc305cd2 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -167,7 +167,7 @@ describe('useFeedConversion', () => { }); }); - it('hydrates degraded preview state from status warnings', async () => { + it('hydrates preview fetch success while preserving status warnings', async () => { fetchMock .mockResolvedValueOnce( new Response( @@ -224,7 +224,7 @@ describe('useFeedConversion', () => { await waitFor(() => { expect(result.current.result?.readinessPhase).toBe('feed_ready'); - expect(result.current.result?.previewStatus).toBe('degraded'); + expect(result.current.result?.previewStatus).toBe('ready'); expect(result.current.result?.warnings).toEqual([ { code: 'preview_partial', @@ -236,6 +236,150 @@ describe('useFeedConversion', () => { }); }); + it('merges degraded preview fetch warnings into the committed result', async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, + }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + next_action: 'wait', + }, + ], + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response('', { + status: 503, + headers: { 'Content-Type': 'application/feed+json' }, + }) + ); + + const { result } = renderHook(() => useFeedConversion()); + await act(async () => { + await result.current.convertFeed('https://example.com/articles', 'token-123'); + }); + + await waitFor(() => { + expect(result.current.result?.previewStatus).toBe('degraded'); + expect(result.current.result?.warnings).toEqual([ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + nextAction: 'wait', + }, + { + code: 'PREVIEW_HTTP_503', + message: 'Preview content is partially degraded right now.', + retryable: true, + nextAction: 'retry', + }, + ]); + }); + }); + + it('merges unavailable preview fetch warnings into the committed result', async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, + }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + next_action: 'wait', + }, + ], + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response('not found', { + status: 404, + headers: { 'Content-Type': 'application/feed+json' }, + }) + ); + + const { result } = renderHook(() => useFeedConversion()); + await act(async () => { + await result.current.convertFeed('https://example.com/articles', 'token-123'); + }); + + await waitFor(() => { + expect(result.current.result?.previewStatus).toBe('unavailable'); + expect(result.current.result?.warnings).toEqual([ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + nextAction: 'wait', + }, + { + code: 'PREVIEW_HTTP_404', + message: 'Preview unavailable right now.', + retryable: false, + nextAction: 'wait', + }, + ]); + }); + }); + it('retries readiness checks from the current result token', async () => { fetchMock .mockResolvedValueOnce( diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index 6e5c7012..4fa765e2 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -305,7 +305,7 @@ async function hydrateFeedStatus( commitResult( feed, - resolvedConversion, + mergePreviewResult(resolvedConversion, previewResult), previewResult.items, false, requestId, @@ -478,6 +478,17 @@ function shouldLoadPreviewItems(previewStatus: FeedPreviewStatus): boolean { return previewStatus === 'ready' || previewStatus === 'degraded'; } +function mergePreviewResult( + conversion: FeedConversionState, + previewResult: PreviewLoadResult +): FeedConversionState { + return { + ...conversion, + previewStatus: previewResult.previewStatus, + warnings: [...conversion.warnings, ...previewResult.warnings], + }; +} + function commitResult( feed: FeedRecord, conversion: FeedConversionState, diff --git a/frontend/src/utils/feedSessionStorage.ts b/frontend/src/utils/feedSessionStorage.ts index a493f024..6105e39d 100644 --- a/frontend/src/utils/feedSessionStorage.ts +++ b/frontend/src/utils/feedSessionStorage.ts @@ -90,15 +90,19 @@ function isFeedDraftState(value: unknown): value is FeedDraftState { } function isFeedResultSnapshot(value: unknown): value is FeedResultSnapshot { - if (!value || typeof value !== 'object') return false; + if (!isRecord(value)) return false; const candidate = value as Partial; + if (typeof candidate.savedAt !== 'string' || !isRecord(candidate.result)) return false; + + const result = candidate.result as Partial; return ( - typeof candidate.savedAt === 'string' && - typeof candidate.result?.readinessPhase === 'string' && - typeof candidate.result?.previewStatus === 'string' && - Boolean(candidate.result?.feed?.feed_token) && - typeof candidate.result?.feed?.feed_token === 'string' + typeof result.readinessPhase === 'string' && + typeof result.previewStatus === 'string' && + isFeedRecord(result.feed) && + isFeedPreviewState(result.preview) && + (result.warnings === undefined || isFeedStatusWarnings(result.warnings)) && + (result.retry === undefined || isFeedRetryState(result.retry)) ); } @@ -116,12 +120,15 @@ function parseJson( } } -function normalizeFeedResultSnapshot(snapshot: FeedResultSnapshot): FeedResultSnapshot { +function normalizeFeedResultSnapshot(snapshot: FeedResultSnapshot): FeedResultSnapshot | undefined { + const preview = normalizePreviewState(snapshot.result.preview); + if (!preview) return undefined; + return { savedAt: snapshot.savedAt, result: { ...snapshot.result, - preview: normalizePreviewState(snapshot.result.preview), + preview, readinessPhase: snapshot.result.readinessPhase, previewStatus: snapshot.result.previewStatus, warnings: normalizeWarnings(snapshot.result.warnings), @@ -130,17 +137,79 @@ function normalizeFeedResultSnapshot(snapshot: FeedResultSnapshot): FeedResultSn }; } -function normalizePreviewState(preview: CreatedFeedResult['preview']): CreatedFeedResult['preview'] { +function normalizePreviewState(preview: unknown): CreatedFeedResult['preview'] | undefined { + if (!isFeedPreviewState(preview)) return undefined; + return { items: normalizePreviewItems(preview.items), isLoading: preview.isLoading, }; } -function normalizePreviewItems(items: FeedPreviewItem[]): FeedPreviewItem[] { - return items.filter((item) => typeof item.title === 'string'); +function normalizePreviewItems(items: unknown[]): FeedPreviewItem[] { + return items.filter((item) => isFeedPreviewItem(item)); } function normalizeWarnings(warnings: FeedStatusWarning[] | undefined): FeedStatusWarning[] { - return Array.isArray(warnings) ? warnings : []; + return Array.isArray(warnings) ? warnings.filter((warning) => isFeedStatusWarning(warning)) : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +function isFeedRecord(value: unknown): value is CreatedFeedResult['feed'] { + if (!isRecord(value)) return false; + + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.url === 'string' && + typeof value.feed_token === 'string' && + typeof value.public_url === 'string' && + typeof value.json_public_url === 'string' && + typeof value.created_at === 'string' && + typeof value.updated_at === 'string' + ); +} + +function isFeedPreviewState(value: unknown): value is CreatedFeedResult['preview'] { + if (!isRecord(value)) return false; + if (!Array.isArray(value.items) || typeof value.isLoading !== 'boolean') return false; + + return value.items.every((item) => isFeedPreviewItem(item)); +} + +function isFeedPreviewItem(value: unknown): value is FeedPreviewItem { + if (!isRecord(value)) return false; + + return ( + typeof value.title === 'string' && + typeof value.excerpt === 'string' && + typeof value.publishedLabel === 'string' && + (value.url === undefined || typeof value.url === 'string') + ); +} + +function isFeedStatusWarnings(value: unknown): value is FeedStatusWarning[] { + return Array.isArray(value) && value.every((warning) => isFeedStatusWarning(warning)); +} + +function isFeedStatusWarning(value: unknown): value is FeedStatusWarning { + if (!isRecord(value)) return false; + + return ( + typeof value.code === 'string' && + typeof value.message === 'string' && + typeof value.retryable === 'boolean' && + (value.nextAction === 'enter_token' || + value.nextAction === 'correct_input' || + value.nextAction === 'retry' || + value.nextAction === 'wait' || + value.nextAction === 'none') + ); +} + +function isFeedRetryState(value: unknown): value is { automatic: boolean } { + return isRecord(value) && typeof value.automatic === 'boolean'; } From c05985b2250e72e7d66fbb7b920d70917a55eaf4 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 22:04:20 +0200 Subject: [PATCH 21/22] Reorder footer actions and add CI parity gate guidance --- AGENTS.md | 1 + Makefile | 9 ++++++++- docs/README.md | 7 +++++++ frontend/src/__tests__/App.test.tsx | 8 +++++--- frontend/src/components/AppPanels.tsx | 26 +++++++++++++------------- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dae9788..f4849377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This document defines execution constraints for AI agents. For general contribut ## Agent-Specific Verification Rules - Always run Dev Container smoke + `make ready` for changes. +- For frontend changes or API contract/spec changes, run `make ci-ready` to mirror CI parity checks. - For frontend changes, also verify in `chrome-devtools` MCP at `http://127.0.0.1:4001/` while the Dev Container is running. - Capture a quick state check for all affected UI states (e.g., guest/member/result) to enforce state parity and avoid duplicate actions. diff --git a/Makefile b/Makefile index 272e2481..e2bae4ac 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # frozen_string_literal: true -.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e +.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e RUBOCOP_FLAGS ?= --cache false @@ -107,6 +107,13 @@ ready: ## Pre-commit gate (quick checks + RSpec) bundle exec rspec @echo "Pre-commit checks complete!" +ci-ready: ## CI parity gate (ready + OpenAPI verify + frontend e2e smoke) + @echo "Running CI parity checks..." + $(MAKE) ready + $(MAKE) openapi-verify + $(MAKE) test-frontend-e2e + @echo "CI parity checks complete!" + yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/ bundle exec rake yard:verify_public_docs diff --git a/docs/README.md b/docs/README.md index ff674af1..f584c5fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ Running the app directly on the host is not supported. | `make setup` | Install Ruby and Node dependencies. | | `make dev` | Run Ruby (port 4000) and frontend (port 4001) dev servers. | | `make ready` | Pre-commit gate: `make quick-check` + `bundle exec rspec`. | +| `make ci-ready` | CI parity gate: `make ready` + `make openapi-verify` + frontend e2e smoke. | | `make test` | Run Ruby and frontend test suites. | | `make lint` | Run all linters. | | `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. | @@ -90,6 +91,12 @@ Always run this before pushing or committing: make ready ``` +For frontend changes and API contract/OpenAPI changes, run the CI-parity gate: + +```bash +make ci-ready +``` + ### Testing Layers | Layer | Tooling | Focus | diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 156882cd..7e4fa6ca 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -184,6 +184,8 @@ describe('App', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', + created_at: '2026-04-05T08:59:00.000Z', + updated_at: '2026-04-05T09:00:00.000Z', }, preview: { items: [], @@ -412,10 +414,10 @@ describe('App', () => { expect(utilityItems).toEqual([ 'Try included feeds', 'Bookmarklet', + 'Clear saved token', + 'Install from Docker Hub', 'OpenAPI spec', 'Source code', - 'Install from Docker Hub', - 'Clear saved token', ]); }); @@ -671,9 +673,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( diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 0e4542ad..8a20b396 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -275,6 +275,19 @@ export function UtilityStrip({ Try included feeds + {hasAccessToken && ( + + )} + + Install from Docker Hub + {openapiUrl && ( Source code - - Install from Docker Hub - - {hasAccessToken && ( - - )}
); From d5ea2aceca1c1858db144260e51a3539fb8e519c Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 5 Apr 2026 22:34:53 +0200 Subject: [PATCH 22/22] fix PR review findings for status contract and storage utils --- ...ge.test.ts => feedWorkflowStorage.test.ts} | 4 +-- frontend/src/api/generated/sdk.gen.ts | 2 +- frontend/src/api/generated/types.gen.ts | 4 ++- frontend/src/components/App.tsx | 2 +- frontend/src/hooks/useFeedConversion.ts | 2 +- ...ssionStorage.ts => feedWorkflowStorage.ts} | 0 frontend/src/utils/persistentStorage.ts | 8 ++++-- public/openapi.yaml | 6 ++++ spec/html2rss/web/api/v1_spec.rb | 10 ++++++- spec/support/api_contract_helpers.rb | 1 + spec/support/openapi.rb | 28 ++++++++++--------- 11 files changed, 45 insertions(+), 22 deletions(-) rename frontend/src/__tests__/{feedSessionStorage.test.ts => feedWorkflowStorage.test.ts} (98%) rename frontend/src/utils/{feedSessionStorage.ts => feedWorkflowStorage.ts} (100%) diff --git a/frontend/src/__tests__/feedSessionStorage.test.ts b/frontend/src/__tests__/feedWorkflowStorage.test.ts similarity index 98% rename from frontend/src/__tests__/feedSessionStorage.test.ts rename to frontend/src/__tests__/feedWorkflowStorage.test.ts index bdf77b6c..d5cabb88 100644 --- a/frontend/src/__tests__/feedSessionStorage.test.ts +++ b/frontend/src/__tests__/feedWorkflowStorage.test.ts @@ -7,9 +7,9 @@ import { loadFeedResultState, saveFeedDraftState, saveFeedResultSnapshot, -} from '../utils/feedSessionStorage'; +} from '../utils/feedWorkflowStorage'; -describe('feedSessionStorage', () => { +describe('feedWorkflowStorage', () => { beforeEach(() => { globalThis.localStorage.clear(); globalThis.sessionStorage.clear(); diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index c04d92d2..38707d19 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -52,7 +52,7 @@ export const renderFeedByToken = (options: * * Get feed status */ -export const getFeedStatus = (options?: Options) => (options?.client ?? client).get({ url: '/feeds/{token}/status', ...options }); +export const getFeedStatus = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}/status', ...options }); /** * Authenticated health check diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 1671b767..0074e863 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -153,7 +153,9 @@ export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderF export type GetFeedStatusData = { body?: never; - path?: never; + path: { + token: string; + }; query?: never; url: '/feeds/{token}/status'; }; diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 6104e2aa..84f41582 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -11,7 +11,7 @@ import { loadFeedDraftState, loadFeedResultState, saveFeedDraftState, -} from '../utils/feedSessionStorage'; +} from '../utils/feedWorkflowStorage'; import { normalizeUserUrl } from '../utils/url'; import type { WorkflowState } from './AppPanels'; import type { CreatedFeedResult, FeedCreationError } from '../api/contracts'; diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index 4fa765e2..c2a2738d 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -12,7 +12,7 @@ import type { FeedRetryState, FeedStatusWarning, } from '../api/contracts'; -import { saveFeedResultSnapshot } from '../utils/feedSessionStorage'; +import { saveFeedResultSnapshot } from '../utils/feedWorkflowStorage'; import { normalizeUserUrl } from '../utils/url'; interface ConversionState { diff --git a/frontend/src/utils/feedSessionStorage.ts b/frontend/src/utils/feedWorkflowStorage.ts similarity index 100% rename from frontend/src/utils/feedSessionStorage.ts rename to frontend/src/utils/feedWorkflowStorage.ts diff --git a/frontend/src/utils/persistentStorage.ts b/frontend/src/utils/persistentStorage.ts index eba073c2..5c4400ad 100644 --- a/frontend/src/utils/persistentStorage.ts +++ b/frontend/src/utils/persistentStorage.ts @@ -6,8 +6,12 @@ const memoryStorage = (() => { return store.size; }, clear: () => store.clear(), - getItem: (key: string) => store.get(key), - key: (index: number) => [...store.keys()][index], + // Storage#getItem returns null when a key is missing. + // eslint-disable-next-line unicorn/no-null + getItem: (key: string) => store.get(key) ?? null, + // Storage#key returns null when the index is out of range. + // eslint-disable-next-line unicorn/no-null + key: (index: number) => [...store.keys()][index] ?? null, removeItem: (key: string) => { store.delete(key); }, diff --git a/public/openapi.yaml b/public/openapi.yaml index d7479097..b1ff2efb 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -318,6 +318,12 @@ paths: get: description: Get feed status operationId: getFeedStatus + parameters: + - in: path + name: token + required: true + schema: + type: string responses: '200': content: diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index e5a5738e..a84eff97 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -451,11 +451,19 @@ def expected_featured_feeds ) end - describe 'GET /api/v1/feeds/{token}/status', openapi: { + describe 'GET /api/v1/feeds/:token/status', openapi: { summary: 'Get feed status', description: 'Returns readiness and degradation metadata for a generated feed.', operation_id: 'getFeedStatus', tags: ['Feeds'], + parameters: [ + { + name: :token, + in: :path, + required: true, + schema: { type: :string } + } + ], security: [] } do it 'documents structured feed status for ready feeds', :aggregate_failures do diff --git a/spec/support/api_contract_helpers.rb b/spec/support/api_contract_helpers.rb index 75728f55..60e8bd26 100644 --- a/spec/support/api_contract_helpers.rb +++ b/spec/support/api_contract_helpers.rb @@ -35,6 +35,7 @@ def expect_error_response(response, code:, **expected) def expect_feed_payload(json) feed = json.fetch('data').fetch('feed') expect_feed_identifier_payload(feed) + expect_feed_source_payload(feed) expect(feed).not_to have_key('strategy') feed end diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 5705a7d5..d71c285e 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -157,21 +157,23 @@ normalized_paths[normalized][verb]['description'] ||= normalized_paths[normalized][verb]['summary'] - next unless normalized == '/feeds/{token}' - - normalized_paths[normalized][verb]['parameters'] ||= [] - has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter| - parameter['name'] == 'token' && parameter['in'] == 'path' - end - unless has_token_param - normalized_paths[normalized][verb]['parameters'] << { - 'name' => 'token', - 'in' => 'path', - 'required' => true, - 'schema' => { 'type' => 'string' } - } + if normalized.start_with?('/feeds/{token}') + normalized_paths[normalized][verb]['parameters'] ||= [] + has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter| + parameter['name'] == 'token' && parameter['in'] == 'path' + end + unless has_token_param + normalized_paths[normalized][verb]['parameters'] << { + 'name' => 'token', + 'in' => 'path', + 'required' => true, + 'schema' => { 'type' => 'string' } + } + end end + next unless normalized == '/feeds/{token}' + token_feed_error_statuses.each do |status| response = normalized_paths[normalized][verb].dig('responses', status) next unless response