Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gem 'puma', require: false

group :development do
gem 'byebug'
gem 'irb', require: false
gem 'rake', require: false
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/App.contract.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('App contract', () => {
})
);
}),
http.get('/api/v1/feeds/generated-token', ({ request }) => {
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
expect(request.headers.get('accept')).toBe('application/feed+json');

return HttpResponse.json(
Expand Down
44 changes: 32 additions & 12 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,22 @@ describe('App', () => {
});

it('renders the result panel when a feed is available', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ items: [] }),
} as Response);

mockUseFeedConversion.mockReturnValue({
isConverting: false,
result: {
id: 'feed-123',
name: 'Example Feed',
url: 'https://example.com/articles',
strategy: 'faraday',
feed_token: 'example-token',
public_url: '/api/v1/feeds/example-token',
json_public_url: '/api/v1/feeds/example-token.json',
feed: {
id: 'feed-123',
name: 'Example Feed',
url: 'https://example.com/articles',
strategy: 'faraday',
feed_token: 'example-token',
public_url: '/api/v1/feeds/example-token',
json_public_url: '/api/v1/feeds/example-token.json',
},
preview: {
items: [],
error: 'Preview unavailable right now.',
},
},
error: null,
convertFeed: mockConvertFeed,
Expand All @@ -233,6 +234,7 @@ describe('App', () => {
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
expect(screen.getByText('Example Feed')).toBeInTheDocument();
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
});

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

it('shows an explicit loading notice while feed creation is still resolving preview state', () => {
mockUseFeedConversion.mockReturnValue({
isConverting: true,
result: null,
error: null,
convertFeed: mockConvertFeed,
clearError: mockClearConversionError,
clearResult: mockClearResult,
});

render(<App />);

expect(screen.getByText('Preparing feed')).toBeInTheDocument();
expect(
screen.getByText('Creating the feed and loading its preview before showing the result.')
).toBeInTheDocument();
});

it('clears stored token from instance info', () => {
mockUseAccessToken.mockReturnValue({
token: 'saved-token',
Expand Down
77 changes: 38 additions & 39 deletions frontend/src/__tests__/ResultDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,41 @@ import { ResultDisplay } from '../components/ResultDisplay';
describe('ResultDisplay', () => {
const mockOnCreateAnother = vi.fn();
const mockResult = {
id: 'test-id',
name: 'Test Feed',
url: 'https://example.com',
strategy: 'faraday',
feed_token: 'test-feed-token',
public_url: 'https://example.com/feed.xml',
json_public_url: 'https://example.com/feed.json',
feed: {
id: 'test-id',
name: 'Test Feed',
url: 'https://example.com',
strategy: 'faraday',
feed_token: 'test-feed-token',
public_url: 'https://example.com/feed.xml',
json_public_url: 'https://example.com/feed.json',
},
preview: {
items: [
{
title: 'Item One',
excerpt: 'First preview item with markup.',
url: 'https://example.com/item-one',
publishedLabel: 'Jan 1, 2024',
},
{
title: '56 points by canpan 1 hour ago | hide | 18 comments',
excerpt: '',
publishedLabel: 'Jan 2, 2024',
},
{
title: 'Item Two',
excerpt: '',
url: 'https://example.com/item-two',
publishedLabel: 'Jan 3, 2024',
},
],
error: null,
},
};

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
items: [
{
title: 'Item One',
content_text: '<p>First preview item with <strong>markup</strong>.</p>',
url: 'https://example.com/item-one',
date_published: '2024-01-01T00:00:00Z',
},
{
content_text: '56 points by canpan 1 hour ago | hide | 18&nbsp;comments',
date_published: '2024-01-02T00:00:00Z',
},
{
content_text: '2. Item Two ( example.com )',
url: 'https://example.com/item-two',
date_published: '2024-01-03T00:00:00Z',
},
],
}),
} as Response);
});

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

it('surfaces preview fetch failures as a result-state message', async () => {
vi.mocked(window.fetch).mockResolvedValueOnce({
ok: false,
json: async () => ({}),
} as Response);

render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
it('surfaces preview failures as a result-state message', async () => {
render(
<ResultDisplay
result={{ ...mockResult, preview: { items: [], error: 'Preview unavailable right now.' } }}
onCreateAnother={mockOnCreateAnother}
/>
);

await waitFor(() => {
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import '@testing-library/jest-dom';
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
import { cleanup } from '@testing-library/preact';
import { server } from './mocks/server';

let server: typeof import('./mocks/server').server;

// Mock window and document for tests
Object.defineProperty(window, 'matchMedia', {
Expand Down Expand Up @@ -49,10 +50,16 @@ const session = createStorageMock();
Object.defineProperty(window, 'localStorage', {
value: local.api,
});
Object.defineProperty(globalThis, 'localStorage', {
value: local.api,
});

Object.defineProperty(window, 'sessionStorage', {
value: session.api,
});
Object.defineProperty(globalThis, 'sessionStorage', {
value: session.api,
});

beforeEach(() => {
local.store.clear();
Expand Down Expand Up @@ -80,7 +87,10 @@ Object.assign(navigator, {
Element.prototype.scrollIntoView = vi.fn();

// Wire up MSW in node environment
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeAll(async () => {
({ server } = await import('./mocks/server'));
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
cleanup();
Expand Down
49 changes: 46 additions & 3 deletions frontend/src/__tests__/useFeedConversion.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ describe('useFeedConversion contract', () => {
}),
{ status: 201 }
);
}),
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
expect(request.headers.get('accept')).toBe('application/feed+json');

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

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

expect(receivedAuthorization).toBe('Bearer test-token-123');
expect(result.current.error).toBeNull();
expect(result.current.result?.feed_token).toBe('generated-token');
expect(result.current.result?.public_url).toBe('/api/v1/feeds/generated-token');
expect(result.current.result?.json_public_url).toBe('/api/v1/feeds/generated-token.json');
expect(result.current.result?.feed.feed_token).toBe('generated-token');
expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token');
expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json');
expect(result.current.result?.preview.error).toBeNull();
expect(result.current.result?.preview.items).toHaveLength(1);
});

it('propagates API validation errors', async () => {
Expand Down Expand Up @@ -83,4 +99,31 @@ describe('useFeedConversion contract', () => {
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Invalid response format from feed creation API');
});

it('preserves the created feed when preview loading fails', async () => {
server.use(
http.post('/api/v1/feeds', async () =>
HttpResponse.json(
buildFeedResponse({
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', async () => new HttpResponse(null, { status: 502 }))
);

const { result } = renderHook(() => useFeedConversion());

await act(async () => {
await result.current.convertFeed('https://example.com/articles', 'faraday', 'token');
});

expect(result.current.error).toBeNull();
expect(result.current.result?.feed.feed_token).toBe('generated-token');
expect(result.current.result?.preview.items).toEqual([]);
expect(result.current.result?.preview.error).toBe('Preview unavailable right now.');
});
});
Loading
Loading