Skip to content

Commit a4a3941

Browse files
committed
Add routed frontend feed workflow
1 parent dfca027 commit a4a3941

22 files changed

Lines changed: 1929 additions & 2349 deletions
Lines changed: 68 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { render, screen, fireEvent, waitFor } from '@testing-library/preact';
33
import { http, HttpResponse } from 'msw';
4-
import { server, buildFeedResponse } from './mocks/server';
4+
import { server, buildFeedResponse, buildStructuredErrorResponse } from './mocks/server';
55
import { App } from '../components/App';
66

77
describe('App contract', () => {
88
const token = 'contract-token';
99

10-
const authenticate = () => {
11-
globalThis.localStorage.setItem('html2rss_access_token', token);
12-
};
10+
beforeEach(() => {
11+
globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create');
12+
globalThis.localStorage.clear();
13+
globalThis.sessionStorage.clear();
14+
globalThis.sessionStorage.setItem('html2rss_access_token', token);
15+
});
16+
17+
it('shows feed result when the API returns structured create payload and preview feed', async () => {
18+
const nativeFetch = globalThis.fetch;
19+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
20+
if (String(input).endsWith('/api/v1/feeds/generated-token.json')) {
21+
expect((init?.headers as Record<string, string> | undefined)?.Accept).toBe('application/feed+json');
22+
return Promise.resolve(
23+
new Response(
24+
JSON.stringify({
25+
items: [
26+
{
27+
title: 'Contract Item',
28+
content_text: 'Contract preview excerpt.',
29+
url: 'https://example.com/contract-item',
30+
date_published: '2024-01-01T00:00:00Z',
31+
},
32+
],
33+
}),
34+
{ status: 200, headers: { 'Content-Type': 'application/feed+json' } }
35+
)
36+
);
37+
}
1338

14-
it('shows feed result when API responds with success', async () => {
15-
authenticate();
39+
return nativeFetch(input, init);
40+
});
1641

1742
server.use(
1843
http.post('/api/v1/feeds', async ({ request }) => {
@@ -27,10 +52,11 @@ describe('App contract', () => {
2752
feed_token: 'generated-token',
2853
public_url: '/api/v1/feeds/generated-token',
2954
json_public_url: '/api/v1/feeds/generated-token.json',
30-
})
55+
}),
56+
{ status: 201 }
3157
);
3258
}),
33-
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
59+
http.get('http://localhost:3000/api/v1/feeds/generated-token.json', ({ request }) => {
3460
expect(request.headers.get('accept')).toBe('application/feed+json');
3561

3662
return HttpResponse.json(
@@ -48,108 +74,67 @@ describe('App contract', () => {
4874
headers: { 'content-type': 'application/feed+json' },
4975
}
5076
);
77+
}),
78+
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
79+
expect(request.headers.get('accept')).toBe('application/feed+json');
80+
81+
return HttpResponse.json({
82+
items: [
83+
{
84+
title: 'Contract Item',
85+
content_text: 'Contract preview excerpt.',
86+
url: 'https://example.com/contract-item',
87+
date_published: '2024-01-01T00:00:00Z',
88+
},
89+
],
90+
});
5191
})
5292
);
5393

5494
render(<App />);
5595

56-
await screen.findByLabelText('Page URL');
5796
await waitFor(() => {
58-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
97+
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
5998
});
99+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
60100

61101
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
62102
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
63-
64103
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
65104

66105
await waitFor(() => {
67106
expect(screen.getByText('Feed ready')).toBeInTheDocument();
68107
expect(screen.getByText('Example Feed')).toBeInTheDocument();
108+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'result');
69109
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
70110
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
71-
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
72-
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
73-
'href',
74-
'http://localhost:3000/api/v1/feeds/generated-token.json'
75-
);
76111
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
77-
expect(screen.getByText('Preview')).toBeInTheDocument();
78112
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
79-
expect(screen.getByText('Contract Item')).toBeInTheDocument();
80113
});
114+
fetchSpy.mockRestore();
81115
});
82116

83-
it('loads instance metadata from /api/v1 without trailing slash', async () => {
84-
let slashlessMetadataRequests = 0;
85-
let trailingSlashMetadataRequests = 0;
86-
87-
server.use(
88-
http.get('/api/v1', () => {
89-
slashlessMetadataRequests += 1;
90-
91-
return HttpResponse.json({
92-
success: true,
93-
data: {
94-
api: {
95-
name: 'html2rss-web API',
96-
description: 'RESTful API for converting websites to RSS feeds',
97-
openapi_url: 'http://example.test/openapi.yaml',
98-
},
99-
instance: {
100-
feed_creation: {
101-
enabled: true,
102-
access_token_required: true,
103-
},
104-
featured_feeds: [],
105-
},
106-
},
107-
});
108-
}),
109-
http.get('/api/v1/', () => {
110-
trailingSlashMetadataRequests += 1;
111-
112-
return HttpResponse.text('', { status: 404 });
113-
})
114-
);
115-
116-
render(<App />);
117-
118-
await screen.findByLabelText('Page URL');
119-
120-
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument();
121-
expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument();
122-
expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1);
123-
expect(trailingSlashMetadataRequests).toBe(0);
124-
});
125-
126-
it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => {
127-
server.use(
128-
http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })),
129-
http.get('/api/v1/', () => HttpResponse.text('', { status: 404 }))
130-
);
131-
132-
render(<App />);
133-
134-
await screen.findByText('Instance metadata unavailable');
135-
136-
expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument();
137-
});
138-
139-
it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => {
140-
authenticate();
141-
117+
it('reopens token recovery when a saved token is rejected by structured auth metadata', async () => {
142118
server.use(
143119
http.post('/api/v1/feeds', async () =>
144-
HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 })
120+
HttpResponse.json(
121+
buildStructuredErrorResponse({
122+
code: 'UNAUTHORIZED',
123+
message: 'Authentication required',
124+
kind: 'auth',
125+
retryable: false,
126+
next_action: 'enter_token',
127+
retry_action: 'none',
128+
}),
129+
{ status: 401 }
130+
)
145131
)
146132
);
147133

148134
render(<App />);
149135

150-
await screen.findByLabelText('Page URL');
151136
await waitFor(() => {
152-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
137+
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
153138
});
154139

155140
fireEvent.input(screen.getByLabelText('Page URL'), {
@@ -160,7 +145,7 @@ describe('App contract', () => {
160145
await screen.findByText('Access token was rejected. Paste a valid token to continue.');
161146

162147
expect(screen.getByText('Enter access token')).toBeInTheDocument();
163-
expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument();
164-
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull();
148+
expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument();
149+
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
165150
});
166151
});

0 commit comments

Comments
 (0)