Skip to content

Commit 4a98d17

Browse files
committed
Harden conversion hydration and snapshot restore handling
1 parent 0e4fd76 commit 4a98d17

4 files changed

Lines changed: 347 additions & 15 deletions

File tree

frontend/src/__tests__/feedSessionStorage.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,112 @@ describe('feedSessionStorage', () => {
7676
clearFeedResultSnapshot('example-token');
7777
expect(loadFeedResultSnapshot('example-token')).toBeUndefined();
7878
});
79+
80+
it.each([
81+
{
82+
name: 'invalid JSON',
83+
value: '{not-json',
84+
},
85+
{
86+
name: 'missing preview',
87+
value: JSON.stringify({
88+
savedAt: '2026-04-05T09:00:00.000Z',
89+
result: {
90+
feed: {
91+
id: 'feed-123',
92+
name: 'Example Feed',
93+
url: 'https://example.com/articles',
94+
feed_token: 'example-token',
95+
public_url: '/api/v1/feeds/example-token',
96+
json_public_url: '/api/v1/feeds/example-token.json',
97+
created_at: '2024-01-01T00:00:00Z',
98+
updated_at: '2024-01-01T00:00:00Z',
99+
},
100+
readinessPhase: 'feed_ready',
101+
previewStatus: 'ready',
102+
warnings: [],
103+
},
104+
}),
105+
},
106+
{
107+
name: 'non-array preview items',
108+
value: JSON.stringify({
109+
savedAt: '2026-04-05T09:00:00.000Z',
110+
result: {
111+
feed: {
112+
id: 'feed-123',
113+
name: 'Example Feed',
114+
url: 'https://example.com/articles',
115+
feed_token: 'example-token',
116+
public_url: '/api/v1/feeds/example-token',
117+
json_public_url: '/api/v1/feeds/example-token.json',
118+
created_at: '2024-01-01T00:00:00Z',
119+
updated_at: '2024-01-01T00:00:00Z',
120+
},
121+
preview: {
122+
items: undefined,
123+
isLoading: false,
124+
},
125+
readinessPhase: 'feed_ready',
126+
previewStatus: 'ready',
127+
warnings: [],
128+
},
129+
}),
130+
},
131+
{
132+
name: 'malformed preview item',
133+
value: JSON.stringify({
134+
savedAt: '2026-04-05T09:00:00.000Z',
135+
result: {
136+
feed: {
137+
id: 'feed-123',
138+
name: 'Example Feed',
139+
url: 'https://example.com/articles',
140+
feed_token: 'example-token',
141+
public_url: '/api/v1/feeds/example-token',
142+
json_public_url: '/api/v1/feeds/example-token.json',
143+
created_at: '2024-01-01T00:00:00Z',
144+
updated_at: '2024-01-01T00:00:00Z',
145+
},
146+
preview: {
147+
items: [undefined],
148+
isLoading: false,
149+
},
150+
readinessPhase: 'feed_ready',
151+
previewStatus: 'ready',
152+
warnings: [],
153+
},
154+
}),
155+
},
156+
{
157+
name: 'malformed feed shape',
158+
value: JSON.stringify({
159+
savedAt: '2026-04-05T09:00:00.000Z',
160+
result: {
161+
feed: {
162+
id: 'feed-123',
163+
name: 'Example Feed',
164+
url: 'https://example.com/articles',
165+
feed_token: 'example-token',
166+
json_public_url: '/api/v1/feeds/example-token.json',
167+
created_at: '2024-01-01T00:00:00Z',
168+
updated_at: '2024-01-01T00:00:00Z',
169+
},
170+
preview: {
171+
items: [],
172+
isLoading: false,
173+
},
174+
readinessPhase: 'feed_ready',
175+
previewStatus: 'ready',
176+
warnings: [],
177+
},
178+
}),
179+
},
180+
])('rejects $name snapshots without throwing', ({ value }) => {
181+
globalThis.localStorage.setItem('html2rss_feed_result_snapshot:example-token', value);
182+
183+
expect(() => loadFeedResultSnapshot('example-token')).not.toThrow();
184+
expect(loadFeedResultSnapshot('example-token')).toBeUndefined();
185+
expect(loadFeedResultState('example-token')).toBeUndefined();
186+
});
79187
});

frontend/src/__tests__/useFeedConversion.test.ts

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ describe('useFeedConversion', () => {
167167
});
168168
});
169169

