ruact 0.0.2 → 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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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
|
data/spec/ruact/railtie_spec.rb
CHANGED
|
@@ -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
|
|
111
|
-
|
|
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
|