Merge pull request #16 from CareerPlug/CP-10361

CP-10361 - Integrate form prefil values
pull/544/head
Bernardo Anderson 4 months ago committed by GitHub
commit d6c2b74b58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -17,10 +17,10 @@ class SubmissionsController < ApplicationController
def show def show
@submission = Submissions.preload_with_pages(@submission) @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 # 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?) unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(

@ -28,6 +28,9 @@ class SubmitFormController < ApplicationController
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
# Fetch prefill values if available
@prefill_values = fetch_prefill_values_if_available
@attachments_index = build_attachments_index(submission) @attachments_index = build_attachments_index(submission)
return unless @form_configs[:prefill_signature] return unless @form_configs[:prefill_signature]
@ -98,4 +101,26 @@ class SubmitFormController < ApplicationController
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid) .preload(:blob).index_by(&:uuid)
end 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 end

@ -42,8 +42,8 @@ class TemplatesController < ApplicationController
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]] associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
).call ).call
# Process ATS fields for template editing # Process prefill fields for template editing
@available_ats_fields = extract_ats_prefill_fields @available_prefill_fields = extract_prefill_fields
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
@ -51,7 +51,7 @@ class TemplatesController < ApplicationController
methods: %i[metadata signed_uuid], methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
), ),
available_ats_fields: @available_ats_fields available_prefill_fields: @available_prefill_fields
).to_json ).to_json
render :edit, layout: 'plain' render :edit, layout: 'plain'

@ -1,88 +1,119 @@
# frozen_string_literal: true # frozen_string_literal: true
module PrefillFieldsHelper module PrefillFieldsHelper
# Cache TTL for ATS field parsing (1 hour) # Extracts and validates prefill field names from Base64-encoded parameters
ATS_FIELDS_CACHE_TTL = 1.hour #
# This method decodes the prefill_fields parameter, validates the field names against
# Maximum number of cached entries to prevent memory bloat # allowed patterns, and caches the results to improve performance on repeated requests.
MAX_CACHE_ENTRIES = 1000 #
# @return [Array<String>] Array of valid prefill field names, empty array if none found or on error
def extract_ats_prefill_fields #
return [] if params[:ats_fields].blank? # @example
# # With params[:prefill_fields] = Base64.urlsafe_encode64(['employee_first_name', 'employee_email'].to_json)
# Create cache key from parameter hash for security and uniqueness # extract_prefill_fields
cache_key = ats_fields_cache_key(params[:ats_fields]) # # => ['employee_first_name', 'employee_email']
def extract_prefill_fields
# Try to get from cache first with error handling Prefill.extract_fields(params)
begin end
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
# Cache miss - perform expensive operations # Merges prefill values with existing submitter values
Rails.logger.debug { "ATS fields cache miss for key: #{cache_key}" } #
# 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<Hash>, 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 # Finds field UUID by matching prefill field name to template field's prefill attribute
decoded_json = Base64.urlsafe_decode64(params[:ats_fields]) #
field_names = JSON.parse(decoded_json) # 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<Hash>, 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 # Clears prefill fields cache (useful for testing or manual cache invalidation)
return cache_and_return_empty(cache_key) unless field_names.is_a?(Array) && field_names.all?(String) #
# 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 # Legacy method aliases for backward compatibility
valid_fields = field_names.select { |name| valid_ats_field_name?(name) } alias build_field_lookup_cache merge_prefill_values
# Cache the result with TTL (with error handling) private
cache_result(cache_key, valid_fields, ATS_FIELDS_CACHE_TTL)
# Log successful field reception # Legacy private methods maintained for any potential direct usage
Rails.logger.info "Processed and cached #{valid_fields.length} ATS prefill fields: #{valid_fields.join(', ')}" # These now delegate to the service layer for consistency
valid_fields def read_from_cache(cache_key)
rescue StandardError => e Prefill::CacheManager.read_from_cache(cache_key)
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
end end
# Clear ATS fields cache (useful for testing or manual cache invalidation) def parse_prefill_fields_param(prefill_fields_param)
def clear_ats_fields_cache # This is now handled internally by FieldExtractor
# Since we can't easily enumerate cache keys, we'll rely on TTL for cleanup # Kept for backward compatibility but not recommended for direct use
# This method is provided for potential future use or testing Prefill::FieldExtractor.send(:parse_encoded_fields, prefill_fields_param)
Rails.logger.info 'ATS fields cache clear requested (relies on TTL for cleanup)'
end 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) def valid_prefill_field_name?(name)
# Only allow expected field name patterns (security) # This is now handled internally by FieldExtractor
name.match?(/\A(employee|manager|account|location)_[a-z_]+\z/) # Kept for backward compatibility but not recommended for direct use
Prefill::FieldExtractor.send(:valid_prefill_field_name?, name)
end end
def ats_fields_cache_key(ats_fields_param) def prefill_fields_cache_key(prefill_fields_param)
# Create secure cache key using SHA256 hash of the parameter Prefill::CacheManager.generate_cache_key('prefill_fields', prefill_fields_param)
# This prevents cache key collisions and keeps keys reasonably sized
hash = Digest::SHA256.hexdigest(ats_fields_param)
"ats_fields:#{hash}"
end end
def cache_result(cache_key, value, ttl) def cache_result(cache_key, value, ttl)
Rails.cache.write(cache_key, value, expires_in: ttl) Prefill::CacheManager.write_to_cache(cache_key, value, ttl)
rescue StandardError => e
Rails.logger.warn "Cache write failed for ATS fields: #{e.message}"
# Continue execution even if caching fails
end end
def cache_and_return_empty(cache_key) def cache_and_return_empty(cache_key)
cache_result(cache_key, [], 5.minutes) cache_result(cache_key, [], 300) # 5 minutes
[] []
end 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 end

