Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ 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 expect(page.getByLabel('Utilities')).toBeVisible();
await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible();

await page.getByLabel('Page URL').fill('https://example.com/articles');
await page.getByRole('button', { name: 'Generate feed URL' }).click();
Expand All @@ -63,6 +64,7 @@ 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 expect(page.getByLabel('Utilities')).toBeVisible();
await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible();
});
});
151 changes: 68 additions & 83 deletions frontend/src/__tests__/App.contract.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, vi } 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', () => {
const token = 'contract-token';

const authenticate = () => {
globalThis.localStorage.setItem('html2rss_access_token', token);
};
beforeEach(() => {
globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create');
globalThis.localStorage.clear();
globalThis.sessionStorage.clear();
globalThis.sessionStorage.setItem('html2rss_access_token', token);
});

it('shows feed result when the API returns structured create payload and preview feed', async () => {
const nativeFetch = globalThis.fetch;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
if (String(input).endsWith('/api/v1/feeds/generated-token.json')) {
expect((init?.headers as Record<string, string> | undefined)?.Accept).toBe('application/feed+json');
return Promise.resolve(
new Response(
JSON.stringify({
items: [
{
title: 'Contract Item',
content_text: 'Contract preview excerpt.',
url: 'https://example.com/contract-item',
date_published: '2024-01-01T00:00:00Z',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/feed+json' } }
)
);
}

it('shows feed result when API responds with success', async () => {
authenticate();
return nativeFetch(input, init);
});

server.use(
http.post('/api/v1/feeds', async ({ request }) => {
Expand All @@ -27,10 +52,11 @@ describe('App contract', () => {
feed_token: 'generated-token',
public_url: '/api/v1/feeds/generated-token',
json_public_url: '/api/v1/feeds/generated-token.json',
})
}),
{ status: 201 }
);
}),
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
http.get('http://localhost:3000/api/v1/feeds/generated-token.json', ({ request }) => {
expect(request.headers.get('accept')).toBe('application/feed+json');

return HttpResponse.json(
Expand All @@ -48,108 +74,67 @@ describe('App contract', () => {
headers: { 'content-type': 'application/feed+json' },
}
);
}),
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
expect(request.headers.get('accept')).toBe('application/feed+json');

return HttpResponse.json({
items: [
{
title: 'Contract Item',
content_text: 'Contract preview excerpt.',
url: 'https://example.com/contract-item',
date_published: '2024-01-01T00:00:00Z',
},
],
});
})
);

render(<App />);

await screen.findByLabelText('Page URL');
await waitFor(() => {
expect(screen.getByRole('combobox')).toHaveValue('faraday');
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 ready')).toBeInTheDocument();
expect(screen.getByText('Example Feed')).toBeInTheDocument();
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'result');
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
'href',
'http://localhost:3000/api/v1/feeds/generated-token.json'
);
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
expect(screen.getByText('Preview')).toBeInTheDocument();
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
expect(screen.getByText('Contract Item')).toBeInTheDocument();
});
fetchSpy.mockRestore();
});

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(<App />);

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(<App />);

await screen.findByText('Instance metadata unavailable');

expect(screen.getByText('Invalid response format from API metadata')).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(<App />);

await screen.findByLabelText('Page URL');
await waitFor(() => {
expect(screen.getByRole('combobox')).toHaveValue('faraday');
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
});

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

expect(screen.getByText('Enter access token')).toBeInTheDocument();
expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument();
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull();
expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument();
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
});
});
Loading
Loading