Module: Clickwrap::Testing

Defined in:
lib/clickwrap/testing.rb

Overview

Fault injection, for this gem's suite and for yours.

=========================================================================== The central promise of this gem is that required evidence and the protected database action commit together or not at all. A promise like that is worth exactly as much as your ability to prove it in a test, and you cannot prove it by reading the code — you prove it by making the evidence write fail on purpose and watching the account, the payout, or the withdrawal fail with it.

Clickwrap::Testing.fail_next_event_write do
assert_raises(Clickwrap::EventWriteFailed) {  }
end

assert_not User.exists?(email: "person@example.com")
assert_no_clickwrap_event :signup

The failure is injected INSIDE the Clickwrap::Event create, which means inside the capture's transaction, which means the host's protected action rolls back with it. Raising before the transaction opened would prove nothing at all: of course the domain action does not happen if the capture never started.

Everything here installs on entry and removes on exit, in an ensure, so a failing assertion inside the block cannot leave a sabotage hook attached to Clickwrap::Event for the rest of the suite. Nothing here is left resident in a production process: the callbacks exist only while a block is running, and this file is only loaded if something references Clickwrap::Testing.

Defined Under Namespace

Modules: FrozenClock Classes: DomainWriteFailed

Constant Summary collapse

EVENT_WRITE_KEY =
:clickwrap_testing_fail_next_event_write
DOMAIN_WRITE_KEY =
:clickwrap_testing_fail_next_domain_write
FROZEN_TIME_KEY =
:clickwrap_testing_frozen_time
FAIL_EVENT_WRITE =

The injected callbacks, held as constants so skip_callback can find the same object it was given. An anonymous lambda created per call would install fine and never come off.

lambda do |_event|
  next unless Testing.consume_flag!(EVENT_WRITE_KEY)

  raise EventWriteFailed,
        "Clickwrap::Testing.fail_next_event_write made this evidence write fail on purpose. " \
        "Whatever your protected action did in this transaction must roll back with it — " \
        "that is the property this helper exists to let you assert."
end
FAIL_DOMAIN_WRITE =
lambda do |record|
  next if record.is_a?(Clickwrap::ApplicationRecord)
  next unless Testing.consume_flag!(DOMAIN_WRITE_KEY)

  raise DomainWriteFailed,
        "Clickwrap::Testing.fail_next_domain_write made this domain write fail on purpose. " \
        "The Clickwrap evidence in the same transaction must roll back with it, so that a " \
        "failed action never leaves behind a receipt saying it succeeded."
end

Class Method Summary collapse

Class Method Details

.consume_flag!(key) ⇒ Object

Reads a one-shot flag and clears it in the same breath, so the sabotage applies to exactly one write.



154
155
156
157
158
159
# File 'lib/clickwrap/testing.rb', line 154

def consume_flag!(key)
  return false unless Thread.current[key]

  Thread.current[key] = nil
  true
end

.fail_next_domain_writeObject

The mirror image: makes the next non-Clickwrap ActiveRecord save raise, so you can prove the other direction — that a domain action blowing up takes its evidence down with it, and never leaves a receipt describing something that did not happen.



98
99
100
101
102
103
104
105
106
# File 'lib/clickwrap/testing.rb', line 98

def fail_next_domain_write
  install_domain_callback!
  Thread.current[DOMAIN_WRITE_KEY] = true

  yield
ensure
  Thread.current[DOMAIN_WRITE_KEY] = nil
  remove_domain_callback!
end

.fail_next_event_writeObject

Makes the NEXT Clickwrap::Event insert raise Clickwrap::EventWriteFailed, from inside the create, and therefore from inside whatever transaction the capture is running in.

Only the next one: a capture that legitimately retries, or a test that goes on to record a control event afterwards, is not sabotaged twice.



84
85
86
87
88
89
90
91
92
# File 'lib/clickwrap/testing.rb', line 84

def fail_next_event_write
  install_event_callback!
  Thread.current[EVENT_WRITE_KEY] = true

  yield
ensure
  Thread.current[EVENT_WRITE_KEY] = nil
  remove_event_callback!
end

.freeze_time_at(moment) ⇒ Object

Freezes the server clock Clickwrap records and evaluates expiry against, so a test about a declaration that expired last Tuesday does not have to sleep until next Tuesday.

It moves Clickwrap.now only. Time.now is left alone on purpose: this gem's evidentiary time is the value it writes into recorded_at_by_server, and a helper that quietly moved the whole process clock would make it much harder to tell which of the two a test actually depends on.



117
118
119
120
121
122
123
124
125
126
127
# File 'lib/clickwrap/testing.rb', line 117

def freeze_time_at(moment)
  moment = Time.parse(moment.to_s) if moment.is_a?(String)
  previous = Thread.current[FROZEN_TIME_KEY]

  install_clock!
  Thread.current[FROZEN_TIME_KEY] = moment.utc

  block_given? ? yield(moment.utc) : moment.utc
ensure
  Thread.current[FROZEN_TIME_KEY] = previous
end

.frozen_timeObject



129
# File 'lib/clickwrap/testing.rb', line 129

def frozen_time = Thread.current[FROZEN_TIME_KEY]

.reset!Object

Clears every flag and detaches every injected callback. Safe to call when nothing was ever installed, which is the point: a suite calls it in setup and in teardown without having to know whether the test that just ran used any of this.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/clickwrap/testing.rb', line 135

def reset!
  Thread.current[EVENT_WRITE_KEY] = nil
  Thread.current[DOMAIN_WRITE_KEY] = nil
  Thread.current[FROZEN_TIME_KEY] = nil

  # Unconditional, and `raise: false` throughout: `reset!` is called from
  # `setup` and `teardown` in suites that mostly never touch fault
  # injection, and it must be a quiet no-op there rather than an
  # ArgumentError about a callback nobody installed.
  @event_depth = 0
  @domain_depth = 0
  Event.skip_callback(:create, :before, FAIL_EVENT_WRITE, raise: false)
  ::ActiveRecord::Base.skip_callback(:save, :before, FAIL_DOMAIN_WRITE, raise: false)

  self
end