funicular 0.4.0 → 0.5.0

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +429 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +1 -1
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +10 -2
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/file_upload.rb +17 -7
  52. data/mrblib/funicular.rb +136 -17
  53. data/mrblib/http.rb +84 -107
  54. data/mrblib/model.rb +1178 -23
  55. data/mrblib/relation.rb +342 -0
  56. data/mrblib/router.rb +45 -4
  57. data/mrblib/styles.rb +20 -0
  58. data/sig/component.rbs +7 -0
  59. data/sig/db.rbs +328 -0
  60. data/sig/funicular.rbs +5 -0
  61. data/sig/http.rbs +8 -21
  62. data/sig/model.rbs +101 -7
  63. data/sig/relation.rbs +44 -0
  64. data/sig/router.rbs +1 -0
  65. data/sig/styles.rbs +1 -0
  66. metadata +19 -2
  67. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Funicular
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/funicular.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "funicular/version"
4
4
  require_relative "funicular/configuration"
5
+ require_relative "funicular/session_epoch"
5
6
  require_relative "funicular/compiler"
6
7
  require_relative "funicular/plugin"
7
8
  require_relative "funicular/schema"
@@ -101,12 +101,20 @@ namespace :funicular do
101
101
 
102
102
  FileUtils.cp(source_js, dest_js)
103
103
  FileUtils.cp(source_css, dest_css)
104
- FileUtils.cp(source_initializer, dest_initializer)
105
104
 
106
105
  puts "Installed Funicular debug assets:"
107
106
  puts " - #{dest_js}"
108
107
  puts " - #{dest_css}"
109
- puts " - #{dest_initializer}"
108
+
109
+ # The initializer belongs to the application once it exists:
110
+ # re-running the installer must never clobber its configuration
111
+ # (source choice, local_database opt-in, user_key).
112
+ if File.exist?(dest_initializer)
113
+ puts "Skipped #{dest_initializer} (exists; delete it first to reinstall the template)"
114
+ else
115
+ FileUtils.cp(source_initializer, dest_initializer)
116
+ puts " - #{dest_initializer}"
117
+ end
110
118
  end
111
119
 
112
120
  desc "Install vendored PicoRuby.wasm artifacts (dist + debug) into public/picoruby/"
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The mrblib runtime is plain Ruby, so the CRuby suite can cover the
6
+ # Response#body alias and the handler error report format directly;
7
+ # the browser-side wiring ships with the next picoruby.wasm rebuild.
8
+ class CallbackErrorVisibilityTest < Minitest::Test
9
+ def setup
10
+ Funicular::SSR::Runtime.load_framework!
11
+ end
12
+
13
+ # Assigning the anonymous class to a constant names it, so the report
14
+ # prints a real component name. Defined lazily: Funicular::Component
15
+ # only exists after the framework loads.
16
+ def probe_component
17
+ unless defined?(::CheckoutProbeComponent)
18
+ Object.const_set(:CheckoutProbeComponent, Class.new(Funicular::Component) do
19
+ def render; end
20
+ end)
21
+ end
22
+ ::CheckoutProbeComponent.new
23
+ end
24
+
25
+ def test_response_body_is_an_alias_of_data
26
+ response = Funicular::HTTP::Response.new(200, { "id" => 1 })
27
+ assert_equal response.data, response.body
28
+ assert_equal({ "id" => 1 }, response.body)
29
+ end
30
+
31
+ def test_report_handler_error_names_component_handler_and_event
32
+ error = ArgumentError.new("wrong number of arguments (given 1, expected 0)")
33
+
34
+ out, _err = capture_io do
35
+ probe_component.report_handler_error("click", "#handle_save", error)
36
+ end
37
+
38
+ assert_includes out, "CheckoutProbeComponent#handle_save"
39
+ assert_includes out, "(onclick)"
40
+ assert_includes out, "ArgumentError: wrong number of arguments"
41
+ end
42
+
43
+ def test_report_handler_error_never_raises
44
+ capture_io do
45
+ assert_nil probe_component.report_handler_error("click", "#x", RuntimeError.new("y"))
46
+ end
47
+ end
48
+ end
@@ -61,4 +61,82 @@ class ConfigurationTest < Minitest::Test
61
61
  assert_equal vendored, @config.cdn_version
62
62
  end
63
63
  end
