diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8b025..706d868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to ActionFigure will be documented in this file. +## [0.7.0] - 2026-06-29 + +### Added + +- **Contract assertions** — test an action's **`params_schema`**/**`rules`** in isolation, without invoking the action body. Minitest: **`assert_valid_params(action_class, params)`** and **`assert_invalid_params(action_class, params, on: :field)`**. RSpec: **`accept_params(params)`** and **`reject_params(params).with_error_on(:field)`** (subject is the action class). These are formatter-agnostic. Both raise a clear **`ArgumentError`** for actions without a **`params_schema`**. +- **`assert_action_json`** / **`refute_action_json`** Minitest assertions — partial match on **`result[:json]`**, mirroring the RSpec **`have_action_json`** matcher. Nested Hashes match as subsets and **`Regexp`** values match against strings. +- Negated Minitest status assertions: **`refute_Ok`**, **`refute_Created`**, … for every status (parity with RSpec **`not_to be_Ok`**). + +### Changed + +- Status helpers for both adapters are now generated from a single **`ActionFigure::Testing::STATUSES`** registry (including **`NoContent`**), so the Minitest and RSpec lists can no longer drift. Replaces the RSpec-only **`ActionFigure::Testing::RSpec::MATCHERS`** constant. +- Status assertions/matchers now fail with a clear "expected an ActionFigure result hash" message when given a non-Hash, instead of raising **`NoMethodError`**. + +### Known limitation + +- There is **no** formatter-agnostic assertion for **non-validation** error bodies (e.g. **`NotFound`**/**`Conflict`** with a custom **`errors:`** payload). Each formatter stores errors differently (**`json[:errors]`** vs **`json[:data]`** vs a JSON:API array) and the result hash carries no formatter identity. Assert those with a format-specific **`assert_action_json`** / **`have_action_json`**. Validation errors are best tested via the contract assertions above. + ## [0.6.2] - 2026-06-25 ### Fixed diff --git a/README.md b/README.md index 94e51f1..828ce02 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Every action class has three responsibilities: | [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. | | [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. | | [Notifications](docs/activesupport-notifications.md) | Opt-in `ActiveSupport::Notifications` events for every action call. Emits action class, outcome status, and duration on the `process.action_figure` event. | -| [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. | +| [Testing](docs/testing.md) | Status assertions/matchers (`assert_Ok`/`be_Ok`, ... plus negated `refute_Ok`), body matchers (`assert_action_json`/`have_action_json`), and formatter-agnostic contract assertions (`assert_valid_params`/`accept_params`, `assert_invalid_params`/`reject_params`). | | [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). | ## Design Philosophy diff --git a/docs/testing.md b/docs/testing.md index 042f9df..68c92db 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -36,6 +36,8 @@ end | `assert_Conflict(result)` | `:conflict` | | `assert_PaymentRequired(result)` | `:payment_required` | +Each status assertion has a negated counterpart — **`refute_Ok`**, **`refute_Created`**, … — that passes when the status is anything *other* than the named one. + These helpers compare **only `result[:status]`** against the Rack-style symbol Rails uses in **`render`** — they **do not** assert on **`[:json]`** keys, payloads, or error message text. Combine them with assertions on **`result[:json]`** (or matchers on the body your formatter produces) whenever shape matters. All assertions accept an optional second argument for a custom failure message: @@ -50,6 +52,18 @@ When a status assertion fails, the default message shows the expected and actual Expected result status to be :ok, but got :unprocessable_content ``` +### Asserting on the body + +Use **`assert_action_json`** to match a (possibly nested) subset of **`result[:json]`** — the Minitest counterpart to RSpec's **`have_action_json`**. Nested Hashes match as subsets, and **`Regexp`** values match against strings: + +```ruby +assert_action_json(result, status: "success") +assert_action_json(result, status: "success", data: { name: "Jane" }) +assert_action_json(result, data: { email: /@example\.com\z/ }) +``` + +**`refute_action_json`** passes when the fragment does **not** match. Both fail with a clear message when given a non-result value or a hash missing the **`:json`** key. + --- ## RSpec @@ -79,6 +93,8 @@ require "action_figure/testing/rspec" | `be_Conflict` | `:conflict` | | `be_PaymentRequired` | `:payment_required` | | `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` | +| `accept_params(params)` | action class's contract accepts `params` | +| `reject_params(params)` | action class's contract rejects `params` (chain `.with_error_on(:field)`) | Like the Minitest helpers, each **`be_*`** matcher compares **only `result[:status]`** — **`[:json]`** is ignored unless you assert on it separately. Use **`have_action_json`** when you want a focused assertion against the **`json`** body (compose with **`a_hash_including`** for nested subsets): @@ -279,6 +295,49 @@ class Users::CreateActionTest < Minitest::Test end ``` +### Contract assertion helpers + +The testing adapters wrap the `.contract.call` boilerplate above in intention-revealing helpers. They are **formatter-agnostic** — they exercise the validation pipeline directly, so the same assertions work regardless of which formatter the action includes. + +**Minitest** — the subject is the action class: + +```ruby +class Users::CreateActionTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def test_accepts_valid_params + assert_valid_params(Users::CreateAction, { email: "jane@example.com", name: "Jane" }) + end + + def test_requires_email + # passes when the contract rejects the params at all + assert_invalid_params(Users::CreateAction, { name: "Jane" }) + + # scope to a field: passes only when :email is among the errors + assert_invalid_params(Users::CreateAction, { name: "Jane" }, on: :email) + end +end +``` + +**RSpec** — the subject is the action class, not a result hash: + +```ruby +RSpec.describe Users::CreateAction do + it "accepts valid params" do + expect(Users::CreateAction).to accept_params(email: "jane@example.com", name: "Jane") + end + + it "requires email" do + expect(Users::CreateAction).to reject_params(name: "Jane") + expect(Users::CreateAction).to reject_params(name: "Jane").with_error_on(:email) + end +end +``` + +Both adapters raise a clear **`ArgumentError`** when the action class declares no **`params_schema`** (and therefore has no contract to validate against). + +> **Validation errors vs. error bodies.** These helpers are the right tool for asserting *validation* behavior. There is no formatter-agnostic helper for **non-validation** error bodies (e.g. a `NotFound`/`Conflict` you return with a custom `errors:` payload) — each formatter stores those differently and the result hash carries no formatter identity. Assert those with a format-specific `assert_action_json` / `have_action_json`. + --- ## Conventions diff --git a/lib/action_figure/testing/minitest.rb b/lib/action_figure/testing/minitest.rb index 4050af1..2ee878e 100644 --- a/lib/action_figure/testing/minitest.rb +++ b/lib/action_figure/testing/minitest.rb @@ -1,58 +1,109 @@ # frozen_string_literal: true +require_relative "statuses" + module ActionFigure module Testing # Minitest assertions for ActionFigure response results. # - # Include in your test class to get assert_Ok, assert_Created, etc.: + # Include in your test class to get assert_Ok, assert_Created, etc. + # (and the negated refute_Ok, refute_Created, ...): # # class Users::CreateTest < Minitest::Test # include ActionFigure::Testing::Minitest # end + # + # The status helpers are generated from ActionFigure::Testing::STATUSES so the + # Minitest and RSpec adapters never drift. module Minitest - def assert_Ok(result, msg = nil) - assert_status(:ok, result, msg) - end + STATUSES.each do |name, status| + define_method(:"assert_#{name}") do |result, msg = nil| + assert_status(status, result, msg) + end - def assert_Created(result, msg = nil) - assert_status(:created, result, msg) + define_method(:"refute_#{name}") do |result, msg = nil| + refute_status(status, result, msg) + end end - def assert_Accepted(result, msg = nil) - assert_status(:accepted, result, msg) + def assert_valid_params(action_class, params, msg = nil) + result = action_contract(action_class).call(params) + message = msg || "Expected params to be valid, but got errors: #{result.errors.to_h.inspect}" + assert result.success?, message end - def assert_NoContent(result, msg = nil) - assert_status(:no_content, result, msg) - end + def assert_invalid_params(action_class, params, on: nil, msg: nil) + result = action_contract(action_class).call(params) - def assert_UnprocessableContent(result, msg = nil) - assert_status(:unprocessable_content, result, msg) + if on.nil? + message = msg || "Expected params to be invalid, but the contract accepted them" + assert result.failure?, message + else + errored = result.errors.to_h.key?(on) + message = msg || "Expected params to be invalid on #{on.inspect}, " \ + "but errors were: #{result.errors.to_h.inspect}" + assert errored, message + end end - def assert_NotFound(result, msg = nil) - assert_status(:not_found, result, msg) + # Asserts +result[:json]+ contains the given fragment as a (possibly nested) subset. + # Mirrors the RSpec +have_action_json+ matcher: nested Hashes match as subsets and + # Regexp values match against strings. + # + # assert_action_json(result, status: "success", data: { name: "Jane" }) + def assert_action_json(result, fragment, msg = nil) + flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash) + unless result.key?(:json) + flunk(msg || "Expected #{result.inspect} to include key :json (ActionFigure render hash)") + end + + message = msg || "Expected result[:json] to include #{fragment.inspect}, got #{result[:json].inspect}" + assert json_subset?(result[:json], fragment), message end - def assert_Forbidden(result, msg = nil) - assert_status(:forbidden, result, msg) + def refute_action_json(result, fragment, msg = nil) + json = result.is_a?(Hash) ? result[:json] : nil + message = msg || "Expected result[:json] not to include #{fragment.inspect}" + refute(json.is_a?(Hash) && json_subset?(json, fragment), message) end - def assert_Conflict(result, msg = nil) - assert_status(:conflict, result, msg) + private + + def json_subset?(actual, expected) + return false unless actual.is_a?(Hash) + + expected.all? do |key, value| + actual.key?(key) && json_value_match?(actual[key], value) + end end - def assert_PaymentRequired(result, msg = nil) - assert_status(:payment_required, result, msg) + def json_value_match?(actual, expected) + case expected + when Hash then json_subset?(actual, expected) + when Regexp then expected.match?(actual.to_s) + else actual == expected + end end - private + def action_contract(action_class) + ActionFigure::Testing.contract_for(action_class) + end def assert_status(expected, result, msg) + flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash) + actual = result[:status] message = msg || "Expected result status to be #{expected.inspect}, but got #{actual.inspect}" assert actual == expected, message end + + def refute_status(expected, result, msg) + flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash) + + actual = result[:status] + message = msg || "Expected result status not to be #{expected.inspect}" + refute actual == expected, message + end end end end diff --git a/lib/action_figure/testing/rspec.rb b/lib/action_figure/testing/rspec.rb index ab0c306..1d460ba 100644 --- a/lib/action_figure/testing/rspec.rb +++ b/lib/action_figure/testing/rspec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "rspec/matchers" +require_relative "statuses" module ActionFigure module Testing @@ -17,22 +18,14 @@ module Testing # end # end module RSpec - MATCHERS = { - Ok: :ok, - Created: :created, - Accepted: :accepted, - NoContent: :no_content, - UnprocessableContent: :unprocessable_content, - NotFound: :not_found, - Forbidden: :forbidden, - Conflict: :conflict, - PaymentRequired: :payment_required - }.freeze - - MATCHERS.each do |name, status| + # Generated from ActionFigure::Testing::STATUSES so the RSpec and Minitest + # adapters never drift. + STATUSES.each do |name, status| ::RSpec::Matchers.define :"be_#{name}" do - match { |result| result[:status] == status } + match { |result| result.is_a?(Hash) && result[:status] == status } failure_message do |result| + next "expected an ActionFigure result hash, got #{result.inspect}" unless result.is_a?(Hash) + "expected result status to be #{status.inspect}, but got #{result[:status].inspect}" end failure_message_when_negated do @@ -67,6 +60,52 @@ module RSpec "#{result.inspect} was expected not to match #{@inner_matcher.description}" end end + + # Asserts an action class's params_schema/rules accept the given params. + # Subject is the action class, not a result hash: + # + # expect(Users::Create).to accept_params(email: "jane@example.com") + ::RSpec::Matchers.define :accept_params do |params| + match { |action_class| ActionFigure::Testing.contract_for(action_class).call(params).success? } + + failure_message do |action_class| + errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h + "expected #{action_class} to accept params, but got errors: #{errors.inspect}" + end + + failure_message_when_negated do |action_class| + "expected #{action_class} not to accept params #{params.inspect}, but it did" + end + end + + # Asserts an action class's params_schema/rules reject the given params. + # Chain +with_error_on+ to require the failure to land on a specific field: + # + # expect(Users::Create).to reject_params(name: "Jane").with_error_on(:email) + ::RSpec::Matchers.define :reject_params do |params| + chain(:with_error_on) { |field| @field = field } + + match do |action_class| + errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h + next false if errors.empty? + + @field.nil? || errors.key?(@field) + end + + failure_message do |action_class| + errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h + if @field + "expected #{action_class} to reject params with an error on #{@field.inspect}, " \ + "but errors were: #{errors.inspect}" + else + "expected #{action_class} to reject params #{params.inspect}, but it accepted them" + end + end + + failure_message_when_negated do |action_class| + "expected #{action_class} not to reject params #{params.inspect}" + end + end end end end diff --git a/lib/action_figure/testing/statuses.rb b/lib/action_figure/testing/statuses.rb new file mode 100644 index 0000000..c6a984e --- /dev/null +++ b/lib/action_figure/testing/statuses.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "action_figure" + +module ActionFigure + # Helpers shared by the Minitest and RSpec testing adapters. + module Testing + # Converts a helper name (+:UnprocessableContent+) to its Rack-style status + # symbol (+:unprocessable_content+). + def self.status_symbol(name) + name.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym + end + + # Resolves an action class's validation contract, raising a clear error when + # the class declares no +params_schema+ (and therefore has no contract). + def self.contract_for(action_class) + contract = action_class.contract + return contract if contract + + raise ArgumentError, + "#{action_class} defines no params_schema, so it has no contract to validate against" + end + + # Single source of truth for the status-helper names exposed by the testing + # adapters, mapping each helper name to the status symbol Rails uses in + # +render+. Both the Minitest assertions (+assert_Ok+, +refute_Ok+, ...) and + # the RSpec matchers (+be_Ok+, ...) are generated from this map so the two + # adapters never drift. + # + # +NoContent+ lives on +Formatter+ rather than +Formatter::REQUIRED_METHODS+, + # so it is added here explicitly. + STATUSES = ActionFigure::Formatter::REQUIRED_METHODS + .to_h { |name| [name, status_symbol(name)] } + .merge(NoContent: :no_content) + .freeze + end +end diff --git a/sig/action_figure.rbs b/sig/action_figure.rbs index acc69fe..6b09529 100644 --- a/sig/action_figure.rbs +++ b/sig/action_figure.rbs @@ -158,7 +158,18 @@ module ActionFigure end module Testing - # Minitest assertions for action response results + # Single source of truth mapping status-helper names to status symbols. + STATUSES: Hash[Symbol, Symbol] + + def self.status_symbol: (Symbol | String name) -> Symbol + + # Resolves an action class's validation contract (RSpec adapter helper). + def self.contract_for: (untyped action_class) -> untyped + + # Minitest assertions for action response results. + # + # assert_* / refute_* status helpers are generated from STATUSES at load time; + # they are declared explicitly here so the signatures stay visible. module Minitest def assert_Ok: (ActionFigure::response result, ?String? msg) -> void def assert_Created: (ActionFigure::response result, ?String? msg) -> void @@ -169,11 +180,28 @@ module ActionFigure def assert_Forbidden: (ActionFigure::response result, ?String? msg) -> void def assert_Conflict: (ActionFigure::response result, ?String? msg) -> void def assert_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void + + def refute_Ok: (ActionFigure::response result, ?String? msg) -> void + def refute_Created: (ActionFigure::response result, ?String? msg) -> void + def refute_Accepted: (ActionFigure::response result, ?String? msg) -> void + def refute_NoContent: (ActionFigure::response result, ?String? msg) -> void + def refute_UnprocessableContent: (ActionFigure::response result, ?String? msg) -> void + def refute_NotFound: (ActionFigure::response result, ?String? msg) -> void + def refute_Forbidden: (ActionFigure::response result, ?String? msg) -> void + def refute_Conflict: (ActionFigure::response result, ?String? msg) -> void + def refute_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void + + def assert_valid_params: (untyped action_class, untyped params, ?String? msg) -> void + def assert_invalid_params: (untyped action_class, untyped params, ?on: Symbol?, ?msg: String?) -> void + + def assert_action_json: (ActionFigure::response result, Hash[Symbol, untyped] fragment, ?String? msg) -> void + def refute_action_json: (ActionFigure::response result, Hash[Symbol, untyped] fragment, ?String? msg) -> void end - # RSpec custom matchers (be_Ok, be_Created, etc.) + # RSpec custom matchers: status matchers (be_Ok, ...), have_action_json, + # and the contract matchers accept_params / reject_params are registered + # globally via RSpec::Matchers.define and so are not module methods. module RSpec - MATCHERS: Hash[Symbol, Symbol] end end end diff --git a/test/action_figure/testing/minitest_contract_test.rb b/test/action_figure/testing/minitest_contract_test.rb new file mode 100644 index 0000000..5b8d587 --- /dev/null +++ b/test/action_figure/testing/minitest_contract_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "test_helper" +require "action_figure/testing/minitest" + +class MinitestContractHelpersTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def build_action(&schema) + Class.new do + include ActionFigure[:jsend] + + params_schema(&schema) + + def call(params:) = Ok(resource: params) + end + end + + def test_assert_valid_params_passes_when_contract_succeeds + action = build_action do + required(:email).filled(:string) + end + + assert_valid_params(action, { email: "jane@example.com" }) + end + + def test_assert_invalid_params_passes_when_contract_fails + action = build_action do + required(:email).filled(:string) + end + + assert_invalid_params(action, { email: "" }) + end + + def test_assert_invalid_params_scoped_to_a_field_passes_when_that_field_errors + action = build_action do + required(:email).filled(:string) + required(:name).filled(:string) + end + + assert_invalid_params(action, { name: "Jane" }, on: :email) + end + + def test_assert_invalid_params_scoped_to_a_field_fails_when_a_different_field_errors + action = build_action do + required(:email).filled(:string) + required(:name).filled(:string) + end + + # name is missing (so :name errors), but we assert the error is on :email + error = assert_raises(Minitest::Assertion) do + assert_invalid_params(action, { email: "jane@example.com" }, on: :email) + end + + assert_includes error.message, ":email" + end + + def test_assert_invalid_params_fails_when_params_are_valid + action = build_action do + required(:email).filled(:string) + end + + error = assert_raises(Minitest::Assertion) do + assert_invalid_params(action, { email: "jane@example.com" }) + end + + assert_includes error.message, "invalid" + end + + def test_contract_helpers_raise_for_actions_without_a_schema + action = Class.new do + include ActionFigure[:jsend] + + def call = Ok(resource: {}) + end + + error = assert_raises(ArgumentError) { assert_valid_params(action, {}) } + assert_includes error.message, "params_schema" + end +end diff --git a/test/action_figure/testing/minitest_test.rb b/test/action_figure/testing/minitest_test.rb index 926c2e1..1dc1a6c 100644 --- a/test/action_figure/testing/minitest_test.rb +++ b/test/action_figure/testing/minitest_test.rb @@ -115,3 +115,100 @@ def call = Ok(resource: {}) assert_includes error.message, ":ok" end end + +class MinitestStatusRegistryTest < Minitest::Test + include ActionFigure::Testing::Minitest + + # Locks the full generated set: every STATUSES entry gets a working assert_* + # and refute_*, including Conflict / PaymentRequired / NoContent. + ActionFigure::Testing::STATUSES.each do |name, status| + define_method(:"test_assert_and_refute_#{name}") do + assert_send([self, :"assert_#{name}", { status: status }]) + assert_send([self, :"refute_#{name}", { status: :some_other_status }]) + end + end +end + +class MinitestStatusGuardAndNegationTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def test_assert_status_fails_clearly_for_non_hash + error = assert_raises(Minitest::Assertion) { assert_Ok("nope") } + assert_includes error.message, "result hash" + end + + def test_refute_Ok_passes_when_status_differs + action = Class.new do + include ActionFigure[:jsend] + + def call = Created(resource: {}) + end + + refute_Ok(action.call) + end + + def test_refute_Ok_fails_when_status_matches + action = Class.new do + include ActionFigure[:jsend] + + def call = Ok(resource: {}) + end + + error = assert_raises(Minitest::Assertion) { refute_Ok(action.call) } + assert_includes error.message, ":ok" + end +end + +class MinitestJsonAssertionTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def build_action(&block) + Class.new do + include ActionFigure[:jsend] + + define_method(:call, &block) + end + end + + def test_assert_action_json_passes_on_matching_subset + result = build_action { Ok(resource: { name: "Tad", id: 1 }) }.call + + assert_action_json(result, status: "success") + assert_action_json(result, data: { name: "Tad", id: 1 }) + end + + def test_assert_action_json_matches_nested_subset + result = build_action { Ok(resource: { name: "Tad", id: 1 }) }.call + + assert_action_json(result, status: "success", data: { name: "Tad" }) + end + + def test_assert_action_json_supports_regexp_values + result = build_action { Ok(resource: { email: "jane@example.com" }) }.call + + assert_action_json(result, data: { email: /@example\.com\z/ }) + end + + def test_assert_action_json_fails_when_shape_differs + result = build_action { Ok(resource: {}) }.call + + error = assert_raises(Minitest::Assertion) { assert_action_json(result, status: "fail") } + assert_includes error.message, "fail" + end + + def test_assert_action_json_fails_clearly_for_non_result + error = assert_raises(Minitest::Assertion) { assert_action_json("nope", status: "success") } + assert_includes error.message, "result hash" + end + + def test_assert_action_json_fails_clearly_when_json_key_missing + error = assert_raises(Minitest::Assertion) { assert_action_json({ status: :no_content }, foo: 1) } + assert_includes error.message, ":json" + end + + def test_refute_action_json_passes_when_subset_does_not_match + result = build_action { Ok(resource: { name: "Tad" }) }.call + + refute_action_json(result, status: "fail") + end +end diff --git a/test/action_figure/testing/rspec_test.rb b/test/action_figure/testing/rspec_test.rb index 12b4fcb..e0a1212 100644 --- a/test/action_figure/testing/rspec_test.rb +++ b/test/action_figure/testing/rspec_test.rb @@ -87,4 +87,67 @@ def build_action(&block) expect(action.call).to have_action_json(status: "fail") end.to raise_error(RSpec::Expectations::ExpectationNotMetError) end + + def build_validated_action(&schema) + Class.new do + include ActionFigure[:jsend] + + params_schema(&schema) + + def call(params:) = Ok(resource: params) + end + end + + it "accept_params passes when the contract accepts the params" do + action = build_validated_action { required(:email).filled(:string) } + + expect(action).to accept_params(email: "jane@example.com") + end + + it "accept_params can be negated for invalid params" do + action = build_validated_action { required(:email).filled(:string) } + + expect(action).not_to accept_params(email: "") + end + + it "reject_params passes when the contract rejects the params" do + action = build_validated_action { required(:email).filled(:string) } + + expect(action).to reject_params(email: "") + end + + it "reject_params scoped with_error_on passes when that field errors" do + action = build_validated_action do + required(:email).filled(:string) + required(:name).filled(:string) + end + + expect(action).to reject_params(name: "Jane").with_error_on(:email) + end + + it "reject_params scoped with_error_on fails when a different field errors" do + action = build_validated_action do + required(:email).filled(:string) + required(:name).filled(:string) + end + + # email is missing (so :email errors), but we assert the error is on :name + expect do + expect(action).to reject_params(name: "Jane").with_error_on(:name) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /:name/) + end + + it "be_* fails clearly when given a non-result value" do + expect do + expect("nope").to be_Ok + end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /result hash/) + end + + it "contract matchers fail clearly for actions without a schema" do + action = build_action { Ok(resource: {}) } + + expect do + expect(action).to accept_params({}) + end.to raise_error(ArgumentError, /params_schema/) + end end