ruact 0.0.1 → 0.0.3

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 (129) 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 +86 -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 +1680 -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 +89 -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 +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,446 @@
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
+ end
159
+
160
+ if defined?(ControllerRequestSpecSupport) &&
161
+ !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_query_routes_appended)
162
+ ControllerRequestSpecSupport.instance_variable_set(:@__ruact_query_routes_appended, true)
163
+ ControllerRequestSpecSupport.app_class.routes.append do
164
+ ruact_queries QueryRequestSpecSupport::CatalogQuery, QueryRequestSpecSupport::PublicCatalogQuery
165
+ end
166
+ end
167
+
168
+ RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do
169
+ include Rack::Test::Methods
170
+
171
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
172
+ let(:app) { app_class.instance }
173
+
174
+ let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
175
+
176
+ before do
177
+ Rails.logger = Logger.new(IO::NULL)
178
+ ControllerRequestSpecSupport.boot!
179
+ end
180
+
181
+ def reset_config
182
+ Ruact.instance_variable_set(:@config, nil)
183
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
184
+ end
185
+
186
+ describe "AC1 — ruact_queries draws one named GET route per public own method" do
187
+ let(:drawn) { app_class.routes.routes }
188
+ let(:named) { drawn.filter_map(&:name) }
189
+
190
+ it "mounts every public_instance_methods(false) of the subclass at /q/<jsIdentifier>" do
191
+ expect(named).to include(
192
+ "ruact_query_categories", "ruact_query_whoami", "ruact_query_echo",
193
+ "ruact_query_serializedPost", "ruact_query_leaky", "ruact_query_nothing",
194
+ "ruact_query_boom", "ruact_query_openCategories"
195
+ )
196
+ end
197
+
198
+ it "does NOT mount base-class methods, mixin methods, or the Ruact::Query accessors" do
199
+ expect(named).not_to include(
200
+ "ruact_query_baseHelper", "ruact_query_mixinHelper",
201
+ "ruact_query_currentUser", "ruact_query_params",
202
+ "ruact_query_request", "ruact_query_session"
203
+ )
204
+ end
205
+
206
+ it "draws GET routes under the default /q prefix, snake_case → camelCase (D4)" do
207
+ route = drawn.find { |r| r.name == "ruact_query_serializedPost" }
208
+ expect(route.verb).to eq("GET")
209
+ expect(route.path.spec.to_s).to eq("/q/serializedPost(.:format)")
210
+ end
211
+
212
+ it "honors a custom Ruact.config.query_route_prefix on a fresh draw (contract decision #7)" do
213
+ reset_config
214
+ Ruact.configure { |c| c.query_route_prefix = "/api/queries" }
215
+ route_set = ActionDispatch::Routing::RouteSet.new
216
+ route_set.draw { ruact_queries QueryRequestSpecSupport::ProbeQuery }
217
+ expect(route_set.routes.map { |r| r.path.spec.to_s }).to include("/api/queries/ping(.:format)")
218
+ end
219
+ end
220
+
221
+ describe "AC2 — dispatch controller inherits the host parent; chain runs BEFORE instantiation (FR89)" do
222
+ it "inherits Ruact.config.query_parent_controller (default ApplicationController)" do
223
+ controller = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::CatalogQuery)
224
+ expect(controller.superclass).to be(ApplicationController)
225
+ end
226
+
227
+ it "honors a custom query_parent_controller" do
228
+ reset_config
229
+ Ruact.configure { |c| c.query_parent_controller = "QueryRequestSpecSupport::AltParentController" }
230
+ controller = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
231
+ expect(controller.superclass).to be(QueryRequestSpecSupport::AltParentController)
232
+ end
233
+
234
+ it "raises Ruact::ConfigurationError at route-draw when the parent name does not resolve" do
235
+ reset_config
236
+ Ruact.configure { |c| c.query_parent_controller = "NoSuchParentController" }
237
+ expect { Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery) }
238
+ .to raise_error(Ruact::ConfigurationError, /NoSuchParentController.*does not resolve/m)
239
+ end
240
+
241
+ it "a halting before_action rejects the request and the query class is never instantiated" do
242
+ calls_before = QueryRequestSpecSupport::CatalogQuery.categories_calls
243
+ get "/q/categories"
244
+ expect(last_response.status).to eq(401)
245
+ expect(QueryRequestSpecSupport::CatalogQuery.categories_calls).to eq(calls_before)
246
+ end
247
+ end
248
+
249
+ describe "AC6 — GET /q/categories end-to-end through the host chain (manual fetch)" do
250
+ it "round-trips: 200, JSON content type, serialized return value" do
251
+ get "/q/categories", {}, login_headers
252
+ expect(last_response.status).to eq(200)
253
+ expect(last_response.headers["Content-Type"]).to include("application/json")
254
+ expect(JSON.parse(last_response.body)).to eq(
255
+ [{ "value" => 1, "label" => "Books" }, { "value" => 2, "label" => "Games" }]
256
+ )
257
+ end
258
+ end
259
+
260
+ describe "AC3 — current_user delegates to the host's own (private) method" do
261
+ it "the query reads the authenticated user end-to-end" do
262
+ get "/q/whoami", {}, login_headers
263
+ expect(last_response.status).to eq(200)
264
+ expect(JSON.parse(last_response.body)).to eq("user" => { "id" => 42, "name" => "Luiz" })
265
+ end
266
+ end
267
+
268
+ describe "AC4 — per-query callback opt-out (ruact_skip_before_action)" do
269
+ it "without the opt-out, the unauthenticated request is rejected (control)" do
270
+ get "/q/categories"
271
+ expect(last_response.status).to eq(401)
272
+ end
273
+
274
+ it "with the opt-out, the skipped callback does NOT run and the query returns" do
275
+ runs_before = QueryRequestSpecSupport.require_login_runs
276
+ get "/q/openCategories"
277
+ expect(last_response.status).to eq(200)
278
+ expect(JSON.parse(last_response.body)).to eq(%w[alpha beta])
279
+ expect(QueryRequestSpecSupport.require_login_runs).to eq(runs_before)
280
+ end
281
+
282
+ it "the opt-out does not leak to sibling query classes" do
283
+ get "/q/whoami"
284
+ expect(last_response.status).to eq(401)
285
+ end
286
+ end
287
+
288
+ describe "AC5 — GET, no CSRF, serialized return value, error chain" do
289
+ it "a tokenless GET succeeds despite protect_from_forgery with: :exception (queries are reads)" do
290
+ get "/q/categories", {}, login_headers
291
+ expect(last_response.status).to eq(200)
292
+ end
293
+
294
+ it "serializes a Serializable return through ruact_props — :secret never leaks" do
295
+ get "/q/serializedPost", {}, login_headers
296
+ body = JSON.parse(last_response.body)
297
+ expect(body).to eq("id" => 1, "title" => "Hi")
298
+ expect(body).not_to have_key("secret")
299
+ end
300
+
301
+ it "under strict_serialization, a non-Serializable return → structured 500 (SerializationError)" do
302
+ reset_config
303
+ Ruact.configure { |c| c.strict_serialization = true }
304
+ get "/q/leaky", {}, login_headers
305
+ expect(last_response.status).to eq(500)
306
+ body = JSON.parse(last_response.body)
307
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
308
+ expect(body.fetch("error_class")).to eq("Ruact::SerializationError")
309
+ expect(body.fetch("message")).to match(/Cannot serialize QueryRequestSpecSupport::LeakyRecord/)
310
+ end
311
+
312
+ it "a query raise renders the salvaged 8.4 structured payload on a GET (D5 gate override)" do
313
+ get "/q/boom", {}, login_headers
314
+ expect(last_response.status).to eq(500)
315
+ body = JSON.parse(last_response.body)
316
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
317
+ expect(body.fetch("action_name")).to eq("boom")
318
+ expect(body.fetch("error_class")).to eq("RuntimeError")
319
+ expect(body.fetch("message")).to eq("query exploded")
320
+ end
321
+ end
322
+
323
+ describe "D6 — nil return renders JSON null (200), not 204" do
324
+ it "renders the literal null body" do
325
+ get "/q/nothing", {}, login_headers
326
+ expect(last_response.status).to eq(200)
327
+ expect(last_response.body).to eq("null")
328
+ end
329
+ end
330
+
331
+ describe "D7 — best-effort kwargs from GET query params (strict FR88 sanitization is 9.5)" do
332
+ it "passes a declared keyword argument by name" do
333
+ get "/q/echo?term=ruby", {}, login_headers
334
+ expect(JSON.parse(last_response.body)).to eq("term" => "ruby")
335
+ end
336
+
337
+ it "omits an absent keyword so the method default applies" do
338
+ get "/q/echo", {}, login_headers
339
+ expect(JSON.parse(last_response.body)).to eq("term" => "default")
340
+ end
341
+ end
342
+
343
+ describe "review round 1 — query method names colliding with controller plumbing are rejected" do
344
+ it "raises Ruact::ConfigurationError when a query method would shadow a framework method" do
345
+ shadowing = Class.new(Ruact::Query) do
346
+ def params
347
+ :shadowed
348
+ end
349
+ end
350
+ stub_const("QueryRequestSpecSupport::ParamsShadowQuery", shadowing)
351
+ expect { Ruact::ServerFunctions::QueryDispatch.controller_for(shadowing) }
352
+ .to raise_error(Ruact::ConfigurationError, /\bparams\b.*already defined/m)
353
+ end
354
+
355
+ it "rejects `render` too (any name on the generated controller chain)" do
356
+ shadowing = Class.new(Ruact::Query) do
357
+ def render
358
+ :shadowed
359
+ end
360
+ end
361
+ stub_const("QueryRequestSpecSupport::RenderShadowQuery", shadowing)
362
+ expect { Ruact::ServerFunctions::QueryDispatch.controller_for(shadowing) }
363
+ .to raise_error(Ruact::ConfigurationError, /\brender\b.*already defined/m)
364
+ end
365
+ end
366
+
367
+ describe "review round 4 — namespace is PRESERVED so collisions are impossible by construction" do
368
+ it "maps Admin::CatalogQuery and AdminCatalogQuery to DISTINCT controllers + route targets" do
369
+ namespaced = Class.new(Ruact::Query) { def ping_a = :a }
370
+ flat = Class.new(Ruact::Query) { def ping_b = :b }
371
+ stub_const("QueryRequestSpecSupport::Nspace::CatalogQuery", namespaced)
372
+ stub_const("QueryRequestSpecSupport::NspaceCatalogQuery", flat)
373
+
374
+ c1 = Ruact::ServerFunctions::QueryDispatch.controller_for(namespaced)
375
+ c2 = Ruact::ServerFunctions::QueryDispatch.controller_for(flat)
376
+
377
+ # Every namespace segment is preserved (`::Nspace::CatalogQuery` keeps the
378
+ # boundary; `NspaceCatalogQuery` has no boundary) — the two map to
379
+ # DISTINCT nested constants, so a const overwrite / cross-wire is
380
+ # impossible regardless of how many RouteSets share the dispatch module.
381
+ expect(c1).not_to be(c2)
382
+ expect(c1.name).to eq(
383
+ "Ruact::ServerFunctions::QueryDispatch::QueryRequestSpecSupport::Nspace::CatalogQueryController"
384
+ )
385
+ expect(c2.name).to eq(
386
+ "Ruact::ServerFunctions::QueryDispatch::QueryRequestSpecSupport::NspaceCatalogQueryController"
387
+ )
388
+ expect(Ruact::ServerFunctions::QueryDispatch.route_target_for(namespaced))
389
+ .to eq("ruact/server_functions/query_dispatch/query_request_spec_support/nspace/catalog_query")
390
+ expect(Ruact::ServerFunctions::QueryDispatch.route_target_for(flat))
391
+ .to eq("ruact/server_functions/query_dispatch/query_request_spec_support/nspace_catalog_query")
392
+ end
393
+
394
+ it "mounts both on the same RouteSet without error, each routing to its own class (no cross-wiring)" do
395
+ namespaced = Class.new(Ruact::Query) { def from_ns = "ns" }
396
+ flat = Class.new(Ruact::Query) { def from_flat = "flat" }
397
+ stub_const("QueryRequestSpecSupport::Pair::TwinQuery", namespaced)
398
+ stub_const("QueryRequestSpecSupport::PairTwinQuery", flat)
399
+
400
+ route_set = ActionDispatch::Routing::RouteSet.new
401
+ expect do
402
+ route_set.draw do
403
+ ruact_queries namespaced
404
+ ruact_queries flat
405
+ end
406
+ end.not_to raise_error
407
+
408
+ targets = route_set.routes.filter_map { |r| r.defaults[:controller] }
409
+ expect(targets).to include(
410
+ "ruact/server_functions/query_dispatch/query_request_spec_support/pair/twin_query",
411
+ "ruact/server_functions/query_dispatch/query_request_spec_support/pair_twin_query"
412
+ )
413
+ end
414
+
415
+ it "round-trips acronym namespaces so Rails resolves the generated controller (round 5)" do
416
+ acro = Class.new(Ruact::Query) { def ping_e = :e }
417
+ stub_const("QueryRequestSpecSupport::APIProbe::CatalogQuery", acro)
418
+
419
+ controller = Ruact::ServerFunctions::QueryDispatch.controller_for(acro)
420
+ target = Ruact::ServerFunctions::QueryDispatch.route_target_for(acro)
421
+ # Exactly how Rails' dispatcher resolves a "controller#action" target.
422
+ resolved = "#{target.camelize}Controller".constantize
423
+
424
+ expect(resolved).to be(controller)
425
+ expect(target).to eq(
426
+ "ruact/server_functions/query_dispatch/query_request_spec_support/api_probe/catalog_query"
427
+ )
428
+ end
429
+
430
+ it "the SAME class re-mounting (dev reload) is still allowed" do
431
+ expect do
432
+ Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
433
+ Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
434
+ end.not_to raise_error
435
+ end
436
+ end
437
+
438
+ describe "regeneration is idempotent (dev-mode routes reload)" do
439
+ it "controller_for builds a fresh class on every call without const warnings" do
440
+ first = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
441
+ second = Ruact::ServerFunctions::QueryDispatch.controller_for(QueryRequestSpecSupport::ProbeQuery)
442
+ expect(second).not_to be(first)
443
+ expect(second.superclass).to be(ApplicationController)
444
+ end
445
+ end
446
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 9.4 — unit spec for the Ruact::Query base class. Deliberately NO Rails
4
+ # boot (AC3): a query subclass is exercised with a plain double standing in for
5
+ # the dispatch context, proving `CatalogQuery.new(fake_context).categories` is
6
+ # unit-testable in isolation.
7
+ require "spec_helper"
8
+
9
+ module Ruact
10
+ RSpec.describe Query, :story_9_4 do
11
+ let(:fake_user) { { "id" => 42 } }
12
+ let(:fake_params) { { "q" => "ruby" } }
13
+ let(:fake_request) { instance_double(Object) }
14
+ let(:fake_session) { { "token" => "abc" } }
15
+ let(:context) do
16
+ double(
17
+ current_user: fake_user,
18
+ params: fake_params,
19
+ request: fake_request,
20
+ session: fake_session
21
+ )
22
+ end
23
+
24
+ let(:query_class) do
25
+ Class.new(described_class) do
26
+ def categories
27
+ %w[books games]
28
+ end
29
+
30
+ def whoami
31
+ current_user
32
+ end
33
+
34
+ def search
35
+ params["q"]
36
+ end
37
+ end
38
+ end
39
+
40
+ describe "Story 9.4 — context accessors delegate to the injected context (AC3)" do
41
+ subject(:query) { query_class.new(context) }
42
+
43
+ it "exposes current_user from the context" do
44
+ expect(query.whoami).to eq(fake_user)
45
+ end
46
+
47
+ it "exposes params from the context" do
48
+ expect(query.search).to eq("ruby")
49
+ end
50
+
51
+ it "exposes request from the context" do
52
+ expect(query.request).to be(fake_request)
53
+ end
54
+
55
+ it "exposes session from the context" do
56
+ expect(query.session).to eq(fake_session)
57
+ end
58
+
59
+ it "is unit-testable with a plain fake context and no Rails boot (AC3)" do
60
+ expect(query_class.new(context).categories).to eq(%w[books games])
61
+ end
62
+ end
63
+
64
+ describe "Story 9.4 — accessor methods are inherited, never own methods of the subclass (AC1)" do
65
+ it "keeps current_user/params/request/session OUT of the subclass's public_instance_methods(false)" do
66
+ own = query_class.public_instance_methods(false)
67
+ expect(own).to contain_exactly(:categories, :whoami, :search)
68
+ end
69
+
70
+ it "defines the accessors on Ruact::Query itself (inherited by every subclass)" do
71
+ expect(described_class.public_instance_methods(false))
72
+ .to include(:current_user, :params, :request, :session)
73
+ end
74
+ end
75
+
76
+ describe "Story 9.4 — ruact_skip_before_action class macro (AC4 / D1)" do
77
+ it "records the callback with its options on the query class" do
78
+ klass = Class.new(described_class)
79
+ klass.ruact_skip_before_action(:require_login, only: :categories)
80
+ expect(klass.__ruact_skipped_callbacks).to eq([[[:require_login], { only: :categories }]])
81
+ end
82
+
83
+ it "accepts multiple callbacks in one call, mirroring Rails' skip_before_action" do
84
+ klass = Class.new(described_class)
85
+ klass.ruact_skip_before_action(:require_login, :check_tenant)
86
+ expect(klass.__ruact_skipped_callbacks).to eq([[%i[require_login check_tenant], {}]])
87
+ end
88
+
89
+ it "accumulates across calls in declaration order" do
90
+ klass = Class.new(described_class)
91
+ klass.ruact_skip_before_action(:require_login)
92
+ klass.ruact_skip_before_action(:check_tenant, raise: false)
93
+ expect(klass.__ruact_skipped_callbacks)
94
+ .to eq([[[:require_login], {}], [[:check_tenant], { raise: false }]])
95
+ end
96
+
97
+ it "keeps the recorded skips per-class — sibling query classes never share them" do
98
+ klass_a = Class.new(described_class)
99
+ klass_b = Class.new(described_class)
100
+ klass_a.ruact_skip_before_action(:require_login)
101
+ expect(klass_b.__ruact_skipped_callbacks).to be_empty
102
+ end
103
+ end
104
+ end
105
+ end
@@ -107,9 +107,8 @@ RSpec.describe Ruact::Railtie do
107
107
  end
108
108
 
109
109
  context "with missing manifest in production (AC#6)" do
110
- before do
111
- Rails.env = ActiveSupport::StringInquirer.new("production")
112
- end
110
+ before { Rails.env = ActiveSupport::StringInquirer.new("production") }
111
+ after { Rails.env = ActiveSupport::StringInquirer.new("development") }
113
112
 
114
113
  it "raises ManifestError" do
115
114
  expect { described_class.check_manifest!(missing_path) }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe RenderContext do
7
+ subject(:ctx) { described_class.new }
8
+
9
+ describe "#register" do
10
+ it "appends a new component entry" do
11
+ ctx.register("NavBar", { "currentUser" => 1 })
12
+ expect(ctx.components.length).to eq(1)
13
+ expect(ctx.components.first[:name]).to eq("NavBar")
14
+ expect(ctx.components.first[:props]).to eq({ "currentUser" => 1 })
15
+ end
16
+
17
+ it "returns a token of the form __RUACT_<index>__" do
18
+ token = ctx.register("Foo", {})
19
+ expect(token).to eq("__RUACT_0__")
20
+ end
21
+
22
+ it "increments token indices across successive registrations" do
23
+ t0 = ctx.register("A", {})
24
+ t1 = ctx.register("B", {})
25
+ t2 = ctx.register("C", {})
26
+ expect([t0, t1, t2]).to eq(%w[__RUACT_0__ __RUACT_1__ __RUACT_2__])
27
+ end
28
+ end
29
+
30
+ describe "#components" do
31
+ it "starts empty" do
32
+ expect(ctx.components).to eq([])
33
+ end
34
+ end
35
+
36
+ describe "#by_token" do
37
+ it "finds a registered component by its token" do
38
+ ctx.register("NavBar", { "x" => 1 })
39
+ entry = ctx.by_token("__RUACT_0__")
40
+ expect(entry[:name]).to eq("NavBar")
41
+ end
42
+
43
+ it "returns nil for an unknown token" do
44
+ expect(ctx.by_token("__RUACT_99__")).to be_nil
45
+ end
46
+ end
47
+
48
+ describe "isolation" do
49
+ it "two contexts are independent" do
50
+ a = described_class.new
51
+ b = described_class.new
52
+ a.register("A", {})
53
+ expect(b.components).to be_empty
54
+ expect(a.components.length).to eq(1)
55
+ end
56
+ end
57
+ end
58
+ end