ruact 0.0.11 → 0.0.12

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +14 -4
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -12
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -1,706 +0,0 @@
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
- require "global_id"
26
- require "active_support/core_ext/integer/time"
27
- require "active_support/core_ext/numeric/time"
28
- require "active_support/message_verifier"
29
-
30
- require "ruact/controller"
31
- require "ruact/server"
32
- require "ruact/routing"
33
-
34
- require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
35
-
36
- # The default `Ruact.config.query_parent_controller` is "ApplicationController"
37
- # — define the conventional host parent the generated dispatch controllers
38
- # inherit (AC2). Top-level on purpose: that is exactly what the default config
39
- # resolves at route-draw time. Guards + CSRF mirror a real host app.
40
- class ApplicationController < ActionController::Base
41
- protect_from_forgery with: :exception
42
-
43
- before_action :require_login
44
-
45
- private
46
-
47
- # Counts every run so AC4 can prove the skip means "did not run", not
48
- # "ran and allowed".
49
- def require_login
50
- QueryRequestSpecSupport.require_login_runs += 1
51
- head :unauthorized unless request.headers["X-Test-Login"] == "1"
52
- end
53
-
54
- # The host's own current_user (hand-rolled style, private) — what the
55
- # QueryContext delegates to via the inherited chain (AC3 / D3).
56
- def current_user
57
- return nil unless request.headers["X-Test-Login"] == "1"
58
-
59
- { "id" => 42, "name" => "Luiz" }
60
- end
61
- end
62
-
63
- module QueryRequestSpecSupport # rubocop:disable Style/OneClassPerFile
64
- class << self
65
- attr_accessor :require_login_runs
66
- end
67
- self.require_login_runs = 0
68
-
69
- # Serializable return object — :secret must never reach the wire (AC5).
70
- class QPost
71
- include Ruact::Serializable
72
-
73
- attr_reader :id, :title, :secret
74
-
75
- def initialize(id:, title:, secret:)
76
- @id = id
77
- @title = title
78
- @secret = secret
79
- end
80
-
81
- ruact_props :id, :title
82
- end
83
-
84
- # Non-Serializable with as_json — strict_serialization must 500 it (AC5).
85
- class LeakyRecord
86
- def as_json(_opts = nil)
87
- { "id" => 1, "leak" => "everything" }
88
- end
89
- end
90
-
91
- # AC1 fixture trio: a base-class method, a mixin method, and own methods —
92
- # only the own methods may be mounted.
93
- class ApplicationQuery < Ruact::Query
94
- def base_helper
95
- :never_mounted
96
- end
97
- end
98
-
99
- module QueryMixin
100
- def mixin_helper
101
- :never_mounted
102
- end
103
- end
104
-
105
- class CatalogQuery < ApplicationQuery
106
- include QueryMixin
107
-
108
- class << self
109
- attr_accessor :categories_calls
110
- end
111
- self.categories_calls = 0
112
-
113
- def categories
114
- self.class.categories_calls += 1
115
- [{ "value" => 1, "label" => "Books" }, { "value" => 2, "label" => "Games" }]
116
- end
117
-
118
- def whoami
119
- { "user" => current_user }
120
- end
121
-
122
- def echo(term: "default")
123
- { "term" => term }
124
- end
125
-
126
- def serialized_post
127
- QPost.new(id: 1, title: "Hi", secret: "s3cret")
128
- end
129
-
130
- def leaky
131
- LeakyRecord.new
132
- end
133
-
134
- def nothing
135
- nil
136
- end
137
-
138
- def boom
139
- raise "query exploded"
140
- end
141
- end
142
-
143
- # AC4 — public query class: opts out of the parent's auth callback.
144
- class PublicCatalogQuery < ApplicationQuery
145
- ruact_skip_before_action :require_login
146
-
147
- def open_categories
148
- %w[alpha beta]
149
- end
150
- end
151
-
152
- # Probe class for unit-level assertions (custom parent / custom prefix) so
153
- # rebuilding ITS controller never touches the classes the e2e routes use.
154
- class ProbeQuery < ApplicationQuery
155
- def ping
156
- :pong
157
- end
158
- end
159
-
160
- # Custom parent for the AC2 configurability unit assertion.
161
- class AltParentController < ActionController::Base; end
162
-
163
- # Story 9.5 (FR88) fixture — a required kwarg (`q`), an optional kwarg with
164
- # a default (`limit`), and a separate required-only method. Mounted on the
165
- # shared app alongside CatalogQuery so the FR88 sanitization is exercised
166
- # end-to-end through the host chain.
167
- class SearchQuery < ApplicationQuery
168
- # `q` is the natural query parameter name and is asserted verbatim in the
169
- # FR88 rejection messages below.
170
- def search(q:, limit: "10") # rubocop:disable Naming/MethodParameterName
171
- { "q" => q, "limit" => limit }
172
- end
173
-
174
- def tags(filter:)
175
- { "filter" => filter }
176
- end
177
-
178
- # A `**rest` query: it opts into arbitrary kwargs, so extra params are NOT
179
- # "unknown" — but the FR88 type allowlist still rejects arrays/objects.
180
- def flexible(q:, **rest) # rubocop:disable Naming/MethodParameterName
181
- { "q" => q, "rest" => rest.transform_keys(&:to_s) }
182
- end
183
-
184
- # For the null-wire contract: `useQuery` sends `null` as a bare key (`?opt`),
185
- # which Rack parses as `nil` (distinct from `?opt=` → `""`).
186
- def nullable(opt: "default")
187
- { "opt" => opt }
188
- end
189
- end
190
-
191
- # Story 13.2 (FR96) — a GlobalID-locatable record used to exercise the signed
192
- # reference round-trip through the real host request cycle.
193
- class RefPost
194
- include GlobalID::Identification
195
-
196
- attr_reader :id
197
-
198
- def initialize(id)
199
- @id = id.to_s
200
- self.class.store[@id] = self
201
- end
202
-
203
- def self.store
204
- @store ||= {}
205
- end
206
-
207
- def self.find(id)
208
- store.fetch(id.to_s)
209
- end
210
- end
211
-
212
- # Story 13.2 (FR96) — a public query (skips auth) that resolves a signed
213
- # reference handed back from the client. A tampered/expired/wrong-purpose
214
- # token raises Ruact::InvalidSignedGlobalIDError, which the Story 8.4
215
- # structured-error chain renders as a clean 400 (no ActiveRecord::RecordNotFound
216
- # leak, no raw-id trust).
217
- class SignedRefQuery < ApplicationQuery
218
- ruact_skip_before_action :require_login
219
-
220
- def resolve_ref(token:)
221
- record = Ruact.locate_signed(token, for: :spec_ref)
222
- { "id" => record.id }
223
- end
224
- end
225
- end
226
-
227
- if defined?(ControllerRequestSpecSupport) &&
228
- !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_query_routes_appended)
229
- ControllerRequestSpecSupport.instance_variable_set(:@__ruact_query_routes_appended, true)
230
- ControllerRequestSpecSupport.app_class.routes.append do
231
- ruact_queries QueryRequestSpecSupport::CatalogQuery,
232
- QueryRequestSpecSupport::PublicCatalogQuery,
233
- QueryRequestSpecSupport::SearchQuery,
234
- QueryRequestSpecSupport::SignedRefQuery
235
- end
236
- end
237
-
238
- RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do # rubocop:disable RSpec/MultipleDescribes
239
- include Rack::Test::Methods
240
-
241
- let(:app_class) { ControllerRequestSpecSupport.app_class }
242
- let(:app) { app_class.instance }
243
-
244
- let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
245
-
246
- before do
247
- Rails.logger = Logger.new(IO::NULL)
248
- ControllerRequestSpecSupport.boot!
249
- end
250
-
251
- def reset_config
252
- Ruact.instance_variable_set(:@config, nil)
253
- Ruact.instance_variable_set(:@configured_at_least_once, false)
254
- end
255
-
256
- describe "AC1 — ruact_queries draws one named GET route per public own method" do
257
- let(:drawn) { app_class.routes.routes }
258
- let(:named) { drawn.filter_map(&:name) }
259
-
260
- it "mounts every public_instance_methods(false) of the subclass at /q/<jsIdentifier>" do
261
- expect(named).to include(
262
- "ruact_query_categories", "ruact_query_whoami", "ruact_query_echo",
263
- "ruact_query_serializedPost", "ruact_query_leaky", "ruact_query_nothing",
264
- "ruact_query_boom", "ruact_query_openCategories"
265
- )
266
- end
267
-
268
- it "does NOT mount base-class methods, mixin methods, or the Ruact::Query accessors" do
269
- expect(named).not_to include(
270
- "ruact_query_baseHelper", "ruact_query_mixinHelper",
271
- "ruact_query_currentUser", "ruact_query_params",
272
- "ruact_query_request", "ruact_query_session"
273
- )
274
- end
275
-
276
- it "draws GET routes under the default /q prefix, snake_case → camelCase (D4)" do
277
- route = drawn.find { |r| r.name == "ruact_query_serializedPost" }
278
- expect(route.verb).to eq("GET")
279
- expect(route.path.spec.to_s).to eq("/q/serializedPost(.:format)")
280
- end
281
-
282
- it "honors a custom Ruact.config.query_route_prefix on a fresh draw (contract decision #7)" do
283
- reset_config
284
- Ruact.configure { |c| c.query_route_prefix = "/api/queries" }
285
- route_set = ActionDispatch::Routing::RouteSet.new
286
- route_set.draw { ruact_queries QueryRequestSpecSupport::ProbeQuery }
287
- expect(route_set.routes.map { |r| r.path.spec.to_s }).to include("/api/queries/ping(.:format)")
288
- end
289
- end
290
-
291
- describe "AC2 — dispatch controller inherits the host parent; chain runs BEFORE instantiation (FR89)" do
292
- it "inherits Ruact.config.query_parent_controller (default ApplicationController)" do
293
- controller = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::CatalogQuery)
294
- expect(controller.superclass).to be(ApplicationController)
295
- end
296
-
297
- it "honors a custom query_parent_controller" do
298
- reset_config
299
- Ruact.configure { |c| c.query_parent_controller = "QueryRequestSpecSupport::AltParentController" }
300
- controller = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
301
- expect(controller.superclass).to be(QueryRequestSpecSupport::AltParentController)
302
- end
303
-
304
- it "raises Ruact::ConfigurationError at route-draw when the parent name does not resolve" do
305
- reset_config
306
- Ruact.configure { |c| c.query_parent_controller = "NoSuchParentController" }
307
- expect { Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery) }
308
- .to raise_error(Ruact::ConfigurationError, /NoSuchParentController.*does not resolve/m)
309
- end
310
-
311
- it "a halting before_action rejects the request and the query class is never instantiated" do
312
- calls_before = QueryRequestSpecSupport::CatalogQuery.categories_calls
313
- get "/q/categories"
314
- expect(last_response.status).to eq(401)
315
- expect(QueryRequestSpecSupport::CatalogQuery.categories_calls).to eq(calls_before)
316
- end
317
- end
318
-
319
- describe "AC6 — GET /q/categories end-to-end through the host chain (manual fetch)" do
320
- it "round-trips: 200, JSON content type, serialized return value" do
321
- get "/q/categories", {}, login_headers
322
- expect(last_response.status).to eq(200)
323
- expect(last_response.headers["Content-Type"]).to include("application/json")
324
- expect(JSON.parse(last_response.body)).to eq(
325
- [{ "value" => 1, "label" => "Books" }, { "value" => 2, "label" => "Games" }]
326
- )
327
- end
328
- end
329
-
330
- describe "AC3 — current_user delegates to the host's own (private) method" do
331
- it "the query reads the authenticated user end-to-end" do
332
- get "/q/whoami", {}, login_headers
333
- expect(last_response.status).to eq(200)
334
- expect(JSON.parse(last_response.body)).to eq("user" => { "id" => 42, "name" => "Luiz" })
335
- end
336
- end
337
-
338
- describe "AC4 — per-query callback opt-out (ruact_skip_before_action)" do
339
- it "without the opt-out, the unauthenticated request is rejected (control)" do
340
- get "/q/categories"
341
- expect(last_response.status).to eq(401)
342
- end
343
-
344
- it "with the opt-out, the skipped callback does NOT run and the query returns" do
345
- runs_before = QueryRequestSpecSupport.require_login_runs
346
- get "/q/openCategories"
347
- expect(last_response.status).to eq(200)
348
- expect(JSON.parse(last_response.body)).to eq(%w[alpha beta])
349
- expect(QueryRequestSpecSupport.require_login_runs).to eq(runs_before)
350
- end
351
-
352
- it "the opt-out does not leak to sibling query classes" do
353
- get "/q/whoami"
354
- expect(last_response.status).to eq(401)
355
- end
356
- end
357
-
358
- describe "AC5 — GET, no CSRF, serialized return value, error chain" do
359
- it "a tokenless GET succeeds despite protect_from_forgery with: :exception (queries are reads)" do
360
- get "/q/categories", {}, login_headers
361
- expect(last_response.status).to eq(200)
362
- end
363
-
364
- it "serializes a Serializable return through ruact_props — :secret never leaks" do
365
- get "/q/serializedPost", {}, login_headers
366
- body = JSON.parse(last_response.body)
367
- expect(body).to eq("id" => 1, "title" => "Hi")
368
- expect(body).not_to have_key("secret")
369
- end
370
-
371
- it "under strict_serialization, a non-Serializable return → structured 500 (SerializationError)" do
372
- reset_config
373
- Ruact.configure { |c| c.strict_serialization = true }
374
- get "/q/leaky", {}, login_headers
375
- expect(last_response.status).to eq(500)
376
- body = JSON.parse(last_response.body)
377
- expect(body.fetch("_ruact_server_action_error")).to be(true)
378
- expect(body.fetch("error_class")).to eq("Ruact::SerializationError")
379
- expect(body.fetch("message")).to match(/Cannot serialize QueryRequestSpecSupport::LeakyRecord/)
380
- end
381
-
382
- it "a query raise renders the salvaged 8.4 structured payload on a GET (D5 gate override)" do
383
- get "/q/boom", {}, login_headers
384
- expect(last_response.status).to eq(500)
385
- body = JSON.parse(last_response.body)
386
- expect(body.fetch("_ruact_server_action_error")).to be(true)
387
- expect(body.fetch("action_name")).to eq("boom")
388
- expect(body.fetch("error_class")).to eq("RuntimeError")
389
- expect(body.fetch("message")).to eq("query exploded")
390
- end
391
- end
392
-
393
- describe "D6 — nil return renders JSON null (200), not 204" do
394
- it "renders the literal null body" do
395
- get "/q/nothing", {}, login_headers
396
- expect(last_response.status).to eq(200)
397
- expect(last_response.body).to eq("null")
398
- end
399
- end
400
-
401
- describe "D7 — best-effort kwargs from GET query params (strict FR88 sanitization is 9.5)" do
402
- it "passes a declared keyword argument by name" do
403
- get "/q/echo?term=ruby", {}, login_headers
404
- expect(JSON.parse(last_response.body)).to eq("term" => "ruby")
405
- end
406
-
407
- it "omits an absent keyword so the method default applies" do
408
- get "/q/echo", {}, login_headers
409
- expect(JSON.parse(last_response.body)).to eq("term" => "default")
410
- end
411
- end
412
-
413
- describe "review round 1 — query method names colliding with controller plumbing are rejected" do
414
- it "raises Ruact::ConfigurationError when a query method would shadow a framework method" do
415
- shadowing = Class.new(Ruact::Query) do
416
- def params
417
- :shadowed
418
- end
419
- end
420
- stub_const("QueryRequestSpecSupport::ParamsShadowQuery", shadowing)
421
- expect { Ruact::ServerFunctions::QueryDispatch.controller_for(shadowing) }
422
- .to raise_error(Ruact::ConfigurationError, /\bparams\b.*already defined/m)
423
- end
424
-
425
- it "rejects `render` too (any name on the generated controller chain)" do
426
- shadowing = Class.new(Ruact::Query) do
427
- def render
428
- :shadowed
429
- end
430
- end
431
- stub_const("QueryRequestSpecSupport::RenderShadowQuery", shadowing)
432
- expect { Ruact::ServerFunctions::QueryDispatch.controller_for(shadowing) }
433
- .to raise_error(Ruact::ConfigurationError, /\brender\b.*already defined/m)
434
- end
435
- end
436
-
437
- describe "review round 4 — namespace is PRESERVED so collisions are impossible by construction" do
438
- it "maps Admin::CatalogQuery and AdminCatalogQuery to DISTINCT controllers + route targets" do
439
- namespaced = Class.new(Ruact::Query) { def ping_a = :a }
440
- flat = Class.new(Ruact::Query) { def ping_b = :b }
441
- stub_const("QueryRequestSpecSupport::Nspace::CatalogQuery", namespaced)
442
- stub_const("QueryRequestSpecSupport::NspaceCatalogQuery", flat)
443
-
444
- c1 = Ruact::ServerFunctions::QueryDispatch.controller_for(namespaced)
445
- c2 = Ruact::ServerFunctions::QueryDispatch.controller_for(flat)
446
-
447
- # Every namespace segment is preserved (`::Nspace::CatalogQuery` keeps the
448
- # boundary; `NspaceCatalogQuery` has no boundary) — the two map to
449
- # DISTINCT nested constants, so a const overwrite / cross-wire is
450
- # impossible regardless of how many RouteSets share the dispatch module.
451
- expect(c1).not_to be(c2)
452
- expect(c1.name).to eq(
453
- "Ruact::ServerFunctions::QueryDispatch::QueryRequestSpecSupport::Nspace::CatalogQueryController"
454
- )
455
- expect(c2.name).to eq(
456
- "Ruact::ServerFunctions::QueryDispatch::QueryRequestSpecSupport::NspaceCatalogQueryController"
457
- )
458
- expect(Ruact::ServerFunctions::QueryDispatch.route_target_for(namespaced))
459
- .to eq("ruact/server_functions/query_dispatch/query_request_spec_support/nspace/catalog_query")
460
- expect(Ruact::ServerFunctions::QueryDispatch.route_target_for(flat))
461
- .to eq("ruact/server_functions/query_dispatch/query_request_spec_support/nspace_catalog_query")
462
- end
463
-
464
- it "mounts both on the same RouteSet without error, each routing to its own class (no cross-wiring)" do
465
- namespaced = Class.new(Ruact::Query) { def from_ns = "ns" }
466
- flat = Class.new(Ruact::Query) { def from_flat = "flat" }
467
- stub_const("QueryRequestSpecSupport::Pair::TwinQuery", namespaced)
468
- stub_const("QueryRequestSpecSupport::PairTwinQuery", flat)
469
-
470
- route_set = ActionDispatch::Routing::RouteSet.new
471
- expect do
472
- route_set.draw do
473
- ruact_queries namespaced
474
- ruact_queries flat
475
- end
476
- end.not_to raise_error
477
-
478
- targets = route_set.routes.filter_map { |r| r.defaults[:controller] }
479
- expect(targets).to include(
480
- "ruact/server_functions/query_dispatch/query_request_spec_support/pair/twin_query",
481
- "ruact/server_functions/query_dispatch/query_request_spec_support/pair_twin_query"
482
- )
483
- end
484
-
485
- it "round-trips acronym namespaces so Rails resolves the generated controller (round 5)" do
486
- acro = Class.new(Ruact::Query) { def ping_e = :e }
487
- stub_const("QueryRequestSpecSupport::APIProbe::CatalogQuery", acro)
488
-
489
- controller = Ruact::ServerFunctions::QueryDispatch.controller_for(acro)
490
- target = Ruact::ServerFunctions::QueryDispatch.route_target_for(acro)
491
- # Exactly how Rails' dispatcher resolves a "controller#action" target.
492
- resolved = "#{target.camelize}Controller".constantize
493
-
494
- expect(resolved).to be(controller)
495
- expect(target).to eq(
496
- "ruact/server_functions/query_dispatch/query_request_spec_support/api_probe/catalog_query"
497
- )
498
- end
499
-
500
- it "the SAME class re-mounting (dev reload) is still allowed" do
501
- expect do
502
- Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
503
- Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
504
- end.not_to raise_error
505
- end
506
- end
507
-
508
- describe "regeneration is idempotent (dev-mode routes reload)" do
509
- it "controller_for builds a fresh class on every call without const warnings" do
510
- first = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
511
- second = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
512
- expect(second).not_to be(first)
513
- expect(second.superclass).to be(ApplicationController)
514
- end
515
- end
516
- end
517
-
518
- RSpec.describe "Story 9.5: FR88 query kwargs sanitization", :story_9_5 do
519
- include Rack::Test::Methods
520
-
521
- let(:app_class) { ControllerRequestSpecSupport.app_class }
522
- let(:app) { app_class.instance }
523
-
524
- let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
525
-
526
- before do
527
- Rails.logger = Logger.new(IO::NULL)
528
- ControllerRequestSpecSupport.boot!
529
- end
530
-
531
- def structured_error(response)
532
- expect(response.status).to eq(400)
533
- body = JSON.parse(response.body)
534
- expect(body.fetch("_ruact_server_action_error")).to be(true)
535
- expect(body.fetch("error_class")).to eq("Ruact::BadRequestError")
536
- body
537
- end
538
-
539
- describe "AC3 happy path — declared primitives pass" do
540
- it "passes a required + optional kwarg by name (values arrive as strings)" do
541
- get "/q/search?q=ruby&limit=5", {}, login_headers
542
- expect(last_response.status).to eq(200)
543
- expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "5")
544
- end
545
-
546
- it "omits an absent optional kwarg so the method default applies" do
547
- get "/q/search?q=ruby", {}, login_headers
548
- expect(last_response.status).to eq(200)
549
- expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "10")
550
- end
551
-
552
- it "accepts boolean-ish / numeric-ish primitive strings on the wire" do
553
- get "/q/search?q=true&limit=0", {}, login_headers
554
- expect(last_response.status).to eq(200)
555
- expect(JSON.parse(last_response.body)).to eq("q" => "true", "limit" => "0")
556
- end
557
- end
558
-
559
- describe "AC3 — array param rejected (only string/number/boolean/null allowed)" do
560
- it "400s with a descriptive error naming the key and the allowlist" do
561
- get "/q/search?q[]=a&q[]=b", {}, login_headers
562
- body = structured_error(last_response)
563
- expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
564
- expect(body.fetch("message")).to match(/arrays and objects are rejected/)
565
- end
566
- end
567
-
568
- describe "AC3 — object (hash) param rejected" do
569
- it "400s with a descriptive error naming the offending key" do
570
- get "/q/search?q[deep]=1", {}, login_headers
571
- body = structured_error(last_response)
572
- expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
573
- end
574
- end
575
-
576
- describe "AC3 — missing required kwarg → 400 naming the missing parameter" do
577
- it "400s naming :q when it is absent" do
578
- get "/q/search?limit=5", {}, login_headers
579
- body = structured_error(last_response)
580
- expect(body.fetch("message")).to match(/missing required parameter\(s\) :q/)
581
- expect(body.fetch("action_name")).to eq("search")
582
- end
583
-
584
- it "400s naming :filter on a required-only method" do
585
- get "/q/tags", {}, login_headers
586
- body = structured_error(last_response)
587
- expect(body.fetch("message")).to match(/missing required parameter\(s\) :filter/)
588
- end
589
- end
590
-
591
- describe "AC3 — unknown param → 400 (rejected, not silently dropped)" do
592
- it "400s naming the unknown parameter" do
593
- get "/q/search?q=ruby&bogus=1", {}, login_headers
594
- body = structured_error(last_response)
595
- expect(body.fetch("message")).to match(/unknown parameter :bogus/)
596
- end
597
-
598
- it "rejects before running the query even when the declared param is also present" do
599
- get "/q/search?q=ruby&bogus=1", {}, login_headers
600
- expect(last_response.status).to eq(400)
601
- end
602
- end
603
-
604
- describe "AC3 — a `**rest` query opts into extra kwargs, but the type allowlist still applies" do
605
- it "accepts extra primitive params (not 'unknown' for a **rest signature)" do
606
- get "/q/flexible?q=ruby&extra=1&flag=true", {}, login_headers
607
- expect(last_response.status).to eq(200)
608
- expect(JSON.parse(last_response.body)).to eq(
609
- "q" => "ruby", "rest" => { "extra" => "1", "flag" => "true" }
610
- )
611
- end
612
-
613
- it "STILL rejects an array param on a **rest query (FR88 type boundary holds)" do
614
- get "/q/flexible?q=ruby&bad[]=1", {}, login_headers
615
- body = structured_error(last_response)
616
- expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
617
- end
618
-
619
- it "STILL rejects an object param on a **rest query" do
620
- get "/q/flexible?q=ruby&bad[x]=1", {}, login_headers
621
- body = structured_error(last_response)
622
- expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
623
- end
624
- end
625
-
626
- describe "null wire contract — a bare key (`?opt`) is delivered as nil, not empty string" do
627
- it "delivers nil (the bare-key wire form useQuery sends for null)" do
628
- get "/q/nullable?opt", {}, login_headers
629
- expect(last_response.status).to eq(200)
630
- expect(JSON.parse(last_response.body)).to eq("opt" => nil)
631
- end
632
-
633
- it "delivers an empty string for `?opt=` (distinct from null)" do
634
- get "/q/nullable?opt=", {}, login_headers
635
- expect(JSON.parse(last_response.body)).to eq("opt" => "")
636
- end
637
- end
638
- end
639
-
640
- # Story 13.2 (FR96) — SignedGlobalID references resolved through the REAL host
641
- # request cycle: a valid token round-trips; a tampered token surfaces as a clean
642
- # 400 via the Story 8.4 structured-error chain (no ActiveRecord::RecordNotFound
643
- # leak, no raw-id trust).
644
- RSpec.describe "Story 13.2: SignedGlobalID record references (FR96)", :story_13_2 do
645
- include Rack::Test::Methods
646
-
647
- let(:app_class) { ControllerRequestSpecSupport.app_class }
648
- let(:app) { app_class.instance }
649
-
650
- before do
651
- Rails.logger = Logger.new(IO::NULL)
652
- ControllerRequestSpecSupport.boot!
653
- # globalid signing context for the booted app (the 7.9 app sets
654
- # secret_key_base but does not load the globalid railtie).
655
- GlobalID.app = "ruact-test"
656
- SignedGlobalID.verifier = ActiveSupport::MessageVerifier.new("a" * 64)
657
- end
658
-
659
- def sign(record, purpose: :spec_ref, expires_in: 1.hour)
660
- Ruact.signed_global_id(record, for: purpose, expires_in: expires_in)
661
- end
662
-
663
- it "round-trips a valid token through the host chain (no auth needed — public query)" do
664
- record = QueryRequestSpecSupport::RefPost.new("42")
665
- get "/q/resolveRef", { "token" => sign(record) }
666
- expect(last_response.status).to(eq(200), "body=#{last_response.body[0, 300]}")
667
- expect(JSON.parse(last_response.body)).to eq("id" => "42")
668
- end
669
-
670
- it "rejects a tampered token as a clean 400 structured error (no RecordNotFound leak)" do
671
- record = QueryRequestSpecSupport::RefPost.new("99")
672
- get "/q/resolveRef", { "token" => "#{sign(record)}tamper" }
673
-
674
- expect(last_response.status).to eq(400)
675
- body = JSON.parse(last_response.body)
676
- expect(body.fetch("_ruact_server_action_error")).to be(true)
677
- expect(body.fetch("action_name")).to eq("resolve_ref")
678
- expect(body.fetch("error_class")).to eq("Ruact::InvalidSignedGlobalIDError")
679
- expect(last_response.body).not_to include("RecordNotFound")
680
- end
681
-
682
- it "rejects a wrong-purpose token as a clean 400" do
683
- record = QueryRequestSpecSupport::RefPost.new("7")
684
- get "/q/resolveRef", { "token" => sign(record, purpose: :other_purpose) }
685
- expect(last_response.status).to eq(400)
686
- expect(JSON.parse(last_response.body).fetch("error_class"))
687
- .to eq("Ruact::InvalidSignedGlobalIDError")
688
- end
689
-
690
- it "does not leak dev-only error fields in production payload mode" do
691
- record = QueryRequestSpecSupport::RefPost.new("5")
692
- Ruact.configure { |c| c.dev_error_payload_enabled = false }
693
- get "/q/resolveRef", { "token" => "#{sign(record)}tamper" }
694
- expect(last_response.status).to eq(400)
695
- body = JSON.parse(last_response.body)
696
- # production payload is exactly the four baseline keys — no split backtrace,
697
- # suggestion, or validation/upload blocks (the real dev-only field names).
698
- expect(body.keys).to contain_exactly(
699
- "_ruact_server_action_error", "action_name", "error_class", "message"
700
- )
701
- expect(body.keys).not_to include("app_frames", "gem_frames", "suggestion")
702
- ensure
703
- Ruact.instance_variable_set(:@config, nil)
704
- Ruact.instance_variable_set(:@configured_at_least_once, false)
705
- end
706
- end