@ -256,14 +256,14 @@
</label> </label>
</div> </div>
<div <div
v-if="availableAtsFields && availableAtsFields.length > 0" v-if="availablePrefillFields && availablePrefillFields.length > 0"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@click.stop @click.stop
> >
<select <select
:placeholder="t('ats_field')" :placeholder="t('prefill_field')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent" class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
data-testid="ats-fields-dropdown" data-testid="prefill-fields-dropdown"
@change="[field.prefill = $event.target.value || undefined, !field.prefill && delete field.prefill, save()]" @change="[field.prefill = $event.target.value || undefined, !field.prefill && delete field.prefill, save()]"
> >
<option <option
@ -273,12 +273,12 @@
{{ '' }} {{ '' }}
</option> </option>
<option <option
v-for="atsField in availableAtsFields" v-for="prefillField in availablePrefillFields"
:key="atsField" :key="prefillField"
:value="atsField" :value="prefillField"
:selected="field.prefill === atsField" :selected="field.prefill === prefillField"
> >
{{ formatAtsFieldName(atsField) }} {{ formatPrefillFieldName(prefillField) }}
</option> </option>
</select> </select>
<label <label
@ -286,7 +286,7 @@
class="absolute -top-1 left-2.5 px-1 h-4" class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px" style="font-size: 8px"
> >
{{ t('ats_field') }} {{ 'Pre-fill Options' }}
</label> </label>
</div> </div>
<li <li
@ -526,14 +526,14 @@ export default {
} }
}, },
computed: { computed: {
availableAtsFields () { availablePrefillFields () {
return this.template.available_ats_fields || [] return this.template.available_prefill_fields || []
}, },
availableAtsFieldsForType () { availablePrefillFieldsForType () {
if (!this.template.ats_fields_by_type || !this.field.type) { if (!this.template.prefill_fields_by_type || !this.field.type) {
return [] return []
} }
return this.template.ats_fields_by_type[this.field.type] || [] return this.template.prefill_fields_by_type[this.field.type] || []
}, },
schemaAttachmentsIndexes () { schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => { return (this.template.schema || []).reduce((acc, item, index) => {
@ -594,7 +594,7 @@ export default {
} }
}, },
methods: { methods: {
formatAtsFieldName (fieldName) { formatPrefillFieldName (fieldName) {
// Convert snake_case to Title Case for display // Convert snake_case to Title Case for display
return fieldName return fieldName
.split('_') .split('_')

@ -2,4 +2,4 @@
<% data_fields = Submissions.filtered_conditions_fields(submitter).to_json %> <% 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 %> <% 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 %> <% 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 %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template&.preferences&.dig('completed_redirect_url') %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template&.preferences&.dig('completed_message') || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template&.preferences&.dig('completed_redirect_url') %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template&.preferences&.dig('completed_message') || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= local_assigns[:values]&.to_json || submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>

@ -1,7 +1,10 @@
<% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %> <% 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.") %> <% 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) %> <% template_fields = @submitter.submission.template_fields || @submitter.submission.template.fields %>
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %> <% 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) %> <% submitters_index = @submitter.submission.submitters.index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %> <% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %> <% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
@ -102,7 +105,7 @@
<div class="fixed bottom-0 w-full h-0 z-20"> <div class="fixed bottom-0 w-full h-0 z-20">
<div class="mx-auto" style="max-width: 1000px"> <div class="mx-auto" style="max-width: 1000px">
<div class="relative md:mx-32"> <div class="relative md:mx-32">
<%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px'), schema: %> <%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px'), schema:, values: values %>
</div> </div>
</div> </div>
</div> </div>

@ -22,6 +22,7 @@ en: &en
hi_there: Hi there hi_there: Hi there
thanks: Thanks thanks: Thanks
private: Private private: Private
prefill_field: Pre-fill Options
authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token
stripe_integration: Stripe Integration stripe_integration: Stripe Integration
require_all_recipients: Require all recipients 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. 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 hi_there: Hola
thanks: Gracias 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_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}".' 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:" 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." 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 hi_there: Ciao
thanks: Grazie 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_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}".' 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:" 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. 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 hi_there: Bonjour
thanks: Merci 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_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}".' 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:' 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. 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á hi_there: Olá
thanks: Obrigado 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_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}".' 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:' 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. 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 hi_there: Hallo
thanks: Danke 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_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.' 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:' 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:'

@ -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<String>] 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<Hash>, 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<Hash>, 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<Hash>, 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

