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,819 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.1 — full-request-cycle spec covering POST /__ruact/fn/:name dispatch.
4
+ #
5
+ # Boots a minimal Rails::Application with `Ruact::Railtie` (which mounts the
6
+ # `ruact/server_functions/endpoint#dispatch_action` route) and exercises:
7
+ # - Registry lookup (known + unknown name)
8
+ # - JSON request body → `params` shadow inside the block
9
+ # - FormData / urlencoded body → same shadow
10
+ # - host controller's `before_action` chain runs before the block
11
+ # - block return value rendered as JSON
12
+ # - rescue_from on the host controller catches errors raised inside the block
13
+ #
14
+ # Follows the Story 7.9 pattern (controller_request_spec.rb) — request-cycle
15
+ # subsystems are loaded HERE so the rest of the suite continues to use
16
+ # spec/support/rails_stub.rb without paying the full Rails boot cost.
17
+ require "action_controller/railtie"
18
+ require "action_view/railtie"
19
+
20
+ require "spec_helper"
21
+ require "rack/test"
22
+ require "tempfile"
23
+
24
+ require "ruact/controller"
25
+ require "ruact/server_functions/endpoint_controller"
26
+
27
+ # Re-run-5 (2026-05-15) — explicitly require `ruact/railtie` (NOT just
28
+ # `ruact`, which is cached as already-loaded by `spec_helper.rb`'s
29
+ # earlier `require "ruact"` that ran BEFORE Rails was defined and
30
+ # therefore skipped the conditional `require_relative "ruact/railtie"
31
+ # if defined?(Rails)` at the bottom of `ruact.rb`). Loading the
32
+ # Railtie file directly registers `Ruact::Railtie` with Rails so its
33
+ # `routes.prepend` AND `config.to_prepare` initializers (latter wires
34
+ # `Ruact::ErbPreprocessorHook` into `ActionView::Template::Handlers::ERB`)
35
+ # fire when the test app's `initialize!` runs.
36
+ require "ruact/railtie"
37
+
38
+ # Re-run-2 (2026-05-14): exercise the REAL `ActiveRecord::RecordInvalid`
39
+ # rather than a structural stub. ActiveRecord is part of Rails (already a
40
+ # dev dep via `gem "rails"`), so requiring `active_model` for the underlying
41
+ # validation error and `active_record` for `RecordInvalid` is cheap. The
42
+ # require is local to this spec — the rest of the suite continues to use
43
+ # the lightweight rails_stub.rb path.
44
+ require "active_model"
45
+ require "active_record"
46
+ require "i18n"
47
+
48
+ # Load ActiveModel + ActiveRecord locale files so `RecordInvalid#message`
49
+ # resolves the `errors.messages.record_invalid` translation key — without
50
+ # this, `error.message` returns "Translation missing: en.activemodel.errors..."
51
+ # instead of the human-readable "Validation failed: Title can't be blank".
52
+ {
53
+ "activemodel" => "active_model",
54
+ "activerecord" => "active_record"
55
+ }.each do |gem_name, dir|
56
+ spec = Gem.loaded_specs[gem_name]
57
+ next unless spec
58
+
59
+ locale_file = File.join(spec.gem_dir, "lib", dir, "locale", "en.yml")
60
+ I18n.load_path << locale_file if File.exist?(locale_file)
61
+ end
62
+ I18n.backend.load_translations
63
+
64
+ # Reuse the Rails::Application booted by `controller_request_spec.rb` if it
65
+ # has already been loaded into this RSpec process — Rails does not support
66
+ # two distinct `Rails::Application` subclasses initialized in the same
67
+ # process (the second `initialize!` raises `FrozenError` on shared internal
68
+ # state). When this spec runs alone, build a dedicated minimal app.
69
+ require_relative "../controller_request_spec" if defined?(Rails::Application) &&
70
+ !defined?(ControllerRequestSpecSupport)
71
+
72
+ # Re-run-5 (2026-05-15) — when reusing the Story 7.9 test app, append
73
+ # the gem's `POST /__ruact/fn/:name` route AT LOAD TIME (before any
74
+ # test runs). This is the only safe window: once `initialize!` has run
75
+ # for the app (driven by EITHER spec's first `boot!`), adding routes
76
+ # post-finalization is unreliable. Doing it here, at spec file load,
77
+ # guarantees the route lands BEFORE either spec calls `boot!`.
78
+ # Review F10 (2026-05-19 re-review) — idempotence guard. This file's
79
+ # top-level block can be re-executed when RSpec's runner `Kernel.load`s the
80
+ # file from an explicit `rspec <files>...` invocation that ALSO names
81
+ # `endpoint_controller_rescue_spec.rb` (whose `require_relative` already
82
+ # loaded this file once). Without the guard, the second pass calls
83
+ # `routes.append` against the same name and Rails raises
84
+ # `ArgumentError: Invalid route name, already in use: 'ruact_server_function_spec'`.
85
+ # The flag lives on `ControllerRequestSpecSupport` (not on a top-level
86
+ # constant) so it ties the dedupe to the actual Rails app the route is
87
+ # attached to.
88
+ if defined?(ControllerRequestSpecSupport) &&
89
+ !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_endpoint_route_appended)
90
+ ControllerRequestSpecSupport.instance_variable_set(:@__ruact_endpoint_route_appended, true)
91
+ ControllerRequestSpecSupport.app_class.routes.append do
92
+ post "/__ruact/fn/:name",
93
+ to: "ruact/server_functions/endpoint#dispatch_action",
94
+ as: :ruact_server_function_spec,
95
+ constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
96
+ end
97
+ end
98
+
99
+ require "ruact/server_action"
100
+
101
+ module DispatchRequestSpecSupport
102
+ class << self
103
+ def app_class
104
+ @app_class ||=
105
+ if defined?(ControllerRequestSpecSupport)
106
+ ControllerRequestSpecSupport.app_class
107
+ else
108
+ build_app_class
109
+ end
110
+ end
111
+
112
+ def boot!
113
+ return if @booted
114
+
115
+ if defined?(ControllerRequestSpecSupport)
116
+ ControllerRequestSpecSupport.boot!
117
+ else
118
+ # Re-run-5 — when this spec runs standalone (no Story 7.9 app
119
+ # in the process), draw the gem route on its own app BEFORE
120
+ # initialize! so dispatch tests find it.
121
+ app_class.routes.append do
122
+ post "/__ruact/fn/:name",
123
+ to: "ruact/server_functions/endpoint#dispatch_action",
124
+ as: :ruact_server_function_standalone,
125
+ constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
126
+ end
127
+ app_class.instance.initialize!
128
+ end
129
+ @booted = true
130
+ end
131
+
132
+ private
133
+
134
+ def build_app_class
135
+ Class.new(Rails::Application) do
136
+ config.eager_load = false
137
+ config.consider_all_requests_local = true
138
+ config.action_controller.perform_caching = false
139
+ config.action_dispatch.show_exceptions = :none
140
+ config.logger = Logger.new(IO::NULL)
141
+ config.active_support.deprecation = :silence
142
+ config.secret_key_base = "x" * 64
143
+ config.hosts.clear if config.respond_to?(:hosts)
144
+ end
145
+ end
146
+ end
147
+
148
+ # Minimal ActiveModel-compatible record class so a REAL
149
+ # `ActiveRecord::RecordInvalid` instance can be constructed (the error's
150
+ # `#initialize(record)` reads `record.errors.full_messages`). This keeps
151
+ # the AC9 spec faithful to the literal AC wording while not requiring a
152
+ # full ActiveRecord schema/setup.
153
+ class StubPost
154
+ include ActiveModel::Model
155
+
156
+ attr_accessor :title
157
+
158
+ validates :title, presence: true
159
+
160
+ # Override `i18n_scope` to `:activerecord` so `RecordInvalid#message`
161
+ # resolves the `activerecord.errors.messages.record_invalid` key
162
+ # ("Validation failed: %{errors}") instead of falling through to the
163
+ # missing `activemodel.errors.messages.record_invalid`.
164
+ def self.i18n_scope
165
+ :activerecord
166
+ end
167
+ end
168
+
169
+ class TestController < ActionController::Base
170
+ include Ruact::Controller
171
+
172
+ rescue_from RuntimeError do |error|
173
+ render(json: { error: error.message, error_class: error.class.name }, status: :unprocessable_entity)
174
+ end
175
+
176
+ rescue_from ActiveRecord::RecordInvalid do |error|
177
+ render(
178
+ json: { error: error.message, error_class: error.class.name, validation: true },
179
+ status: :unprocessable_entity
180
+ )
181
+ end
182
+
183
+ before_action :require_token, only: %i[authed_action]
184
+
185
+ # Re-run-3 (2026-05-15) — simulates a host before_action that touches
186
+ # `request.body` (e.g., a generic audit/logging filter that reads the
187
+ # raw POST body for signature verification). Pre-batch, `body.read`
188
+ # advanced the IO to EOF, so the action's own `body.read` returned
189
+ # `""` and silently coerced the action call to `{}`. The fix uses
190
+ # `request.raw_post` (Rack-cached) so the action still sees the
191
+ # original body. The `body_peek` action below proves it.
192
+ before_action :peek_body, only: %i[body_peek]
193
+ def peek_body
194
+ @peeked = request.body.read.tap { request.body.rewind if request.body.respond_to?(:rewind) }
195
+ end
196
+
197
+ # spec_helper wipes the registries between examples (lazy-init singletons
198
+ # reset to fresh instances), so the controller's class-body `ruact_action`
199
+ # declarations would only populate the original singleton — invisible to
200
+ # the new one a subsequent example sees. Re-register at the start of every
201
+ # example via this class method instead.
202
+ def self.register_ruact_actions!
203
+ ruact_action(:echo) { |params| { "echoed" => params.to_unsafe_h } }
204
+
205
+ ruact_action(:fail_hard) { |_params| raise "intentional failure" }
206
+
207
+ ruact_action(:authed_action) { |params| { "ok" => true, "by" => params[:by] } }
208
+
209
+ ruact_action(:capture_both) do |params|
210
+ {
211
+ "block_params" => params.to_unsafe_h,
212
+ "request_params_name" => self.params[:name]
213
+ }
214
+ end
215
+
216
+ ruact_action(:strong_params_demo) do |params|
217
+ permitted = params.require(:post).permit(:title, :body)
218
+ { "permitted" => permitted.to_h }
219
+ end
220
+
221
+ ruact_action(:nil_return) { |_p| nil }
222
+
223
+ ruact_action(:invalid_record) do |_p|
224
+ record = DispatchRequestSpecSupport::StubPost.new
225
+ record.valid? # populates record.errors
226
+ raise ActiveRecord::RecordInvalid, record
227
+ end
228
+
229
+ ruact_action(:body_peek) do |params|
230
+ { "echoed" => params.to_unsafe_h }
231
+ end
232
+
233
+ ruact_action(:routing_identity) do |_p|
234
+ # Re-run-2 (2026-05-14) — proves that `params[:controller]` and
235
+ # `params[:action]` inside the host action describe the HOST class,
236
+ # not the gem endpoint route.
237
+ {
238
+ "controller" => controller_path,
239
+ "action" => action_name,
240
+ "params_controller" => self.params[:controller],
241
+ "params_action" => self.params[:action]
242
+ }
243
+ end
244
+ end
245
+
246
+ private
247
+
248
+ def require_token
249
+ return if request.headers["X-Test-Token"] == "secret"
250
+
251
+ render(json: { error: "unauthorized" }, status: :unauthorized)
252
+ end
253
+ end
254
+ end
255
+
256
+ Rails.application = nil if Rails.respond_to?(:application) && !Rails.application.is_a?(Rails::Application)
257
+
258
+ RSpec.describe "Story 8.1: POST /__ruact/fn/:name dispatch", :story_8_1 do
259
+ include Rack::Test::Methods
260
+
261
+ let(:app_class) { DispatchRequestSpecSupport.app_class }
262
+ let(:app) { app_class.instance }
263
+
264
+ before do
265
+ Rails.logger = Logger.new(IO::NULL)
266
+ DispatchRequestSpecSupport.boot!
267
+ # spec_helper resets the registry singletons between examples — re-register
268
+ # so the endpoint controller can resolve the test action names. (Production
269
+ # gets re-registrations naturally from class-body evaluation at controller
270
+ # autoload; the test environment short-circuits that.)
271
+ DispatchRequestSpecSupport::TestController.register_ruact_actions!
272
+ end
273
+
274
+ describe "AC2 — happy-path dispatch" do
275
+ it "dispatches a registered action with JSON body and returns the block's return value as JSON" do
276
+ post "/__ruact/fn/echo", { "title" => "Hi" }.to_json, { "CONTENT_TYPE" => "application/json" }
277
+ expect(last_response.status).to eq(200)
278
+ expect(last_response.headers["Content-Type"]).to include("application/json")
279
+ expect(JSON.parse(last_response.body)).to eq("echoed" => { "title" => "Hi" })
280
+ end
281
+
282
+ it "returns 404 with a structured error for an unknown action name" do
283
+ post "/__ruact/fn/no_such_thing", "{}", { "CONTENT_TYPE" => "application/json" }
284
+ expect(last_response.status).to eq(404)
285
+ expect(JSON.parse(last_response.body)).to eq("error" => "unknown ruact action: :no_such_thing")
286
+ end
287
+
288
+ it "accepts form-encoded request bodies (FormData / urlencoded)" do
289
+ post "/__ruact/fn/echo", { "title" => "From form" }
290
+ expect(last_response.status).to eq(200)
291
+ body = JSON.parse(last_response.body)
292
+ expect(body.fetch("echoed")).to include("title" => "From form")
293
+ end
294
+
295
+ it "treats an empty request body as an empty params hash" do
296
+ post "/__ruact/fn/echo", "", { "CONTENT_TYPE" => "application/json" }
297
+ expect(last_response.status).to eq(200)
298
+ expect(JSON.parse(last_response.body)).to eq("echoed" => {})
299
+ end
300
+
301
+ it "preserves form-encoded body fields named `name`, `action`, `controller` " \
302
+ "(review-batch 2 — drop spurious `.except`)" do
303
+ post "/__ruact/fn/echo", { "name" => "alice", "action" => "submit", "controller" => "foo" }
304
+ expect(last_response.status).to eq(200)
305
+ body = JSON.parse(last_response.body)
306
+ expect(body.fetch("echoed")).to include(
307
+ "name" => "alice",
308
+ "action" => "submit",
309
+ "controller" => "foo"
310
+ )
311
+ end
312
+
313
+ it "returns a structured 400 on malformed JSON instead of silently treating it as {} " \
314
+ "(re-run-4 #1 — structured bad-request response, not raw JSON::ParserError)" do
315
+ # Pre-Re-run-4 this surfaced a raw `JSON::ParserError` from inside
316
+ # the action body. Now `ruact_action`'s defined method catches the
317
+ # parse error and renders a 400 with a `{error}` JSON body — same
318
+ # contract as the unknown-action 404 path.
319
+ post "/__ruact/fn/echo", "{ not json", { "CONTENT_TYPE" => "application/json" }
320
+ expect(last_response.status).to eq(400)
321
+ body = JSON.parse(last_response.body)
322
+ expect(body.fetch("error")).to match(/malformed JSON body/)
323
+ end
324
+ end
325
+
326
+ describe "AC3 — before_action chain runs before the block" do
327
+ it "the host's before_action short-circuits without ever executing the block" do
328
+ post "/__ruact/fn/authed_action",
329
+ { "by" => "alice" }.to_json,
330
+ { "CONTENT_TYPE" => "application/json" }
331
+ expect(last_response.status).to eq(401)
332
+ expect(JSON.parse(last_response.body)).to eq("error" => "unauthorized")
333
+ end
334
+
335
+ it "the block runs only when the before_action passes" do
336
+ post "/__ruact/fn/authed_action",
337
+ { "by" => "alice" }.to_json,
338
+ { "CONTENT_TYPE" => "application/json", "HTTP_X_TEST_TOKEN" => "secret" }
339
+ expect(last_response.status).to eq(200)
340
+ expect(JSON.parse(last_response.body)).to eq("ok" => true, "by" => "alice")
341
+ end
342
+ end
343
+
344
+ describe "AC5 — params shadow inside the block" do
345
+ it "the block's `params` argument carries the action-call args; the controller's " \
346
+ "`params` accessor carries the routing data (controller/action)" do
347
+ # Re-run-4 (#2): `:name` is no longer injected into
348
+ # `request.path_parameters` (would shadow a legitimate body field
349
+ # named `:name`). The block's `params` is the body; the controller's
350
+ # `params` carries `:controller` and `:action` (Rails routing data).
351
+ post "/__ruact/fn/capture_both",
352
+ { "title" => "From body" }.to_json,
353
+ { "CONTENT_TYPE" => "application/json" }
354
+ body = JSON.parse(last_response.body)
355
+ expect(body.fetch("block_params")).to eq("title" => "From body")
356
+ expect(body.fetch("request_params_name")).to be_nil
357
+ end
358
+
359
+ it "preserves a body field literally named `:name` (re-run-4 #2 — no leak from path_parameters)" do
360
+ # Send `{ "name": "alice" }` as the body. Pre-batch the dispatcher
361
+ # had injected `name: "send_name"` into path_parameters which
362
+ # merged into params and shadowed the body field; the block would
363
+ # have seen `params[:name] == "send_name"`. Now it sees "alice".
364
+ post "/__ruact/fn/echo",
365
+ { "name" => "alice" }.to_json,
366
+ { "CONTENT_TYPE" => "application/json" }
367
+ expect(last_response.status).to eq(200)
368
+ expect(JSON.parse(last_response.body)).to eq("echoed" => { "name" => "alice" })
369
+ end
370
+ end
371
+
372
+ describe "AC3 — rescue_from on host controller catches block errors" do
373
+ it "wraps a block-raised RuntimeError into a structured 422 via the host's rescue_from" do
374
+ post "/__ruact/fn/fail_hard", "{}", { "CONTENT_TYPE" => "application/json" }
375
+ expect(last_response.status).to eq(422)
376
+ body = JSON.parse(last_response.body)
377
+ expect(body).to eq("error" => "intentional failure", "error_class" => "RuntimeError")
378
+ end
379
+ end
380
+
381
+ describe "AC2 — 204 No Content for nil block return (review-batch 1 2026-05-14)" do
382
+ it "renders 204 with empty body when the block returns nil" do
383
+ post "/__ruact/fn/nil_return", "{}", { "CONTENT_TYPE" => "application/json" }
384
+ expect(last_response.status).to eq(204)
385
+ expect(last_response.body).to eq("")
386
+ end
387
+ end
388
+
389
+ describe "AC8 — CSRF contract (review-batch 5 2026-05-14)" do
390
+ # The gem-level endpoint MUST skip forgery protection itself — otherwise
391
+ # the route would reject requests before reaching the host controller
392
+ # that's supposed to be the source of truth for CSRF. The host's
393
+ # `protect_from_forgery` then enforces (or doesn't, in API mode).
394
+ it "EndpointController applies skip_forgery_protection at the class level (Story 8.3 — CSRF " \
395
+ "for controller-hosted actions is delegated; verify_authenticity_token is wired " \
396
+ "conditionally for the standalone branch only — see Story 8.3 AC5)" do
397
+ callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
398
+ verify_callback = callbacks.find { |c| c.filter == :verify_authenticity_token }
399
+
400
+ if verify_callback
401
+ # Story 8.3 — the callback exists but is gated by `dispatching_standalone?`,
402
+ # so it never fires on the controller-hosted dispatch path (the host's own
403
+ # `protect_from_forgery` remains the single source of CSRF truth for that branch).
404
+ expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
405
+ else
406
+ # Pre-Story-8.3: the callback was removed unconditionally.
407
+ expect(callbacks.map(&:filter)).not_to include(:verify_authenticity_token)
408
+ end
409
+ end
410
+
411
+ it "EndpointController inherits from ActionController::Base — runs the full CSRF middleware stack " \
412
+ "on dispatch (allowing the host's protect_from_forgery to take effect)" do
413
+ expect(Ruact::ServerFunctions::EndpointController.ancestors).to include(ActionController::Base)
414
+ end
415
+
416
+ # End-to-end CSRF behavior — a host controller with protect_from_forgery
417
+ # rejecting an invalid token — is a Rails-stack integration concern
418
+ # that requires session middleware + a real Rails request cycle with
419
+ # `config.action_controller.allow_forgery_protection = true`. The
420
+ # contract is preserved by virtue of (a) the gem skipping forgery on
421
+ # ITS endpoint and (b) delegating to `host_class.dispatch` which runs
422
+ # the host's own `verify_authenticity_token` filter. Pre-batch-5
423
+ # versions of the story file flagged this as deferred to Story 8.2's
424
+ # `<form action={fn}>` integration where CSRF is the user-visible path.
425
+ end
426
+
427
+ describe "Story 8.5 — regression: multipart UploadedFile passes through ruact_action_raw_args", :story_8_5 do
428
+ # Regression guard against future refactors of the controller-hosted
429
+ # branch's multipart path (controller.rb `ruact_action_raw_args` →
430
+ # `request.request_parameters`). The deep request-cycle coverage moved to
431
+ # the v2 concern in Story 9.1 (`spec/ruact/server_upload_request_spec.rb`);
432
+ # this single example pins the controller-hosted pass-through here so the
433
+ # dispatch suite catches a regression even if that file moves.
434
+ it "params[:cover] reaches the block as ActionDispatch::Http::UploadedFile" do
435
+ fixture_path = File.expand_path("../../support/fixtures/pixel.png", __dir__)
436
+ DispatchRequestSpecSupport::TestController.ruact_action(:upload_check) do |params|
437
+ { "klass" => params[:cover].class.name }
438
+ end
439
+
440
+ post "/__ruact/fn/upload_check",
441
+ { "cover" => Rack::Test::UploadedFile.new(fixture_path, "image/png") }
442
+ expect(last_response.status).to eq(200)
443
+ expect(JSON.parse(last_response.body).fetch("klass")).to eq("ActionDispatch::Http::UploadedFile")
444
+ end
445
+ end
446
+
447
+ # Story 9.1 review patch (2026-06-08, round 4) — the v1 endpoint stays alive
448
+ # as the strangler-fig safety net until Story 9.9 and still shares the
449
+ # salvaged upload guard. The deep upload matrix was re-anchored on the v2
450
+ # concern (and `endpoint_controller_upload_spec.rb` removed), so this minimal
451
+ # OBSERVABLE-CONTRACT smoke spec keeps the v1 endpoint's 413 path from
452
+ # regressing before demolition. Not the old implementation-coupled matrix —
453
+ # just the wire-visible contract through `POST /__ruact/fn/:name`.
454
+ describe "Story 9.1 — v1 endpoint upload-limit smoke (strangler safety net)", :story_9_1 do
455
+ before do
456
+ # spec_helper's global before-hook resets @config; re-prime AFTER it
457
+ # (local before runs after global) so the tight cap sticks for the body.
458
+ Ruact.instance_variable_set(:@config, nil)
459
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
460
+ Ruact.configure { |c| c.max_upload_bytes = 1024 }
461
+ end
462
+
463
+ it "an oversized multipart POST /__ruact/fn/:name rejects with 413 + structured upload_limit body" do
464
+ large = Tempfile.new(["big", ".bin"])
465
+ large.binmode
466
+ large.write("x" * 4096) # 4 KB > the 1 KB cap
467
+ large.rewind
468
+
469
+ post "/__ruact/fn/oversized_smoke",
470
+ { "cover" => Rack::Test::UploadedFile.new(large.path, "application/octet-stream") },
471
+ { "HTTP_ACCEPT" => "application/json" }
472
+
473
+ expect(last_response.status).to eq(413)
474
+ body = JSON.parse(last_response.body)
475
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
476
+ expect(body.fetch("error_class")).to eq("Ruact::UploadTooLargeError")
477
+ expect(body.fetch("upload_limit")).to include("limit_bytes" => 1024)
478
+ ensure
479
+ large.close
480
+ large.unlink
481
+ end
482
+ end
483
+
484
+ describe "Re-run-3 — before_action reads request.body (#3 raw_post fix)" do
485
+ it "the action still sees the original body when a before_action already drained it" do
486
+ # Pre-Re-run-3: the before_action's `body.read` advanced the IO to EOF;
487
+ # the action's own `body.read` returned `""` → `ruact_action_raw_args`
488
+ # silently coerced to `{}` → echoed empty params. Now: `request.raw_post`
489
+ # is Rack-cached, so the action sees the full body.
490
+ post "/__ruact/fn/body_peek", { "title" => "Hello" }.to_json, { "CONTENT_TYPE" => "application/json" }
491
+ expect(last_response.status).to eq(200)
492
+ expect(JSON.parse(last_response.body)).to eq("echoed" => { "title" => "Hello" })
493
+ end
494
+ end
495
+
496
+ describe "Re-run-2 — host routing identity (#6 path params)" do
497
+ it "inside the host action, params[:controller] and params[:action] describe " \
498
+ "the host, not the gem endpoint route" do
499
+ post "/__ruact/fn/routing_identity", "{}", { "CONTENT_TYPE" => "application/json" }
500
+ expect(last_response.status).to eq(200)
501
+ body = JSON.parse(last_response.body)
502
+ expect(body.fetch("controller")).to eq("dispatch_request_spec_support/test")
503
+ expect(body.fetch("action")).to eq("routing_identity")
504
+ expect(body.fetch("params_controller")).to eq("dispatch_request_spec_support/test")
505
+ expect(body.fetch("params_action")).to eq("routing_identity")
506
+ end
507
+ end
508
+
509
+ describe "Re-run-2 — unknown action returns 404 even when body is malformed (#5)" do
510
+ it "does not parse the body before lookup, so a corrupted JSON for an unknown " \
511
+ "name still returns the 404 shape" do
512
+ # Pre-Re-run-2 this raised ParseError on body parse before the lookup.
513
+ post "/__ruact/fn/no_such_thing", "{ not json", { "CONTENT_TYPE" => "application/json" }
514
+ expect(last_response.status).to eq(404)
515
+ expect(JSON.parse(last_response.body)).to eq("error" => "unknown ruact action: :no_such_thing")
516
+ end
517
+ end
518
+
519
+ describe "AC9 — ActiveRecord::RecordInvalid (re-run-2 #9 — real AR class, not a stub)" do
520
+ it "wraps a real ActiveRecord::RecordInvalid into a structured 422 via the host's rescue_from" do
521
+ post "/__ruact/fn/invalid_record", "{}", { "CONTENT_TYPE" => "application/json" }
522
+ expect(last_response.status).to eq(422)
523
+ body = JSON.parse(last_response.body)
524
+ expect(body.fetch("error_class")).to eq("ActiveRecord::RecordInvalid")
525
+ expect(body.fetch("error")).to match(/Title can't be blank/i)
526
+ expect(body.fetch("validation")).to be(true)
527
+ end
528
+ end
529
+
530
+ describe "Task 5.4 — strong-parameters API works on the shadowed `params`" do
531
+ it "params.require(:post).permit(:title, :body) returns the permitted hash" do
532
+ post "/__ruact/fn/strong_params_demo",
533
+ { "post" => { "title" => "Hi", "body" => "Body", "evil" => "ignored" } }.to_json,
534
+ { "CONTENT_TYPE" => "application/json" }
535
+ expect(last_response.status).to eq(200)
536
+ body = JSON.parse(last_response.body)
537
+ expect(body.fetch("permitted")).to eq("title" => "Hi", "body" => "Body")
538
+ end
539
+
540
+ it "params.require(:post) ParameterMissing is caught by the Story 8.4 rescue_from " \
541
+ "→ 500 + structured payload (proves the shadowed params is a real ActionController::Parameters)" do
542
+ # Pre-Story 8.4 this raised through to Rack (spec app has
543
+ # `show_exceptions: :none`). Story 8.4's endpoint-level
544
+ # `rescue_from StandardError` now intercepts ParameterMissing and
545
+ # renders the structured 500 body. Asserting `error_class` proves
546
+ # the call truly reached `params.require(:post)` inside the block.
547
+ post "/__ruact/fn/strong_params_demo", "{}", { "CONTENT_TYPE" => "application/json" }
548
+ expect(last_response.status).to eq(500)
549
+ body = JSON.parse(last_response.body)
550
+ expect(body.fetch("error_class")).to eq("ActionController::ParameterMissing")
551
+ expect(body.fetch("message")).to match(/param is missing.*post/i)
552
+ end
553
+ end
554
+
555
+ # Story 8.2 — multipart `<form action={fn}>` dispatch + end-to-end CSRF
556
+ # matrix. Inherits Story 8.1's AC8 (end-to-end CSRF) which was FORMALLY
557
+ # DEFERRED to this story (Re-run-5 scope clarification, 2026-05-15). Covers
558
+ # AC1 / AC5 / AC10 from the Story 8.2 spec. Nested inside the same top-level
559
+ # describe (RSpec/MultipleDescribes) so this file remains a single example
560
+ # group at the top level — the nested context exists so the spec's two
561
+ # story-tagged surfaces (8.1 baseline + 8.2 additions) live in one place.
562
+ describe "Story 8.2 — multipart `<form action>` dispatch", :story_8_2 do
563
+ describe "AC1 — multipart/form-data dispatch from <form action={fn}>" do
564
+ # R4 (2026-05-17 review patch): the previous spec passed plain
565
+ # Ruby hashes to `Rack::Test#post`, which sends an
566
+ # `application/x-www-form-urlencoded` body — NOT multipart. To
567
+ # exercise the real `<form action={fn}>` wire shape (the React 19
568
+ # runtime sends `Content-Type: multipart/form-data; boundary=…`),
569
+ # this helper hand-builds a multipart body. Each test below
570
+ # explicitly asserts `request.media_type == "multipart/form-data"`
571
+ # via the test app's `routing_identity` action so a future
572
+ # regression that quietly downgrades to urlencoded fails LOUDLY.
573
+ def multipart_post(path, fields)
574
+ boundary = "----RuactSpecBoundary#{SecureRandom.hex(8)}"
575
+ body = +""
576
+ fields.each do |key, value|
577
+ flatten_field(key, value).each do |(name, val)|
578
+ body << "--#{boundary}\r\n"
579
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
580
+ body << val.to_s
581
+ body << "\r\n"
582
+ end
583
+ end
584
+ body << "--#{boundary}--\r\n"
585
+ post path, body,
586
+ "CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}",
587
+ "CONTENT_LENGTH" => body.bytesize.to_s
588
+ end
589
+
590
+ def flatten_field(key, value, prefix = nil)
591
+ full = prefix ? "#{prefix}[#{key}]" : key.to_s
592
+ case value
593
+ when Hash
594
+ value.flat_map { |k, v| flatten_field(k, v, full) }
595
+ else
596
+ [[full, value]]
597
+ end
598
+ end
599
+
600
+ it "dispatches a REAL multipart body and the block sees the fields as ActionController::Parameters" do
601
+ # The literal `<form action={createPost}>` round-trip: React 19
602
+ # invokes the function with FormData; the runtime POSTs as
603
+ # multipart; Rails' multipart parser unwraps the parts into
604
+ # `request.request_parameters`; `ruact_action_raw_args`
605
+ # (Story 8.1) surfaces them as the block's `params` shadow.
606
+ multipart_post "/__ruact/fn/echo",
607
+ { "title" => "From form", "body" => "Form-encoded body" }
608
+ expect(last_response.status).to eq(200)
609
+ expect(last_response.headers["Content-Type"]).to include("application/json")
610
+ body = JSON.parse(last_response.body)
611
+ expect(body.fetch("echoed")).to include(
612
+ "title" => "From form",
613
+ "body" => "Form-encoded body"
614
+ )
615
+ # R4: prove the request media type was actually multipart — not
616
+ # the urlencoded fallback Rack::Test gives plain-hash bodies.
617
+ expect(last_request.media_type).to eq("multipart/form-data")
618
+ end
619
+
620
+ it "strong-parameters on the block's shadowed params works with a multipart body " \
621
+ "(`params.require(:post).permit(:title, :body)`)" do
622
+ # `params.require(:post).permit(...)` from inside the block — proves
623
+ # the shadowed params is a real ActionController::Parameters, with
624
+ # multipart-decoded nested-hash form fields.
625
+ multipart_post "/__ruact/fn/strong_params_demo",
626
+ { "post" => { "title" => "Hi", "body" => "Body", "evil" => "ignored" } }
627
+ expect(last_response.status).to eq(200)
628
+ body = JSON.parse(last_response.body)
629
+ expect(body.fetch("permitted")).to eq("title" => "Hi", "body" => "Body")
630
+ expect(last_request.media_type).to eq("multipart/form-data")
631
+ end
632
+
633
+ it "returns 204 from a multipart submission when the block returns nil " \
634
+ "(parity with the JSON-body branch)" do
635
+ multipart_post "/__ruact/fn/nil_return", { "ignored" => "field" }
636
+ expect(last_response.status).to eq(204)
637
+ expect(last_response.body).to eq("")
638
+ expect(last_request.media_type).to eq("multipart/form-data")
639
+ end
640
+
641
+ it "routes ActiveRecord::RecordInvalid raised inside the block to the host's " \
642
+ "rescue_from from a multipart submission (status 422 + structured body)" do
643
+ multipart_post "/__ruact/fn/invalid_record", { "irrelevant" => "field" }
644
+ expect(last_response.status).to eq(422)
645
+ body = JSON.parse(last_response.body)
646
+ expect(body.fetch("error_class")).to eq("ActiveRecord::RecordInvalid")
647
+ expect(body.fetch("validation")).to be(true)
648
+ expect(last_request.media_type).to eq("multipart/form-data")
649
+ end
650
+ end
651
+
652
+ describe "AC5 — CSRF matrix (closes Story 8.1 AC8 deferral)" do
653
+ # The gem's EndpointController explicitly skips its own
654
+ # `verify_authenticity_token` so the host's `protect_from_forgery` is
655
+ # the single source of truth. The structural guarantee is asserted
656
+ # in Story 8.1's AC8 block above; this block exercises the
657
+ # downstream behaviour: a request that reaches the host action with
658
+ # the right token succeeds, one without it fails with 422 (Rails
659
+ # default response code for `InvalidAuthenticityToken`).
660
+
661
+ it "the gem endpoint inherits from ActionController::Base so the host's CSRF " \
662
+ "stack participates on dispatch" do
663
+ # Smoke-restated from Story 8.1's AC8 to keep this matrix self-contained;
664
+ # the full end-to-end CSRF round-trip (forgery_protection enabled + valid
665
+ # token accepted, invalid token rejected) would require flipping the
666
+ # spec-app's `config.action_controller.allow_forgery_protection` to true
667
+ # AND drawing in `ActionDispatch::Session::CookieStore` middleware — the
668
+ # spec app turns BOTH off (because every other test in this suite needs
669
+ # CSRF off to focus on dispatch mechanics). Documenting the boundary
670
+ # here keeps the matrix legible without rebooting Rails.
671
+ expect(Ruact::ServerFunctions::EndpointController.ancestors)
672
+ .to include(ActionController::Base)
673
+ end
674
+
675
+ it "the gem endpoint does NOT add an UNCONDITIONAL verify_authenticity_token to the chain " \
676
+ "(Story 8.3 — the callback exists for the standalone branch but is gated by " \
677
+ "`dispatching_standalone?`, so controller-hosted dispatch is unaffected)" do
678
+ callbacks = Ruact::ServerFunctions::EndpointController._process_action_callbacks
679
+ verify_callback = callbacks.find { |c| c.filter == :verify_authenticity_token }
680
+ if verify_callback
681
+ expect(verify_callback.instance_variable_get(:@if)).to eq([:dispatching_standalone?])
682
+ else
683
+ expect(callbacks.map(&:filter)).not_to include(:verify_authenticity_token)
684
+ end
685
+ end
686
+
687
+ it "API mode (host without protect_from_forgery) accepts the request without a token " \
688
+ "— the spec app's default state mirrors API-mode behaviour" do
689
+ # In the spec app, `allow_forgery_protection` is implicitly false (the
690
+ # framework default for `eager_load=false`). A POST without any CSRF
691
+ # header succeeds — the gem does not impose its own policy.
692
+ post "/__ruact/fn/echo", { "title" => "API mode" }
693
+ expect(last_response.status).to eq(200)
694
+ end
695
+
696
+ it "a valid X-CSRF-Token header is forwarded through to the host's session " \
697
+ "infrastructure (smoke — no token-rotation here, just delivery)" do
698
+ # The runtime's job is to read `<meta name=\"csrf-token\">` and
699
+ # forward as `X-CSRF-Token`. We can't easily round-trip the full
700
+ # `protect_from_forgery` flow here because that requires session
701
+ # middleware + `allow_forgery_protection = true`, both turned off
702
+ # in this suite. The request-level guarantee — header reaches the
703
+ # host action — is asserted by the routing-identity action below
704
+ # echoing all observable request state.
705
+ post "/__ruact/fn/routing_identity",
706
+ "{}",
707
+ { "CONTENT_TYPE" => "application/json", "HTTP_X_CSRF_TOKEN" => "test-token" }
708
+ expect(last_response.status).to eq(200)
709
+ end
710
+ end
711
+ end
712
+
713
+ # Story 8.3 — standalone-host dispatch via /__ruact/fn/:name. Registers
714
+ # a Module hosting `:standalone_demo`, asserts the dispatcher branches
715
+ # through StandaloneDispatcher, asserts the host shape detection
716
+ # identifies the entry as a Module, and exercises the
717
+ # invalid-host-shape defense-in-depth branch. CSRF is covered separately
718
+ # in csrf_request_spec.rb (`Story 8.3 — standalone branch CSRF matrix`).
719
+ describe "Story 8.3 — standalone-host dispatch via /__ruact/fn/:name", :story_8_3 do
720
+ # Flip the EndpointController's allow_forgery_protection to false for
721
+ # this describe block so the standalone branch behaves as API-mode —
722
+ # matching the rest of dispatch_request_spec.rb. The protected path
723
+ # (allow_forgery_protection = true) is exercised in csrf_request_spec.rb.
724
+ around do |example|
725
+ previous = Ruact::ServerFunctions::EndpointController.allow_forgery_protection
726
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = false
727
+ example.run
728
+ ensure
729
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = previous
730
+ end
731
+
732
+ before(:all) do
733
+ # Declare the standalone host module ONCE — registries are reset between
734
+ # examples but the module reference must stay stable.
735
+ unless defined?(DispatchSpecStandaloneHost)
736
+ Object.const_set(:DispatchSpecStandaloneHost, Module.new)
737
+ DispatchSpecStandaloneHost.extend(Ruact::ServerAction)
738
+ end
739
+ end
740
+
741
+ before do
742
+ # spec_helper resets the registries between examples; always re-register
743
+ # so the entry is freshly bound to the live registry instance.
744
+ DispatchSpecStandaloneHost.module_eval do
745
+ ruact_action(:standalone_demo) do |params|
746
+ {
747
+ "message" => params[:message].to_s,
748
+ "host_kind" => "module",
749
+ "before_action_fired" => false
750
+ }
751
+ end
752
+ end
753
+ end
754
+
755
+ it "dispatches a standalone-hosted action and returns the block's return value as JSON" do
756
+ post "/__ruact/fn/standalone_demo",
757
+ { "message" => "from standalone" }.to_json,
758
+ { "CONTENT_TYPE" => "application/json" }
759
+ expect(last_response.status).to eq(200)
760
+ expect(last_response.headers["Content-Type"]).to include("application/json")
761
+ expect(JSON.parse(last_response.body)).to eq(
762
+ "message" => "from standalone",
763
+ "host_kind" => "module",
764
+ "before_action_fired" => false
765
+ )
766
+ end
767
+
768
+ it "the entry's host is a Module (not a Class) — proves the codegen-side path " \
769
+ "cannot tell standalone-hosted apart from controller-hosted (same accessor shape)" do
770
+ entry = Ruact.action_registry.entries[:standalone_demo]
771
+ expect(entry.controller).to be_a(Module)
772
+ expect(entry.controller).not_to be_a(Class)
773
+ expect(entry.js_identifier).to eq("standaloneDemo")
774
+ end
775
+
776
+ it "the EndpointController's standalone_host? predicate identifies the host as standalone" do
777
+ entry = Ruact.action_registry.entries[:standalone_demo]
778
+ expect(Ruact::ServerFunctions::EndpointController.standalone_host?(entry.controller)).to be(true)
779
+ end
780
+
781
+ describe "Story 8.4 — standalone block raise produces structured payload", :story_8_4 do
782
+ before do
783
+ DispatchSpecStandaloneHost.module_eval do
784
+ ruact_action(:standalone_boom) { |_p| raise "standalone explosion" }
785
+ end
786
+ end
787
+
788
+ it "an unrescued StandardError raised inside the standalone block falls through to " \
789
+ "EndpointController's rescue_from StandardError and returns 500 + structured body" do
790
+ post "/__ruact/fn/standalone_boom", "{}", { "CONTENT_TYPE" => "application/json" }
791
+ expect(last_response.status).to eq(500)
792
+ body = JSON.parse(last_response.body)
793
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
794
+ expect(body.fetch("action_name")).to eq("standalone_boom")
795
+ expect(body.fetch("error_class")).to eq("RuntimeError")
796
+ expect(body.fetch("message")).to eq("standalone explosion")
797
+ end
798
+ end
799
+
800
+ it "an invalid host shape (neither Class nor extending Ruact::ServerAction) renders 500 " \
801
+ "with the documented error message (defense-in-depth against registry injection)" do
802
+ # Manually inject a bogus entry — proves the dispatcher's host-shape
803
+ # validation surfaces clearly when something has gone very wrong.
804
+ bogus = Ruact::ServerFunctions::RegistryEntry.new(
805
+ ruby_symbol: :bogus_host,
806
+ js_identifier: "bogusHost",
807
+ kind: :action,
808
+ controller: "not_a_class_or_standalone_module",
809
+ block: ->(_p) {}
810
+ )
811
+ Ruact.action_registry.instance_variable_get(:@entries)[:bogus_host] = bogus
812
+
813
+ post "/__ruact/fn/bogus_host", "{}", { "CONTENT_TYPE" => "application/json" }
814
+ expect(last_response.status).to eq(500)
815
+ body = JSON.parse(last_response.body)
816
+ expect(body.fetch("error")).to match(/invalid host shape/)
817
+ end
818
+ end
819
+ end