Skip to content

Commit 6c4ca54

Browse files
committed
test: enforce zeitwerk compliance in ready gate
1 parent d5be3f4 commit 6c4ca54

5 files changed

Lines changed: 52 additions & 29 deletions

File tree

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue
5858
lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found
5959
@echo "Running RuboCop linting..."
6060
bundle exec rubocop
61+
@echo "Running Zeitwerk eager-load check..."
62+
bundle exec rake zeitwerk:verify
6163
@echo "Running YARD public-method docs check..."
6264
bundle exec rake yard:verify_public_docs
6365
@echo "Ruby linting complete!"

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ make openapi
6666

6767
Dev URLs: Ruby app at `http://localhost:4000`, frontend dev server at `http://localhost:4001`.
6868

69+
Backend code under the `Html2rss::Web` namespace now lives under `app/web/**`, so Zeitwerk can mirror constant paths directly instead of relying on directory-specific namespace wiring.
70+
`make ready` also runs `rake zeitwerk:verify`, which eager-loads the app and fails on loader drift early.
71+
6972
## Make Targets
7073

7174
| Command | Purpose |

Rakefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,14 @@ namespace :yard do
139139
puts 'YARD public method documentation check passed.'
140140
end
141141
end
142+
143+
namespace :zeitwerk do
144+
desc 'Fail when Zeitwerk cannot eager load the app tree cleanly'
145+
task :verify do
146+
ENV['RACK_ENV'] ||= 'test'
147+
require_relative 'app'
148+
149+
Html2rss::Web::Boot.eager_load!
150+
puts 'Zeitwerk eager load check passed.'
151+
end
152+
end

spec/html2rss/web/app_integration_spec.rb

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
require 'securerandom'
88
require_relative '../../../app'
99

10-
RSpec.describe Html2rss::Web::App do # rubocop:disable RSpec/MultipleMemoizedHelpers
10+
RSpec.describe Html2rss::Web::App, :aggregate_failures do # rubocop:disable RSpec/MultipleMemoizedHelpers
1111
include Rack::Test::Methods
1212

1313
let(:app) { described_class.freeze.app }
@@ -75,7 +75,7 @@
7575
end
7676

7777
describe 'GET /api/v1/feeds/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers
78-
it 'returns unauthorized for invalid tokens', :aggregate_failures do
78+
it 'returns unauthorized for invalid tokens' do
7979
allow(Html2rss::Web::FeedToken).to receive(:decode).and_return(nil)
8080

8181
get '/api/v1/feeds/invalid-token', {}, { 'HTTP_ACCEPT' => 'application/xml' }
@@ -85,7 +85,7 @@
8585
expect(last_response.body).to include('Invalid token')
8686
end
8787

88-
it 'renders the XML feed with cache headers', :aggregate_failures do
88+
it 'renders the XML feed with cache headers' do
8989
get "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_HOST' => 'localhost:3000', 'HTTP_ACCEPT' => 'application/xml' }
9090

9191
expect(last_response.status).to eq(200)
@@ -96,7 +96,7 @@
9696
expect(last_response.body).to eq('<rss version="2.0"></rss>')
9797
end
9898

99-
it 'accepts URL-escaped public feed tokens', :aggregate_failures do
99+
it 'accepts URL-escaped public feed tokens' do
100100
padded_feed_token = 'signed-public-token='
101101
encoded_padded_feed_token = CGI.escape(padded_feed_token)
102102

@@ -108,53 +108,53 @@
108108
expect(last_response.headers['Content-Type']).to eq('application/xml')
109109
end
110110

111-
it 'renders the JSON feed when requested by extension', :aggregate_failures do
111+
it 'renders the JSON feed when requested by extension' do
112112
get "/api/v1/feeds/#{feed_token}.json"
113113

114114
expect(last_response.status).to eq(200)
115115
expect(last_response.headers['Content-Type']).to eq('application/feed+json')
116116
end
117117

118-
it 'renders the JSON feed when requested through Accept', :aggregate_failures do
118+
it 'renders the JSON feed when requested through Accept' do
119119
get "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' }
120120
expect([last_response.status, last_response.headers['Content-Type']]).to eq([200, 'application/feed+json'])
121121
expect(last_response.headers['Cache-Control']).to include('max-age=600')
122122
expect(last_response.headers['Vary']).to include('Accept')
123123
end
124124