64
+
65
+ # --- local-database namespace settings (docs decisions 12/13) ---------
66
+
67
+ def test_namespace_defaults
68
+ assert_equal false, @config.local_database
69
+ assert_equal "funicular", @config.application_id
70
+ assert_nil @config.user_key
71
+ assert_equal false, @config.anonymous_only
72
+ end
73
+
74
+ def test_local_database_setter_normalizes_truthiness
75
+ @config.local_database = Object.new
76
+ assert_equal true, @config.local_database
77
+ @config.local_database = nil
78
+ assert_equal false, @config.local_database
79
+ end
80
+
81
+ def test_disabled_local_database_needs_no_identity
82
+ assert_equal true, @config.validate_local_database!
83
+ end
84
+
85
+ def test_enabled_local_database_requires_an_identity_declaration
86
+ @config.local_database = true
87
+ error = assert_raises(ArgumentError) do
88
+ @config.validate_local_database!
89
+ end
90
+ assert_includes error.message, "user_key"
91
+ assert_includes error.message, "anonymous_only"
92
+ end
93
+
94
+ def test_either_identity_declaration_satisfies_local_database
95
+ @config.local_database = true
96
+ @config.anonymous_only = true
97
+ assert_equal true, @config.validate_local_database!
98
+
99
+ fresh = Funicular::Configuration.new
100
+ fresh.local_database = true
101
+ fresh.user_key = ->(_controller) { nil }
102
+ assert_equal true, fresh.validate_local_database!
103
+ end
104
+
105
+ def test_application_id_rejects_empty_values
106
+ error = assert_raises(ArgumentError) { @config.application_id = "" }
107
+ assert_includes error.message, "application_id"
108
+ error = assert_raises(ArgumentError) { @config.application_id = nil }
109
+ assert_includes error.message, "application_id"
110
+ end
111
+
112
+ def test_application_id_is_canonicalized_with_to_s
113
+ @config.application_id = :chat_app
114
+ assert_equal "chat_app", @config.application_id
115
+ end
116
+
117
+ def test_user_key_must_be_callable
118
+ error = assert_raises(ArgumentError) { @config.user_key = "not callable" }
119
+ assert_includes error.message, "callable"
120
+ end
121
+
122
+ def test_user_key_and_anonymous_only_are_mutually_exclusive
123
+ # The reliably-detectable server-side config error: whichever
124
+ # setter comes second raises, in both orders.
125
+ @config.user_key = ->(controller) { nil }
126
+ error = assert_raises(ArgumentError) { @config.anonymous_only = true }
127
+ assert_includes error.message, "mutually exclusive"
128
+
129
+ fresh = Funicular::Configuration.new
130
+ fresh.anonymous_only = true
131
+ error = assert_raises(ArgumentError) do
132
+ fresh.user_key = ->(controller) { nil }
133
+ end
134
+ assert_includes error.message, "mutually exclusive"
135
+ end
136
+
137
+ def test_anonymous_only_false_never_conflicts
138
+ @config.user_key = ->(controller) { nil }
139
+ @config.anonymous_only = false
140
+ assert_equal false, @config.anonymous_only
141
+ end
64
142
  end
data/minitest/dsl_test.rb CHANGED
@@ -164,6 +164,33 @@ class DSLTest < Minitest::Test
164
164
  assert_equal "btn btn--on extra", vnode.children[2].props[:class]
165
165
  end
166
166
 
167
+ def test_style_value_behaves_like_the_string_it_wraps
168
+ klass = Class.new(Funicular::Component) do
169
+ styles do
170
+ field "field-base"
171
+ end
172
+
173
+ def render
174
+ div(class: styles.field + " col-span-2") { "x" }
175
+ end
176
+ end
177
+
178
+ vnode = klass.new.build_vdom
179
+ assert_equal "field-base col-span-2", vnode.props[:class]
180
+
181
+ value = klass.new.styles.field
182
+ combined = value + "!"
183
+ assert_kind_of Funicular::StyleValue, combined
184
+ assert_equal "field-base!", combined.to_s
185
+ assert_equal "field-basefield-base", (value + value).to_s
186
+ # Like String#+: non-String operands raise instead of being to_s-ed.
187
+ assert_raises(TypeError) { value + 123 }
188
+ # No to_str on purpose: the mruby client's String#+ never coerces,
189
+ # so CRuby must reject the reversed form the same way the browser
190
+ # does instead of letting SSR accept code the client raises on.
191
+ assert_raises(TypeError) { "prefix " + value }
192
+ end
193
+
167
194
  def test_unknown_style_raises
168
195
  klass = Class.new(Funicular::Component) do
