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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ RSpec.describe SnapshotWriter, :story_8_0a do
9
+ around do |example|
10
+ Dir.mktmpdir do |dir|
11
+ @tmpdir = dir
12
+ example.run
13
+ end
14
+ end
15
+
16
+ let(:path) { File.join(@tmpdir, "out.txt") }
17
+
18
+ describe ".write_if_changed! (Story 8.0a — atomic, byte-aware writer)" do
19
+ it "writes the file when it does not yet exist and returns true" do
20
+ expect(described_class.write_if_changed!(path: path, content: "hello\n"))
21
+ .to be(true)
22
+ expect(File.read(path)).to eq("hello\n")
23
+ end
24
+
25
+ it "skips writing when the existing content matches byte-for-byte" do
26
+ File.write(path, "hello\n")
27
+ original_mtime = File.mtime(path)
28
+ sleep 1.05
29
+
30
+ expect(described_class.write_if_changed!(path: path, content: "hello\n"))
31
+ .to be(false)
32
+ expect(File.mtime(path)).to eq(original_mtime)
33
+ end
34
+
35
+ it "writes when the existing content differs by even a single byte" do
36
+ File.write(path, "hello\n")
37
+ expect(described_class.write_if_changed!(path: path, content: "hello!\n"))
38
+ .to be(true)
39
+ expect(File.read(path)).to eq("hello!\n")
40
+ end
41
+
42
+ it "creates missing parent directories" do
43
+ nested = File.join(@tmpdir, "a", "b", "c", "out.txt")
44
+ described_class.write_if_changed!(path: nested, content: "x")
45
+ expect(File.read(nested)).to eq("x")
46
+ end
47
+
48
+ it "writes via a same-directory tmpfile so partial reads never see " \
49
+ "a torn file (Story 8.0a)" do
50
+ described_class.write_if_changed!(path: path, content: "atomic\n")
51
+ # After the write the temp sibling must not linger.
52
+ siblings = Dir.children(@tmpdir)
53
+ expect(siblings).to eq(["out.txt"])
54
+ end
55
+
56
+ it "raises Ruact::ConfigurationError when the parent directory is unwritable " \
57
+ "(Story 8.0a)" do
58
+ read_only = File.join(@tmpdir, "ro")
59
+ FileUtils.mkdir_p(read_only)
60
+ FileUtils.chmod(0o500, read_only)
61
+ target = File.join(read_only, "nested", "out.txt")
62
+
63
+ expect { described_class.write_if_changed!(path: target, content: "x") }
64
+ .to raise_error(Ruact::ConfigurationError, /cannot create/)
65
+ ensure
66
+ FileUtils.chmod(0o700, read_only) if read_only && File.exist?(read_only)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.3 — covers `Ruact::ServerAction` extended onto a Module: AC1
4
+ # (registration shape + no method defined on host module) and AC6 (guard-
5
+ # rail matrix mirroring the controller-DSL path adapted to module context).
6
+
7
+ require "spec_helper"
8
+
9
+ RSpec.describe Ruact::ServerAction, :story_8_3 do
10
+ describe "AC1 — extend Ruact::ServerAction + ruact_action registers a standalone host" do
11
+ it "registers a standalone host in Ruact.action_registry with controller: <the Module>" do
12
+ mod = Module.new do
13
+ extend Ruact::ServerAction
14
+
15
+ def self.name
16
+ "AC1RegistrationModule"
17
+ end
18
+
19
+ ruact_action(:standalone_create_post) { |_params| { ok: true } }
20
+ end
21
+
22
+ entries = Ruact.action_registry.entries
23
+ expect(entries).to include(:standalone_create_post)
24
+
25
+ entry = entries[:standalone_create_post]
26
+ expect(entry).to be_a(Ruact::ServerFunctions::RegistryEntry)
27
+ expect(entry.kind).to eq(:action)
28
+ expect(entry.controller).to be(mod)
29
+ expect(entry.controller).to be_a(Module)
30
+ expect(entry.controller).not_to be_a(Class)
31
+ expect(entry.js_identifier).to eq("standaloneCreatePost")
32
+ expect(entry.block).to be_a(Proc)
33
+ end
34
+
35
+ it "does NOT define an instance method on the host module — the block is reachable " \
36
+ "only through the gem endpoint, never as a Ruby method" do
37
+ mod = Module.new do
38
+ extend Ruact::ServerAction
39
+
40
+ def self.name
41
+ "AC1NoMethodModule"
42
+ end
43
+
44
+ ruact_action(:no_method_exposed) { |_params| nil }
45
+ end
46
+
47
+ expect(mod.respond_to?(:no_method_exposed)).to be(false)
48
+ expect(mod.instance_methods).not_to include(:no_method_exposed)
49
+ expect(mod.singleton_methods).not_to include(:no_method_exposed)
50
+ # Direct dispatch attempts (someone trying to `Mod.send(:no_method_exposed)`
51
+ # should fail with NoMethodError) — proves the surface is endpoint-only.
52
+ expect { mod.send(:no_method_exposed, {}) }.to raise_error(NoMethodError)
53
+ end
54
+ end
55
+
56
+ describe "AC6 — guard-rail matrix (mirror controller-DSL adapted to module context)" do
57
+ let(:host) do
58
+ Module.new do
59
+ extend Ruact::ServerAction
60
+
61
+ def self.name
62
+ "GuardRailModule"
63
+ end
64
+ end
65
+ end
66
+
67
+ it "raises ArgumentError when given a String instead of a Symbol" do
68
+ expect do
69
+ host.module_eval { ruact_action("create_post") { |_p| nil } }
70
+ end.to raise_error(ArgumentError, /ruact_action requires a Symbol/)
71
+ end
72
+
73
+ it "raises ArgumentError when the block is missing" do
74
+ expect { host.module_eval { ruact_action(:create_post) } }
75
+ .to raise_error(ArgumentError, /requires a block/)
76
+ end
77
+
78
+ it "raises ArgumentError when the block accepts no positional argument" do
79
+ expect do
80
+ host.module_eval { ruact_action(:create_post) {} } # no positional arg
81
+ end.to raise_error(ArgumentError, /exactly one positional parameter/)
82
+ end
83
+
84
+ it "raises ArgumentError when the block accepts more than one positional argument" do
85
+ expect do
86
+ host.module_eval { ruact_action(:create_post) { |_a, _b| nil } }
87
+ end.to raise_error(ArgumentError, /exactly one positional parameter/)
88
+ end
89
+
90
+ it "raises ArgumentError when the block has a required keyword argument" do
91
+ block_with_kwarg = ->(_p, required:) { required }
92
+ expect do
93
+ host.module_eval { ruact_action(:create_post, &block_with_kwarg) }
94
+ end.to raise_error(ArgumentError, /no required keyword arguments/)
95
+ end
96
+
97
+ it "ACCEPTS a block with a single positional arg" do
98
+ expect do
99
+ host.module_eval { ruact_action(:create_post) { |_p| nil } }
100
+ end.not_to raise_error
101
+ end
102
+
103
+ it "ACCEPTS a block with a splat positional arg" do
104
+ another_host = Module.new do
105
+ extend Ruact::ServerAction
106
+
107
+ def self.name
108
+ "SplatHost"
109
+ end
110
+ end
111
+ expect do
112
+ another_host.module_eval { ruact_action(:create_post) { |*_args| nil } }
113
+ end.not_to raise_error
114
+ end
115
+
116
+ it "ACCEPTS a block with optional keyword args" do
117
+ another_host = Module.new do
118
+ extend Ruact::ServerAction
119
+
120
+ def self.name
121
+ "OptKwHost"
122
+ end
123
+ end
124
+ expect do
125
+ another_host.module_eval { ruact_action(:create_post) { |_p, key: nil| key } }
126
+ end.not_to raise_error
127
+ end
128
+
129
+ it "raises Ruact::ConfigurationError for a bad naming-bridge symbol (:Create_Post)" do
130
+ expect do
131
+ host.module_eval { ruact_action(:Create_Post) { |_p| nil } }
132
+ end.to raise_error(Ruact::ConfigurationError) do |error|
133
+ expect(error.message).to include(":Create_Post")
134
+ expect(error.message).to include("GuardRailModule")
135
+ end
136
+ end
137
+
138
+ it "raises Ruact::ConfigurationError for a JS-reserved-word target (:class → \"class\")" do
139
+ expect do
140
+ host.module_eval { ruact_action(:class) { |_p| nil } }
141
+ end.to raise_error(Ruact::ConfigurationError, /JS reserved word/)
142
+ end
143
+
144
+ it "raises Ruact::ConfigurationError for a ruact-runtime-reserved target (:revalidate)" do
145
+ expect do
146
+ host.module_eval { ruact_action(:revalidate) { |_p| nil } }
147
+ end.to raise_error(Ruact::ConfigurationError) do |error|
148
+ expect(error.message).to include("revalidate")
149
+ end
150
+ end
151
+
152
+ it "raises Ruact::ConfigurationError for the second standalone host declaring the same symbol" do
153
+ host.module_eval { ruact_action(:dup_symbol) { |_p| nil } }
154
+ second_host = Module.new do
155
+ extend Ruact::ServerAction
156
+
157
+ def self.name
158
+ "SecondHostModule"
159
+ end
160
+ end
161
+ expect do
162
+ second_host.module_eval { ruact_action(:dup_symbol) { |_p| nil } }
163
+ end.to raise_error(Ruact::ConfigurationError) do |error|
164
+ expect(error.message).to include(":dup_symbol")
165
+ expect(error.message).to include("GuardRailModule")
166
+ expect(error.message).to include("SecondHostModule")
167
+ end
168
+ end
169
+
170
+ it "does NOT have a FRAMEWORK_RESERVED_METHODS check — standalone modules can use " \
171
+ "names that would clobber an ActionController method (e.g., :params), since the " \
172
+ "module has no ActionController surface" do
173
+ expect do
174
+ Module.new do
175
+ extend Ruact::ServerAction
176
+
177
+ def self.name
178
+ "FrameworkResvHost"
179
+ end
180
+
181
+ ruact_action(:params) { |_p| nil }
182
+ end
183
+ end.not_to raise_error
184
+ end
185
+
186
+ it "Story 8.3 review R4 — rejects Class hosts at first ruact_action call with a " \
187
+ "documented Ruact::ConfigurationError pointing the dev to `include Ruact::Controller`" do
188
+ klass = Class.new do
189
+ extend Ruact::ServerAction
190
+
191
+ def self.name
192
+ "WronglyExtendedClass"
193
+ end
194
+ end
195
+
196
+ expect do
197
+ klass.class_eval { ruact_action(:bad_host) { |_p| nil } }
198
+ end.to raise_error(Ruact::ConfigurationError) do |error|
199
+ expect(error.message).to include("WronglyExtendedClass")
200
+ expect(error.message).to include("standalone HOST MODULES")
201
+ expect(error.message).to include("include Ruact::Controller")
202
+ end
203
+ end
204
+
205
+ it "does NOT install a method_added hook — a later `def` on the host module does not " \
206
+ "raise (standalone modules don't define action methods at all)" do
207
+ expect do
208
+ Module.new do
209
+ extend Ruact::ServerAction
210
+
211
+ def self.name
212
+ "MethodAddedHost"
213
+ end
214
+
215
+ ruact_action(:registered_action) { |_p| nil }
216
+
217
+ # A later method definition on the module would not trigger anything
218
+ # — the registry holds the block; the module surface is irrelevant.
219
+ define_method(:registered_action) { :unrelated_def }
220
+ end
221
+ end.not_to raise_error
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.3 — `Ruact::ServerFunctions::StandaloneContext`: AC3 surface
4
+ # (exposes params/request/session/cookies/headers; render/redirect_to/head
5
+ # raise NoMethodError; current_user memoizes + falls back to env key +
6
+ # raises CurrentUserNotConfiguredError when neither path produces a value).
7
+
8
+ require "spec_helper"
9
+ require "action_controller"
10
+ require "action_dispatch"
11
+
12
+ module Ruact
13
+ module ServerFunctions
14
+ RSpec.describe StandaloneContext, :story_8_3 do
15
+ let(:env) { {} }
16
+ let(:request) do
17
+ ActionDispatch::Request.new(env.merge(
18
+ "REQUEST_METHOD" => "POST",
19
+ "rack.input" => StringIO.new("")
20
+ ))
21
+ end
22
+ let(:params) { ActionController::Parameters.new("title" => "Hello") }
23
+ let(:context) { described_class.new(params: params, request: request) }
24
+
25
+ describe "exposed surface (AC3)" do
26
+ it "exposes params (the action-call args, as ActionController::Parameters)" do
27
+ expect(context.params).to be_a(ActionController::Parameters)
28
+ expect(context.params[:title]).to eq("Hello")
29
+ end
30
+
31
+ it "exposes the live request" do
32
+ expect(context.request).to be(request)
33
+ end
34
+
35
+ it "exposes headers via request.headers" do
36
+ expect(context.headers).to be(request.headers)
37
+ end
38
+ end
39
+
40
+ describe "blocked surface — render / redirect_to / head (AC3)" do
41
+ it "render raises NoMethodError with the documented hint" do
42
+ expect { context.render(json: { ok: true }) }
43
+ .to raise_error(NoMethodError, /does not expose `render`/)
44
+ end
45
+
46
+ it "redirect_to raises NoMethodError with the documented hint" do
47
+ expect { context.redirect_to("/login") }
48
+ .to raise_error(NoMethodError, /does not expose `redirect_to`/)
49
+ end
50
+
51
+ it "head raises NoMethodError with the documented hint" do
52
+ expect { context.head(:no_content) }
53
+ .to raise_error(NoMethodError, /does not expose `head`/)
54
+ end
55
+ end
56
+
57
+ describe "current_user resolver path (AC3)" do
58
+ around do |example|
59
+ Ruact.instance_variable_set(:@config, nil)
60
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
61
+ example.run
62
+ ensure
63
+ Ruact.instance_variable_set(:@config, nil)
64
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
65
+ end
66
+
67
+ before do
68
+ # Silence the warn-on-reconfigure noise.
69
+ allow(Rails).to receive(:logger).and_return(Logger.new(IO::NULL))
70
+ end
71
+
72
+ it "raises Ruact::CurrentUserNotConfiguredError when no resolver is configured AND no env key is set" do
73
+ expect { context.current_user }.to raise_error(Ruact::CurrentUserNotConfiguredError) do |err|
74
+ expect(err.message).to include("Ruact.current_user requires Ruact.config.current_user_resolver")
75
+ expect(err.message).to include("Devise")
76
+ expect(err.message).to include("hand-rolled session")
77
+ end
78
+ end
79
+
80
+ it "returns the configured resolver's value, passing request.env to the lambda" do
81
+ user = Struct.new(:id).new(42)
82
+ Ruact.configure do |c|
83
+ c.current_user_resolver = lambda { |env_arg|
84
+ expect(env_arg).to be(request.env)
85
+ user
86
+ }
87
+ end
88
+ expect(context.current_user).to be(user)
89
+ end
90
+
91
+ it "memoizes current_user across multiple calls (resolver invoked once)" do
92
+ call_count = 0
93
+ Ruact.configure do |c|
94
+ c.current_user_resolver = lambda { |_env|
95
+ call_count += 1
96
+ :user
97
+ }
98
+ end
99
+ context.current_user
100
+ context.current_user
101
+ context.current_user
102
+ expect(call_count).to eq(1)
103
+ end
104
+
105
+ it "prefers an upstream-set request.env['ruact.current_user'] over the resolver" do
106
+ env["ruact.current_user"] = :upstream_user
107
+ resolver_called = false
108
+ Ruact.configure do |c|
109
+ c.current_user_resolver = lambda { |_env|
110
+ resolver_called = true
111
+ :resolver_user
112
+ }
113
+ end
114
+ expect(context.current_user).to eq(:upstream_user)
115
+ expect(resolver_called).to be(false)
116
+ end
117
+
118
+ it "falls back to the resolver when the env key is absent (not just nil)" do
119
+ # Pitfall: env.key? differs from env.fetch — a `nil` value should still
120
+ # prefer the env path. Verify by setting nil explicitly.
121
+ env["ruact.current_user"] = nil
122
+ Ruact.configure do |c|
123
+ c.current_user_resolver = ->(_env) { :resolver_user }
124
+ end
125
+ expect(context.current_user).to be_nil
126
+ end
127
+ end
128
+
129
+ describe "__ruact_current_user_read? (Pitfall #4 dev warning flag)" do
130
+ it "is false when the block never reads current_user" do
131
+ expect(context.__ruact_current_user_read?).to be(false)
132
+ end
133
+
134
+ it "flips to true when the block reads current_user" do
135
+ Ruact.configure { |c| c.current_user_resolver = ->(_env) { :u } }
136
+ context.current_user
137
+ expect(context.__ruact_current_user_read?).to be(true)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end