Skip to content

Commit 3ea0aa2

Browse files
committed
refactor(frontend): consume structured metadata and remove flow heuristics
1 parent 0b59322 commit 3ea0aa2

16 files changed

Lines changed: 1553 additions & 2057 deletions

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

Lines changed: 50 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeEach } 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', () => {
@@ -11,20 +11,15 @@ describe('App contract', () => {
1111
globalThis.history.replaceState({}, '', 'http://localhost:3000/create');
1212
globalThis.localStorage.clear();
1313
globalThis.sessionStorage.clear();
14-
});
15-
16-
const authenticate = () => {
1714
globalThis.sessionStorage.setItem('html2rss_access_token', token);
18-
};
19-
20-
it('shows feed result when API responds with success', async () => {
21-
authenticate();
15+
});
2216

17+
it('shows feed result when the API returns structured create and status payloads', async () => {
2318
server.use(
2419
http.post('/api/v1/feeds', async ({ request }) => {
25-
const body = (await request.json()) as { url: string; strategy: string };
20+
const body = (await request.json()) as { url: string };
2621

27-
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' });
22+
expect(body).toEqual({ url: 'https://example.com/articles' });
2823
expect(request.headers.get('authorization')).toBe(`Bearer ${token}`);
2924

3025
return HttpResponse.json(
@@ -33,9 +28,29 @@ describe('App contract', () => {
3328
feed_token: 'generated-token',
3429
public_url: '/api/v1/feeds/generated-token',
3530
json_public_url: '/api/v1/feeds/generated-token.json',
36-
})
31+
conversion: {
32+
readiness_phase: 'link_created',
33+
preview_status: 'pending',
34+
warnings: [],
35+
},
36+
}),
37+
{ status: 201 }
3738
);
3839
}),
40+
http.get('/api/v1/feeds/generated-token/status', () =>
41+
HttpResponse.json(
42+
buildFeedResponse({
43+
feed_token: 'generated-token',
44+
public_url: '/api/v1/feeds/generated-token',
45+
json_public_url: '/api/v1/feeds/generated-token.json',
46+
conversion: {
47+
readiness_phase: 'feed_ready',
48+
preview_status: 'ready',
49+
warnings: [],
50+
},
51+
})
52+
)
53+
),
3954
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
4055
expect(request.headers.get('accept')).toBe('application/feed+json');
4156

@@ -60,101 +75,47 @@ describe('App contract', () => {
6075
render(<App />);
6176

6277
await waitFor(() => {
63-
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeEnabled();
78+
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
6479
});
6580
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
6681

6782
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
6883
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
69-
7084
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
7185

72-
await waitFor(
73-
() => {
74-
expect(screen.getByText('Feed created')).toBeInTheDocument();
75-
expect(screen.getByText('Example Feed')).toBeInTheDocument();
76-
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'warming');
77-
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
78-
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
79-
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
80-
expect(screen.getByText('Preview')).toBeInTheDocument();
81-
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
82-
},
83-
{ timeout: 3000 }
84-
);
85-
});
86-
87-
it('loads instance metadata from /api/v1 without trailing slash', async () => {
88-
let slashlessMetadataRequests = 0;
89-
let trailingSlashMetadataRequests = 0;
90-
91-
server.use(
92-
http.get('/api/v1', () => {
93-
slashlessMetadataRequests += 1;
94-
95-
return HttpResponse.json({
96-
success: true,
97-
data: {
98-
api: {
99-
name: 'html2rss-web API',
100-
description: 'RESTful API for converting websites to RSS feeds',
101-
openapi_url: 'http://example.test/openapi.yaml',
102-
},
103-
instance: {
104-
feed_creation: {
105-
enabled: true,
106-
access_token_required: true,
107-
},
108-
featured_feeds: [],
109-
},
110-
},
111-
});
112-
}),
113-
http.get('/api/v1/', () => {
114-
trailingSlashMetadataRequests += 1;
115-
116-
return HttpResponse.text('', { status: 404 });
117-
})
118-
);
119-
120-
render(<App />);
121-
122-
await screen.findByLabelText('Page URL');
123-
124-
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument();
125-
expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument();
126-
expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1);
127-
expect(trailingSlashMetadataRequests).toBe(0);
128-
});
129-
130-
it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => {
131-
server.use(
132-
http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })),
133-
http.get('/api/v1/', () => HttpResponse.text('', { status: 404 }))
134-
);
135-
136-
render(<App />);
137-
138-
await screen.findByText('Instance metadata unavailable');
139-
140-
expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument();
86+
await waitFor(() => {
87+
expect(screen.getByText('Feed ready')).toBeInTheDocument();
88+
expect(screen.getByText('Example Feed')).toBeInTheDocument();
89+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready');
90+
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
91+
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
92+
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
93+
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
94+
});
14195
});
14296

143-
it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => {
144-
authenticate();
145-
97+
it('reopens token recovery when a saved token is rejected by structured auth metadata', async () => {
14698
server.use(
14799
http.post('/api/v1/feeds', async () =>
148-
HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 })
100+
HttpResponse.json(
101+
buildStructuredErrorResponse({
102+
code: 'UNAUTHORIZED',
103+
message: 'Authentication required',
104+
kind: 'auth',
105+
retryable: false,
106+
next_action: 'enter_token',
107+
retry_action: 'none',
108+
}),
109+
{ status: 401 }
110+
)
149111
)
150112
);
151113

152114
render(<App />);
153115

154116
await waitFor(() => {
155-
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeEnabled();
117+
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
156118
});
157-
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
158119

159120
fireEvent.input(screen.getByLabelText('Page URL'), {
160121
target: { value: 'https://example.com/articles' },

0 commit comments

Comments
 (0)