From f42d4e4777006d49a1ea13b1ca0e28e5592cbf6e Mon Sep 17 00:00:00 2001 From: "James Armes (they/them)" Date: Fri, 9 Jun 2023 14:26:43 -0400 Subject: [PATCH] Added unit tests with an enforced minimum coverage (#18) --- .github/config/rubocop_linter_action.yml | 1 + .github/workflows/branch.yml | 16 +++- .github/workflows/main.yml | 16 +++- .gitignore | 3 + .rspec | 4 + .rubocop.yml | 6 ++ Gemfile | 9 ++ Gemfile.lock | 67 ++++++++++++--- Rakefile | 7 +- lib/config.rb | 2 +- lib/destination.rb | 6 ++ lib/destination/base.rb | 2 +- lib/destination/jsonl.rb | 1 + lib/export.rb | 8 +- lib/filter.rb | 14 +++- lib/filter/base.rb | 7 ++ lib/filterable.rb | 14 ++++ lib/import.rb | 22 ++--- lib/senzing.rb | 2 + lib/source.rb | 6 ++ lib/source/base.rb | 2 +- lib/transformable.rb | 15 ++++ lib/transformation.rb | 19 +++-- lib/transformation/base.rb | 7 ++ .../{satic_prefix.rb => static_prefix.rb} | 0 spec/spec_helper.rb | 23 +++++ spec/support/examples.rb | 6 ++ spec/support/examples/filter.rb | 11 +++ spec/support/examples/proxy_method.rb | 32 +++++++ spec/support/examples/source.rb | 17 ++++ spec/support/examples/transform.rb | 42 ++++++++++ spec/support/factories.rb | 11 +++ spec/support/factories/config_factory.rb | 9 ++ .../factories/destination/csv_factory.rb | 18 ++++ spec/support/factories/senzing_factory.rb | 11 +++ spec/support/factories/source/csv_factory.rb | 12 +++ spec/unit/config_spec.rb | 29 +++++++ spec/unit/destination/csv_spec.rb | 42 ++++++++++ spec/unit/destination/file_spec.rb | 42 ++++++++++ spec/unit/destination/jsonl_spec.rb | 42 ++++++++++ spec/unit/destination/mongo_spec.rb | 46 ++++++++++ spec/unit/destination_spec.rb | 27 ++++++ spec/unit/export_spec.rb | 49 +++++++++++ spec/unit/filter/non_human_spec.rb | 55 ++++++++++++ spec/unit/filter/value_is_spec.rb | 38 +++++++++ spec/unit/filter_spec.rb | 55 ++++++++++++ spec/unit/import_spec.rb | 62 ++++++++++++++ spec/unit/senzing_spec.rb | 84 +++++++++++++++++++ spec/unit/source/csv_spec.rb | 25 ++++++ spec/unit/source/informix_spec.rb | 25 ++++++ spec/unit/source_spec.rb | 27 ++++++ spec/unit/transformation/split_value_spec.rb | 37 ++++++++ .../unit/transformation/static_prefix_spec.rb | 26 ++++++ spec/unit/transformation/static_value_spec.rb | 23 +++++ spec/unit/transformation_spec.rb | 56 +++++++++++++ 55 files changed, 1192 insertions(+), 46 deletions(-) create mode 100644 .rspec create mode 100644 lib/filterable.rb create mode 100644 lib/transformable.rb rename lib/transformation/{satic_prefix.rb => static_prefix.rb} (100%) create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/examples.rb create mode 100644 spec/support/examples/filter.rb create mode 100644 spec/support/examples/proxy_method.rb create mode 100644 spec/support/examples/source.rb create mode 100644 spec/support/examples/transform.rb create mode 100644 spec/support/factories.rb create mode 100644 spec/support/factories/config_factory.rb create mode 100644 spec/support/factories/destination/csv_factory.rb create mode 100644 spec/support/factories/senzing_factory.rb create mode 100644 spec/support/factories/source/csv_factory.rb create mode 100644 spec/unit/config_spec.rb create mode 100644 spec/unit/destination/csv_spec.rb create mode 100644 spec/unit/destination/file_spec.rb create mode 100644 spec/unit/destination/jsonl_spec.rb create mode 100644 spec/unit/destination/mongo_spec.rb create mode 100644 spec/unit/destination_spec.rb create mode 100644 spec/unit/export_spec.rb create mode 100644 spec/unit/filter/non_human_spec.rb create mode 100644 spec/unit/filter/value_is_spec.rb create mode 100644 spec/unit/filter_spec.rb create mode 100644 spec/unit/import_spec.rb create mode 100644 spec/unit/senzing_spec.rb create mode 100644 spec/unit/source/csv_spec.rb create mode 100644 spec/unit/source/informix_spec.rb create mode 100644 spec/unit/source_spec.rb create mode 100644 spec/unit/transformation/split_value_spec.rb create mode 100644 spec/unit/transformation/static_prefix_spec.rb create mode 100644 spec/unit/transformation/static_value_spec.rb create mode 100644 spec/unit/transformation_spec.rb diff --git a/.github/config/rubocop_linter_action.yml b/.github/config/rubocop_linter_action.yml index bf25887..249432a 100644 --- a/.github/config/rubocop_linter_action.yml +++ b/.github/config/rubocop_linter_action.yml @@ -14,6 +14,7 @@ check_name: 'RuboCop Results' versions: - rubocop: 'latest' - rubocop-rake: 'latest' + - rubocop-rspec: 'latest' # Description: RuboCop configuration file path relative to the workspace. # Valid options: A valid file path inside of the workspace. diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index d0ead59..f3c7902 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -11,8 +11,22 @@ jobs: steps: - uses: actions/checkout@v3 - - name: RuboCop Linter uses: andrewmcodes/rubocop-linter-action@v3.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + spec: + runs-on: ubuntu-latest + env: + COVERAGE: 1 + + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + # runs 'bundle install' and caches installed gems automatically + bundler-cache: true + - name: Run tests + run: bundle exec rspec diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d88193b..01f6cd8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,8 +11,22 @@ jobs: steps: - uses: actions/checkout@v3 - - name: RuboCop Linter uses: andrewmcodes/rubocop-linter-action@v3.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + spec: + runs-on: ubuntu-latest + env: + COVERAGE: 1 + + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + # runs 'bundle install' and caches installed gems automatically + bundler-cache: true + - name: Run tests + run: bundle exec rspec diff --git a/.gitignore b/.gitignore index e2fadf6..6ac918f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ vendor # Ignore import and export files. ./*.csv ./*.json + +# Ignore test outputs +coverage diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..b521ced --- /dev/null +++ b/.rspec @@ -0,0 +1,4 @@ +--require spec_helper.rb +--color +--format RSpec::Github::Formatter +--format documentation diff --git a/.rubocop.yml b/.rubocop.yml index 8dbe18b..6a8a12e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,6 @@ require: - rubocop-rake + - rubocop-rspec AllCops: NewCops: enable @@ -10,3 +11,8 @@ AllCops: Naming/MemoizedInstanceVariableName: Enabled: false + +# Favor more explicit contexts over limited nesting. +RSpec/NestedGroups: + AllowedGroups: + - context diff --git a/Gemfile b/Gemfile index 0faf261..93dc9a6 100644 --- a/Gemfile +++ b/Gemfile @@ -7,5 +7,14 @@ gemspec group :development do gem 'rake', '~> 13.0' gem 'rubocop', '~> 1.48' + gem 'rubocop-factory_bot', '~> 2.23' gem 'rubocop-rake', '~> 0.6' + gem 'rubocop-rspec', '~> 2.22' +end + +group :test do + gem 'factory_bot', '~> 6.2' + gem 'rspec', '~> 3.12' + gem 'rspec-github', '~> 2.4' + gem 'simplecov', '~> 0.22' end diff --git a/Gemfile.lock b/Gemfile.lock index 14af1c3..88f22c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,12 +14,12 @@ PATH GEM remote: https://rubygems.org/ specs: - activemodel (7.0.4.3) - activesupport (= 7.0.4.3) - activerecord (7.0.4.3) - activemodel (= 7.0.4.3) - activesupport (= 7.0.4.3) - activesupport (7.0.4.3) + activemodel (7.0.5) + activesupport (= 7.0.5) + activerecord (7.0.5) + activemodel (= 7.0.5) + activesupport (= 7.0.5) + activesupport (7.0.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -29,14 +29,18 @@ GEM ast (2.4.2) bson (4.15.0) concurrent-ruby (1.2.2) - down (5.4.0) + diff-lcs (1.5.0) + docile (1.4.0) + down (5.4.1) addressable (~> 2.8) - faraday (2.7.4) + factory_bot (6.2.1) + activesupport (>= 5.0.0) + faraday (2.7.6) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) file_exists (0.2.0) - i18n (1.13.0) + i18n (1.14.1) concurrent-ruby (~> 1.0) ibm_db (5.4.1) activerecord (< 7.1) @@ -48,14 +52,31 @@ GEM mongo (2.18.2) bson (>= 4.14.1, < 5.0.0) parallel (1.23.0) - parser (3.2.2.1) + parser (3.2.2.3) ast (~> 2.4.1) + racc public_suffix (5.0.1) + racc (1.7.0) rainbow (3.1.1) rake (13.0.6) regexp_parser (2.8.0) rexml (3.2.5) - rubocop (1.51.0) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-github (2.4.0) + rspec-core (~> 3.0) + rspec-mocks (3.12.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.0) + rubocop (1.52.0) json (~> 2.3) parallel (~> 1.10) parser (>= 3.2.0.0) @@ -65,13 +86,27 @@ GEM rubocop-ast (>= 1.28.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.1) + rubocop-ast (1.29.0) parser (>= 3.2.1.0) + rubocop-capybara (2.18.0) + rubocop (~> 1.41) + rubocop-factory_bot (2.23.1) + rubocop (~> 1.33) rubocop-rake (0.6.0) rubocop (~> 1.0) + rubocop-rspec (2.22.0) + rubocop (~> 1.33) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sequel (5.68.0) + sequel (5.69.0) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) thor (1.2.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -86,9 +121,15 @@ PLATFORMS DEPENDENCIES cmr-entity-resolution! + factory_bot (~> 6.2) rake (~> 13.0) + rspec (~> 3.12) + rspec-github (~> 2.4) rubocop (~> 1.48) + rubocop-factory_bot (~> 2.23) rubocop-rake (~> 0.6) + rubocop-rspec (~> 2.22) + simplecov (~> 0.22) BUNDLED WITH 2.4.10 diff --git a/Rakefile b/Rakefile index 140695c..444527c 100644 --- a/Rakefile +++ b/Rakefile @@ -1,9 +1,12 @@ # frozen_string_literal: true +require 'rspec/core/rake_task' require 'rubocop/rake_task' -task default: [:rubocop] +task default: %i[spec rubocop] -RuboCop::RakeTask.new do |task| +RuboCop::RakeTask.new(:rubocop) do |task| task.requires << 'rubocop' end + +RSpec::Core::RakeTask.new(:spec) diff --git a/lib/config.rb b/lib/config.rb index 983ecf5..bd38b18 100644 --- a/lib/config.rb +++ b/lib/config.rb @@ -29,7 +29,7 @@ def from_file(path) def initialize defaults - yield self + yield self if block_given? initialize_logger end diff --git a/lib/destination.rb b/lib/destination.rb index dde69cc..855bb92 100644 --- a/lib/destination.rb +++ b/lib/destination.rb @@ -6,12 +6,18 @@ # Helper methods for loading destinations. module Destination + class InvalidDestination < RuntimeError; end + # Load a destination based on the configuration file. # # @param destination_config [Hash] Destination configuration from the config # file. # @return [Destination::Base] + # + # @raise [InvalidDestination] When the destination type can not be found. def self.from_config(destination_config) Object.const_get("Destination::#{destination_config[:type]}").new(destination_config) + rescue NameError + raise InvalidDestination, "Unknown destination type #{destination_config[:type]}" end end diff --git a/lib/destination/base.rb b/lib/destination/base.rb index 7cdf638..eab4f8c 100644 --- a/lib/destination/base.rb +++ b/lib/destination/base.rb @@ -32,7 +32,7 @@ def add_record(record) # # @return [Hash] def defaults - { field_map: [] } + { field_map: {}, transformations: [] } end end end diff --git a/lib/destination/jsonl.rb b/lib/destination/jsonl.rb index 4db708f..cf838b4 100644 --- a/lib/destination/jsonl.rb +++ b/lib/destination/jsonl.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'json' require_relative 'file' module Destination diff --git a/lib/export.rb b/lib/export.rb index 3ef7377..f2f2496 100644 --- a/lib/export.rb +++ b/lib/export.rb @@ -2,10 +2,12 @@ require 'faraday' require_relative 'destination' -require_relative 'transformation' +require_relative 'transformable' # Exports data from senzing into a configured destination. class Export + include Transformable + def initialize(config) @config = config end @@ -25,12 +27,12 @@ def from_file private def process_record(record) - Transformation.transform(@config, record, destination.config[:transformations]) + record = transform(destination, record) # Map fields. output = {} @destination.config[:field_map].each do |field, map| - output[map] = record[field] + output[map.to_sym] = record[field] end output diff --git a/lib/filter.rb b/lib/filter.rb index c13ce65..5a5a618 100644 --- a/lib/filter.rb +++ b/lib/filter.rb @@ -5,11 +5,13 @@ # Filter records during processing. module Filter + class InvalidFilter < RuntimeError; end + # Pass a record through all configured filters to determine if it should be # kept. # # @param config [Config] Configuration object. - # @param record [CSV::Row] The record to filter. + # @param record [Hash] The record to filter. # @return [Boolean] Whether or not this record should be included. def self.filter(config, record) result = config.filters.all? do |filter| @@ -21,9 +23,19 @@ def self.filter(config, record) result end + # Load a filter based on the defined configuration. + # + # @param filter_config [Hash|String] The name of a filter or a configuration + # hash for one. + # @return [Filter::Base] + # + # @raise [InvalidFilter] When the filter can not be found. def self.filter_from_config(filter_config) return Object.const_get("Filter::#{filter_config}").new unless filter_config.is_a?(Hash) Object.const_get("Filter::#{filter_config[:filter]}").new(filter_config) + rescue NameError + type = filter_config.is_a?(Hash) ? filter_config[:filter] : filter_config + raise InvalidFilter, "Unknown filter type #{type}" end end diff --git a/lib/filter/base.rb b/lib/filter/base.rb index 17aaa4b..9fc6c2c 100644 --- a/lib/filter/base.rb +++ b/lib/filter/base.rb @@ -7,6 +7,13 @@ def initialize(filter_config = {}) @filter_config = defaults.merge(filter_config) end + # Returns the configuration for the current filter. + # + # @return [Hash] + def config + @filter_config + end + # Apply the filter to a record to determine if it should be kept. # # @param record [CSV::ROW] The record to apply the filter to. diff --git a/lib/filterable.rb b/lib/filterable.rb new file mode 100644 index 0000000..6bfeace --- /dev/null +++ b/lib/filterable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'filter' + +# Helper module that adds transformation support. +module Filterable + # Apply filters to a record + # + # @param record [Hash] The record to apply filters to. + # @return [Boolean] Whether or not the record should be included. + def filter(record) + Filter.filter(@config, record) + end +end diff --git a/lib/import.rb b/lib/import.rb index ac27474..b51b9f5 100644 --- a/lib/import.rb +++ b/lib/import.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true -require_relative 'filter' +require_relative 'filterable' require_relative 'senzing' require_relative 'source' -require_relative 'transformation' +require_relative 'transformable' # Imports data from a configured destination into Senzing. class Import + include Filterable + include Transformable + # Instantiate a new import object. # # @param config [Config] @@ -21,26 +24,13 @@ def import source.each do |record| next unless filter(record) - transform(source, record) - senzing.upsert_record(record) + senzing.upsert_record(transform(source, record)) end end end private - # Apply filters to a record - # - # @param record [Hash] The record to apply filters to. - # @return [Boolean] Whether or not the record should be included. - def filter(record) - Filter.filter(@config, record) - end - - def transform(source, record) - Transformation.transform(@config, record, source.config[:transformations]) - end - # Loads the Senzing client and proxies calls. # # @return [Senzing] diff --git a/lib/senzing.rb b/lib/senzing.rb index 57f69b3..1d5a5b6 100644 --- a/lib/senzing.rb +++ b/lib/senzing.rb @@ -4,6 +4,8 @@ # Client for the Senzing API class Senzing + attr_reader :config + # Instantiates a new Senzing client object. # # @param config [Config] diff --git a/lib/source.rb b/lib/source.rb index 6ed35d9..5f508c0 100644 --- a/lib/source.rb +++ b/lib/source.rb @@ -5,11 +5,17 @@ # Helper methods for loading sources. module Source + class InvalidSource < RuntimeError; end + # Load a source based on the configuration. # # @param source_config [Hash] Source configuration. # @return [Source::Base] + # + # @raise [InvalidSource] When the source type can not be found. def self.from_config(source_config) Object.const_get("Source::#{source_config[:type]}").new(source_config) + rescue NameError + raise InvalidSource, "Unknown source type #{source_config[:type]}" end end diff --git a/lib/source/base.rb b/lib/source/base.rb index d07eee0..942ccee 100644 --- a/lib/source/base.rb +++ b/lib/source/base.rb @@ -52,7 +52,7 @@ def field_mapper(field) # # @return [Hash] def defaults - { field_map: [] } + { field_map: {} } end end end diff --git a/lib/transformable.rb b/lib/transformable.rb new file mode 100644 index 0000000..2842c09 --- /dev/null +++ b/lib/transformable.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'transformation' + +# Helper module that adds transformation support. +module Transformable + # Apply transformations to a record + # + # @param context [Source::Base|Destination::Base] Context (source or destination) for the record. + # @param record [Hash] The record to be transformed. + # @return [Hash] + def transform(context, record) + Transformation.transform(@config, record, context.config[:transformations]) + end +end diff --git a/lib/transformation.rb b/lib/transformation.rb index 4a1017e..b9d995a 100644 --- a/lib/transformation.rb +++ b/lib/transformation.rb @@ -1,25 +1,27 @@ # frozen_string_literal: true -require_relative 'transformation/satic_prefix' +require_relative 'transformation/static_prefix' require_relative 'transformation/split_value' require_relative 'transformation/static_value' # Transform records during processing. module Transformation + class InvalidTransform < RuntimeError; end + # Pass a record through all configured transforms. # # @param config [Config] Configuration object. - # @param record [CSV::Row] The record to transform. + # @param record [Hash] The record to transform. # @param transformations [Array] Array of transformation configurations. - # @return [Boolean] Whether or not this record was transformed. + # @return [Hash] The resulting record after all transformations have been applied. def self.transform(config, record, transformations) - result = transformations.any? do |filter| - transform_from_config(filter).transform(record) + result = transformations.any? do |transformation| + transform_from_config(transformation).transform(record) end config.logger.info("Transformations applied to record #{record[:RECORD_ID]}") if result - result + record end # Load a transformation based on the defined configuration. @@ -27,9 +29,14 @@ def self.transform(config, record, transformations) # @param transform_config [Hash|String] The name of a transformation or a # configuration hash for one. # @return [Transformation::Base] + # + # @raise [InvalidTransform] When the transformation can not be found. def self.transform_from_config(transform_config) return Object.const_get("Transformation::#{transform_config}").new unless transform_config.is_a?(Hash) Object.const_get("Transformation::#{transform_config[:transform]}").new(transform_config) + rescue NameError + type = transform_config.is_a?(Hash) ? transform_config[:transform] : filter_config + raise InvalidTransform, "Unknown transformation type #{type}" end end diff --git a/lib/transformation/base.rb b/lib/transformation/base.rb index fb5f0ef..be11d5d 100644 --- a/lib/transformation/base.rb +++ b/lib/transformation/base.rb @@ -7,6 +7,13 @@ def initialize(transform_config = {}) @transform_config = defaults.merge(transform_config) end + # Returns the configuration for the current transformation. + # + # @return [Hash] + def config + @transform_config + end + # Apply the transformation to a record to determine. # # @param record [CSV::ROW] The record to transform. diff --git a/lib/transformation/satic_prefix.rb b/lib/transformation/static_prefix.rb similarity index 100% rename from lib/transformation/satic_prefix.rb rename to lib/transformation/static_prefix.rb diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..279fd7b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'factory_bot' + +# Configure code coverage reporting. +if ENV['COVERAGE'] + require 'simplecov' + + SimpleCov.minimum_coverage 95 + SimpleCov.start do + add_filter '/spec/' + + track_files 'lib/**/*.rb' + end +end + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end + +# Include shared examples and factories. +require_relative 'support/examples' +require_relative 'support/factories' diff --git a/spec/support/examples.rb b/spec/support/examples.rb new file mode 100644 index 0000000..651e4c7 --- /dev/null +++ b/spec/support/examples.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative 'examples/filter' +require_relative 'examples/proxy_method' +require_relative 'examples/source' +require_relative 'examples/transform' diff --git a/spec/support/examples/filter.rb b/spec/support/examples/filter.rb new file mode 100644 index 0000000..73cfd8a --- /dev/null +++ b/spec/support/examples/filter.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'filter' do |result| + subject(:filter) do + defined?(filter_config) ? described_class.new(filter_config) : described_class.new + end + + it "returns #{result}" do + expect(filter.filter(record)).to be(result) + end +end diff --git a/spec/support/examples/proxy_method.rb b/spec/support/examples/proxy_method.rb new file mode 100644 index 0000000..decd2f6 --- /dev/null +++ b/spec/support/examples/proxy_method.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'proxy method' do |method, klass| + before do + allow(klass).to receive(:new).and_return(object) + end + + context 'when object has not been created' do + before { subject.instance_variable_set("@#{method}", nil) } + + it 'returns a new object' do + expect(subject.send(method)).to eq(object) + end + + it 'instantiates a new object' do + subject.send(method) + expect(klass).to have_received(:new) + end + end + + context 'when object has been created' do + before { subject.instance_variable_set("@#{method}", object) } + + it 'returns the previously set object' do + expect(subject.send(method)).to eq(object) + end + + it 'does not try to create a new object' do + expect(klass).not_to have_received(:new) + end + end +end diff --git a/spec/support/examples/source.rb b/spec/support/examples/source.rb new file mode 100644 index 0000000..f0e7740 --- /dev/null +++ b/spec/support/examples/source.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'source' do + subject(:source) { described_class.new(source_config) } + + let(:rows) do + [ + { record_id: '1', first_name: 'Shredward', last_name: 'Whiskers' }, + { record_id: '2', first_name: 'Timmy', last_name: 'Tester' }, + { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson' } + ] + end + + it 'yields expected records' do + expect { |probe| source.each(&probe) }.to yield_successive_args(*rows) + end +end diff --git a/spec/support/examples/transform.rb b/spec/support/examples/transform.rb new file mode 100644 index 0000000..ac5a742 --- /dev/null +++ b/spec/support/examples/transform.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'transform' do |modified: true, unmodified: true| + subject(:transform) do + defined?(transform_config) ? described_class.new(transform_config) : described_class.new + end + + if modified + context 'when the transformation conditions are met' do + it 'transforms the record' do + transform.transform(record) + expect(record).to eq(expected) + end + end + end + + if unmodified + context 'when the transformation conditions are not met' do + subject(:transform) do + local_config.nil? ? described_class.new : described_class.new(local_config) + end + + let(:local_record) { defined?(unmatched_record) ? unmatched_record : record } + let(:local_config) do + if defined?(unmatched_config) + unmatched_config + elsif defined?(transform_config) + transform_config + end + end + + before do + raise 'Unmatched config or record must be set' unless defined?(unmatched_config) || defined?(unmatched_record) + end + + it 'does not transform the record' do + transform.transform(local_record) + expect(local_record).to eq(local_record) # rubocop:disable RSpec/IdenticalEqualityAssertion + end + end + end +end diff --git a/spec/support/factories.rb b/spec/support/factories.rb new file mode 100644 index 0000000..e987ac4 --- /dev/null +++ b/spec/support/factories.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Allow rspec mocks to be used by factories. +FactoryBot::SyntaxRunner.class_eval do + include RSpec::Mocks::ExampleMethods +end + +require_relative 'factories/config_factory' +require_relative 'factories/senzing_factory' +require_relative 'factories/destination/csv_factory' +require_relative 'factories/source/csv_factory' diff --git a/spec/support/factories/config_factory.rb b/spec/support/factories/config_factory.rb new file mode 100644 index 0000000..b08f2e7 --- /dev/null +++ b/spec/support/factories/config_factory.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :config, class: 'Config' do + sources { [{ type: 'CSV' }] } + destination { { type: 'CSV' } } + filters { [{ filter: 'NonHuman' }] } + end +end diff --git a/spec/support/factories/destination/csv_factory.rb b/spec/support/factories/destination/csv_factory.rb new file mode 100644 index 0000000..37c8f49 --- /dev/null +++ b/spec/support/factories/destination/csv_factory.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :destination_csv, class: 'Destination::CSV' do + initialize_with do + new(field_map: { + ENTITY_ID: 'entity_id', + RECORD_ID: 'record_id', + PRIMARY_NAME_FIRST: 'first_name', + PRIMARY_NAME_LAST: 'last_name' + }) + end + + after(:build) do |factory| + allow(factory).to receive(:add_record) + end + end +end diff --git a/spec/support/factories/senzing_factory.rb b/spec/support/factories/senzing_factory.rb new file mode 100644 index 0000000..c1d44a2 --- /dev/null +++ b/spec/support/factories/senzing_factory.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :senzing, class: 'Senzing' do + initialize_with { new(build(:config)) } + + after(:build) do |factory| + allow(factory).to receive(:upsert_record) + end + end +end diff --git a/spec/support/factories/source/csv_factory.rb b/spec/support/factories/source/csv_factory.rb new file mode 100644 index 0000000..7a46560 --- /dev/null +++ b/spec/support/factories/source/csv_factory.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :source_csv, class: 'Source::CSV' do + initialize_with { new({ field_map: [] }) } + + after(:build) do |factory| + record = { id: 1, first_name: 'Shredward', last_name: 'Whiskers' } + allow(factory).to receive(:each).and_yield(record) + end + end +end diff --git a/spec/unit/config_spec.rb b/spec/unit/config_spec.rb new file mode 100644 index 0000000..8080164 --- /dev/null +++ b/spec/unit/config_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' + +RSpec.describe Config do + describe '#initialize' do + it 'can be initialized without a block' do + expect(described_class.new).to be_a(described_class) + end + + it 'can be initialized with a block' do + expect { |probe| described_class.new(&probe) }.to yield_with_args(described_class) + end + end + + describe '.from_file' do + subject(:from_file) { described_class.from_file(path) } + + let(:path) { File.join(__dir__, '../../config/config.sample.yml') } + + it 'can be loaded from a file' do + expect(from_file).to be_a(described_class) + end + + it 'loads expected options' do + expect(from_file.sources.first[:type]).to eq('CSV') + end + end +end diff --git a/spec/unit/destination/csv_spec.rb b/spec/unit/destination/csv_spec.rb new file mode 100644 index 0000000..eded7f0 --- /dev/null +++ b/spec/unit/destination/csv_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative '../../../lib/destination/csv' + +describe Destination::CSV do + # Since we're using an actual CSV object we can't use message spies so disable + # this cop. + # rubocop:disable RSpec/MessageSpies + describe '#add_record' do + subject(:destination) { described_class.new(destination_config) } + + let(:destination_config) do + { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] } + end + let(:records) do + [ + { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' }, + { record_id: 2, first_name: 'Timmy', last_name: 'Tester' }, + { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson', + address: { street: '123 street road' } } + ] + end + let(:csv) { CSV.new(StringIO.new, write_headers: true, headers: destination_config[:headers]) } + + before do + allow(CSV).to receive(:open).and_return(csv) + end + + it 'writes the record to the file' do + expect(csv).to receive(:puts) + + destination.add_record(records[0]) + end + + it 'properly formats the record' do + expect(csv).to receive(:puts).with(['C-40', 'Bobbert', '"Bobby" Bobberson', '123 street road']) + + destination.add_record(records[2]) + end + end + # rubocop:enable RSpec/MessageSpies +end diff --git a/spec/unit/destination/file_spec.rb b/spec/unit/destination/file_spec.rb new file mode 100644 index 0000000..f6db126 --- /dev/null +++ b/spec/unit/destination/file_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative '../../../lib/destination/file' + +describe Destination::File do + # Since we're using an actual StringIO object we can't use message spies so + # disable this cop. + # rubocop:disable RSpec/MessageSpies + describe '#add_record' do + subject(:destination) { described_class.new(destination_config) } + + let(:destination_config) do + { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] } + end + let(:records) do + [ + { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' }, + { record_id: 2, first_name: 'Timmy', last_name: 'Tester' }, + { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson', + address: { street: '123 street road' } } + ] + end + let(:file) { StringIO.new } + + before do + allow(File).to receive(:open).and_return(file) + end + + it 'writes the record to the file' do + expect(file).to receive(:puts) + + destination.add_record(records[0]) + end + + it 'properly formats the record' do + expect(file).to receive(:puts).with(records[2].to_s) + + destination.add_record(records[2]) + end + end + # rubocop:enable RSpec/MessageSpies +end diff --git a/spec/unit/destination/jsonl_spec.rb b/spec/unit/destination/jsonl_spec.rb new file mode 100644 index 0000000..b0c588b --- /dev/null +++ b/spec/unit/destination/jsonl_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative '../../../lib/destination/jsonl' + +describe Destination::JSONL do + # Since we're using an actual StringIO object we can't use message spies so + # disable this cop. + # rubocop:disable RSpec/MessageSpies + describe '#add_record' do + subject(:destination) { described_class.new(destination_config) } + + let(:destination_config) do + { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] } + end + let(:records) do + [ + { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' }, + { record_id: 2, first_name: 'Timmy', last_name: 'Tester' }, + { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson', + address: { street: '123 street road' } } + ] + end + let(:file) { StringIO.new } + + before do + allow(File).to receive(:open).and_return(file) + end + + it 'writes the record to the file' do + expect(file).to receive(:puts) + + destination.add_record(records[0]) + end + + it 'properly formats the record' do + expect(file).to receive(:puts).with(records[2].to_json) + + destination.add_record(records[2]) + end + end + # rubocop:enable RSpec/MessageSpies +end diff --git a/spec/unit/destination/mongo_spec.rb b/spec/unit/destination/mongo_spec.rb new file mode 100644 index 0000000..2bd948e --- /dev/null +++ b/spec/unit/destination/mongo_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative '../../../lib/destination/mongo' + +describe Destination::Mongo do + describe '#add_record' do + subject(:destination) { described_class.new(destination_config) } + + let(:destination_config) do + { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] } + end + let(:records) do + [ + { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' }, + { record_id: 2, first_name: 'Timmy', last_name: 'Tester' }, + { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson', + address: { street: '123 street road' } } + ] + end + let(:client) do + instance_double(Mongo::Client).tap do |client| + allow(client).to receive(:use).and_return(client) + allow(client).to receive(:[]).and_return(collection) + end + end + let(:collection) do + instance_double(Mongo::Collection).tap do |collection| + allow(collection).to receive(:insert_one) + end + end + + before do + allow(Mongo::Client).to receive(:new).and_return(client) + end + + it 'writes the record to the database' do + destination.add_record(records[0]) + expect(collection).to have_received(:insert_one) + end + + it 'properly formats the record' do + destination.add_record(records[2]) + expect(collection).to have_received(:insert_one).with(records[2]) + end + end +end diff --git a/spec/unit/destination_spec.rb b/spec/unit/destination_spec.rb new file mode 100644 index 0000000..753bd1c --- /dev/null +++ b/spec/unit/destination_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/destination' + +RSpec.describe Destination do + let(:config) { build(:config, destination: destination_config) } + let(:destination_config) { { type: 'CSV' } } + + describe '.from_config' do + subject(:concrete) { described_class.from_config(config.destination) } + + context 'when a valid destination type is provided' do + it 'returns the proper destination' do + expect(concrete).to be_a(Destination::CSV) + end + end + + context 'when an invalid destination type is provided' do + let(:destination_config) { { type: 'Invalid' } } + + it 'raises an exception' do + expect { concrete }.to raise_error(Destination::InvalidDestination) + end + end + end +end diff --git a/spec/unit/export_spec.rb b/spec/unit/export_spec.rb new file mode 100644 index 0000000..af41a2a --- /dev/null +++ b/spec/unit/export_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/export' + +RSpec.describe Export do + subject(:export) { described_class.new(config) } + + let(:config) { build(:config) } + let(:destination) { build(:destination_csv) } + + describe '#from_file' do + let(:record) { { RECORD_ID: 1, PRIMARY_NAME_FIRST: 'Shredward', PRIMARY_NAME_LAST: 'Whiskers' } } + let(:mapped) { { entity_id: 2, first_name: 'Shredward', last_name: 'Whiskers', record_id: 1 } } + let(:entity) do + { + RESOLVED_ENTITY: { + ENTITY_ID: 2, + RECORDS: [record] + } + }.to_json + end + + before do + allow(File).to receive(:readlines).and_return([entity]) + allow(Destination::CSV).to receive(:new).and_return(destination) + allow(Transformation).to receive(:transform).and_return(record.merge(ENTITY_ID: 2)) + end + + context 'when the record has not been transformed' do + it 'sends the unmodified record to the destination' do + export.from_file + + expect(destination).to have_received(:add_record).with(mapped) + end + end + + context 'when the record has been transformed' do + let(:record) { super().merge(PRIMARY_NAME_FIRST: 'Bobert') } + let(:mapped) { super().merge(first_name: 'Bobert') } + + it 'sends the modified record to the destination' do + export.from_file + + expect(destination).to have_received(:add_record).with(mapped) + end + end + end +end diff --git a/spec/unit/filter/non_human_spec.rb b/spec/unit/filter/non_human_spec.rb new file mode 100644 index 0000000..6b962e4 --- /dev/null +++ b/spec/unit/filter/non_human_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative '../../../lib/filter/non_human' + +describe Filter::NonHuman do + subject(:filter) { described_class.new } + + let(:record) do + { record_id: 1, PRIMARY_NAME_FIRST: 'Shredward', PRIMARY_NAME_MIDDLE: 'Shreddy', PRIMARY_NAME_LAST: 'Whiskers' } + end + + describe '#filter' do + context 'when the name does not match any terms' do + include_examples 'filter', true + end + + context 'when the name matches an anywhere term' do + [ + { PRIMARY_NAME_FIRST: 'Shredward AND ASSOC' }, + { PRIMARY_NAME_MIDDLE: 'SPORTS & ENT Shreddy' }, + { PRIMARY_NAME_LAST: 'Whiskers "EXPUNGED" Seal' } + ].each do |updates| + include_examples 'filter', false do + let(:record) { super().merge(updates) } + end + end + end + + context 'when the name matches a beginning term' do + let(:record) { super().merge(PRIMARY_NAME_FIRST: 'REGISTER AGENT Shredward') } + + include_examples 'filter', false + end + + context 'when the name matches an ending term' do + let(:record) { super().merge(PRIMARY_NAME_LAST: 'Whiskers AUTO DADDY') } + + include_examples 'filter', false + end + + context 'when the name matches an exact term' do + let(:record) do + super().merge(PRIMARY_NAME_FIRST: 'EXPUNGED', PRIMARY_NAME_MIDDLE: 'DEFENDANT', PRIMARY_NAME_LAST: 'RECORD') + end + + include_examples 'filter', false + end + + context 'when the name matches a middle term' do + let(:record) { super().merge(PRIMARY_NAME_MIDDLE: 'Shreddy FOUNDATION') } + + include_examples 'filter', false + end + end +end diff --git a/spec/unit/filter/value_is_spec.rb b/spec/unit/filter/value_is_spec.rb new file mode 100644 index 0000000..5e1cbac --- /dev/null +++ b/spec/unit/filter/value_is_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative '../../../lib/filter/value_is' + +describe Filter::ValueIs do + subject(:filter) { described_class.new(filter_config) } + + let(:filter_config) { { field: 'test_field', value: 'test value' } } + let(:record) { { record_id: 1, test_field: 'test value' } } + + describe '#filter' do + context 'when the operator is not reversed' do + context 'when the value matches' do + include_examples 'filter', true + end + + context 'when the value does not match' do + let(:filter_config) { super().merge(value: 'bad value') } + + include_examples 'filter', false + end + end + + context 'when the operator is reversed' do + let(:filter_config) { super().merge(inverse: true) } + + context 'when the value matches' do + include_examples 'filter', false + end + + context 'when the value does not match' do + let(:filter_config) { super().merge(value: 'bad value') } + + include_examples 'filter', true + end + end + end +end diff --git a/spec/unit/filter_spec.rb b/spec/unit/filter_spec.rb new file mode 100644 index 0000000..2be5de4 --- /dev/null +++ b/spec/unit/filter_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/filter' + +describe Filter do + let(:config) { build(:config, filters: filter_config) } + let(:record) { {} } + let(:filter_config) { [{ filter: 'ValueIs' }] } + + describe '.filter' do + subject(:result) { described_class.filter(config, record) } + + let(:filter) { instance_double(Filter::ValueIs) } + + before do + allow(described_class).to receive(:filter_from_config).and_return(filter) + allow(filter).to receive(:filter).and_return(!filtered) + end + + context 'when the record is filtered out' do + let(:filtered) { true } + + it 'returns false' do + expect(result).to be(false) + end + end + + context 'when the record is not filtered out' do + let(:filtered) { false } + + it 'returns true' do + expect(result).to be(true) + end + end + end + + describe '.filter_from_config' do + subject(:concrete) { described_class.filter_from_config(filter_config.first) } + + context 'when a valid filter type is provided' do + it 'returns the proper filter' do + expect(concrete).to be_a(Filter::ValueIs) + end + end + + context 'when an invalid filter type is provided' do + let(:filter_config) { [{ filter: 'Invalid' }] } + + it 'raises an exception' do + expect { concrete }.to raise_error(Filter::InvalidFilter) + end + end + end +end diff --git a/spec/unit/import_spec.rb b/spec/unit/import_spec.rb new file mode 100644 index 0000000..b29bf7b --- /dev/null +++ b/spec/unit/import_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/import' + +RSpec.describe Import do + subject(:import) { described_class.new(config) } + + let(:config) { build(:config) } + let(:senzing) { build(:senzing) } + + describe '#import' do + let(:source) { build(:source_csv) } + let(:senzing) { build(:senzing) } + let(:record) { { id: 1, first_name: 'Shredward', last_name: 'Whiskers' } } + + before do + allow(Source::CSV).to receive(:new).and_return(source) + allow(Senzing).to receive(:new).and_return(senzing) + allow(Filter).to receive(:filter).and_return(included) + allow(Transformation).to receive(:transform).and_return(record) + end + + context 'when the record is not filtered out' do + let(:included) { true } + + context 'when the record has not been transformed' do + it 'sends the unmodified record to senzing' do + import.import + + expect(senzing).to have_received(:upsert_record).with(record) + end + end + + context 'when the record has been transformed' do + let(:record) { super().merge(test_field: 'test value') } + + it 'sends the modified record to senzing' do + import.import + + expect(senzing).to have_received(:upsert_record).with(record) + end + end + end + + context 'when the record is filtered out' do + let(:included) { false } + + it 'does not send the record to senzing' do + import.import + + expect(senzing).not_to have_received(:upsert_record) + end + end + end + + describe '#senzing' do + include_examples 'proxy method', :senzing, Senzing do + let(:object) { senzing } + end + end +end diff --git a/spec/unit/senzing_spec.rb b/spec/unit/senzing_spec.rb new file mode 100644 index 0000000..6a4f2df --- /dev/null +++ b/spec/unit/senzing_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/senzing' + +RSpec.describe Senzing do + subject(:senzing) { described_class.new(config) } + + let(:senzing_config) { { data_source: 'RSPEC', tls: false } } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:client) do + Faraday.new { |b| b.adapter(:test, stubs) } + end + + let(:config) do + build(:config, senzing: senzing_config) + end + + before do + allow(Faraday).to receive(:new).and_return(client).and_yield(client) + end + + describe '#initialize' do + it 'applies defaults to the config' do + expect(senzing.config.senzing[:host]).to eq('localhost') + end + + it 'does not override configs with defaults' do + expect(senzing.config.senzing[:data_source]).to eq('RSPEC') + end + end + + # Since we're using an actual Faraday object we can't use message spies so + # disable this cop. + # rubocop:disable RSpec/MessageSpies + describe '#upsert_record' do + let(:record) { { RECORD_ID: '1234' } } + + it 'writes the records to Senzing' do + stubs.put('/data-sources/RSPEC/records/1234') { [200, {}, ''] } + expect(client).to receive(:put).and_call_original + + senzing.upsert_record(record) + stubs.verify_stubbed_calls + end + end + # rubocop:enable RSpec/MessageSpies + + describe '#client' do + include_examples 'proxy method', :client, Faraday do + let(:object) { client } + end + end + + describe '#url' do + subject(:url) { URI(senzing.send(:url)) } + + let(:senzing_config) { super().merge(host: 'rspec', port: 1234) } + + it 'returns the expected host' do + expect(url.host).to eq(senzing_config[:host]) + end + + it 'returns the expected port' do + expect(url.port).to eq(senzing_config[:port]) + end + + context 'when TLS is enabled' do + let(:senzing_config) { super().merge(tls: true) } + + it 'returns the expected scheme' do + expect(url.scheme).to eq('https') + end + end + + context 'when TLS is disabled' do + let(:senzing_config) { super().merge(tls: false) } + + it 'returns the expected scheme' do + expect(url.scheme).to eq('http') + end + end + end +end diff --git a/spec/unit/source/csv_spec.rb b/spec/unit/source/csv_spec.rb new file mode 100644 index 0000000..24be160 --- /dev/null +++ b/spec/unit/source/csv_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative '../../../lib/source/csv' + +describe Source::CSV do + describe '#each' do + let(:source_config) { { parth: '/rspec/test.csv' } } + let(:headers) { %w[record_id first_name last_name] } + + include_examples 'source' do + before do + # Generate a CSV object instead of reading form the file system. + contents = CSV.generate do |csv| + csv << CSV::Row.new(headers, headers, true) + rows.each do |row| + csv << CSV::Row.new(row.keys, row.values) + end + end + + csv = CSV.new(contents, headers: true) + allow(CSV).to receive(:open).and_return(csv) + end + end + end +end diff --git a/spec/unit/source/informix_spec.rb b/spec/unit/source/informix_spec.rb new file mode 100644 index 0000000..557124d --- /dev/null +++ b/spec/unit/source/informix_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative '../../../lib/source/informix' + +describe Source::Informix do + describe '#each' do + let(:source_config) do + { + table: 'rspec', + host: 'rspec.com', + database: 'rspec', + user: 'swhiskers', + password: 'clearmyrecrod', + schema: 'aoc', + security: false + } + end + + include_examples 'source' do + before do + allow(Sequel).to receive(:connect).and_return(Sequel.mock(fetch: rows)) + end + end + end +end diff --git a/spec/unit/source_spec.rb b/spec/unit/source_spec.rb new file mode 100644 index 0000000..be91dd1 --- /dev/null +++ b/spec/unit/source_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/source' + +RSpec.describe Source do + let(:config) { build(:config, sources: source_config) } + let(:source_config) { [{ type: 'CSV' }] } + + describe '.from_config' do + subject(:concrete) { described_class.from_config(config.sources.first) } + + context 'when a valid source type is provided' do + it 'returns the proper source' do + expect(concrete).to be_a(Source::CSV) + end + end + + context 'when an invalid source type is provided' do + let(:source_config) { [{ type: 'Invalid' }] } + + it 'raises an exception' do + expect { concrete }.to raise_error(Source::InvalidSource) + end + end + end +end diff --git a/spec/unit/transformation/split_value_spec.rb b/spec/unit/transformation/split_value_spec.rb new file mode 100644 index 0000000..faee9fd --- /dev/null +++ b/spec/unit/transformation/split_value_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative '../../../lib/transformation/split_value' + +describe Transformation::SplitValue do + subject(:transform) { described_class.new(transform_config) } + + let(:record) { { record_id: 1, test_field: 'test,value,suffix' } } + let(:transform_config) { { field: 'test_field', parts: { 1 => 'new_field' } } } + let(:expected) { record.merge(new_field: 'value') } + + describe '#transform' do + context 'when the delimiter is not specified' do + include_examples 'transform' do + let(:unmatched_record) { record.merge(test_field: 'test-value-suffix') } + end + end + + context 'when a multi-character delimiter is specified' do + let(:record) { super().merge(test_field: 'test|@|value|@|suffix') } + let(:transform_config) { super().merge(delimiter: '|@|') } + + include_examples 'transform' do + let(:unmatched_config) { transform_config.merge(delimiter: ':') } + end + end + + context 'when a special character delimiter is specified' do + let(:record) { super().merge(test_field: "test\tvalue\tsuffix") } + let(:transform_config) { super().merge(delimiter: "\t") } + + include_examples 'transform' do + let(:unmatched_config) { transform_config.merge(delimiter: "\n") } + end + end + end +end diff --git a/spec/unit/transformation/static_prefix_spec.rb b/spec/unit/transformation/static_prefix_spec.rb new file mode 100644 index 0000000..0109437 --- /dev/null +++ b/spec/unit/transformation/static_prefix_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative '../../../lib/transformation/static_prefix' + +describe Transformation::StaticPrefix do + subject(:transform) { described_class.new(transform_config) } + + let(:record) { { record_id: 1, test_field: 'test value', empty_field: '' } } + let(:transform_config) { { field: 'test_field', prefix: 'RSPEC' } } + let(:expected) { record.merge(test_field: 'RSPECtest value') } + + describe '#transform' do + context 'when configured to apply to non-empty fields only' do + include_examples 'transform' do + let(:unmatched_config) { transform_config.merge(field: 'empty_field') } + end + end + + context 'when configured to apply to empty fields as well' do + let(:transform_config) { super().merge(field: 'empty_field', if_not_empty: false) } + let(:expected) { record.merge(empty_field: 'RSPEC') } + + include_examples 'transform', unmodified: false + end + end +end diff --git a/spec/unit/transformation/static_value_spec.rb b/spec/unit/transformation/static_value_spec.rb new file mode 100644 index 0000000..44bf778 --- /dev/null +++ b/spec/unit/transformation/static_value_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative '../../../lib/transformation/static_value' + +describe Transformation::StaticValue do + subject(:transform) { described_class.new(transform_config) } + + let(:record) { { record_id: 1 } } + let(:transform_config) { { field: 'new_field', value: 'test value' } } + let(:expected) { record.merge(new_field: 'test value') } + + describe '#transform' do + context 'when the configured field does not exist' do + include_examples 'transform', unmodified: false + end + + context 'when the configured field exists' do + let(:record) { super().merge(new_field: 'existing value') } + + include_examples 'transform', unmodified: false + end + end +end diff --git a/spec/unit/transformation_spec.rb b/spec/unit/transformation_spec.rb new file mode 100644 index 0000000..7231e55 --- /dev/null +++ b/spec/unit/transformation_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative '../../lib/config' +require_relative '../../lib/transformation' + +describe Transformation do + let(:config) { build(:config) } + let(:record) { { record_id: 1 } } + let(:transform_config) { [{ transform: 'StaticValue' }] } + + describe '.transform' do + subject(:result) { described_class.transform(config, record, transform_config) } + + let(:transform) { instance_double(Transformation::StaticValue) } + let(:transformed_record) { record } + + before do + allow(described_class).to receive(:transform_from_config).and_return(transform) + allow(transform).to receive(:transform).with(record) do |record| + record.merge!(transformed_record) + end + end + + context 'when the record is transformed' do + let(:transformed_record) { record.merge({ test_field: 'test_value' }) } + + it 'returns the modified record' do + expect(result).to eq(transformed_record) + end + end + + context 'when the record is not transformed' do + it 'returns the unmodified record' do + expect(result).to eq(record) + end + end + end + + describe '.transform_from_config' do + subject(:concrete) { described_class.transform_from_config(transform_config.first) } + + context 'when a valid transform type is provided' do + it 'returns the proper transformer' do + expect(concrete).to be_a(Transformation::StaticValue) + end + end + + context 'when an invalid transform type is provided' do + let(:transform_config) { [{ transform: 'Invalid' }] } + + it 'raises an exception' do + expect { concrete }.to raise_error(Transformation::InvalidTransform) + end + end + end +end