Skip to content

Commit cb16c81

Browse files
committed
refactor: harden demo contract and simplify ui states
1 parent 1bd9113 commit cb16c81

24 files changed

Lines changed: 423 additions & 184 deletions

app/api/v1/feeds/create_feed.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def feed_attributes(feed_data)
123123
typed_feed.to_h.merge(
124124
created_at: timestamp,
125125
updated_at: timestamp
126-
).slice(:id, :name, :url, :strategy, :public_url, :created_at, :updated_at)
126+
).slice(:id, :name, :url, :strategy, :feed_token, :public_url, :created_at, :updated_at)
127127
end
128128

129129
# Parses params with optional JSON body override.

app/api/v1/root_metadata.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
require 'uri'
4+
5+
require_relative '../../account_manager'
6+
7+
module Html2rss
8+
module Web
9+
module Api
10+
module V1
11+
##
12+
# Builds the public metadata payload for the API root endpoint.
13+
module RootMetadata
14+
class << self
15+
# @param router [Roda::RodaRequest]
16+
# @return [Hash{Symbol=>Object}]
17+
def build(router)
18+
{
19+
api: {
20+
name: 'html2rss-web API',
21+
description: 'RESTful API for converting websites to RSS feeds',
22+
openapi_url: "#{router.base_url}/api/v1/openapi.yaml"
23+
},
24+
demo: demo_payload
25+
}
26+
end
27+
28+
private
29+
30+
# @return [Hash{Symbol=>Object}]
31+
def demo_payload
32+
account = AccountManager.get_account_by_username('demo')
33+
return { enabled: false, sources: [] } unless account
34+
35+
{
36+
enabled: true,
37+
token: account[:token],
38+
strategy: 'ssrf_filter',
39+
sources: Array(account[:allowed_urls]).map.with_index do |url, index|
40+
{ id: demo_source_id(url, index), url: url }
41+
end
42+
}
43+
end
44+
45+
# @param url [String]
46+
# @param index [Integer]
47+
# @return [String]
48+
def demo_source_id(url, index)
49+
parts = demo_source_parts(url)
50+
return parts.join('-').gsub(/[^a-zA-Z0-9]+/, '-').downcase if parts.any?
51+
52+
fallback_demo_source_id(index)
53+
rescue URI::InvalidURIError
54+
fallback_demo_source_id(index)
55+
end
56+
57+
# @param url [String]
58+
# @return [Array<String>]
59+
def demo_source_parts(url)
60+
uri = URI.parse(url)
61+
[uri.host.to_s.gsub(/^www\./, ''), first_path_segment(uri)].reject(&:empty?)
62+
end
63+
64+
# @param uri [URI::Generic]
65+
# @return [String]
66+
def first_path_segment(uri)
67+
uri.path.to_s.split('/').find { |segment| !segment.empty? }.to_s
68+
end
69+
70+
# @param index [Integer]
71+
# @return [String]
72+
def fallback_demo_source_id(index)
73+
"demo-#{index + 1}"
74+
end
75+
end
76+
end
77+
end
78+
end
79+
end
80+
end

app/auto_source.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def build_feed_data(name, url, token_data, strategy, identifiers)
9797
url: url,
9898
username: token_data[:username],
9999
strategy: strategy,
100+
feed_token: identifiers[:feed_token],
100101
public_url: public_url
101102
)
102103
end

app/boundary_models.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def to_h
1616

1717
##
1818
# Feed metadata contract used between feed services and API responses.
19-
FeedMetadata = Data.define(:id, :name, :url, :username, :strategy, :public_url) do
19+
FeedMetadata = Data.define(:id, :name, :url, :username, :strategy, :feed_token, :public_url) do
2020
# @return [Hash{Symbol=>Object}]
2121
def to_h
2222
{
@@ -25,6 +25,7 @@ def to_h
2525
url: url,
2626
username: username,
2727
strategy: strategy,
28+
feed_token: feed_token,
2829
public_url: public_url
2930
}
3031
end

app/routes/api_v1.rb

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative '../api/v1/root_metadata'
34
require_relative '../request_target'
45

