diff --git a/app/jobs/integrations/aggregator/send_private_app_token_job.rb b/app/jobs/integrations/aggregator/send_private_app_token_job.rb new file mode 100644 index 00000000000..12963d57720 --- /dev/null +++ b/app/jobs/integrations/aggregator/send_private_app_token_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SendPrivateAppTokenJob < ApplicationJob + queue_as 'integrations' + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + + def perform(integration:) + result = Integrations::Aggregator::SendPrivateAppTokenService.call(integration:) + result.raise_if_error! + end + end + end +end diff --git a/app/services/integrations/aggregator/base_service.rb b/app/services/integrations/aggregator/base_service.rb index e1a024c321a..454ed225207 100644 --- a/app/services/integrations/aggregator/base_service.rb +++ b/app/services/integrations/aggregator/base_service.rb @@ -32,6 +32,8 @@ def provider 'xero' when 'Integrations::AnrokIntegration' 'anrok' + when 'Integrations::HubspotIntegration' + 'hubspot' end end @@ -43,6 +45,8 @@ def provider_key 'xero' when 'Integrations::AnrokIntegration' 'anrok' + when 'Integrations::HubspotIntegration' + 'hubspot' end end diff --git a/app/services/integrations/aggregator/send_private_app_token_service.rb b/app/services/integrations/aggregator/send_private_app_token_service.rb new file mode 100644 index 00000000000..659390ec348 --- /dev/null +++ b/app/services/integrations/aggregator/send_private_app_token_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SendPrivateAppTokenService < BaseService + def action_path + "connection/#{integration.connection_id}/metadata" + end + + def call + return unless integration.type == 'Integrations::HubspotIntegration' + return unless integration.private_app_token + + payload = { + privateAppToken: integration.private_app_token + } + + response = http_client.post_with_response(payload, headers) + result.response = response + + result + end + + private + + def headers + { + 'Provider-Config-Key' => 'hubspot', + 'Authorization' => "Bearer #{secret_key}" + } + end + end + end +end diff --git a/app/services/integrations/hubspot/create_service.rb b/app/services/integrations/hubspot/create_service.rb new file mode 100644 index 00000000000..570e379862b --- /dev/null +++ b/app/services/integrations/hubspot/create_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + class CreateService < BaseService + attr_reader :params + def initialize(params:) + @params = params + + super + end + + def call + organization = Organization.find_by(id: params[:organization_id]) + + unless organization.premium_integrations.include?('hubspot') + return result.not_allowed_failure!(code: 'premium_integration_missing') + end + + integration = Integrations::HubspotIntegration.new( + organization:, + name: params[:name], + code: params[:code], + connection_id: params[:connection_id], + private_app_token: params[:private_app_token], + default_targeted_object: params[:default_targeted_object], + sync_invoices: ActiveModel::Type::Boolean.new.cast(params[:sync_invoices]), + sync_subscriptions: ActiveModel::Type::Boolean.new.cast(params[:sync_subscriptions]) + ) + + integration.save! + + if integration.type == 'Integrations::HubspotIntegration' + Integrations::Aggregator::SendPrivateAppTokenJob.perform_later(integration:) + end + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/hubspot/update_service.rb b/app/services/integrations/hubspot/update_service.rb new file mode 100644 index 00000000000..a661ed68406 --- /dev/null +++ b/app/services/integrations/hubspot/update_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + class UpdateService < BaseService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: 'integration') unless integration + + unless integration.organization.premium_integrations.include?('hubspot') + return result.not_allowed_failure!(code: 'premium_integration_missing') + end + + old_private_app_token = integration.private_app_token + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + integration.private_app_token = params[:private_app_token] if params.key?(:private_app_token) + integration.default_targeted_object = params[:default_targeted_object] if params.key?(:default_targeted_object) + integration.sync_invoices = params[:sync_invoices] if params.key?(:sync_invoices) + integration.sync_subscriptions = params[:sync_subscriptions] if params.key?(:sync_subscriptions) + + integration.save! + + if integration.type == 'Integrations::HubspotIntegration' && integration.private_app_token != old_private_app_token + Integrations::Aggregator::SendPrivateAppTokenJob.perform_later(integration:) + end + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/spec/jobs/integrations/aggregator/send_private_app_token_job_spec.rb b/spec/jobs/integrations/aggregator/send_private_app_token_job_spec.rb new file mode 100644 index 00000000000..f0f195ccf39 --- /dev/null +++ b/spec/jobs/integrations/aggregator/send_private_app_token_job_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Aggregator::SendPrivateAppTokenJob, type: :job do + describe '#perform' do + subject(:send_token_job) { described_class } + + let(:send_token_service) { instance_double(Integrations::Aggregator::SendPrivateAppTokenService) } + let(:integration) { create(:hubspot_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::SendPrivateAppTokenService).to receive(:new).and_return(send_token_service) + allow(send_token_service).to receive(:call).and_return(result) + end + + it 'sends the private app token the nango' do + described_class.perform_now(integration:) + + expect(Integrations::Aggregator::SendPrivateAppTokenService).to have_received(:new) + expect(send_token_service).to have_received(:call) + end + end +end diff --git a/spec/services/integrations/aggregator/send_private_app_token_service_spec.rb b/spec/services/integrations/aggregator/send_private_app_token_service_spec.rb new file mode 100644 index 00000000000..7b67e366ba2 --- /dev/null +++ b/spec/services/integrations/aggregator/send_private_app_token_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Aggregator::SendPrivateAppTokenService do + subject(:send_private_token_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe '.call' do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/connection/#{integration.connection_id}/metadata" } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response) + + integration.private_app_token = 'privatetoken' + integration.save! + end + + it 'successfully sends token to nango' do + send_private_token_service.call + + aggregate_failures do + expect(LagoHttpClient::Client).to have_received(:new) + .with(endpoint) + expect(lago_client).to have_received(:post_with_response) do |payload| + expect(payload[:privateAppToken]).to eq('privatetoken') + end + end + end + end +end diff --git a/spec/services/integrations/hubspot/create_service_spec.rb b/spec/services/integrations/hubspot/create_service_spec.rb new file mode 100644 index 00000000000..999b5867e8a --- /dev/null +++ b/spec/services/integrations/hubspot/create_service_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Hubspot::CreateService, type: :service do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe '#call' do + subject(:service_call) { described_class.call(params: create_args) } + + let(:name) { 'Hubspot 1' } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:create_args) do + { + name:, + code: 'hubspot1', + organization_id: organization.id, + connection_id: 'conn1', + private_app_token: 'token', + client_secret: 'secret', + default_targeted_object: "test", + sync_invoices: false, + sync_subscriptions: false + } + end + + context 'without premium license' do + it 'does not create an integration' do + expect { service_call }.not_to change(Integrations::HubspotIntegration, :count) + end + + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + end + + context 'with premium license' do + around { |test| lago_premium!(&test) } + + context 'with hubspot premium integration not present' do + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + end + + context 'with hubspot premium integration present' do + before do + organization.update!(premium_integrations: ['hubspot']) + allow(Integrations::Aggregator::SendPrivateAppTokenJob).to receive(:perform_later) + end + + context 'without validation errors' do + it 'creates an integration' do + expect { service_call }.to change(Integrations::HubspotIntegration, :count).by(1) + + integration = Integrations::HubspotIntegration.order(:created_at).last + expect(integration.name).to eq(name) + expect(integration.code).to eq(create_args[:code]) + expect(integration.connection_id).to eq(create_args[:connection_id]) + expect(integration.private_app_token).to eq(create_args[:private_app_token]) + expect(integration.default_targeted_object).to eq(create_args[:default_targeted_object]) + expect(integration.sync_invoices).to eq(create_args[:sync_invoices]) + expect(integration.sync_subscriptions).to eq(create_args[:sync_subscriptions]) + expect(integration.organization_id).to eq(organization.id) + end + + it 'returns an integration in result object' do + result = service_call + + expect(result.integration).to be_a(Integrations::HubspotIntegration) + end + + it 'calls Integrations::Aggregator::SendPrivateAppTokenJob' do + service_call + + integration = Integrations::HubspotIntegration.order(:created_at).last + expect(Integrations::Aggregator::SendPrivateAppTokenJob).to have_received(:perform_later).with(integration:) + end + end + + context 'with validation error' do + let(:name) { nil } + + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(['value_is_mandatory']) + end + end + end + end + end + end +end diff --git a/spec/services/integrations/hubspot/update_service_spec.rb b/spec/services/integrations/hubspot/update_service_spec.rb new file mode 100644 index 00000000000..b2bf405a1c4 --- /dev/null +++ b/spec/services/integrations/hubspot/update_service_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Hubspot::UpdateService, type: :service do + let(:integration) { create(:hubspot_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe '#call' do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { 'Hubspot 1' } + let(:update_args) do + { + name:, + code: 'hubspot1', + private_app_token: 'new_token' + } + end + + context 'without premium license' do + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + end + + context 'with premium license' do + around { |test| lago_premium!(&test) } + + context 'with hubspot premium integration not present' do + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + end + + context 'with hubspot premium integration present' do + before do + organization.update!(premium_integrations: ['hubspot']) + allow(Integrations::Aggregator::SendPrivateAppTokenJob).to receive(:perform_later) + end + + context 'without validation errors' do + it 'updates an integration' do + service_call + + integration = Integrations::HubspotIntegration.order(:updated_at).last + expect(integration.name).to eq(name) + expect(integration.private_app_token).to eq(update_args[:private_app_token]) + end + + it 'returns an integration in result object' do + result = service_call + + expect(result.integration).to be_a(Integrations::HubspotIntegration) + end + + it 'calls Integrations::Aggregator::SendPrivateAppTokenJob' do + service_call + + expect(Integrations::Aggregator::SendPrivateAppTokenJob).to have_received(:perform_later).with(integration:) + end + end + + context 'with validation error' do + let(:name) { nil } + + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(['value_is_mandatory']) + end + end + end + end + end + end +end