169
196
  styles do
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "funicular/epoch_header"
5
+
6
+ # Exercises Funicular::EpochHeader, the middleware that writes
7
+ # X-Funicular-Epoch on every outgoing response: from the env value the
8
+ # controller concern pinned, from the stored session entry when the
9
+ # request died before the concern ran, or not at all when neither
10
+ # exists.
11
+ class EpochHeaderTest < Minitest::Test
12
+ # Lowercase: the Rack 3 spec requires lowercase response header
13
+ # names, and HTTP header lookups are case-insensitive client-side.
14
+ HEADER = "x-funicular-epoch"
15
+
16
+ def setup
17
+ @config = Funicular::Configuration.new
18
+ @config.local_database = true
19
+ Funicular.instance_variable_set(:@configuration, @config)
20
+ end
21
+
22
+ def teardown
23
+ Funicular.instance_variable_set(:@configuration, nil)
24
+ end
25
+
26
+ def app_returning(status, headers = {})
27
+ ->(_env) { [status, headers, ["body"]] }
28
+ end
29
+
30
+ def test_writes_the_env_pinned_epoch
31
+ middleware = Funicular::EpochHeader.new(app_returning(200))
32
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
33
+ assert_equal "e-ctrl", headers[HEADER]
34
+ end
35
+
36
+ def test_disabled_local_database_leaves_the_response_untouched
37
+ @config.local_database = false
38
+ headers = { HEADER => "inner", "X-Funicular-Epoch" => "legacy" }
39
+ middleware = Funicular::EpochHeader.new(app_returning(200, headers))
40
+ _, result, = middleware.call({ "funicular.epoch" => "outer" })
41
+ assert_same headers, result
42
+ assert_equal "inner", result[HEADER]
43
+ assert_equal "legacy", result["X-Funicular-Epoch"]
44
+ end
45
+
46
+ def test_falls_back_to_the_stored_session_epoch
47
+ # The concern never ran (an earlier before_action raised, say),
48
+ # but the session already carries this app's epoch: stamped
49
+ # without rotation.
50
+ session = {
51
+ "funicular_epochs" => {
52
+ "funicular" => { "identity" => "i", "epoch" => "e-stored" },
53
+ },
54
+ }
55
+ middleware = Funicular::EpochHeader.new(app_returning(500))
56
+ _, headers, = middleware.call({ "rack.session" => session })
57
+ assert_equal "e-stored", headers[HEADER]
58
+ end
59
+
60
+ def test_the_env_value_wins_over_the_stored_one
61
+ session = {
62
+ "funicular_epochs" => {
63
+ "funicular" => { "identity" => "i", "epoch" => "e-stored" },
64
+ },
65
+ }
66
+ middleware = Funicular::EpochHeader.new(app_returning(200))
67
+ _, headers, = middleware.call(
68
+ { "funicular.epoch" => "e-ctrl", "rack.session" => session })
69
+ assert_equal "e-ctrl", headers[HEADER]
70
+ end
71
+
72
+ def test_without_any_epoch_the_header_stays_absent
73
+ middleware = Funicular::EpochHeader.new(app_returning(200))
74
+ _, headers, = middleware.call({})
75
+ refute headers.key?(HEADER)
76
+ end
77
+
78
+ def test_an_inner_header_is_dropped_when_no_epoch_is_available
79
+ # No env value and no session: the contract is "no authoritative
80
+ # epoch, no header". An inner layer's leftover would otherwise
81
+ # survive as the page's only epoch signal -- and one stamped
82
+ # before the session rotated would read as a MATCH, keeping a page
83
+ # alive that decision 13 wants terminal.
84
+ middleware = Funicular::EpochHeader.new(
85
+ app_returning(200, { HEADER => "stale-pre-login" }))
86
+ _, headers, = middleware.call({})
87
+ refute headers.key?(HEADER)
88
+ end
89
+
90
+ def test_a_legacy_cased_inner_header_is_dropped_without_an_epoch_too
91
+ middleware = Funicular::EpochHeader.new(
92
+ app_returning(200, { "X-Funicular-Epoch" => "stale-pre-login" }))
93
+ _, headers, = middleware.call({})
94
+ refute headers.key?("X-Funicular-Epoch")
95
+ refute headers.key?(HEADER)
96
+ end
97
+
98
+ def test_respects_the_configured_application_id
99
+ @config.application_id = "second_app"
100
+ session = {
101
+ "funicular_epochs" => {
102
+ "funicular" => { "identity" => "i", "epoch" => "e-other" },
103
+ "second_app" => { "identity" => "i", "epoch" => "e-mine" },
104
+ },
105
+ }
106
+ middleware = Funicular::EpochHeader.new(app_returning(200))
107
+ _, headers, = middleware.call({ "rack.session" => session })
108
+ assert_equal "e-mine", headers[HEADER]
109
+ end
110
+
111
+ def test_overwrites_a_stale_inner_header
112
+ # An inner layer's header may have been stamped BEFORE a login/
113
+ # logout rotated the epoch; keeping it would let an old page apply
114
+ # the post-transition response as a match. The framework's value
115
+ # is authoritative.
116
+ middleware = Funicular::EpochHeader.new(
117
+ app_returning(200, { HEADER => "stale-pre-login" }))
118
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
119
+ assert_equal "e-ctrl", headers[HEADER]
120
+ end
121
+
122
+ def test_a_legacy_cased_inner_header_cannot_ride_out_as_a_duplicate
123
+ # Plain header hashes are case-sensitive: a stale capitalized
124
+ # spelling must be removed, not merely shadowed by our lowercase
125
+ # (Rack 3) name.
126
+ middleware = Funicular::EpochHeader.new(
127
+ app_returning(200, { "X-Funicular-Epoch" => "stale-pre-login" }))
128
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
129
+ assert_equal "e-ctrl", headers[HEADER]
130
+ refute headers.key?("X-Funicular-Epoch")
131
+ end
132
+
133
+ def test_the_header_name_is_lowercase_for_rack_3
134
+ middleware = Funicular::EpochHeader.new(app_returning(200))
135
+ _, headers, = middleware.call({ "funicular.epoch" => "e-ctrl" })
136
+ assert_equal ["x-funicular-epoch"], headers.keys
137
+ end
138
+
139
+ def test_a_broken_session_read_never_masks_the_response
140
+ broken = Object.new
141
+ def broken.[](_key)
142
+ raise "session store exploded"
143
+ end
144
+ middleware = Funicular::EpochHeader.new(app_returning(500))
145
+ status, headers, = middleware.call({ "rack.session" => broken })
146
+ assert_equal 500, status
147
+ refute headers.key?(HEADER)
148
+ end
149
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "funicular/epoch_stamping"
5
+ require "funicular/epoch_header"
6
+
7
+ # Exercises Funicular::EpochStamping (the controller mixin the Railtie
8
+ # installs) and its interplay with the EpochHeader middleware. The
9
+ # around_action stamps the epoch before the action AND re-stamps in
10
+ # its ensure with the post-action identity: a login/logout inside the
11
+ # action rotates on its own response, and a raising action still gets
12
+ # a truthful header on the exception page.
13
+ class EpochStampingTest < Minitest::Test
14
+ class FakeRequest
15
+ attr_reader :env, :session
16
+
17
+ def initialize(env = {}, session = nil)
18
+ @env = env
19
+ @session = session
20
+ end
21
+ end
22
+
23
+ class FakeControllerBase
24
+ def self.around_action(name)
25
+ (@around_actions ||= []) << name
26
+ end
27
+
28
+ def self.around_actions
29
+ @around_actions || []
30
+ end
31
+
32
+ attr_reader :request, :session
33
+
34
+ def initialize(session, env = {})
35
+ @request = FakeRequest.new(env, session)
36
+ @session = session
37
+ end
38
+
39
+ # A minimal filter chain: the action block runs inside every
40
+ # registered around_action, exceptions propagating like Rails'.
41
+ def process(&action)
42
+ chain = self.class.around_actions.reverse.inject(action) do |inner, name|
43
+ -> { send(name) { inner.call } }
44
+ end
45
+ chain.call
46
+ end
47
+ end
48
+
49
+ class StampedController < FakeControllerBase
50
+ include Funicular::EpochStamping
51
+
52
+ attr_accessor :current_user_key
53
+ end
54
+
55
+ # What request.session looks like in a session-less Rails API app:
56
+ # enabled? says no, every read and write raises.
57
+ class DisabledSession
58
+ def enabled?
59
+ false
60
+ end
61
+
62
+ def [](_key)
63
+ raise "sessions are disabled in this application"
64
+ end
65
+
66
+ def []=(_key, _value)
67
+ raise "sessions are disabled in this application"
68
+ end
69
+ end
70
+
71
+ def setup
72
+ @config = Funicular::Configuration.new
73
+ @config.local_database = true
74
+ @config.user_key = ->(controller) { controller.current_user_key }
75
+ Funicular.instance_variable_set(:@configuration, @config)
76
+ @session = {}
77
+ end
78
+
79
+ def teardown
80
+ Funicular.instance_variable_set(:@configuration, nil)
81
+ end
82
+
83
+ def session_epoch
84
+ entry = @session["funicular_epochs"]
85
+ entry ? entry["funicular"]["epoch"] : nil
86
+ end
87
+
88
+ # Runs one fake request: the controller starts as initial_key, the
89
+ # action block may mutate it (login/logout), the env epoch is what
90
+ # the middleware would write.
91
+ def run_request(initial_key, env = {}, &action)
92
+ controller = StampedController.new(@session, env)
93
+ controller.current_user_key = initial_key
94
+ controller.process { action ? action.call(controller) : nil }
95
+ env["funicular.epoch"]
96
+ end
97
+
98
+ def test_including_registers_the_around_action
99
+ assert_includes StampedController.around_actions, :stamp_funicular_epoch
100
+ end
101
+
102
+ # An application may route an action named "session" (a schema
103
+ # endpoint, say): that action SHADOWS the controller's session
104
+ # accessor. The concern must read request.session and never notice.
105
+ # (Not a StampedController subclass: the fake around_action registry
106
+ # is a class ivar and does not inherit.)
107
+ class SessionShadowedController < FakeControllerBase
108
+ include Funicular::EpochStamping
109
+
110
+ attr_accessor :current_user_key
111
+
112
+ def session
113
+ raise "the session action was invoked instead of the accessor"
114
+ end
115
+ end
116
+
117
+ def test_the_stamp_never_calls_the_controller_session_accessor
118
+ env = {}
119
+ controller = SessionShadowedController.new(@session, env)
120
+ controller.current_user_key = "u1"
121
+ ran = false
122
+ controller.process { ran = true }
123
+ assert ran
124
+ refute_nil env["funicular.epoch"]
125
+ assert_equal session_epoch, env["funicular.epoch"]
126
+ end
127
+
128
+ def test_disabled_local_database_does_not_touch_the_session_or_resolver
129
+ @config.local_database = false
130
+ @config.user_key = ->(_controller) { flunk "resolver was called" }
131
+ ran = false
132
+ session = {}
133
+ controller = StampedController.new(session)
134
+ controller.current_user_key = "u1"
135
+ controller.process { ran = true }
136
+ assert ran
137
+ assert_empty session
138
+ assert_nil controller.request.env["funicular.epoch"]
139
+ end
140
+
141
+ def test_the_epoch_lands_in_the_request_env
142
+ epoch = run_request("u1")
143
+ refute_nil epoch
144
+ refute_empty epoch
145
+ # Same user, same session: stable across requests.
146
+ assert_equal epoch, run_request("u1")
147
+ end
148
+
149
+ def test_the_env_value_matches_the_session_entry
150
+ epoch = run_request("u1")
151
+ assert_equal epoch, session_epoch
152
+ end
153
+
154
+ def test_a_login_inside_the_action_rotates_on_its_own_response
155
+ # The page was rendered signed-out; the LOGIN action itself flips
156
+ # the identity mid-request. Its own response must already carry
157
+ # the rotated epoch -- with only a pre-action stamp, an old page
158
+ # would apply the login response as an epoch match and the
159
+ # rotation would be a request late.
160
+ anonymous = run_request(nil)
161
+ login_epoch = run_request(nil) do |controller|
162
+ controller.current_user_key = "u1"
163
+ end
164
+ refute_equal anonymous, login_epoch
165
+ assert_equal session_epoch, login_epoch
166
+ # And it holds on the user's next request.
167
+ assert_equal login_epoch, run_request("u1")
168
+ end
169
+
170
+ def test_a_logout_inside_the_action_rotates_on_its_own_response
171
+ signed_in = run_request("u1")
172
+ logout_epoch = run_request("u1") do |controller|
173
+ controller.current_user_key = nil
174
+ end
175
+ refute_equal signed_in, logout_epoch
176
+ assert_equal session_epoch, logout_epoch
177
+ end
178
+
179
+ def test_a_disabled_session_skips_stamping_but_runs_the_action
180
+ # Session-less Rails API apps (no session middleware) must keep
181
+ # working with Funicular merely loaded: the epoch feature stays
182
+ # off instead of raising on every action.
183
+ ran = false
184
+ controller = StampedController.new(DisabledSession.new)
185
+ controller.current_user_key = "u1"
186
+ controller.process { ran = true }
187
+ assert ran
188
+ assert_nil controller.request.env["funicular.epoch"]
189
+ end
190
+
191
+ def test_an_enabled_session_object_still_stamps
192
+ enabled = Hash.new
193
+ def enabled.enabled?
194
+ true
195
+ end
196
+ controller = StampedController.new(enabled)
197
+ controller.current_user_key = "u1"
198
+ controller.process { nil }
199
+ refute_nil controller.request.env["funicular.epoch"]
200
+ end
201
+
202
+ def test_a_raising_action_still_produces_a_stamped_response
203
+ # The exception renderer builds a FRESH response none of the
204
+ # controller's headers survive into; the ensure re-stamp plus the
205
+ # middleware cover it -- with the POST-transition identity even
206
+ # when the action logged the user in before dying.
207
+ inner = lambda do |env|
208
+ controller = StampedController.new(@session, env)
209
+ controller.current_user_key = nil
210
+ begin
211
+ controller.process do
212
+ controller.current_user_key = "u1"
213
+ raise "boom in the action"
214
+ end
215
+ rescue RuntimeError
216
+ [500, {}, ["error page"]]
217
+ end
218
+ end
219
+ status, headers, = Funicular::EpochHeader.new(inner).call({})
220
+ assert_equal 500, status
221
+ assert_equal session_epoch, headers["x-funicular-epoch"]
222
+ assert_equal '["v1","funicular","user","u1"]',
223
+ @session["funicular_epochs"]["funicular"]["identity"]
224
+ end
225
+ end
@@ -0,0 +1,15 @@
1
+ # Reads both props and state so SSR.render_component tests can assert
2
+ # each channel is wired through. Not routed on purpose: rendering it
3
+ # is only possible through the single-component entry point.
4
+ class ProbeComponent < Funicular::Component
5
+ def initialize_state
6
+ { note: "default note" }
7
+ end
8
+
9
+ def render
10
+ div(class: "probe") do
11
+ h2 { props[:label].to_s }
12
+ p { state[:note] }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ Funicular::SSR::Runtime.load_framework!
6
+
7
+ # Router-side navigation guard: a component may veto navigation with a
8
+ # confirmation message (mrblib logic exercised under CRuby; the browser
9
+ # integration is covered by test/navigation_guard_test.rb and the
10
+ # beforeunload listener).
11
+ class NavigationGuardTest < Minitest::Test
12
+ class FreeComponent
13
+ def navigation_guard
14
+ nil
15
+ end
16
+ end
17
+
18
+ class GuardedComponent
19
+ def navigation_guard
20
+ "Unsaved changes will be lost. Leave?"
21
+ end
22
+ end
23
+
24
+ def setup
25
+ @router = Funicular::Router.new(nil)
26
+ end
27
+
28
+ def teardown
29
+ Funicular.confirm_handler = nil
30
+ end
31
+
32
+ def test_leaving_is_allowed_without_a_current_component
33
+ assert_equal true, @router.leave_allowed?
34
+ end
35
+
36
+ def test_leaving_is_allowed_when_the_guard_returns_nil
37
+ @router.instance_variable_set(:@current_component, FreeComponent.new)
38
+ assert_equal true, @router.leave_allowed?
39
+ end
40
+
41
+ def test_guard_message_is_prompted_and_denial_blocks_leaving
42
+ @router.instance_variable_set(:@current_component, GuardedComponent.new)
43
+ asked = []
44
+ Funicular.confirm_handler = ->(message) { asked << message; false }
45
+
46
+ assert_equal false, @router.leave_allowed?
47
+ assert_equal [ "Unsaved changes will be lost. Leave?" ], asked
48
+ end
49
+
50
+ def test_confirmation_allows_leaving
51
+ @router.instance_variable_set(:@current_component, GuardedComponent.new)
52
+ Funicular.confirm_handler = ->(_message) { true }
53
+
54
+ assert_equal true, @router.leave_allowed?
55
+ end
56
+
57
+ def test_confirm_defaults_to_true_on_the_server
58
+ # SSR must never block: without a handler, server mode allows leaving.
59
+ assert_equal true, Funicular.confirm("anything")
60
+ end
61
+
62
+ def test_component_guard_defaults_to_nil
63
+ assert_nil Funicular::Component.new.navigation_guard
64
+ end
65
+ end