From 33df257c994b611f0c8ae8cab22797d9ff36dab7 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 28 Apr 2026 18:38:55 +0200 Subject: [PATCH 1/7] feat: establish subscription endpoint --- .../api/subscriptions_controller.rb | 49 +++++++++++++++++ config/initializers/cors.rb | 2 +- config/routes.rb | 1 + spec/requests/api/subscriptions_spec.rb | 55 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/subscriptions_controller.rb create mode 100644 spec/requests/api/subscriptions_spec.rb diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb new file mode 100644 index 000000000..c7cccd1d5 --- /dev/null +++ b/app/controllers/api/subscriptions_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Api + class SubscriptionsController < ApiController + def create + payload = subscription_params.to_h + errors = validation_errors_for(payload) + + Rails.logger.warn( + "[subscriptions#create] payload=#{payload.inspect} result=#{errors.empty? ? 'success' : 'failure'}" + ) + + if errors.empty? + render json: { + ok: true, + message: 'Subscription accepted', + subscription: payload + }, status: :ok + else + render json: { + ok: false, + error_code: 'subscription_validation_failed', + message: 'Subscription rejected due to invalid input', + errors:, + subscription: payload + }, status: :unprocessable_content + end + end + + private + + def subscription_params + params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy) + end + + def validation_errors_for(payload) + errors = [] + errors << 'email is required' if payload['email'].blank? + errors << 'email is invalid' if payload['email'].present? && !valid_email?(payload['email']) + errors << 'privacy_policy must be true' unless payload['privacy_policy'] == true + errors + end + + def valid_email?(email) + # Keep codebase-consistent validator and also require a dot in the domain. + email.match?(EmailValidator.regexp) && email.split('@').last&.include?('.') + end + end +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 3dc4dd85c..36007fe3e 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -8,7 +8,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do # localhost and test domain origins - origins(%r{https?://localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test? + origins(%r{https?://([a-z0-9-]+\.)?localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test? standard_cors_options end diff --git a/config/routes.rb b/config/routes.rb index 24aaca4bf..c64e4d46d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,7 @@ resources :features, only: %i[index] resources :profile_auth_check, only: %i[index] + resources :subscriptions, only: %i[create] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb new file mode 100644 index 000000000..62ca173b4 --- /dev/null +++ b/spec/requests/api/subscriptions_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Subscriptions API' do + describe 'POST /api/subscriptions' do + let(:path) { '/api/subscriptions' } + let(:payload) do + { + subscription: { + email: 'teacher@example.com', + test_opt_in: true, + privacy_policy: true + } + } + end + + it 'returns success for a valid payload' do + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to include( + 'ok' => true, + 'message' => 'Subscription accepted' + ) + end + + it 'returns 422 when email is missing' do + post(path, params: payload.deep_merge(subscription: { email: '' }), as: :json) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors']).to include('email is required') + end + + it 'returns 422 when email is malformed' do + post(path, params: payload.deep_merge(subscription: { email: 'invalid-email' }), as: :json) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors']).to include('email is invalid') + end + + it 'returns 422 when privacy_policy is not true' do + post(path, params: payload.deep_merge(subscription: { privacy_policy: false }), as: :json) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors']).to include('privacy_policy must be true') + end + + it 'returns 400 when subscription params are missing' do + post(path, params: {}, as: :json) + + expect(response).to have_http_status(:bad_request) + end + end +end From e9b185252fe8ca3581fb1145eeade74ca0544140 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 28 Apr 2026 19:09:51 +0200 Subject: [PATCH 2/7] feat: set up form handler submission to pardot --- .env.example | 5 +- .../api/subscriptions_controller.rb | 25 ++++++-- .../pardot_form_handler_submitter.rb | 57 +++++++++++++++++++ config/application.rb | 2 + spec/requests/api/subscriptions_spec.rb | 38 +++++++++++++ 5 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 app/services/subscriptions/pardot_form_handler_submitter.rb diff --git a/.env.example b/.env.example index bcf9830dc..3e52404cc 100644 --- a/.env.example +++ b/.env.example @@ -64,4 +64,7 @@ SALESFORCE_CONNECT_PASSWORD=password SALESFORCE_CONNECT_USER=postgres SCRATCH_ASSET_CONFIG_BASE_URL=https://example.com/config/ -SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ \ No newline at end of file +SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ + +# Pardot Form Handler endpoint for subscription forwarding +PARDOT_SUBSCRIPTION_URL= \ No newline at end of file diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index c7cccd1d5..0d04a10e3 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -11,11 +11,20 @@ def create ) if errors.empty? - render json: { - ok: true, - message: 'Subscription accepted', - subscription: payload - }, status: :ok + submit_result = subscriptions_submitter.call(form_payload: payload) + if submit_result.success? + render json: { + ok: true, + message: 'Subscription accepted', + subscription: payload + }, status: :ok + else + render json: { + ok: false, + error_code: submit_result.error_code, + message: submit_result.message + }, status: submit_result.status + end else render json: { ok: false, @@ -33,6 +42,12 @@ def subscription_params params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy) end + def subscriptions_submitter + @subscriptions_submitter ||= Subscriptions::PardotFormHandlerSubmitter.new( + endpoint_url: Rails.configuration.x.subscriptions.pardot_form_handler_url + ) + end + def validation_errors_for(payload) errors = [] errors << 'email is required' if payload['email'].blank? diff --git a/app/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb new file mode 100644 index 000000000..38d89043f --- /dev/null +++ b/app/services/subscriptions/pardot_form_handler_submitter.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Subscriptions + class PardotFormHandlerSubmitter + Result = Struct.new(:success?, :status, :error_code, :message, keyword_init: true) + + REQUEST_TIMEOUT_SECONDS = 10 + OPEN_TIMEOUT_SECONDS = 5 + + def initialize(endpoint_url:) + @endpoint_url = endpoint_url + end + + def call(form_payload:) + return missing_configuration_result if endpoint_url.blank? + + response = faraday.post(endpoint_url, form_payload) + return Result.new(success?: true) if response.success? + + Result.new( + success?: false, + status: :bad_gateway, + error_code: 'subscription_provider_rejected', + message: 'Subscription provider rejected the request.' + ) + rescue Faraday::Error + # Sentry.capture_exception(e) + Result.new( + success?: false, + status: :service_unavailable, + error_code: 'subscription_provider_unavailable', + message: 'Subscription provider is currently unavailable.' + ) + end + + private + + attr_reader :endpoint_url + + def faraday + @faraday ||= Faraday.new do |f| + f.request :url_encoded + f.options.timeout = REQUEST_TIMEOUT_SECONDS + f.options.open_timeout = OPEN_TIMEOUT_SECONDS + end + end + + def missing_configuration_result + Result.new( + success?: false, + status: :service_unavailable, + error_code: 'subscription_provider_not_configured', + message: 'Subscription provider endpoint is not configured.' + ) + end + end +end diff --git a/config/application.rb b/config/application.rb index e8b482e8c..06a00fdf3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -69,5 +69,7 @@ class Application < Rails::Application config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') + + config.x.subscriptions.pardot_form_handler_url = ENV.fetch('PARDOT_SUBSCRIPTION_URL', '') end end diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb index 62ca173b4..c2b10d125 100644 --- a/spec/requests/api/subscriptions_spec.rb +++ b/spec/requests/api/subscriptions_spec.rb @@ -15,6 +15,24 @@ } end + let(:submitter_result_success) do + Subscriptions::PardotFormHandlerSubmitter::Result.new(success?: true) + end + let(:submitter_result_failure) do + Subscriptions::PardotFormHandlerSubmitter::Result.new( + success?: false, + status: :service_unavailable, + error_code: 'subscription_provider_unavailable', + message: 'Subscription provider is currently unavailable.' + ) + end + let(:submitter) { instance_double(Subscriptions::PardotFormHandlerSubmitter) } + + before do + allow(Subscriptions::PardotFormHandlerSubmitter).to receive(:new).and_return(submitter) + allow(submitter).to receive(:call).and_return(submitter_result_success) + end + it 'returns success for a valid payload' do post(path, params: payload, as: :json) @@ -23,6 +41,13 @@ 'ok' => true, 'message' => 'Subscription accepted' ) + expect(submitter).to have_received(:call).with( + form_payload: { + 'email' => 'teacher@example.com', + 'test_opt_in' => true, + 'privacy_policy' => true + } + ) end it 'returns 422 when email is missing' do @@ -51,5 +76,18 @@ expect(response).to have_http_status(:bad_request) end + + it 'returns provider error status/message when provider submission fails' do + allow(submitter).to receive(:call).and_return(submitter_result_failure) + + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:service_unavailable) + expect(response.parsed_body).to include( + 'ok' => false, + 'error_code' => 'subscription_provider_unavailable', + 'message' => 'Subscription provider is currently unavailable.' + ) + end end end From 9434509e68daa0d9cdfbd6fd9770c8208198d32e Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 28 Apr 2026 19:27:59 +0200 Subject: [PATCH 3/7] feat: lock down success and log --- .../pardot_form_handler_submitter.rb | 65 +++++++++++++++++-- .../pardot_form_handler_submitter_spec.rb | 58 +++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 spec/services/subscriptions/pardot_form_handler_submitter_spec.rb diff --git a/app/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb index 38d89043f..5d3f48d0b 100644 --- a/app/services/subscriptions/pardot_form_handler_submitter.rb +++ b/app/services/subscriptions/pardot_form_handler_submitter.rb @@ -6,6 +6,9 @@ class PardotFormHandlerSubmitter REQUEST_TIMEOUT_SECONDS = 10 OPEN_TIMEOUT_SECONDS = 5 + SUCCESS_STATUS_CODES = [200, 302].freeze + SUCCESS_LOCATION_PATTERNS = ['/success'].freeze + ERROR_LOCATION_PATTERNS = ['/error'].freeze def initialize(endpoint_url:) @endpoint_url = endpoint_url @@ -15,14 +18,12 @@ def call(form_payload:) return missing_configuration_result if endpoint_url.blank? response = faraday.post(endpoint_url, form_payload) - return Result.new(success?: true) if response.success? - - Result.new( - success?: false, - status: :bad_gateway, - error_code: 'subscription_provider_rejected', - message: 'Subscription provider rejected the request.' + Rails.logger.info( + "[subscriptions#provider] status=#{response.status} " \ + "location=#{redirect_location(response)} " \ + "classification=#{classification_for(response)}" ) + classify_response(response) rescue Faraday::Error # Sentry.capture_exception(e) Result.new( @@ -53,5 +54,55 @@ def missing_configuration_result message: 'Subscription provider endpoint is not configured.' ) end + + def classify_response(response) + return reject_result unless SUCCESS_STATUS_CODES.include?(response.status) + return Result.new(success?: true) if response.status == 200 + return reject_result if redirect_to_error_location?(response) + return Result.new(success?: true) if redirect_to_success_location?(response) + + ambiguous_result + end + + def reject_result + Result.new( + success?: false, + status: :bad_gateway, + error_code: 'subscription_provider_rejected', + message: 'Subscription provider rejected the request.' + ) + end + + def ambiguous_result + Result.new( + success?: false, + status: :bad_gateway, + error_code: 'subscription_provider_ambiguous', + message: 'Subscription provider response was ambiguous.' + ) + end + + def redirect_to_success_location?(response) + location = redirect_location(response) + SUCCESS_LOCATION_PATTERNS.any? { |pattern| location.include?(pattern) } + end + + def redirect_to_error_location?(response) + location = redirect_location(response) + ERROR_LOCATION_PATTERNS.any? { |pattern| location.include?(pattern) } + end + + def redirect_location(response) + response.headers.fetch('location', '').to_s.downcase + end + + def classification_for(response) + return 'rejected_status' unless SUCCESS_STATUS_CODES.include?(response.status) + return 'accepted_200' if response.status == 200 + return 'rejected_error_redirect' if redirect_to_error_location?(response) + return 'accepted_success_redirect' if redirect_to_success_location?(response) + + 'ambiguous_redirect' + end end end diff --git a/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb new file mode 100644 index 000000000..324d5aabc --- /dev/null +++ b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Subscriptions::PardotFormHandlerSubmitter do + let(:endpoint_url) { 'https://example.com/form-handler' } + let(:service) { described_class.new(endpoint_url: endpoint_url) } + let(:payload) { { 'email' => 'teacher@example.com', 'privacy_policy' => true } } + + describe '#call' do + let(:connection) { instance_double(Faraday::Connection) } + let(:response) { instance_double(Faraday::Response, status: status, headers: headers, success?: success) } + let(:headers) { {} } + let(:status) { 200 } + let(:success) { true } + + before do + allow(Faraday).to receive(:new).and_return(connection) + allow(connection).to receive(:post).and_return(response) + end + + it 'returns success for a 200 response' do + result = service.call(form_payload: payload) + + expect(result.success?).to be(true) + end + + it 'returns success for a 302 success redirect location' do + allow(response).to receive_messages(status: 302, success?: false, headers: { 'location' => '/subscriptions/success' }) + + result = service.call(form_payload: payload) + + expect(result.success?).to be(true) + end + + it 'returns rejected for a 302 error redirect location' do + allow(response).to receive_messages(status: 302, success?: false, headers: { 'location' => '/subscriptions/error' }) + + result = service.call(form_payload: payload) + + aggregate_failures do + expect(result.success?).to be(false) + expect(result.error_code).to eq('subscription_provider_rejected') + end + end + + it 'returns ambiguous for an unknown 302 redirect location' do + allow(response).to receive_messages(status: 302, success?: false, headers: { 'location' => '/subscriptions/unknown' }) + + result = service.call(form_payload: payload) + + aggregate_failures do + expect(result.success?).to be(false) + expect(result.error_code).to eq('subscription_provider_ambiguous') + end + end + end +end From 72dc376e0caa42ae2b1cd9c2c11582ec759f2d94 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 29 Apr 2026 17:25:53 +0200 Subject: [PATCH 4/7] feat: update the logging --- app/controllers/api/subscriptions_controller.rb | 9 +++++---- .../subscriptions/pardot_form_handler_submitter.rb | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index 0d04a10e3..9047ec66d 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -6,19 +6,19 @@ def create payload = subscription_params.to_h errors = validation_errors_for(payload) - Rails.logger.warn( - "[subscriptions#create] payload=#{payload.inspect} result=#{errors.empty? ? 'success' : 'failure'}" - ) - if errors.empty? submit_result = subscriptions_submitter.call(form_payload: payload) if submit_result.success? + Rails.logger.info('[subscriptions#create] outcome=success') render json: { ok: true, message: 'Subscription accepted', subscription: payload }, status: :ok else + Rails.logger.warn( + "[subscriptions#create] outcome=failure error_code=#{submit_result.error_code}" + ) render json: { ok: false, error_code: submit_result.error_code, @@ -26,6 +26,7 @@ def create }, status: submit_result.status end else + Rails.logger.warn('[subscriptions#create] outcome=failure error_code=subscription_validation_failed') render json: { ok: false, error_code: 'subscription_validation_failed', diff --git a/app/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb index 5d3f48d0b..6631f05e9 100644 --- a/app/services/subscriptions/pardot_form_handler_submitter.rb +++ b/app/services/subscriptions/pardot_form_handler_submitter.rb @@ -24,8 +24,8 @@ def call(form_payload:) "classification=#{classification_for(response)}" ) classify_response(response) - rescue Faraday::Error - # Sentry.capture_exception(e) + rescue Faraday::Error => e + Sentry.capture_exception(e) Result.new( success?: false, status: :service_unavailable, From ae0e40611cd7787df90ed50a82d24109e5633b6e Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 29 Apr 2026 18:19:05 +0200 Subject: [PATCH 5/7] feat: conform the payload we send to form handler --- .../subscriptions/pardot_form_handler_submitter.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb index 6631f05e9..702cfb5a5 100644 --- a/app/services/subscriptions/pardot_form_handler_submitter.rb +++ b/app/services/subscriptions/pardot_form_handler_submitter.rb @@ -17,7 +17,7 @@ def initialize(endpoint_url:) def call(form_payload:) return missing_configuration_result if endpoint_url.blank? - response = faraday.post(endpoint_url, form_payload) + response = faraday.post(endpoint_url, provider_payload(form_payload)) Rails.logger.info( "[subscriptions#provider] status=#{response.status} " \ "location=#{redirect_location(response)} " \ @@ -55,6 +55,14 @@ def missing_configuration_result ) end + def provider_payload(form_payload) + # Map internal API contract to Pardot Form Handler external field names. + { + 'email' => form_payload['email'], + 'Tester' => form_payload['test_opt_in'] + }.compact + end + def classify_response(response) return reject_result unless SUCCESS_STATUS_CODES.include?(response.status) return Result.new(success?: true) if response.status == 200 From cae0dad3159473a0f4068e4d202eab4181da43e3 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 29 Apr 2026 18:30:46 +0200 Subject: [PATCH 6/7] feat: adapt to how form handler reports success and error --- .../pardot_form_handler_submitter.rb | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/app/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb index 702cfb5a5..39ae502f8 100644 --- a/app/services/subscriptions/pardot_form_handler_submitter.rb +++ b/app/services/subscriptions/pardot_form_handler_submitter.rb @@ -6,9 +6,9 @@ class PardotFormHandlerSubmitter REQUEST_TIMEOUT_SECONDS = 10 OPEN_TIMEOUT_SECONDS = 5 - SUCCESS_STATUS_CODES = [200, 302].freeze - SUCCESS_LOCATION_PATTERNS = ['/success'].freeze - ERROR_LOCATION_PATTERNS = ['/error'].freeze + SUCCESS_STATUS_CODE = 200 + ERROR_BODY_PATTERNS = ['error page'].freeze + SUCCESS_BODY_PATTERNS = ['success page'].freeze def initialize(endpoint_url:) @endpoint_url = endpoint_url @@ -64,10 +64,11 @@ def provider_payload(form_payload) end def classify_response(response) - return reject_result unless SUCCESS_STATUS_CODES.include?(response.status) - return Result.new(success?: true) if response.status == 200 - return reject_result if redirect_to_error_location?(response) - return Result.new(success?: true) if redirect_to_success_location?(response) + body = response_body(response) + + return reject_result if error_body?(body) + return reject_result unless response.status == SUCCESS_STATUS_CODE + return Result.new(success?: true) if success_body?(body) ambiguous_result end @@ -90,14 +91,16 @@ def ambiguous_result ) end - def redirect_to_success_location?(response) - location = redirect_location(response) - SUCCESS_LOCATION_PATTERNS.any? { |pattern| location.include?(pattern) } + def error_body?(body) + ERROR_BODY_PATTERNS.any? { |pattern| body.include?(pattern) } + end + + def success_body?(body) + SUCCESS_BODY_PATTERNS.any? { |pattern| body.include?(pattern) } end - def redirect_to_error_location?(response) - location = redirect_location(response) - ERROR_LOCATION_PATTERNS.any? { |pattern| location.include?(pattern) } + def response_body(response) + response.body.to_s.downcase end def redirect_location(response) @@ -105,12 +108,13 @@ def redirect_location(response) end def classification_for(response) - return 'rejected_status' unless SUCCESS_STATUS_CODES.include?(response.status) - return 'accepted_200' if response.status == 200 - return 'rejected_error_redirect' if redirect_to_error_location?(response) - return 'accepted_success_redirect' if redirect_to_success_location?(response) + body = response_body(response) + + return 'rejected_error_body' if error_body?(body) + return 'rejected_status' unless response.status == SUCCESS_STATUS_CODE + return 'accepted_success_body' if success_body?(body) - 'ambiguous_redirect' + 'ambiguous_response' end end end From d411b62ae35772d2a6d18b6a64b03b0dea40fa39 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 29 Apr 2026 18:48:25 +0200 Subject: [PATCH 7/7] test: update the tests --- spec/requests/api/subscriptions_spec.rb | 21 ++++ .../pardot_form_handler_submitter_spec.rb | 109 ++++++++++++------ 2 files changed, 97 insertions(+), 33 deletions(-) diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb index c2b10d125..6b8c05c2c 100644 --- a/spec/requests/api/subscriptions_spec.rb +++ b/spec/requests/api/subscriptions_spec.rb @@ -26,6 +26,14 @@ message: 'Subscription provider is currently unavailable.' ) end + let(:submitter_result_rejected) do + Subscriptions::PardotFormHandlerSubmitter::Result.new( + success?: false, + status: :bad_gateway, + error_code: 'subscription_provider_rejected', + message: 'Subscription provider rejected the request.' + ) + end let(:submitter) { instance_double(Subscriptions::PardotFormHandlerSubmitter) } before do @@ -89,5 +97,18 @@ 'message' => 'Subscription provider is currently unavailable.' ) end + + it 'returns provider rejection shape when provider rejects request' do + allow(submitter).to receive(:call).and_return(submitter_result_rejected) + + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:bad_gateway) + expect(response.parsed_body).to include( + 'ok' => false, + 'error_code' => 'subscription_provider_rejected', + 'message' => 'Subscription provider rejected the request.' + ) + end end end diff --git a/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb index 324d5aabc..72efaa829 100644 --- a/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb +++ b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb @@ -3,56 +3,99 @@ require 'rails_helper' RSpec.describe Subscriptions::PardotFormHandlerSubmitter do - let(:endpoint_url) { 'https://example.com/form-handler' } - let(:service) { described_class.new(endpoint_url: endpoint_url) } - let(:payload) { { 'email' => 'teacher@example.com', 'privacy_policy' => true } } + let(:endpoint_url) { 'https://example.test/form-handler' } + let(:submitter) { described_class.new(endpoint_url:) } + let(:connection) { instance_double(Faraday::Connection) } + + let(:payload) do + { + 'email' => 'teacher@example.com', + 'test_opt_in' => true, + 'privacy_policy' => true + } + end + + let(:headers) { {} } + let(:response_body) { '' } + let(:response_status) { 200 } + let(:response) { instance_double(Faraday::Response, status: response_status, body: response_body, headers:) } + + before do + allow(submitter).to receive(:faraday).and_return(connection) + allow(connection).to receive(:post).and_return(response) + allow(Sentry).to receive(:capture_exception) + end describe '#call' do - let(:connection) { instance_double(Faraday::Connection) } - let(:response) { instance_double(Faraday::Response, status: status, headers: headers, success?: success) } - let(:headers) { {} } - let(:status) { 200 } - let(:success) { true } - - before do - allow(Faraday).to receive(:new).and_return(connection) - allow(connection).to receive(:post).and_return(response) - end + it 'returns success when status 200 and body contains success marker' do + allow(response).to receive(:body).and_return('Cannot find success page to redirect to.') - it 'returns success for a 200 response' do - result = service.call(form_payload: payload) + result = submitter.call(form_payload: payload) expect(result.success?).to be(true) end - it 'returns success for a 302 success redirect location' do - allow(response).to receive_messages(status: 302, success?: false, headers: { 'location' => '/subscriptions/success' }) + it 'returns rejected when body contains error marker even with status 200' do + allow(response).to receive(:body).and_return('Cannot find error page to redirect to.') - result = service.call(form_payload: payload) + result = submitter.call(form_payload: payload) - expect(result.success?).to be(true) + expect(result.success?).to be(false) + expect(result.status).to eq(:bad_gateway) + expect(result.error_code).to eq('subscription_provider_rejected') + end + + it 'returns rejected when status is not 200' do + allow(response).to receive(:status).and_return(302) + + result = submitter.call(form_payload: payload) + + expect(result.success?).to be(false) + expect(result.status).to eq(:bad_gateway) + expect(result.error_code).to eq('subscription_provider_rejected') end - it 'returns rejected for a 302 error redirect location' do - allow(response).to receive_messages(status: 302, success?: false, headers: { 'location' => '/subscriptions/error' }) + it 'returns ambiguous when status is 200 and body has no markers' do + allow(response).to receive(:body).and_return('ok') - result = service.call(form_payload: payload) + result = submitter.call(form_payload: payload) - aggregate_failures do - expect(result.success?).to be(false) - expect(result.error_code).to eq('subscription_provider_rejected') - end + expect(result.success?).to be(false) + expect(result.status).to eq(:bad_gateway) + expect(result.error_code).to eq('subscription_provider_ambiguous') end - it 'returns ambiguous for an unknown 302 redirect location' do - allow(response).to receive_messages(status: 302, success?: false, headers: { 'location' => '/subscriptions/unknown' }) + it 'returns unavailable on Faraday::Error' do + allow(connection).to receive(:post).and_raise(Faraday::Error, 'connection failed') + + result = submitter.call(form_payload: payload) + + expect(result.success?).to be(false) + expect(result.status).to eq(:service_unavailable) + expect(result.error_code).to eq('subscription_provider_unavailable') + expect(Sentry).to have_received(:capture_exception) + end + + it 'returns not configured when endpoint_url is blank' do + blank_submitter = described_class.new(endpoint_url: '') + + result = blank_submitter.call(form_payload: payload) + + expect(result.success?).to be(false) + expect(result.status).to eq(:service_unavailable) + expect(result.error_code).to eq('subscription_provider_not_configured') + end - result = service.call(form_payload: payload) + it 'posts payload mapped to email and Tester only' do + submitter.call(form_payload: payload) - aggregate_failures do - expect(result.success?).to be(false) - expect(result.error_code).to eq('subscription_provider_ambiguous') - end + expect(connection).to have_received(:post).with( + endpoint_url, + { + 'email' => 'teacher@example.com', + 'Tester' => true + } + ) end end end