ruact 0.0.4 → 0.0.6
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -5,104 +5,25 @@ require "tmpdir"
|
|
|
5
5
|
|
|
6
6
|
module Ruact
|
|
7
7
|
module ServerFunctions
|
|
8
|
-
RSpec.describe Snapshot, :
|
|
9
|
-
let(:
|
|
10
|
-
let(:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
describe ".dump (Story 8.0a — pure snapshot builder)" do
|
|
16
|
-
it "returns the empty payload when both registries are empty" do
|
|
17
|
-
snapshot = described_class.dump(actions, queries, now: frozen_time)
|
|
18
|
-
|
|
19
|
-
expect(snapshot).to eq(
|
|
20
|
-
version: 1,
|
|
21
|
-
generated_at: "2026-05-13T12:34:56Z",
|
|
22
|
-
functions: []
|
|
23
|
-
)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
it "merges action + query entries into a single functions array" do
|
|
27
|
-
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
28
|
-
queries.register(:categories, kind: :query, controller: cats_controller)
|
|
29
|
-
|
|
30
|
-
snapshot = described_class.dump(actions, queries, now: frozen_time)
|
|
31
|
-
|
|
32
|
-
expect(snapshot[:functions]).to contain_exactly(
|
|
33
|
-
{ "ruby_symbol" => "create_post", "js_identifier" => "createPost",
|
|
34
|
-
"kind" => "action", "controller" => "PostsController" },
|
|
35
|
-
{ "ruby_symbol" => "categories", "js_identifier" => "categories",
|
|
36
|
-
"kind" => "query", "controller" => "CategoriesController" }
|
|
37
|
-
)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
it "sorts functions by ruby_symbol for deterministic output (Story 8.0a)" do
|
|
41
|
-
actions.register(:zeta, kind: :action, controller: posts_controller)
|
|
42
|
-
actions.register(:alpha, kind: :action, controller: posts_controller)
|
|
43
|
-
queries.register(:mike, kind: :query, controller: posts_controller)
|
|
44
|
-
|
|
45
|
-
symbols = described_class.dump(actions, queries, now: frozen_time)[:functions]
|
|
46
|
-
.map { |fn| fn["ruby_symbol"] }
|
|
47
|
-
expect(symbols).to eq(%w[alpha mike zeta])
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
it "stringifies controller class names; falls back to nil for nil controllers" do
|
|
51
|
-
actions.register(:demo_ping, kind: :action, controller: nil)
|
|
52
|
-
snapshot = described_class.dump(actions, queries, now: frozen_time)
|
|
53
|
-
expect(snapshot[:functions].first["controller"]).to be_nil
|
|
54
|
-
end
|
|
8
|
+
RSpec.describe Snapshot, :story_9_3 do
|
|
9
|
+
let(:frozen_time) { Time.utc(2026, 5, 13, 12, 34, 56) }
|
|
10
|
+
let(:entries) do
|
|
11
|
+
[
|
|
12
|
+
{ "js_identifier" => "createPost", "kind" => "action", "http_method" => "POST",
|
|
13
|
+
"path" => "/posts", "segments" => [], "controller" => "posts", "action" => "create" }
|
|
14
|
+
]
|
|
55
15
|
end
|
|
56
16
|
|
|
57
|
-
describe ".
|
|
58
|
-
it "
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
queries.register(:foo, kind: :query, controller: cats_controller)
|
|
64
|
-
expect { described_class.functions_payload(actions, queries) }
|
|
65
|
-
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
66
|
-
# AC7 exact shape: rake wraps to "[ruact] error: server-function naming collision: :foo (in PostsController) and :foo (in CategoriesController) both map to JS identifier \"foo\""
|
|
67
|
-
expect(error.message).to start_with("server-function naming collision:")
|
|
68
|
-
expect(error.message).to include(":foo (in PostsController)")
|
|
69
|
-
expect(error.message).to include(":foo (in CategoriesController)")
|
|
70
|
-
expect(error.message).to include('"foo"')
|
|
71
|
-
# No "kind:" annotation per Pass-2 patch 2026-05-14
|
|
72
|
-
expect(error.message).not_to include("kind:")
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
it "raises when different Ruby symbols cross-collide via the naming bridge" do
|
|
77
|
-
# `:foo_bar` action + `:foo__bar` query both → "fooBar"
|
|
78
|
-
actions.register(:foo_bar, kind: :action, controller: posts_controller)
|
|
79
|
-
queries.register(:foo__bar, kind: :query, controller: cats_controller)
|
|
80
|
-
expect { described_class.functions_payload(actions, queries) }
|
|
81
|
-
.to raise_error(Ruact::ConfigurationError, /server-function naming collision.*"fooBar"/m)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
it "raises when both registries contain the same Ruby symbol with matching " \
|
|
85
|
-
"js_identifier (one js_identifier per emitted export is the design intent)" do
|
|
86
|
-
actions.register(:categories, kind: :action, controller: posts_controller)
|
|
87
|
-
queries.register(:categories, kind: :query, controller: cats_controller)
|
|
88
|
-
expect { described_class.functions_payload(actions, queries) }
|
|
89
|
-
.to raise_error(Ruact::ConfigurationError, /server-function naming collision/)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
describe ".functions_payload (Story 8.0a — fingerprint surface)" do
|
|
94
|
-
it "excludes the generated_at timestamp so registry-equivalent calls match" do
|
|
95
|
-
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
96
|
-
|
|
97
|
-
first = described_class.functions_payload(actions, queries)
|
|
98
|
-
sleep 0.01
|
|
99
|
-
second = described_class.functions_payload(actions, queries)
|
|
100
|
-
|
|
101
|
-
expect(first).to eq(second)
|
|
17
|
+
describe ".dump_v2" do
|
|
18
|
+
it "wraps entries in a version-2 snapshot Hash" do
|
|
19
|
+
snap = described_class.dump_v2(entries, now: frozen_time)
|
|
20
|
+
expect(snap[:version]).to eq(2)
|
|
21
|
+
expect(snap[:generated_at]).to eq(frozen_time.iso8601)
|
|
22
|
+
expect(snap[:functions]).to eq(entries)
|
|
102
23
|
end
|
|
103
24
|
end
|
|
104
25
|
|
|
105
|
-
describe ".
|
|
26
|
+
describe ".generate_v2! (write-if-changed)" do
|
|
106
27
|
around do |example|
|
|
107
28
|
Dir.mktmpdir do |dir|
|
|
108
29
|
@tmpdir = dir
|
|
@@ -112,142 +33,61 @@ module Ruact
|
|
|
112
33
|
|
|
113
34
|
let(:path) { File.join(@tmpdir, "server-functions.json") }
|
|
114
35
|
|
|
115
|
-
it "writes
|
|
116
|
-
|
|
117
|
-
action_registry: actions, query_registry: queries, path: path, now: frozen_time
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
expect(result).to be(true)
|
|
121
|
-
expect(File).to exist(path)
|
|
36
|
+
it "writes a version-2 bridge on first call" do
|
|
37
|
+
expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
|
|
122
38
|
parsed = JSON.parse(File.read(path))
|
|
123
|
-
expect(parsed.fetch("version")).to eq(
|
|
124
|
-
expect(parsed.fetch("functions")).to eq(
|
|
39
|
+
expect(parsed.fetch("version")).to eq(2)
|
|
40
|
+
expect(parsed.fetch("functions").first.fetch("js_identifier")).to eq("createPost")
|
|
125
41
|
end
|
|
126
42
|
|
|
127
|
-
it "
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
described_class.generate!(action_registry: actions, query_registry: queries,
|
|
131
|
-
path: path, now: frozen_time)
|
|
132
|
-
|
|
133
|
-
original_mtime = File.mtime(path)
|
|
134
|
-
original_bytes = File.read(path)
|
|
135
|
-
original_time = JSON.parse(original_bytes)["generated_at"]
|
|
136
|
-
sleep 1.05 # ensure mtime resolution is exceeded if we DID rewrite
|
|
137
|
-
|
|
138
|
-
result = described_class.generate!(
|
|
139
|
-
action_registry: actions, query_registry: queries,
|
|
140
|
-
path: path, now: Time.now.utc # different now
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
expect(result).to be(false)
|
|
144
|
-
expect(File.mtime(path)).to eq(original_mtime)
|
|
145
|
-
expect(JSON.parse(File.read(path))["generated_at"]).to eq(original_time)
|
|
43
|
+
it "short-circuits when entries are unchanged (no timestamp churn)" do
|
|
44
|
+
described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
|
|
45
|
+
expect(described_class.generate_v2!(entries: entries, path: path, now: Time.now.utc)).to be(false)
|
|
146
46
|
end
|
|
147
47
|
|
|
148
|
-
it "rewrites
|
|
149
|
-
described_class.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
action_registry: actions, query_registry: queries, path: path, now: frozen_time
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
expect(result).to be(true)
|
|
158
|
-
expect(JSON.parse(File.read(path))["functions"].size).to eq(1)
|
|
48
|
+
it "rewrites when entries change" do
|
|
49
|
+
described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
|
|
50
|
+
more = entries + [{ "js_identifier" => "destroyPost", "kind" => "action",
|
|
51
|
+
"http_method" => "DELETE", "path" => "/posts/:id",
|
|
52
|
+
"segments" => ["id"], "controller" => "posts", "action" => "destroy" }]
|
|
53
|
+
expect(described_class.generate_v2!(entries: more, path: path, now: frozen_time)).to be(true)
|
|
159
54
|
end
|
|
160
55
|
|
|
161
56
|
it "creates the parent directory if missing" do
|
|
162
|
-
nested = File.join(@tmpdir, "deep", "
|
|
163
|
-
expect
|
|
164
|
-
|
|
165
|
-
path: nested, now: frozen_time)
|
|
166
|
-
end
|
|
167
|
-
.to change { File.exist?(nested) }.from(false).to(true)
|
|
57
|
+
nested = File.join(@tmpdir, "deep", "nested", "server-functions.json")
|
|
58
|
+
expect(described_class.generate_v2!(entries: entries, path: nested, now: frozen_time)).to be(true)
|
|
59
|
+
expect(File.exist?(nested)).to be(true)
|
|
168
60
|
end
|
|
169
61
|
|
|
170
62
|
it "recovers from a corrupted existing file by overwriting it" do
|
|
171
|
-
File.write(path, "not json")
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
describe "route-driven (v2) snapshot (Story 9.3)", :story_9_3 do
|
|
191
|
-
let(:entries) do
|
|
192
|
-
[
|
|
193
|
-
{ "js_identifier" => "createPost", "kind" => "action", "http_method" => "POST",
|
|
194
|
-
"path" => "/posts", "segments" => [], "controller" => "posts", "action" => "create" }
|
|
195
|
-
]
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
describe ".dump_v2" do
|
|
199
|
-
it "wraps entries in a version-2 snapshot Hash" do
|
|
200
|
-
snap = described_class.dump_v2(entries, now: frozen_time)
|
|
201
|
-
expect(snap[:version]).to eq(2)
|
|
202
|
-
expect(snap[:generated_at]).to eq(frozen_time.iso8601)
|
|
203
|
-
expect(snap[:functions]).to eq(entries)
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
describe ".generate_v2! (write-if-changed)" do
|
|
208
|
-
around do |example|
|
|
209
|
-
Dir.mktmpdir do |dir|
|
|
210
|
-
@tmpdir = dir
|
|
211
|
-
example.run
|
|
212
|
-
end
|
|
63
|
+
File.write(path, "{ not json")
|
|
64
|
+
expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
|
|
65
|
+
expect(JSON.parse(File.read(path)).fetch("version")).to eq(2)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Story 13.4 — the new typed-query metadata must survive the JSON
|
|
69
|
+
# round-trip unchanged AND keep the file byte-stable (no churn on a
|
|
70
|
+
# second pass with identical entries).
|
|
71
|
+
context "with typed-query params metadata (Story 13.4)", :story_13_4 do
|
|
72
|
+
let(:query_entries) do
|
|
73
|
+
[
|
|
74
|
+
{ "js_identifier" => "searchUsers", "kind" => "query", "http_method" => "GET",
|
|
75
|
+
"path" => "/q/searchUsers", "segments" => [], "accepts_params" => true,
|
|
76
|
+
"params" => [{ "name" => "term", "required" => true },
|
|
77
|
+
{ "name" => "limit", "required" => false }],
|
|
78
|
+
"params_rest" => false, "controller" => "CatalogQuery", "action" => "search_users" }
|
|
79
|
+
]
|
|
213
80
|
end
|
|
214
81
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
it "writes a version-2 bridge on first call" do
|
|
218
|
-
expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
|
|
82
|
+
it "round-trips the params metadata unchanged" do
|
|
83
|
+
described_class.generate_v2!(entries: query_entries, path: path, now: frozen_time)
|
|
219
84
|
parsed = JSON.parse(File.read(path))
|
|
220
|
-
expect(parsed.fetch("
|
|
221
|
-
expect(parsed.fetch("functions").first.fetch("js_identifier")).to eq("createPost")
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
it "short-circuits when entries are unchanged (no timestamp churn)" do
|
|
225
|
-
described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
|
|
226
|
-
expect(described_class.generate_v2!(entries: entries, path: path, now: Time.now.utc)).to be(false)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
it "rewrites when entries change" do
|
|
230
|
-
described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
|
|
231
|
-
more = entries + [{ "js_identifier" => "destroyPost", "kind" => "action",
|
|
232
|
-
"http_method" => "DELETE", "path" => "/posts/:id",
|
|
233
|
-
"segments" => ["id"], "controller" => "posts", "action" => "destroy" }]
|
|
234
|
-
expect(described_class.generate_v2!(entries: more, path: path, now: frozen_time)).to be(true)
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
describe ".v1_declarations? (Decision-#6 ownership primitive)" do
|
|
239
|
-
it "is false when both registries are empty" do
|
|
240
|
-
expect(described_class.v1_declarations?(Registry.new, Registry.new)).to be(false)
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
it "is true when the action registry has any entry" do
|
|
244
|
-
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
245
|
-
expect(described_class.v1_declarations?(actions, Registry.new)).to be(true)
|
|
85
|
+
expect(parsed.fetch("functions")).to eq(query_entries)
|
|
246
86
|
end
|
|
247
87
|
|
|
248
|
-
it "is
|
|
249
|
-
|
|
250
|
-
expect(described_class.
|
|
88
|
+
it "is byte-stable: a second pass with identical entries writes nothing" do
|
|
89
|
+
described_class.generate_v2!(entries: query_entries, path: path, now: frozen_time)
|
|
90
|
+
expect(described_class.generate_v2!(entries: query_entries, path: path, now: Time.now.utc)).to be(false)
|
|
251
91
|
end
|
|
252
92
|
end
|
|
253
93
|
end
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# Story 9.1 — request-cycle spec for the Story 8.4 structured-error chain
|
|
4
4
|
# RE-ANCHORED on the `Ruact::Server` concern (its final, v2 home). Replaces
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# the removed v1 endpoint rescue spec (AC5: no orphan salvage, no double
|
|
6
|
+
# coverage). Pins, against REAL host-controller routes (no synthetic gem
|
|
7
|
+
# endpoint anywhere):
|
|
8
8
|
#
|
|
9
9
|
# - structured payload on function-call requests: discriminator, baseline
|
|
10
10
|
# fields, dev extras, validation_errors, suggestion (inventory A1, A2,
|
|
@@ -56,7 +56,7 @@ I18n.backend.load_translations
|
|
|
56
56
|
require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
|
|
57
57
|
|
|
58
58
|
module ServerRescueSpecSupport
|
|
59
|
-
# The exact wire shape the
|
|
59
|
+
# The exact wire shape the runtime sends on every server-function fetch:
|
|
60
60
|
# JSON body + `Accept: application/json` — the Bucket-2 / function-call
|
|
61
61
|
# request shape the concern's predicate keys on.
|
|
62
62
|
FUNCTION_CALL_HEADERS = {
|
|
@@ -177,8 +177,8 @@ module ServerRescueSpecSupport
|
|
|
177
177
|
# Host with real CSRF enforcement — the concern's explicit
|
|
178
178
|
# InvalidAuthenticityToken registration must render the structured 403
|
|
179
179
|
# for function-call requests (inventory A13). Forgery is flipped on
|
|
180
|
-
# per-example via `allow_forgery_protection` (class-level),
|
|
181
|
-
#
|
|
180
|
+
# per-example via `allow_forgery_protection` (class-level), on the host
|
|
181
|
+
# controller that includes Ruact::Server.
|
|
182
182
|
class ForgeryServerController < ActionController::Base
|
|
183
183
|
include Ruact::Server
|
|
184
184
|
|
data/spec/ruact/server_spec.rb
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
# host until then).
|
|
11
11
|
# - AC2 / D3: the `__ruact_function_call?` predicate matrix — the single
|
|
12
12
|
# named discrimination point Story 9.2 reuses. Keyed on the raw `Accept`
|
|
13
|
-
# header containing `application/json` (what the
|
|
14
|
-
# every
|
|
13
|
+
# header containing `application/json` (what the runtime sends on
|
|
14
|
+
# every server-function fetch); deliberately NOT `request.format`, which is
|
|
15
15
|
# influenced by path extensions and `params[:format]`.
|
|
16
16
|
#
|
|
17
17
|
# Request-cycle behavior (error chain, upload guard) is pinned by
|
|
@@ -55,15 +55,14 @@ RSpec.describe Ruact::Server, :story_9_1 do
|
|
|
55
55
|
expect(before_filters.first).to eq(:__ruact_enforce_upload_limit!)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
it "adds
|
|
58
|
+
it "adds only the Story 13.3 `ruact_errors` opt-in helper as a public method (predicate + handlers stay private)" do
|
|
59
|
+
# Story 9.1's installation surface was "nothing public"; Story 13.3 (FR98)
|
|
60
|
+
# adds exactly ONE deliberate public method — the `ruact_errors(record)`
|
|
61
|
+
# opt-in validation-error collector / reader (AC2). The dual-bucket
|
|
62
|
+
# predicate, error handlers, and flash helpers all remain private.
|
|
59
63
|
added = ServerConcernUnitSupport::ConcernController.public_instance_methods -
|
|
60
64
|
ServerConcernUnitSupport::PlainController.public_instance_methods
|
|
61
|
-
expect(added).to eq([])
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
it "registers nothing in the v1 registries (codegen exposure is Story 9.3, not 9.1)" do
|
|
65
|
-
expect(Ruact.action_registry.entries).to be_empty
|
|
66
|
-
expect(Ruact.query_registry.entries).to be_empty
|
|
65
|
+
expect(added).to eq([:ruact_errors])
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
it "keeps INHERITED host rescue_from handlers more recent than its own (review patch)",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 13.2 (FR96) — unit specs for the signed-reference primitive:
|
|
4
|
+
# Ruact.signed_global_id (outbound) + Ruact.locate_signed (inbound). Covers the
|
|
5
|
+
# four epic AC cases (round-trip / tampered / expired / wrong-purpose) plus the
|
|
6
|
+
# loud-omission guards that keep an unscoped or silently-non-expiring token
|
|
7
|
+
# unconstructible.
|
|
8
|
+
|
|
9
|
+
require "spec_helper"
|
|
10
|
+
require "global_id"
|
|
11
|
+
require "active_support"
|
|
12
|
+
require "active_support/core_ext/integer/time"
|
|
13
|
+
require "active_support/core_ext/numeric/time"
|
|
14
|
+
require "active_support/message_verifier"
|
|
15
|
+
|
|
16
|
+
# A GlobalID-locatable test record: globalid keys off `class.name` + `#id` and
|
|
17
|
+
# resolves via `class.find(id)`. Top-level + constantizable so the locator can
|
|
18
|
+
# reach it (mirrors how query_request_spec defines its host classes top-level).
|
|
19
|
+
class SignedRefSpecPost
|
|
20
|
+
include GlobalID::Identification
|
|
21
|
+
|
|
22
|
+
attr_reader :id
|
|
23
|
+
|
|
24
|
+
def initialize(id)
|
|
25
|
+
@id = id.to_s
|
|
26
|
+
self.class.store[@id] = self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.store
|
|
30
|
+
@store ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.find(id)
|
|
34
|
+
store.fetch(id.to_s)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module Ruact # rubocop:disable Style/OneClassPerFile
|
|
39
|
+
RSpec.describe SignedReferences, :story_13_2 do
|
|
40
|
+
let(:record) { SignedRefSpecPost.new("7") }
|
|
41
|
+
|
|
42
|
+
before do
|
|
43
|
+
# globalid signing needs an app name + a verifier secret. Set both as
|
|
44
|
+
# test infrastructure (no other spec uses globalid, so the global state
|
|
45
|
+
# is inert elsewhere).
|
|
46
|
+
GlobalID.app = "ruact-test"
|
|
47
|
+
SignedGlobalID.verifier = ActiveSupport::MessageVerifier.new("a" * 64)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe ".signed_global_id + .locate_signed round-trip (AC: round-trip)" do
|
|
51
|
+
it "mints a token that resolves back to the same record for a matching purpose" do
|
|
52
|
+
token = Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)
|
|
53
|
+
|
|
54
|
+
expect(token).to be_a(String)
|
|
55
|
+
expect(token).not_to eq("7") # a signed token, not the raw id
|
|
56
|
+
expect(token).to include("--") # MessageVerifier signature separator
|
|
57
|
+
located = Ruact.locate_signed(token, for: :post_edit)
|
|
58
|
+
expect(located.id).to eq("7")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe ".locate_signed rejection cases" do
|
|
63
|
+
let(:token) { Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour) }
|
|
64
|
+
|
|
65
|
+
it "rejects a tampered token (AC: tampered)" do
|
|
66
|
+
expect { Ruact.locate_signed("#{token}tamper", for: :post_edit) }
|
|
67
|
+
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "rejects an expired token (AC: expired)" do
|
|
71
|
+
expired = Ruact.signed_global_id(record, for: :post_edit, expires_in: -1.hour)
|
|
72
|
+
expect { Ruact.locate_signed(expired, for: :post_edit) }
|
|
73
|
+
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "rejects a wrong-purpose token (AC: wrong-for)" do
|
|
77
|
+
expect { Ruact.locate_signed(token, for: :post_delete) }
|
|
78
|
+
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "rejects a nil / garbage token" do
|
|
82
|
+
expect { Ruact.locate_signed(nil, for: :post_edit) }
|
|
83
|
+
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
84
|
+
expect { Ruact.locate_signed("not-a-token", for: :post_edit) }
|
|
85
|
+
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "does not echo the raw token in the rejection message (no leak)" do
|
|
89
|
+
Ruact.locate_signed("#{token}tamper", for: :post_edit)
|
|
90
|
+
rescue Ruact::InvalidSignedGlobalIDError => e
|
|
91
|
+
expect(e.message).not_to include(token)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe "loud-omission guards (never a silent insecure default)" do
|
|
96
|
+
it "raises when no purpose is given and none is configured" do
|
|
97
|
+
expect { Ruact.signed_global_id(record, expires_in: 1.hour) }
|
|
98
|
+
.to raise_error(Ruact::Error, /purpose/)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "raises on an explicit for: nil (an unscoped token is never allowed)" do
|
|
102
|
+
expect { Ruact.signed_global_id(record, for: nil, expires_in: 1.hour) }
|
|
103
|
+
.to raise_error(Ruact::Error, /purpose/)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "raises when expiry is omitted and none is configured" do
|
|
107
|
+
expect { Ruact.signed_global_id(record, for: :post_edit) }
|
|
108
|
+
.to raise_error(Ruact::Error, /expir/i)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "honors an explicit expires_in: nil as a deliberate non-expiring token" do
|
|
112
|
+
token = Ruact.signed_global_id(record, for: :post_edit, expires_in: nil)
|
|
113
|
+
expect(Ruact.locate_signed(token, for: :post_edit).id).to eq("7")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "rejects expires_in: false (globalid would silently treat it as non-expiring)" do
|
|
117
|
+
expect { Ruact.signed_global_id(record, for: :post_edit, expires_in: false) }
|
|
118
|
+
.to raise_error(Ruact::Error, /expiry must be/)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "rejects a bare Integer expires_in (globalid would crash on #from_now)" do
|
|
122
|
+
expect { Ruact.signed_global_id(record, for: :post_edit, expires_in: 3600) }
|
|
123
|
+
.to raise_error(Ruact::Error, /expiry must be/)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "rejects a configured default of false (the second silent non-expiring path)" do
|
|
127
|
+
Ruact.configure { |c| c.signed_global_id_default_expires_in = false }
|
|
128
|
+
expect { Ruact.signed_global_id(record, for: :post_edit) }
|
|
129
|
+
.to raise_error(Ruact::Error, /expiry must be/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "raises a clear error for a non-GlobalID-locatable value" do
|
|
133
|
+
expect { Ruact.signed_global_id("plain-string", for: :post_edit, expires_in: 1.hour) }
|
|
134
|
+
.to raise_error(Ruact::Error, /GlobalID-locatable/)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "does NOT convert a valid token to a deleted record into InvalidSignedGlobalIDError " \
|
|
138
|
+
"(contract: a gone record is the host's normal finder concern, not a forged token)" do
|
|
139
|
+
token = Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)
|
|
140
|
+
SignedRefSpecPost.store.delete(record.id) # the row is gone, the token is still valid
|
|
141
|
+
|
|
142
|
+
# the underlying finder raises (KeyError here; ActiveRecord::RecordNotFound in a real app),
|
|
143
|
+
# NOT Ruact::InvalidSignedGlobalIDError — the primitive only owns signature/expiry/purpose.
|
|
144
|
+
expect { Ruact.locate_signed(token, for: :post_edit) }
|
|
145
|
+
.to raise_error(KeyError)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe "configured defaults" do
|
|
150
|
+
it "falls back to signed_global_id_default_purpose / _expires_in when the call omits them" do
|
|
151
|
+
Ruact.configure do |c|
|
|
152
|
+
c.signed_global_id_default_purpose = :app_default
|
|
153
|
+
c.signed_global_id_default_expires_in = 1.hour
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
token = Ruact.signed_global_id(record)
|
|
157
|
+
expect(Ruact.locate_signed(token).id).to eq("7")
|
|
158
|
+
# a token minted under the default purpose must not resolve under another
|
|
159
|
+
expect { Ruact.locate_signed(token, for: :something_else) }
|
|
160
|
+
.to raise_error(Ruact::InvalidSignedGlobalIDError)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
RSpec.describe StringDistance, :story_13_5 do
|
|
7
|
+
describe ".damerau_levenshtein" do
|
|
8
|
+
it "returns 0 for identical strings" do
|
|
9
|
+
expect(described_class.damerau_levenshtein("postId", "postId")).to eq(0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "counts an adjacent transposition as a single edit" do
|
|
13
|
+
expect(described_class.damerau_levenshtein("postId", "postdI")).to eq(1)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "handles empty strings" do
|
|
17
|
+
expect(described_class.damerau_levenshtein("", "abc")).to eq(3)
|
|
18
|
+
expect(described_class.damerau_levenshtein("abc", "")).to eq(3)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe ".closest_match" do
|
|
23
|
+
let(:pool) { %w[postId initialCount title] }
|
|
24
|
+
|
|
25
|
+
it "finds the nearest candidate within the threshold (case-insensitive)" do
|
|
26
|
+
expect(described_class.closest_match("postID", pool)).to eq("postId")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "returns nil when nothing is within distance" do
|
|
30
|
+
expect(described_class.closest_match("zzzzzzzz", pool)).to be_nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "prefers the smallest distance" do
|
|
34
|
+
expect(described_class.closest_match("titel", pool)).to eq("title")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|