Skip to content

Commit 3528cd7

Browse files
committed
fix(web): preserve created feeds when preview loading fails
1 parent f558689 commit 3528cd7

12 files changed

Lines changed: 373 additions & 209 deletions

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ gem 'puma', require: false
2323

2424
group :development do
2525
gem 'byebug'
26+
gem 'irb', require: false
2627
gem 'rake', require: false
2728
gem 'rubocop', require: false
2829
gem 'rubocop-performance', require: false

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('App contract', () => {
3030
})
3131
);
3232
}),
33-
http.get('/api/v1/feeds/generated-token', ({ request }) => {
33+
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
3434
expect(request.headers.get('accept')).toBe('application/feed+json');
3535

3636
return HttpResponse.json(

frontend/src/__tests__/App.test.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,21 +206,22 @@ describe('App', () => {
206206
});
207207

208208
it('renders the result panel when a feed is available', async () => {
209-
vi.spyOn(window, 'fetch').mockResolvedValue({
210-
ok: true,
211-
json: async () => ({ items: [] }),
212-
} as Response);
213-
214209
mockUseFeedConversion.mockReturnValue({
215210
isConverting: false,
216211
result: {
217-
id: 'feed-123',
218-
name: 'Example Feed',
219-
url: 'https://example.com/articles',
220-
strategy: 'faraday',
221-
feed_token: 'example-token',
222-
public_url: '/api/v1/feeds/example-token',
223-
json_public_url: '/api/v1/feeds/example-token.json',
212+
feed: {
213+
id: 'feed-123',
214+
name: 'Example Feed',
215+
url: 'https://example.com/articles',
216+
strategy: 'faraday',
217+
feed_token: 'example-token',
218+
public_url: '/api/v1/feeds/example-token',
219+
json_public_url: '/api/v1/feeds/example-token.json',
220+
},
221+
preview: {
222+
items: [],
223+
error: 'Preview unavailable right now.',
224+
},
224225
},
225226
error: null,
226227
convertFeed: mockConvertFeed,
@@ -233,6 +234,7 @@ describe('App', () => {
233234
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
234235
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
235236
expect(screen.getByText('Example Feed')).toBeInTheDocument();
237+
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
236238
});
237239

238240
it('surfaces conversion errors to the user', () => {
@@ -250,6 +252,24 @@ describe('App', () => {
250252
expect(screen.getByText('Access denied')).toBeInTheDocument();
251253
});
252254

255+
it('shows an explicit loading notice while feed creation is still resolving preview state', () => {
256+
mockUseFeedConversion.mockReturnValue({
257+
isConverting: true,
258+
result: null,
259+
error: null,
260+
convertFeed: mockConvertFeed,
261+
clearError: mockClearConversionError,
262+
clearResult: mockClearResult,
263+
});
264+
265+
render(<App />);
266+
267+
expect(screen.getByText('Preparing feed')).toBeInTheDocument();
268+
expect(
269+
screen.getByText('Creating the feed and loading its preview before showing the result.')
270+
).toBeInTheDocument();
271+
});
272+
253273
it('clears stored token from instance info', () => {
254274
mockUseAccessToken.mockReturnValue({
255275
token: 'saved-token',

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,41 @@ import { ResultDisplay } from '../components/ResultDisplay';
66
describe('ResultDisplay', () => {
77
const mockOnCreateAnother = vi.fn();
88
const mockResult = {
9-
id: 'test-id',
10-
name: 'Test Feed',
11-
url: 'https://example.com',
12-
strategy: 'faraday',
13-
feed_token: 'test-feed-token',
14-
public_url: 'https://example.com/feed.xml',
15-
json_public_url: 'https://example.com/feed.json',
9+
feed: {
10+
id: 'test-id',
11+
name: 'Test Feed',
12+
url: 'https://example.com',
13+
strategy: 'faraday',
14+
feed_token: 'test-feed-token',
15+
public_url: 'https://example.com/feed.xml',
16+
json_public_url: 'https://example.com/feed.json',
17+
},
18+
preview: {
19+
items: [
20+
{
21+
title: 'Item One',
22+
excerpt: 'First preview item with markup.',
23+
url: 'https://example.com/item-one',
24+
publishedLabel: 'Jan 1, 2024',
25+
},
26+
{
27+
title: '56 points by canpan 1 hour ago | hide | 18 comments',
28+
excerpt: '',
29+
publishedLabel: 'Jan 2, 2024',
30+
},
31+
{
32+
title: 'Item Two',
33+
excerpt: '',
34+
url: 'https://example.com/item-two',
35+
publishedLabel: 'Jan 3, 2024',
36+
},
37+
],
38+
error: null,
39+
},
1640
};
1741

1842
beforeEach(() => {
1943
vi.clearAllMocks();
20-
vi.spyOn(window, 'fetch').mockResolvedValue({
21-
ok: true,
22-
json: async () => ({
23-
items: [
24-
{
25-
title: 'Item One',
26-
content_text: '<p>First preview item with <strong>markup</strong>.</p>',
27-
url: 'https://example.com/item-one',
28-
date_published: '2024-01-01T00:00:00Z',
29-
},
30-
{
31-
content_text: '56 points by canpan 1 hour ago | hide | 18&nbsp;comments',
32-
date_published: '2024-01-02T00:00:00Z',
33-
},
34-
{
35-
content_text: '2. Item Two ( example.com )',
36-
url: 'https://example.com/item-two',
37-
date_published: '2024-01-03T00:00:00Z',
38-
},
39-
],
40-
}),
41-
} as Response);
4244
});
4345

4446
it('renders the success state actions and richer preview cards', async () => {
@@ -60,18 +62,15 @@ describe('ResultDisplay', () => {
6062
expect(screen.getByText('Item Two')).toBeInTheDocument();
6163
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
6264
});
63-
expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', {
64-
headers: { Accept: 'application/feed+json' },
65-
});
6665
});
6766

68-
it('surfaces preview fetch failures as a result-state message', async () => {
69-
vi.mocked(window.fetch).mockResolvedValueOnce({
70-
ok: false,
71-
json: async () => ({}),
72-
} as Response);
73-
74-
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
67+
it('surfaces preview failures as a result-state message', async () => {
68+
render(
69+
<ResultDisplay
70+
result={{ ...mockResult, preview: { items: [], error: 'Preview unavailable right now.' } }}
71+
onCreateAnother={mockOnCreateAnother}
72+
/>
73+
);
7574

7675
await waitFor(() => {
7776
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();

frontend/src/__tests__/setup.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import '@testing-library/jest-dom';
22
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
33
import { cleanup } from '@testing-library/preact';
4-
import { server } from './mocks/server';
4+
5+
let server: typeof import('./mocks/server').server;
56

67
// Mock window and document for tests
78
Object.defineProperty(window, 'matchMedia', {
@@ -49,10 +50,16 @@ const session = createStorageMock();
4950
Object.defineProperty(window, 'localStorage', {
5051
value: local.api,
5152
});
53+
Object.defineProperty(globalThis, 'localStorage', {
54+
value: local.api,
55+
});
5256

5357
Object.defineProperty(window, 'sessionStorage', {
5458
value: session.api,
5559
});
60+
Object.defineProperty(globalThis, 'sessionStorage', {
61+
value: session.api,
62+
});
5663

5764
beforeEach(() => {
5865
local.store.clear();
@@ -80,7 +87,10 @@ Object.assign(navigator, {
8087
Element.prototype.scrollIntoView = vi.fn();
8188

8289
// Wire up MSW in node environment
83-
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
90+
beforeAll(async () => {
91+
({ server } = await import('./mocks/server'));
92+
server.listen({ onUnhandledRequest: 'error' });
93+
});
8494
afterEach(() => {
8595
server.resetHandlers();
8696
cleanup();

frontend/src/__tests__/useFeedConversion.contract.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ describe('useFeedConversion contract', () => {
2424
}),
2525
{ status: 201 }
2626
);
27+
}),
28+
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
29+
expect(request.headers.get('accept')).toBe('application/feed+json');
30+
31+
return HttpResponse.json({
32+
items: [
33+
{
34+
title: 'Generated item',
35+
content_text: 'Contract preview',
36+
url: 'https://example.com/items/generated',
37+
date_published: '2024-01-02T00:00:00Z',
38+
},
39+
],
40+
});
2741
})
2842
);
2943

@@ -35,9 +49,11 @@ describe('useFeedConversion contract', () => {
3549

3650
expect(receivedAuthorization).toBe('Bearer test-token-123');
3751
expect(result.current.error).toBeNull();
38-
expect(result.current.result?.feed_token).toBe('generated-token');
39-
expect(result.current.result?.public_url).toBe('/api/v1/feeds/generated-token');
40-
expect(result.current.result?.json_public_url).toBe('/api/v1/feeds/generated-token.json');
52+
expect(result.current.result?.feed.feed_token).toBe('generated-token');
53+
expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token');
54+
expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json');
55+
expect(result.current.result?.preview.error).toBeNull();
56+
expect(result.current.result?.preview.items).toHaveLength(1);
4157
});
4258

4359
it('propagates API validation errors', async () => {
@@ -83,4 +99,31 @@ describe('useFeedConversion contract', () => {
8399
expect(result.current.result).toBeNull();
84100
expect(result.current.error).toBe('Invalid response format from feed creation API');
85101
});
102+
103+
it('preserves the created feed when preview loading fails', async () => {
104+
server.use(
105+
http.post('/api/v1/feeds', async () =>
106+
HttpResponse.json(
107+
buildFeedResponse({
108+
feed_token: 'generated-token',
109+
public_url: '/api/v1/feeds/generated-token',
110+
json_public_url: '/api/v1/feeds/generated-token.json',
111+
}),
112+
{ status: 201 }
113+
)
114+
),
115+
http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(null, { status: 502 }))
116+
);
117+
118+
const { result } = renderHook(() => useFeedConversion());
119+
120+
await act(async () => {
121+
await result.current.convertFeed('https://example.com/articles', 'faraday', 'token');
122+
});
123+
124+
expect(result.current.error).toBeNull();
125+
expect(result.current.result?.feed.feed_token).toBe('generated-token');
126+
expect(result.current.result?.preview.items).toEqual([]);
127+
expect(result.current.result?.preview.error).toBe('Preview unavailable right now.');
128+
});
86129
});

0 commit comments

Comments
 (0)