From f180d704741c940e5cb6f61cd52f60fd0860a8b6 Mon Sep 17 00:00:00 2001 From: Bernardo Anderson Date: Fri, 29 Aug 2025 15:48:36 -0500 Subject: [PATCH] CP-10361 - Forgot a few things --- config/locales/i18n.yml | 12 +- lib/prefill.rb | 96 +++++++ lib/prefill/cache_manager.rb | 85 +++++++ lib/prefill/field_extractor.rb | 77 ++++++ lib/prefill/field_mapper.rb | 84 +++++++ lib/prefill/value_merger.rb | 62 +++++ spec/integration/prefill_integration_spec.rb | 233 +++++++++++++++++ spec/lib/prefill/cache_manager_spec.rb | 148 +++++++++++ spec/lib/prefill/field_extractor_spec.rb | 191 ++++++++++++++ spec/lib/prefill/field_mapper_spec.rb | 251 +++++++++++++++++++ spec/lib/prefill/value_merger_spec.rb | 213 ++++++++++++++++ spec/lib/prefill_spec.rb | 175 +++++++++++++ 12 files changed, 1621 insertions(+), 6 deletions(-) create mode 100644 lib/prefill.rb create mode 100644 lib/prefill/cache_manager.rb create mode 100644 lib/prefill/field_extractor.rb create mode 100644 lib/prefill/field_mapper.rb create mode 100644 lib/prefill/value_merger.rb create mode 100644 spec/integration/prefill_integration_spec.rb create mode 100644 spec/lib/prefill/cache_manager_spec.rb create mode 100644 spec/lib/prefill/field_extractor_spec.rb create mode 100644 spec/lib/prefill/field_mapper_spec.rb create mode 100644 spec/lib/prefill/value_merger_spec.rb create mode 100644 spec/lib/prefill_spec.rb diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 6a7a2158..7b5853b4 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -22,7 +22,7 @@ en: &en hi_there: Hi there thanks: Thanks private: Private - ats_field: Pre-fill Options + prefill_field: Pre-fill Options authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token stripe_integration: Stripe Integration require_all_recipients: Require all recipients @@ -908,7 +908,7 @@ es: &es sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Firme documentos con un certificado de confianza proporcionado por DocuSeal. Sus documentos y datos nunca se comparten con DocuSeal. Se proporciona un checksum de PDF para generar una firma de confianza. hi_there: Hola thanks: Gracias - ats_field: Opciones de Prellenado + prefill_field: Opciones de Prellenado you_have_been_invited_to_submit_the_name_form: 'Has sido invitado/a a enviar el formulario "%{name}".' you_have_been_invited_to_sign_the_name: 'Has sido invitado/a a firmar el "%{name}".' alternatively_you_can_review_and_download_your_copy_using_the_link_below: "Alternativamente, puedes revisar y descargar tu copia usando el enlace a continuación:" @@ -1743,7 +1743,7 @@ it: &it sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: "Firma documenti con un certificato di fiducia fornito da DocuSeal. I tuoi documenti e i tuoi dati non vengono mai condivisi con DocuSeal. Il checksum PDF è fornito per generare una firma di fiducia." hi_there: Ciao thanks: Grazie - ats_field: Opzioni di Precompilazione + prefill_field: Opzioni di Precompilazione you_have_been_invited_to_submit_the_name_form: 'Sei stato invitato a inviare il modulo "%{name}".' you_have_been_invited_to_sign_the_name: 'Sei stato invitato a firmare il "%{name}".' alternatively_you_can_review_and_download_your_copy_using_the_link_below: "In alternativa, puoi rivedere e scaricare la tua copia utilizzando il link qui sotto:" @@ -2578,7 +2578,7 @@ fr: &fr sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Signez des documents avec un certificat de confiance fourni par DocuSeal. Vos documents et données ne sont jamais partagés avec DocuSeal. Un checksum PDF est fourni pour générer une signature de confiance. hi_there: Bonjour thanks: Merci - ats_field: Options de Préremplissage + prefill_field: Options de Préremplissage you_have_been_invited_to_submit_the_name_form: 'Vous avez été invité à remplir le formulaire "%{name}".' you_have_been_invited_to_sign_the_name: 'Vous avez été invité à signer "%{name}".' alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Vous pouvez également consulter et télécharger votre copie en utilisant le lien ci-dessous:' @@ -3415,7 +3415,7 @@ pt: &pt sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Assine documentos com certificado confiável fornecido pela DocuSeal. Seus documentos e dados nunca são compartilhados com a DocuSeal. O checksum do PDF é fornecido para gerar uma assinatura confiável. hi_there: Olá thanks: Obrigado - ats_field: Opções de Preenchimento + prefill_field: Opções de Preenchimento you_have_been_invited_to_submit_the_name_form: 'Você foi convidado a submeter o formulário "%{name}".' you_have_been_invited_to_sign_the_name: 'Você foi convidado a assinar "%{name}".' alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Você pode revisar e baixar sua cópia usando o link abaixo:' @@ -4252,7 +4252,7 @@ de: &de sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Unterzeichnen Sie Dokumente mit einem vertrauenswürdigen Zertifikat von DocuSeal. Ihre Dokumente und Daten werden niemals mit DocuSeal geteilt. Eine PDF-Prüfziffer wird bereitgestellt, um eine vertrauenswürdige Signatur zu generieren. hi_there: Hallo thanks: Danke - ats_field: Vorausfüll-Optionen + prefill_field: Vorausfüll-Optionen you_have_been_invited_to_submit_the_name_form: 'Du wurdest eingeladen, das Formular "%{name}" einzureichen.' you_have_been_invited_to_sign_the_name: 'Du wurdest eingeladen, "%{name}" zu unterschreiben.' alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Du kannst alternativ deine Kopie mit dem untenstehenden Link überprüfen und herunterladen:' diff --git a/lib/prefill.rb b/lib/prefill.rb new file mode 100644 index 00000000..31657d12 --- /dev/null +++ b/lib/prefill.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative 'prefill/cache_manager' +require_relative 'prefill/field_extractor' +require_relative 'prefill/field_mapper' +require_relative 'prefill/value_merger' + +# Prefill provides a clean facade for prefill functionality. +# This module encapsulates the complexity of extracting, validating, mapping, and merging +# prefill 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 prefill integration. +# +# @example Basic usage +# # Extract valid field names from request parameters +# field_names = Prefill.extract_fields(params) +# +# # Merge prefill values with existing submitter values +# merged_values = Prefill.merge_values(submitter_values, prefill_values, template_fields) +# +# # Find specific field UUID by name +# field_uuid = Prefill.find_field_uuid('employee_first_name', template_fields) +module Prefill + # Extracts and validates prefill field names from request parameters + # + # @param params [ActionController::Parameters] Request parameters containing prefill_fields + # @return [Array] Array of valid prefill field names + # + # @example + # Prefill.extract_fields(params) + # # => ['employee_first_name', 'employee_email'] + def extract_fields(params) + FieldExtractor.call(params) + end + + # Merges prefill values with existing submitter values + # + # Existing submitter values always take precedence over prefill values to prevent + # overwriting user input. + # + # @param submitter_values [Hash] Existing values entered by submitters + # @param prefill_values [Hash] Prefill values from external system + # @param template_fields [Array, nil] Template field definitions + # @return [Hash] Merged values with submitter values taking precedence + # + # @example + # Prefill.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, prefill_values, template_fields = nil) + ValueMerger.call(submitter_values, prefill_values, template_fields) + end + + # Finds field UUID by matching prefill field name to template field's prefill attribute + # + # @param field_name [String] Prefill field name to look up + # @param template_fields [Array, nil] Template field definitions + # @return [String, nil] Field UUID if found, nil otherwise + # + # @example + # Prefill.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 prefill field names to field UUIDs + # + # @example + # Prefill.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 prefill-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/prefill/cache_manager.rb b/lib/prefill/cache_manager.rb new file mode 100644 index 00000000..8d23b085 --- /dev/null +++ b/lib/prefill/cache_manager.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Prefill + module CacheManager + # Cache TTL for prefill 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/prefill/field_extractor.rb b/lib/prefill/field_extractor.rb new file mode 100644 index 00000000..8d6738ce --- /dev/null +++ b/lib/prefill/field_extractor.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Prefill + 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 prefill field names from Base64-encoded parameters + # + # This method decodes the prefill_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 prefill field names, empty array if none found or on error + # + # @example + # # With params[:prefill_fields] = Base64.urlsafe_encode64(['employee_first_name', 'employee_email'].to_json) + # Prefill::FieldExtractor.call(params) + # # => ['employee_first_name', 'employee_email'] + def call(params) + return [] if params[:prefill_fields].blank? + + cache_key = CacheManager.generate_cache_key('prefill_fields', params[:prefill_fields]) + + CacheManager.fetch_field_extraction(cache_key) do + extract_and_validate_fields(params[:prefill_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 prefill 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_prefill_field_name?(name) } + end + + # Checks if a field name matches the valid prefill field pattern + # + # @param name [String] Field name to validate + # @return [Boolean] True if field name is valid, false otherwise + def valid_prefill_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_prefill_field_name? + end +end diff --git a/lib/prefill/field_mapper.rb b/lib/prefill/field_mapper.rb new file mode 100644 index 00000000..e1bcf151 --- /dev/null +++ b/lib/prefill/field_mapper.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Prefill + module FieldMapper + # Creates optimized mapping between prefill field names and template field UUIDs + # + # Creates a hash mapping prefill 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 prefill field names to field UUIDs + # + # @example + # template_fields = [ + # { 'uuid' => 'field-1', 'prefill' => 'employee_first_name' }, + # { 'uuid' => 'field-2', 'prefill' => 'employee_last_name' } + # ] + # Prefill::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 prefill 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] Prefill 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 prefill 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/prefill/value_merger.rb b/lib/prefill/value_merger.rb new file mode 100644 index 00000000..1b0dfa88 --- /dev/null +++ b/lib/prefill/value_merger.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Prefill + module ValueMerger + # Merges prefill values with existing submitter values + # + # This method combines externally-provided prefill values with values already entered by submitters. + # Existing submitter values always take precedence over prefill 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 prefill_values [Hash] Prefill values from external system, keyed by prefill 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 prefill values + # + # @example + # submitter_values = { 'field-uuid-1' => 'John' } + # prefill_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' } + # ] + # + # Prefill::ValueMerger.call(submitter_values, prefill_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, prefill_values, template_fields = nil) + return submitter_values if prefill_values.blank? + + # Build optimized lookup cache for better performance with large field sets + field_mapping = FieldMapper.call(template_fields) + + merge_values(submitter_values, prefill_values, field_mapping) + end + + private + + # Merges prefill values into submitter values for fields that are blank + # + # @param submitter_values [Hash] Current submitter field values + # @param prefill_values [Hash] Prefill field values to merge + # @param field_mapping [Hash] Mapping of prefill field names to template field UUIDs + # @return [Hash] Updated submitter values + def merge_values(submitter_values, prefill_values, field_mapping) + return submitter_values if prefill_values.blank? || field_mapping.blank? + + prefill_values.each do |prefill_field_name, prefill_value| + field_uuid = field_mapping[prefill_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] = prefill_value if current_value.nil? || current_value == '' + end + + submitter_values + end + + module_function :call, :merge_values + end +end diff --git a/spec/integration/prefill_integration_spec.rb b/spec/integration/prefill_integration_spec.rb new file mode 100644 index 00000000..6ab95172 --- /dev/null +++ b/spec/integration/prefill_integration_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'ATS Prefill Integration', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:template_folder) { create(:template_folder, account: account) } + + let(:template_fields) do + [ + { + 'uuid' => 'field-1-uuid', + 'name' => 'First Name', + 'type' => 'text', + 'prefill' => 'employee_first_name', + 'submitter_uuid' => 'submitter-uuid-1' + }, + { + 'uuid' => 'field-2-uuid', + 'name' => 'Last Name', + 'type' => 'text', + 'prefill' => 'employee_last_name', + 'submitter_uuid' => 'submitter-uuid-1' + }, + { + 'uuid' => 'field-3-uuid', + 'name' => 'Email', + 'type' => 'text', + 'prefill' => 'employee_email', + 'submitter_uuid' => 'submitter-uuid-1' + }, + { + 'uuid' => 'field-4-uuid', + 'name' => 'Signature', + 'type' => 'signature', + 'submitter_uuid' => 'submitter-uuid-1' + } + ] + end + + let(:template) do + create(:template, + account: account, + author: user, + folder: template_folder, + fields: template_fields, + submitters: [{ 'name' => 'First Party', 'uuid' => 'submitter-uuid-1' }]) + end + + let(:submission) do + create(:submission, + template: template, + account: account, + created_by_user: user, + template_fields: template_fields, + template_submitters: [{ 'name' => 'First Party', 'uuid' => 'submitter-uuid-1' }]) + end + + let(:submitter) do + create(:submitter, + submission: submission, + uuid: 'submitter-uuid-1', + name: 'John Doe', + email: 'john@example.com') + end + + describe 'Controller ATS parameter processing' do + let(:controller) { SubmitFormController.new } + + before do + allow(controller).to receive(:params).and_return(ActionController::Parameters.new(test_params)) + end + + context 'when ATS fields and values are provided via Base64 parameters' do + let(:test_params) do + { + prefill_fields: Base64.urlsafe_encode64(%w[employee_first_name employee_last_name employee_email].to_json), + prefill_values: Base64.urlsafe_encode64({ 'employee_first_name' => 'John', 'employee_last_name' => 'Smith', + 'employee_email' => 'john.smith@company.com' }.to_json) + } + end + + it 'successfully decodes and processes ATS parameters' do + result = controller.send(:fetch_prefill_values_if_available) + + expect(result).to eq({ + 'employee_first_name' => 'John', + 'employee_last_name' => 'Smith', + 'employee_email' => 'john.smith@company.com' + }) + end + end + + context 'when prefill_values parameter contains invalid Base64' do + let(:test_params) do + { + prefill_fields: Base64.urlsafe_encode64(['employee_first_name'].to_json), + prefill_values: 'invalid-base64!' + } + end + + it 'handles Base64 decoding errors gracefully' do + result = controller.send(:fetch_prefill_values_if_available) + expect(result).to eq({}) + end + end + + context 'when prefill_values parameter contains valid Base64 but invalid JSON' do + let(:test_params) do + { + prefill_fields: Base64.urlsafe_encode64(['employee_first_name'].to_json), + prefill_values: Base64.urlsafe_encode64('invalid json') + } + end + + it 'handles JSON parsing errors gracefully' do + result = controller.send(:fetch_prefill_values_if_available) + expect(result).to eq({}) + end + end + + context 'when prefill_values parameter contains valid JSON but wrong data type' do + let(:test_params) do + { + prefill_fields: Base64.urlsafe_encode64(['employee_first_name'].to_json), + prefill_values: Base64.urlsafe_encode64('["not", "a", "hash"]') + } + end + + it 'handles invalid data type gracefully' do + result = controller.send(:fetch_prefill_values_if_available) + expect(result).to eq({}) + end + end + + context 'when no ATS parameters are provided' do + let(:test_params) { {} } + + it 'returns empty hash when no ATS parameters present' do + result = controller.send(:fetch_prefill_values_if_available) + expect(result).to eq({}) + end + end + end + + describe 'Helper method integration' do + include PrefillFieldsHelper + + it 'correctly maps ATS field names to template field UUIDs' do + result = find_field_uuid_by_name('employee_first_name', template_fields) + expect(result).to eq('field-1-uuid') + + result = find_field_uuid_by_name('employee_last_name', template_fields) + expect(result).to eq('field-2-uuid') + + result = find_field_uuid_by_name('nonexistent_field', template_fields) + expect(result).to be_nil + end + + it 'correctly merges ATS values with existing submitter values' do + existing_values = { 'field-1-uuid' => 'Existing John' } + prefill_values = { 'employee_first_name' => 'ATS John', 'employee_last_name' => 'ATS Smith' } + + result = merge_prefill_values(existing_values, prefill_values, template_fields) + + expect(result).to eq({ + 'field-1-uuid' => 'Existing John', # Should not override existing value + 'field-2-uuid' => 'ATS Smith' # Should add new ATS value + }) + end + + it 'handles empty ATS values gracefully' do + existing_values = { 'field-1-uuid' => 'Existing John' } + prefill_values = {} + + result = merge_prefill_values(existing_values, prefill_values, template_fields) + + expect(result).to eq({ + 'field-1-uuid' => 'Existing John' + }) + end + + it 'handles missing template fields gracefully' do + existing_values = {} + prefill_values = { 'nonexistent_field' => 'Some Value' } + + result = merge_prefill_values(existing_values, prefill_values, template_fields) + + expect(result).to eq({}) + end + end + + describe 'End-to-end ATS prefill workflow' do + include PrefillFieldsHelper + + it 'processes complete ATS prefill workflow from parameters to merged values' do + # Step 1: Simulate controller parameter processing + controller = SubmitFormController.new + prefill_fields_data = %w[employee_first_name employee_last_name employee_email] + prefill_values_data = { + 'employee_first_name' => 'John', + 'employee_last_name' => 'Smith', + 'employee_email' => 'john.smith@company.com' + } + + encoded_fields = Base64.urlsafe_encode64(prefill_fields_data.to_json) + encoded_values = Base64.urlsafe_encode64(prefill_values_data.to_json) + + params = ActionController::Parameters.new({ + prefill_fields: encoded_fields, + prefill_values: encoded_values + }) + + allow(controller).to receive(:params).and_return(params) + + prefill_values = controller.send(:fetch_prefill_values_if_available) + + # Step 2: Simulate existing submitter values + existing_submitter_values = { 'field-1-uuid' => 'Existing John' } + + # Step 3: Merge ATS values with existing values + final_values = merge_prefill_values(existing_submitter_values, prefill_values, template_fields) + + # Step 4: Verify final result + expect(final_values).to eq({ + 'field-1-uuid' => 'Existing John', # Existing value preserved + 'field-2-uuid' => 'Smith', # ATS value applied + 'field-3-uuid' => 'john.smith@company.com' # ATS value applied + }) + end + end +end diff --git a/spec/lib/prefill/cache_manager_spec.rb b/spec/lib/prefill/cache_manager_spec.rb new file mode 100644 index 00000000..fda65581 --- /dev/null +++ b/spec/lib/prefill/cache_manager_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Prefill::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/prefill/field_extractor_spec.rb b/spec/lib/prefill/field_extractor_spec.rb new file mode 100644 index 00000000..5bafe271 --- /dev/null +++ b/spec/lib/prefill/field_extractor_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Prefill::FieldExtractor do + describe '.call' do + context 'when prefill_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(prefill_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 = Prefill::CacheManager.generate_cache_key('prefill_fields', encoded_fields) + + allow(Prefill::CacheManager).to receive(:fetch_field_extraction).and_call_original + + described_class.call(params) + + expect(Prefill::CacheManager).to have_received(:fetch_field_extraction).with(cache_key) + end + + it 'returns cached result on subsequent calls' do + cache_key = Prefill::CacheManager.generate_cache_key('prefill_fields', encoded_fields) + cached_result = ['cached_field'] + + allow(Prefill::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 prefill_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 prefill_fields parameter is blank' do + let(:params) { ActionController::Parameters.new(prefill_fields: '') } + + it 'returns an empty array' do + result = described_class.call(params) + expect(result).to eq([]) + end + end + + context 'when prefill_fields parameter is invalid' do + let(:params) { ActionController::Parameters.new(prefill_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(prefill_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(prefill_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(prefill_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(prefill_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(prefill_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(prefill_fields: invalid_json) + + result = described_class.call(params) + expect(result).to eq([]) + end + + it 'handles Base64 decoding errors gracefully' do + params = ActionController::Parameters.new(prefill_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/prefill/field_mapper_spec.rb b/spec/lib/prefill/field_mapper_spec.rb new file mode 100644 index 00000000..9a766679 --- /dev/null +++ b/spec/lib/prefill/field_mapper_spec.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Prefill::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 = Prefill::CacheManager.generate_cache_key('field_mapping', cache_signature) + + allow(Prefill::CacheManager).to receive(:fetch_field_mapping).and_call_original + + described_class.call(template_fields) + + expect(Prefill::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 = Prefill::CacheManager.generate_cache_key('field_mapping', cache_signature) + cached_result = { 'cached_field' => 'cached_uuid' } + + allow(Prefill::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/prefill/value_merger_spec.rb b/spec/lib/prefill/value_merger_spec.rb new file mode 100644 index 00000000..8b11aa2a --- /dev/null +++ b/spec/lib/prefill/value_merger_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Prefill::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(:prefill_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, prefill_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, prefill_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 + prefill_values_with_unknown = prefill_values.merge('unknown_field' => 'Unknown Value') + + result = described_class.call(submitter_values, prefill_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(Prefill::FieldMapper).to receive(:call).and_return(expected_mapping) + + described_class.call(submitter_values, prefill_values, template_fields) + + expect(Prefill::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, prefill_values, nil) + expect(result).to eq(submitter_values) + end + end + + context 'when prefill_values is blank' do + it 'returns original submitter_values for nil prefill_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 prefill_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, prefill_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' => '' } + prefill_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, prefill_values, template_fields) + + expect(result['field-1-uuid']).to eq('John') + end + + it 'treats nil as blank' do + submitter_values = { 'field-1-uuid' => nil } + prefill_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, prefill_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 } + prefill_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, prefill_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 } + prefill_values = { 'employee_first_name' => 'John' } + + result = described_class.call(submitter_values, prefill_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(Prefill::FieldMapper).to receive(:call).and_return({}) + + result = described_class.call(submitter_values, prefill_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' + } + + prefill_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, prefill_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, prefill_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/prefill_spec.rb b/spec/lib/prefill_spec.rb new file mode 100644 index 00000000..f800e444 --- /dev/null +++ b/spec/lib/prefill_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Prefill 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(prefill_fields: 'encoded_data') } + + it 'delegates to FieldExtractor' do + expected_result = %w[employee_first_name employee_last_name] + + allow(Prefill::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(:prefill_values) { { 'employee_last_name' => 'Doe' } } + + it 'delegates to ValueMerger' do + expected_result = { 'field-1-uuid' => 'John', 'field-2-uuid' => 'Doe' } + + allow(Prefill::ValueMerger).to receive(:call).with(submitter_values, prefill_values, + template_fields).and_return(expected_result) + + result = described_class.merge_values(submitter_values, prefill_values, template_fields) + expect(result).to eq(expected_result) + end + + it 'handles nil template_fields' do + allow(Prefill::ValueMerger).to receive(:call).with(submitter_values, prefill_values, + nil).and_return(submitter_values) + + result = described_class.merge_values(submitter_values, prefill_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(Prefill::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(Prefill::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(Prefill::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(Prefill::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(prefill_fields: encoded_fields) + end + + let(:submitter_values) { { 'field-1-uuid' => 'Existing Name' } } + let(:prefill_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, prefill_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 prefill + }) + 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(Prefill::FieldExtractor).to receive(:call).and_raise(StandardError, 'Test error') + + expect { described_class.extract_fields({}) }.to raise_error(StandardError, 'Test error') + end + end +end