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

Instance Method Summary collapse

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.

Returns:

  • (Boolean)


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