170-
it('hydrates degraded preview state from status warnings', async () => {
170+
it('hydrates preview fetch success while preserving status warnings', async () => {
171171
fetchMock
172172
.mockResolvedValueOnce(
173173
new Response(
@@ -224,7 +224,7 @@ describe('useFeedConversion', () => {
224224

225225
await waitFor(() => {
226226
expect(result.current.result?.readinessPhase).toBe('feed_ready');
227-
expect(result.current.result?.previewStatus).toBe('degraded');
227+
expect(result.current.result?.previewStatus).toBe('ready');
228228
expect(result.current.result?.warnings).toEqual([
229229
{
230230
code: 'preview_partial',
@@ -236,6 +236,150 @@ describe('useFeedConversion', () => {
236236
});
237237
});
238238

239+
it('merges degraded preview fetch warnings into the committed result', async () => {
240+
fetchMock
241+
.mockResolvedValueOnce(
242+
new Response(
243+
JSON.stringify({
244+
success: true,
245+
data: {
246+
feed: mockFeed,
247+
conversion: {
248+
readiness_phase: 'link_created',
249+
preview_status: 'pending',
250+
warnings: [],
251+
},
252+
},
253+
}),
254+
{ status: 201, headers: { 'Content-Type': 'application/json' } }
255+
)
256+
)
257+
.mockResolvedValueOnce(
258+
new Response(
259+
JSON.stringify({
260+
success: true,
261+
data: {
262+
feed: mockFeed,
263+
conversion: {
264+
readiness_phase: 'feed_ready',
265+
preview_status: 'ready',
266+
warnings: [
267+
{
268+
code: 'STATUS_WARNING',
269+
message: 'Status warning should be preserved.',
270+
retryable: false,
271+
next_action: 'wait',
272+
},
273+
],
274+
},
275+
},
276+
}),
277+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
278+
)
279+
)
280+
.mockResolvedValueOnce(
281+
new Response('', {
282+
status: 503,
283+
headers: { 'Content-Type': 'application/feed+json' },
284+
})
285+
);
286+
287+
const { result } = renderHook(() => useFeedConversion());
288+
await act(async () => {
289+
await result.current.convertFeed('https://example.com/articles', 'token-123');
290+
});
291+
292+
await waitFor(() => {
293+
expect(result.current.result?.previewStatus).toBe('degraded');
294+
expect(result.current.result?.warnings).toEqual([
295+
{
296+
code: 'STATUS_WARNING',
297+
message: 'Status warning should be preserved.',
298+
retryable: false,
299+
nextAction: 'wait',
300+
},
301+
{
302+
code: 'PREVIEW_HTTP_503',
303+
message: 'Preview content is partially degraded right now.',
304+
retryable: true,
305+
nextAction: 'retry',
306+
},
307+
]);
308+
});
309+
});
310+
311+
it('merges unavailable preview fetch warnings into the committed result', async () => {
312+
fetchMock
313+
.mockResolvedValueOnce(
314+
new Response(
315+
JSON.stringify({
316+
success: true,
317+
data: {
318+
feed: mockFeed,
319+
conversion: {
320+
readiness_phase: 'link_created',
321+
preview_status: 'pending',
322+
warnings: [],
323+
},
324+
},
325+
}),
326+
{ status: 201, headers: { 'Content-Type': 'application/json' } }
327+
)
328+
)
329+
.mockResolvedValueOnce(
330+
new Response(
331+
JSON.stringify({
332+
success: true,
333+
data: {
334+
feed: mockFeed,
335+
conversion: {
336+
readiness_phase: 'feed_ready',
337+
preview_status: 'ready',
338+
warnings: [
339+
{
340+
code: 'STATUS_WARNING',
341+
message: 'Status warning should be preserved.',
342+
retryable: false,
343+
next_action: 'wait',
344+
},
345+
],
346+
},
347+
},
348+
}),
349+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
350+
)
351+
)
352+
.mockResolvedValueOnce(
353+
new Response('not found', {
354+
status: 404,
355+
headers: { 'Content-Type': 'application/feed+json' },
356+
})
357+
);
358+
359+
const { result } = renderHook(() => useFeedConversion());
360+
await act(async () => {
361+
await result.current.convertFeed('https://example.com/articles', 'token-123');
362+
});
363+
364+
await waitFor(() => {
365+
expect(result.current.result?.previewStatus).toBe('unavailable');
366+
expect(result.current.result?.warnings).toEqual([
367+
{
368+
code: 'STATUS_WARNING',
369+
message: 'Status warning should be preserved.',
370+
retryable: false,
371+
nextAction: 'wait',
372+
},
373+
{
374+
code: 'PREVIEW_HTTP_404',
375+
message: 'Preview unavailable right now.',
376+
retryable: false,
377+
nextAction: 'wait',
378+
},
379+
]);
380+
});
381+
});
382+
239383
it('retries readiness checks from the current result token', async () => {
240384
fetchMock
241385
.mockResolvedValueOnce(

frontend/src/hooks/useFeedConversion.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ async function hydrateFeedStatus(
305305

306306
commitResult(
307307
feed,
308-
resolvedConversion,
308+
mergePreviewResult(resolvedConversion, previewResult),
309309
previewResult.items,
310310
false,
311311
requestId,
@@ -478,6 +478,17 @@ function shouldLoadPreviewItems(previewStatus: FeedPreviewStatus): boolean {
478478
return previewStatus === 'ready' || previewStatus === 'degraded';
479479
}
480480

481+
function mergePreviewResult(
482+
conversion: FeedConversionState,
483+
previewResult: PreviewLoadResult
484+
): FeedConversionState {
485+
return {
486+
...conversion,
487+
previewStatus: previewResult.previewStatus,
488+
warnings: [...conversion.warnings, ...previewResult.warnings],
489+
};
490+
}
491+
481492
function commitResult(
482493
feed: FeedRecord,
483494
conversion: FeedConversionState,

0 commit comments

Comments
 (0)