diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 7cc9e62e..268abc40 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class SubmitFormController < ApplicationController - include PrefillFieldsHelper - layout 'form' around_action :with_browser_locale, only: %i[show completed success] diff --git a/app/helpers/prefill_fields_helper.rb b/app/helpers/prefill_fields_helper.rb index 439d5117..011baec9 100644 --- a/app/helpers/prefill_fields_helper.rb +++ b/app/helpers/prefill_fields_helper.rb @@ -1,15 +1,6 @@ # frozen_string_literal: true module PrefillFieldsHelper - # Cache TTL for ATS field parsing (1 hour) - ATS_FIELDS_CACHE_TTL = 1.hour - - # Maximum number of cached entries to prevent memory bloat - MAX_CACHE_ENTRIES = 1000 - - # Cache TTL for field UUID lookup optimization (30 minutes) - FIELD_LOOKUP_CACHE_TTL = 30.minutes - # Extracts and validates ATS prefill field names from Base64-encoded parameters # # This method decodes the ats_fields parameter, validates the field names against @@ -22,25 +13,7 @@ module PrefillFieldsHelper # extract_ats_prefill_fields # # => ['employee_first_name', 'employee_email'] def extract_ats_prefill_fields - return [] if params[:ats_fields].blank? - - cache_key = ats_fields_cache_key(params[:ats_fields]) - - # Try to get from cache first - cached_result = read_from_cache(cache_key) - return cached_result if cached_result - - # Parse and validate the ATS fields - field_names = parse_ats_fields_param(params[:ats_fields]) - return cache_and_return_empty(cache_key) if field_names.nil? - - # Validate and filter field names - valid_fields = validate_and_filter_field_names(field_names) - return cache_and_return_empty(cache_key) if valid_fields.nil? - - # Cache and return the valid fields - cache_result(cache_key, valid_fields, ATS_FIELDS_CACHE_TTL) - valid_fields + AtsPrefill.extract_fields(params) end # Merges ATS prefill values with existing submitter values @@ -66,23 +39,23 @@ module PrefillFieldsHelper # # => { 'field-uuid-1' => 'John', 'field-uuid-2' => 'Doe' } # # Note: 'John' is preserved over 'Jane' because submitter value takes precedence def merge_ats_prefill_values(submitter_values, ats_values, template_fields = nil) - return submitter_values if ats_values.blank? - - # Build optimized lookup cache for better performance with large field sets - field_lookup = build_field_lookup_cache(template_fields) - - # Only use ATS values for fields that don't already have submitter values - ats_values.each do |field_name, value| - # Use cached lookup for better performance - matching_field_uuid = field_lookup[field_name] - - next if matching_field_uuid.nil? - - # Only set if submitter hasn't already filled this field - submitter_values[matching_field_uuid] = value if submitter_values[matching_field_uuid].blank? - end + AtsPrefill.merge_values(submitter_values, ats_values, template_fields) + end - submitter_values + # Finds field UUID by matching ATS field name to template field's prefill attribute + # + # This method provides backward compatibility and is now optimized to use + # the cached lookup when possible. + # + # @param field_name [String] ATS field name to look up + # @param template_fields [Array, nil] Template field definitions + # @return [String, nil] Field UUID if found, nil otherwise + # + # @example + # find_field_uuid_by_name('employee_first_name', template_fields) + # # => 'field-uuid-123' + def find_field_uuid_by_name(field_name, template_fields = nil) + AtsPrefill.find_field_uuid(field_name, template_fields) end # Clears ATS fields cache (useful for testing or manual cache invalidation) @@ -93,143 +66,54 @@ module PrefillFieldsHelper # # @return [void] def clear_ats_fields_cache - # Since we can't easily enumerate cache keys, we'll rely on TTL for cleanup - # This method is provided for potential future use or testing + AtsPrefill.clear_cache end + # Legacy method aliases for backward compatibility + alias build_field_lookup_cache merge_ats_prefill_values + private - # Safely reads from cache with error handling - # - # @param cache_key [String] The cache key to read from - # @return [Object, nil] Cached value if found and readable, nil otherwise + # Legacy private methods maintained for any potential direct usage + # These now delegate to the service layer for consistency + def read_from_cache(cache_key) - Rails.cache.read(cache_key) - rescue StandardError - # Return nil if cache read fails, allowing normal processing to continue - nil + AtsPrefill::CacheManager.read_from_cache(cache_key) end - # Parses and decodes the ATS fields parameter - # - # @param ats_fields_param [String] Base64-encoded JSON string containing field names - # @return [Array, nil] Array of field names if parsing succeeds, nil on error def parse_ats_fields_param(ats_fields_param) - decoded_json = Base64.urlsafe_decode64(ats_fields_param) - JSON.parse(decoded_json) - rescue StandardError - # Return nil if Base64 decoding or JSON parsing fails - nil + # This is now handled internally by FieldExtractor + # Kept for backward compatibility but not recommended for direct use + AtsPrefill::FieldExtractor.send(:parse_encoded_fields, ats_fields_param) end - # Validates and filters field names to only include allowed patterns - # - # @param field_names [Array] Array of field names to validate - # @return [Array, nil] Array of valid field names, nil if input is invalid def validate_and_filter_field_names(field_names) - # Validate that we got an array of strings - return nil unless field_names.is_a?(Array) && field_names.all?(String) - - # Filter to only expected field name patterns - field_names.select { |name| valid_ats_field_name?(name) } + # This is now handled internally by FieldExtractor + # Kept for backward compatibility but not recommended for direct use + AtsPrefill::FieldExtractor.send(:validate_field_names, field_names) end def valid_ats_field_name?(name) - # Only allow expected field name patterns (security) - name.match?(/\A(employee|manager|account|location)_[a-z_]+\z/) + # This is now handled internally by FieldExtractor + # Kept for backward compatibility but not recommended for direct use + AtsPrefill::FieldExtractor.send(:valid_ats_field_name?, name) end def ats_fields_cache_key(ats_fields_param) - # Create secure cache key using SHA256 hash of the parameter - # This prevents cache key collisions and keeps keys reasonably sized - hash = Digest::SHA256.hexdigest(ats_fields_param) - "ats_fields:#{hash}" + AtsPrefill::CacheManager.generate_cache_key('ats_fields', ats_fields_param) end def cache_result(cache_key, value, ttl) - Rails.cache.write(cache_key, value, expires_in: ttl) - rescue StandardError - # Continue execution even if caching fails + AtsPrefill::CacheManager.write_to_cache(cache_key, value, ttl) end def cache_and_return_empty(cache_key) - cache_result(cache_key, [], 5.minutes) + cache_result(cache_key, [], 300) # 5 minutes [] end - # Builds an optimized lookup cache for field UUID resolution - # - # Creates a hash mapping ATS field names to template field UUIDs for O(1) lookup - # performance instead of O(n) linear search. Results are cached to improve - # performance across multiple requests. - # - # @param template_fields [Array, nil] Template field definitions - # @return [Hash] Mapping of ATS field names to field UUIDs - # - # @example - # template_fields = [ - # { 'uuid' => 'field-1', 'prefill' => 'employee_first_name' }, - # { 'uuid' => 'field-2', 'prefill' => 'employee_last_name' } - # ] - # build_field_lookup_cache(template_fields) - # # => { 'employee_first_name' => 'field-1', 'employee_last_name' => 'field-2' } - def build_field_lookup_cache(template_fields) - return {} if template_fields.blank? - - # Create cache key based on template fields structure - cache_key = field_lookup_cache_key(template_fields) - - # Try to get from cache first - begin - cached_lookup = Rails.cache.read(cache_key) - return cached_lookup if cached_lookup - rescue StandardError - # Continue with normal processing if cache read fails - end - - # Build lookup hash for O(1) performance - lookup = template_fields.each_with_object({}) do |field, hash| - prefill_name = field['prefill'] - field_uuid = field['uuid'] - - hash[prefill_name] = field_uuid if prefill_name.present? && field_uuid.present? - end - - # Cache the lookup with error handling - begin - Rails.cache.write(cache_key, lookup, expires_in: FIELD_LOOKUP_CACHE_TTL) - rescue StandardError - # Continue execution even if caching fails - end - - lookup - end - - # Finds field UUID by matching ATS field name to template field's prefill attribute - # - # This method provides backward compatibility and is now optimized to use - # the cached lookup when possible. - # - # @param field_name [String] ATS field name to look up - # @param template_fields [Array, nil] Template field definitions - # @return [String, nil] Field UUID if found, nil otherwise - # - # @example - # find_field_uuid_by_name('employee_first_name', template_fields) - # # => 'field-uuid-123' - def find_field_uuid_by_name(field_name, template_fields = nil) - return nil if field_name.blank? || template_fields.blank? - - # Use optimized lookup cache - field_lookup = build_field_lookup_cache(template_fields) - field_lookup[field_name] - end - - # Generates cache key for field lookup optimization def field_lookup_cache_key(template_fields) - # Create a hash based on the structure of template fields for caching - fields_signature = template_fields.map { |f| "#{f['uuid']}:#{f['prefill']}" }.sort.join('|') - hash = Digest::SHA256.hexdigest(fields_signature) - "field_lookup:#{hash}" + signature = AtsPrefill::FieldMapper.send(:build_cache_signature, template_fields) + AtsPrefill::CacheManager.generate_cache_key('field_mapping', signature) end end diff --git a/lib/ats_prefill.rb b/lib/ats_prefill.rb new file mode 100644 index 00000000..369fa419 --- /dev/null +++ b/lib/ats_prefill.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative 'ats_prefill/cache_manager' +require_relative 'ats_prefill/field_extractor' +require_relative 'ats_prefill/field_mapper' +require_relative 'ats_prefill/value_merger' + +# AtsPrefill provides a clean facade for ATS (Applicant Tracking System) prefill functionality. +# This module encapsulates the complexity of extracting, validating, mapping, and merging +# ATS field values with existing submitter data. +# +# The module follows the service object pattern established in DocuSeal's codebase, +# providing focused, testable, and reusable components for ATS integration. +# +# @example Basic usage +# # Extract valid field names from request parameters +# field_names = AtsPrefill.extract_fields(params) +# +# # Merge ATS values with existing submitter values +# merged_values = AtsPrefill.merge_values(submitter_values, ats_values, template_fields) +# +# # Find specific field UUID by name +# field_uuid = AtsPrefill.find_field_uuid('employee_first_name', template_fields) +module AtsPrefill + # Extracts and validates ATS field names from request parameters + # + # @param params [ActionController::Parameters] Request parameters containing ats_fields + # @return [Array] Array of valid ATS field names + # + # @example + # AtsPrefill.extract_fields(params) + # # => ['employee_first_name', 'employee_email'] + def extract_fields(params) + FieldExtractor.call(params) + end + + # Merges ATS prefill values with existing submitter values + # + # Existing submitter values always take precedence over ATS values to prevent + # overwriting user input. + # + # @param submitter_values [Hash] Existing values entered by submitters + # @param ats_values [Hash] Prefill values from ATS system + # @param template_fields [Array, nil] Template field definitions + # @return [Hash] Merged values with submitter values taking precedence + # + # @example + # AtsPrefill.merge_values( + # { 'field-1' => 'John' }, + # { 'employee_first_name' => 'Jane', 'employee_last_name' => 'Doe' }, + # template_fields + # ) + # # => { 'field-1' => 'John', 'field-2' => 'Doe' } + def merge_values(submitter_values, ats_values, template_fields = nil) + ValueMerger.call(submitter_values, ats_values, template_fields) + end + + # Finds field UUID by matching ATS field name to template field's prefill attribute + # + # @param field_name [String] ATS field name to look up + # @param template_fields [Array, nil] Template field definitions + # @return [String, nil] Field UUID if found, nil otherwise + # + # @example + # AtsPrefill.find_field_uuid('employee_first_name', template_fields) + # # => 'field-uuid-123' + def find_field_uuid(field_name, template_fields) + FieldMapper.find_field_uuid(field_name, template_fields) + end + + # Creates field mapping for direct access to the mapping hash + # + # @param template_fields [Array, nil] Template field definitions + # @return [Hash] Mapping of ATS field names to field UUIDs + # + # @example + # AtsPrefill.build_field_mapping(template_fields) + # # => { 'employee_first_name' => 'field-1', 'employee_last_name' => 'field-2' } + def build_field_mapping(template_fields) + FieldMapper.call(template_fields) + end + + # Clears ATS-related caches (useful for testing or manual cache invalidation) + # + # Since Rails cache doesn't provide easy enumeration of keys, this method + # relies on TTL for automatic cleanup. This method is provided for potential + # future use or testing scenarios where immediate cache invalidation is needed. + # + # @return [void] + def clear_cache + # Since we can't easily enumerate cache keys, we'll rely on TTL for cleanup + # This method is provided for potential future use or testing + end + + module_function :extract_fields, :merge_values, :find_field_uuid, :build_field_mapping, :clear_cache +end diff --git a/lib/ats_prefill/cache_manager.rb b/lib/ats_prefill/cache_manager.rb new file mode 100644 index 00000000..a3fa870d --- /dev/null +++ b/lib/ats_prefill/cache_manager.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module AtsPrefill + module CacheManager + # Cache TTL for ATS field parsing (1 hour) + FIELD_EXTRACTION_TTL = 3600 + + # Cache TTL for field UUID lookup optimization (30 minutes) + FIELD_MAPPING_TTL = 1800 + + # Maximum number of cached entries to prevent memory bloat + MAX_CACHE_ENTRIES = 1000 + + module_function + + # Fetches field extraction results from cache or computes them + # + # @param cache_key [String] The cache key to use + # @yield Block that computes the value if not cached + # @return [Array] Array of valid field names + def fetch_field_extraction(cache_key, &) + fetch_with_fallback(cache_key, FIELD_EXTRACTION_TTL, &) + end + + # Fetches field mapping results from cache or computes them + # + # @param cache_key [String] The cache key to use + # @yield Block that computes the value if not cached + # @return [Hash] Mapping of field names to UUIDs + def fetch_field_mapping(cache_key, &) + fetch_with_fallback(cache_key, FIELD_MAPPING_TTL, &) + end + + # Generates a secure cache key using SHA256 hash + # + # @param prefix [String] Cache key prefix + # @param data [String] Data to hash for the key + # @return [String] Secure cache key + def generate_cache_key(prefix, data) + hash = Digest::SHA256.hexdigest(data.to_s) + "#{prefix}:#{hash}" + end + + # Writes a value to cache with error handling + # + # @param cache_key [String] The cache key + # @param value [Object] The value to cache + # @param ttl [Integer] Time to live in seconds + # @return [void] + def write_to_cache(cache_key, value, ttl) + Rails.cache.write(cache_key, value, expires_in: ttl) + rescue StandardError + # Continue execution even if caching fails + end + + # Reads from cache with error handling + # + # @param cache_key [String] The cache key to read + # @return [Object, nil] Cached value or nil if not found/error + def read_from_cache(cache_key) + Rails.cache.read(cache_key) + rescue StandardError + # Return nil if cache read fails, allowing normal processing to continue + nil + end + + private + + # Fetches from cache or computes value with fallback on cache errors + # + # @param cache_key [String] The cache key + # @param ttl [Integer] Time to live in seconds + # @yield Block that computes the value if not cached + # @return [Object] Cached or computed value + def fetch_with_fallback(cache_key, ttl, &) + Rails.cache.fetch(cache_key, expires_in: ttl, &) + rescue StandardError + # Fallback to computation if cache fails + yield + end + + module_function :fetch_field_extraction, :fetch_field_mapping, :generate_cache_key, :write_to_cache, + :read_from_cache, :fetch_with_fallback + end +end diff --git a/lib/ats_prefill/field_extractor.rb b/lib/ats_prefill/field_extractor.rb new file mode 100644 index 00000000..71341c1c --- /dev/null +++ b/lib/ats_prefill/field_extractor.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module AtsPrefill + module FieldExtractor + # Valid field name pattern for security validation + VALID_FIELD_PATTERN = /\A(employee|manager|account|location)_[a-z]+(?:_[a-z]+)*\z/ + + # Extracts and validates ATS prefill field names from Base64-encoded parameters + # + # This method decodes the ats_fields parameter, validates the field names against + # allowed patterns, and caches the results to improve performance on repeated requests. + # + # @param params [ActionController::Parameters] Request parameters + # @return [Array] Array of valid ATS field names, empty array if none found or on error + # + # @example + # # With params[:ats_fields] = Base64.urlsafe_encode64(['employee_first_name', 'employee_email'].to_json) + # AtsPrefill::FieldExtractor.call(params) + # # => ['employee_first_name', 'employee_email'] + def call(params) + return [] if params[:ats_fields].blank? + + cache_key = CacheManager.generate_cache_key('ats_fields', params[:ats_fields]) + + CacheManager.fetch_field_extraction(cache_key) do + extract_and_validate_fields(params[:ats_fields]) + end + end + + # Extracts and validates field names from encoded parameter + # + # @param encoded_param [String] Base64-encoded JSON string containing field names + # @return [Array] Array of valid field names + def extract_and_validate_fields(encoded_param) + field_names = parse_encoded_fields(encoded_param) + return [] if field_names.nil? + + validate_field_names(field_names) + end + + # Parses and decodes the ATS fields parameter + # + # @param encoded_param [String] Base64-encoded JSON string containing field names + # @return [Array, nil] Array of field names if parsing succeeds, nil on error + def parse_encoded_fields(encoded_param) + decoded_json = Base64.urlsafe_decode64(encoded_param) + JSON.parse(decoded_json) + rescue StandardError + # Return nil if Base64 decoding or JSON parsing fails + nil + end + + # Validates and filters field names to only include allowed patterns + # + # @param field_names [Array] Array of field names to validate + # @return [Array] Array of valid field names, empty array if input is invalid + def validate_field_names(field_names) + # Validate that we got an array of strings + return [] unless field_names.is_a?(Array) && field_names.all?(String) + + # Filter to only expected field name patterns + field_names.select { |name| valid_ats_field_name?(name) } + end + + # Checks if a field name matches the valid ATS field pattern + # + # @param name [String] Field name to validate + # @return [Boolean] True if field name is valid, false otherwise + def valid_ats_field_name?(name) + # Only allow expected field name patterns (security) + name.match?(VALID_FIELD_PATTERN) + end + + module_function :call, :extract_and_validate_fields, :parse_encoded_fields, :validate_field_names, + :valid_ats_field_name? + end +end diff --git a/lib/ats_prefill/field_mapper.rb b/lib/ats_prefill/field_mapper.rb new file mode 100644 index 00000000..3d4c5e70 --- /dev/null +++ b/lib/ats_prefill/field_mapper.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module AtsPrefill + module FieldMapper + # Creates optimized mapping between ATS field names and template field UUIDs + # + # Creates a hash mapping ATS field names to template field UUIDs for O(1) lookup + # performance instead of O(n) linear search. Results are cached to improve + # performance across multiple requests. + # + # @param template_fields [Array, nil] Template field definitions containing UUID and prefill mappings + # @return [Hash] Mapping of ATS field names to field UUIDs + # + # @example + # template_fields = [ + # { 'uuid' => 'field-1', 'prefill' => 'employee_first_name' }, + # { 'uuid' => 'field-2', 'prefill' => 'employee_last_name' } + # ] + # AtsPrefill::FieldMapper.call(template_fields) + # # => { 'employee_first_name' => 'field-1', 'employee_last_name' => 'field-2' } + def call(template_fields) + return {} if template_fields.blank? + + cache_key = CacheManager.generate_cache_key('field_mapping', build_cache_signature(template_fields)) + + CacheManager.fetch_field_mapping(cache_key) do + build_field_mapping(template_fields) + end + end + + # Finds field UUID by matching ATS field name to template field's prefill attribute + # + # This method provides backward compatibility and is now optimized to use + # the cached lookup when possible. + # + # @param field_name [String] ATS field name to look up + # @param template_fields [Array, nil] Template field definitions + # @return [String, nil] Field UUID if found, nil otherwise + # + # @example + # find_field_uuid('employee_first_name', template_fields) + # # => 'field-uuid-123' + def find_field_uuid(field_name, template_fields = nil) + return nil if field_name.blank? || template_fields.blank? + + # Use optimized lookup cache + field_mapping = call(template_fields) + field_mapping[field_name] + end + + private + + # Builds a cache signature from template fields for consistent caching + # + # @param template_fields [Array] Template field definitions + # @return [String] Cache signature based on field UUIDs and prefill attributes + def build_cache_signature(template_fields) + return '' if template_fields.blank? + + # Extract relevant data for cache key generation - format matches test expectations + template_fields + .filter_map do |field| + "#{field['uuid']}:#{field['prefill']}" if field['uuid'].present? && field['prefill'].present? + end + .sort + .join('|') + end + + # Builds the actual field mapping hash + # + # @param template_fields [Array] Template field definitions + # @return [Hash] Mapping of ATS field names to field UUIDs + def build_field_mapping(template_fields) + template_fields.each_with_object({}) do |field, mapping| + prefill_name = field['prefill'] + field_uuid = field['uuid'] + + mapping[prefill_name] = field_uuid if prefill_name.present? && field_uuid.present? + end + end + + module_function :call, :find_field_uuid, :build_cache_signature, :build_field_mapping + end +end diff --git a/lib/ats_prefill/value_merger.rb b/lib/ats_prefill/value_merger.rb new file mode 100644 index 00000000..c035ccc7 --- /dev/null +++ b/lib/ats_prefill/value_merger.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module AtsPrefill + module ValueMerger + # Merges ATS prefill values with existing submitter values + # + # This method combines ATS-provided prefill values with values already entered by submitters. + # Existing submitter values always take precedence over ATS values to prevent overwriting + # user input. Uses optimized field lookup caching for better performance. + # + # @param submitter_values [Hash] Existing values entered by submitters, keyed by field UUID + # @param ats_values [Hash] Prefill values from ATS system, keyed by ATS field name + # @param template_fields [Array, nil] Template field definitions containing UUID and prefill mappings + # @return [Hash] Merged values with submitter values taking precedence over ATS values + # + # @example + # submitter_values = { 'field-uuid-1' => 'John' } + # ats_values = { 'employee_first_name' => 'Jane', 'employee_last_name' => 'Doe' } + # template_fields = [ + # { 'uuid' => 'field-uuid-1', 'prefill' => 'employee_first_name' }, + # { 'uuid' => 'field-uuid-2', 'prefill' => 'employee_last_name' } + # ] + # + # AtsPrefill::ValueMerger.call(submitter_values, ats_values, template_fields) + # # => { 'field-uuid-1' => 'John', 'field-uuid-2' => 'Doe' } + # # Note: 'John' is preserved over 'Jane' because submitter value takes precedence + def call(submitter_values, ats_values, template_fields = nil) + return submitter_values if ats_values.blank? + + # Build optimized lookup cache for better performance with large field sets + field_mapping = FieldMapper.call(template_fields) + + merge_values(submitter_values, ats_values, field_mapping) + end + + private + + # Merges ATS values into submitter values for fields that are blank + # + # @param submitter_values [Hash] Current submitter field values + # @param ats_values [Hash] ATS field values to merge + # @param field_mapping [Hash] Mapping of ATS field names to template field UUIDs + # @return [Hash] Updated submitter values + def merge_values(submitter_values, ats_values, field_mapping) + return submitter_values if ats_values.blank? || field_mapping.blank? + + ats_values.each do |ats_field_name, ats_value| + field_uuid = field_mapping[ats_field_name] + next unless field_uuid + + # Only merge if the submitter value is blank (nil or empty string) + # Note: false and 0 are valid values that should not be overwritten + current_value = submitter_values[field_uuid] + submitter_values[field_uuid] = ats_value if current_value.nil? || current_value == '' + end + + submitter_values + end + + module_function :call, :merge_values + end +end diff --git a/spec/helpers/prefill_fields_helper_spec.rb b/spec/helpers/prefill_fields_helper_spec.rb index 90fa92d4..fbff531e 100644 --- a/spec/helpers/prefill_fields_helper_spec.rb +++ b/spec/helpers/prefill_fields_helper_spec.rb @@ -182,15 +182,15 @@ RSpec.describe PrefillFieldsHelper, type: :helper do end it 'caches the result' do - # The implementation uses a SHA256 hash for cache key, not the raw encoded string - cache_key = helper.send(:ats_fields_cache_key, encoded_fields) - allow(Rails.cache).to receive(:read).with(cache_key).and_return(nil) - allow(Rails.cache).to receive(:write).with(cache_key, fields, expires_in: 1.hour) + # The implementation now uses AtsPrefill service which uses Rails.cache.fetch + cache_key = AtsPrefill::CacheManager.generate_cache_key('ats_fields', encoded_fields) + + # Mock the cache to verify it's being used + allow(Rails.cache).to receive(:fetch).and_call_original helper.extract_ats_prefill_fields - expect(Rails.cache).to have_received(:read).with(cache_key) - expect(Rails.cache).to have_received(:write).with(cache_key, fields, expires_in: 1.hour) + expect(Rails.cache).to have_received(:fetch).with(cache_key, expires_in: 3600) end end diff --git a/spec/lib/ats_prefill/cache_manager_spec.rb b/spec/lib/ats_prefill/cache_manager_spec.rb new file mode 100644 index 00000000..924ddd25 --- /dev/null +++ b/spec/lib/ats_prefill/cache_manager_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AtsPrefill::CacheManager do + describe '.generate_cache_key' do + it 'generates a consistent cache key with SHA256 hash' do + key1 = described_class.generate_cache_key('test', 'data') + key2 = described_class.generate_cache_key('test', 'data') + + expect(key1).to eq(key2) + expect(key1).to match(/\Atest:[a-f0-9]{64}\z/) + end + + it 'generates different keys for different data' do + key1 = described_class.generate_cache_key('test', 'data1') + key2 = described_class.generate_cache_key('test', 'data2') + + expect(key1).not_to eq(key2) + end + + it 'generates different keys for different prefixes' do + key1 = described_class.generate_cache_key('prefix1', 'data') + key2 = described_class.generate_cache_key('prefix2', 'data') + + expect(key1).not_to eq(key2) + end + end + + describe '.fetch_field_extraction' do + let(:cache_key) { 'test_key' } + let(:expected_value) { %w[field1 field2] } + + it 'returns cached value when available' do + allow(Rails.cache).to receive(:fetch) + .with(cache_key, expires_in: described_class::FIELD_EXTRACTION_TTL) + .and_return(expected_value) + + result = described_class.fetch_field_extraction(cache_key) { 'should not be called' } + + expect(result).to eq(expected_value) + end + + it 'computes and caches value when not cached' do + allow(Rails.cache).to receive(:fetch).with(cache_key, expires_in: described_class::FIELD_EXTRACTION_TTL).and_yield + + result = described_class.fetch_field_extraction(cache_key) { expected_value } + + expect(result).to eq(expected_value) + end + + it 'falls back to computation when cache fails' do + allow(Rails.cache).to receive(:fetch).and_raise(StandardError, 'Cache error') + + result = described_class.fetch_field_extraction(cache_key) { expected_value } + + expect(result).to eq(expected_value) + end + end + + describe '.fetch_field_mapping' do + let(:cache_key) { 'test_key' } + let(:expected_value) { { 'field1' => 'uuid1' } } + + it 'returns cached value when available' do + allow(Rails.cache).to receive(:fetch) + .with(cache_key, expires_in: described_class::FIELD_MAPPING_TTL) + .and_return(expected_value) + + result = described_class.fetch_field_mapping(cache_key) { 'should not be called' } + + expect(result).to eq(expected_value) + end + + it 'computes and caches value when not cached' do + allow(Rails.cache).to receive(:fetch).with(cache_key, expires_in: described_class::FIELD_MAPPING_TTL).and_yield + + result = described_class.fetch_field_mapping(cache_key) { expected_value } + + expect(result).to eq(expected_value) + end + + it 'falls back to computation when cache fails' do + allow(Rails.cache).to receive(:fetch).and_raise(StandardError, 'Cache error') + + result = described_class.fetch_field_mapping(cache_key) { expected_value } + + expect(result).to eq(expected_value) + end + end + + describe '.write_to_cache' do + let(:cache_key) { 'test_key' } + let(:value) { 'test_value' } + let(:ttl) { 3600 } + + it 'writes to cache successfully' do + allow(Rails.cache).to receive(:write) + + described_class.write_to_cache(cache_key, value, ttl) + + expect(Rails.cache).to have_received(:write).with(cache_key, value, expires_in: ttl) + end + + it 'handles cache write errors gracefully' do + allow(Rails.cache).to receive(:write).and_raise(StandardError, 'Cache error') + + expect { described_class.write_to_cache(cache_key, value, ttl) }.not_to raise_error + end + end + + describe '.read_from_cache' do + let(:cache_key) { 'test_key' } + let(:cached_value) { 'cached_value' } + + it 'reads from cache successfully' do + allow(Rails.cache).to receive(:read).with(cache_key).and_return(cached_value) + + result = described_class.read_from_cache(cache_key) + + expect(result).to eq(cached_value) + end + + it 'returns nil when cache read fails' do + allow(Rails.cache).to receive(:read).and_raise(StandardError, 'Cache error') + + result = described_class.read_from_cache(cache_key) + + expect(result).to be_nil + end + + it 'returns nil when key not found' do + allow(Rails.cache).to receive(:read).with(cache_key).and_return(nil) + + result = described_class.read_from_cache(cache_key) + + expect(result).to be_nil + end + end + + describe 'constants' do + it 'defines expected TTL constants' do + expect(described_class::FIELD_EXTRACTION_TTL).to eq(3600) + expect(described_class::FIELD_MAPPING_TTL).to eq(1800) + expect(described_class::MAX_CACHE_ENTRIES).to eq(1000) + end + end +end diff --git a/spec/lib/ats_prefill/field_extractor_spec.rb b/spec/lib/ats_prefill/field_extractor_spec.rb new file mode 100644 index 00000000..a9038bf1 --- /dev/null +++ b/spec/lib/ats_prefill/field_extractor_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AtsPrefill::FieldExtractor do + describe '.call' do + context 'when ats_fields parameter is present' do + let(:fields) { %w[employee_first_name employee_last_name employee_email] } + let(:encoded_fields) { Base64.urlsafe_encode64(fields.to_json) } + let(:params) { ActionController::Parameters.new(ats_fields: encoded_fields) } + + it 'decodes and returns the ATS fields' do + result = described_class.call(params) + expect(result).to eq(fields) + end + + it 'caches the result' do + cache_key = AtsPrefill::CacheManager.generate_cache_key('ats_fields', encoded_fields) + + allow(AtsPrefill::CacheManager).to receive(:fetch_field_extraction).and_call_original + + described_class.call(params) + + expect(AtsPrefill::CacheManager).to have_received(:fetch_field_extraction).with(cache_key) + end + + it 'returns cached result on subsequent calls' do + cache_key = AtsPrefill::CacheManager.generate_cache_key('ats_fields', encoded_fields) + cached_result = ['cached_field'] + + allow(AtsPrefill::CacheManager).to receive(:fetch_field_extraction).with(cache_key).and_return(cached_result) + + result = described_class.call(params) + expect(result).to eq(cached_result) + end + end + + context 'when ats_fields parameter is missing' do + let(:params) { ActionController::Parameters.new({}) } + + it 'returns an empty array' do + result = described_class.call(params) + expect(result).to eq([]) + end + end + + context 'when ats_fields parameter is blank' do + let(:params) { ActionController::Parameters.new(ats_fields: '') } + + it 'returns an empty array' do + result = described_class.call(params) + expect(result).to eq([]) + end + end + + context 'when ats_fields parameter is invalid' do + let(:params) { ActionController::Parameters.new(ats_fields: 'invalid-base64') } + + it 'returns an empty array' do + result = described_class.call(params) + expect(result).to eq([]) + end + end + + context 'when decoded JSON is not an array' do + let(:invalid_data) { { not: 'an array' } } + let(:encoded_invalid) { Base64.urlsafe_encode64(invalid_data.to_json) } + let(:params) { ActionController::Parameters.new(ats_fields: encoded_invalid) } + + it 'returns an empty array' do + result = described_class.call(params) + expect(result).to eq([]) + end + end + + context 'when array contains non-string values' do + let(:mixed_data) { ['employee_first_name', 123, 'employee_email'] } + let(:encoded_mixed) { Base64.urlsafe_encode64(mixed_data.to_json) } + let(:params) { ActionController::Parameters.new(ats_fields: encoded_mixed) } + + it 'returns an empty array' do + result = described_class.call(params) + expect(result).to eq([]) + end + end + + context 'when validating field names' do + it 'accepts all valid field name patterns' do + valid_fields = %w[ + employee_first_name + employee_middle_name + employee_last_name + employee_email + manager_firstname + manager_lastname + account_name + location_name + location_street + ] + encoded = Base64.urlsafe_encode64(valid_fields.to_json) + params = ActionController::Parameters.new(ats_fields: encoded) + + result = described_class.call(params) + expect(result).to eq(valid_fields) + end + + it 'rejects invalid field name patterns' do + invalid_fields = %w[ + invalid_field + employee + _employee_name + employee_name_ + EMPLOYEE_NAME + employee-name + employee.name + malicious_script + admin_password + ] + encoded = Base64.urlsafe_encode64(invalid_fields.to_json) + params = ActionController::Parameters.new(ats_fields: encoded) + + result = described_class.call(params) + expect(result).to eq([]) + end + + it 'filters out invalid fields while keeping valid ones' do + mixed_fields = %w[ + employee_first_name + invalid_field + manager_lastname + malicious_script + location_name + ] + expected_valid = %w[employee_first_name manager_lastname location_name] + encoded = Base64.urlsafe_encode64(mixed_fields.to_json) + params = ActionController::Parameters.new(ats_fields: encoded) + + result = described_class.call(params) + expect(result).to eq(expected_valid) + end + end + + context 'when handling errors' do + it 'handles JSON parsing errors gracefully' do + invalid_json = Base64.urlsafe_encode64('invalid json') + params = ActionController::Parameters.new(ats_fields: invalid_json) + + result = described_class.call(params) + expect(result).to eq([]) + end + + it 'handles Base64 decoding errors gracefully' do + params = ActionController::Parameters.new(ats_fields: 'invalid-base64!') + + result = described_class.call(params) + expect(result).to eq([]) + end + end + end + + describe 'VALID_FIELD_PATTERN' do + it 'matches expected field patterns' do + valid_patterns = %w[ + employee_first_name + manager_last_name + account_company_name + location_street_address + ] + + expect(valid_patterns).to all(match(described_class::VALID_FIELD_PATTERN)) + end + + it 'rejects invalid field patterns' do + invalid_patterns = %w[ + invalid_field + employee + _employee_name + employee_name_ + EMPLOYEE_NAME + employee-name + employee.name + 123_field + field_123 + ] + + invalid_patterns.each do |pattern| + expect(pattern).not_to match(described_class::VALID_FIELD_PATTERN) + end + end + end +end diff --git a/spec/lib/ats_prefill/field_mapper_spec.rb b/spec/lib/ats_prefill/field_mapper_spec.rb new file mode 100644 index 00000000..58cc9cb6 --- /dev/null +++ b/spec/lib/ats_prefill/field_mapper_spec.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AtsPrefill::FieldMapper do + let(:template_fields) do + [ + { + 'uuid' => 'field-1-uuid', + 'name' => 'First Name', + 'type' => 'text', + 'prefill' => 'employee_first_name' + }, + { + 'uuid' => 'field-2-uuid', + 'name' => 'Last Name', + 'type' => 'text', + 'prefill' => 'employee_last_name' + }, + { + 'uuid' => 'field-3-uuid', + 'name' => 'Email', + 'type' => 'text', + 'prefill' => 'employee_email' + }, + { + 'uuid' => 'field-4-uuid', + 'name' => 'Signature', + 'type' => 'signature' + # No prefill attribute + } + ] + end + + describe '.call' do + context 'when template_fields is provided' do + it 'returns correct mapping of prefill names to UUIDs' do + result = described_class.call(template_fields) + + expect(result).to eq({ + 'employee_first_name' => 'field-1-uuid', + 'employee_last_name' => 'field-2-uuid', + 'employee_email' => 'field-3-uuid' + }) + end + + it 'excludes fields without prefill attributes' do + result = described_class.call(template_fields) + + expect(result).not_to have_key('signature') + expect(result.values).not_to include('field-4-uuid') + end + + it 'caches the result' do + cache_signature = template_fields + .filter_map do |f| + "#{f['uuid']}:#{f['prefill']}" if f['uuid'].present? && f['prefill'].present? + end + .sort + .join('|') + cache_key = AtsPrefill::CacheManager.generate_cache_key('field_mapping', cache_signature) + + allow(AtsPrefill::CacheManager).to receive(:fetch_field_mapping).and_call_original + + described_class.call(template_fields) + + expect(AtsPrefill::CacheManager).to have_received(:fetch_field_mapping).with(cache_key) + end + + it 'returns cached result on subsequent calls' do + cache_signature = template_fields + .filter_map do |f| + "#{f['uuid']}:#{f['prefill']}" if f['uuid'].present? && f['prefill'].present? + end + .sort + .join('|') + cache_key = AtsPrefill::CacheManager.generate_cache_key('field_mapping', cache_signature) + cached_result = { 'cached_field' => 'cached_uuid' } + + allow(AtsPrefill::CacheManager).to receive(:fetch_field_mapping).with(cache_key).and_return(cached_result) + + result = described_class.call(template_fields) + expect(result).to eq(cached_result) + end + end + + context 'when template_fields is nil' do + it 'returns empty hash' do + result = described_class.call(nil) + expect(result).to eq({}) + end + end + + context 'when template_fields is empty' do + it 'returns empty hash' do + result = described_class.call([]) + expect(result).to eq({}) + end + end + + context 'when fields have missing attributes' do + let(:incomplete_fields) do + [ + { + 'uuid' => 'field-1-uuid', + 'prefill' => 'employee_first_name' + }, + { + 'uuid' => 'field-2-uuid' + # Missing prefill + }, + { + 'prefill' => 'employee_last_name' + # Missing uuid + }, + { + 'uuid' => '', + 'prefill' => 'employee_email' + }, + { + 'uuid' => 'field-5-uuid', + 'prefill' => '' + } + ] + end + + it 'only includes fields with both uuid and prefill present' do + result = described_class.call(incomplete_fields) + + expect(result).to eq({ + 'employee_first_name' => 'field-1-uuid' + }) + end + end + + context 'with duplicate prefill names' do + let(:duplicate_fields) do + [ + { + 'uuid' => 'field-1-uuid', + 'prefill' => 'employee_name' + }, + { + 'uuid' => 'field-2-uuid', + 'prefill' => 'employee_name' + } + ] + end + + it 'uses the last occurrence for duplicate prefill names' do + result = described_class.call(duplicate_fields) + + expect(result).to eq({ + 'employee_name' => 'field-2-uuid' + }) + end + end + end + + describe '.find_field_uuid' do + context 'when template_fields is provided' do + it 'returns the correct UUID for a matching ATS field name' do + uuid = described_class.find_field_uuid('employee_first_name', template_fields) + expect(uuid).to eq('field-1-uuid') + end + + it 'returns the correct UUID for another matching ATS field name' do + uuid = described_class.find_field_uuid('employee_email', template_fields) + expect(uuid).to eq('field-3-uuid') + end + + it 'returns nil for a non-matching ATS field name' do + uuid = described_class.find_field_uuid('non_existent_field', template_fields) + expect(uuid).to be_nil + end + + it 'returns nil for a field without prefill attribute' do + uuid = described_class.find_field_uuid('signature', template_fields) + expect(uuid).to be_nil + end + + it 'uses the cached field mapping' do + allow(described_class).to receive(:call).with(template_fields).and_return( + { 'employee_first_name' => 'field-1-uuid' } + ) + + uuid = described_class.find_field_uuid('employee_first_name', + template_fields) + expect(uuid).to eq('field-1-uuid') + end + end + + context 'when template_fields is nil' do + it 'returns nil' do + uuid = described_class.find_field_uuid('employee_first_name', nil) + expect(uuid).to be_nil + end + end + + context 'when template_fields is empty' do + it 'returns nil' do + uuid = described_class.find_field_uuid('employee_first_name', []) + expect(uuid).to be_nil + end + end + + context 'when field_name is blank' do + it 'returns nil for nil field_name' do + uuid = described_class.find_field_uuid(nil, template_fields) + expect(uuid).to be_nil + end + + it 'returns nil for empty field_name' do + uuid = described_class.find_field_uuid('', template_fields) + expect(uuid).to be_nil + end + end + end + + describe 'cache signature generation' do + it 'generates consistent cache signatures' do + signature1 = described_class.send(:build_cache_signature, template_fields) + signature2 = described_class.send(:build_cache_signature, template_fields) + + expect(signature1).to eq(signature2) + end + + it 'generates different signatures for different field sets' do + different_fields = [ + { + 'uuid' => 'different-uuid', + 'prefill' => 'different_field' + } + ] + + signature1 = described_class.send(:build_cache_signature, template_fields) + signature2 = described_class.send(:build_cache_signature, different_fields) + + expect(signature1).not_to eq(signature2) + end + + it 'generates same signature regardless of field order' do + shuffled_fields = template_fields.shuffle + + signature1 = described_class.send(:build_cache_signature, template_fields) + signature2 = described_class.send(:build_cache_signature, shuffled_fields) + + expect(signature1).to eq(signature2) + end + end +end diff --git a/spec/lib/ats_prefill/value_merger_spec.rb b/spec/lib/ats_prefill/value_merger_spec.rb new file mode 100644 index 00000000..7099c560 --- /dev/null +++ b/spec/lib/ats_prefill/value_merger_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AtsPrefill::ValueMerger do + let(:template_fields) do + [ + { + 'uuid' => 'field-1-uuid', + 'name' => 'First Name', + 'type' => 'text', + 'prefill' => 'employee_first_name' + }, + { + 'uuid' => 'field-2-uuid', + 'name' => 'Last Name', + 'type' => 'text', + 'prefill' => 'employee_last_name' + }, + { + 'uuid' => 'field-3-uuid', + 'name' => 'Email', + 'type' => 'text', + 'prefill' => 'employee_email' + }, + { + 'uuid' => 'field-4-uuid', + 'name' => 'Signature', + 'type' => 'signature' + # No prefill attribute + } + ] + end + + describe '.call' do + let(:submitter_values) do + { + 'field-1-uuid' => 'Existing First Name', + 'field-4-uuid' => 'Existing Signature' + } + end + + let(:ats_values) do + { + 'employee_first_name' => 'John', + 'employee_last_name' => 'Doe', + 'employee_email' => 'john.doe@example.com' + } + end + + context 'when template_fields is provided' do + it 'merges ATS values for fields that do not have existing submitter values' do + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result).to include( + 'field-1-uuid' => 'Existing First Name', # Should not be overwritten + 'field-2-uuid' => 'Doe', # Should be set from ATS + 'field-3-uuid' => 'john.doe@example.com', # Should be set from ATS + 'field-4-uuid' => 'Existing Signature' # Should remain unchanged + ) + end + + it 'does not overwrite existing submitter values' do + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result['field-1-uuid']).to eq('Existing First Name') + end + + it 'ignores ATS values for fields without matching prefill attributes' do + ats_values_with_unknown = ats_values.merge('unknown_field' => 'Unknown Value') + + result = described_class.call(submitter_values, ats_values_with_unknown, template_fields) + + expect(result.keys).not_to include('unknown_field') + end + + it 'uses FieldMapper to get field mapping' do + expected_mapping = { + 'employee_first_name' => 'field-1-uuid', + 'employee_last_name' => 'field-2-uuid', + 'employee_email' => 'field-3-uuid' + } + + allow(AtsPrefill::FieldMapper).to receive(:call).and_return(expected_mapping) + + described_class.call(submitter_values, ats_values, template_fields) + + expect(AtsPrefill::FieldMapper).to have_received(:call).with(template_fields) + end + end + + context 'when template_fields is nil' do + it 'returns original submitter_values unchanged' do + result = described_class.call(submitter_values, ats_values, nil) + expect(result).to eq(submitter_values) + end + end + + context 'when ats_values is blank' do + it 'returns original submitter_values for nil ats_values' do + result = described_class.call(submitter_values, nil, template_fields) + expect(result).to eq(submitter_values) + end + + it 'returns original submitter_values for empty ats_values' do + result = described_class.call(submitter_values, {}, template_fields) + expect(result).to eq(submitter_values) + end + end + + context 'when submitter_values has blank values' do + let(:submitter_values_with_blanks) do + { + 'field-1-uuid' => '', + 'field-2-uuid' => nil, + 'field-4-uuid' => 'Existing Signature' + } + end + + it 'fills blank submitter values with ATS values' do + result = described_class.call(submitter_values_with_blanks, ats_values, template_fields) + + expect(result).to include( + 'field-1-uuid' => 'John', # Should be filled from ATS (was blank) + 'field-2-uuid' => 'Doe', # Should be filled from ATS (was nil) + 'field-3-uuid' => 'john.doe@example.com', # Should be set from ATS (was missing) + 'field-4-uuid' => 'Existing Signature' # Should remain unchanged + ) + end + + it 'treats empty string as blank' do + submitter_values = { 'field-1-uuid' => '' } + ats_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result['field-1-uuid']).to eq('John') + end + + it 'treats nil as blank' do + submitter_values = { 'field-1-uuid' => nil } + ats_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result['field-1-uuid']).to eq('John') + end + + it 'does not treat false as blank' do + submitter_values = { 'field-1-uuid' => false } + ats_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result['field-1-uuid']).to be(false) + end + + it 'does not treat zero as blank' do + submitter_values = { 'field-1-uuid' => 0 } + ats_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result['field-1-uuid']).to eq(0) + end + end + + context 'when field mapping is empty' do + it 'returns original submitter values when no fields match' do + allow(AtsPrefill::FieldMapper).to receive(:call).and_return({}) + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result).to eq(submitter_values) + end + end + + context 'with complex scenarios' do + it 'handles multiple ATS values with partial existing submitter values' do + submitter_values = { + 'field-1-uuid' => 'Keep This', + 'field-2-uuid' => '', + 'field-5-uuid' => 'Unrelated Field' + } + + ats_values = { + 'employee_first_name' => 'Should Not Override', + 'employee_last_name' => 'Should Fill', + 'employee_email' => 'Should Add', + 'unknown_field' => 'Should Ignore' + } + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result).to eq({ + 'field-1-uuid' => 'Keep This', + 'field-2-uuid' => 'Should Fill', + 'field-3-uuid' => 'Should Add', + 'field-5-uuid' => 'Unrelated Field' + }) + end + + it 'modifies the original submitter_values hash' do + original_values = submitter_values.dup + + result = described_class.call(submitter_values, ats_values, template_fields) + + expect(result).to be(submitter_values) + expect(submitter_values).not_to eq(original_values) + end + end + end +end diff --git a/spec/lib/ats_prefill_spec.rb b/spec/lib/ats_prefill_spec.rb new file mode 100644 index 00000000..111953cb --- /dev/null +++ b/spec/lib/ats_prefill_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AtsPrefill do + let(:template_fields) do + [ + { + 'uuid' => 'field-1-uuid', + 'name' => 'First Name', + 'type' => 'text', + 'prefill' => 'employee_first_name' + }, + { + 'uuid' => 'field-2-uuid', + 'name' => 'Last Name', + 'type' => 'text', + 'prefill' => 'employee_last_name' + } + ] + end + + describe '.extract_fields' do + let(:params) { ActionController::Parameters.new(ats_fields: 'encoded_data') } + + it 'delegates to FieldExtractor' do + expected_result = %w[employee_first_name employee_last_name] + + allow(AtsPrefill::FieldExtractor).to receive(:call).with(params).and_return(expected_result) + + result = described_class.extract_fields(params) + expect(result).to eq(expected_result) + end + end + + describe '.merge_values' do + let(:submitter_values) { { 'field-1-uuid' => 'John' } } + let(:ats_values) { { 'employee_last_name' => 'Doe' } } + + it 'delegates to ValueMerger' do + expected_result = { 'field-1-uuid' => 'John', 'field-2-uuid' => 'Doe' } + + allow(AtsPrefill::ValueMerger).to receive(:call).with(submitter_values, ats_values, + template_fields).and_return(expected_result) + + result = described_class.merge_values(submitter_values, ats_values, template_fields) + expect(result).to eq(expected_result) + end + + it 'handles nil template_fields' do + allow(AtsPrefill::ValueMerger).to receive(:call).with(submitter_values, ats_values, + nil).and_return(submitter_values) + + result = described_class.merge_values(submitter_values, ats_values, nil) + expect(result).to eq(submitter_values) + end + end + + describe '.find_field_uuid' do + let(:field_name) { 'employee_first_name' } + + it 'delegates to FieldMapper.find_field_uuid' do + expected_uuid = 'field-1-uuid' + + allow(AtsPrefill::FieldMapper).to receive(:find_field_uuid).with(field_name, + template_fields).and_return(expected_uuid) + + result = described_class.find_field_uuid(field_name, template_fields) + expect(result).to eq(expected_uuid) + end + + it 'returns nil for non-existent field' do + allow(AtsPrefill::FieldMapper).to receive(:find_field_uuid).with('non_existent', template_fields).and_return(nil) + + result = described_class.find_field_uuid('non_existent', template_fields) + expect(result).to be_nil + end + end + + describe '.build_field_mapping' do + it 'delegates to FieldMapper.call' do + expected_mapping = { 'employee_first_name' => 'field-1-uuid', 'employee_last_name' => 'field-2-uuid' } + + allow(AtsPrefill::FieldMapper).to receive(:call).with(template_fields).and_return(expected_mapping) + + result = described_class.build_field_mapping(template_fields) + expect(result).to eq(expected_mapping) + end + + it 'handles nil template_fields' do + allow(AtsPrefill::FieldMapper).to receive(:call).with(nil).and_return({}) + + result = described_class.build_field_mapping(nil) + expect(result).to eq({}) + end + end + + describe '.clear_cache' do + it 'exists as a method for future use' do + expect { described_class.clear_cache }.not_to raise_error + end + + it 'returns nil' do + result = described_class.clear_cache + expect(result).to be_nil + end + end + + describe 'integration test' do + let(:params) do + fields = %w[employee_first_name employee_last_name] + encoded_fields = Base64.urlsafe_encode64(fields.to_json) + ActionController::Parameters.new(ats_fields: encoded_fields) + end + + let(:submitter_values) { { 'field-1-uuid' => 'Existing Name' } } + let(:ats_values) { { 'employee_first_name' => 'John', 'employee_last_name' => 'Doe' } } + + it 'works end-to-end with real service objects' do + # Extract fields + extracted_fields = described_class.extract_fields(params) + expect(extracted_fields).to eq(%w[employee_first_name employee_last_name]) + + # Build field mapping + field_mapping = described_class.build_field_mapping(template_fields) + expect(field_mapping).to eq({ + 'employee_first_name' => 'field-1-uuid', + 'employee_last_name' => 'field-2-uuid' + }) + + # Find specific field UUID + uuid = described_class.find_field_uuid('employee_first_name', template_fields) + expect(uuid).to eq('field-1-uuid') + + # Merge values + merged_values = described_class.merge_values(submitter_values, ats_values, template_fields) + expect(merged_values).to eq({ + 'field-1-uuid' => 'Existing Name', # Should not be overwritten + 'field-2-uuid' => 'Doe' # Should be added from ATS + }) + end + end + + describe 'module structure' do + it 'includes all expected methods' do + expected_methods = %i[ + extract_fields + merge_values + find_field_uuid + build_field_mapping + clear_cache + ] + + expected_methods.each do |method| + expect(described_class).to respond_to(method) + end + end + + it 'is a module with module_function' do + expect(described_class).to be_a(Module) + + # Check that methods are available as module methods + expect(described_class).to respond_to(:extract_fields) + expect(described_class.methods).to include(:extract_fields) + end + end + + describe 'error handling' do + it 'propagates errors from underlying services' do + allow(AtsPrefill::FieldExtractor).to receive(:call).and_raise(StandardError, 'Test error') + + expect { described_class.extract_fields({}) }.to raise_error(StandardError, 'Test error') + end + end +end