diff --git a/app/jobs/send_http_webhook_job.rb b/app/jobs/send_http_webhook_job.rb new file mode 100644 index 00000000000..cc27517aeff --- /dev/null +++ b/app/jobs/send_http_webhook_job.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SendHttpWebhookJob < ApplicationJob + def perform(webhook) + Webhooks::SendHttpService.call(webhook:) + end +end diff --git a/app/jobs/send_webhook_job.rb b/app/jobs/send_webhook_job.rb index cf8036789bf..fb92d77dabd 100644 --- a/app/jobs/send_webhook_job.rb +++ b/app/jobs/send_webhook_job.rb @@ -43,6 +43,13 @@ class SendWebhookJob < ApplicationJob def perform(webhook_type, object, options = {}, webhook_id = nil) raise(NotImplementedError) unless WEBHOOK_SERVICES.include?(webhook_type) - WEBHOOK_SERVICES.fetch(webhook_type).new(object:, options:, webhook_id:).call + # NOTE: This condition is only temporary to handle enqueued jobs + # TODO: Remove this condition after queued jobs are processed + if webhook_id + SendHttpWebhookJob.perform_later(Webhook.find(webhook_id)) + return + end + + WEBHOOK_SERVICES.fetch(webhook_type).new(object:, options:).call end end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 7d4e7ee04c2..7573087539c 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -8,6 +8,7 @@ class Webhook < ApplicationRecord belongs_to :webhook_endpoint belongs_to :object, polymorphic: true, optional: true + # TODO: Use relation to be able to eager load delegate :organization, to: :webhook_endpoint enum status: STATUS @@ -15,4 +16,39 @@ class Webhook < ApplicationRecord def self.ransackable_attributes(_auth_object = nil) %w[id webhook_type] end + + def generate_headers + signature = case webhook_endpoint.signature_algo&.to_sym + when :jwt + jwt_signature + when :hmac + hmac_signature + end + + { + 'X-Lago-Signature' => signature, + 'X-Lago-Signature-Algorithm' => webhook_endpoint.signature_algo.to_s, + 'X-Lago-Unique-Key' => id + } + end + + def jwt_signature + JWT.encode( + { + data: payload.to_json, + iss: issuer + }, + RsaPrivateKey, + 'RS256', + ) + end + + def hmac_signature + hmac = OpenSSL::HMAC.digest('sha-256', organization.api_key, payload.to_json) + Base64.strict_encode64(hmac) + end + + def issuer + ENV['LAGO_API_URL'] + end end diff --git a/app/services/webhooks/base_service.rb b/app/services/webhooks/base_service.rb index 600c18e7ed6..9c5fa8cf503 100644 --- a/app/services/webhooks/base_service.rb +++ b/app/services/webhooks/base_service.rb @@ -5,14 +5,12 @@ module Webhooks # NOTE: Abstract Service, should not be used directly class BaseService - def initialize(object:, options: {}, webhook_id: nil) + def initialize(object:, options: {}) @object = object @options = options&.with_indifferent_access - @webhook_id = webhook_id end def call - return resend if webhook_id.present? return if current_organization.webhook_endpoints.none? payload = { @@ -21,27 +19,17 @@ def call object_type => object_serializer.serialize } + # TODO: Wrap in transaction so we create all webhook models or none + # Ensure the http jobs are dispatched after the transaction is committed current_organization.webhook_endpoints.each do |webhook_endpoint| - webhook = initialize_webhook(webhook_endpoint, payload) - send_webhook(webhook, webhook_endpoint, payload) + webhook = create_webhook(webhook_endpoint, payload) + SendHttpWebhookJob.perform_later(webhook) end end - def resend - webhook = Webhook.find_by(id: webhook_id) - return if webhook.blank? - - webhook.retries += 1 if webhook.failed? - webhook.last_retried_at = Time.zone.now if webhook.retries.positive? - webhook.endpoint = webhook.webhook_endpoint.webhook_url - - payload = JSON.parse(webhook.payload) - send_webhook(webhook, webhook.webhook_endpoint, payload) - end - private - attr_reader :object, :options, :webhook_id + attr_reader :object, :options def object_serializer # Empty @@ -59,99 +47,16 @@ def object_type # Empty end - def send_webhook(webhook, webhook_endpoint, payload) - http_client = LagoHttpClient::Client.new(webhook_endpoint.webhook_url) - headers = generate_headers(webhook.id, webhook_endpoint, payload) - response = http_client.post_with_response(payload, headers) - - succeed_webhook(webhook, response) - rescue LagoHttpClient::HttpError, - Net::OpenTimeout, - Net::ReadTimeout, - Net::HTTPBadResponse, - Errno::ECONNRESET, - Errno::ECONNREFUSED, - Errno::EPIPE, - OpenSSL::SSL::SSLError, - SocketError, - EOFError => e - fail_webhook(webhook, e) - - # NOTE: By default, Lago is retrying 3 times a webhook - return if webhook.retries >= ENV.fetch('LAGO_WEBHOOK_ATTEMPTS', 3).to_i - - SendWebhookJob.set(wait: wait_value(webhook)) - .perform_later(webhook_type, object, options, webhook.id) - end - - def generate_headers(webhook_id, webhook_endpoint, payload) - signature = case webhook_endpoint.signature_algo&.to_sym - when :jwt - jwt_signature(payload) - when :hmac - hmac_signature(payload) - end - - { - 'X-Lago-Signature' => signature, - 'X-Lago-Signature-Algorithm' => webhook_endpoint.signature_algo.to_s, - 'X-Lago-Unique-Key' => webhook_id - } - end - - def jwt_signature(payload) - JWT.encode( - { - data: payload.to_json, - iss: issuer - }, - RsaPrivateKey, - 'RS256', - ) - end - - def hmac_signature(payload) - hmac = OpenSSL::HMAC.digest('sha-256', current_organization.api_key, payload.to_json) - Base64.strict_encode64(hmac) - end - - def issuer - ENV['LAGO_API_URL'] - end - - def initialize_webhook(webhook_endpoint, payload) + def create_webhook(webhook_endpoint, payload) webhook = Webhook.new(webhook_endpoint:) webhook.webhook_type = webhook_type webhook.endpoint = webhook_endpoint.webhook_url + # Question: When can this be a hash? webhook.object_id = object.is_a?(Hash) ? object.fetch(:id, nil) : object&.id webhook.object_type = object.is_a?(Hash) ? object.fetch(:class, nil) : object&.class&.to_s - webhook.payload = payload.to_json - webhook.retries += 1 if webhook.failed? - webhook.last_retried_at = Time.zone.now if webhook.retries.positive? + webhook.payload = payload webhook.pending! webhook end - - def succeed_webhook(webhook, response) - webhook.http_status = response&.code&.to_i - webhook.response = response&.body.presence || {} - webhook.succeeded! - end - - def fail_webhook(webhook, error) - if error.is_a?(LagoHttpClient::HttpError) - webhook.http_status = error.error_code - webhook.response = error.error_body - else - webhook.response = error.message - end - webhook.failed! - end - - def wait_value(webhook) - # NOTE: This is based on the Rails Active Job wait algorithm - executions = webhook.retries - ((executions**4) + (Kernel.rand * (executions**4) * 0.15)) + 2 - end end end diff --git a/app/services/webhooks/retry_service.rb b/app/services/webhooks/retry_service.rb index 9bede0860a5..69607925321 100644 --- a/app/services/webhooks/retry_service.rb +++ b/app/services/webhooks/retry_service.rb @@ -12,12 +12,7 @@ def call return result.not_found_failure!(resource: 'webhook') unless webhook return result.not_allowed_failure!(code: 'is_succeeded') if webhook.succeeded? - SendWebhookJob.perform_later( - webhook.webhook_type, - webhook.object, - {}, - webhook.id, - ) + SendHttpWebhookJob.perform_later(webhook) result.webhook = webhook result diff --git a/app/services/webhooks/send_http_service.rb b/app/services/webhooks/send_http_service.rb new file mode 100644 index 00000000000..185658a6b1d --- /dev/null +++ b/app/services/webhooks/send_http_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Webhooks + class SendHttpService < ::BaseService + def initialize(webhook:) + @webhook = webhook + + super + end + + def call + webhook.endpoint = webhook.webhook_endpoint.webhook_url + + http_client = LagoHttpClient::Client.new(webhook.webhook_endpoint.webhook_url) + response = http_client.post_with_response(webhook.payload, webhook.generate_headers) + + mark_webhook_as_succeeded(response) + rescue LagoHttpClient::HttpError, + Net::OpenTimeout, + Net::ReadTimeout, + Net::HTTPBadResponse, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::EPIPE, + OpenSSL::SSL::SSLError, + SocketError, + EOFError => e + mark_webhook_as_failed(e) + + # NOTE: By default, Lago is retrying 3 times a webhook + return if webhook.retries >= ENV.fetch('LAGO_WEBHOOK_ATTEMPTS', 3).to_i + + SendHttpWebhookJob.set(wait: wait_value).perform_later(webhook) + end + + private + + attr_reader :webhook + + def mark_webhook_as_succeeded(response) + webhook.http_status = response&.code&.to_i + webhook.response = response&.body.presence || {} + webhook.status = :succeeded + webhook.save! + end + + def mark_webhook_as_failed(error) + if error.is_a?(LagoHttpClient::HttpError) + webhook.http_status = error.error_code + webhook.response = error.error_body + else + webhook.response = error.message + end + webhook.retries += 1 + webhook.last_retried_at = Time.zone.now + webhook.status = :failed + webhook.save! + end + + def wait_value + # NOTE: This is based on the Rails Active Job wait algorithm + executions = webhook.retries + ((executions**4) + (Kernel.rand * (executions**4) * 0.15)) + 2 + end + end +end diff --git a/spec/jobs/send_webhook_job_spec.rb b/spec/jobs/send_webhook_job_spec.rb index ffb4f335311..2b6a29a2a54 100644 --- a/spec/jobs/send_webhook_job_spec.rb +++ b/spec/jobs/send_webhook_job_spec.rb @@ -8,12 +8,29 @@ let(:organization) { create(:organization, webhook_url: 'http://foo.bar') } let(:invoice) { create(:invoice, organization:) } + context 'when webhook_id is present' do + let(:webhook_service) { instance_double(Webhooks::Invoices::CreatedService) } + + before do + allow(Webhooks::Invoices::CreatedService).to receive(:new) + allow(SendHttpWebhookJob).to receive(:perform_later) + end + + it 'calls the webhook invoice service' do + webhook = create(:webhook, webhook_endpoint: create(:webhook_endpoint, organization:)) + send_webhook_job.perform_now('invoice.created', invoice, {}, webhook.id) + + expect(SendHttpWebhookJob).to have_received(:perform_later).with(webhook) + expect(Webhooks::Invoices::CreatedService).not_to have_received(:new) + end + end + context 'when webhook_type is invoice.created' do let(:webhook_service) { instance_double(Webhooks::Invoices::CreatedService) } before do allow(Webhooks::Invoices::CreatedService).to receive(:new) - .with(object: invoice, options: {}, webhook_id: nil) + .with(object: invoice, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -31,7 +48,7 @@ before do allow(Webhooks::Invoices::AddOnCreatedService).to receive(:new) - .with(object: invoice, options: {}, webhook_id: nil) + .with(object: invoice, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -49,7 +66,7 @@ before do allow(Webhooks::Invoices::PaidCreditAddedService).to receive(:new) - .with(object: invoice, options: {}, webhook_id: nil) + .with(object: invoice, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -78,7 +95,7 @@ before do allow(Webhooks::Events::ErrorService).to receive(:new) - .with(object:, options: {}, webhook_id: nil) + .with(object:, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -106,7 +123,7 @@ before do allow(Webhooks::Events::ValidationErrorsService).to receive(:new) - .with(object:, options:, webhook_id: nil) + .with(object:, options:) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -125,7 +142,7 @@ before do allow(Webhooks::Fees::PayInAdvanceCreatedService).to receive(:new) - .with(object: fee, options: {}, webhook_id: nil) + .with(object: fee, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -152,7 +169,7 @@ before do allow(Webhooks::PaymentProviders::InvoicePaymentFailureService).to receive(:new) - .with(object: invoice, options: webhook_options, webhook_id: nil) + .with(object: invoice, options: webhook_options) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -175,7 +192,7 @@ before do allow(Webhooks::PaymentProviders::CustomerCreatedService).to receive(:new) - .with(object: customer, options: {}, webhook_id: nil) + .with(object: customer, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -197,7 +214,7 @@ before do allow(Webhooks::PaymentProviders::CustomerCheckoutService).to receive(:new) - .with(object: customer, options: {checkout_url: 'https://example.com'}, webhook_id: nil) + .with(object: customer, options: {checkout_url: 'https://example.com'}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -229,7 +246,7 @@ before do allow(Webhooks::PaymentProviders::CustomerErrorService).to receive(:new) - .with(object: customer, options: webhook_options, webhook_id: nil) + .with(object: customer, options: webhook_options) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -252,7 +269,7 @@ before do allow(Webhooks::CreditNotes::CreatedService).to receive(:new) - .with(object: credit_note, options: {}, webhook_id: nil) + .with(object: credit_note, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -274,7 +291,7 @@ before do allow(Webhooks::CreditNotes::GeneratedService).to receive(:new) - .with(object: credit_note, options: {}, webhook_id: nil) + .with(object: credit_note, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -305,7 +322,7 @@ before do allow(Webhooks::CreditNotes::PaymentProviderRefundFailureService).to receive(:new) - .with(object: credit_note, options: webhook_options, webhook_id: nil) + .with(object: credit_note, options: webhook_options) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -328,7 +345,7 @@ before do allow(Webhooks::Invoices::DraftedService).to receive(:new) - .with(object: invoice, options: {}, webhook_id: nil) + .with(object: invoice, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -350,7 +367,7 @@ before do allow(Webhooks::Subscriptions::TerminatedService).to receive(:new) - .with(object: subscription, options: {}, webhook_id: nil) + .with(object: subscription, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -372,7 +389,7 @@ before do allow(Webhooks::Subscriptions::TerminationAlertService).to receive(:new) - .with(object: subscription, options: {}, webhook_id: nil) + .with(object: subscription, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -394,7 +411,7 @@ before do allow(Webhooks::Invoices::PaymentStatusUpdatedService).to receive(:new) - .with(object: invoice, options: {}, webhook_id: nil) + .with(object: invoice, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -423,7 +440,7 @@ before do allow(Webhooks::Subscriptions::StartedService).to receive(:new) - .with(object: subscription, options: {}, webhook_id: nil) + .with(object: subscription, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end @@ -445,7 +462,7 @@ before do allow(Webhooks::Customers::ViesCheckService).to receive(:new) - .with(object: customer, options: {}, webhook_id: nil) + .with(object: customer, options: {}) .and_return(webhook_service) allow(webhook_service).to receive(:call) end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 31436d462bf..6645231b66d 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -3,6 +3,49 @@ require 'rails_helper' RSpec.describe Webhook, type: :model do + subject(:webhook) { create(:webhook) } + + let(:organization) { create(:organization, name: "sefsefs", api_key: 'the_key') } + it { is_expected.to belong_to(:webhook_endpoint) } it { is_expected.to belong_to(:object).optional } + + describe '#generate_headers' do + it 'generates the query headers' do + headers = webhook.generate_headers + + expect(headers).to have_key('X-Lago-Signature') + expect(headers).to have_key('X-Lago-Signature-Algorithm') + expect(headers).to have_key('X-Lago-Unique-Key') + expect(headers['X-Lago-Signature-Algorithm']).to eq('jwt') + expect(headers['X-Lago-Unique-Key']).to eq(webhook.id) + end + end + + describe '#jwt_signature' do + it 'generates a correct jwt signature' do + decoded_signature = JWT.decode( + webhook.jwt_signature, + RsaPublicKey, + true, + { + algorithm: 'RS256', + iss: ENV['LAGO_API_URL'], + verify_iss: true + }, + ) + + expect(decoded_signature).to eq([{"data" => webhook.payload.to_json, "iss" => "https://api.lago.dev"}, {"alg" => "RS256"}]) + end + end + + describe '#hmac_signature' do + it 'generates a correct hmac signature' do + webhook.webhook_endpoint.organization.api_key = 'the_key' + hmac = OpenSSL::HMAC.digest('sha-256', 'the_key', webhook.payload.to_json) + base64_hmac = Base64.strict_encode64(hmac) + + expect(base64_hmac).to eq(webhook.hmac_signature) + end + end end diff --git a/spec/services/webhooks/base_service_spec.rb b/spec/services/webhooks/base_service_spec.rb index 96270d24c38..f1faf00ef65 100644 --- a/spec/services/webhooks/base_service_spec.rb +++ b/spec/services/webhooks/base_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Webhooks::BaseService, type: :service do - subject(:webhook_service) { DummyClass.new(object:, webhook_id: previous_webhook&.id) } + subject(:webhook_service) { WebhooksSpec::DummyClass.new(object:) } let(:organization) { create(:organization) } let(:customer) { create(:customer, organization:) } @@ -13,276 +13,81 @@ let(:previous_webhook) { nil } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - let(:response) { OpenStruct.new(code: 200, body: 'Success') } - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response).and_return(response) - end - - context 'when organization has one webhook endpoint' do - subject(:webhook_service) { DummyClass.new(object:) } - - it 'calls the webhook' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url).once - expect(lago_client).to have_received(:post_with_response).once - end - end - - context 'when organization has 2 webhook endpoints' do - subject(:webhook_service) { DummyClass.new(object:) } - - let(:another_webhook_endpoint) { create(:webhook_endpoint, organization:) } - - it 'calls 2 webhooks' do - webhook_service.call - - organization.reload.webhook_endpoints.each do |endpoint| - expect(LagoHttpClient::Client).to have_received(:new).with(endpoint.webhook_url) - expect(lago_client).to have_received(:post_with_response) - end - end - end - - it 'builds payload with the object type root key' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload['dummy']).to be_present - end + allow(SendHttpWebhookJob).to receive(:perform_later) end - it 'creates a succeeded webhook' do + it 'creates a pending webhook' do webhook_service.call webhook = Webhook.order(created_at: :desc).first aggregate_failures do - expect(webhook).to be_succeeded + expect(webhook.status).to eq('pending') expect(webhook.retries).to be_zero expect(webhook.webhook_type).to eq('dummy.test') expect(webhook.endpoint).to eq(webhook.webhook_endpoint.webhook_url) expect(webhook.object_id).to eq(invoice.id) expect(webhook.object_type).to eq('Invoice') - expect(webhook.http_status).to eq(200) - expect(webhook.response).to eq('Success') + expect(webhook.http_status).to be_nil + expect(webhook.response).to be_nil + expect(webhook.payload.keys).to eq %w[webhook_type object_type dummy] end end - context 'with a previous failed webhook' do - let(:previous_webhook) do - create(:webhook, :failed, webhook_endpoint: organization.webhook_endpoints.first) - end - - it 'succeeds the retried webhook' do + context 'when organization has one webhook endpoint' do + it 'enqueues one http job' do webhook_service.call - previous_webhook.reload - - aggregate_failures do - expect(previous_webhook).to be_succeeded - expect(previous_webhook.http_status).to eq(200) - expect(previous_webhook.retries).to eq(1) - expect(previous_webhook.last_retried_at).not_to be_nil - end + expect(SendHttpWebhookJob).to have_received(:perform_later).once end end - context 'without webhook endpoint' do - let(:organization) { create(:organization) } - - before do - organization.webhook_endpoints.destroy_all - end - - it 'does not call the webhook' do + context 'when organization has 2 webhook endpoints' do + it 'calls 2 webhooks' do + create(:webhook_endpoint, organization:) + object.reload webhook_service.call - expect(LagoHttpClient::Client).not_to have_received(:new) - expect(lago_client).not_to have_received(:post_with_response) + expect(SendHttpWebhookJob).to have_received(:perform_later).twice end end - context 'when client returns an error' do - let(:error_body) do - { - message: 'forbidden' - } - end - - let(:webhook_endpoint) { organization.webhook_endpoints.first } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(webhook_endpoint.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - .and_raise( - LagoHttpClient::HttpError.new(403, error_body.to_json, ''), - ) - end - - it 'creates a failed webhook' do - webhook_service.call - - webhook = Webhook.order(created_at: :desc).first - - aggregate_failures do - expect(webhook).to be_failed - expect(webhook.http_status).to eq(403) - end - end - - it 'enqueues a SendWebhookJob' do - expect { webhook_service.call }.to have_enqueued_job(SendWebhookJob) - end - - context 'with a previous failed webhook' do - let(:previous_webhook) { create(:webhook, :failed, webhook_endpoint:) } - - it 'fails the retried webhooks' do - webhook_service.call - - previous_webhook.reload - - aggregate_failures do - expect(previous_webhook).to be_failed - expect(previous_webhook.http_status).to eq(403) - expect(previous_webhook.retries).to eq(1) - expect(previous_webhook.last_retried_at).not_to be_nil - end - end - - context 'when the previous failed webhook have been retried 3 times' do - let(:previous_webhook) { create(:webhook, :failed, webhook_endpoint:, retries: 2) } - - it 'does not enqueue a SendWebhookJob' do - expect { webhook_service.call }.not_to have_enqueued_job(SendWebhookJob) - end - end - end - end + context 'without webhook endpoint' do + let(:organization) { create(:organization) } - context 'when request fails with a non HTTP error' do before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - .and_raise(Net::ReadTimeout) + organization.webhook_endpoints.destroy_all end - it 'creates a failed webhook' do + it 'does not create the webhook model' do webhook_service.call - webhook = Webhook.order(created_at: :desc).first - - aggregate_failures do - expect(webhook).to be_failed - expect(webhook.http_status).to be_nil - expect(webhook.response).to be_present - end - end - - it 'enqueues a SendWebhookJob' do - expect { webhook_service.call }.to have_enqueued_job(SendWebhookJob) + expect(Webhook.where(object: invoice)).not_to exist end end end +end - describe '.generate_headers' do - let(:webhook_endpoint) { create(:webhook_endpoint, organization:) } - let(:payload) do - ::V1::InvoiceSerializer.new( - object, - root_name: 'invoice', - includes: %i[customer subscriptions], - ).serialize.merge(webook_type: 'add_on.created') - end - - it 'generates the query headers' do - dummy_webhook_id = '895b41d0-474f-4a1f-a911-2df2d74dbe67' - headers = webhook_service.__send__(:generate_headers, dummy_webhook_id, webhook_endpoint, payload) - - expect(headers).to have_key('X-Lago-Signature') - expect(headers).to have_key('X-Lago-Signature-Algorithm') - expect(headers).to have_key('X-Lago-Unique-Key') - expect(headers['X-Lago-Signature-Algorithm']).to eq('jwt') - expect(headers['X-Lago-Unique-Key']).to eq(dummy_webhook_id) +module WebhooksSpec + class DummyClass < Webhooks::BaseService + def current_organization + @current_organization ||= object.organization end - end - describe '.jwt_signature' do - let(:payload) do + def object_serializer ::V1::InvoiceSerializer.new( object, root_name: 'invoice', - includes: %i[customer subscriptions], - ).serialize.merge(webook_type: 'add_on.created') + ) end - it 'generates a correct jwt signature' do - signature = webhook_service.__send__(:jwt_signature, payload) - - decoded_signature = JWT.decode( - signature, - RsaPublicKey, - true, - { - algorithm: 'RS256', - iss: ENV['LAGO_API_URL'], - verify_iss: true - }, - ).first - - expect(decoded_signature['data']).to eq(payload.to_json) + def webhook_type + 'dummy.test' end - end - describe '.hmac_signature' do - let(:payload) do - ::V1::InvoiceSerializer.new( - object, - root_name: 'invoice', - includes: %i[customer subscriptions], - ).serialize.merge(webook_type: 'add_on.created') + def object_type + 'dummy' end - - it 'generates a correct hmac signature' do - signature = webhook_service.__send__(:hmac_signature, payload) - hmac = OpenSSL::HMAC.digest('sha-256', organization.api_key, payload.to_json) - base64_hmac = Base64.strict_encode64(hmac) - - expect(base64_hmac).to eq(signature) - end - end -end - -class DummyClass < Webhooks::BaseService - def current_organization - @current_organization ||= object.organization - end - - def object_serializer - ::V1::InvoiceSerializer.new( - object, - root_name: 'invoice', - ) - end - - def webhook_type - 'dummy.test' - end - - def object_type - 'dummy' end end diff --git a/spec/services/webhooks/credit_notes/created_service_spec.rb b/spec/services/webhooks/credit_notes/created_service_spec.rb index 289e3da87f9..971fd583b2c 100644 --- a/spec/services/webhooks/credit_notes/created_service_spec.rb +++ b/spec/services/webhooks/credit_notes/created_service_spec.rb @@ -11,26 +11,6 @@ let(:credit_note) { create(:credit_note, customer:, invoice:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with credit_note.created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('credit_note.created') - expect(payload[:object_type]).to eq('credit_note') - expect(payload['credit_note'][:customer]).to be_present - expect(payload['credit_note']['items']).to eq([]) - end - end + it_behaves_like 'creates webhook', 'credit_note.created', 'credit_note', {'customer' => Hash, 'items' => []} end end diff --git a/spec/services/webhooks/credit_notes/generated_service_spec.rb b/spec/services/webhooks/credit_notes/generated_service_spec.rb index c9894f7b4b3..6f39cb8be86 100644 --- a/spec/services/webhooks/credit_notes/generated_service_spec.rb +++ b/spec/services/webhooks/credit_notes/generated_service_spec.rb @@ -11,25 +11,6 @@ let(:credit_note) { create(:credit_note, customer:, invoice:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with credit_note.generated webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('credit_note.generated') - expect(payload[:object_type]).to eq('credit_note') - expect(payload['credit_note'][:customer]).to be_present - end - end + it_behaves_like 'creates webhook', 'credit_note.generated', 'credit_note', {'customer' => Hash} end end diff --git a/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb b/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb index 95a05b90be7..df44c9c8abb 100644 --- a/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb +++ b/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb @@ -12,24 +12,6 @@ let(:webhook_options) { {provider_error: {message: 'message', error_code: 'code'}} } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with credit_note.refund_failure webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('credit_note.refund_failure') - expect(payload[:object_type]).to eq('credit_note_payment_provider_refund_error') - end - end + it_behaves_like 'creates webhook', 'credit_note.refund_failure', 'credit_note_payment_provider_refund_error' end end diff --git a/spec/services/webhooks/customers/vies_check_service_spec.rb b/spec/services/webhooks/customers/vies_check_service_spec.rb index 58d568b2174..c5023b242bc 100644 --- a/spec/services/webhooks/customers/vies_check_service_spec.rb +++ b/spec/services/webhooks/customers/vies_check_service_spec.rb @@ -9,24 +9,6 @@ let(:customer) { create(:customer, organization:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with customer.vies_check webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('customer.vies_check') - expect(payload[:object_type]).to eq('customer') - end - end + it_behaves_like 'creates webhook', 'customer.vies_check', 'customer' end end diff --git a/spec/services/webhooks/events/error_service_spec.rb b/spec/services/webhooks/events/error_service_spec.rb index 4c58b2d405b..0eb76b82deb 100644 --- a/spec/services/webhooks/events/error_service_spec.rb +++ b/spec/services/webhooks/events/error_service_spec.rb @@ -10,24 +10,6 @@ let(:options) { {error: {transaction_id: ['value_already_exist']}} } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with event.error webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('event.error') - expect(payload[:object_type]).to eq('event_error') - end - end + it_behaves_like 'creates webhook', 'event.error', 'event_error', {'error' => String, 'event' => Hash} end end diff --git a/spec/services/webhooks/events/validation_errors_service_spec.rb b/spec/services/webhooks/events/validation_errors_service_spec.rb index 844643d798b..f07d12d711e 100644 --- a/spec/services/webhooks/events/validation_errors_service_spec.rb +++ b/spec/services/webhooks/events/validation_errors_service_spec.rb @@ -18,29 +18,10 @@ end describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with events.errors webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('events.errors') - expect(payload[:object_type]).to eq('events_errors') - expect(payload['events_errors']).to include( - invalid_code: Array, - missing_aggregation_property: Array, - missing_group_key: Array, - ) - end - end + it_behaves_like 'creates webhook', 'events.errors', 'events_errors', { + 'invalid_code' => Array, + 'missing_aggregation_property' => Array, + 'missing_group_key' => Array + } end end diff --git a/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb b/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb index 83c9527b91b..03f0160e8c6 100644 --- a/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb +++ b/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb @@ -11,24 +11,6 @@ let(:fee) { create(:fee, customer:, subscription:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with fee.created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('fee.created') - expect(payload[:object_type]).to eq('fee') - end - end + it_behaves_like 'creates webhook', 'fee.created', 'fee', {'amount_cents' => Integer} end end diff --git a/spec/services/webhooks/integrations/customer_created_service_spec.rb b/spec/services/webhooks/integrations/customer_created_service_spec.rb index 0b7f0a87507..a2807746695 100644 --- a/spec/services/webhooks/integrations/customer_created_service_spec.rb +++ b/spec/services/webhooks/integrations/customer_created_service_spec.rb @@ -9,24 +9,6 @@ let(:organization) { create(:organization) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with customer.accounting_provider_created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('customer.accounting_provider_created') - expect(payload[:object_type]).to eq('customer') - end - end + it_behaves_like 'creates webhook', 'customer.accounting_provider_created', 'customer' end end diff --git a/spec/services/webhooks/integrations/customer_error_service_spec.rb b/spec/services/webhooks/integrations/customer_error_service_spec.rb index 12d94b5ca03..86cef9453b7 100644 --- a/spec/services/webhooks/integrations/customer_error_service_spec.rb +++ b/spec/services/webhooks/integrations/customer_error_service_spec.rb @@ -10,26 +10,6 @@ let(:webhook_options) { {provider_error: {message: 'message', error_code: 'code'}} } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with customer.accounting_provider_error webhook type' do - webhook_service.call - - aggregate_failures do - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('customer.accounting_provider_error') - expect(payload[:object_type]).to eq('accounting_provider_customer_error') - end - end - end + it_behaves_like 'creates webhook', 'customer.accounting_provider_error', 'accounting_provider_customer_error' end end diff --git a/spec/services/webhooks/invoices/add_on_created_service_spec.rb b/spec/services/webhooks/invoices/add_on_created_service_spec.rb index f050ae93ab7..1c554cb8467 100644 --- a/spec/services/webhooks/invoices/add_on_created_service_spec.rb +++ b/spec/services/webhooks/invoices/add_on_created_service_spec.rb @@ -11,24 +11,6 @@ let(:invoice) { create(:invoice, customer:, organization:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.add_on_added webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.add_on_added') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.add_on_added', 'invoice' end end diff --git a/spec/services/webhooks/invoices/created_service_spec.rb b/spec/services/webhooks/invoices/created_service_spec.rb index 020c4af0dd5..01cb28115e2 100644 --- a/spec/services/webhooks/invoices/created_service_spec.rb +++ b/spec/services/webhooks/invoices/created_service_spec.rb @@ -16,24 +16,6 @@ end describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.created') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.created', 'invoice', {'fees' => Array, 'credits' => Array} end end diff --git a/spec/services/webhooks/invoices/drafted_service_spec.rb b/spec/services/webhooks/invoices/drafted_service_spec.rb index 07b4318d417..2e302e489cf 100644 --- a/spec/services/webhooks/invoices/drafted_service_spec.rb +++ b/spec/services/webhooks/invoices/drafted_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Webhooks::Invoices::DraftedService do - subject(:webhook_invoice_service) { described_class.new(object: invoice) } + subject(:webhook_service) { described_class.new(object: invoice) } # let(:webhook_endpoint) { create(:webhook_endpoint, webhook_url:) } let(:organization) { create(:organization) } @@ -17,24 +17,6 @@ end describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.drafted webhook type' do - webhook_invoice_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.drafted') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.drafted', 'invoice', {'fees' => Array, 'credits' => Array} end end diff --git a/spec/services/webhooks/invoices/generated_service_spec.rb b/spec/services/webhooks/invoices/generated_service_spec.rb index 63e96285100..26527926494 100644 --- a/spec/services/webhooks/invoices/generated_service_spec.rb +++ b/spec/services/webhooks/invoices/generated_service_spec.rb @@ -11,25 +11,6 @@ let(:organization) { create(:organization) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.generated webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.generated') - expect(payload[:object_type]).to eq('invoice') - expect(payload['invoice'][:customer]).to be_present - end - end + it_behaves_like 'creates webhook', 'invoice.generated', 'invoice', {'amount_cents' => Integer} end end diff --git a/spec/services/webhooks/invoices/one_off_created_service_spec.rb b/spec/services/webhooks/invoices/one_off_created_service_spec.rb index 10753df44bd..37c49ba507e 100644 --- a/spec/services/webhooks/invoices/one_off_created_service_spec.rb +++ b/spec/services/webhooks/invoices/one_off_created_service_spec.rb @@ -10,24 +10,6 @@ let(:invoice) { create(:invoice, customer:, organization:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.one_off_created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.one_off_created') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.one_off_created', 'invoice' end end diff --git a/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb b/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb index c190cee3c18..6c7dd9b0ae1 100644 --- a/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb +++ b/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb @@ -11,24 +11,6 @@ let(:invoice) { create(:invoice, customer:, organization:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.paid_credit_added webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.paid_credit_added') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.paid_credit_added', 'invoice' end end diff --git a/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb b/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb index 89e533fd74a..433e9d9b6f5 100644 --- a/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb +++ b/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Webhooks::Invoices::PaymentDisputeLostService do - subject(:service) { described_class.new(object: invoice) } + subject(:webhook_service) { described_class.new(object: invoice) } let(:organization) { create(:organization) } let(:customer) { create(:customer, organization:) } @@ -16,24 +16,6 @@ end describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.payment_dispute_lost webhook type' do - service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.payment_dispute_lost') - expect(payload[:object_type]).to eq('payment_dispute_lost') - end - end + it_behaves_like 'creates webhook', 'invoice.payment_dispute_lost', 'payment_dispute_lost', {'invoice' => Hash} end end diff --git a/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb b/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb index 4e75003284c..082b082e673 100644 --- a/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb +++ b/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb @@ -11,24 +11,6 @@ let(:invoice) { create(:invoice, customer:, organization:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.payment_status_updated webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.payment_status_updated') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.payment_status_updated', 'invoice' end end diff --git a/spec/services/webhooks/invoices/voided_service_spec.rb b/spec/services/webhooks/invoices/voided_service_spec.rb index d29c5f406b8..8d5a4febe39 100644 --- a/spec/services/webhooks/invoices/voided_service_spec.rb +++ b/spec/services/webhooks/invoices/voided_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Webhooks::Invoices::VoidedService do - subject(:webhook_invoice_service) { described_class.new(object: invoice) } + subject(:webhook_service) { described_class.new(object: invoice) } let(:organization) { create(:organization) } let(:customer) { create(:customer, organization:) } @@ -16,24 +16,6 @@ end describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.voided webhook type' do - webhook_invoice_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.voided') - expect(payload[:object_type]).to eq('invoice') - end - end + it_behaves_like 'creates webhook', 'invoice.voided', 'invoice', {'fees' => Array, 'credits' => Array} end end diff --git a/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb b/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb index cb6b9238535..c0d82d081ba 100644 --- a/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb +++ b/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb @@ -9,24 +9,6 @@ let(:organization) { create(:organization) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with customer.checkout_url_generated webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('customer.checkout_url_generated') - expect(payload[:object_type]).to eq('payment_provider_customer_checkout_url') - end - end + it_behaves_like 'creates webhook', 'customer.checkout_url_generated', 'payment_provider_customer_checkout_url' end end diff --git a/spec/services/webhooks/payment_providers/customer_created_service_spec.rb b/spec/services/webhooks/payment_providers/customer_created_service_spec.rb index 8d0797f3213..6b047acaf63 100644 --- a/spec/services/webhooks/payment_providers/customer_created_service_spec.rb +++ b/spec/services/webhooks/payment_providers/customer_created_service_spec.rb @@ -9,24 +9,6 @@ let(:organization) { create(:organization) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with customer.payment_provider_created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('customer.payment_provider_created') - expect(payload[:object_type]).to eq('customer') - end - end + it_behaves_like 'creates webhook', 'customer.payment_provider_created', 'customer' end end diff --git a/spec/services/webhooks/payment_providers/customer_error_service_spec.rb b/spec/services/webhooks/payment_providers/customer_error_service_spec.rb index d2b3963c9c0..ec97f24f561 100644 --- a/spec/services/webhooks/payment_providers/customer_error_service_spec.rb +++ b/spec/services/webhooks/payment_providers/customer_error_service_spec.rb @@ -10,26 +10,6 @@ let(:webhook_options) { {provider_error: {message: 'message', error_code: 'code'}} } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with customer.payment_provider_error webhook type' do - webhook_service.call - - aggregate_failures do - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('customer.payment_provider_error') - expect(payload[:object_type]).to eq('payment_provider_customer_error') - end - end - end + it_behaves_like 'creates webhook', 'customer.payment_provider_error', 'payment_provider_customer_error' end end diff --git a/spec/services/webhooks/payment_providers/error_service_spec.rb b/spec/services/webhooks/payment_providers/error_service_spec.rb index 85f64295162..ef2b3261765 100644 --- a/spec/services/webhooks/payment_providers/error_service_spec.rb +++ b/spec/services/webhooks/payment_providers/error_service_spec.rb @@ -9,27 +9,5 @@ let(:organization) { create(:organization) } let(:webhook_options) { {provider_error: {message: 'message', error_code: 'code', source: 'stripe', action: 'payment_provider.register_webhook'}} } - describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with payment_provider.error webhook type' do - webhook_service.call - - aggregate_failures do - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('payment_provider.error') - expect(payload[:object_type]).to eq('payment_provider_error') - end - end - end - end + it_behaves_like 'creates webhook', 'payment_provider.error', 'payment_provider_error' end diff --git a/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb b/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb index 94f603c0278..a3dc1720b74 100644 --- a/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb +++ b/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb @@ -12,24 +12,6 @@ let(:webhook_options) { {provider_error: {message: 'message', error_code: 'code'}} } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with invoice.payment_failure webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('invoice.payment_failure') - expect(payload[:object_type]).to eq('payment_provider_invoice_payment_error') - end - end + it_behaves_like 'creates webhook', 'invoice.payment_failure', 'payment_provider_invoice_payment_error' end end diff --git a/spec/services/webhooks/retry_service_spec.rb b/spec/services/webhooks/retry_service_spec.rb index 847e31fa06e..f364e28d65e 100644 --- a/spec/services/webhooks/retry_service_spec.rb +++ b/spec/services/webhooks/retry_service_spec.rb @@ -8,13 +8,7 @@ let(:webhook) { create(:webhook, :failed) } it 'enqueues a SendWebhookJob' do - expect { retry_service.call }.to have_enqueued_job(SendWebhookJob) - .with( - webhook.webhook_type, - webhook.object, - {}, - webhook.id, - ) + expect { retry_service.call }.to have_enqueued_job(SendHttpWebhookJob).with(webhook) end it 'assigns webhook to result' do diff --git a/spec/services/webhooks/send_http_service_spec.rb b/spec/services/webhooks/send_http_service_spec.rb new file mode 100644 index 00000000000..485544a409e --- /dev/null +++ b/spec/services/webhooks/send_http_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Webhooks::SendHttpService, type: :service do + subject(:service) { described_class.new(webhook:) } + + let(:webhook_endpoint) { create(:webhook_endpoint, webhook_url: 'https://wh.test.com') } + let(:webhook) { create(:webhook, webhook_endpoint:) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + + context 'when client returns a success' do + before do + WebMock.stub_request(:post, 'https://wh.test.com').to_return(status: 200, body: 'ok') + end + + it 'marks the webhook as succeeded' do + service.call + + expect(WebMock).to have_requested(:post, 'https://wh.test.com').with( + body: webhook.payload.to_json, + headers: {'Content-Type' => 'application/json'} + ) + expect(webhook.status).to eq 'succeeded' + expect(webhook.http_status).to eq 200 + expect(webhook.response).to eq 'ok' + end + end + + context 'when client returns an error' do + let(:error_body) do + { + message: 'forbidden' + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new).with(webhook.webhook_endpoint.webhook_url).and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_raise( + LagoHttpClient::HttpError.new(403, error_body.to_json, ''), + ) + allow(SendHttpWebhookJob).to receive(:set).and_return(class_double(SendHttpWebhookJob, perform_later: nil)) + end + + it 'creates a failed webhook' do + service.call + + aggregate_failures do + expect(webhook).to be_failed + expect(webhook.http_status).to eq(403) + expect(SendHttpWebhookJob).to have_received(:set) + end + end + + context 'with a failed webhook' do + let(:webhook) { create(:webhook, :failed) } + + it 'fails the retried webhooks' do + service.call + + aggregate_failures do + expect(webhook).to be_failed + expect(webhook.http_status).to eq(403) + expect(webhook.retries).to eq(1) + expect(webhook.last_retried_at).not_to be_nil + expect(SendHttpWebhookJob).to have_received(:set) + end + end + + context 'when the webhook failed 3 times' do + let(:webhook) { create(:webhook, :failed, retries: 2) } + + it 'stops trying' do + service.call + expect(webhook.reload.retries).to eq 3 + expect(SendHttpWebhookJob).not_to have_received(:set) + end + end + end + end +end diff --git a/spec/services/webhooks/subscriptions/started_service_spec.rb b/spec/services/webhooks/subscriptions/started_service_spec.rb index 728b766ffc7..653eb3ff426 100644 --- a/spec/services/webhooks/subscriptions/started_service_spec.rb +++ b/spec/services/webhooks/subscriptions/started_service_spec.rb @@ -9,26 +9,6 @@ let(:organization) { subscription.organization } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with subscription.started webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('subscription.started') - expect(payload[:object_type]).to eq('subscription') - expect(payload['subscription'][:customer]).to be_present - expect(payload['subscription'][:plan]).to be_present - end - end + it_behaves_like 'creates webhook', 'subscription.started', 'subscription' end end diff --git a/spec/services/webhooks/subscriptions/terminated_service_spec.rb b/spec/services/webhooks/subscriptions/terminated_service_spec.rb index 0ccb6c7913e..4bb5099ce7f 100644 --- a/spec/services/webhooks/subscriptions/terminated_service_spec.rb +++ b/spec/services/webhooks/subscriptions/terminated_service_spec.rb @@ -9,24 +9,6 @@ let(:organization) { subscription.organization } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with subscription.terminated webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('subscription.terminated') - expect(payload[:object_type]).to eq('subscription') - end - end + it_behaves_like 'creates webhook', 'subscription.terminated', 'subscription' end end diff --git a/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb b/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb index b7d07132186..961c2ec19a3 100644 --- a/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb +++ b/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb @@ -9,24 +9,6 @@ let(:organization) { subscription.organization } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with subscription.termination_alert webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('subscription.termination_alert') - expect(payload[:object_type]).to eq('subscription') - end - end + it_behaves_like 'creates webhook', 'subscription.termination_alert', 'subscription' end end diff --git a/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb b/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb index aefd8630b56..4c431294e02 100644 --- a/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb +++ b/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb @@ -9,24 +9,6 @@ let(:organization) { subscription.organization } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with subscription.trial_ended webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('subscription.trial_ended') - expect(payload[:object_type]).to eq('subscription') - end - end + it_behaves_like 'creates webhook', 'subscription.trial_ended', 'subscription' end end diff --git a/spec/services/webhooks/wallet_transactions/created_service_spec.rb b/spec/services/webhooks/wallet_transactions/created_service_spec.rb index 9e3ecf7bae8..20b5eadd022 100644 --- a/spec/services/webhooks/wallet_transactions/created_service_spec.rb +++ b/spec/services/webhooks/wallet_transactions/created_service_spec.rb @@ -11,25 +11,6 @@ let(:wallet_transaction) { create(:wallet_transaction, wallet:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with wallet_transaction.created webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('wallet_transaction.created') - expect(payload[:object_type]).to eq('wallet_transaction') - end - end + it_behaves_like 'creates webhook', 'wallet_transaction.created', 'wallet_transaction' end end diff --git a/spec/services/webhooks/wallet_transactions/updated_service_spec.rb b/spec/services/webhooks/wallet_transactions/updated_service_spec.rb index 5ae5aecc764..706ad209e29 100644 --- a/spec/services/webhooks/wallet_transactions/updated_service_spec.rb +++ b/spec/services/webhooks/wallet_transactions/updated_service_spec.rb @@ -11,25 +11,6 @@ let(:wallet_transaction) { create(:wallet_transaction, wallet:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with wallet_transaction.updated webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('wallet_transaction.updated') - expect(payload[:object_type]).to eq('wallet_transaction') - end - end + it_behaves_like 'creates webhook', 'wallet_transaction.updated', 'wallet_transaction' end end diff --git a/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb b/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb index f5428aca4c3..ffca34f15ac 100644 --- a/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb +++ b/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb @@ -10,25 +10,6 @@ let(:wallet) { create(:wallet, customer:) } describe '.call' do - let(:lago_client) { instance_double(LagoHttpClient::Client) } - - before do - allow(LagoHttpClient::Client).to receive(:new) - .with(organization.webhook_endpoints.first.webhook_url) - .and_return(lago_client) - allow(lago_client).to receive(:post_with_response) - end - - it 'builds payload with wallet.depleted_ongoing_balance webhook type' do - webhook_service.call - - expect(LagoHttpClient::Client).to have_received(:new) - .with(organization.webhook_endpoints.first.webhook_url) - - expect(lago_client).to have_received(:post_with_response) do |payload| - expect(payload[:webhook_type]).to eq('wallet.depleted_ongoing_balance') - expect(payload[:object_type]).to eq('wallet') - end - end + it_behaves_like 'creates webhook', 'wallet.depleted_ongoing_balance', 'wallet' end end diff --git a/spec/support/shared_examples/creates_webhook.rb b/spec/support/shared_examples/creates_webhook.rb new file mode 100644 index 00000000000..509061670c4 --- /dev/null +++ b/spec/support/shared_examples/creates_webhook.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'creates webhook' do |webhook_type, object_type, object = {}| + it 'create correct webhook model' do + webhook_service.call + + webhook = Webhook.order(created_at: :desc).first + expect(webhook.payload).to match({ + 'webhook_type' => webhook_type, + 'object_type' => object_type, + object_type => hash_including(object) + }) + end +end