Skip to content

Commit 9a91435

Browse files
authored
test(web): harden feed conversion retry and race scenarios (#918)
Summary - port retry/race hardening tests for feed conversion behavior - strengthen API fallback assertions in spec/html2rss/web/api/v1_spec.rb - tighten frontend conversion hook retry/error path tests Validation - make ready - make dev smoke boot (startup and shutdown successful)
1 parent ed2b3e9 commit 9a91435

10 files changed

Lines changed: 443 additions & 105 deletions

File tree

app/web/config/environment_validator.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def validate_build_metadata!
100100

101101
log_missing_build_metadata!
102102
warn_lines(*missing_build_metadata_warning_lines)
103-
exit 1
103+
nil
104104
end
105105

106106
def validate_account_configuration!
@@ -154,15 +154,16 @@ def build_metadata_values
154154
def log_missing_build_metadata!
155155
SecurityLogger.log_config_validation_failure(
156156
'build_metadata',
157-
'Missing BUILD_TAG or GIT_SHA'
157+
'Missing BUILD_TAG or GIT_SHA',
158+
severity: :warn
158159
)
159160
end
160161

161162
# @return [Array<String>]
162163
def missing_build_metadata_warning_lines
163164
[
164-
'CRITICAL: Missing build metadata for production deployment!',
165-
'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.'
165+
'WARNING: Missing build metadata for production deployment.',
166+
'Set BUILD_TAG and GIT_SHA to improve release traceability.'
166167
]
167168
end
168169
end

frontend/package-lock.json

Lines changed: 53 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"test:e2e": "playwright test"
2222
},
2323
"dependencies": {
24-
"@hey-api/client-fetch": "^0.13.1",
2524
"preact": "^10.27.2",
2625
"tslib": "^2.8.1"
2726
},

frontend/src/__tests__/useFeedConversion.test.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,128 @@ describe('useFeedConversion', () => {
412412
});
413413
});
414414