125-
it 'prefers the path extension over Accept negotiation', :aggregate_failures do
125+
it 'prefers the path extension over Accept negotiation' do
126126
header 'Accept', 'application/feed+json'
127127
get "/api/v1/feeds/#{feed_token}.xml"
128128

129129
expect(last_response.status).to eq(200)
130130
expect(last_response.headers['Content-Type']).to eq('application/xml')
131131
end
132132

133-
it 'honors Accept quality values for feed negotiation', :aggregate_failures do
133+
it 'honors Accept quality values for feed negotiation' do
134134
header 'Accept', 'application/xml;q=1.0, application/feed+json;q=0.2'
135135
get "/api/v1/feeds/#{feed_token}"
136136

137137
expect(last_response.status).to eq(200)
138138
expect(last_response.headers['Content-Type']).to eq('application/xml')
139139
end
140140

141-
it 'treats wildcard Accept as rss unless json is more specific', :aggregate_failures do
141+
it 'treats wildcard Accept as rss unless json is more specific' do
142142
header 'Accept', '*/*'
143143
get "/api/v1/feeds/#{feed_token}"
144144

145145
expect(last_response.status).to eq(200)
146146
expect(last_response.headers['Content-Type']).to eq('application/xml')
147147
end
148148

149-
it 'ignores q=0 json feed media types during negotiation', :aggregate_failures do
149+
it 'ignores q=0 json feed media types during negotiation' do
150150
header 'Accept', 'application/feed+json;q=0, application/xml;q=0.4'
151151
get "/api/v1/feeds/#{feed_token}"
152152

153153
expect(last_response.status).to eq(200)
154154
expect(last_response.headers['Content-Type']).to eq('application/xml')
155155
end
156156

157-
it 'serves HEAD requests for token feeds with negotiated headers only', :aggregate_failures do
157+
it 'serves HEAD requests for token feeds with negotiated headers only' do
158158
head "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' }
159159

160160
expect(last_response.status).to eq(200)
@@ -163,7 +163,7 @@
163163
expect(last_response.body).to eq('')
164164
end
165165

166-
it 'ignores query param strategy overrides', :aggregate_failures do
166+
it 'ignores query param strategy overrides' do
167167
header 'Accept', 'application/xml'
168168
get "/api/v1/feeds/#{feed_token}", { 'strategy' => 'invalid' }
169169

@@ -227,7 +227,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:)
227227
context 'without authentication' do # rubocop:disable RSpec/MultipleMemoizedHelpers
228228
before { allow(Html2rss::Web::Auth).to receive(:authenticate).and_return(nil) }
229229

230-
it 'requires authentication', :aggregate_failures do
230+
it 'requires authentication' do
231231
post '/api/v1/feeds', request_payload.to_json, json_headers
232232

233233
expect(last_response.status).to eq(401)
@@ -239,15 +239,15 @@ def stub_escaped_feed_token(raw_token:, encoded_token:)
239239
context 'with authenticated account' do # rubocop:disable RSpec/MultipleMemoizedHelpers
240240
before { allow(Html2rss::Web::Auth).to receive(:authenticate).and_return(account) }
241241

242-
it 'returns bad request when JSON payload is invalid', :aggregate_failures do
242+
it 'returns bad request when JSON payload is invalid' do
243243
post '/api/v1/feeds', '{ invalid', json_headers
244244

245245
expect(last_response.status).to eq(400)
246246
expect(last_response.content_type).to include('application/json')
247247
expect(json_body).to include('error' => include('message' => 'Invalid JSON payload'))
248248
end
249249

250-
it 'returns bad request when URL is missing', :aggregate_failures do
250+
it 'returns bad request when URL is missing' do
251251
allow(Html2rss::Web::Api::V1::FeedMetadata).to receive(:site_title_for).and_return('Example')
252252

253253
post '/api/v1/feeds', request_payload.merge(url: '').to_json, auth_headers
@@ -258,7 +258,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:)
258258
)
259259
end
260260

261-
it 'returns forbidden when URL is not allowed for account', :aggregate_failures do
261+
it 'returns forbidden when URL is not allowed for account' do
262262
allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?).and_return(false)
263263

264264
post '/api/v1/feeds', request_payload.to_json, auth_headers
@@ -269,7 +269,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:)
269269
)
270270
end
271271

272-
it 'returns bad request for unsupported strategy', :aggregate_failures do
272+
it 'returns bad request for unsupported strategy' do
273273
post '/api/v1/feeds', request_payload.merge(strategy: 'unsupported').to_json, auth_headers
274274

275275
expect(last_response.status).to eq(400)
@@ -278,7 +278,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:)
278278
)
279279
end
280280

281-
it 'returns error when feed creation fails', :aggregate_failures do
281+
it 'returns error when feed creation fails' do
282282
allow(Html2rss::Web::AutoSource).to receive(:create_stable_feed).and_return(nil)
283283

284284
post '/api/v1/feeds', request_payload.to_json, auth_headers
@@ -289,7 +289,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:)
289289
)
290290
end
291291

292-
it 'returns created feed metadata', :aggregate_failures do
292+
it 'returns created feed metadata' do
293293
post '/api/v1/feeds', request_payload.to_json, auth_headers
294294

295295
expect(last_response.status).to eq(201)

spec/html2rss/web/app_spec.rb

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,20 +77,20 @@ def service_error_response_tuple(path)
7777

7878
it { expect(described_class).to be < Roda }
7979

80-
context 'with Rack::Test' do
80+
context 'with Rack::Test', :aggregate_failures do
8181
include Rack::Test::Methods
8282

8383
def app = described_class
8484

85-
it 'serves the homepage with core security headers', :aggregate_failures do
85+
it 'serves the homepage with core security headers' do
8686
get '/'
8787

8888
expect(last_response).to be_ok
8989
expect(last_response.headers['Content-Security-Policy']).to include("default-src 'none'")
9090
expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000')
9191
end
9292

93-
it 'serves static feed routes with caching headers', :aggregate_failures do
93+
it 'serves static feed routes with caching headers' do
9494
stub_static_feed
9595

9696
get '/legacy'
@@ -103,7 +103,7 @@ def app = described_class
103103
expect(last_response.body).to eq('<rss/>')
104104
end
105105

106-
it 'serves static json feed routes when json is requested by extension', :aggregate_failures do
106+
it 'serves static json feed routes when json is requested by extension' do
107107
stub_static_feed
108108
get '/legacy.json'
109109

@@ -112,7 +112,7 @@ def app = described_class
112112
)
113113
end
114114

115-
it 'serves HEAD requests for static feed routes with negotiated headers only', :aggregate_failures do
115+
it 'serves HEAD requests for static feed routes with negotiated headers only' do
116116
stub_static_feed
117117
head '/legacy'
118118

@@ -122,7 +122,14 @@ def app = described_class
122122
expect(last_response.body).to eq('')
123123
end
124124

125-
it 'coerces string ttl values before cache expiry math', :aggregate_failures do
125+
it 'returns method not allowed for unsupported verbs on token feed routes' do
126+
post '/api/v1/feeds/test-token'
127+
128+
expect(last_response.status).to eq(405)
129+
expect(last_response.headers['Allow']).to eq('GET')
130+
end
131+
132+
it 'coerces string ttl values before cache expiry math' do
126133
stub_static_feed(ttl: '180')
127134

128135
get '/legacy'
@@ -131,7 +138,7 @@ def app = described_class
131138
expect(last_response.headers['Cache-Control']).to include('max-age=10800')
132139
end
133140

134-
it 'renders XML error when static feed generation fails', :aggregate_failures do
141+
it 'renders XML error when static feed generation fails' do
135142
allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('<error/>')
136143

137144
get '/missing-feed'
@@ -141,7 +148,7 @@ def app = described_class
141148
expect(last_response.body).to eq('<error/>')
142149
end
143150

144-
it 'renders JSON Feed-shaped errors when static json feed generation fails', :aggregate_failures do
151+
it 'renders JSON Feed-shaped errors when static json feed generation fails' do
145152
get '/missing-feed.json'
146153

147154
expect(json_feed_error_tuple).to eq(
@@ -150,15 +157,15 @@ def app = described_class
150157
)
151158
end
152159

153-
it 'renders service failures as non-cacheable xml feed errors', :aggregate_failures do
160+
it 'renders service failures as non-cacheable xml feed errors' do
154161
stub_static_service_error('legacy-service-error')
155162

156163
expect(service_error_response_tuple('/legacy-service-error')).to eq(
157164
[500, 'application/xml', %w[max-age=0 must-revalidate no-cache no-store private], '<error/>']
158165
)
159166
end
160167

161-
it 'hides unexpected internal error details from API responses', :aggregate_failures do
168+
it 'hides unexpected internal error details from API responses' do
162169
allow(Html2rss::Web::Routes::ApiV1).to receive(:call).and_raise(StandardError, 'boom')
163170

164171
get '/api/v1'

0 commit comments

Comments
 (0)