Last active
April 4, 2023 08:01
-
-
Save stephaneliu/19cdc81b7131e64c55a93c84e784566a to your computer and use it in GitHub Desktop.
Custom RuboCop cop to prevent using VCR cassettes with record strategies that make live requests
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# .rubocop.yml | |
require: | |
- ./lib/custom_cops/vcr/record_strategy.rb | |
# lib/custom_cops/custom_cop_base.rb | |
# frozen_string_literal: true | |
# See https://docs.rubocop.org/rubocop/1.23/development.html for more info on creating new [Rubo]cops | |
module CustomCops | |
class CustomCopBase < RuboCop::Cop::Base | |
end | |
end | |
# lib/custom_cops/vcr/record_strategy.rb | |
# frozen_string_literal: true | |
require_relative '../custom_cop_base' | |
module CustomCops | |
module Vcr | |
# Enforces the use of record strategies that do not make live requests | |
# `record: :once` - Only record if cassette does not exist (VCR Default). | |
# `record: :none` - Never record new interactions. Replay previous. | |
# `record: :record_on_error` - Prevent a cassette from being recorded when the code that uses cassette raises error. | |
# `record: all` - Record new interactions. Never replay previous. | |
# `record: :new_episodes` - Record new interactions. Replay previous. | |
# | |
# # Good | |
# VCR.use_cassette('document_download') do | |
# VCR.use_cassette('document_download', record: :once) do | |
# VCR.use_cassette('document_download', record: :none) do | |
# | |
# # Bad | |
# VCR.use_cassette('document_download', record: :all_episodes) do | |
# VCR.use_cassette('document_download', record: :new_episodes) do | |
# VCR.use_cassette('document_download', record: :record_on_error) do | |
class RecordStrategy < CustomCopBase | |
# View Abstract Syntax Tree (AST) using Parser (https://github.com/whitequark/parser) | |
# | |
# > gem install ruby_parser | |
# > ruby-parse -e "VCR.use_cassette('document_download', record: :all_episodes, match_requests_on: [:method, :host])" | |
# (send | |
# (const nil :VCR) :use_cassette | |
# (str "document_download") | |
# (kwargs | |
# (pair | |
# (sym :record) | |
# (sym :all_episodes)) | |
# (pair | |
# (sym :match_requests_on) | |
# (array | |
# (sym :method) | |
# (sym :host))))) | |
# | |
# More info on interact with AST - https://docs.rubocop.org/rubocop/1.23/development.html | |
def_node_matcher :record_strategy, <<-PATTERN | |
(send (const nil? :VCR) :use_cassette (str ...) (hash <(pair (sym :record)(sym $_)) ... > ...) ...) | |
# | | | | | |
# | | | Hash pair is not order dependent | |
# | | | | |
# | | Any order - https://docs.rubocop.org/rubocop-ast/node_pattern.html#for-match-in-any-order | |
# | | | |
# | Any number of nodes (wildcard) - https://docs.rubocop.org/rubocop-ast/node_pattern.html#for-several-subsequent-nodes | |
# | | |
# Predicate methods - https://docs.rubocop.org/rubocop-ast/node_pattern.html#predicate-methods | |
PATTERN | |
STRATEGY = %i[once none].freeze | |
MSG = "Only use `record: :once` or `record: :none` for VCR record strategy" | |
# Optimization: don't call `on_send` unless method name is in this list | |
# https://thoughtbot.com/blog/rubocop-custom-cops-for-custom-needs | |
RESTRICT_ON_SEND = %i[use_cassette].freeze | |
def on_send(node) | |
record_strategy(node) do |strategy| | |
next if STRATEGY.include?(strategy.to_sym) | |
add_offense(node) | |
end | |
end | |
end | |
end | |
end | |
# spec/lib/custom_cops/vcr/record-strategy_spec.rb | |
# frozen_string_literal: true | |
require 'rails_helper' | |
require 'rubocop' | |
require_relative '../../../../lib/custom_cops/vcr/record_strategy' | |
RSpec.describe CustomCops::Vcr::RecordStrategy do | |
let(:cop) do | |
config = RuboCop::Config.new({ described_class.badge.to_s => {} }, "/") | |
described_class.new(config) | |
end | |
let(:investigation_report) do | |
RuboCop::Cop::Commissioner.new([cop]).investigate(cop.parse(source)) | |
end | |
context 'when use_cassette does not have record option' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test') do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
context 'with other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo]) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
end | |
end | |
context 'when use_cassette `record: :once`' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :once) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
context 'when `record: :once` is before other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :once, match_requests_on: [:host, :method, :static_photo]) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
end | |
context 'when `record: :once` is after other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo], record: :once) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
end | |
end | |
context 'when use_cassette `record: :none`' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :none) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
context 'when `record: :none` is before other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :none, match_requests_on: [:host, :method, :static_photo]) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
end | |
context 'when `record: :none` is after other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo], record: :none) do | |
# ... | |
end | |
RUBY | |
end | |
it 'does not record an offense' do | |
expect(investigation_report.offenses).to be_blank | |
end | |
end | |
end | |
context 'when use_cassette `record :all_episodes`' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :all_episodes) do | |
# ... | |
end | |
RUBY | |
end | |
it 'records an offense' do | |
expect(investigation_report.offenses).to be_present | |
expect(investigation_report.offenses.first.message).to eq(described_class::MSG) | |
end | |
context 'when `record: :all_episodes` is before other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :episodes, match_requests_on: [:host, :method, :static_photo]) do | |
# ... | |
end | |
RUBY | |
end | |
it 'records an offense' do | |
expect(investigation_report.offenses).to be_present | |
expect(investigation_report.offenses.first.message).to eq(described_class::MSG) | |
end | |
end | |
context 'when `record: :all_episodes` is after other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo], record: :episodes) do | |
# ... | |
end | |
RUBY | |
end | |
it 'records an offense' do | |
expect(investigation_report.offenses).to be_present | |
expect(investigation_report.offenses.first.message).to eq(described_class::MSG) | |
end | |
end | |
end | |
context 'when use_cassette `record :unknown_strategy`' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', record: :unknown_strategy) do | |
# ... | |
end | |
RUBY | |
end | |
it 'records an offense' do | |
expect(investigation_report.offenses).to be_present | |
expect(investigation_report.offenses.first.message).to eq(described_class::MSG) | |
end | |
context 'when `record: :unknown_strategy` is before other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', | |
record: :unknown_strategy, | |
match_requests_on: [:host, :method, :static_photo]) do | |
# ... | |
end | |
RUBY | |
end | |
it 'records an offense' do | |
expect(investigation_report.offenses).to be_present | |
expect(investigation_report.offenses.first.message).to eq(described_class::MSG) | |
end | |
end | |
context 'when `record: :unknown_strategy episodes` is after other options' do | |
let(:source) do | |
<<~RUBY | |
VCR.use_cassette('external_test', | |
match_requests_on: [:host, :method, :static_photo], | |
record: :unknown_strategy) do | |
# ... | |
end | |
RUBY | |
end | |
it 'records an offense' do | |
expect(investigation_report.offenses).to be_present | |
expect(investigation_report.offenses.first.message).to eq(described_class::MSG) | |
end | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment