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
9 changes: 5 additions & 4 deletions app/web/config/environment_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def validate_build_metadata!

log_missing_build_metadata!
warn_lines(*missing_build_metadata_warning_lines)
exit 1
nil
end

def validate_account_configuration!
Expand Down Expand Up @@ -154,15 +154,16 @@ def build_metadata_values
def log_missing_build_metadata!
SecurityLogger.log_config_validation_failure(
'build_metadata',
'Missing BUILD_TAG or GIT_SHA'
'Missing BUILD_TAG or GIT_SHA',
severity: :warn
)
end

# @return [Array<String>]
def missing_build_metadata_warning_lines
[
'CRITICAL: Missing build metadata for production deployment!',
'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.'
'WARNING: Missing build metadata for production deployment.',
'Set BUILD_TAG and GIT_SHA to improve release traceability.'
]
end
end
Expand Down
67 changes: 53 additions & 14 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@hey-api/client-fetch": "^0.13.1",
"preact": "^10.27.2",
"tslib": "^2.8.1"
},
Expand Down
243 changes: 243 additions & 0 deletions frontend/src/__tests__/useFeedConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,128 @@ describe('useFeedConversion', () => {
});
});

it('does not auto-retry browserless for unauthorized faraday failures', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: { message: 'Unauthorized' },
}),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
)
);

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

await act(async () => {
await expect(
result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken')
).rejects.toThrow('Unauthorized');
});

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Unauthorized');
});

it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: { code: 'BAD_REQUEST', message: 'Input rejected' },
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
)
);

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

await act(async () => {
await expect(
result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken')
).rejects.toThrow('Input rejected');
});

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Input rejected');
});

it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => {
const createdFeed = {
id: 'test-id',
name: 'Test Feed',
url: 'https://example.com/articles',
strategy: 'browserless',
feed_token: 'test-token',
public_url: 'https://example.com/feed',
json_public_url: 'https://example.com/feed.json',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};

fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch https://example.com/articles',
},
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: true,
data: {
feed: createdFeed,
},
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
)
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'Content-Type': 'application/feed+json' },
})
);

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

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

const retryRequest = fetchMock.mock.calls[1]?.[0] as Request;
expect(await retryRequest.clone().json()).toEqual({
url: 'https://example.com/articles',
strategy: 'browserless',
});
expect(result.current.result?.retry).toEqual({
automatic: true,
from: 'faraday',
to: 'browserless',
});
});

it('does not offer a duplicate manual retry after automatic fallback also fails', async () => {
fetchMock
.mockResolvedValueOnce(
Expand Down Expand Up @@ -459,4 +581,125 @@ describe('useFeedConversion', () => {
'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed'
);
});

it('ignores stale preview updates from an earlier conversion request', async () => {
const feedA = {
id: 'feed-a-id',
name: 'Feed A',
url: 'https://example.com/a',
strategy: 'faraday',
feed_token: 'feed-a-token',
public_url: 'https://example.com/feed-a',
json_public_url: 'https://example.com/feed-a.json',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const feedB = {
id: 'feed-b-id',
name: 'Feed B',
url: 'https://example.com/b',
strategy: 'faraday',
feed_token: 'feed-b-token',
public_url: 'https://example.com/feed-b',
json_public_url: 'https://example.com/feed-b.json',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};

let resolvePreviewA: ((value: Response) => void) | null = null;
const previewAPromise = new Promise<Response>((resolve) => {
resolvePreviewA = resolve;
});
let resolvePreviewB: ((value: Response) => void) | null = null;
const previewBPromise = new Promise<Response>((resolve) => {
resolvePreviewB = resolve;
});

fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: true,
data: { feed: feedA },
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
)
)
.mockReturnValueOnce(previewAPromise as Promise<Response>)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: true,
data: { feed: feedB },
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
)
)
.mockReturnValueOnce(previewBPromise as Promise<Response>);

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

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

expect(result.current.result?.feed.feed_token).toBe('feed-b-token');

resolvePreviewB?.(
new Response(
JSON.stringify({
items: [
{
title: 'Preview B',
content_text: 'Current preview item',
url: 'https://example.com/b/item',
date_published: '2024-01-02T00:00:00Z',
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/feed+json' },
}
)
);

await waitFor(() => {
expect(result.current.result?.feed.feed_token).toBe('feed-b-token');
expect(result.current.result?.preview.items[0]?.title).toBe('Preview B');
});

resolvePreviewA?.(
new Response(
JSON.stringify({
items: [
{
title: 'Preview A',
content_text: 'Stale preview item',
url: 'https://example.com/a/item',
date_published: '2024-01-03T00:00:00Z',
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/feed+json' },
}
)
);

await waitFor(() => {
expect(result.current.result?.feed.feed_token).toBe('feed-b-token');
expect(result.current.result?.preview.items[0]?.title).toBe('Preview B');
});
});
});
4 changes: 2 additions & 2 deletions frontend/src/api/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ export type RenderFeedByTokenData = {

export type RenderFeedByTokenErrors = {
/**
* returns JSON Feed-shaped errors when requested by json extension
* returns unauthorized for invalid tokens
*/
401: string;
/**
* returns JSON Feed-shaped forbidden errors when requested through Accept
* returns forbidden when auto source is disabled
*/
403: string;
/**
Expand Down
Loading
Loading