Back to Repositories

Testing Webhook Delivery Service Implementation in Postal Server

This test suite examines the WebhookDeliveryService functionality in the Postal server application, focusing on webhook request handling and delivery. It verifies proper request processing, error handling, and retry mechanisms for webhook deliveries.

Test Coverage Overview

The test suite provides comprehensive coverage of webhook delivery scenarios including successful deliveries and error cases.

Key areas tested include:
  • Webhook request formatting and signature headers
  • Successful 200 OK response handling
  • Error handling for 500 Internal Server Error responses
  • Retry mechanism with progressive timing
  • Maximum retry attempt handling

Implementation Analysis

The testing approach uses RSpec’s describe/context structure to organize different webhook response scenarios. It leverages factory_bot for test data generation and WebMock for HTTP request stubbing.

The tests utilize let blocks for setup, before hooks for request stubbing, and expectation matching for verifying request headers and payload structure.

Technical Details

Testing tools and configuration:
  • RSpec for test framework
  • WebMock for HTTP request mocking
  • FactoryBot for test data generation
  • Rails test helpers for database interaction
  • Custom matchers for webhook signature validation

Best Practices Demonstrated

The test suite exemplifies several testing best practices including isolated test cases, proper setup and teardown, and comprehensive error scenario coverage.

Notable practices include:
  • Consistent use of context blocks for different scenarios
  • Proper stubbing of external HTTP requests
  • Thorough validation of request headers and signatures
  • Progressive retry timing verification
  • Clear test case organization and naming

postalserver/postal

spec/services/webhook_delivery_service_spec.rb

            
# frozen_string_literal: true

require "rails_helper"

RSpec.describe WebhookDeliveryService do
  let(:server) { create(:server) }
  let(:webhook) { create(:webhook, server: server) }
  let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook) }

  subject(:service) { described_class.new(webhook_request: webhook_request) }

  let(:response_status) { 200 }
  let(:response_body) { "OK" }

  before do
    stub_request(:post, webhook.url).to_return(status: response_status, body: response_body)
  end

  describe "#call" do
    it "sends a request to the webhook's url" do
      service.call
      expect(WebMock).to have_requested(:post, webhook.url).with({
        body: {
          event: webhook_request.event,
          timestamp: webhook_request.created_at.to_f,
          payload: webhook_request.payload,
          uuid: webhook_request.uuid
        }.to_json,
        headers: {
          "Content-Type" => "application/json",
          "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i,
          "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i,
          "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i
        }
      })
    end

    context "when the endpoint returns a 200 OK" do
      it "creates a webhook request for the server" do
        service.call
        expect(server.message_db.webhooks.list(1)[:total]).to eq(1)
        webhook_request = server.message_db.webhooks.list(1)[:records].first
        expect(webhook_request).to have_attributes(
          event: webhook_request.event,
          url: webhook_request.url,
          status_code: 200,
          body: "OK",
          uuid: webhook_request.uuid,
          will_retry?: false,
          payload: webhook_request.payload,
          attempt: 1,
          timestamp: webhook_request.timestamp
        )
      end

      it "deletes the webhook request" do
        service.call
        expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
      end

      it "updates the last used at time on the webhook" do
        service.call
        expect(webhook.reload.last_used_at).to be_within(1.second).of(Time.current)
      end
    end

    context "when the request returns a 500 Internal Server Error for the first time" do
      let(:response_status) { 500 }
      let(:response_body) { "internal server error!" }

      it "unlocks the webhook request if locked" do
        expect { service.call }.to change { webhook_request.reload.locked? }.from(true).to(false)
      end

      it "updates the retry time and attempt counter" do
        service.call
        expect(webhook_request.reload.attempts).to eq(1)
        expect(webhook_request.retry_after).to be_within(1.second).of(2.minutes.from_now)
      end
    end

    context "when the request returns a 500 Internal Server Error for the second time" do
      let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook, attempts: 1) }
      let(:response_status) { 500 }
      let(:response_body) { "internal server error!" }

      it "updates the retry time and attempt counter" do
        service.call
        expect(webhook_request.reload.attempts).to eq(2)
        expect(webhook_request.retry_after).to be_within(1.second).of(3.minutes.from_now)
      end
    end

    context "when the request returns a 500 Internal Server Error for the sixth time" do
      let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook, attempts: 5) }
      let(:response_status) { 500 }
      let(:response_body) { "internal server error!" }

      it "creates a webhook request for the server" do
        service.call
        expect(server.message_db.webhooks.list(1)[:total]).to eq(1)
        webhook_request = server.message_db.webhooks.list(1)[:records].first
        expect(webhook_request).to have_attributes(
          status_code: 500,
          body: "internal server error!",
          will_retry?: false,
          attempt: 6
        )
      end

      it "deletes the webhook request" do
        service.call
        expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
      end
    end
  end
end