Skip to content

Add accept_params / reject_params matchers - #35

Open
VSN2015 wants to merge 1 commit into
masterfrom
feature/behavioural-matchers
Open

Add accept_params / reject_params matchers#35
VSN2015 wants to merge 1 commit into
masterfrom
feature/behavioural-matchers

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

The last item from the original brainstormed backlog.

The gap

permit_param reads the declaration. That leaves the behaviour untested — whether a payload is accepted, and what it casts to. A contract can be fully specified by permit_param and still be wrong about the thing it exists to do.

The matchers

expect(described_class).to accept_params(user: { name: "Jo", email: "a@b.co", age: "30" })
  .for_action(:create)
  .returning("name" => "Jo", "email" => "a@b.co", "age" => 30, "plan" => "free")

expect(described_class).to reject_params(user: { name: "Jo", email: "nope" })
  .for_action(:create).with_violation("user.email", :format)

Still no request dispatched — the rule runs against the payload directly.

returning pins the cast, defaulted, transformed output, which the declaration matcher cannot reach. with_violation is repeatable and its code is optional.

Failure messages, which are the whole point

expected UsersController to accept those params, but it rejected them: user.email (missing)
expected UsersController to reject those params, but it accepted them, returning {"name"=>"Jo", "email"=>"a@b.co"}
expected UsersController to reject those params with user.age (inclusion), but the violations were: user.name (missing), user.email (missing)

A hand-rolled expect { ... }.to raise_error(Permittable::InvalidParameters) tells you nothing about why; that third message is the reason to have a matcher at all.

Two decisions worth reviewing

They read the contract, not the rollout mode. The rule is copied into a throwaway host with mode: :enforce, so a monitor-mode rule still reject_params. The question a spec asks is what the contract says, not what the deploy currently does with it — and a spec that silently stopped asserting because someone flipped a rollout switch would be worse than useless. There's a spec pinning this.

Subject and rule resolution are shared with permit_param, so for_action behaves identically and ambiguity fails just as loudly (declares 2 contracts — disambiguate with accept_params(...).for_action(:action)).

Verification

  • 211 examples, 0 failures (12 new, written before the implementation), covering both matchers, returning matches and mismatches, all three failure messages, monitor-mode behaviour, the single-contract and ambiguous-contract paths, and a standalone Contract subject
  • RuboCop clean

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #35: Behavioral matchers accept_params and reject_params significantly improve contract testability by asserting actual runtime parameter parsing and casting behavior rather than mere DSL syntax.

Comment thread lib/permittable/rspec.rb

attr_accessor :params
end
host.permittable_contracts = [rule.merge(mode: :enforce).freeze]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forcing mode: :enforce in the test harness (rule.merge(mode: :enforce)) ensures matchers test what the contract declares, enabling contracts under rollout in monitor mode to be verified against their intended rejection rules.

Comment thread lib/permittable/rspec.rb
def accepted_ok?
return false unless @violations.empty?

@returning.nil? || @result.to_h == ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparing @result.to_h == ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h in accepted_ok? makes assertions on .returning(...) indifferent to symbol vs string keys, matching developer expectations.

permit_param reads the declaration, which leaves the behaviour
untested: whether a payload is accepted, and what it casts to. A
contract can be fully specified by permit_param and still be wrong
about the thing it exists to do.

accept_params / reject_params run the rule against a payload directly
— still no request dispatched — and assert the outcome:

  expect(described_class).to accept_params(user: { name: "Jo", age: "30" })
    .for_action(:create).returning("name" => "Jo", "age" => 30, "plan" => "free")

  expect(described_class).to reject_params(user: { email: "nope" })
    .for_action(:create).with_violation("user.email", :format)

returning pins the cast, defaulted, transformed output, which the
declaration matcher cannot reach. with_violation is repeatable and its
code is optional.

Two decisions worth knowing:

* They read the CONTRACT, not the rollout mode. The rule is copied into
  a throwaway host with mode: :enforce, so a monitor-mode rule still
  reject_params — the question a spec asks is what the contract says,
  not what the deploy currently does with it.
* Subject and rule resolution are shared with permit_param, so
  for_action behaves identically and ambiguity fails just as loudly.

Failure messages name what actually happened, since that is the whole
value of a matcher over a hand-rolled call:

  expected UsersController to reject those params with user.age
  (inclusion), but the violations were: user.name (missing),
  user.email (missing)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the feature/behavioural-matchers branch from a5fb7b3 to c70a594 Compare September 11, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant