Module: Phlex::Reactive::TestHelpers
- Defined in:
- lib/phlex/reactive/test_helpers.rb,
lib/phlex/reactive/test_helpers/matchers.rb
Overview
Public test helpers for downstream apps (issue #110). Livewire ships
Livewire::test(...); this is the phlex-reactive equivalent — a public
surface so an app never reaches for the PRIVATE component.send(:reactive_token)
or hand-rolls the POST headers the docs used to teach.
Mix it in from your rails_helper:
RSpec.configure do |c|
c.include Phlex::Reactive::TestHelpers, type: :request # HTTP helpers
c.include Phlex::Reactive::TestHelpers # + the no-HTTP driver
end
The HTTP helpers (post_reactive_action/post_reactive_multipart) need Rails'
integration post, so they belong in type: :request examples. The no-HTTP
driver (run_reactive) and token minting work anywhere.
The driver goes THROUGH the endpoint's security contract on purpose — default-deny, signed identity round-trip (record re-find), schema coercion, the same transaction wrapper. A helper that skipped it would teach users to test a component that would fail at the real endpoint.
NB: verbose_errors defaults ON in the test env (Rails.env.local? — issue
#82). It only changes an endpoint FAILURE body (never a status), so it does
not affect these helpers' happy path; a downstream app asserting an empty
failure body may want Phlex::Reactive.verbose_errors = false in its setup.
Defined Under Namespace
Classes: Result, UndeclaredReactiveAction
Class Method Summary collapse
-
.describe_streams(streams) ⇒ Object
A compact listing of the turbo-stream action="..." target="..." pairs in
streams— the readable core of every failure message. -
.matcher_target_id(component_or_id) ⇒ Object
The DOM id a matcher argument targets: a String is the id verbatim; a component instance answers #id.
-
.stream_action_targets?(stream, actions, id) ⇒ Boolean
True when a turbo-stream's opening tag re-renders
idwith one ofactionsat that target.
Instance Method Summary collapse
-
#post_reactive_action(component_or_class, act, params: {}, payload: {}) ⇒ Object
POST a reactive action as the client's JSON body (token + act + params) to Phlex::Reactive.action_path — NEVER a hardcoded "/reactive/actions", so a remounted path (the gem warns about shadowed paths) is honored.
-
#post_reactive_multipart(component_or_class, act, params: {}, payload: {}) ⇒ Object
POST a reactive action as multipart FormData (the client's encoding when a
:fileparam is present, issue #34): token + act flat, params bracketed. -
#reactive_token_for(component_or_class, payload = {}) ⇒ Object
Mint an identity token exactly as a component would.
-
#run_reactive(component, action, **params) ⇒ Object
The no-HTTP unit driver.
Class Method Details
.describe_streams(streams) ⇒ Object
A compact listing of the turbo-stream action="..." target="..." pairs in
streams — the readable core of every failure message.
31 32 33 34 35 36 37 38 39 |
# File 'lib/phlex/reactive/test_helpers/matchers.rb', line 31 def self.describe_streams(streams) pairs = streams.map do |stream| open_tag = stream[/<turbo-stream\b[^>]*>/] || stream action = open_tag[/\baction="([^"]+)"/, 1] || "?" target = open_tag[/\btarget="([^"]+)"/, 1] target ? "#{action}->##{target}" : action end pairs.empty? ? "(no streams)" : pairs.join(", ") end |
.matcher_target_id(component_or_id) ⇒ Object
The DOM id a matcher argument targets: a String is the id verbatim; a component instance answers #id.
25 26 27 |
# File 'lib/phlex/reactive/test_helpers/matchers.rb', line 25 def self.matcher_target_id(component_or_id) component_or_id.is_a?(::String) ? component_or_id : component_or_id.id end |
.stream_action_targets?(stream, actions, id) ⇒ Boolean
True when a turbo-stream's opening tag re-renders id with one of
actions at that target. Shared by the replace/remove target checks.
43 44 45 46 47 |
# File 'lib/phlex/reactive/test_helpers/matchers.rb', line 43 def self.stream_action_targets?(stream, actions, id) open_tag = stream[/<turbo-stream\b[^>]*>/] || "" actions.include?(open_tag[/\baction="([^"]+)"/, 1]) && open_tag.include?(%(target="#{ERB::Util.html_escape(id)}")) end |
Instance Method Details
#post_reactive_action(component_or_class, act, params: {}, payload: {}) ⇒ Object
POST a reactive action as the client's JSON body (token + act + params) to
Phlex::Reactive.action_path — NEVER a hardcoded "/reactive/actions", so a
remounted path (the gem warns about shadowed paths) is honored. Accepts a
component INSTANCE or a CLASS + explicit payload: (the identity pieces).
56 57 58 59 60 61 |
# File 'lib/phlex/reactive/test_helpers.rb', line 56 def post_reactive_action(component_or_class, act, params: {}, payload: {}) token = reactive_action_token(component_or_class, payload) post Phlex::Reactive.action_path, params: { token:, act:, params: }.to_json, headers: { "Content-Type" => "application/json", "Accept" => "text/vnd.turbo-stream.html" } end |
#post_reactive_multipart(component_or_class, act, params: {}, payload: {}) ⇒ Object
POST a reactive action as multipart FormData (the client's encoding when a
:file param is present, issue #34): token + act flat, params bracketed.
No JSON Content-Type — Rails' test post builds the multipart body from
the nested Hash (files ride as UploadedFile values).
67 68 69 70 71 72 |
# File 'lib/phlex/reactive/test_helpers.rb', line 67 def post_reactive_multipart(component_or_class, act, params: {}, payload: {}) token = reactive_action_token(component_or_class, payload) post Phlex::Reactive.action_path, params: { token:, act:, params: }, headers: { "Accept" => "text/vnd.turbo-stream.html" } end |
#reactive_token_for(component_or_class, payload = {}) ⇒ Object
Mint an identity token exactly as a component would.
- CLASS form — the already-public sign path: Phlex::Reactive.sign( payload.merge("c" => klass.name)). Pass the identity pieces yourself ("gid" => record.to_gid.to_s, "s" => ...) when you need them.
- INSTANCE form — wraps the component's PRIVATE #reactive_token, so the token carries the instance's live record/state with no hand-assembly.
44 45 46 47 48 49 50 |
# File 'lib/phlex/reactive/test_helpers.rb', line 44 def reactive_token_for(component_or_class, payload = {}) if component_or_class.is_a?(::Class) Phlex::Reactive.sign(payload.merge("c" => component_or_class.name)) else component_or_class.send(:reactive_token) end end |
#run_reactive(component, action, **params) ⇒ Object
The no-HTTP unit driver. Runs action on component through the SAME
security contract the endpoint enforces, and returns a Result:
1. default-deny — an undeclared action raises UndeclaredReactiveAction.
2. identity round-trip — signs the instance's token, verifies it, and
rebuilds the component via from_identity (re-finding a record-backed
component's row; a stale gid raises ActiveRecord::RecordNotFound, the
endpoint's 404 equivalent). So the action runs against the rebuilt
instance, exactly as it would after a real POST.
3. coercion — params pass through the action's compiled ParamSchema
(issue #109); undeclared keys are dropped, declared ones cast.
4. transaction — run inside the endpoint's transaction wrapper so
after_commit broadcasts behave.
5. authorization — a registered authorization error RAISES (the endpoint
maps it to 403; a unit test asserts the real exception).
89 90 91 92 93 94 95 96 97 98 |
# File 'lib/phlex/reactive/test_helpers.rb', line 89 def run_reactive(component, action, **params) klass = component.class action_def = klass.reactive_action(action) || raise_undeclared(klass, action) rebuilt = rebuild_from_identity(component, klass) coerced = action_def.schema.coerce(stringify_params(params)) returned = run_reactive_action(rebuilt, action_def, coerced) Result.new(returned:, component: rebuilt) end |