diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 181bacd5..66940486 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -17,10 +17,10 @@ class SubmissionsController < ApplicationController def show @submission = Submissions.preload_with_pages(@submission) - @available_ats_fields = extract_ats_prefill_fields + @available_prefill_fields = extract_prefill_fields # Optional: store in session for persistence across requests - session[:ats_prefill_fields] = @available_ats_fields if @available_ats_fields.any? + session[:prefill_fields] = @available_prefill_fields if @available_prefill_fields.any? unless @submission.submitters.all?(&:completed_at?) ActiveRecord::Associations::Preloader.new( diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index e2be5cba..955b14a4 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -28,6 +28,9 @@ class SubmitFormController < ApplicationController Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) + # Fetch prefill values if available + @prefill_values = fetch_prefill_values_if_available + @attachments_index = build_attachments_index(submission) return unless @form_configs[:prefill_signature] @@ -98,4 +101,26 @@ class SubmitFormController < ApplicationController ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) .preload(:blob).index_by(&:uuid) end + + def fetch_prefill_values_if_available + # External system passes values directly as Base64-encoded JSON parameters + return {} if params[:prefill_values].blank? + + # Security: Limit input size to prevent DoS attacks (64KB limit) + return {} if params[:prefill_values].bytesize > 65_536 + + begin + decoded_json = Base64.urlsafe_decode64(params[:prefill_values]) + + # Security: Limit decoded JSON size as well + return {} if decoded_json.bytesize > 32_768 + + prefill_values = JSON.parse(decoded_json) + + # Validate that we got a hash + prefill_values.is_a?(Hash) ? prefill_values : {} + rescue StandardError + {} + end + end end diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 2e2e5919..180b082e 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -42,8 +42,8 @@ class TemplatesController < ApplicationController associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]] ).call - # Process ATS fields for template editing - @available_ats_fields = extract_ats_prefill_fields + # Process prefill fields for template editing + @available_prefill_fields = extract_prefill_fields @template_data = @template.as_json.merge( @@ -51,7 +51,7 @@ class TemplatesController < ApplicationController methods: %i[metadata signed_uuid], include: { preview_images: { methods: %i[url metadata filename] } } ), - available_ats_fields: @available_ats_fields + available_prefill_fields: @available_prefill_fields ).to_json render :edit, layout: 'plain' diff --git a/app/helpers/prefill_fields_helper.rb b/app/helpers/prefill_fields_helper.rb index 260c3902..663163e2 100644 --- a/app/helpers/prefill_fields_helper.rb +++ b/app/helpers/prefill_fields_helper.rb @@ -1,88 +1,119 @@ # 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 - - def extract_ats_prefill_fields - return [] if params[:ats_fields].blank? - - # Create cache key from parameter hash for security and uniqueness - cache_key = ats_fields_cache_key(params[:ats_fields]) - - # Try to get from cache first with error handling - begin - cached_result = Rails.cache.read(cache_key) - if cached_result - Rails.logger.debug { "ATS fields cache hit for key: #{cache_key}" } - return cached_result - end - rescue StandardError => e - Rails.logger.warn "Cache read failed for ATS fields: #{e.message}" - # Continue with normal processing if cache read fails - end + # 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. + # + # @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) + # extract_prefill_fields + # # => ['employee_first_name', 'employee_email'] + def extract_prefill_fields + Prefill.extract_fields(params) + end - # Cache miss - perform expensive operations - Rails.logger.debug { "ATS fields cache miss for key: #{cache_key}" } + # 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' } + # ] + # + # merge_prefill_values(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 merge_prefill_values(submitter_values, prefill_values, template_fields = nil) + Prefill.merge_values(submitter_values, prefill_values, template_fields) + end - begin - decoded_json = Base64.urlsafe_decode64(params[:ats_fields]) - field_names = JSON.parse(decoded_json) + # 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_by_name('employee_first_name', template_fields) + # # => 'field-uuid-123' + def find_field_uuid_by_name(field_name, template_fields = nil) + Prefill.find_field_uuid(field_name, template_fields) + end - # Validate that we got an array of strings - return cache_and_return_empty(cache_key) unless field_names.is_a?(Array) && field_names.all?(String) + # Clears prefill fields cache (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_prefill_fields_cache + Prefill.clear_cache + end - # Filter to only expected field name patterns - valid_fields = field_names.select { |name| valid_ats_field_name?(name) } + # Legacy method aliases for backward compatibility + alias build_field_lookup_cache merge_prefill_values - # Cache the result with TTL (with error handling) - cache_result(cache_key, valid_fields, ATS_FIELDS_CACHE_TTL) + private - # Log successful field reception - Rails.logger.info "Processed and cached #{valid_fields.length} ATS prefill fields: #{valid_fields.join(', ')}" + # Legacy private methods maintained for any potential direct usage + # These now delegate to the service layer for consistency - valid_fields - rescue StandardError => e - Rails.logger.warn "Failed to parse ATS prefill fields: #{e.message}" - # Cache empty result for failed parsing to avoid repeated failures - cache_result(cache_key, [], 5.minutes) - [] - end + def read_from_cache(cache_key) + Prefill::CacheManager.read_from_cache(cache_key) end - # Clear ATS fields cache (useful for testing or manual cache invalidation) - 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 - Rails.logger.info 'ATS fields cache clear requested (relies on TTL for cleanup)' + def parse_prefill_fields_param(prefill_fields_param) + # This is now handled internally by FieldExtractor + # Kept for backward compatibility but not recommended for direct use + Prefill::FieldExtractor.send(:parse_encoded_fields, prefill_fields_param) end - private + def validate_and_filter_field_names(field_names) + # This is now handled internally by FieldExtractor + # Kept for backward compatibility but not recommended for direct use + Prefill::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/) + def valid_prefill_field_name?(name) + # This is now handled internally by FieldExtractor + # Kept for backward compatibility but not recommended for direct use + Prefill::FieldExtractor.send(:valid_prefill_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}" + def prefill_fields_cache_key(prefill_fields_param) + Prefill::CacheManager.generate_cache_key('prefill_fields', prefill_fields_param) end def cache_result(cache_key, value, ttl) - Rails.cache.write(cache_key, value, expires_in: ttl) - rescue StandardError => e - Rails.logger.warn "Cache write failed for ATS fields: #{e.message}" - # Continue execution even if caching fails + Prefill::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 + + def field_lookup_cache_key(template_fields) + signature = Prefill::FieldMapper.send(:build_cache_signature, template_fields) + Prefill::CacheManager.generate_cache_key('field_mapping', signature) + end end diff --git a/app/javascript/template_builder/field_settings.vue b/app/javascript/template_builder/field_settings.vue index e645e01e..e8200ae7 100644 --- a/app/javascript/template_builder/field_settings.vue +++ b/app/javascript/template_builder/field_settings.vue @@ -256,14 +256,14 @@
  • { @@ -594,7 +594,7 @@ export default { } }, methods: { - formatAtsFieldName (fieldName) { + formatPrefillFieldName (fieldName) { // Convert snake_case to Title Case for display return fieldName .split('_') diff --git a/app/views/submit_form/_submission_form.html.erb b/app/views/submit_form/_submission_form.html.erb index bccf174b..b00e1989 100644 --- a/app/views/submit_form/_submission_form.html.erb +++ b/app/views/submit_form/_submission_form.html.erb @@ -2,4 +2,4 @@ <% data_fields = Submissions.filtered_conditions_fields(submitter).to_json %> <% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> <% optional_invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['optional_invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> - + diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb index bdd5351f..69323c8f 100644 --- a/app/views/submit_form/show.html.erb +++ b/app/views/submit_form/show.html.erb @@ -1,7 +1,10 @@ <% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %> <% content_for(:html_description, "#{@submitter.account.name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %> -<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %> -<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %> +<% template_fields = @submitter.submission.template_fields || @submitter.submission.template.fields %> +<% fields_index = Templates.build_field_areas_index(template_fields) %> +<% submitter_values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %> +<% values = merge_prefill_values(submitter_values, @prefill_values || {}, template_fields) %> + <% submitters_index = @submitter.submission.submitters.index_by(&:uuid) %> <% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %> <% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %> @@ -102,7 +105,7 @@ diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 3ffb9b95..7b5853b4 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -22,6 +22,7 @@ en: &en hi_there: Hi there thanks: Thanks private: Private + 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 @@ -907,6 +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 + 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:" @@ -1741,6 +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 + 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:" @@ -2575,6 +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 + 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:' @@ -3411,6 +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 + 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:' @@ -4247,6 +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 + 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/helpers/prefill_fields_helper_spec.rb b/spec/helpers/prefill_fields_helper_spec.rb index 14d79232..9ce3634d 100644 --- a/spec/helpers/prefill_fields_helper_spec.rb +++ b/spec/helpers/prefill_fields_helper_spec.rb @@ -3,348 +3,233 @@ require 'rails_helper' RSpec.describe PrefillFieldsHelper, type: :helper do - # Clear cache before each test to ensure clean state - before do - Rails.cache.clear + 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 '#extract_ats_prefill_fields' do - it 'extracts valid field names from base64 encoded parameter' do - fields = %w[employee_first_name employee_email manager_firstname] - encoded = Base64.urlsafe_encode64(fields.to_json) - - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - - result = helper.extract_ats_prefill_fields - expect(result).to eq(fields) - end - - it 'returns empty array for invalid base64' do - allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) - - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) - end - - it 'returns empty array for invalid JSON' do - invalid_json = Base64.urlsafe_encode64('invalid json') - allow(helper).to receive(:params).and_return({ ats_fields: invalid_json }) - - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) - end - - it 'filters out invalid field names' do - fields = %w[employee_first_name malicious_field account_name invalid-field] - encoded = Base64.urlsafe_encode64(fields.to_json) - - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) + describe '#find_field_uuid_by_name' do + context 'when template_fields is provided' do + it 'returns the correct UUID for a matching ATS field name' do + uuid = helper.send(:find_field_uuid_by_name, 'employee_first_name', template_fields) + expect(uuid).to eq('field-1-uuid') + end - result = helper.extract_ats_prefill_fields - expect(result).to eq(%w[employee_first_name account_name]) - end + it 'returns the correct UUID for another matching ATS field name' do + uuid = helper.send(:find_field_uuid_by_name, 'employee_email', template_fields) + expect(uuid).to eq('field-3-uuid') + end - it 'returns empty array when no ats_fields parameter' do - allow(helper).to receive(:params).and_return({}) + it 'returns nil for a non-matching ATS field name' do + uuid = helper.send(:find_field_uuid_by_name, 'non_existent_field', template_fields) + expect(uuid).to be_nil + end - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) + it 'returns nil for a field without prefill attribute' do + uuid = helper.send(:find_field_uuid_by_name, 'signature', template_fields) + expect(uuid).to be_nil + end end - it 'returns empty array when ats_fields parameter is empty' do - allow(helper).to receive(:params).and_return({ ats_fields: '' }) - - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) + context 'when template_fields is nil' do + it 'returns nil' do + uuid = helper.send(:find_field_uuid_by_name, 'employee_first_name', nil) + expect(uuid).to be_nil + end end - it 'returns empty array when decoded JSON is not an array' do - not_array = Base64.urlsafe_encode64({ field: 'employee_name' }.to_json) - allow(helper).to receive(:params).and_return({ ats_fields: not_array }) - - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) + context 'when template_fields is empty' do + it 'returns nil' do + uuid = helper.send(:find_field_uuid_by_name, 'employee_first_name', []) + expect(uuid).to be_nil + end end - it 'returns empty array when array contains non-string values' do - mixed_array = ['employee_first_name', 123, 'manager_firstname'] - encoded = Base64.urlsafe_encode64(mixed_array.to_json) - - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) + context 'when field_name is blank' do + it 'returns nil for nil field_name' do + uuid = helper.send(:find_field_uuid_by_name, nil, template_fields) + expect(uuid).to be_nil + end - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) + it 'returns nil for empty field_name' do + uuid = helper.send(:find_field_uuid_by_name, '', template_fields) + expect(uuid).to be_nil + end end + end - it 'accepts all valid field name patterns' do - 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(fields.to_json) - - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - - result = helper.extract_ats_prefill_fields - expect(result).to eq(fields) + describe '#merge_prefill_values' do + let(:submitter_values) do + { + 'field-1-uuid' => 'Existing First Name', + 'field-4-uuid' => 'Existing Signature' + } end - it 'logs successful field reception on cache miss' do - fields = %w[employee_first_name employee_email] - encoded = Base64.urlsafe_encode64(fields.to_json) - - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.logger).to receive(:info) - allow(Rails.logger).to receive(:debug) - - helper.extract_ats_prefill_fields - - expect(Rails.logger).to have_received(:info).with( - 'Processed and cached 2 ATS prefill fields: employee_first_name, employee_email' - ) + let(:prefill_values) do + { + 'employee_first_name' => 'John', + 'employee_last_name' => 'Doe', + 'employee_email' => 'john.doe@example.com' + } end - it 'logs parsing errors and caches empty result' do - allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) - allow(Rails.logger).to receive(:warn) - allow(Rails.logger).to receive(:debug) - - result = helper.extract_ats_prefill_fields - expect(result).to eq([]) + context 'when template_fields is provided' do + it 'merges ATS values for fields that do not have existing submitter values' do + result = helper.merge_prefill_values(submitter_values, prefill_values, template_fields) - expect(Rails.logger).to have_received(:warn).with( - a_string_matching(/Failed to parse ATS prefill fields:/) - ) - end - - # Caching-specific tests - describe 'caching behavior' do - let(:fields) { %w[employee_first_name employee_email manager_firstname] } - let(:encoded) { Base64.urlsafe_encode64(fields.to_json) } - - # Use memory store for caching tests since test environment uses null_store - around do |example| - original_cache = Rails.cache - Rails.cache = ActiveSupport::Cache::MemoryStore.new - example.run - Rails.cache = original_cache + 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 'caches successful parsing results' do - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.logger).to receive(:info) - allow(Rails.logger).to receive(:debug) + it 'does not overwrite existing submitter values' do + result = helper.merge_prefill_values(submitter_values, prefill_values, template_fields) - # First call should parse and cache - result1 = helper.extract_ats_prefill_fields - expect(result1).to eq(fields) - - # Verify cache write occurred - cache_key = helper.send(:ats_fields_cache_key, encoded) - cached_value = Rails.cache.read(cache_key) - expect(cached_value).to eq(fields) + expect(result['field-1-uuid']).to eq('Existing First Name') end - it 'returns cached results on subsequent calls' do - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.logger).to receive(:info) - allow(Rails.logger).to receive(:debug) - - # First call - cache miss - result1 = helper.extract_ats_prefill_fields - expect(result1).to eq(fields) - - # Verify cache miss was logged - expect(Rails.logger).to have_received(:debug).at_least(:once) do |&block| - block&.call&.include?('cache miss') - end - - # Reset logger expectations - allow(Rails.logger).to receive(:debug) + it 'ignores ATS values for fields without matching prefill attributes' do + prefill_values_with_unknown = prefill_values.merge('unknown_field' => 'Unknown Value') - # Second call - should be cache hit - result2 = helper.extract_ats_prefill_fields - expect(result2).to eq(fields) + result = helper.merge_prefill_values(submitter_values, prefill_values_with_unknown, template_fields) - # Verify cache hit was logged - expect(Rails.logger).to have_received(:debug).at_least(:once) do |&block| - block&.call&.include?('cache hit') - end + expect(result.keys).not_to include('unknown_field') end + end - it 'caches empty results for parsing errors' do - allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) - allow(Rails.logger).to receive(:warn) - allow(Rails.logger).to receive(:debug) - - # First call should fail and cache empty result - result1 = helper.extract_ats_prefill_fields - expect(result1).to eq([]) - - # Verify empty result is cached - cache_key = helper.send(:ats_fields_cache_key, 'invalid_base64') - cached_value = Rails.cache.read(cache_key) - expect(cached_value).to eq([]) - - # Reset logger expectations - allow(Rails.logger).to receive(:debug) - - # Second call should return cached empty result - result2 = helper.extract_ats_prefill_fields - expect(result2).to eq([]) - - # Verify cache hit was logged - expect(Rails.logger).to have_received(:debug).at_least(:once) do |&block| - block&.call&.include?('cache hit') - end + context 'when template_fields is nil' do + it 'returns original submitter_values unchanged' do + result = helper.merge_prefill_values(submitter_values, prefill_values, nil) + expect(result).to eq(submitter_values) end + end - it 'generates consistent cache keys for same input' do - key1 = helper.send(:ats_fields_cache_key, encoded) - key2 = helper.send(:ats_fields_cache_key, encoded) - - expect(key1).to eq(key2) - expect(key1).to start_with('ats_fields:') - expect(key1.length).to be > 20 # Should be a reasonable hash length + context 'when prefill_values is blank' do + it 'returns original submitter_values for nil prefill_values' do + result = helper.merge_prefill_values(submitter_values, nil, template_fields) + expect(result).to eq(submitter_values) end - it 'generates different cache keys for different inputs' do - fields2 = %w[manager_lastname location_name] - encoded2 = Base64.urlsafe_encode64(fields2.to_json) - - key1 = helper.send(:ats_fields_cache_key, encoded) - key2 = helper.send(:ats_fields_cache_key, encoded2) - - expect(key1).not_to eq(key2) + it 'returns original submitter_values for empty prefill_values' do + result = helper.merge_prefill_values(submitter_values, {}, template_fields) + expect(result).to eq(submitter_values) end + end - it 'respects cache TTL for successful results' do - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.cache).to receive(:write).and_call_original - - helper.extract_ats_prefill_fields - - expect(Rails.cache).to have_received(:write).with( - anything, - fields, - expires_in: PrefillFieldsHelper::ATS_FIELDS_CACHE_TTL - ) + 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 'uses shorter TTL for error results' do - allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) - allow(Rails.cache).to receive(:write).and_call_original - allow(Rails.logger).to receive(:warn) - - helper.extract_ats_prefill_fields + it 'fills blank submitter values with ATS values' do + result = helper.merge_prefill_values(submitter_values_with_blanks, prefill_values, template_fields) - expect(Rails.cache).to have_received(:write).with( - anything, - [], - expires_in: 5.minutes + 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 + end + end - it 'handles cache read failures gracefully' do - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.cache).to receive(:read).and_raise(StandardError.new('Cache error')) - allow(Rails.logger).to receive(:info) - allow(Rails.logger).to receive(:debug) - allow(Rails.logger).to receive(:warn) - - # Should fall back to normal processing - result = helper.extract_ats_prefill_fields - expect(result).to eq(fields) - expect(Rails.logger).to have_received(:warn).with('Cache read failed for ATS fields: Cache error') - end + describe '#extract_prefill_fields' do + before do + allow(helper).to receive(:params).and_return(params) + end - it 'handles cache write failures gracefully' do - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.cache).to receive(:write).and_raise(StandardError.new('Cache error')) - allow(Rails.logger).to receive(:info) - allow(Rails.logger).to receive(:debug) - allow(Rails.logger).to receive(:warn) + 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) { { prefill_fields: encoded_fields } } - # Should still return correct result even if caching fails - result = helper.extract_ats_prefill_fields + it 'decodes and returns the ATS fields' do + result = helper.extract_prefill_fields expect(result).to eq(fields) - expect(Rails.logger).to have_received(:warn).with('Cache write failed for ATS fields: Cache error') end - end - - describe 'performance characteristics' do - let(:fields) { %w[employee_first_name employee_email manager_firstname] } - let(:encoded) { Base64.urlsafe_encode64(fields.to_json) } - # Use memory store for performance tests since test environment uses null_store - around do |example| - original_cache = Rails.cache - Rails.cache = ActiveSupport::Cache::MemoryStore.new - example.run - Rails.cache = original_cache - end + it 'caches the result' do + # The implementation now uses AtsPrefill service which uses Rails.cache.fetch + cache_key = Prefill::CacheManager.generate_cache_key('prefill_fields', encoded_fields) - it 'avoids expensive operations on cache hits' do - allow(helper).to receive(:params).and_return({ ats_fields: encoded }) - allow(Rails.logger).to receive(:info) - allow(Rails.logger).to receive(:debug) + # Mock the cache to verify it's being used + allow(Rails.cache).to receive(:fetch).and_call_original - # First call to populate cache - helper.extract_ats_prefill_fields + helper.extract_prefill_fields - # Mock expensive operations to verify they're not called on cache hit - allow(Base64).to receive(:urlsafe_decode64).and_call_original - allow(JSON).to receive(:parse).and_call_original + expect(Rails.cache).to have_received(:fetch).with(cache_key, expires_in: 3600) + end + end - # Second call should use cache - result = helper.extract_ats_prefill_fields - expect(result).to eq(fields) + context 'when prefill_fields parameter is missing' do + let(:params) { {} } - # Verify expensive operations were not called on second call - expect(Base64).not_to have_received(:urlsafe_decode64) - expect(JSON).not_to have_received(:parse) + it 'returns an empty array' do + result = helper.extract_prefill_fields + expect(result).to eq([]) end end - end - describe '#valid_ats_field_name?' do - it 'returns true for valid employee field names' do - expect(helper.send(:valid_ats_field_name?, 'employee_first_name')).to be true - expect(helper.send(:valid_ats_field_name?, 'employee_email')).to be true - expect(helper.send(:valid_ats_field_name?, 'employee_phone_number')).to be true - end + context 'when prefill_fields parameter is invalid' do + let(:params) { { prefill_fields: 'invalid-base64' } } - it 'returns true for valid manager field names' do - expect(helper.send(:valid_ats_field_name?, 'manager_firstname')).to be true - expect(helper.send(:valid_ats_field_name?, 'manager_lastname')).to be true - expect(helper.send(:valid_ats_field_name?, 'manager_email')).to be true + it 'returns an empty array' do + result = helper.extract_prefill_fields + expect(result).to eq([]) + end end - it 'returns true for valid account field names' do - expect(helper.send(:valid_ats_field_name?, 'account_name')).to be true - expect(helper.send(:valid_ats_field_name?, 'account_id')).to be true - end + it 'accepts all valid field name patterns' do + 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(fields.to_json) - it 'returns true for valid location field names' do - expect(helper.send(:valid_ats_field_name?, 'location_name')).to be true - expect(helper.send(:valid_ats_field_name?, 'location_street')).to be true - expect(helper.send(:valid_ats_field_name?, 'location_city')).to be true - end + allow(helper).to receive(:params).and_return({ prefill_fields: encoded }) - it 'returns false for invalid field names' do - expect(helper.send(:valid_ats_field_name?, 'malicious_field')).to be false - expect(helper.send(:valid_ats_field_name?, 'invalid-field')).to be false - expect(helper.send(:valid_ats_field_name?, 'EMPLOYEE_NAME')).to be false - expect(helper.send(:valid_ats_field_name?, 'employee')).to be false - expect(helper.send(:valid_ats_field_name?, 'employee_')).to be false - expect(helper.send(:valid_ats_field_name?, '_employee_name')).to be false + result = helper.extract_prefill_fields + expect(result).to eq(fields) end 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