56
module Html2rss
@@ -127,14 +128,9 @@ def mount_root(router)
127128
# @option return [String] :name API display name.
128129
# @option return [String] :description human-readable API description.
129130
# @option return [String] :openapi_url absolute OpenAPI spec URL.
131+
# @option return [Hash] :demo public demo metadata block.
130132
def api_root_payload(router)
131-
{
132-
api: {
133-
name: 'html2rss-web API',
134-
description: 'RESTful API for converting websites to RSS feeds',
135-
openapi_url: "#{router.base_url}/api/v1/openapi.yaml"
136-
}
137-
}
133+
Api::V1::RootMetadata.build(router)
138134
end
139135

140136
# @param result [Hash, String]

docs/api/v1/openapi.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,33 @@ paths:
4141
- description
4242
- openapi_url
4343
type: object
44+
demo:
45+
properties:
46+
enabled:
47+
type: boolean
48+
sources:
49+
items:
50+
properties:
51+
id:
52+
type: string
53+
url:
54+
type: string
55+
required:
56+
- id
57+
- url
58+
type: object
59+
type: array
60+
strategy:
61+
type: string
62+
token:
63+
type: string
64+
required:
65+
- enabled
66+
- sources
67+
type: object
4468
required:
4569
- api
70+
- demo
4671
type: object
4772
success:
4873
type: boolean
@@ -90,6 +115,8 @@ paths:
90115
properties:
91116
created_at:
92117
type: string
118+
feed_token:
119+
type: string
93120
id:
94121
type: string
95122
name:
@@ -107,6 +134,7 @@ paths:
107134
- name
108135
- url
109136
- strategy
137+
- feed_token
110138
- public_url
111139
- created_at
112140
- updated_at

frontend/index.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<meta name="robots" content="noindex, nofollow" />
7-
<meta name="description" content="Convert websites to RSS feeds instantly with html2rss-web." />
7+
<meta
8+
name="description"
9+
content="html2rss converts fixed demo pages or operator-submitted URLs into feed endpoints."
10+
/>
811
<link
912
rel="icon"
1013
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='10' fill='%230b0c0d'/%3E%3Cpath d='M14 20h36v6H14zm0 12h25v6H14zm0 12h16v6H14z' fill='%23f3f4f6'/%3E%3C/svg%3E"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('App contract', () => {
2626
return HttpResponse.json(
2727
buildFeedResponse({
2828
url: body.url,
29+
feed_token: 'generated-token',
2930
public_url: '/api/v1/feeds/generated-token',
3031
})
3132
);

frontend/src/__tests__/App.test.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,21 @@ vi.mock('../hooks/useFeedConversion', () => ({
1212
useFeedConversion: vi.fn(),
1313
}));
1414

15+
vi.mock('../hooks/useApiMetadata', () => ({
16+
useApiMetadata: vi.fn(),
17+
}));
18+
1519
vi.mock('../hooks/useStrategies', () => ({
1620
useStrategies: vi.fn(),
1721
}));
1822

1923
import { useAuth } from '../hooks/useAuth';
24+
import { useApiMetadata } from '../hooks/useApiMetadata';
2025
import { useFeedConversion } from '../hooks/useFeedConversion';
2126
import { useStrategies } from '../hooks/useStrategies';
2227

2328
const mockUseAuth = useAuth as any;
29+
const mockUseApiMetadata = useApiMetadata as any;
2430
const mockUseFeedConversion = useFeedConversion as any;
2531
const mockUseStrategies = useStrategies as any;
2632

