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