From 4ec993cd4d45f94bd27b748683998ae3f006d48c Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Tue, 8 Jul 2025 10:32:41 -0500 Subject: [PATCH 1/3] skip some CSRF stuff We'll properly implement authentication later --- app/controllers/email_smtp_settings_controller.rb | 1 + app/controllers/submissions_controller.rb | 1 + app/controllers/template_documents_controller.rb | 1 + 3 files changed, 3 insertions(+) diff --git a/app/controllers/email_smtp_settings_controller.rb b/app/controllers/email_smtp_settings_controller.rb index 766a9545..972765f3 100644 --- a/app/controllers/email_smtp_settings_controller.rb +++ b/app/controllers/email_smtp_settings_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class EmailSmtpSettingsController < ApplicationController + skip_before_action :verify_authenticity_token before_action :load_encrypted_config authorize_resource :encrypted_config, only: :index authorize_resource :encrypted_config, parent: false, only: :create diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index c411b5df..389e9591 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class SubmissionsController < ApplicationController + skip_before_action :verify_authenticity_token before_action :load_template, only: %i[new create] authorize_resource :template, only: %i[new create] diff --git a/app/controllers/template_documents_controller.rb b/app/controllers/template_documents_controller.rb index abd72010..7144f5e0 100644 --- a/app/controllers/template_documents_controller.rb +++ b/app/controllers/template_documents_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class TemplateDocumentsController < ApplicationController + skip_before_action :verify_authenticity_token load_and_authorize_resource :template def create From c2a7bd8772670fef9f47843cb471f5c563edbe21 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Tue, 8 Jul 2025 13:02:24 -0500 Subject: [PATCH 2/3] add submission specific export --- app/controllers/export_controller.rb | 24 +++++++++++++++++++ app/models/document_generation_event.rb | 2 +- app/models/email_event.rb | 2 +- app/models/export_location.rb | 19 ++++++++------- app/views/templates/_submission.html.erb | 3 +++ config/routes.rb | 2 ++ ...ubmissions_endpoint_to_export_locations.rb | 5 ++++ db/schema.rb | 7 +++--- 8 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20250708172115_add_submissions_endpoint_to_export_locations.rb diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index cfa94cf4..bcde4d7e 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -32,6 +32,30 @@ class ExportController < ApplicationController head :internal_server_error end + def export_submission + submission = Submission.find(params[:id]) + export_location = ExportLocation.default_location + + unless export_location&.submissions_endpoint.present? + redirect_to submission, alert: 'Export failed: Submission export endpoint is not configured.' + return + end + + payload = { + submission_id: submission.id, + template_name: submission.template&.name, + events: submission.submission_events.order(updated_at: :desc).limit(1) + } + + response = post_to_api(payload, export_location.submissions_endpoint, export_location.extra_params) + + if response&.success? + redirect_to submission, notice: "Submission ##{submission.id} events exported successfully." + else + redirect_to submission, alert: "Failed to export submission ##{submission.id} events." + end + end + private def api_connection diff --git a/app/models/document_generation_event.rb b/app/models/document_generation_event.rb index 24d71ba3..f4e0a701 100644 --- a/app/models/document_generation_event.rb +++ b/app/models/document_generation_event.rb @@ -13,7 +13,7 @@ # Indexes # # index_document_generation_events_on_submitter_id (submitter_id) -# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text])) +# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[])) # # Foreign Keys # diff --git a/app/models/email_event.rb b/app/models/email_event.rb index 3aaa5db9..3c6aac61 100644 --- a/app/models/email_event.rb +++ b/app/models/email_event.rb @@ -20,7 +20,7 @@ # # index_email_events_on_account_id_and_event_datetime (account_id,event_datetime) # index_email_events_on_email (email) -# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text])) +# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[])) # index_email_events_on_emailable (emailable_type,emailable_id) # index_email_events_on_message_id (message_id) # diff --git a/app/models/export_location.rb b/app/models/export_location.rb index a211c0a3..0be6dfb3 100644 --- a/app/models/export_location.rb +++ b/app/models/export_location.rb @@ -2,15 +2,16 @@ # # Table name: export_locations # -# id :bigint not null, primary key -# api_base_url :string not null -# authorization_token :string -# default_location :boolean default(FALSE), not null -# extra_params :jsonb not null -# name :string not null -# templates_endpoint :string -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# api_base_url :string not null +# authorization_token :string +# default_location :boolean default(FALSE), not null +# extra_params :jsonb not null +# name :string not null +# submissions_endpoint :string +# templates_endpoint :string +# created_at :datetime not null +# updated_at :datetime not null # class ExportLocation < ApplicationRecord validates :name, presence: true diff --git a/app/views/templates/_submission.html.erb b/app/views/templates/_submission.html.erb index cfa7f936..fc4804b1 100644 --- a/app/views/templates/_submission.html.erb +++ b/app/views/templates/_submission.html.erb @@ -101,6 +101,9 @@ <%= t('view') %> + + <%= button_to button_title(title: nil, icon: svg_icon('upload', class: 'w-6 h-6')), export_submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('export'), method: :post %> + <% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %> <%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %> diff --git a/config/routes.rb b/config/routes.rb index 7fc306c2..18390c8a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ Rails.application.routes.draw do resources :submissions_archived, only: %i[index], path: 'submissions/archived' resources :submissions, only: %i[index], controller: 'submissions_dashboard' resources :submissions, only: %i[show destroy] do + post :export, on: :member, to: 'export#export_submission' + resources :unarchive, only: %i[create], controller: 'submissions_unarchive' resources :events, only: %i[index], controller: 'submission_events' end diff --git a/db/migrate/20250708172115_add_submissions_endpoint_to_export_locations.rb b/db/migrate/20250708172115_add_submissions_endpoint_to_export_locations.rb new file mode 100644 index 00000000..2f3a31f8 --- /dev/null +++ b/db/migrate/20250708172115_add_submissions_endpoint_to_export_locations.rb @@ -0,0 +1,5 @@ +class AddSubmissionsEndpointToExportLocations < ActiveRecord::Migration[8.0] + def change + add_column :export_locations, :submissions_endpoint, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 9a97cf4f..326eb62d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do +ActiveRecord::Schema[8.0].define(version: 2025_07_08_172115) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -160,7 +160,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do t.string "event_name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))" + t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" end @@ -177,7 +177,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do t.datetime "created_at", null: false t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime" t.index ["email"], name: "index_email_events_on_email" - t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))" + t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))" t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable" t.index ["message_id"], name: "index_email_events_on_message_id" end @@ -225,6 +225,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "extra_params", default: {}, null: false + t.string "submissions_endpoint" end create_table "oauth_access_grants", force: :cascade do |t| From b7963f63ae35b07818bf1025ae6bf000fddcf3fe Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 17 Jul 2025 14:19:14 -0500 Subject: [PATCH 3/3] move API specific code into a service and subclass services * ExportService handles the connection and post endpoint * separate subclasses for template and submission * add specs --- app/controllers/export_controller.rb | 68 +----- app/services/export_service.rb | 43 ++++ app/services/export_submission_service.rb | 47 ++++ app/services/export_template_service.rb | 32 +++ spec/factories/export_locations.rb | 29 +++ .../export_submission_service_spec.rb | 200 ++++++++++++++++++ spec/services/export_template_service_spec.rb | 158 ++++++++++++++ 7 files changed, 517 insertions(+), 60 deletions(-) create mode 100644 app/services/export_service.rb create mode 100644 app/services/export_submission_service.rb create mode 100644 app/services/export_template_service.rb create mode 100644 spec/factories/export_locations.rb create mode 100644 spec/services/export_submission_service_spec.rb create mode 100644 spec/services/export_template_service_spec.rb diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index bcde4d7e..cf2c1da2 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -7,80 +7,28 @@ class ExportController < ApplicationController skip_before_action :maybe_redirect_to_setup skip_before_action :verify_authenticity_token - # Template is sent as JSON already; we're just gonnna send it on to the third party. + # Send template to third party. def export_template - export_location = ExportLocation.default_location - data = request.raw_post.present? ? JSON.parse(request.raw_post) : params.to_unsafe_h - response = post_to_api(data, export_location.templates_endpoint, export_location.extra_params) + service = ExportTemplateService.new(data) - if response&.success? - Rails.logger.info("Successfully exported template #{data[:template][:name]} to #{export_location.name}") + if service.call head :ok else - Rails.logger.error("Failed to export template to third party: #{response&.status}") - Rollbar.error("#{export_location.name} template export API error: #{response&.status}") if defined?(Rollbar) - head :unprocessable_entity + redirect_to root_path, alert: service.error_message end - rescue Faraday::Error => e - Rails.logger.error("Failed to export template Faraday: #{e.message}") - Rollbar.error("Failed to export template: #{e.message}") if defined?(Rollbar) - head :service_unavailable - rescue StandardError => e - Rails.logger.error("Failed to export template: #{e.message}") - Rollbar.error(e) if defined?(Rollbar) - head :internal_server_error end + # Send submission to third party. def export_submission submission = Submission.find(params[:id]) - export_location = ExportLocation.default_location - - unless export_location&.submissions_endpoint.present? - redirect_to submission, alert: 'Export failed: Submission export endpoint is not configured.' - return - end - - payload = { - submission_id: submission.id, - template_name: submission.template&.name, - events: submission.submission_events.order(updated_at: :desc).limit(1) - } + service = ExportSubmissionService.new(submission) - response = post_to_api(payload, export_location.submissions_endpoint, export_location.extra_params) - - if response&.success? + if service.call redirect_to submission, notice: "Submission ##{submission.id} events exported successfully." else - redirect_to submission, alert: "Failed to export submission ##{submission.id} events." - end - end - - private - - def api_connection - @api_connection ||= Faraday.new(url: ExportLocation.default_location.api_base_url) do |faraday| - faraday.request :json - faraday.response :json - faraday.adapter Faraday.default_adapter + redirect_to submission, alert: service.error_message end - rescue StandardError => e - Rails.logger.error("Failed to create API connection: #{e.message}") - Rollbar.error(e) if defined?(Rollbar) - nil end - def post_to_api(data, endpoint, extra_params = nil) - connection = api_connection - return nil unless connection - - connection.post(endpoint) do |req| - # req.headers['Authorization'] = "Bearer #{export_location.authorization_token}" lol - - # Merge extra_params into data if provided - data = data.merge(extra_params) if extra_params.present? && data.is_a?(Hash) - - req.body = data.is_a?(String) ? data : data.to_json - end - end end diff --git a/app/services/export_service.rb b/app/services/export_service.rb new file mode 100644 index 00000000..10ae28d5 --- /dev/null +++ b/app/services/export_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'faraday' + +class ExportService + attr_reader :error_message + + def initialize + @error_message = nil + end + + protected + + def api_connection + @api_connection ||= Faraday.new(url: ExportLocation.default_location.api_base_url) do |faraday| + faraday.request :json + faraday.response :json + faraday.adapter Faraday.default_adapter + end + rescue StandardError => e + Rails.logger.error("Failed to create API connection: #{e.message}") + Rollbar.error(e) if defined?(Rollbar) + nil + end + + def post_to_api(data, endpoint, extra_params = nil) + connection = api_connection + return nil unless connection + + connection.post(endpoint) do |req| + data = data.merge(extra_params) if extra_params.present? && data.is_a?(Hash) + req.body = data.is_a?(String) ? data : data.to_json + end + end + + def export_location + @export_location ||= ExportLocation.default_location + end + + def set_error(message) + @error_message = message + end +end diff --git a/app/services/export_submission_service.rb b/app/services/export_submission_service.rb new file mode 100644 index 00000000..23fb4398 --- /dev/null +++ b/app/services/export_submission_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ExportSubmissionService < ExportService + attr_reader :submission + + def initialize(submission) + super() + @submission = submission + end + + def call + unless export_location&.submissions_endpoint.present? + set_error('Export failed: Submission export endpoint is not configured.') + return false + end + + payload = build_payload + response = post_to_api(payload, export_location.submissions_endpoint, export_location.extra_params) + + if response&.success? + true + else + set_error("Failed to export submission ##{submission.id} events.") + false + end + rescue Faraday::Error => e + Rails.logger.error("Failed to export submission Faraday: #{e.message}") + Rollbar.error("Failed to export submission: #{e.message}") if defined?(Rollbar) + set_error("Network error occurred during export: #{e.message}") + false + rescue StandardError => e + Rails.logger.error("Failed to export submission: #{e.message}") + Rollbar.error(e) if defined?(Rollbar) + set_error("An unexpected error occurred during export: #{e.message}") + false + end + + private + + def build_payload + { + submission_id: submission.id, + template_name: submission.template&.name, + events: submission.submission_events.order(updated_at: :desc).limit(1) + } + end +end diff --git a/app/services/export_template_service.rb b/app/services/export_template_service.rb new file mode 100644 index 00000000..7f0d4e61 --- /dev/null +++ b/app/services/export_template_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ExportTemplateService < ExportService + def initialize(data) + super() + @data = data + end + + def call + response = post_to_api(@data, export_location.templates_endpoint, export_location.extra_params) + + if response&.success? + Rails.logger.info("Successfully exported template #{@data[:template][:name]} to #{export_location.name}") + true + else + Rails.logger.error("Failed to export template to third party: #{response&.status}") + Rollbar.error("#{export_location.name} template export API error: #{response&.status}") if defined?(Rollbar) + set_error("Failed to export template to third party") + false + end + rescue Faraday::Error => e + Rails.logger.error("Failed to export template Faraday: #{e.message}") + Rollbar.error("Failed to export template: #{e.message}") if defined?(Rollbar) + set_error("Network error occurred during template export: #{e.message}") + false + rescue StandardError => e + Rails.logger.error("Failed to export template: #{e.message}") + Rollbar.error(e) if defined?(Rollbar) + set_error("An unexpected error occurred during template export: #{e.message}") + false + end +end diff --git a/spec/factories/export_locations.rb b/spec/factories/export_locations.rb new file mode 100644 index 00000000..0d23f543 --- /dev/null +++ b/spec/factories/export_locations.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :export_location do + name { Faker::Company.name } + api_base_url { 'https://api.example.com' } + default_location { false } + extra_params { {} } + templates_endpoint { '/templates' } + submissions_endpoint { nil } + authorization_token { nil } + + trait :with_submissions_endpoint do + submissions_endpoint { '/submissions' } + end + + trait :with_authorization_token do + authorization_token { SecureRandom.hex(32) } + end + + trait :default do + default_location { true } + end + + trait :with_extra_params do + extra_params { { 'api_key' => 'test_key', 'version' => '1.0' } } + end + end +end diff --git a/spec/services/export_submission_service_spec.rb b/spec/services/export_submission_service_spec.rb new file mode 100644 index 00000000..52832241 --- /dev/null +++ b/spec/services/export_submission_service_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExportSubmissionService do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:template) { create(:template, account: account, author: user) } + let(:submission) { create(:submission, template: template, account: account) } + let(:export_location) { create(:export_location, :with_submissions_endpoint) } + let(:service) { described_class.new(submission) } + let(:faraday_connection) { instance_double(Faraday::Connection) } + let(:faraday_response) { instance_double(Faraday::Response) } + + before do + allow(ExportLocation).to receive(:default_location).and_return(export_location) + allow(Faraday).to receive(:new).and_return(faraday_connection) + end + + describe '#call' do + context 'when export location is not configured' do + before do + allow(ExportLocation).to receive(:default_location).and_return(nil) + end + + it 'returns false and sets error message' do + expect(service.call).to be false + expect(service.error_message).to eq('Export failed: Submission export endpoint is not configured.') + end + end + + context 'when export location has no submissions endpoint' do + before do + allow(export_location).to receive(:submissions_endpoint).and_return(nil) + end + + it 'returns false and sets error message' do + expect(service.call).to be false + expect(service.error_message).to eq('Export failed: Submission export endpoint is not configured.') + end + end + + context 'when export location is properly configured' do + let(:request_double) { double('request', body: nil) } + + before do + allow(request_double).to receive(:body=) + allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + end + + context 'when API request succeeds' do + before do + allow(faraday_response).to receive(:success?).and_return(true) + end + + it 'returns true' do + expect(service.call).to be true + end + + it 'makes API call with correct endpoint' do + expect(faraday_connection).to receive(:post).with(export_location.submissions_endpoint) + service.call + end + end + + context 'when API request fails' do + before do + allow(faraday_response).to receive(:success?).and_return(false) + end + + it 'returns false and sets error message' do + expect(service.call).to be false + expect(service.error_message).to eq("Failed to export submission ##{submission.id} events.") + end + end + + context 'when API response is nil' do + before do + allow(faraday_connection).to receive(:post).and_return(nil) + end + + it 'returns false and sets error message' do + expect(service.call).to be false + expect(service.error_message).to eq("Failed to export submission ##{submission.id} events.") + end + end + end + + context 'when Faraday error occurs' do + before do + allow(faraday_connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('Connection failed')) + end + + it 'returns false and sets network error message' do + expect(service.call).to be false + expect(service.error_message).to eq('Network error occurred during export: Connection failed') + end + + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('Failed to export submission Faraday: Connection failed') + service.call + end + + it 'reports to Rollbar if available' do + stub_const('Rollbar', double) + expect(Rollbar).to receive(:error).with('Failed to export submission: Connection failed') + service.call + end + end + + context 'when other standard error occurs' do + before do + allow(ExportLocation).to receive(:default_location).and_raise(StandardError.new('Database error')) + end + + it 'returns false and sets generic error message' do + expect(service.call).to be false + expect(service.error_message).to eq('An unexpected error occurred during export: Database error') + end + + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('Failed to export submission: Database error') + service.call + end + + it 'reports to Rollbar if available' do + stub_const('Rollbar', double) + error = StandardError.new('Database error') + allow(ExportLocation).to receive(:default_location).and_raise(error) + expect(Rollbar).to receive(:error).with(error) + service.call + end + end + end + + describe 'payload building' do + let(:request_double) { double('request', body: nil) } + + before do + allow(request_double).to receive(:body=) + allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + allow(faraday_response).to receive(:success?).and_return(true) + end + + it 'includes submission_id in payload' do + expect(request_double).to receive(:body=) do |body| + expect(JSON.parse(body)).to include('submission_id' => submission.id) + end + service.call + end + + it 'includes template_name in payload' do + expect(request_double).to receive(:body=) do |body| + expect(JSON.parse(body)).to include('template_name' => submission.template.name) + end + service.call + end + + it 'includes recent events in payload' do + expect(request_double).to receive(:body=) do |body| + parsed_body = JSON.parse(body) + expect(parsed_body).to have_key('events') + end + service.call + end + + context 'when template is nil' do + before do + allow(submission).to receive(:template).and_return(nil) + end + + it 'includes nil template_name in payload' do + expect(request_double).to receive(:body=) do |body| + expect(JSON.parse(body)).to include('template_name' => nil) + end + service.call + end + end + end + + describe 'extra_params handling' do + let(:extra_params) { { 'api_key' => 'test_key', 'version' => '1.0' } } + let(:request_double) { double('request', body: nil) } + + before do + allow(export_location).to receive(:extra_params).and_return(extra_params) + allow(request_double).to receive(:body=) + allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + allow(faraday_response).to receive(:success?).and_return(true) + end + + it 'merges extra_params into the payload' do + expect(request_double).to receive(:body=) do |body| + parsed_body = JSON.parse(body) + expect(parsed_body).to include('api_key' => 'test_key', 'version' => '1.0') + end + service.call + end + end +end diff --git a/spec/services/export_template_service_spec.rb b/spec/services/export_template_service_spec.rb new file mode 100644 index 00000000..cb81a74e --- /dev/null +++ b/spec/services/export_template_service_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExportTemplateService do + let(:export_location) { create(:export_location, :default) } + let(:data) { { template: { name: 'Test Template' } } } + let(:service) { described_class.new(data) } + let(:faraday_connection) { instance_double(Faraday::Connection) } + let(:faraday_response) { instance_double(Faraday::Response) } + + before do + allow(ExportLocation).to receive(:default_location).and_return(export_location) + allow(Faraday).to receive(:new).and_return(faraday_connection) + end + + describe '#call' do + let(:request_double) { double('request', body: nil) } + + before do + allow(request_double).to receive(:body=) + allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + end + + context 'when API request succeeds' do + before do + allow(faraday_response).to receive(:success?).and_return(true) + end + + it 'returns true' do + expect(service.call).to be true + end + + it 'makes API call with correct endpoint' do + expect(faraday_connection).to receive(:post).with(export_location.templates_endpoint) + service.call + end + + it 'logs success message' do + expect(Rails.logger).to receive(:info).with("Successfully exported template Test Template to #{export_location.name}") + service.call + end + end + + context 'when API request fails' do + before do + allow(faraday_response).to receive(:success?).and_return(false) + allow(faraday_response).to receive(:status).and_return(422) + end + + it 'returns false and sets error message' do + expect(service.call).to be false + expect(service.error_message).to eq('Failed to export template to third party') + end + + it 'logs error message' do + expect(Rails.logger).to receive(:error).with('Failed to export template to third party: 422') + service.call + end + + it 'reports to Rollbar if available' do + stub_const('Rollbar', double) + expect(Rollbar).to receive(:error).with("#{export_location.name} template export API error: 422") + service.call + end + end + + context 'when API response is nil' do + before do + allow(faraday_connection).to receive(:post).and_return(nil) + end + + it 'returns false and sets error message' do + expect(service.call).to be false + expect(service.error_message).to eq('Failed to export template to third party') + end + end + + context 'when Faraday error occurs' do + before do + allow(faraday_connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('Connection failed')) + end + + it 'returns false and sets network error message' do + expect(service.call).to be false + expect(service.error_message).to eq('Network error occurred during template export: Connection failed') + end + + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('Failed to export template Faraday: Connection failed') + service.call + end + + it 'reports to Rollbar if available' do + stub_const('Rollbar', double) + expect(Rollbar).to receive(:error).with('Failed to export template: Connection failed') + service.call + end + end + + context 'when other standard error occurs' do + before do + allow(ExportLocation).to receive(:default_location).and_raise(StandardError.new('Database error')) + end + + it 'returns false and sets generic error message' do + expect(service.call).to be false + expect(service.error_message).to eq('An unexpected error occurred during template export: Database error') + end + + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('Failed to export template: Database error') + service.call + end + + it 'reports to Rollbar if available' do + stub_const('Rollbar', double) + error = StandardError.new('Database error') + allow(ExportLocation).to receive(:default_location).and_raise(error) + expect(Rollbar).to receive(:error).with(error) + service.call + end + end + end + + describe 'data handling' do + let(:request_double) { double('request', body: nil) } + + before do + allow(request_double).to receive(:body=) + allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + allow(faraday_response).to receive(:success?).and_return(true) + end + + it 'sends the data in the request body' do + expect(request_double).to receive(:body=) do |body| + expect(JSON.parse(body)).to eq(data.deep_stringify_keys) + end + service.call + end + + context 'when extra_params are provided' do + let(:extra_params) { { 'api_key' => 'test_key', 'version' => '1.0' } } + + before do + allow(export_location).to receive(:extra_params).and_return(extra_params) + end + + it 'merges extra_params into the data' do + expect(request_double).to receive(:body=) do |body| + parsed_body = JSON.parse(body) + expect(parsed_body).to include('api_key' => 'test_key', 'version' => '1.0') + end + service.call + end + end + end +end