@@ -36,8 +42,22 @@ describe('App', () => {
3642
mockUseAuth.mockReturnValue({
3743
isAuthenticated: false,
3844
username: null,
45+
token: null,
3946
login: mockLogin,
4047
logout: mockLogout,
48+
isLoading: false,
49+
error: null,
50+
});
51+
52+
mockUseApiMetadata.mockReturnValue({
53+
demo: {
54+
enabled: true,
55+
token: 'CHANGE_ME_DEMO_TOKEN',
56+
strategy: 'ssrf_filter',
57+
sources: [{ id: 'github-com-trending', url: 'https://github.com/trending' }],
58+
},
59+
isLoading: false,
60+
error: null,
4161
});
4262

4363
mockUseFeedConversion.mockReturnValue({
@@ -58,8 +78,8 @@ describe('App', () => {
5878
it('should render demo section when not authenticated', () => {
5979
render(<App />);
6080

61-
expect(screen.getByText('Convert website to RSS')).toBeInTheDocument();
62-
expect(screen.getByText('Try a demo source instantly. Sign in to convert your own URLs.')).toBeInTheDocument();
81+
expect(screen.getByText('Run a demo source')).toBeInTheDocument();
82+
expect(screen.getByText('Run a known source. Sign in to submit your own URL.')).toBeInTheDocument();
6383
expect(screen.getByText('Run demo')).toBeInTheDocument();
6484
});
6585

@@ -70,6 +90,8 @@ describe('App', () => {
7090
token: 'test-token',
7191
login: mockLogin,
7292
logout: mockLogout,
93+
isLoading: false,
94+
error: null,
7395
});
7496

7597
mockUseStrategies.mockReturnValue({
@@ -95,6 +117,8 @@ describe('App', () => {
95117
token: 'test-token',
96118
login: mockLogin,
97119
logout: mockLogout,
120+
isLoading: false,
121+
error: null,
98122
});
99123

100124
mockUseStrategies.mockReturnValue({
@@ -119,6 +143,8 @@ describe('App', () => {
119143
token: 'test-token',
120144
login: mockLogin,
121145
logout: mockLogout,
146+
isLoading: false,
147+
error: null,
122148
});
123149

124150
mockUseStrategies.mockReturnValue({
@@ -155,6 +181,7 @@ describe('App', () => {
155181
url: 'https://example.com/articles',
156182
username: 'guest',
157183
strategy: 'ssrf_filter',
184+
feed_token: 'example-token',
158185
public_url: '/api/v1/feeds/example-token',
159186
},
160187
error: null,

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe('ResultDisplay', () => {
1111
url: 'https://example.com',
1212
username: 'testuser',
1313
strategy: 'ssrf_filter',
14+
feed_token: 'test-feed-token',
1415
public_url: 'https://example.com/feed.xml',
1516
};
1617

@@ -22,16 +23,23 @@ describe('ResultDisplay', () => {
2223
} as Response);
2324
});
2425

25-
it('should render success message and feed details', () => {
26+
it('should render guest result with copy action only', () => {
2627
render(<ResultDisplay result={mockResult} onClose={mockOnClose} />);
2728

2829
expect(screen.getByText('Feed created')).toBeInTheDocument();
2930
expect(screen.getByText('Test Feed')).toBeInTheDocument();
3031
expect(screen.queryByText('Copy the URL or open it in your RSS reader.')).not.toBeInTheDocument();
3132
expect(screen.getByRole('button', { name: 'Copy URL' })).toBeInTheDocument();
33+
expect(screen.queryByRole('link', { name: 'Subscribe in reader' })).not.toBeInTheDocument();
34+
expect(screen.queryByText('Opens your default RSS reader if configured.')).not.toBeInTheDocument();
35+
expect(screen.queryByRole('link', { name: 'Open feed in new tab' })).not.toBeInTheDocument();
36+
});
37+
38+
it('should render authenticated result actions', () => {
39+
render(<ResultDisplay result={mockResult} onClose={mockOnClose} isAuthenticated username="testuser" />);
40+
3241
expect(screen.getByRole('link', { name: 'Subscribe in reader' })).toBeInTheDocument();
3342
expect(screen.getByText('Opens your default RSS reader if configured.')).toBeInTheDocument();
34-
expect(screen.queryByRole('link', { name: 'Open feed in new tab' })).not.toBeInTheDocument();
3543
});
3644

3745
it('should call onClose when convert-another button is clicked', () => {
@@ -58,7 +66,7 @@ describe('ResultDisplay', () => {
5866
const onRequestSignIn = vi.fn();
5967
render(<ResultDisplay result={mockResult} onClose={mockOnClose} onRequestSignIn={onRequestSignIn} />);
6068

61-
expect(screen.getByText('Have credentials?')).toBeInTheDocument();
69+
expect(screen.getByText('Sign in to convert another URL.')).toBeInTheDocument();
6270
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
6371
expect(onRequestSignIn).toHaveBeenCalled();
6472
});

0 commit comments

Comments
 (0)