mirror of https://github.com/docusealco/docuseal
CP-10379 ordered signing (#59)
* add named signing order values and defer to template signing order * the enum changes and the default in submission.rb don't REALLY matter since almost all of our changes in future commits defer to templates. * add template methods to know how many actual submitters there are and add complex default logic based on when fields are added or removed. For example: If only 1 employee field it's single sided. If we add a manager field it automatically changes to employee_then_manager unless manually changed to a different dual sided. If either field is removed, it automatically switches back to single_sided * enforce new signing order logic - replace submitters_order_preserved? with signing_order_enforced? in send_signature_requests - add manager_then_employee branch to send_signature_requests to send to second submitter first, while we don't send out emails with Docuseal, there are changes further down the line required - skip submitters without fields for single_sided in create_from_submitters, this is mostly necessary for single_sided manager forms - refactor current_submitter_order? to reverse submitter_items for manager_then_employee instead of special-casing index * wire up named signing order through controllers * when saving a template, check if preferences have changed, if it has changed, fire webhook event. * changes in templates_controller.rb are for automatic updates based on field types. So if only 1 field type (employee fields only) this automatically updates * template_preferences_controller.rb handles manual updates to signing order from user * add signing order UI - add SigningOrderModal component for selecting signing order from within the template builder - show signing order button in builder toolbar only when template has 2+ submitter fields * add template.preferences_updated webhook job * add template.preferences_updated webhook support - add template.preferences_updated to account default webhook events - guard account create_careerplug_webhook against missing CAREERPLUG_WEBHOOK_URL env var - create partnership-scoped webhook for template.preferences_updated on partnership creation - add template.preferences_updated to WebhookUrl::EVENTS - update PARTNERSHIP_EVENTS to only include template.preferences_updated - return WebhookUrl.none instead of raising for templates with neither account nor partnership - extend webhooks:setup_development rake task to create partnership webhooks * rubocop and rspec fixes * erb_lint violation fixes * harden webhooks with account_id and partnership_id in payload * we're requiring two points of contact in the db for multitenancy * use external account id to match correctly in webhook payload * PR comments * handle submitter UUID not matching correctly with flash alert that surfaces to user * add more testing for simultaneous and single sided orders * add comment for skipping Devise auth for Iframe auth * refactor template webhook enqueue to a shared concern * use safe navigation for first_party name * make default submitters_order value consistent between `lib/submissions.rb` and `submission.rb` * more descriptive error message for signing order error * update to non-predicate method for rubocop we used to just return true or false, but we are using nil to signify that the submitter uuid is not found for the controller so the error can be surfaced to the user. * erb_lint formatting fix * PR comment changes * change current_submitter_order to validate_submitter_order for clarity * add translationspull/608/head
parent
41e18f9484
commit
d8f04ad115
@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TemplateWebhooks
|
||||
def enqueue_template_created_webhooks(template)
|
||||
WebhookUrls.for_template(template, 'template.created').each do |webhook_url|
|
||||
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
|
||||
'webhook_url_id' => webhook_url.id)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_template_updated_webhooks(template)
|
||||
WebhookUrls.for_template(template, 'template.updated').each do |webhook_url|
|
||||
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id,
|
||||
'webhook_url_id' => webhook_url.id)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_template_preferences_updated_webhooks(template)
|
||||
WebhookUrls.for_template(template, 'template.preferences_updated').each do |webhook_url|
|
||||
SendTemplatePreferencesUpdatedWebhookRequestJob.perform_async('template_id' => template.id,
|
||||
'webhook_url_id' => webhook_url.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title text-lg">
|
||||
{{ t('select_signing_order') }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl modal-close-button"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form @submit.prevent="saveAndClose">
|
||||
<div class="space-y-2 mb-4">
|
||||
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
|
||||
<input
|
||||
v-model="signingOrder"
|
||||
type="radio"
|
||||
value="employee_then_manager"
|
||||
class="radio radio-primary mt-1"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div>{{ firstParty }} completes the form first, then {{ secondParty }}</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
|
||||
<input
|
||||
v-model="signingOrder"
|
||||
type="radio"
|
||||
value="manager_then_employee"
|
||||
class="radio radio-primary mt-1"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div>{{ secondParty }} completes the form first, then {{ firstParty }}</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
|
||||
<input
|
||||
v-model="signingOrder"
|
||||
type="radio"
|
||||
value="simultaneous"
|
||||
class="radio radio-primary mt-1"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div>{{ t('simultaneous_signing_description') }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="base-button w-full mt-4 modal-save-button">
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SigningOrderModal',
|
||||
inject: ['t', 'template', 'baseFetch', 'authenticityToken'],
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
signingOrder: this.template.preferences?.submitters_order || 'employee_then_manager'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
firstParty () {
|
||||
return this.template.submitters[0]?.name || this.t('first_party')
|
||||
},
|
||||
secondParty () {
|
||||
return this.template.submitters[1]?.name || this.t('second_party')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveAndClose () {
|
||||
if (!this.template.preferences) {
|
||||
this.template.preferences = {}
|
||||
}
|
||||
this.template.preferences.submitters_order = this.signingOrder
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('template[preferences][submitters_order]', this.signingOrder)
|
||||
|
||||
if (this.template?.partnership_context) {
|
||||
const context = this.template.partnership_context
|
||||
if (context.accessible_partnership_ids) {
|
||||
context.accessible_partnership_ids.forEach(id => {
|
||||
formData.append('accessible_partnership_ids[]', id)
|
||||
})
|
||||
}
|
||||
if (context.external_partnership_id) {
|
||||
formData.append('external_partnership_id', context.external_partnership_id)
|
||||
}
|
||||
if (context.external_account_id) {
|
||||
formData.append('external_account_id', context.external_account_id)
|
||||
}
|
||||
}
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/preferences`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(() => {
|
||||
this.$emit('close')
|
||||
}).catch((error) => {
|
||||
console.error('Error saving signing order:', error)
|
||||
alert(this.t('failed_to_save_signing_order_please_try_again_or_contact_support'))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendTemplatePreferencesUpdatedWebhookRequestJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options queue: :webhooks
|
||||
|
||||
def perform(params = {})
|
||||
template = Template.find(params['template_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.preferences_updated')
|
||||
|
||||
data = {
|
||||
id: template.id,
|
||||
external_account_id: template.account&.external_account_id,
|
||||
external_partnership_id: template.partnership&.external_partnership_id,
|
||||
external_id: template.external_id,
|
||||
application_key: template.application_key,
|
||||
submitters_order: template.preferences['submitters_order']
|
||||
}
|
||||
|
||||
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.preferences_updated', data:)
|
||||
|
||||
return unless WebhookRetryLogic.should_retry?(response: resp, attempt: attempt, record: template)
|
||||
|
||||
SendTemplatePreferencesUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
|
||||
'template_id' => template.id,
|
||||
'webhook_url_id' => webhook_url.id,
|
||||
'attempt' => attempt + 1,
|
||||
'last_status' => resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,131 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe SendTemplatePreferencesUpdatedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
let(:webhook_url) { create(:webhook_url, account:, events: ['template.preferences_updated']) }
|
||||
|
||||
before do
|
||||
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
|
||||
value: GenerateCertificate.call.transform_values(&:to_pem))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'sends a webhook request with minimal submitters_order data' do
|
||||
template.update!(preferences: { 'submitters_order' => 'employee_then_manager' })
|
||||
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.preferences_updated',
|
||||
'timestamp' => /.*/,
|
||||
'data' => {
|
||||
'id' => template.id,
|
||||
'external_account_id' => template.account&.external_account_id,
|
||||
'external_partnership_id' => template.partnership&.external_partnership_id,
|
||||
'external_id' => template.external_id,
|
||||
'application_key' => template.application_key,
|
||||
'submitters_order' => 'employee_then_manager'
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it 'sends a webhook request with the secret' do
|
||||
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
|
||||
template.update!(preferences: { 'submitters_order' => 'simultaneous' })
|
||||
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.preferences_updated',
|
||||
'timestamp' => /.*/,
|
||||
'data' => {
|
||||
'id' => template.id,
|
||||
'external_account_id' => template.account&.external_account_id,
|
||||
'external_partnership_id' => template.partnership&.external_partnership_id,
|
||||
'external_id' => template.external_id,
|
||||
'application_key' => template.application_key,
|
||||
'submitters_order' => 'simultaneous'
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook',
|
||||
'X-Secret-Header' => 'secret_value'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it "doesn't send a webhook request if the event is not in the webhook's events" do
|
||||
webhook_url.update!(events: ['template.created'])
|
||||
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
|
||||
|
||||
expect(WebMock).not_to have_requested(:post, webhook_url.url)
|
||||
end
|
||||
|
||||
it 'sends again if the response status is 400 or higher' do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
expect do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
|
||||
end.to change(described_class.jobs, :size).by(1)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
|
||||
args = described_class.jobs.last['args'].first
|
||||
|
||||
expect(args['attempt']).to eq(1)
|
||||
expect(args['last_status']).to eq(401)
|
||||
expect(args['webhook_url_id']).to eq(webhook_url.id)
|
||||
expect(args['template_id']).to eq(template.id)
|
||||
end
|
||||
|
||||
it "doesn't send again if the max attempts is reached" do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
expect do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
|
||||
end.not_to change(described_class.jobs, :size)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
end
|
||||
|
||||
it 'sends webhook with single_sided submitters_order' do
|
||||
template.update!(preferences: { 'submitters_order' => 'single_sided' })
|
||||
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.preferences_updated',
|
||||
'timestamp' => /.*/,
|
||||
'data' => {
|
||||
'id' => template.id,
|
||||
'external_account_id' => template.account&.external_account_id,
|
||||
'external_partnership_id' => template.partnership&.external_partnership_id,
|
||||
'external_id' => template.external_id,
|
||||
'application_key' => template.application_key,
|
||||
'submitters_order' => 'single_sided'
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook'
|
||||
}
|
||||
).once
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Submissions::CreateFromSubmitters do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user, submitter_count: 2) }
|
||||
|
||||
let(:submitter_attrs) do
|
||||
template.submitters.map do |s|
|
||||
{ 'uuid' => s['uuid'], 'email' => Faker::Internet.email }.with_indifferent_access
|
||||
end
|
||||
end
|
||||
|
||||
def call(template:, submitters_order:)
|
||||
described_class.call(
|
||||
template:,
|
||||
user:,
|
||||
submissions_attrs: [{ 'submitters' => submitter_attrs }.with_indifferent_access],
|
||||
source: :api,
|
||||
submitters_order:
|
||||
)
|
||||
end
|
||||
|
||||
describe 'is_order_sent for employee_then_manager' do
|
||||
it 'sets sent_at only on the first submitter (Employee)' do
|
||||
submissions = call(template:, submitters_order: 'employee_then_manager')
|
||||
submitters = submissions.first.submitters.sort_by { |s| template.submitters.index { |ts| ts['uuid'] == s.uuid } }
|
||||
|
||||
expect(submitters[0].sent_at).not_to be_nil
|
||||
expect(submitters[1].sent_at).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'is_order_sent for manager_then_employee' do
|
||||
it 'sets sent_at only on the second submitter (Manager)' do
|
||||
submissions = call(template:, submitters_order: 'manager_then_employee')
|
||||
submitters = submissions.first.submitters.sort_by { |s| template.submitters.index { |ts| ts['uuid'] == s.uuid } }
|
||||
|
||||
expect(submitters[0].sent_at).to be_nil
|
||||
expect(submitters[1].sent_at).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'is_order_sent for simultaneous' do
|
||||
it 'sets sent_at on all submitters' do
|
||||
submissions = call(template:, submitters_order: 'simultaneous')
|
||||
|
||||
expect(submissions.first.submitters).to all(have_attributes(sent_at: be_present))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'single_sided skipping' do
|
||||
before do
|
||||
manager_uuid = template.submitters[1]['uuid']
|
||||
template.update_column(:fields, template.fields.reject { |f| f['submitter_uuid'] == manager_uuid })
|
||||
end
|
||||
|
||||
it 'skips submitters without fields' do
|
||||
submissions = call(template:, submitters_order: 'single_sided')
|
||||
submitter_uuids = submissions.first.submitters.map(&:uuid)
|
||||
|
||||
expect(submitter_uuids).to include(template.submitters[0]['uuid'])
|
||||
expect(submitter_uuids).not_to include(template.submitters[1]['uuid'])
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Submissions do
|
||||
describe '.send_signature_requests' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user, submitter_count: 2) }
|
||||
let(:submission) { create(:submission, template:, created_by_user: user) }
|
||||
|
||||
let(:employee_uuid) { template.submitters[0]['uuid'] }
|
||||
let(:manager_uuid) { template.submitters[1]['uuid'] }
|
||||
|
||||
let!(:employee) { create(:submitter, submission:, uuid: employee_uuid) }
|
||||
let!(:manager) { create(:submitter, submission:, uuid: manager_uuid) }
|
||||
|
||||
before do
|
||||
allow(Submitters).to receive(:send_signature_requests)
|
||||
end
|
||||
|
||||
def update_order(order)
|
||||
template.update_column(:preferences, { 'submitters_order' => order })
|
||||
submission.reload
|
||||
end
|
||||
|
||||
context 'with employee_then_manager order' do
|
||||
before { update_order('employee_then_manager') }
|
||||
|
||||
it 'sends signature request only to the employee first' do
|
||||
described_class.send_signature_requests([submission])
|
||||
|
||||
expect(Submitters).to have_received(:send_signature_requests).with([employee], delay_seconds: nil)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with manager_then_employee order' do
|
||||
before { update_order('manager_then_employee') }
|
||||
|
||||
it 'sends signature request only to the manager first' do
|
||||
described_class.send_signature_requests([submission])
|
||||
|
||||
expect(Submitters).to have_received(:send_signature_requests).with([manager], delay_seconds: nil)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with simultaneous order' do
|
||||
before { update_order('simultaneous') }
|
||||
|
||||
it 'sends signature requests to all submitters' do
|
||||
described_class.send_signature_requests([submission])
|
||||
|
||||
expect(Submitters).to have_received(:send_signature_requests).with(contain_exactly(employee, manager),
|
||||
delay_seconds: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Submitters do
|
||||
describe '.validate_submitter_order' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user, submitter_count: 2) }
|
||||
let(:submission) { create(:submission, template:, created_by_user: user) }
|
||||
|
||||
let(:employee_uuid) { template.submitters[0]['uuid'] }
|
||||
let(:manager_uuid) { template.submitters[1]['uuid'] }
|
||||
|
||||
let!(:employee) { create(:submitter, submission:, uuid: employee_uuid) }
|
||||
let!(:manager) { create(:submitter, submission:, uuid: manager_uuid) }
|
||||
|
||||
def update_order(order)
|
||||
template.update_column(:preferences, { 'submitters_order' => order })
|
||||
submission.reload
|
||||
end
|
||||
|
||||
context 'with manager_then_employee order' do
|
||||
before { update_order('manager_then_employee') }
|
||||
|
||||
it 'returns true for the manager (index 1)' do
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for the employee when manager has not completed' do
|
||||
manager.update!(completed_at: nil)
|
||||
expect(described_class.validate_submitter_order(employee.reload)).to be false
|
||||
end
|
||||
|
||||
it 'returns true for the employee when manager has completed' do
|
||||
manager.update!(completed_at: Time.current)
|
||||
expect(described_class.validate_submitter_order(employee.reload)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'with employee_then_manager order' do
|
||||
before { update_order('employee_then_manager') }
|
||||
|
||||
it 'returns true for the first submitter (Employee)' do
|
||||
expect(described_class.validate_submitter_order(employee.reload)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for the manager when employee has not completed' do
|
||||
employee.update!(completed_at: nil)
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be false
|
||||
end
|
||||
|
||||
it 'returns true for the manager when employee has completed' do
|
||||
employee.update!(completed_at: Time.current)
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'with simultaneous order' do
|
||||
before { update_order('simultaneous') }
|
||||
|
||||
it 'returns true for the first submitter' do
|
||||
expect(described_class.validate_submitter_order(employee.reload)).to be true
|
||||
end
|
||||
|
||||
it 'returns true for the second submitter when the first has completed' do
|
||||
employee.update!(completed_at: Time.current)
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for the second submitter when the first has not completed' do
|
||||
employee.update!(completed_at: nil)
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with single_sided order' do
|
||||
before { update_order('single_sided') }
|
||||
|
||||
it 'returns true for the first submitter' do
|
||||
expect(described_class.validate_submitter_order(employee.reload)).to be true
|
||||
end
|
||||
|
||||
it 'returns true for the second submitter when the first has completed' do
|
||||
employee.update!(completed_at: Time.current)
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for the second submitter when the first has not completed' do
|
||||
employee.update!(completed_at: nil)
|
||||
expect(described_class.validate_submitter_order(manager.reload)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,65 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account, '#create_careerplug_webhook' do
|
||||
around do |example|
|
||||
original_secret = ENV.fetch('CAREERPLUG_WEBHOOK_SECRET', nil)
|
||||
original_url = ENV.fetch('CAREERPLUG_WEBHOOK_URL', nil)
|
||||
|
||||
# Set required env vars for webhook creation
|
||||
ENV['CAREERPLUG_WEBHOOK_SECRET'] = 'test_secret'
|
||||
ENV['CAREERPLUG_WEBHOOK_URL'] = 'http://example.com/webhook'
|
||||
|
||||
example.run
|
||||
|
||||
# Restore original env vars
|
||||
ENV['CAREERPLUG_WEBHOOK_SECRET'] = original_secret
|
||||
ENV['CAREERPLUG_WEBHOOK_URL'] = original_url
|
||||
end
|
||||
|
||||
describe 'CareerPlug webhook creation' do
|
||||
it 'creates webhook after successful account creation' do
|
||||
account = build(:account)
|
||||
expect(account.webhook_urls).to be_empty
|
||||
|
||||
account.save!
|
||||
|
||||
expect(account.webhook_urls.count).to eq(1)
|
||||
webhook = account.webhook_urls.first
|
||||
expect(webhook.url).to eq('http://example.com/webhook')
|
||||
expect(webhook.events).to eq(['form.viewed', 'form.started', 'form.completed', 'form.declined'])
|
||||
expect(webhook.secret).to eq({ 'X-CareerPlug-Secret' => 'test_secret' })
|
||||
end
|
||||
|
||||
it 'does not create webhook if account creation fails' do
|
||||
# This test verifies that after_commit behavior works correctly
|
||||
# by simulating a transaction rollback
|
||||
|
||||
expect do
|
||||
described_class.transaction do
|
||||
create(:account)
|
||||
# Simulate some error that would cause rollback
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end.not_to change(described_class, :count)
|
||||
|
||||
expect do
|
||||
described_class.transaction do
|
||||
create(:account)
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end.not_to change(WebhookUrl, :count)
|
||||
end
|
||||
|
||||
it 'does not create webhook when CAREERPLUG_WEBHOOK_SECRET is blank' do
|
||||
original_secret = ENV.fetch('CAREERPLUG_WEBHOOK_SECRET', nil)
|
||||
ENV['CAREERPLUG_WEBHOOK_SECRET'] = ''
|
||||
|
||||
account = create(:account)
|
||||
expect(account.webhook_urls.count).to eq(0)
|
||||
|
||||
ENV['CAREERPLUG_WEBHOOK_SECRET'] = original_secret
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Submission do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
let(:submission) { create(:submission, template:, created_by_user: user) }
|
||||
|
||||
describe '#template_signing_order' do
|
||||
it 'returns the submitters_order from template preferences' do
|
||||
template.update!(preferences: { 'submitters_order' => 'employee_then_manager' })
|
||||
expect(submission.template_signing_order).to eq('employee_then_manager')
|
||||
end
|
||||
|
||||
it 'returns nil when template has no submitters_order preference' do
|
||||
template.update_column(:preferences, {})
|
||||
expect(submission.reload.template_signing_order).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when submission has no template' do
|
||||
submission.update!(template: nil)
|
||||
expect(submission.template_signing_order).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signing_order_enforced?' do
|
||||
it 'returns true for employee_then_manager' do
|
||||
template.update!(preferences: { 'submitters_order' => 'employee_then_manager' })
|
||||
expect(submission.signing_order_enforced?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for manager_then_employee' do
|
||||
template.update!(preferences: { 'submitters_order' => 'manager_then_employee' })
|
||||
expect(submission.signing_order_enforced?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for simultaneous' do
|
||||
template.update!(preferences: { 'submitters_order' => 'simultaneous' })
|
||||
expect(submission.signing_order_enforced?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for single_sided' do
|
||||
template.update!(preferences: { 'submitters_order' => 'single_sided' })
|
||||
expect(submission.signing_order_enforced?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,153 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Template do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
describe '#unique_submitter_uuids' do
|
||||
it 'returns unique submitter UUIDs from fields' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'signature' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'date' }
|
||||
])
|
||||
|
||||
expect(template.unique_submitter_uuids).to match_array(%w[uuid1 uuid2])
|
||||
end
|
||||
|
||||
it 'filters out nil submitter_uuids' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => nil, 'type' => 'image' },
|
||||
{ 'type' => 'signature' }
|
||||
])
|
||||
|
||||
expect(template.unique_submitter_uuids).to eq(['uuid1'])
|
||||
end
|
||||
|
||||
it 'returns empty array when no fields' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(fields: [])
|
||||
|
||||
expect(template.unique_submitter_uuids).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#effective_submitters_order' do
|
||||
it 'returns preferences submitters_order when set' do
|
||||
template = create(:template, account:, author: user)
|
||||
template.update_column(:preferences, { 'submitters_order' => 'manager_then_employee' })
|
||||
|
||||
expect(template.effective_submitters_order).to eq('manager_then_employee')
|
||||
end
|
||||
|
||||
it 'returns single_sided when preferences not set and template has fewer than 2 unique submitters' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update_column(:preferences, {})
|
||||
template.update_column(:fields, [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }])
|
||||
|
||||
expect(template.reload.effective_submitters_order).to eq('single_sided')
|
||||
end
|
||||
|
||||
it 'returns employee_then_manager when preferences not set and template has 2+ unique submitters' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update_column(:preferences, {})
|
||||
template.update_column(:fields, [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' }
|
||||
])
|
||||
|
||||
expect(template.reload.effective_submitters_order).to eq('employee_then_manager')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_submitters_order' do
|
||||
context 'when template has less than 2 unique submitters' do
|
||||
it 'sets submitters_order to single_sided' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(preferences: { 'submitters_order' => 'employee_then_manager' },
|
||||
fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' }
|
||||
])
|
||||
|
||||
# Remove uuid2 fields to trigger single_sided
|
||||
template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('single_sided')
|
||||
end
|
||||
|
||||
it 'always sets single_sided when only one unique submitter' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('single_sided')
|
||||
|
||||
template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'signature' }])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('single_sided')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template has 2 or more unique submitters' do
|
||||
it 'sets employee_then_manager when adding second submitter' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(preferences: { 'submitters_order' => 'single_sided' },
|
||||
fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }])
|
||||
|
||||
# Add second submitter
|
||||
template.update!(fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' }
|
||||
])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('employee_then_manager')
|
||||
end
|
||||
|
||||
it 'sets employee_then_manager when submitters_order is blank and 2 submitters added' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' }
|
||||
])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('employee_then_manager')
|
||||
end
|
||||
|
||||
it 'preserves employee_then_manager when multiple submitters' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(preferences: { 'submitters_order' => 'employee_then_manager' },
|
||||
fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' }
|
||||
])
|
||||
|
||||
template.update!(fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' },
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'date' }
|
||||
])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('employee_then_manager')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when removing fields transitions from multi to single submitter' do
|
||||
it 'changes from employee_then_manager to single_sided' do
|
||||
template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0)
|
||||
template.update!(preferences: { 'submitters_order' => 'employee_then_manager' },
|
||||
fields: [
|
||||
{ 'submitter_uuid' => 'uuid1', 'type' => 'text' },
|
||||
{ 'submitter_uuid' => 'uuid2', 'type' => 'signature' }
|
||||
])
|
||||
|
||||
# Remove all uuid2 fields
|
||||
template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }])
|
||||
|
||||
expect(template.reload.preferences['submitters_order']).to eq('single_sided')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue