diff --git a/Gemfile.lock b/Gemfile.lock index 8cda53d..d3c40a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - code_teams (1.1.0) + code_teams (1.2.0) sorbet-runtime GEM @@ -99,7 +99,7 @@ GEM thor (1.3.2) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-emoji (4.2.0) yard (0.9.37) yard-sorbet (0.9.0) sorbet-runtime diff --git a/README.md b/README.md index 4b7d4a6..5884c8e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,32 @@ if errors.any? end ``` +## Testing + +`code_teams` provides test helpers for creating temporary teams in your specs without writing YML files to disk. Add the following to your `spec_helper.rb` (or `rails_helper.rb`): + +```ruby +require 'code_teams/testing' + +CodeTeams::Testing.enable! +``` + +This gives you: +- A `code_team_with_config` helper method available in all specs +- Automatic cleanup of testing teams between examples + +Example usage in a spec: + +```ruby +RSpec.describe 'my feature' do + it 'works with a team' do + team = code_team_with_config(name: 'Test Team') + + expect(CodeTeams.find('Test Team')).to eq(team) + end +end +``` + ## Contributing Bug reports and pull requests are welcome! diff --git a/code_teams.gemspec b/code_teams.gemspec index 9a9a1f7..50d12af 100644 --- a/code_teams.gemspec +++ b/code_teams.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |spec| spec.name = 'code_teams' - spec.version = '1.1.0' + spec.version = '1.2.0' spec.authors = ['Gusto Engineers'] spec.email = ['dev@gusto.com'] spec.summary = 'A low-dependency gem for declaring and querying engineering teams' diff --git a/lib/code_teams.rb b/lib/code_teams.rb index c1f341c..4ff6aec 100644 --- a/lib/code_teams.rb +++ b/lib/code_teams.rb @@ -85,7 +85,7 @@ def self.from_yml(config_yml) ) end - sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(Team) } + sig { params(raw_hash: T::Hash[String, T.untyped]).returns(Team) } def self.from_hash(raw_hash) new( config_yml: nil, @@ -104,7 +104,7 @@ def self.register_plugins end end - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, T.untyped]) } attr_reader :raw_hash sig { returns(T.nilable(String)) } @@ -113,7 +113,7 @@ def self.register_plugins sig do params( config_yml: T.nilable(String), - raw_hash: T::Hash[T.untyped, T.untyped] + raw_hash: T::Hash[String, T.untyped] ).void end def initialize(config_yml:, raw_hash:) diff --git a/lib/code_teams/plugin.rb b/lib/code_teams/plugin.rb index 2735f31..a651f6f 100644 --- a/lib/code_teams/plugin.rb +++ b/lib/code_teams/plugin.rb @@ -25,12 +25,12 @@ def self.data_accessor_name(key = default_data_accessor_name) sig { returns(String) } def self.default_data_accessor_name # e.g., MyNamespace::MyPlugin -> my_plugin - Utils.underscore(Utils.demodulize(name)) + Utils.underscore(Utils.demodulize(T.must(name))) end - sig { params(base: T.untyped).void } + sig { params(base: T.class_of(Plugin)).void } def self.inherited(base) # rubocop:disable Lint/MissingSuper - all_plugins << T.cast(base, T.class_of(Plugin)) + all_plugins << base end sig { returns(T::Array[T.class_of(Plugin)]) } diff --git a/lib/code_teams/rspec_helpers.rb b/lib/code_teams/rspec_helpers.rb new file mode 100644 index 0000000..7eecdac --- /dev/null +++ b/lib/code_teams/rspec_helpers.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# +# typed: false + +require 'securerandom' +require 'code_teams/testing' + +module CodeTeams + module RSpecHelpers + def code_team_with_config(team_config = {}) + team_config = team_config.dup + team_config[:name] ||= "Fake Team #{SecureRandom.hex(4)}" + CodeTeams::Testing.create_code_team(team_config) + end + end +end diff --git a/lib/code_teams/testing.rb b/lib/code_teams/testing.rb new file mode 100644 index 0000000..c94bee4 --- /dev/null +++ b/lib/code_teams/testing.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +# +# typed: strict + +require 'securerandom' +require 'code_teams' +require 'code_teams/rspec_helpers' + +module CodeTeams + # Utilities for tests that need a controlled set of teams without writing YML + # files to disk. + # + # Opt-in by requiring `code_teams/testing`. + module Testing + extend T::Sig + + THREAD_KEY = T.let(:__code_teams_collection, Symbol) + @enabled = T.let(false, T::Boolean) + + sig { void } + def self.enable! + return if @enabled + + CodeTeams.prepend(CodeTeamsExtension) + @enabled = true + + return unless defined?(RSpec) + + T.unsafe(RSpec).configure do |config| + config.include CodeTeams::RSpecHelpers + + config.around do |example| + example.run + # Bust caches because plugins may hang onto stale data between examples. + if CodeTeams::Testing.code_teams.any? + CodeTeams.bust_caches! + CodeTeams::Testing.reset! + end + end + end + end + + sig { params(attributes: T::Hash[Symbol, T.untyped]).returns(CodeTeams::Team) } + def self.create_code_team(attributes) + attributes = attributes.dup + attributes[:name] ||= "Fake Team #{SecureRandom.hex(4)}" + + code_team = CodeTeams::Team.new( + config_yml: 'tmp/fake_config.yml', + raw_hash: Utils.deep_stringify_keys(attributes) + ) + + code_teams << code_team + code_team + end + + sig { returns(T::Array[CodeTeams::Team]) } + def self.code_teams + existing = Thread.current[THREAD_KEY] + return existing if existing.is_a?(Array) + + Thread.current[THREAD_KEY] = [] + T.cast(Thread.current[THREAD_KEY], T::Array[CodeTeams::Team]) + end + + sig { void } + def self.reset! + Thread.current[THREAD_KEY] = [] + end + + module CodeTeamsExtension + extend T::Sig + + sig { params(base: Module).void } + def self.prepended(base) + base.singleton_class.prepend(ClassMethods) + end + + module ClassMethods + extend T::Sig + + sig { returns(T::Array[CodeTeams::Team]) } + def all + CodeTeams::Testing.code_teams + super + end + + sig { params(name: String).returns(T.nilable(CodeTeams::Team)) } + def find(name) + CodeTeams::Testing.code_teams.find { |t| t.name == name } || super + end + end + end + end +end diff --git a/lib/code_teams/utils.rb b/lib/code_teams/utils.rb index 281cddb..63756b9 100644 --- a/lib/code_teams/utils.rb +++ b/lib/code_teams/utils.rb @@ -1,7 +1,14 @@ +# frozen_string_literal: true +# +# typed: strict + module CodeTeams module Utils + extend T::Sig + module_function + sig { params(string: String).returns(String) } def underscore(string) string.gsub('::', '/') .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') @@ -10,8 +17,24 @@ def underscore(string) .downcase end + sig { params(string: String).returns(String) } def demodulize(string) - string.split('::').last + T.must(string.split('::').last) + end + + # Recursively converts symbol keys to strings. Top-level input should be a Hash. + sig { params(value: T.untyped).returns(T.untyped) } + def deep_stringify_keys(value) + case value + when Hash + value.each_with_object({}) do |(k, v), acc| + acc[k.to_s] = deep_stringify_keys(v) + end + when Array + value.map { |v| deep_stringify_keys(v) } + else + value + end end end end diff --git a/spec/lib/code_teams/r_spec_helpers_integration_spec.rb b/spec/lib/code_teams/r_spec_helpers_integration_spec.rb new file mode 100644 index 0000000..07b031b --- /dev/null +++ b/spec/lib/code_teams/r_spec_helpers_integration_spec.rb @@ -0,0 +1,16 @@ +require 'code_teams/testing' + +CodeTeams::Testing.enable! + +RSpec.describe CodeTeams::RSpecHelpers do + it 'exposes code_team_with_config and makes the team discoverable' do + code_team_with_config(name: 'RSpec Team') + + expect(CodeTeams.find('RSpec Team')).not_to be_nil + end + + it 'cleans up testing teams between examples' do + expect(CodeTeams::Testing.code_teams).to be_empty + expect(CodeTeams.find('RSpec Team')).to be_nil + end +end diff --git a/spec/lib/code_teams/testing_spec.rb b/spec/lib/code_teams/testing_spec.rb new file mode 100644 index 0000000..53b6e0e --- /dev/null +++ b/spec/lib/code_teams/testing_spec.rb @@ -0,0 +1,15 @@ +require 'code_teams/testing' + +CodeTeams::Testing.enable! + +RSpec.describe CodeTeams::Testing do + describe '.create_code_team' do + it 'adds the team to CodeTeams.all and CodeTeams.find' do + team = described_class.create_code_team({ name: 'Temp Team', extra_data: { foo: { bar: 1 } } }) + + expect(CodeTeams.all).to include(team) + expect(CodeTeams.find('Temp Team')).to eq(team) + expect(team.raw_hash.dig('extra_data', 'foo', 'bar')).to eq(1) + end + end +end