constable-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,834 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # `attest(actual).to matcher` -- fluent sugar layered over the plain assertion
5
+ # primitives, which are always available too.
6
+ #
7
+ # A matcher is a *value*, not a method call. Inside a case, `be_created` and
8
+ # `exist(email: "a@b.com")` are bare method calls with no receiver: they fall through
9
+ # Expectations#method_missing, which builds a Deferred capturing nothing but the
10
+ # matcher's name, arguments and block. Nothing runs until `.to` / `.not_to` hands the
11
+ # Deferred the actual value. That deferral is the whole point -- the same object has to
12
+ # be able to phrase both "expected X to eq 1" and "expected X not to eq 1", and it can
13
+ # only do that if it still knows what it was asked to check when the failure happens.
14
+ #
15
+ # Failure messages always name both sides. Where the actual carries something a human
16
+ # would want to see -- a response body, a record's attributes, validation errors -- it
17
+ # is attached as `context:` on the raised AssertionFailed, because the reporter prints
18
+ # context underneath the failure message.
19
+ module Matchers
20
+ # A registered matcher. `deferred_class` lets a matcher whose grammar is richer than
21
+ # "name plus arguments" (only `change`, so far) supply its own Deferred subclass.
22
+ Matcher = Struct.new(:name, :block, :deferred_class)
23
+
24
+ # Enough of the status table to be useful without dragging in Rack just to translate
25
+ # :created into 201. Rails apps get the same answer either way.
26
+ HTTP_STATUS_CODES = {
27
+ continue: 100, switching_protocols: 101,
28
+ ok: 200, created: 201, accepted: 202, non_authoritative_information: 203,
29
+ no_content: 204, reset_content: 205, partial_content: 206,
30
+ multiple_choices: 300, moved_permanently: 301, found: 302, see_other: 303,
31
+ not_modified: 304, temporary_redirect: 307, permanent_redirect: 308,
32
+ bad_request: 400, unauthorized: 401, payment_required: 402, forbidden: 403,
33
+ not_found: 404, method_not_allowed: 405, not_acceptable: 406,
34
+ request_timeout: 408, conflict: 409, gone: 410, precondition_failed: 412,
35
+ payload_too_large: 413, unsupported_media_type: 415, im_a_teapot: 418,
36
+ unprocessable_entity: 422, locked: 423, too_many_requests: 429,
37
+ internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
38
+ service_unavailable: 503, gateway_timeout: 504
39
+ }.freeze
40
+
41
+ HTTP_STATUS_GROUPS = {
42
+ informational: (100..199), success: (200..299), successful: (200..299),
43
+ redirect: (300..399), missing: (404..404), client_error: (400..499),
44
+ error: (500..599), server_error: (500..599)
45
+ }.freeze
46
+
47
+ MAX_INSPECT = 200
48
+ MAX_BODY = 800
49
+
50
+ class << self
51
+ # Constable::Matchers.define(:be_created) { |response| response.status == 201 }
52
+ #
53
+ # A truthy return passes. A block may instead return [bool, message, context] when
54
+ # it can say something more useful about the failure than the generic phrasing.
55
+ def define(name, deferred_class: Deferred, &block)
56
+ raise ArgumentError, "Constable::Matchers.define(:#{name}) requires a block" unless block
57
+
58
+ custom[name.to_sym] = Matcher.new(name.to_sym, block, deferred_class)
59
+ name.to_sym
60
+ end
61
+
62
+ def matcher_for(name)
63
+ custom[name.to_sym] || builtins[name.to_sym]
64
+ end
65
+
66
+ def registered?(name)
67
+ !matcher_for(name).nil?
68
+ end
69
+
70
+ # Drops every user-defined matcher and restores the built-ins any override shadowed.
71
+ # Built-ins are kept in their own table precisely so this can be a clean reset rather
72
+ # than a wipe that leaves `eq` undefined for the rest of the process.
73
+ def clear!
74
+ @custom = {}
75
+ self
76
+ end
77
+
78
+ def names
79
+ (builtins.keys + custom.keys).uniq.sort
80
+ end
81
+
82
+ def custom = (@custom ||= {})
83
+ def builtins = (@builtins ||= {})
84
+
85
+ # Names a bare call inside a case is allowed to resolve as a matcher: anything
86
+ # registered, plus the be_*/have_* predicate fallbacks. Deliberately narrow -- a
87
+ # typo'd method should still raise NoMethodError, not silently become a matcher.
88
+ def matcher_name?(name)
89
+ return true if registered?(name)
90
+
91
+ name.to_s.match?(/\A(?:be|have)_[a-z_][a-z0-9_]*\z/)
92
+ end
93
+
94
+ def deferred_for(name, args = [], block = nil)
95
+ matcher = matcher_for(name)
96
+ return matcher.deferred_class.new(name, args, block, matcher: matcher) if matcher
97
+
98
+ predicate = predicate_for(name)
99
+ unless predicate
100
+ raise Constable::Error,
101
+ "no matcher named :#{name} is registered -- define one with " \
102
+ "Constable::Matchers.define(:#{name}) { |actual, *args| ... }"
103
+ end
104
+
105
+ PredicateDeferred.new(name, args, block, predicate: predicate)
106
+ end
107
+
108
+ # be_created -> #created?, have_timed_out -> #has_timed_out?
109
+ def predicate_for(name)
110
+ case name.to_s
111
+ when /\Abe_(.+)\z/ then "#{Regexp.last_match(1)}?"
112
+ when /\Ahave_(.+)\z/ then "has_#{Regexp.last_match(1)}?"
113
+ end
114
+ end
115
+
116
+ def fail!(message, context = nil)
117
+ raise Constable::AssertionFailed.new(message, context: context)
118
+ end
119
+
120
+ def truthy?(value)
121
+ !value.nil? && value != false
122
+ end
123
+
124
+ # A matcher block's return value, in either of the two supported shapes. The rich
125
+ # form is recognised narrowly -- [bool, String-or-nil, anything] -- so a matcher that
126
+ # legitimately returns an array of results isn't misread as a failure tuple.
127
+ def normalize_result(result)
128
+ if result.is_a?(Array) && (2..3).cover?(result.size) &&
129
+ [true, false].include?(result[0]) &&
130
+ (result[1].nil? || result[1].is_a?(String))
131
+ [result[0], result[1], result[2]]
132
+ else
133
+ [truthy?(result), nil, nil]
134
+ end
135
+ end
136
+
137
+ # How the actual is named in a message. Responses and classes get their own name;
138
+ # everything else gets a bounded #inspect so a fat object can't drown the summary.
139
+ def describe(actual)
140
+ return "the block" if actual.is_a?(Proc)
141
+ return "response" if response_like?(actual)
142
+ return actual.name if actual.is_a?(Module) && actual.name
143
+
144
+ truncate(actual.inspect, MAX_INSPECT)
145
+ rescue StandardError
146
+ "a #{actual.class}"
147
+ end
148
+
149
+ # Reads the way the call site was written: `eq 1`, `exist email: "a@b.com"`.
150
+ def format_args(args)
151
+ args.empty? ? "" : " #{format_list(args)}"
152
+ end
153
+
154
+ def format_list(args)
155
+ args.map { |a| a.is_a?(Hash) ? format_hash(a) : a.inspect }.join(", ")
156
+ end
157
+
158
+ def format_hash(hash)
159
+ hash.map { |k, v| k.is_a?(Symbol) ? "#{k}: #{v.inspect}" : "#{k.inspect} => #{v.inspect}" }.join(", ")
160
+ end
161
+
162
+ def truncate(string, limit)
163
+ str = string.to_s
164
+ str.length > limit ? "#{str[0, limit]}…" : str
165
+ end
166
+
167
+ # Whatever the failure is about, this is what someone staring at the summary
168
+ # actually wants to see. Every probe is defensive: an object that raises from
169
+ # #body simply contributes nothing.
170
+ def context_for(actual)
171
+ context = {}
172
+ add_body_context(context, actual)
173
+ add_attributes_context(context, actual)
174
+ add_errors_context(context, actual)
175
+ context.empty? ? nil : context
176
+ end
177
+
178
+ def merge_context(*contexts)
179
+ merged = contexts.compact.reduce({}) do |acc, ctx|
180
+ ctx.is_a?(Hash) ? acc.merge(ctx) : acc.merge("Detail" => ctx.to_s)
181
+ end
182
+ merged.empty? ? nil : merged
183
+ end
184
+
185
+ def response_like?(actual)
186
+ actual.respond_to?(:status) && actual.respond_to?(:body) && !actual.is_a?(Module)
187
+ rescue StandardError
188
+ false
189
+ end
190
+
191
+ # 200 from anything that plausibly models an HTTP response, or nil if it isn't one.
192
+ def response_status(actual)
193
+ return actual if actual.is_a?(Integer)
194
+ return actual.status.to_i if actual.respond_to?(:status) && actual.status.respond_to?(:to_i)
195
+ return actual.code.to_i if actual.respond_to?(:code) && actual.code.to_s.match?(/\A\d+\z/)
196
+
197
+ nil
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ def status_name(code)
203
+ HTTP_STATUS_CODES.key(code) || code
204
+ end
205
+
206
+ def status_label(code)
207
+ return "no status" if code.nil?
208
+
209
+ name = HTTP_STATUS_CODES.key(code)
210
+ name ? ":#{name} (#{code})" : code.to_s
211
+ end
212
+
213
+ def expected_status_label(expected)
214
+ group = HTTP_STATUS_GROUPS[expected.to_s.to_sym] if expected.is_a?(Symbol) || expected.is_a?(String)
215
+ return ":#{expected} (#{group.first}-#{group.last})" if group && !HTTP_STATUS_CODES.key?(expected.to_s.to_sym)
216
+
217
+ code = status_code_for(expected)
218
+ code ? status_label(code) : expected.inspect
219
+ end
220
+
221
+ def status_code_for(expected)
222
+ case expected
223
+ when Integer then expected
224
+ when /\A\d+\z/ then expected.to_i
225
+ else HTTP_STATUS_CODES[expected.to_s.to_sym]
226
+ end
227
+ end
228
+
229
+ def status_matches?(code, expected)
230
+ return false if code.nil?
231
+
232
+ key = expected.is_a?(Symbol) || expected.is_a?(String) ? expected.to_s.to_sym : nil
233
+ if key && HTTP_STATUS_GROUPS.key?(key) && !HTTP_STATUS_CODES.key?(key)
234
+ return HTTP_STATUS_GROUPS[key].cover?(code)
235
+ end
236
+
237
+ expected_code = status_code_for(expected)
238
+ !expected_code.nil? && expected_code == code
239
+ end
240
+
241
+ def response_location(actual)
242
+ return actual.location.to_s if actual.respond_to?(:location) && actual.location
243
+
244
+ headers = actual.respond_to?(:headers) ? actual.headers : nil
245
+ headers ||= actual.respond_to?(:header) ? actual.header : nil
246
+ return nil unless headers.respond_to?(:[])
247
+
248
+ (headers["Location"] || headers["location"])&.to_s
249
+ rescue StandardError
250
+ nil
251
+ end
252
+
253
+ # "http://example.test/sessions/new" and "/sessions/new" describe the same redirect.
254
+ def location_path(location)
255
+ location.to_s.sub(%r{\Ahttps?://[^/]+}, "")
256
+ end
257
+
258
+ # The text between a block's braces, used so a `change` failure can say
259
+ # `User.count` instead of "the value". Best-effort by design.
260
+ def block_source(block)
261
+ return nil unless block.is_a?(Proc)
262
+ return nil unless defined?(RubyVM::AbstractSyntaxTree)
263
+
264
+ source = RubyVM::AbstractSyntaxTree.of(block, keep_script_lines: true)&.source
265
+ return nil unless source
266
+
267
+ inner = source[/\{(.*)\}/m, 1] || source[/\bdo\b(.*)\bend\b/m, 1]
268
+ inner = inner&.strip
269
+ inner && !inner.empty? ? inner.gsub(/\s+/, " ") : nil
270
+ rescue StandardError, ScriptError
271
+ nil
272
+ end
273
+
274
+ private
275
+
276
+ def add_body_context(context, actual)
277
+ return unless actual.respond_to?(:body) && !actual.is_a?(Proc)
278
+
279
+ body = actual.body
280
+ return if body.nil?
281
+
282
+ text = body.respond_to?(:read) ? body.read : body.to_s
283
+ context["Response body"] = truncate(text, MAX_BODY) unless text.strip.empty?
284
+ rescue StandardError
285
+ nil
286
+ end
287
+
288
+ def add_attributes_context(context, actual)
289
+ return unless actual.respond_to?(:attributes)
290
+
291
+ attributes = actual.attributes
292
+ context["Attributes"] = truncate(attributes.inspect, MAX_BODY) if attributes.is_a?(Hash) && !attributes.empty?
293
+ rescue StandardError
294
+ nil
295
+ end
296
+
297
+ def add_errors_context(context, actual)
298
+ return unless actual.respond_to?(:errors)
299
+
300
+ errors = actual.errors
301
+ messages = errors.respond_to?(:full_messages) ? errors.full_messages : nil
302
+ context["Errors"] = messages.join(", ") if messages.respond_to?(:join) && !messages.empty?
303
+ rescue StandardError
304
+ nil
305
+ end
306
+ end
307
+
308
+ # A matcher that knows its name and arguments but not yet its subject.
309
+ class Deferred
310
+ attr_reader :name, :args, :block, :matcher
311
+
312
+ def initialize(name, args = [], block = nil, matcher: nil)
313
+ @name = name.to_sym
314
+ @args = Array(args)
315
+ @block = block
316
+ @matcher = matcher
317
+ end
318
+
319
+ # => [passed, message, context]
320
+ def matches?(actual)
321
+ Matchers.normalize_result(invoke(actual))
322
+ end
323
+
324
+ def invoke(actual)
325
+ @matcher.block.call(actual, *@args, &@block)
326
+ end
327
+
328
+ # "eq 1", "be a String", "exist email: \"a@b.com\"" -- the phrase both the positive
329
+ # and the negated message are built around, so negation never needs its own matcher.
330
+ def description
331
+ phrase = @name.to_s.sub(/\Abe_/, "be ").sub(/\Ahave_/, "have ").tr("_", " ")
332
+ "#{phrase}#{Matchers.format_args(@args)}"
333
+ end
334
+
335
+ def failure_message(actual)
336
+ "expected #{Matchers.describe(actual)} to #{description}"
337
+ end
338
+
339
+ def negated_failure_message(actual)
340
+ "expected #{Matchers.describe(actual)} not to #{description}"
341
+ end
342
+
343
+ def context_for(actual)
344
+ Matchers.context_for(actual)
345
+ end
346
+ end
347
+
348
+ # The be_*/have_* fallback: with no matcher registered under the name, the name itself
349
+ # is the assertion -- `be_created` asks the actual whether it is `created?`.
350
+ class PredicateDeferred < Deferred
351
+ attr_reader :predicate
352
+
353
+ def initialize(name, args = [], block = nil, predicate:)
354
+ super(name, args, block)
355
+ @predicate = predicate
356
+ end
357
+
358
+ def matches?(actual)
359
+ unless actual.respond_to?(@predicate)
360
+ return [false, "expected #{Matchers.describe(actual)} to respond to ##{@predicate} " \
361
+ "(no matcher named :#{@name} is registered), but it does not",
362
+ Matchers.context_for(actual)]
363
+ end
364
+
365
+ passed = Matchers.truthy?(actual.public_send(@predicate, *@args, &@block))
366
+ [passed, passed ? nil : failure_message(actual), Matchers.context_for(actual)]
367
+ end
368
+
369
+ def failure_message(actual)
370
+ "#{super}, but #{owner_name(actual)}##{@predicate} returned false"
371
+ end
372
+
373
+ def negated_failure_message(actual)
374
+ "#{super}, but #{owner_name(actual)}##{@predicate} returned true"
375
+ end
376
+
377
+ private
378
+
379
+ def owner_name(actual)
380
+ actual.is_a?(Module) ? actual.name.to_s : actual.class.name.to_s
381
+ end
382
+ end
383
+
384
+ # `change` is the one matcher whose grammar is more than name-plus-arguments: it needs
385
+ # a before/after sampling around the action, and .by/.from/.to chaining to say what
386
+ # kind of change it wanted.
387
+ class ChangeMatcher < Deferred
388
+ def initialize(name, args = [], block = nil, matcher: nil)
389
+ super
390
+ @by_set = @from_set = @to_set = false
391
+ end
392
+
393
+ def by(delta)
394
+ @by = delta
395
+ @by_set = true
396
+ self
397
+ end
398
+
399
+ def from(value)
400
+ @from = value
401
+ @from_set = true
402
+ self
403
+ end
404
+
405
+ def to(value)
406
+ @to = value
407
+ @to_set = true
408
+ self
409
+ end
410
+
411
+ def matches?(action)
412
+ unless action.respond_to?(:call)
413
+ raise Constable::Error,
414
+ "change needs the block form: attest { ... }.to change { #{expression} }"
415
+ end
416
+
417
+ @before = sample
418
+ action.call
419
+ @after = sample
420
+
421
+ evaluate
422
+ end
423
+
424
+ def description
425
+ parts = ["change #{expression}"]
426
+ parts << "by #{@by.inspect}" if @by_set
427
+ parts << "from #{@from.inspect}" if @from_set
428
+ parts << "to #{@to.inspect}" if @to_set
429
+ parts.join(" ")
430
+ end
431
+
432
+ def negated_failure_message(_action)
433
+ "expected the block not to #{description}, " \
434
+ "but #{expression} changed from #{@before.inspect} to #{@after.inspect}"
435
+ end
436
+
437
+ def context_for(_action) = nil
438
+
439
+ private
440
+
441
+ # The thing being watched: change { User.count } or change(user, :name).
442
+ # Duplicated on the way out: a matcher that watches a mutable object would otherwise
443
+ # compare it against itself and conclude nothing ever changes.
444
+ def sample
445
+ value = @block ? @block.call : @args[0].public_send(@args[1])
446
+ begin
447
+ value.dup
448
+ rescue StandardError
449
+ value
450
+ end
451
+ end
452
+
453
+ def expression
454
+ @expression ||= if @block
455
+ src = Matchers.block_source(@block)
456
+ src ? "`#{src}`" : "the value"
457
+ else
458
+ "#{Matchers.describe(@args[0])}##{@args[1]}"
459
+ end
460
+ end
461
+
462
+ def evaluate
463
+ return from_mismatch if @from_set && @before != @from
464
+ return by_result if @by_set
465
+ return to_result if @to_set
466
+
467
+ return [true, nil, nil] if @before != @after
468
+
469
+ [false, "expected the block to #{description}, " \
470
+ "but #{expression} stayed at #{@before.inspect}", nil]
471
+ end
472
+
473
+ def from_mismatch
474
+ [false, "expected the block to #{description}, " \
475
+ "but #{expression} started at #{@before.inspect}, not #{@from.inspect}", nil]
476
+ end
477
+
478
+ def by_result
479
+ delta = begin
480
+ @after - @before
481
+ rescue StandardError
482
+ nil
483
+ end
484
+ return [true, nil, nil] if delta == @by
485
+
486
+ [false, "expected the block to #{description}, but #{expression} changed by " \
487
+ "#{delta.inspect} (#{@before.inspect} to #{@after.inspect})", nil]
488
+ end
489
+
490
+ def to_result
491
+ return [true, nil, nil] if @after == @to && @before != @after
492
+
493
+ [false, "expected the block to #{description}, but #{expression} " \
494
+ "went from #{@before.inspect} to #{@after.inspect}", nil]
495
+ end
496
+ end
497
+
498
+ # What `attest` returns. Holds the actual (or, in block form, the action) and does the
499
+ # raising, so matchers only ever have to answer "did this pass, and what would you say
500
+ # about it if it didn't".
501
+ class Expectation
502
+ NOTHING = Object.new.freeze
503
+
504
+ attr_reader :actual, :block
505
+
506
+ def initialize(actual = NOTHING, block: nil)
507
+ @actual = actual
508
+ @block = block
509
+ end
510
+
511
+ def block_form? = !@block.nil?
512
+
513
+ # In block form the matcher's subject is the action itself -- change and raise_error
514
+ # need to run it, not look at its result.
515
+ def target = block_form? ? @block : @actual
516
+
517
+ def to(matcher)
518
+ matcher = coerce(matcher)
519
+ passed, message, context = matcher.matches?(target)
520
+ return satisfied if passed
521
+
522
+ Matchers.fail!(message || matcher.failure_message(target),
523
+ Matchers.merge_context(matcher.context_for(target), context))
524
+ end
525
+
526
+ def not_to(matcher)
527
+ matcher = coerce(matcher)
528
+ passed, _message, context = matcher.matches?(target)
529
+ return satisfied unless passed
530
+
531
+ Matchers.fail!(matcher.negated_failure_message(target),
532
+ Matchers.merge_context(matcher.context_for(target), context))
533
+ end
534
+ alias to_not not_to
535
+
536
+ private
537
+
538
+ # Handing the actual back makes `user = attest(build_user).to be_valid` read fine;
539
+ # in block form there is no actual to hand back.
540
+ def satisfied = block_form? ? self : @actual
541
+
542
+ def coerce(matcher)
543
+ return matcher if matcher.respond_to?(:matches?)
544
+
545
+ raise Constable::Error,
546
+ "attest(...).to expects a matcher, got #{matcher.inspect}. " \
547
+ "Did you mean `attest(x).to eq(#{matcher.inspect})`?"
548
+ end
549
+ end
550
+
551
+ # Mixed into Constable::Case. Everything a case needs to write `attest(response).to
552
+ # be_created` -- including the bare `be_created`, which lands here.
553
+ module Expectations
554
+ def attest(actual = Expectation::NOTHING, &block)
555
+ if block
556
+ Expectation.new(block: block)
557
+ elsif !actual.equal?(Expectation::NOTHING)
558
+ Expectation.new(actual)
559
+ else
560
+ raise Constable::Error,
561
+ "attest needs a value or a block: attest(response).to be_created, " \
562
+ "or attest { ... }.to change { User.count }"
563
+ end
564
+ end
565
+
566
+ # Explicit escape hatch for a matcher whose name collides with a real method.
567
+ def matcher(name, *args, &block)
568
+ Matchers.deferred_for(name, args, block)
569
+ end
570
+
571
+ private
572
+
573
+ def method_missing(name, *args, &block)
574
+ return super unless Matchers.matcher_name?(name)
575
+
576
+ Matchers.deferred_for(name, args, block)
577
+ end
578
+
579
+ def respond_to_missing?(name, include_private = false)
580
+ Matchers.matcher_name?(name) || super
581
+ end
582
+ end
583
+
584
+ # -- Built-ins ---------------------------------------------------------------------
585
+ #
586
+ # Registered into their own table (never `custom`), so Matchers.clear! is a reset to
587
+ # this set rather than a wipe. Each one degrades gracefully when Rails is absent: they
588
+ # duck-type responses and records instead of naming ActionDispatch or ActiveRecord.
589
+
590
+ def self.define_builtin(name, deferred_class: Deferred, &block)
591
+ builtins[name.to_sym] = Matcher.new(name.to_sym, block, deferred_class)
592
+ end
593
+ private_class_method :define_builtin
594
+
595
+ define_builtin(:eq) do |actual, expected|
596
+ next true if actual == expected
597
+
598
+ note = actual.instance_of?(expected.class) ? "" : " (#{actual.class} vs #{expected.class})"
599
+ [false, "expected #{Matchers.describe(actual)} to eq #{expected.inspect}#{note}", nil]
600
+ end
601
+
602
+ define_builtin(:eql) do |actual, expected|
603
+ next true if actual.eql?(expected)
604
+
605
+ [false, "expected #{Matchers.describe(actual)} to eql #{expected.inspect} " \
606
+ "(eql? compares value and type; #{actual.class} vs #{expected.class})", nil]
607
+ end
608
+
609
+ define_builtin(:include) do |actual, *expected|
610
+ unless actual.respond_to?(:include?)
611
+ next [false, "expected #{Matchers.describe(actual)} to include " \
612
+ "#{expected.map(&:inspect).join(", ")}, but a #{actual.class} has no #include?", nil]
613
+ end
614
+
615
+ missing = expected.reject do |item|
616
+ if actual.is_a?(Hash) && item.is_a?(Hash)
617
+ item.all? { |k, v| actual.key?(k) && actual[k] == v }
618
+ elsif actual.is_a?(Hash)
619
+ actual.key?(item)
620
+ else
621
+ actual.include?(item)
622
+ end
623
+ end
624
+ next true if missing.empty?
625
+
626
+ [false, "expected #{Matchers.describe(actual)} to include " \
627
+ "#{expected.map(&:inspect).join(", ")}, but #{missing.map(&:inspect).join(", ")} " \
628
+ "#{missing.one? ? "is" : "are"} missing", nil]
629
+ end
630
+
631
+ define_builtin(:match) do |actual, pattern|
632
+ matched =
633
+ if pattern.is_a?(Regexp)
634
+ actual.is_a?(String) || actual.is_a?(Symbol) ? pattern.match?(actual.to_s) : false
635
+ elsif actual.respond_to?(:match?)
636
+ actual.match?(pattern)
637
+ else
638
+ actual == pattern
639
+ end
640
+ next true if matched
641
+
642
+ [false, "expected #{Matchers.describe(actual)} to match #{pattern.inspect}", nil]
643
+ end
644
+
645
+ define_builtin(:raise_error) do |actual, *args|
646
+ unless actual.respond_to?(:call)
647
+ next [false, "raise_error needs the block form: attest { ... }.to raise_error(...), " \
648
+ "got #{Matchers.describe(actual)}", nil]
649
+ end
650
+
651
+ expected_class = args.find { |a| a.is_a?(Class) } || StandardError
652
+ expected_message = args.find { |a| a.is_a?(String) || a.is_a?(Regexp) }
653
+
654
+ begin
655
+ actual.call
656
+ [false, "expected the block to raise #{expected_class}, but nothing was raised", nil]
657
+ rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised below unless it is the one asked for
658
+ raise if e.is_a?(SystemExit) || e.is_a?(Interrupt) || e.is_a?(SignalException) || e.is_a?(NoMemoryError)
659
+
660
+ context = { "Raised" => "#{e.class}: #{e.message}" }
661
+ message_matched =
662
+ expected_message.nil? ||
663
+ (expected_message.is_a?(Regexp) ? expected_message.match?(e.message) : e.message == expected_message)
664
+
665
+ if !e.is_a?(expected_class)
666
+ [false, "expected the block to raise #{expected_class}, but it raised #{e.class}: #{e.message}", context]
667
+ elsif !message_matched
668
+ [false, "expected the block to raise #{expected_class} with message #{expected_message.inspect}, " \
669
+ "but the message was #{e.message.inspect}", context]
670
+ else
671
+ [true, nil, context]
672
+ end
673
+ end
674
+ end
675
+
676
+ define_builtin(:have_attributes) do |actual, expected|
677
+ unless expected.is_a?(Hash)
678
+ next [false, "have_attributes expects a hash of attributes, got #{expected.inspect}", nil]
679
+ end
680
+
681
+ mismatches = expected.filter_map do |key, value|
682
+ if actual.respond_to?(key)
683
+ got = actual.public_send(key)
684
+ "#{key}: expected #{value.inspect}, got #{got.inspect}" unless got == value
685
+ elsif actual.respond_to?(:[])
686
+ got = actual[key]
687
+ "#{key}: expected #{value.inspect}, got #{got.inspect}" unless got == value
688
+ else
689
+ "#{key}: #{Matchers.describe(actual)} does not respond to ##{key}"
690
+ end
691
+ end
692
+ next true if mismatches.empty?
693
+
694
+ [false, "expected #{Matchers.describe(actual)} to have attributes " \
695
+ "#{Matchers.format_hash(expected)}, but #{mismatches.join("; ")}", nil]
696
+ end
697
+
698
+ define_builtin(:exist) do |actual, *args|
699
+ if actual.respond_to?(:exists?)
700
+ passed = args.empty? ? actual.exists? : actual.exists?(*args)
701
+ next true if passed
702
+
703
+ criteria = args.empty? ? "any record" : "a record matching #{Matchers.format_list(args)}"
704
+ context = begin
705
+ actual.respond_to?(:count) ? { "Rows in table" => actual.count.to_s } : nil
706
+ rescue StandardError
707
+ nil
708
+ end
709
+ next [false, "expected #{Matchers.describe(actual)} to have #{criteria}, but none exists", context]
710
+ end
711
+
712
+ if actual.respond_to?(:exist?)
713
+ next true if actual.exist?
714
+
715
+ next [false, "expected #{Matchers.describe(actual)} to exist on disk, but it does not", nil]
716
+ end
717
+
718
+ if actual.is_a?(String)
719
+ next true if File.exist?(actual)
720
+
721
+ next [false, "expected the path #{actual.inspect} to exist, but no such file or directory", nil]
722
+ end
723
+
724
+ [false, "expected #{Matchers.describe(actual)} to exist, but a #{actual.class} " \
725
+ "responds to neither #exists? nor #exist?", nil]
726
+ end
727
+
728
+ # Registered even though be_* would fall back to #created? anyway: a response's status
729
+ # is the common case, and "got :unprocessable_entity (422)" beats "created? was false".
730
+ define_builtin(:be_created) do |actual|
731
+ code = Matchers.response_status(actual)
732
+ if code
733
+ next true if code == 201
734
+
735
+ next [false, "expected response to be created (201), but got #{Matchers.status_label(code)}",
736
+ Matchers.context_for(actual)]
737
+ end
738
+
739
+ unless actual.respond_to?(:created?)
740
+ next [false, "expected #{Matchers.describe(actual)} to be created, but a #{actual.class} " \
741
+ "responds to neither #status nor #created?", nil]
742
+ end
743
+
744
+ next true if actual.created?
745
+
746
+ [false, "expected #{Matchers.describe(actual)} to be created, but " \
747
+ "#{actual.class}#created? returned false", Matchers.context_for(actual)]
748
+ end
749
+
750
+ define_builtin(:redirect_to) do |actual, target|
751
+ code = Matchers.response_status(actual)
752
+ location = Matchers.response_location(actual)
753
+
754
+ if code.nil?
755
+ next [false, "expected #{Matchers.describe(actual)} to redirect to #{target.inspect}, " \
756
+ "but a #{actual.class} has no HTTP status", nil]
757
+ end
758
+
759
+ unless (300..399).cover?(code)
760
+ next [false, "expected response to redirect to #{target.inspect}, but it returned " \
761
+ "#{Matchers.status_label(code)} with no redirect", Matchers.context_for(actual)]
762
+ end
763
+
764
+ matched =
765
+ if target.is_a?(Regexp)
766
+ location && target.match?(location)
767
+ else
768
+ location == target.to_s || Matchers.location_path(location) == Matchers.location_path(target.to_s)
769
+ end
770
+ next true if matched
771
+
772
+ [false, "expected response to redirect to #{target.inspect}, but it redirected to " \
773
+ "#{location.inspect}", Matchers.context_for(actual)]
774
+ end
775
+
776
+ define_builtin(:have_http_status) do |actual, expected|
777
+ code = Matchers.response_status(actual)
778
+ if code.nil?
779
+ next [false, "expected #{Matchers.describe(actual)} to have HTTP status " \
780
+ "#{Matchers.expected_status_label(expected)}, but a #{actual.class} has no status", nil]
781
+ end
782
+ next true if Matchers.status_matches?(code, expected)
783
+
784
+ if Matchers.status_code_for(expected).nil? && !Matchers::HTTP_STATUS_GROUPS.key?(expected.to_s.to_sym)
785
+ known = Matchers::HTTP_STATUS_CODES.keys.first(5).map(&:inspect).join(", ")
786
+ next [false, "have_http_status does not know the status #{expected.inspect}; " \
787
+ "use an integer or one of #{known}, …", nil]
788
+ end
789
+
790
+ [false, "expected response to have HTTP status #{Matchers.expected_status_label(expected)}, " \
791
+ "but got #{Matchers.status_label(code)}", Matchers.context_for(actual)]
792
+ end
793
+
794
+ define_builtin(:change, deferred_class: ChangeMatcher) do |_actual, *_args|
795
+ raise Constable::Error, "change is only usable through attest { ... }.to change { ... }"
796
+ end
797
+
798
+ define_builtin(:be_a) do |actual, klass|
799
+ next true if actual.is_a?(klass)
800
+
801
+ [false, "expected #{Matchers.describe(actual)} to be a #{klass}, but it is a #{actual.class}", nil]
802
+ end
803
+ builtins[:be_an] = builtins[:be_a]
804
+ builtins[:be_kind_of] = builtins[:be_a]
805
+
806
+ define_builtin(:be_nil) do |actual|
807
+ next true if actual.nil?
808
+
809
+ [false, "expected nil, but got #{Matchers.describe(actual)} (#{actual.class})", nil]
810
+ end
811
+
812
+ define_builtin(:be_empty) do |actual|
813
+ unless actual.respond_to?(:empty?)
814
+ next [false, "expected #{Matchers.describe(actual)} to be empty, but a #{actual.class} has no #empty?", nil]
815
+ end
816
+ next true if actual.empty?
817
+
818
+ size = actual.respond_to?(:size) ? " (#{actual.size} entries)" : ""
819
+ [false, "expected #{Matchers.describe(actual)} to be empty, but it is not#{size}", nil]
820
+ end
821
+
822
+ define_builtin(:be_truthy) do |actual|
823
+ next true if actual
824
+
825
+ [false, "expected a truthy value, but got #{actual.inspect}", nil]
826
+ end
827
+
828
+ define_builtin(:be_falsey) do |actual|
829
+ next true unless actual
830
+
831
+ [false, "expected a falsey value, but got #{Matchers.describe(actual)}", nil]
832
+ end
833
+ end
834
+ end