ruact 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,598 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.4 — request-cycle spec for the v2 query dispatch layer, on the shared
4
+ # Story-7.9 Rails app. Pins, against REAL drawn routes (manual fetch — no
5
+ # codegen dependency; the TS `useQuery` reference is Story 9.5):
6
+ #
7
+ # - AC1: `ruact_queries` draws one NAMED GET route per public own method at
8
+ # `GET /q/<jsIdentifier>`; base-class and mixin methods are NOT mounted
9
+ # - AC2: the internal dispatch controller inherits
10
+ # `Ruact.config.query_parent_controller` — the host's REAL before_action
11
+ # chain runs (and halts) BEFORE the query class is instantiated (FR89)
12
+ # - AC3: the query reads `current_user` — the host's own method — end-to-end
13
+ # - AC4: `ruact_skip_before_action` opts a query class out per-query; the
14
+ # skipped callback provably does not run
15
+ # - AC5: GET + no CSRF; return value serialized via ruact_props /
16
+ # Serializable / strict_serialization; a raise → salvaged 8.4 chain (D5)
17
+ # - AC6: `GET /q/categories` round-trips through the host chain end-to-end
18
+ # - D6: nil return renders JSON `null` (200); D7: best-effort kwargs
19
+
20
+ require "action_controller/railtie"
21
+ require "action_view/railtie"
22
+
23
+ require "spec_helper"
24
+ require "rack/test"
25
+
26
+ require "ruact/controller"
27
+ require "ruact/server"
28
+ require "ruact/routing"
29
+
30
+ require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
31
+
32
+ # The default `Ruact.config.query_parent_controller` is "ApplicationController"
33
+ # — define the conventional host parent the generated dispatch controllers
34
+ # inherit (AC2). Top-level on purpose: that is exactly what the default config
35
+ # resolves at route-draw time. Guards + CSRF mirror a real host app.
36
+ class ApplicationController < ActionController::Base
37
+ protect_from_forgery with: :exception
38
+
39
+ before_action :require_login
40
+
41
+ private
42
+
43
+ # Counts every run so AC4 can prove the skip means "did not run", not
44
+ # "ran and allowed".
45
+ def require_login
46
+ QueryRequestSpecSupport.require_login_runs += 1
47
+ head :unauthorized unless request.headers["X-Test-Login"] == "1"
48
+ end
49
+
50
+ # The host's own current_user (hand-rolled style, private) — what the
51
+ # QueryContext delegates to via the inherited chain (AC3 / D3).
52
+ def current_user
53
+ return nil unless request.headers["X-Test-Login"] == "1"
54
+
55
+ { "id" => 42, "name" => "Luiz" }
56
+ end
57
+ end
58
+
59
+ module QueryRequestSpecSupport # rubocop:disable Style/OneClassPerFile
60
+ class << self
61
+ attr_accessor :require_login_runs
62
+ end
63
+ self.require_login_runs = 0
64
+
65
+ # Serializable return object — :secret must never reach the wire (AC5).
66
+ class QPost
67
+ include Ruact::Serializable
68
+
69
+ attr_reader :id, :title, :secret
70
+
71
+ def initialize(id:, title:, secret:)
72
+ @id = id
73
+ @title = title
74
+ @secret = secret
75
+ end
76
+
77
+ ruact_props :id, :title
78
+ end
79
+
80
+ # Non-Serializable with as_json — strict_serialization must 500 it (AC5).
81
+ class LeakyRecord
82
+ def as_json(_opts = nil)
83
+ { "id" => 1, "leak" => "everything" }
84
+ end
85
+ end
86
+
87
+ # AC1 fixture trio: a base-class method, a mixin method, and own methods —
88
+ # only the own methods may be mounted.
89
+ class ApplicationQuery < Ruact::Query
90
+ def base_helper
91
+ :never_mounted
92
+ end
93
+ end
94
+
95
+ module QueryMixin
96
+ def mixin_helper
97
+ :never_mounted
98
+ end
99
+ end
100
+
101
+ class CatalogQuery < ApplicationQuery
102
+ include QueryMixin
103
+
104
+ class << self
105
+ attr_accessor :categories_calls
106
+ end
107
+ self.categories_calls = 0
108
+
109
+ def categories
110
+ self.class.categories_calls += 1
111
+ [{ "value" => 1, "label" => "Books" }, { "value" => 2, "label" => "Games" }]
112
+ end
113
+
114
+ def whoami
115
+ { "user" => current_user }
116
+ end
117
+
118
+ def echo(term: "default")
119
+ { "term" => term }
120
+ end
121
+
122
+ def serialized_post
123
+ QPost.new(id: 1, title: "Hi", secret: "s3cret")
124
+ end
125
+
126
+ def leaky
127
+ LeakyRecord.new
128
+ end
129
+
130
+ def nothing
131
+ nil
132
+ end
133
+
134
+ def boom
135
+ raise "query exploded"
136
+ end
137
+ end
138
+
139
+ # AC4 — public query class: opts out of the parent's auth callback.
140
+ class PublicCatalogQuery < ApplicationQuery
141
+ ruact_skip_before_action :require_login
142
+
143
+ def open_categories
144
+ %w[alpha beta]
145
+ end
146
+ end
147
+
148
+ # Probe class for unit-level assertions (custom parent / custom prefix) so
149
+ # rebuilding ITS controller never touches the classes the e2e routes use.
150
+ class ProbeQuery < ApplicationQuery
151
+ def ping
152
+ :pong
153
+ end
154
+ end
155
+
156
+ # Custom parent for the AC2 configurability unit assertion.
157
+ class AltParentController < ActionController::Base; end
158
+
159
+ # Story 9.5 (FR88) fixture — a required kwarg (`q`), an optional kwarg with
160
+ # a default (`limit`), and a separate required-only method. Mounted on the
161
+ # shared app alongside CatalogQuery so the FR88 sanitization is exercised
162
+ # end-to-end through the host chain.
163
+ class SearchQuery < ApplicationQuery
164
+ # `q` is the natural query parameter name and is asserted verbatim in the
165
+ # FR88 rejection messages below.
166
+ def search(q:, limit: "10") # rubocop:disable Naming/MethodParameterName
167
+ { "q" => q, "limit" => limit }
168
+ end
169
+
170
+ def tags(filter:)
171
+ { "filter" => filter }
172
+ end
173
+
174
+ # A `**rest` query: it opts into arbitrary kwargs, so extra params are NOT
175
+ # "unknown" — but the FR88 type allowlist still rejects arrays/objects.
176
+ def flexible(q:, **rest) # rubocop:disable Naming/MethodParameterName
177
+ { "q" => q, "rest" => rest.transform_keys(&:to_s) }
178
+ end
179
+
180
+ # For the null-wire contract: `useQuery` sends `null` as a bare key (`?opt`),
181
+ # which Rack parses as `nil` (distinct from `?opt=` → `""`).
182
+ def nullable(opt: "default")
183
+ { "opt" => opt }
184
+ end
185
+ end
186
+ end
187
+
188
+ if defined?(ControllerRequestSpecSupport) &&
189
+ !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_query_routes_appended)
190
+ ControllerRequestSpecSupport.instance_variable_set(:@__ruact_query_routes_appended, true)
191
+ ControllerRequestSpecSupport.app_class.routes.append do
192
+ ruact_queries QueryRequestSpecSupport::CatalogQuery,
193
+ QueryRequestSpecSupport::PublicCatalogQuery,
194
+ QueryRequestSpecSupport::SearchQuery
195
+ end
196
+ end
197
+
198
+ RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do # rubocop:disable RSpec/MultipleDescribes
199
+ include Rack::Test::Methods
200
+
201
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
202
+ let(:app) { app_class.instance }
203
+
204
+ let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
205
+
206
+ before do
207
+ Rails.logger = Logger.new(IO::NULL)
208
+ ControllerRequestSpecSupport.boot!
209
+ end
210
+
211
+ def reset_config
212
+ Ruact.instance_variable_set(:@config, nil)
213
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
214
+ end
215
+
216
+ describe "AC1 — ruact_queries draws one named GET route per public own method" do
217
+ let(:drawn) { app_class.routes.routes }
218
+ let(:named) { drawn.filter_map(&:name) }
219
+
220
+ it "mounts every public_instance_methods(false) of the subclass at /q/<jsIdentifier>" do
221
+ expect(named).to include(
222
+ "ruact_query_categories", "ruact_query_whoami", "ruact_query_echo",
223
+ "ruact_query_serializedPost", "ruact_query_leaky", "ruact_query_nothing",
224
+ "ruact_query_boom", "ruact_query_openCategories"
225
+ )
226
+ end
227
+
228
+ it "does NOT mount base-class methods, mixin methods, or the Ruact::Query accessors" do
229
+ expect(named).not_to include(
230
+ "ruact_query_baseHelper", "ruact_query_mixinHelper",
231
+ "ruact_query_currentUser", "ruact_query_params",
232
+ "ruact_query_request", "ruact_query_session"
233
+ )
234
+ end
235
+
236
+ it "draws GET routes under the default /q prefix, snake_case → camelCase (D4)" do
237
+ route = drawn.find { |r| r.name == "ruact_query_serializedPost" }
238
+ expect(route.verb).to eq("GET")
239
+ expect(route.path.spec.to_s).to eq("/q/serializedPost(.:format)")
240
+ end
241
+
242
+ it "honors a custom Ruact.config.query_route_prefix on a fresh draw (contract decision #7)" do
243
+ reset_config
244
+ Ruact.configure { |c| c.query_route_prefix = "/api/queries" }
245
+ route_set = ActionDispatch::Routing::RouteSet.new
246
+ route_set.draw { ruact_queries QueryRequestSpecSupport::ProbeQuery }
247
+ expect(route_set.routes.map { |r| r.path.spec.to_s }).to include("/api/queries/ping(.:format)")
248
+ end
249
+ end
250
+
251
+ describe "AC2 — dispatch controller inherits the host parent; chain runs BEFORE instantiation (FR89)" do
252
+ it "inherits Ruact.config.query_parent_controller (default ApplicationController)" do
253
+ controller = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::CatalogQuery)
254
+ expect(controller.superclass).to be(ApplicationController)
255
+ end
256
+
257
+ it "honors a custom query_parent_controller" do
258
+ reset_config
259
+ Ruact.configure { |c| c.query_parent_controller = "QueryRequestSpecSupport::AltParentController" }
260
+ controller = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
261
+ expect(controller.superclass).to be(QueryRequestSpecSupport::AltParentController)
262
+ end
263
+
264
+ it "raises Ruact::ConfigurationError at route-draw when the parent name does not resolve" do
265
+ reset_config
266
+ Ruact.configure { |c| c.query_parent_controller = "NoSuchParentController" }
267
+ expect { Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery) }
268
+ .to raise_error(Ruact::ConfigurationError, /NoSuchParentController.*does not resolve/m)
269
+ end
270
+
271
+ it "a halting before_action rejects the request and the query class is never instantiated" do
272
+ calls_before = QueryRequestSpecSupport::CatalogQuery.categories_calls
273
+ get "/q/categories"
274
+ expect(last_response.status).to eq(401)
275
+ expect(QueryRequestSpecSupport::CatalogQuery.categories_calls).to eq(calls_before)
276
+ end
277
+ end
278
+
279
+ describe "AC6 — GET /q/categories end-to-end through the host chain (manual fetch)" do
280
+ it "round-trips: 200, JSON content type, serialized return value" do
281
+ get "/q/categories", {}, login_headers
282
+ expect(last_response.status).to eq(200)
283
+ expect(last_response.headers["Content-Type"]).to include("application/json")
284
+ expect(JSON.parse(last_response.body)).to eq(
285
+ [{ "value" => 1, "label" => "Books" }, { "value" => 2, "label" => "Games" }]
286
+ )
287
+ end
288
+ end
289
+
290
+ describe "AC3 — current_user delegates to the host's own (private) method" do
291
+ it "the query reads the authenticated user end-to-end" do
292
+ get "/q/whoami", {}, login_headers
293
+ expect(last_response.status).to eq(200)
294
+ expect(JSON.parse(last_response.body)).to eq("user" => { "id" => 42, "name" => "Luiz" })
295
+ end
296
+ end
297
+
298
+ describe "AC4 — per-query callback opt-out (ruact_skip_before_action)" do
299
+ it "without the opt-out, the unauthenticated request is rejected (control)" do
300
+ get "/q/categories"
301
+ expect(last_response.status).to eq(401)
302
+ end
303
+
304
+ it "with the opt-out, the skipped callback does NOT run and the query returns" do
305
+ runs_before = QueryRequestSpecSupport.require_login_runs
306
+ get "/q/openCategories"
307
+ expect(last_response.status).to eq(200)
308
+ expect(JSON.parse(last_response.body)).to eq(%w[alpha beta])
309
+ expect(QueryRequestSpecSupport.require_login_runs).to eq(runs_before)
310
+ end
311
+
312
+ it "the opt-out does not leak to sibling query classes" do
313
+ get "/q/whoami"
314
+ expect(last_response.status).to eq(401)
315
+ end
316
+ end
317
+
318
+ describe "AC5 — GET, no CSRF, serialized return value, error chain" do
319
+ it "a tokenless GET succeeds despite protect_from_forgery with: :exception (queries are reads)" do
320
+ get "/q/categories", {}, login_headers
321
+ expect(last_response.status).to eq(200)
322
+ end
323
+
324
+ it "serializes a Serializable return through ruact_props — :secret never leaks" do
325
+ get "/q/serializedPost", {}, login_headers
326
+ body = JSON.parse(last_response.body)
327
+ expect(body).to eq("id" => 1, "title" => "Hi")
328
+ expect(body).not_to have_key("secret")
329
+ end
330
+
331
+ it "under strict_serialization, a non-Serializable return → structured 500 (SerializationError)" do
332
+ reset_config
333
+ Ruact.configure { |c| c.strict_serialization = true }
334
+ get "/q/leaky", {}, login_headers
335
+ expect(last_response.status).to eq(500)
336
+ body = JSON.parse(last_response.body)
337
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
338
+ expect(body.fetch("error_class")).to eq("Ruact::SerializationError")
339
+ expect(body.fetch("message")).to match(/Cannot serialize QueryRequestSpecSupport::LeakyRecord/)
340
+ end
341
+
342
+ it "a query raise renders the salvaged 8.4 structured payload on a GET (D5 gate override)" do
343
+ get "/q/boom", {}, login_headers
344
+ expect(last_response.status).to eq(500)
345
+ body = JSON.parse(last_response.body)
346
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
347
+ expect(body.fetch("action_name")).to eq("boom")
348
+ expect(body.fetch("error_class")).to eq("RuntimeError")
349
+ expect(body.fetch("message")).to eq("query exploded")
350
+ end
351
+ end
352
+
353
+ describe "D6 — nil return renders JSON null (200), not 204" do
354
+ it "renders the literal null body" do
355
+ get "/q/nothing", {}, login_headers
356
+ expect(last_response.status).to eq(200)
357
+ expect(last_response.body).to eq("null")
358
+ end
359
+ end
360
+
361
+ describe "D7 — best-effort kwargs from GET query params (strict FR88 sanitization is 9.5)" do
362
+ it "passes a declared keyword argument by name" do
363
+ get "/q/echo?term=ruby", {}, login_headers
364
+ expect(JSON.parse(last_response.body)).to eq("term" => "ruby")
365
+ end
366
+
367
+ it "omits an absent keyword so the method default applies" do
368
+ get "/q/echo", {}, login_headers
369
+ expect(JSON.parse(last_response.body)).to eq("term" => "default")
370
+ end
371
+ end
372
+
373
+ describe "review round 1 — query method names colliding with controller plumbing are rejected" do
374
+ it "raises Ruact::ConfigurationError when a query method would shadow a framework method" do
375
+ shadowing = Class.new(Ruact::Query) do
376
+ def params
377
+ :shadowed
378
+ end
379
+ end
380
+ stub_const("QueryRequestSpecSupport::ParamsShadowQuery", shadowing)
381
+ expect { Ruact::ServerFunctions::QueryDispatch.controller_for(shadowing) }
382
+ .to raise_error(Ruact::ConfigurationError, /\bparams\b.*already defined/m)
383
+ end
384
+
385
+ it "rejects `render` too (any name on the generated controller chain)" do
386
+ shadowing = Class.new(Ruact::Query) do
387
+ def render
388
+ :shadowed
389
+ end
390
+ end
391
+ stub_const("QueryRequestSpecSupport::RenderShadowQuery", shadowing)
392
+ expect { Ruact::ServerFunctions::QueryDispatch.controller_for(shadowing) }
393
+ .to raise_error(Ruact::ConfigurationError, /\brender\b.*already defined/m)
394
+ end
395
+ end
396
+
397
+ describe "review round 4 — namespace is PRESERVED so collisions are impossible by construction" do
398
+ it "maps Admin::CatalogQuery and AdminCatalogQuery to DISTINCT controllers + route targets" do
399
+ namespaced = Class.new(Ruact::Query) { def ping_a = :a }
400
+ flat = Class.new(Ruact::Query) { def ping_b = :b }
401
+ stub_const("QueryRequestSpecSupport::Nspace::CatalogQuery", namespaced)
402
+ stub_const("QueryRequestSpecSupport::NspaceCatalogQuery", flat)
403
+
404
+ c1 = Ruact::ServerFunctions::QueryDispatch.controller_for(namespaced)
405
+ c2 = Ruact::ServerFunctions::QueryDispatch.controller_for(flat)
406
+
407
+ # Every namespace segment is preserved (`::Nspace::CatalogQuery` keeps the
408
+ # boundary; `NspaceCatalogQuery` has no boundary) — the two map to
409
+ # DISTINCT nested constants, so a const overwrite / cross-wire is
410
+ # impossible regardless of how many RouteSets share the dispatch module.
411
+ expect(c1).not_to be(c2)
412
+ expect(c1.name).to eq(
413
+ "Ruact::ServerFunctions::QueryDispatch::QueryRequestSpecSupport::Nspace::CatalogQueryController"
414
+ )
415
+ expect(c2.name).to eq(
416
+ "Ruact::ServerFunctions::QueryDispatch::QueryRequestSpecSupport::NspaceCatalogQueryController"
417
+ )
418
+ expect(Ruact::ServerFunctions::QueryDispatch.route_target_for(namespaced))
419
+ .to eq("ruact/server_functions/query_dispatch/query_request_spec_support/nspace/catalog_query")
420
+ expect(Ruact::ServerFunctions::QueryDispatch.route_target_for(flat))
421
+ .to eq("ruact/server_functions/query_dispatch/query_request_spec_support/nspace_catalog_query")
422
+ end
423
+
424
+ it "mounts both on the same RouteSet without error, each routing to its own class (no cross-wiring)" do
425
+ namespaced = Class.new(Ruact::Query) { def from_ns = "ns" }
426
+ flat = Class.new(Ruact::Query) { def from_flat = "flat" }
427
+ stub_const("QueryRequestSpecSupport::Pair::TwinQuery", namespaced)
428
+ stub_const("QueryRequestSpecSupport::PairTwinQuery", flat)
429
+
430
+ route_set = ActionDispatch::Routing::RouteSet.new
431
+ expect do
432
+ route_set.draw do
433
+ ruact_queries namespaced
434
+ ruact_queries flat
435
+ end
436
+ end.not_to raise_error
437
+
438
+ targets = route_set.routes.filter_map { |r| r.defaults[:controller] }
439
+ expect(targets).to include(
440
+ "ruact/server_functions/query_dispatch/query_request_spec_support/pair/twin_query",
441
+ "ruact/server_functions/query_dispatch/query_request_spec_support/pair_twin_query"
442
+ )
443
+ end
444
+
445
+ it "round-trips acronym namespaces so Rails resolves the generated controller (round 5)" do
446
+ acro = Class.new(Ruact::Query) { def ping_e = :e }
447
+ stub_const("QueryRequestSpecSupport::APIProbe::CatalogQuery", acro)
448
+
449
+ controller = Ruact::ServerFunctions::QueryDispatch.controller_for(acro)
450
+ target = Ruact::ServerFunctions::QueryDispatch.route_target_for(acro)
451
+ # Exactly how Rails' dispatcher resolves a "controller#action" target.
452
+ resolved = "#{target.camelize}Controller".constantize
453
+
454
+ expect(resolved).to be(controller)
455
+ expect(target).to eq(
456
+ "ruact/server_functions/query_dispatch/query_request_spec_support/api_probe/catalog_query"
457
+ )
458
+ end
459
+
460
+ it "the SAME class re-mounting (dev reload) is still allowed" do
461
+ expect do
462
+ Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
463
+ Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
464
+ end.not_to raise_error
465
+ end
466
+ end
467
+
468
+ describe "regeneration is idempotent (dev-mode routes reload)" do
469
+ it "controller_for builds a fresh class on every call without const warnings" do
470
+ first = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
471
+ second = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
472
+ expect(second).not_to be(first)
473
+ expect(second.superclass).to be(ApplicationController)
474
+ end
475
+ end
476
+ end
477
+
478
+ RSpec.describe "Story 9.5: FR88 query kwargs sanitization", :story_9_5 do
479
+ include Rack::Test::Methods
480
+
481
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
482
+ let(:app) { app_class.instance }
483
+
484
+ let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
485
+
486
+ before do
487
+ Rails.logger = Logger.new(IO::NULL)
488
+ ControllerRequestSpecSupport.boot!
489
+ end
490
+
491
+ def structured_error(response)
492
+ expect(response.status).to eq(400)
493
+ body = JSON.parse(response.body)
494
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
495
+ expect(body.fetch("error_class")).to eq("Ruact::BadRequestError")
496
+ body
497
+ end
498
+
499
+ describe "AC3 happy path — declared primitives pass" do
500
+ it "passes a required + optional kwarg by name (values arrive as strings)" do
501
+ get "/q/search?q=ruby&limit=5", {}, login_headers
502
+ expect(last_response.status).to eq(200)
503
+ expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "5")
504
+ end
505
+
506
+ it "omits an absent optional kwarg so the method default applies" do
507
+ get "/q/search?q=ruby", {}, login_headers
508
+ expect(last_response.status).to eq(200)
509
+ expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "10")
510
+ end
511
+
512
+ it "accepts boolean-ish / numeric-ish primitive strings on the wire" do
513
+ get "/q/search?q=true&limit=0", {}, login_headers
514
+ expect(last_response.status).to eq(200)
515
+ expect(JSON.parse(last_response.body)).to eq("q" => "true", "limit" => "0")
516
+ end
517
+ end
518
+
519
+ describe "AC3 — array param rejected (only string/number/boolean/null allowed)" do
520
+ it "400s with a descriptive error naming the key and the allowlist" do
521
+ get "/q/search?q[]=a&q[]=b", {}, login_headers
522
+ body = structured_error(last_response)
523
+ expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
524
+ expect(body.fetch("message")).to match(/arrays and objects are rejected/)
525
+ end
526
+ end
527
+
528
+ describe "AC3 — object (hash) param rejected" do
529
+ it "400s with a descriptive error naming the offending key" do
530
+ get "/q/search?q[deep]=1", {}, login_headers
531
+ body = structured_error(last_response)
532
+ expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
533
+ end
534
+ end
535
+
536
+ describe "AC3 — missing required kwarg → 400 naming the missing parameter" do
537
+ it "400s naming :q when it is absent" do
538
+ get "/q/search?limit=5", {}, login_headers
539
+ body = structured_error(last_response)
540
+ expect(body.fetch("message")).to match(/missing required parameter\(s\) :q/)
541
+ expect(body.fetch("action_name")).to eq("search")
542
+ end
543
+
544
+ it "400s naming :filter on a required-only method" do
545
+ get "/q/tags", {}, login_headers
546
+ body = structured_error(last_response)
547
+ expect(body.fetch("message")).to match(/missing required parameter\(s\) :filter/)
548
+ end
549
+ end
550
+
551
+ describe "AC3 — unknown param → 400 (rejected, not silently dropped)" do
552
+ it "400s naming the unknown parameter" do
553
+ get "/q/search?q=ruby&bogus=1", {}, login_headers
554
+ body = structured_error(last_response)
555
+ expect(body.fetch("message")).to match(/unknown parameter :bogus/)
556
+ end
557
+
558
+ it "rejects before running the query even when the declared param is also present" do
559
+ get "/q/search?q=ruby&bogus=1", {}, login_headers
560
+ expect(last_response.status).to eq(400)
561
+ end
562
+ end
563
+
564
+ describe "AC3 — a `**rest` query opts into extra kwargs, but the type allowlist still applies" do
565
+ it "accepts extra primitive params (not 'unknown' for a **rest signature)" do
566
+ get "/q/flexible?q=ruby&extra=1&flag=true", {}, login_headers
567
+ expect(last_response.status).to eq(200)
568
+ expect(JSON.parse(last_response.body)).to eq(
569
+ "q" => "ruby", "rest" => { "extra" => "1", "flag" => "true" }
570
+ )
571
+ end
572
+
573
+ it "STILL rejects an array param on a **rest query (FR88 type boundary holds)" do
574
+ get "/q/flexible?q=ruby&bad[]=1", {}, login_headers
575
+ body = structured_error(last_response)
576
+ expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
577
+ end
578
+
579
+ it "STILL rejects an object param on a **rest query" do
580
+ get "/q/flexible?q=ruby&bad[x]=1", {}, login_headers
581
+ body = structured_error(last_response)
582
+ expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
583
+ end
584
+ end
585
+
586
+ describe "null wire contract — a bare key (`?opt`) is delivered as nil, not empty string" do
587
+ it "delivers nil (the bare-key wire form useQuery sends for null)" do
588
+ get "/q/nullable?opt", {}, login_headers
589
+ expect(last_response.status).to eq(200)
590
+ expect(JSON.parse(last_response.body)).to eq("opt" => nil)
591
+ end
592
+
593
+ it "delivers an empty string for `?opt=` (distinct from null)" do
594
+ get "/q/nullable?opt=", {}, login_headers
595
+ expect(JSON.parse(last_response.body)).to eq("opt" => "")
596
+ end
597
+ end
598
+ end