415+
it('does not auto-retry browserless for unauthorized faraday failures', async () => {
416+
fetchMock.mockResolvedValueOnce(
417+
new Response(
418+
JSON.stringify({
419+
success: false,
420+
error: { message: 'Unauthorized' },
421+
}),
422+
{
423+
status: 401,
424+
headers: { 'Content-Type': 'application/json' },
425+
}
426+
)
427+
);
428+
429+
const { result } = renderHook(() => useFeedConversion());
430+
431+
await act(async () => {
432+
await expect(
433+
result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken')
434+
).rejects.toThrow('Unauthorized');
435+
});
436+
437+
expect(fetchMock).toHaveBeenCalledTimes(1);
438+
expect(result.current.result).toBeNull();
439+
expect(result.current.error).toBe('Unauthorized');
440+
});
441+
442+
it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => {
443+
fetchMock.mockResolvedValueOnce(
444+
new Response(
445+
JSON.stringify({
446+
success: false,
447+
error: { code: 'BAD_REQUEST', message: 'Input rejected' },
448+
}),
449+
{
450+
status: 400,
451+
headers: { 'Content-Type': 'application/json' },
452+
}
453+
)
454+
);
455+
456+
const { result } = renderHook(() => useFeedConversion());
457+
458+
await act(async () => {
459+
await expect(
460+
result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken')
461+
).rejects.toThrow('Input rejected');
462+
});
463+
464+
expect(fetchMock).toHaveBeenCalledTimes(1);
465+
expect(result.current.result).toBeNull();
466+
expect(result.current.error).toBe('Input rejected');
467+
});
468+
469+
it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => {
470+
const createdFeed = {
471+
id: 'test-id',
472+
name: 'Test Feed',
473+
url: 'https://example.com/articles',
474+
strategy: 'browserless',
475+
feed_token: 'test-token',
476+
public_url: 'https://example.com/feed',
477+
json_public_url: 'https://example.com/feed.json',
478+
created_at: '2024-01-01T00:00:00Z',
479+
updated_at: '2024-01-01T00:00:00Z',
480+
};
481+
482+
fetchMock
483+
.mockResolvedValueOnce(
484+
new Response(
485+
JSON.stringify({
486+
success: false,
487+
error: {
488+
code: 'INTERNAL_SERVER_ERROR',
489+
message: 'Failed to fetch https://example.com/articles',
490+
},
491+
}),
492+
{
493+
status: 500,
494+
headers: { 'Content-Type': 'application/json' },
495+
}
496+
)
497+
)
498+
.mockResolvedValueOnce(
499+
new Response(
500+
JSON.stringify({
501+
success: true,
502+
data: {
503+
feed: createdFeed,
504+
},
505+
}),
506+
{
507+
status: 201,
508+
headers: { 'Content-Type': 'application/json' },
509+
}
510+
)
511+
)
512+
.mockResolvedValueOnce(
513+
new Response(JSON.stringify({ items: [] }), {
514+
status: 200,
515+
headers: { 'Content-Type': 'application/feed+json' },
516+
})
517+
);
518+
519+
const { result } = renderHook(() => useFeedConversion());
520+
521+
await act(async () => {
522+
await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken');
523+
});
524+
525+
const retryRequest = fetchMock.mock.calls[1]?.[0] as Request;
526+
expect(await retryRequest.clone().json()).toEqual({
527+
url: 'https://example.com/articles',
528+
strategy: 'browserless',
529+
});
530+
expect(result.current.result?.retry).toEqual({
531+
automatic: true,
532+
from: 'faraday',
533+
to: 'browserless',
534+
});
535+
});
536+
415537
it('does not offer a duplicate manual retry after automatic fallback also fails', async () => {
416538
fetchMock
417539
.mockResolvedValueOnce(
@@ -459,4 +581,125 @@ describe('useFeedConversion', () => {
459581
'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed'
460582
);
461583
});
584+
585+
it('ignores stale preview updates from an earlier conversion request', async () => {
586+
const feedA = {
587+
id: 'feed-a-id',
588+
name: 'Feed A',
589+
url: 'https://example.com/a',
590+
strategy: 'faraday',
591+
feed_token: 'feed-a-token',
592+
public_url: 'https://example.com/feed-a',
593+
json_public_url: 'https://example.com/feed-a.json',
594+
created_at: '2024-01-01T00:00:00Z',
595+
updated_at: '2024-01-01T00:00:00Z',
596+
};
597+
const feedB = {
598+
id: 'feed-b-id',
599+
name: 'Feed B',
600+
url: 'https://example.com/b',
601+
strategy: 'faraday',
602+
feed_token: 'feed-b-token',
603+
public_url: 'https://example.com/feed-b',
604+
json_public_url: 'https://example.com/feed-b.json',
605+
created_at: '2024-01-01T00:00:00Z',
606+
updated_at: '2024-01-01T00:00:00Z',
607+
};
608+
609+
let resolvePreviewA: ((value: Response) => void) | null = null;
610+
const previewAPromise = new Promise<Response>((resolve) => {
611+
resolvePreviewA = resolve;
612+
});
613+
let resolvePreviewB: ((value: Response) => void) | null = null;
614+
const previewBPromise = new Promise<Response>((resolve) => {
615+
resolvePreviewB = resolve;
616+
});
617+
618+
fetchMock
619+
.mockResolvedValueOnce(
620+
new Response(
621+
JSON.stringify({
622+
success: true,
623+
data: { feed: feedA },
624+
}),
625+
{
626+
status: 201,
627+
headers: { 'Content-Type': 'application/json' },
628+
}
629+
)
630+
)
631+
.mockReturnValueOnce(previewAPromise as Promise<Response>)
632+
.mockResolvedValueOnce(
633+
new Response(
634+
JSON.stringify({
635+
success: true,
636+
data: { feed: feedB },
637+
}),
638+
{
639+
status: 201,
640+
headers: { 'Content-Type': 'application/json' },
641+
}
642+
)
643+
)
644+
.mockReturnValueOnce(previewBPromise as Promise<Response>);
645+
646+
const { result } = renderHook(() => useFeedConversion());
647+
648+
await act(async () => {
649+
await result.current.convertFeed('https://example.com/a', 'faraday', 'testtoken');
650+
});
651+
await act(async () => {
652+
await result.current.convertFeed('https://example.com/b', 'faraday', 'testtoken');
653+
});
654+
655+
expect(result.current.result?.feed.feed_token).toBe('feed-b-token');
656+
657+
resolvePreviewB?.(
658+
new Response(
659+
JSON.stringify({
660+
items: [
661+
{
662+
title: 'Preview B',
663+
content_text: 'Current preview item',
664+
url: 'https://example.com/b/item',
665+
date_published: '2024-01-02T00:00:00Z',
666+
},
667+
],
668+
}),
669+
{
670+
status: 200,
671+
headers: { 'Content-Type': 'application/feed+json' },
672+
}
673+
)
674+
);
675+
676+
await waitFor(() => {
677+
expect(result.current.result?.feed.feed_token).toBe('feed-b-token');
678+
expect(result.current.result?.preview.items[0]?.title).toBe('Preview B');
679+
});
680+
681+
resolvePreviewA?.(
682+
new Response(
683+
JSON.stringify({
684+
items: [
685+
{
686+
title: 'Preview A',
687+
content_text: 'Stale preview item',
688+
url: 'https://example.com/a/item',
689+
date_published: '2024-01-03T00:00:00Z',
690+
},
691+
],
692+
}),
693+
{
694+
status: 200,
695+
headers: { 'Content-Type': 'application/feed+json' },
696+
}
697+
)
698+
);
699+
700+
await waitFor(() => {
701+
expect(result.current.result?.feed.feed_token).toBe('feed-b-token');
702+
expect(result.current.result?.preview.items[0]?.title).toBe('Preview B');
703+
});
704+
});
462705
});

frontend/src/api/generated/types.gen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,11 @@ export type RenderFeedByTokenData = {
116116

117117
export type RenderFeedByTokenErrors = {
118118
/**
119-
* returns JSON Feed-shaped errors when requested by json extension
119+
* returns unauthorized for invalid tokens
120120
*/
121121
401: string;
122122
/**
123-
* returns JSON Feed-shaped forbidden errors when requested through Accept
123+
* returns forbidden when auto source is disabled
124124
*/
125125
403: string;
126126
/**

0 commit comments

Comments
 (0)