rubocop-dev_doc 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/config/default.yml +230 -61
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +1 -1
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +2 -2
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +31 -4
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +58 -3
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid reading `obj[key]` more than once with the same receiver and
|
|
6
|
+
# same key in a single method body.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# When the same bracket read appears in multiple places, two distinct
|
|
10
|
+
# failure modes open up:
|
|
11
|
+
#
|
|
12
|
+
# 1. **Silent typos.** Bracket access returns nil for missing keys;
|
|
13
|
+
# nothing raises. Two occurrences of `params[:status]` keep the
|
|
14
|
+
# spelling in sync, but if one of them silently becomes
|
|
15
|
+
# `params[:stutus]`, the line returns nil and the bug ships. A
|
|
16
|
+
# single assignment fixes the spelling in exactly one place — a
|
|
17
|
+
# typo there becomes a `NameError`, not a silent nil.
|
|
18
|
+
#
|
|
19
|
+
# 2. **Reader ambiguity and mutation risk.** The reader has to verify
|
|
20
|
+
# every occurrence really is the same key and that nothing in
|
|
21
|
+
# between mutates the hash. Assigning once makes the value a
|
|
22
|
+
# stable named thing — and on receivers like `session` or shared
|
|
23
|
+
# hashes, it also avoids a real TOCTOU shape where the value can
|
|
24
|
+
# change between the guard and the use.
|
|
25
|
+
#
|
|
26
|
+
# ❌
|
|
27
|
+
# def show
|
|
28
|
+
# @item = Item.lookup_by_slug(params[:slug])
|
|
29
|
+
# redirect_to canonical_url(@item) if @item.slug != params[:slug]
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# ✔️
|
|
33
|
+
# def show
|
|
34
|
+
# slug = params[:slug]
|
|
35
|
+
# @item = Item.lookup_by_slug(slug)
|
|
36
|
+
# redirect_to canonical_url(@item) if @item.slug != slug
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# The cop only counts reads. `obj[k] = v` is a write (`:[]=`) and is
|
|
40
|
+
# not compared against bracket reads of the same key.
|
|
41
|
+
#
|
|
42
|
+
# ## Exception
|
|
43
|
+
# Genuine intentional re-reads (e.g. a deliberate second check after
|
|
44
|
+
# a write that may have mutated the receiver) go through inline
|
|
45
|
+
# `# rubocop:disable` with a reason.
|
|
46
|
+
#
|
|
47
|
+
# NOTE: Receiver and key are compared by source text — `hash['foo']`
|
|
48
|
+
# and `hash[:foo]` look different to the cop even though they're the
|
|
49
|
+
# same value on a `HashWithIndifferentAccess` like `params`. That is
|
|
50
|
+
# a known false negative; the cop will miss that shape rather than
|
|
51
|
+
# over-fire.
|
|
52
|
+
#
|
|
53
|
+
# NOTE: Scope is the enclosing `def`/`defs` body, including nested
|
|
54
|
+
# blocks. Block parameters are scoped to their block, so the same name
|
|
55
|
+
# in two sibling blocks (`arr.each { |item| item[:k] }; other.each {
|
|
56
|
+
# |item| item[:k] }`) is correctly treated as two different bindings,
|
|
57
|
+
# not a repeat. Reads of the same block param *within one block* are
|
|
58
|
+
# still flagged. (Numbered/`it` block params are matched textually — a
|
|
59
|
+
# rare residual false positive; inline-disable if it surfaces.)
|
|
60
|
+
#
|
|
61
|
+
# NOTE: Multi-argument bracket calls (`arr[i, len]`, slicing) are
|
|
62
|
+
# ignored. Only single-argument reads participate.
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# # bad — same key read twice
|
|
66
|
+
# def show
|
|
67
|
+
# @item = Item.lookup_by_slug(params[:slug])
|
|
68
|
+
# redirect_to canonical_url(@item) if @item.slug != params[:slug]
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # bad — guard-then-use re-reads the receiver
|
|
72
|
+
# def session_get(key)
|
|
73
|
+
# return nil unless session[key]
|
|
74
|
+
# session[key].symbolize_keys
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# # good — assign once
|
|
78
|
+
# def session_get(key)
|
|
79
|
+
# payload = session[key]
|
|
80
|
+
# return nil unless payload
|
|
81
|
+
# payload.symbolize_keys
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# # good — different keys, no offense
|
|
85
|
+
# def filters
|
|
86
|
+
# @search = params[:search]
|
|
87
|
+
# @status = params[:status]
|
|
88
|
+
# end
|
|
89
|
+
class RepeatedBracketRead < Base
|
|
90
|
+
MSG = "Receiver `%<receiver>s[%<key>s]` already read earlier in this method — assign once and reuse.".freeze
|
|
91
|
+
|
|
92
|
+
def on_def(node)
|
|
93
|
+
check_method(node)
|
|
94
|
+
end
|
|
95
|
+
alias on_defs on_def
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def check_method(def_node)
|
|
100
|
+
body = def_node.body
|
|
101
|
+
return unless body
|
|
102
|
+
|
|
103
|
+
seen = {}
|
|
104
|
+
body.each_descendant(:send) do |send_node|
|
|
105
|
+
next unless bracket_read?(send_node)
|
|
106
|
+
|
|
107
|
+
receiver = send_node.receiver
|
|
108
|
+
key = send_node.first_argument
|
|
109
|
+
composite = composite_key(receiver, key)
|
|
110
|
+
|
|
111
|
+
if seen[composite]
|
|
112
|
+
add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
|
|
113
|
+
else
|
|
114
|
+
seen[composite] = true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Block parameters are scoped to their block: the same name read in two
|
|
120
|
+
# sibling blocks is a *different* binding, not a repeat. Key those reads
|
|
121
|
+
# by their binding block so they don't collide. Method-call / ivar /
|
|
122
|
+
# method-local receivers keep a purely textual key.
|
|
123
|
+
def composite_key(receiver, key_node)
|
|
124
|
+
key = key_node.source
|
|
125
|
+
block = binding_block(receiver)
|
|
126
|
+
block ? "#{block.object_id}|#{receiver.source}|#{key}" : "#{receiver.source}|#{key}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The nearest enclosing block that binds the receiver as a block
|
|
130
|
+
# argument, or nil when the receiver is not a block-local variable.
|
|
131
|
+
def binding_block(receiver)
|
|
132
|
+
return nil unless receiver.lvar_type?
|
|
133
|
+
|
|
134
|
+
name = receiver.children.first
|
|
135
|
+
receiver.each_ancestor(:block) do |block|
|
|
136
|
+
return block if block.arguments.any? { |arg| arg.respond_to?(:name) && arg.name == name }
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def bracket_read?(node)
|
|
142
|
+
node.method_name == :[] &&
|
|
143
|
+
node.receiver &&
|
|
144
|
+
node.arguments.size == 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid using `&.` on the same receiver more than once in a method body.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# When a receiver appears with `&.` repeatedly across a method, the
|
|
9
|
+
# reader has to mentally re-evaluate the nil case at every call, and
|
|
10
|
+
# the *intent* of each `&.` becomes ambiguous — is this call nullable
|
|
11
|
+
# for a fresh reason, or is the dev just mirroring the earlier `&.`
|
|
12
|
+
# out of habit? Repetition also propagates nil silently into
|
|
13
|
+
# downstream comparisons (`current_user&.id == something` evaluates
|
|
14
|
+
# to `nil == something`, which is false but not for the reason the
|
|
15
|
+
# reader expects).
|
|
16
|
+
#
|
|
17
|
+
# Resolve the nil case once at the top of the method, then use the
|
|
18
|
+
# local with plain `.`:
|
|
19
|
+
#
|
|
20
|
+
# ❌
|
|
21
|
+
# def show?
|
|
22
|
+
# current_user&.super_admin? ||
|
|
23
|
+
# current_user&.developer? ||
|
|
24
|
+
# current_user&.admin_of_organization?(organization)
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# ✔️ Guard once, then plain calls
|
|
28
|
+
# def show?
|
|
29
|
+
# return false unless current_user
|
|
30
|
+
#
|
|
31
|
+
# current_user.super_admin? ||
|
|
32
|
+
# current_user.developer? ||
|
|
33
|
+
# current_user.admin_of_organization?(organization)
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# ✔️ Alternative — assign-and-test in the condition
|
|
37
|
+
# def label
|
|
38
|
+
# if (user = current_user)
|
|
39
|
+
# "#{user.full_name} <#{user.email}>"
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# The cop is policy-safe by design: it does not assume anything
|
|
44
|
+
# about `current_user` non-nullability or branching semantics — it
|
|
45
|
+
# only flags repeated `&.` on the same receiver, which is a smell
|
|
46
|
+
# regardless of whether the receiver is `current_user`, a model
|
|
47
|
+
# attribute, or a local variable.
|
|
48
|
+
#
|
|
49
|
+
# ## Exception
|
|
50
|
+
# Legitimate cases (e.g. when the repeated calls are conceptually
|
|
51
|
+
# distinct sources that happen to share source spelling) go through
|
|
52
|
+
# inline `# rubocop:disable` with a reason.
|
|
53
|
+
#
|
|
54
|
+
# NOTE: Receivers are compared by source text — two `&.` calls
|
|
55
|
+
# share a receiver if their source spelling matches verbatim. The
|
|
56
|
+
# cop does not perform flow analysis, so a receiver reassigned
|
|
57
|
+
# between uses is not detected (false negative).
|
|
58
|
+
#
|
|
59
|
+
# NOTE: Scope is the enclosing `def`/`defs` body. The cop does not
|
|
60
|
+
# partition by inner blocks, so the same parameter name reused
|
|
61
|
+
# across separate block bodies in one method may produce a false
|
|
62
|
+
# positive — inline-disable with a reason if it surfaces.
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# # bad — same receiver, multiple safe-nav reads
|
|
66
|
+
# def card
|
|
67
|
+
# name = current_user&.full_name
|
|
68
|
+
# email = current_user&.email
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # bad — same receiver, multiple safe-nav predicates
|
|
72
|
+
# def show?
|
|
73
|
+
# user&.super_admin? || user&.developer?
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# # good — assign once, then plain calls
|
|
77
|
+
# def show?
|
|
78
|
+
# return false unless user
|
|
79
|
+
#
|
|
80
|
+
# user.super_admin? || user.developer?
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# # good — single use of safe-nav
|
|
84
|
+
# def label
|
|
85
|
+
# current_user&.full_name
|
|
86
|
+
# end
|
|
87
|
+
class RepeatedSafeNavigationReceiver < Base
|
|
88
|
+
MSG = "Receiver `%<receiver>s` already used with `&.` earlier in this method — assign once and use `.` after.".freeze
|
|
89
|
+
|
|
90
|
+
def on_def(node)
|
|
91
|
+
check_method(node)
|
|
92
|
+
end
|
|
93
|
+
alias on_defs on_def
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def check_method(def_node)
|
|
98
|
+
body = def_node.body
|
|
99
|
+
return unless body
|
|
100
|
+
|
|
101
|
+
seen = {}
|
|
102
|
+
body.each_descendant(:csend) do |csend|
|
|
103
|
+
receiver = csend.receiver
|
|
104
|
+
next unless receiver
|
|
105
|
+
|
|
106
|
+
key = receiver.source
|
|
107
|
+
if seen[key]
|
|
108
|
+
add_offense(csend.loc.dot, message: format(MSG, receiver: key))
|
|
109
|
+
else
|
|
110
|
+
seen[key] = true
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Flag `glib_travel_freeze` calls in test files — use `glib_travel` instead.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `glib_travel_freeze` stops the clock completely, which is not representative
|
|
9
|
+
# of real-world behavior: in production, time ticks as code executes.
|
|
10
|
+
# We have had bugs that went undetected in tests because `glib_travel_freeze`
|
|
11
|
+
# masked timing issues, only to surface in production.
|
|
12
|
+
#
|
|
13
|
+
# `glib_travel` advances time normally inside the block — use it instead.
|
|
14
|
+
# Reserve `glib_travel_freeze` only as a last resort when `glib_travel`
|
|
15
|
+
# truly cannot work for a specific test, and document why.
|
|
16
|
+
#
|
|
17
|
+
# ## Escape hatch
|
|
18
|
+
# Disable per-line with a comment explaining why `glib_travel` doesn't work:
|
|
19
|
+
#
|
|
20
|
+
# glib_travel_freeze(time) do # rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze
|
|
21
|
+
# # Reason: <explanation>
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad
|
|
26
|
+
# glib_travel_freeze(Time.current) do
|
|
27
|
+
# expect(order.expired?).to be true
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # bad (non-block form)
|
|
31
|
+
# glib_travel_freeze(Time.current)
|
|
32
|
+
# expect(order.expired?).to be true
|
|
33
|
+
#
|
|
34
|
+
# # good
|
|
35
|
+
# glib_travel(Time.current) do
|
|
36
|
+
# expect(order.expired?).to be true
|
|
37
|
+
# end
|
|
38
|
+
class AvoidGlibTravelFreeze < Base
|
|
39
|
+
MSG = 'Avoid `glib_travel_freeze` — use `glib_travel` instead. ' \
|
|
40
|
+
'Frozen time masks timing bugs that surface in production. ' \
|
|
41
|
+
'Use `# rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze` ' \
|
|
42
|
+
'with a comment explaining why `glib_travel` cannot work for this test.'
|
|
43
|
+
|
|
44
|
+
RESTRICT_ON_SEND = %i[glib_travel_freeze].freeze
|
|
45
|
+
|
|
46
|
+
def on_send(node)
|
|
47
|
+
add_offense(node.loc.selector)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Prefer controller tests; flag unit/service tests (`< ActiveSupport::TestCase`).
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# What the user sees and experiences is what matters; internal
|
|
9
|
+
# implementation does not. A controller test exercises behaviour
|
|
10
|
+
# end-to-end through the same path a user takes, so it catches the
|
|
11
|
+
# regressions that actually reach production. A unit/service test is only
|
|
12
|
+
# needed when a code path genuinely cannot be reached through a
|
|
13
|
+
# controller test (very rare) — e.g. a search-ranking detail the
|
|
14
|
+
# controller never exposes.
|
|
15
|
+
#
|
|
16
|
+
# This cop flags only the literal `< ActiveSupport::TestCase`
|
|
17
|
+
# superclass. The blessed blackbox bases —
|
|
18
|
+
# `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
|
|
19
|
+
# `ActionMailer::TestCase`, `ActiveJob::TestCase` — are NOT flagged, even
|
|
20
|
+
# though they inherit from `ActiveSupport::TestCase` transitively.
|
|
21
|
+
#
|
|
22
|
+
# ## Escape hatch
|
|
23
|
+
# When a unit test is genuinely necessary, suppress with a reason that
|
|
24
|
+
# explains why a controller test can't cover the path. That reason IS the
|
|
25
|
+
# required justification — keep it specific and reviewable:
|
|
26
|
+
#
|
|
27
|
+
# # rubocop:disable DevDoc/Test/AvoidUnitTest -- search ranking isn't visible through the controller
|
|
28
|
+
# class Ai::Retrieval::PgSearchStrategyTest < ActiveSupport::TestCase
|
|
29
|
+
# # ...
|
|
30
|
+
# end
|
|
31
|
+
# # rubocop:enable DevDoc/Test/AvoidUnitTest
|
|
32
|
+
#
|
|
33
|
+
# NOTE: The cop matches the *direct* superclass only. A project base
|
|
34
|
+
# (`class ApplicationServiceTest < ActiveSupport::TestCase`) is flagged
|
|
35
|
+
# once (justify it there); subclasses of that base are not re-flagged.
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# # bad
|
|
39
|
+
# class NoteRenderingTest < ActiveSupport::TestCase
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# # good — exercised through the controller
|
|
43
|
+
# class NotesControllerTest < ActionDispatch::IntegrationTest
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# # good — genuinely unit-only, justified with a reason
|
|
47
|
+
# # rubocop:disable DevDoc/Test/AvoidUnitTest -- <why a controller test can't cover this>
|
|
48
|
+
# class SomeServiceTest < ActiveSupport::TestCase
|
|
49
|
+
# end
|
|
50
|
+
# # rubocop:enable DevDoc/Test/AvoidUnitTest
|
|
51
|
+
class AvoidUnitTest < Base
|
|
52
|
+
MSG = 'Prefer a controller test — unit tests are a rare exception. If a controller test ' \
|
|
53
|
+
'genuinely cannot cover this path, disable this cop on the class with a reason.'.freeze
|
|
54
|
+
|
|
55
|
+
def on_class(node)
|
|
56
|
+
superclass = node.parent_class
|
|
57
|
+
return unless superclass&.const_type?
|
|
58
|
+
return unless superclass.const_name == 'ActiveSupport::TestCase'
|
|
59
|
+
|
|
60
|
+
add_offense(superclass)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Controller tests that assert on the rendered `response.body` should
|
|
6
|
+
# also snapshot the full response with `response_assert_equal`.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# `response_assert_equal` (glib-web's `Glib::TestHelpers`) is the most
|
|
10
|
+
# blackbox assertion available — it snapshots the entire rendered
|
|
11
|
+
# response, so it catches regressions anywhere in the output, not just
|
|
12
|
+
# the one substring a targeted assertion happens to check. Per the
|
|
13
|
+
# testing best practice, happy- and unhappy-path controller tests
|
|
14
|
+
# should always use it.
|
|
15
|
+
#
|
|
16
|
+
# The common slip this cop guards against: a test inspects the rendered
|
|
17
|
+
# body with `assert_match` / `assert_no_match` / `assert_includes` /
|
|
18
|
+
# `response.body.scan(...)` but forgets the snapshot. The targeted
|
|
19
|
+
# assertion documents intent, but on its own it only proves the one
|
|
20
|
+
# line — collateral changes elsewhere in the response slip through.
|
|
21
|
+
#
|
|
22
|
+
# ❌ Inspects the body, but no snapshot
|
|
23
|
+
# test 'index lists the item' do
|
|
24
|
+
# get items_url(format: :json)
|
|
25
|
+
# assert_response :success
|
|
26
|
+
# assert_match 'Widget', response.body
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# ✔️ Keep the focused assertion AND snapshot the whole response
|
|
30
|
+
# test 'index lists the item' do
|
|
31
|
+
# get items_url(format: :json)
|
|
32
|
+
# assert_response :success
|
|
33
|
+
# assert_match 'Widget', response.body
|
|
34
|
+
# response_assert_equal
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# ## What is NOT flagged
|
|
38
|
+
# - Tests that already call `response_assert_equal`.
|
|
39
|
+
# - Exception/redirect paths — a test asserting a non-success status
|
|
40
|
+
# (`assert_response :not_found`, `:forbidden`, `:redirect`, etc.)
|
|
41
|
+
# often has no stable rendered body to snapshot.
|
|
42
|
+
# - State-change tests that never read `response.body` (e.g.
|
|
43
|
+
# `assert_difference 'Model.count'`, mailer assertions). Passing
|
|
44
|
+
# `response.body` to a helper like `submit_form(response.body, ...)`
|
|
45
|
+
# is not a body assertion and is not flagged.
|
|
46
|
+
#
|
|
47
|
+
# ## Inline disable
|
|
48
|
+
# For the rare happy-path test whose response is genuinely
|
|
49
|
+
# non-deterministic and cannot be made stable, add a
|
|
50
|
+
# `rubocop:disable DevDoc/Test/ResponseAssertEqual` comment to the
|
|
51
|
+
# `test '...' do` line, with a written reason on the next line
|
|
52
|
+
# (e.g. "chunked response includes a per-run boundary token").
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# # bad
|
|
56
|
+
# test 'shows the row' do
|
|
57
|
+
# get foo_url(format: :json)
|
|
58
|
+
# assert_match 'bar', response.body
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# # good
|
|
62
|
+
# test 'shows the row' do
|
|
63
|
+
# get foo_url(format: :json)
|
|
64
|
+
# assert_match 'bar', response.body
|
|
65
|
+
# response_assert_equal
|
|
66
|
+
# end
|
|
67
|
+
class ResponseAssertEqual < Base
|
|
68
|
+
MSG = 'Asserts on `response.body` but never calls `response_assert_equal`. ' \
|
|
69
|
+
'Add the snapshot assertion (usually last) — it captures the whole rendered ' \
|
|
70
|
+
'response, a stronger guard than a targeted body assertion. Exception-path ' \
|
|
71
|
+
'tests that cannot snapshot may disable this cop with a reason.'.freeze
|
|
72
|
+
|
|
73
|
+
# @!method test_block?(node)
|
|
74
|
+
def_node_matcher :test_block?, <<~PATTERN
|
|
75
|
+
(block (send nil? :test ...) _args _body)
|
|
76
|
+
PATTERN
|
|
77
|
+
|
|
78
|
+
# @!method response_body_read?(node)
|
|
79
|
+
def_node_matcher :response_body_read?, <<~PATTERN
|
|
80
|
+
(send (send nil? :response) {:body :parsed_body})
|
|
81
|
+
PATTERN
|
|
82
|
+
|
|
83
|
+
# @!method calls_snapshot?(node)
|
|
84
|
+
def_node_search :calls_snapshot?, <<~PATTERN
|
|
85
|
+
(send nil? :response_assert_equal)
|
|
86
|
+
PATTERN
|
|
87
|
+
|
|
88
|
+
# @!method response_status?(node)
|
|
89
|
+
def_node_matcher :response_status?, '(send (send nil? :response) :status)'
|
|
90
|
+
|
|
91
|
+
# Non-success HTTP statuses (symbol form). A test asserting any of
|
|
92
|
+
# these is an exception / redirect / empty-body path — no JSON body
|
|
93
|
+
# to snapshot.
|
|
94
|
+
NON_SUCCESS_STATUS_SYMBOLS = %i[
|
|
95
|
+
not_found forbidden unauthorized unprocessable_entity unprocessable_content
|
|
96
|
+
bad_request conflict gone no_content not_modified redirect moved_permanently
|
|
97
|
+
found see_other payment_required too_many_requests precondition_failed
|
|
98
|
+
].freeze
|
|
99
|
+
|
|
100
|
+
def on_block(node)
|
|
101
|
+
return unless test_block?(node)
|
|
102
|
+
return unless node.body
|
|
103
|
+
return if calls_snapshot?(node)
|
|
104
|
+
return if exempt_from_snapshot?(node)
|
|
105
|
+
return unless inspects_response_body?(node)
|
|
106
|
+
|
|
107
|
+
add_offense(node.send_node.loc.selector)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Exception / redirect / empty-body responses can't (and shouldn't) be
|
|
113
|
+
# snapshotted. Handles both the `assert_response :symbol` convention
|
|
114
|
+
# and the numeric `assert_response 404` / `assert_equal 404,
|
|
115
|
+
# response.status` convention, plus `assert_empty response.body`.
|
|
116
|
+
def exempt_from_snapshot?(test_node)
|
|
117
|
+
test_node.each_descendant(:send).any? { |s| non_snapshotable_assertion?(s) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def non_snapshotable_assertion?(send_node)
|
|
121
|
+
case send_node.method_name
|
|
122
|
+
when :assert_response then non_success_status?(send_node.first_argument)
|
|
123
|
+
when :assert_equal then non_success_status_equal?(send_node)
|
|
124
|
+
when :assert_empty then response_body_read?(send_node.first_argument)
|
|
125
|
+
else false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# `assert_equal <non-2xx code>, response.status`
|
|
130
|
+
def non_success_status_equal?(send_node)
|
|
131
|
+
args = send_node.arguments
|
|
132
|
+
args.size >= 2 && non_success_status?(args[0]) && response_status?(args[1])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A non-success status: a symbol (`:not_found`) or a numeric code
|
|
136
|
+
# >= 300 (`404`). 2xx codes are NOT exempt — they should snapshot.
|
|
137
|
+
def non_success_status?(node)
|
|
138
|
+
return false unless node
|
|
139
|
+
|
|
140
|
+
(node.sym_type? && NON_SUCCESS_STATUS_SYMBOLS.include?(node.value)) ||
|
|
141
|
+
(node.int_type? && node.value >= 300)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# True when the test inspects the response *as JSON*. Three forms count:
|
|
145
|
+
# - `response.parsed_body` (any use — it's the JSON-parsed body)
|
|
146
|
+
# - `JSON.parse(response.body)` (explicit JSON parse, even if the
|
|
147
|
+
# result is stored in a local first)
|
|
148
|
+
# - `response.body` directly inside an assertion
|
|
149
|
+
# (`assert_match 'x', response.body`)
|
|
150
|
+
#
|
|
151
|
+
# Deliberately NOT counted: `response.body` fed to a format-specific
|
|
152
|
+
# parser (`parse_xlsx`, `CSV.parse`, `Nokogiri…parse`) or a render/submit
|
|
153
|
+
# helper (`submit_form`) — those are non-JSON or pass-through, and
|
|
154
|
+
# `response_assert_equal` (JSON-only) can't snapshot them.
|
|
155
|
+
def inspects_response_body?(test_node)
|
|
156
|
+
test_node.each_descendant(:send).any? do |read|
|
|
157
|
+
next false unless response_body_read?(read)
|
|
158
|
+
|
|
159
|
+
read.method_name == :parsed_body || asserted?(read) || json_parsed?(read)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `response.body` / `response.parsed_body` read within an assertion.
|
|
164
|
+
def asserted?(read)
|
|
165
|
+
read.each_ancestor(:send).any? { |a| a.method_name.to_s.start_with?('assert') }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `JSON.parse(response.body)` — the JSON constant specifically, so
|
|
169
|
+
# format-specific parsers (`parse_xlsx`, `CSV.parse`) don't match.
|
|
170
|
+
def json_parsed?(read)
|
|
171
|
+
parent = read.parent
|
|
172
|
+
parent&.send_type? && parent.method_name == :parse &&
|
|
173
|
+
parent.receiver&.const_type? && parent.receiver.const_name == 'JSON'
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|