@ -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<String>] 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

@ -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<String>] 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<String>] 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<String>, 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<String>] 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

@ -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<Hash>, 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<Hash>, 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<Hash>] 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<Hash>] 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

@ -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<Hash>, 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

@ -3,348 +3,233 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PrefillFieldsHelper, type: :helper do RSpec.describe PrefillFieldsHelper, type: :helper do
# Clear cache before each test to ensure clean state let(:template_fields) do
before do [
Rails.cache.clear {
'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 end
describe '#extract_ats_prefill_fields' do describe '#find_field_uuid_by_name' do
it 'extracts valid field names from base64 encoded parameter' do context 'when template_fields is provided' do
fields = %w[employee_first_name employee_email manager_firstname] it 'returns the correct UUID for a matching ATS field name' do
encoded = Base64.urlsafe_encode64(fields.to_json) uuid = helper.send(:find_field_uuid_by_name, 'employee_first_name', template_fields)
expect(uuid).to eq('field-1-uuid')
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) end
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 })
result = helper.extract_ats_prefill_fields it 'returns the correct UUID for another matching ATS field name' do
expect(result).to eq(%w[employee_first_name account_name]) uuid = helper.send(:find_field_uuid_by_name, 'employee_email', template_fields)
end expect(uuid).to eq('field-3-uuid')
end
it 'returns empty array when no ats_fields parameter' do it 'returns nil for a non-matching ATS field name' do
allow(helper).to receive(:params).and_return({}) 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 it 'returns nil for a field without prefill attribute' do
expect(result).to eq([]) uuid = helper.send(:find_field_uuid_by_name, 'signature', template_fields)
expect(uuid).to be_nil
end
end end
it 'returns empty array when ats_fields parameter is empty' do context 'when template_fields is nil' do
allow(helper).to receive(:params).and_return({ ats_fields: '' }) it 'returns nil' do
uuid = helper.send(:find_field_uuid_by_name, 'employee_first_name', nil)
result = helper.extract_ats_prefill_fields expect(uuid).to be_nil
expect(result).to eq([]) end
end end
it 'returns empty array when decoded JSON is not an array' do context 'when template_fields is empty' do
not_array = Base64.urlsafe_encode64({ field: 'employee_name' }.to_json) it 'returns nil' do
allow(helper).to receive(:params).and_return({ ats_fields: not_array }) uuid = helper.send(:find_field_uuid_by_name, 'employee_first_name', [])
expect(uuid).to be_nil
result = helper.extract_ats_prefill_fields end
expect(result).to eq([])
end end
it 'returns empty array when array contains non-string values' do context 'when field_name is blank' do
mixed_array = ['employee_first_name', 123, 'manager_firstname'] it 'returns nil for nil field_name' do
encoded = Base64.urlsafe_encode64(mixed_array.to_json) uuid = helper.send(:find_field_uuid_by_name, nil, template_fields)
expect(uuid).to be_nil
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) end
result = helper.extract_ats_prefill_fields it 'returns nil for empty field_name' do
expect(result).to eq([]) uuid = helper.send(:find_field_uuid_by_name, '', template_fields)
expect(uuid).to be_nil
end
end end
end
it 'accepts all valid field name patterns' do describe '#merge_prefill_values' do
fields = %w[ let(:submitter_values) do
employee_first_name {
employee_middle_name 'field-1-uuid' => 'Existing First Name',
employee_last_name 'field-4-uuid' => 'Existing Signature'
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)
end end
it 'logs successful field reception on cache miss' do let(:prefill_values) do
fields = %w[employee_first_name employee_email] {
encoded = Base64.urlsafe_encode64(fields.to_json) 'employee_first_name' => 'John',
'employee_last_name' => 'Doe',
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) 'employee_email' => 'john.doe@example.com'
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'
)
end end
it 'logs parsing errors and caches empty result' do context 'when template_fields is provided' do
allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) it 'merges ATS values for fields that do not have existing submitter values' do
allow(Rails.logger).to receive(:warn) result = helper.merge_prefill_values(submitter_values, prefill_values, template_fields)
allow(Rails.logger).to receive(:debug)
result = helper.extract_ats_prefill_fields
expect(result).to eq([])
expect(Rails.logger).to have_received(:warn).with( expect(result).to include(
a_string_matching(/Failed to parse ATS prefill fields:/) 'field-1-uuid' => 'Existing First Name', # Should not be overwritten
) 'field-2-uuid' => 'Doe', # Should be set from ATS
end 'field-3-uuid' => 'john.doe@example.com', # Should be set from ATS
'field-4-uuid' => 'Existing Signature' # Should remain unchanged
# 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
end end
it 'caches successful parsing results' do it 'does not overwrite existing submitter values' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) result = helper.merge_prefill_values(submitter_values, prefill_values, template_fields)
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
# First call should parse and cache expect(result['field-1-uuid']).to eq('Existing First Name')
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)
end end
it 'returns cached results on subsequent calls' do it 'ignores ATS values for fields without matching prefill attributes' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) prefill_values_with_unknown = prefill_values.merge('unknown_field' => 'Unknown Value')
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)
# Second call - should be cache hit result = helper.merge_prefill_values(submitter_values, prefill_values_with_unknown, template_fields)
result2 = helper.extract_ats_prefill_fields
expect(result2).to eq(fields)
# Verify cache hit was logged expect(result.keys).not_to include('unknown_field')
expect(Rails.logger).to have_received(:debug).at_least(:once) do |&block|
block&.call&.include?('cache hit')
end
end end
end
it 'caches empty results for parsing errors' do context 'when template_fields is nil' do
allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) it 'returns original submitter_values unchanged' do
allow(Rails.logger).to receive(:warn) result = helper.merge_prefill_values(submitter_values, prefill_values, nil)
allow(Rails.logger).to receive(:debug) expect(result).to eq(submitter_values)
# 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
end end
end
it 'generates consistent cache keys for same input' do context 'when prefill_values is blank' do
key1 = helper.send(:ats_fields_cache_key, encoded) it 'returns original submitter_values for nil prefill_values' do
key2 = helper.send(:ats_fields_cache_key, encoded) result = helper.merge_prefill_values(submitter_values, nil, template_fields)
expect(result).to eq(submitter_values)
expect(key1).to eq(key2)
expect(key1).to start_with('ats_fields:')
expect(key1.length).to be > 20 # Should be a reasonable hash length
end end
it 'generates different cache keys for different inputs' do it 'returns original submitter_values for empty prefill_values' do
fields2 = %w[manager_lastname location_name] result = helper.merge_prefill_values(submitter_values, {}, template_fields)
encoded2 = Base64.urlsafe_encode64(fields2.to_json) expect(result).to eq(submitter_values)
key1 = helper.send(:ats_fields_cache_key, encoded)
key2 = helper.send(:ats_fields_cache_key, encoded2)
expect(key1).not_to eq(key2)
end end
end
it 'respects cache TTL for successful results' do context 'when submitter_values has blank values' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) let(:submitter_values_with_blanks) do
allow(Rails.cache).to receive(:write).and_call_original {
'field-1-uuid' => '',
helper.extract_ats_prefill_fields 'field-2-uuid' => nil,
'field-4-uuid' => 'Existing Signature'
expect(Rails.cache).to have_received(:write).with( }
anything,
fields,
expires_in: PrefillFieldsHelper::ATS_FIELDS_CACHE_TTL
)
end end
it 'uses shorter TTL for error results' do it 'fills blank submitter values with ATS values' do
allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) result = helper.merge_prefill_values(submitter_values_with_blanks, prefill_values, template_fields)
allow(Rails.cache).to receive(:write).and_call_original
allow(Rails.logger).to receive(:warn)
helper.extract_ats_prefill_fields
expect(Rails.cache).to have_received(:write).with( expect(result).to include(
anything, 'field-1-uuid' => 'John', # Should be filled from ATS (was blank)
[], 'field-2-uuid' => 'Doe', # Should be filled from ATS (was nil)
expires_in: 5.minutes '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
end
it 'handles cache read failures gracefully' do describe '#extract_prefill_fields' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) before do
allow(Rails.cache).to receive(:read).and_raise(StandardError.new('Cache error')) allow(helper).to receive(:params).and_return(params)
allow(Rails.logger).to receive(:info) end
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
it 'handles cache write failures gracefully' do context 'when prefill_fields parameter is present' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) let(:fields) { %w[employee_first_name employee_last_name employee_email] }
allow(Rails.cache).to receive(:write).and_raise(StandardError.new('Cache error')) let(:encoded_fields) { Base64.urlsafe_encode64(fields.to_json) }
allow(Rails.logger).to receive(:info) let(:params) { { prefill_fields: encoded_fields } }
allow(Rails.logger).to receive(:debug)
allow(Rails.logger).to receive(:warn)
# Should still return correct result even if caching fails it 'decodes and returns the ATS fields' do
result = helper.extract_ats_prefill_fields result = helper.extract_prefill_fields
expect(result).to eq(fields) expect(result).to eq(fields)
expect(Rails.logger).to have_received(:warn).with('Cache write failed for ATS fields: Cache error')
end 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 it 'caches the result' do
around do |example| # The implementation now uses AtsPrefill service which uses Rails.cache.fetch
original_cache = Rails.cache cache_key = Prefill::CacheManager.generate_cache_key('prefill_fields', encoded_fields)
Rails.cache = ActiveSupport::Cache::MemoryStore.new
example.run
Rails.cache = original_cache
end
it 'avoids expensive operations on cache hits' do # Mock the cache to verify it's being used
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) allow(Rails.cache).to receive(:fetch).and_call_original
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
# First call to populate cache helper.extract_prefill_fields
helper.extract_ats_prefill_fields
# Mock expensive operations to verify they're not called on cache hit expect(Rails.cache).to have_received(:fetch).with(cache_key, expires_in: 3600)
allow(Base64).to receive(:urlsafe_decode64).and_call_original end
allow(JSON).to receive(:parse).and_call_original end
# Second call should use cache context 'when prefill_fields parameter is missing' do
result = helper.extract_ats_prefill_fields let(:params) { {} }
expect(result).to eq(fields)
# Verify expensive operations were not called on second call it 'returns an empty array' do
expect(Base64).not_to have_received(:urlsafe_decode64) result = helper.extract_prefill_fields
expect(JSON).not_to have_received(:parse) expect(result).to eq([])
end end
end end
end
describe '#valid_ats_field_name?' do context 'when prefill_fields parameter is invalid' do
it 'returns true for valid employee field names' do let(:params) { { prefill_fields: 'invalid-base64' } }
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
it 'returns true for valid manager field names' do it 'returns an empty array' do
expect(helper.send(:valid_ats_field_name?, 'manager_firstname')).to be true result = helper.extract_prefill_fields
expect(helper.send(:valid_ats_field_name?, 'manager_lastname')).to be true expect(result).to eq([])
expect(helper.send(:valid_ats_field_name?, 'manager_email')).to be true end
end end
it 'returns true for valid account field names' do it 'accepts all valid field name patterns' do
expect(helper.send(:valid_ats_field_name?, 'account_name')).to be true fields = %w[
expect(helper.send(:valid_ats_field_name?, 'account_id')).to be true employee_first_name
end 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 allow(helper).to receive(:params).and_return({ prefill_fields: encoded })
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
it 'returns false for invalid field names' do result = helper.extract_prefill_fields
expect(helper.send(:valid_ats_field_name?, 'malicious_field')).to be false expect(result).to eq(fields)
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
end end
end end
end end

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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
Loading…
Cancel
Save