diff --git a/app/jobs/schedule_bounce_notifications_job.rb b/app/jobs/schedule_bounce_notifications_job.rb new file mode 100644 index 000000000..7cc917d2a --- /dev/null +++ b/app/jobs/schedule_bounce_notifications_job.rb @@ -0,0 +1,11 @@ +class ScheduleBounceNotificationsJob < ApplicationJob + queue_as :bounce_notifications + + def perform + CloudWatchService.record_job_started_metric(self.class.name) + CurrentJobLoggingAttributes.job_class = self.class.name + CurrentJobLoggingAttributes.job_id = job_id + + SendBounceNotificationsJob.perform_later(bounced_on_date: Time.zone.yesterday.to_date) + end +end diff --git a/app/jobs/send_bounce_notifications_job.rb b/app/jobs/send_bounce_notifications_job.rb new file mode 100644 index 000000000..0f314aa72 --- /dev/null +++ b/app/jobs/send_bounce_notifications_job.rb @@ -0,0 +1,21 @@ +class SendBounceNotificationsJob < ApplicationJob + queue_as :bounce_notifications + + def perform(bounced_on_date:) + CloudWatchService.record_job_started_metric(self.class.name) + CurrentJobLoggingAttributes.job_class = self.class.name + CurrentJobLoggingAttributes.job_id = job_id + + bounced_deliveries = Delivery.bounced_on_day(bounced_on_date) + bounced_deliveries.group_by(&:form_id).each do |form_id, deliveries| + form = deliveries.first.form + group = Api::V2::GroupResource.find(form_id) + + group.group_admin_users.each do |user| + BounceNotificationMailer.bounce_notification_to_group_admins_email(form:, user:, deliveries:).deliver_now + end + + Rails.logger.info "Sent bounce notifications to group admins for bounced deliveries on #{bounced_on_date.strftime('%-d %B %Y')} for form #{form_id}" + end + end +end diff --git a/app/lib/notify_utils.rb b/app/lib/notify_utils.rb new file mode 100644 index 000000000..d0e751994 --- /dev/null +++ b/app/lib/notify_utils.rb @@ -0,0 +1,5 @@ +module NotifyUtils + def make_notify_boolean(bool) + bool ? "yes" : "no" + end +end diff --git a/app/mailers/bounce_notification_mailer.rb b/app/mailers/bounce_notification_mailer.rb new file mode 100644 index 000000000..5a779b8a2 --- /dev/null +++ b/app/mailers/bounce_notification_mailer.rb @@ -0,0 +1,67 @@ +class BounceNotificationMailer < GovukNotifyRails::Mailer + include NotifyUtils + + def bounce_notification_to_group_admins_email(form:, user:, deliveries:) + set_template(Settings.govuk_notify.bounce_notification_to_group_admins_template_id) + + # We're assuming that all bounces are for the same reason + hard_bounce = hard_bounce?(deliveries.first) + + bounced_submissions = bounced_submissions(deliveries) + bounced_batches = bounced_batches(deliveries) + + set_personalisation( + user_name: user.name, + submission_email: form.submission_email, + form_name: form.name, + has_bounced_submissions: make_notify_boolean(bounced_submissions.any?), + bounced_submissions_list: bounced_submissions, + has_bounced_batches: make_notify_boolean(bounced_batches.any?), + bounced_batches_list: bounced_batches, + hard_bounce: make_notify_boolean(hard_bounce), + soft_bounce: make_notify_boolean(!hard_bounce), + deadline_date: deadline_date(deliveries), + ) + + mail(to: user.email) + end + +private + + def hard_bounce?(delivery) + delivery.failure_details&.[]("bounceType") == "Permanent" + end + + def bounced_submissions(deliveries) + deliveries.select(&:immediate?).sort_by(&:last_attempt_at).map do |delivery| + sent_at = delivery.last_attempt_at.in_time_zone(TimeZoneUtils.submission_time_zone) + + I18n.t("mailer.bounce_notification.bounced_submission", + submission_reference: delivery.submissions.sole.reference, + sent_at_time: sent_at.strftime("%l:%M%P").strip, + sent_at_date: sent_at.strftime("%-d %B %Y")) + end + end + + def bounced_batches(deliveries) + deliveries.select { |d| %w[daily weekly].include?(d.delivery_schedule) }.sort_by(&:last_attempt_at).map do |delivery| + sent_at = delivery.last_attempt_at.in_time_zone(TimeZoneUtils.submission_time_zone) + sent_at_time = sent_at.strftime("%l:%M%P").strip + sent_at_date = sent_at.strftime("%-d %B %Y") + + if delivery.daily? + I18n.t("mailer.bounce_notification.bounced_daily_batch", sent_at_time:, sent_at_date:) + else + I18n.t("mailer.bounce_notification.bounced_weekly_batch", sent_at_time:, sent_at_date:) + end + end + end + + def deadline_date(deliveries) + delivery_ids = deliveries.map(&:id) + earliest_submission_created_at = Submission.joins(:submission_deliveries).where(submission_deliveries: { delivery_id: delivery_ids }).minimum(:created_at) + earliest_submission_date_time = earliest_submission_created_at.in_time_zone(TimeZoneUtils.submission_time_zone) + deadline_date_time = earliest_submission_date_time + Settings.submissions.maximum_retention_seconds.seconds + deadline_date_time.strftime("%-d %B %Y") + end +end diff --git a/app/mailers/form_submission_confirmation_mailer.rb b/app/mailers/form_submission_confirmation_mailer.rb index b330427cf..2837d4b6e 100644 --- a/app/mailers/form_submission_confirmation_mailer.rb +++ b/app/mailers/form_submission_confirmation_mailer.rb @@ -1,4 +1,6 @@ class FormSubmissionConfirmationMailer < GovukNotifyRails::Mailer + include NotifyUtils + def send_confirmation_email(what_happens_next_markdown:, support_contact_details:, notify_response_id:, confirmation_email_address:, mailer_options:, submission_locale: :en, what_happens_next_markdown_cy: nil, support_contact_details_cy: nil) @submission_locale = submission_locale.to_sym set_template(template_id) @@ -54,10 +56,6 @@ def default_support_contact_details_text I18n.t("mailer.submission_confirmation.default_support_contact_details") end - def make_notify_boolean(bool) - bool ? "yes" : "no" - end - def template_id return Settings.govuk_notify.form_filler_confirmation_email_welsh_template_id if @submission_locale == :cy diff --git a/app/models/delivery.rb b/app/models/delivery.rb index 7e5390e6d..a76e60cf5 100644 --- a/app/models/delivery.rb +++ b/app/models/delivery.rb @@ -6,6 +6,11 @@ class Delivery < ApplicationRecord scope :delivered, -> { where.not(delivered_at: nil).where(failed_at: nil).or(where("#{table_name}.delivered_at > failed_at")) } scope :failed, -> { where.not(failed_at: nil).where(delivered_at: nil).or(where("#{table_name}.delivered_at <= failed_at")) } + scope :bounced_on_day, lambda { |date| + range = date.in_time_zone(TimeZoneUtils.submission_time_zone).all_day + failed.where(failure_reason: "bounced", failed_at: range) + } + enum :delivery_schedule, { immediate: "immediate", daily: "daily", @@ -35,4 +40,12 @@ def new_attempt! failure_details: nil, ) end + + def form_id + submissions.first&.form_id + end + + def form + submissions.first&.form + end end diff --git a/app/resources/api/v2/group_resource.rb b/app/resources/api/v2/group_resource.rb new file mode 100644 index 000000000..9855054ef --- /dev/null +++ b/app/resources/api/v2/group_resource.rb @@ -0,0 +1,10 @@ +class Api::V2::GroupResource < ActiveResource::Base + self.element_name = "group" + self.site = Settings.forms_api.base_url + self.prefix = "/api/v2/forms/:form_id/" + self.include_format_in_path = false + + def self.find(form_id) + super(:one, from: "/api/v2/forms/#{form_id}/group") + end +end diff --git a/config/locales/cy.yml b/config/locales/cy.yml index a24534233..53caa6e0d 100644 --- a/config/locales/cy.yml +++ b/config/locales/cy.yml @@ -468,6 +468,10 @@ cy: layout: skip_link: Neidio i’r prif gynnwys mailer: + bounce_notification: + bounced_daily_batch: Daily batch sent at %{sent_at_time} on %{sent_at_date} + bounced_submission: "%{submission_reference} at %{sent_at_time} on %{sent_at_date}" + bounced_weekly_batch: Weekly batch sent at %{sent_at_time} on %{sent_at_date} cannot_reply: contact_form_filler_html: "

If you need to contact the person who completed this form, you’ll need to contact them directly.

" contact_form_filler_plain: If you need to contact the person who completed this form, you’ll need to contact them directly. diff --git a/config/locales/en.yml b/config/locales/en.yml index 672042f31..4b961b0eb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -468,6 +468,10 @@ en: layout: skip_link: Skip to main content mailer: + bounce_notification: + bounced_daily_batch: Daily batch sent at %{sent_at_time} on %{sent_at_date} + bounced_submission: "%{submission_reference} at %{sent_at_time} on %{sent_at_date}" + bounced_weekly_batch: Weekly batch sent at %{sent_at_time} on %{sent_at_date} cannot_reply: contact_form_filler_html: "

If you need to contact the person who completed this form, you’ll need to contact them directly.

" contact_form_filler_plain: If you need to contact the person who completed this form, you’ll need to contact them directly. diff --git a/config/recurring.yml b/config/recurring.yml index d1e7f7c78..595719fec 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -36,3 +36,6 @@ development: schedule_weekly_batch_deliveries_job: class: ScheduleWeeklyBatchDeliveriesJob schedule: every Monday at 2:15am Europe/London + schedule_bounce_notifications_job: + class: ScheduleBounceNotificationsJob + schedule: every day at 2:30am Europe/London diff --git a/config/settings.yml b/config/settings.yml index 69759ed80..e39ebebda 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -27,6 +27,7 @@ govuk_notify: form_submission_email_template_id: 427eb8bc-ce0d-40a3-bf54-d76e8c3ec916 form_filler_confirmation_email_template_id: 2d1f36dc-9799-43dd-8673-b631f9e0b4a5 form_filler_confirmation_email_welsh_template_id: b297c8f1-419e-4af4-b067-b8cad6364daf + bounce_notification_to_group_admins_template_id: 9a93a110-c97d-4b51-87f3-a7ec5b9daddc # Configuration for Sentry # Sentry will only initialise if dsn is set to some other value diff --git a/spec/factories/admin_users.rb b/spec/factories/admin_users.rb new file mode 100644 index 000000000..e0f6bc29d --- /dev/null +++ b/spec/factories/admin_users.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :admin_user, class: DataStruct do + name { Faker::Name.name } + email { Faker::Internet.unique.email } + end +end diff --git a/spec/factories/deliveries.rb b/spec/factories/deliveries.rb index f23b5908e..0bd923d7e 100644 --- a/spec/factories/deliveries.rb +++ b/spec/factories/deliveries.rb @@ -3,6 +3,7 @@ delivery_reference { Faker::Alphanumeric.alphanumeric } created_at { Time.current } delivery_schedule { :immediate } + last_attempt_at { Time.current + 10.seconds } trait :not_sent do delivery_reference { nil } @@ -24,24 +25,66 @@ failure_reason { "example" } end + trait :bounced do + transient do + bounce_type { "Permanent" } + end + + failed + failure_reason { "bounced" } + failure_details { { "bounceType" => bounce_type } } + end + trait :delivered_after_failure do failed_at { created_at + 5.minutes } delivered_at { created_at + 10.minutes } failure_reason { "example" } end + trait :delivered_after_bounce do + delivered_after_failure + failure_reason { "bounced" } + end + trait :failed_after_delivery do delivered_at { created_at + 5.minutes } failed_at { created_at + 10.minutes } failure_reason { "example" } end + trait :bounced_after_delivery do + failed_after_delivery + failure_reason { "bounced" } + end + + trait :with_submissions do + transient do + submissions_count { 2 } + form_id { 101 } + end + + submissions do + Array.new(submissions_count) { association(:submission, form_id:) } + end + end + + trait :immediate do + submissions_count { 1 } + with_submissions + + delivery_schedule { "immediate" } + end + trait :daily_scheduled_delivery do + with_submissions + delivery_schedule { "daily" } batch_begin_at { created_at.beginning_of_day } end trait :weekly_scheduled_delivery do + with_submissions + delivery_schedule { "weekly" } batch_begin_at { (created_at - 7.days).beginning_of_day } end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb new file mode 100644 index 000000000..dbdcbdcdc --- /dev/null +++ b/spec/factories/groups.rb @@ -0,0 +1,22 @@ +FactoryBot.define do + factory :group, class: Api::V2::GroupResource do + sequence(:name) { |n| "Group #{n}" } + + transient do + group_admin_users_count { 1 } + organisation_admin_users_count { 1 } + organisation_name { Faker::Company.name } + end + + group_admin_users do + build_list(:admin_user, group_admin_users_count) + end + + organisation do + { + name: organisation_name, + admin_users: build_list(:admin_user, organisation_admin_users_count), + } + end + end +end diff --git a/spec/jobs/schedule_bounce_notifications_job_spec.rb b/spec/jobs/schedule_bounce_notifications_job_spec.rb new file mode 100644 index 000000000..139caaac9 --- /dev/null +++ b/spec/jobs/schedule_bounce_notifications_job_spec.rb @@ -0,0 +1,9 @@ +require "rails_helper" + +RSpec.describe ScheduleBounceNotificationsJob do + it "schedules a SendBounceNotifications job with yesterday's date" do + expect(SendBounceNotificationsJob).to receive(:perform_later).with(bounced_on_date: Time.zone.yesterday.to_date) + + described_class.perform_now + end +end diff --git a/spec/jobs/send_bounce_notifications_job_spec.rb b/spec/jobs/send_bounce_notifications_job_spec.rb new file mode 100644 index 000000000..b57f67d94 --- /dev/null +++ b/spec/jobs/send_bounce_notifications_job_spec.rb @@ -0,0 +1,70 @@ +require "rails_helper" + +RSpec.describe SendBounceNotificationsJob do + include ActiveSupport::Testing::TimeHelpers + include ActiveJob::TestHelper + + let(:bounced_on_date) { Date.new(2026, 5, 6) } + + let(:form_with_bounces) { build :form } + let(:other_form_with_bounces) { build :form } + let(:form_without_bounces) { build :form } + let(:group) { build :group, group_admin_users_count: 1 } + let(:other_group) { build :group, group_admin_users_count: 1 } + + let(:req_headers) { { "Accept" => "application/json" } } + + before do + create_list :delivery, 2, :bounced, :immediate, form_id: form_with_bounces.id, failed_at: Time.zone.local(2026, 5, 5, 23, 0, 0) + create :delivery, :delivered, :immediate, form_id: form_without_bounces.id + + ActiveResource::HttpMock.respond_to do |mock| + mock.get "/api/v2/forms/#{form_with_bounces.form_id}/group", req_headers, group.to_json, 200 + mock.get "/api/v2/forms/#{other_form_with_bounces.form_id}/group", req_headers, other_group.to_json, 200 + end + end + + context "when there are multiple forms with bounces on the date", :capture_logging do + before do + create_list :delivery, 2, :bounced, :daily_scheduled_delivery, form_id: other_form_with_bounces.id, failed_at: Time.zone.local(2026, 5, 6, 22, 59, 0) + described_class.perform_now(bounced_on_date:) + end + + it "sends an email per form with bounced submissions" do + expect(ActionMailer::Base.deliveries.size).to eq 2 + expect(ActionMailer::Base.deliveries.map(&:to).flatten).to contain_exactly( + group.group_admin_users.first.email, + other_group.group_admin_users.first.email, + ) + end + + it "logs a message for each form with bounced submissions" do + expect(log_lines).to include( + hash_including( + "level" => "INFO", + "message" => "Sent bounce notifications to group admins for bounced deliveries on 6 May 2026 for form #{form_with_bounces.form_id}", + ), + hash_including( + "level" => "INFO", + "message" => "Sent bounce notifications to group admins for bounced deliveries on 6 May 2026 for form #{other_form_with_bounces.form_id}", + ), + ) + end + end + + context "when the group has multiple group admins" do + let(:group) { build :group, group_admin_users_count: 2 } + + before do + described_class.perform_now(bounced_on_date:) + end + + it "sends an email to each group admin" do + expect(ActionMailer::Base.deliveries.size).to eq 2 + expect(ActionMailer::Base.deliveries.map(&:to).flatten).to contain_exactly( + group.group_admin_users.first.email, + group.group_admin_users.second.email, + ) + end + end +end diff --git a/spec/mailers/bounce_notification_mailer_spec.rb b/spec/mailers/bounce_notification_mailer_spec.rb new file mode 100644 index 000000000..060200ccc --- /dev/null +++ b/spec/mailers/bounce_notification_mailer_spec.rb @@ -0,0 +1,135 @@ +require "rails_helper" + +RSpec.describe BounceNotificationMailer do + describe "#bounce_notification_to_group_admins_email" do + subject(:mail) do + described_class.bounce_notification_to_group_admins_email(form:, user:, deliveries: deliveries) + end + + let(:user) { build :admin_user } + let(:form) { build :form } + + before do + Settings.govuk_notify.bounce_notification_to_group_admins_template_id = "some-template-id" + end + + describe "basic personalisation" do + let(:deliveries) { create_list :delivery, 3, :bounced, :immediate } + + it "includes personalisation for the user and form" do + expect(mail.govuk_notify_personalisation).to include( + user_name: user.name, + submission_email: form.submission_email, + form_name: form.name, + ) + end + + it "sets the to email address" do + expect(mail.to).to eq [user.email] + end + + it "sets the template" do + expect(mail.govuk_notify_template).to eq "some-template-id" + end + end + + describe "personalisation for the bounced deliveries list" do + let(:latest_immediate_delivery) do + submission = create(:submission, reference: "LATEST") + create(:delivery, :bounced, :immediate, last_attempt_at: Time.zone.local(2026, 5, 7, 11, 22, 20), submissions: [submission]) + end + let(:earliest_immediate_delivery) do + submission = create(:submission, reference: "EARLIEST") + create(:delivery, :bounced, :immediate, last_attempt_at: Time.zone.local(2026, 5, 7, 9, 20, 5), submissions: [submission]) + end + let(:daily_delivery) { create :delivery, :bounced, :daily_scheduled_delivery, last_attempt_at: Time.zone.local(2026, 5, 7, 10, 10, 0) } + let(:weekly_delivery) { create :delivery, :bounced, :weekly_scheduled_delivery, last_attempt_at: Time.zone.local(2026, 5, 7, 6, 23, 0) } + + context "when there are bounced submissions and bounced batches" do + let(:deliveries) { [latest_immediate_delivery, earliest_immediate_delivery, daily_delivery, weekly_delivery] } + + it "includes the correct boolean personalisation" do + expect(mail.govuk_notify_personalisation).to include(has_bounced_submissions: "yes", + has_bounced_batches: "yes") + end + + it "includes a list of bounced submissions ordered by sent date with times in London time" do + expect(mail.govuk_notify_personalisation[:bounced_submissions_list]) + .to eq ["EARLIEST at 10:20am on 7 May 2026", "LATEST at 12:22pm on 7 May 2026"] + end + + it "includes a list of bounced batches ordered by sent date with times in London time" do + expect(mail.govuk_notify_personalisation[:bounced_batches_list]) + .to eq ["Weekly batch sent at 7:23am on 7 May 2026", "Daily batch sent at 11:10am on 7 May 2026"] + end + end + + context "when there are no bounced submissions" do + let(:deliveries) { [daily_delivery] } + + it "sets has_bounced_submissions to no" do + expect(mail.govuk_notify_personalisation).to include(has_bounced_submissions: "no") + end + + it "sets the bounced_submissions_list to an empty array" do + expect(mail.govuk_notify_personalisation).to include(bounced_submissions_list: []) + end + end + + context "when there are no bounced batches" do + let(:deliveries) { [earliest_immediate_delivery] } + + it "sets has_bounced_batches to no" do + expect(mail.govuk_notify_personalisation).to include(has_bounced_batches: "no") + end + + it "sets the bounced_batches_list to an empty array" do + expect(mail.govuk_notify_personalisation).to include(bounced_batches_list: []) + end + end + end + + describe "personalisation for the bounce type" do + context "when the bounce is a hard bounce" do + let(:deliveries) { [create(:delivery, :bounced, :immediate, bounce_type: "Permanent")] } + + it "sets hard_bounce to yes and soft_bounce to no" do + expect(mail.govuk_notify_personalisation).to include(hard_bounce: "yes", soft_bounce: "no") + end + end + + context "when the bounce is a soft bounce" do + let(:deliveries) { [create(:delivery, :bounced, :immediate, bounce_type: "Transient")] } + + it "sets hard_bounce to no and soft_bounce to yes" do + expect(mail.govuk_notify_personalisation).to include(hard_bounce: "no", soft_bounce: "yes") + end + end + end + + describe "personalisation for the deadline date" do + let(:latest_submission) { create(:submission, created_at: Time.zone.local(2026, 5, 7, 9, 20, 5)) } + let(:earliest_submission) { create(:submission, created_at: Time.zone.local(2026, 5, 5, 23, 0, 0)) } + + context "when there only immediate submission deliveries" do + let(:deliveries) do + [create(:delivery, submissions: [latest_submission]), create(:delivery, submissions: [earliest_submission])] + end + + it "sets the deadline date to 30 days after the earliest submission created_at date in London time" do + expect(mail.govuk_notify_personalisation[:deadline_date]).to eq("5 June 2026") + end + end + + context "when there is a bounced batch delivery" do + let(:deliveries) do + [create(:delivery, :weekly, submissions: [latest_submission, earliest_submission])] + end + + it "sets the deadline date to 30 days after the earliest submission created_at date in London time" do + expect(mail.govuk_notify_personalisation[:deadline_date]).to eq("5 June 2026") + end + end + end + end +end diff --git a/spec/models/delivery_spec.rb b/spec/models/delivery_spec.rb index 0eb25728d..212c2b6c9 100644 --- a/spec/models/delivery_spec.rb +++ b/spec/models/delivery_spec.rb @@ -83,6 +83,89 @@ expect(described_class.failed).to contain_exactly(failed_delivery, failed_after_delivery) end end + + describe ".bounced_on_day" do + context "when the date is around the start of BST" do + # London local date 2025-03-29 => UTC 2025-03-29 00:00:00..2025-03-29 23:59:59 + let!(:start_of_gmt_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 3, 29, 0, 0, 0)) } + let!(:end_of_gmt_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 3, 29, 23, 59, 59)) } + + # London local date 2025-03-30 => UTC 2025-03-30 00:00:00..2025-03-30 22:59:59 + let!(:start_of_change_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 3, 30, 0, 0, 0)) } + let!(:end_of_change_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 3, 30, 22, 59, 59)) } + + # London local date 2025-03-31 => UTC 2025-03-30 23:00:00..2025-03-31 22:59:59 + let!(:start_of_bst_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 3, 30, 23, 0, 0)) } + let!(:end_of_bst_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 3, 31, 22, 59, 59)) } + let(:date) { Date.new(2022, 6, 1) } + + it "returns deliveries that bounced on the day before the clocks change" do + deliveries = described_class.bounced_on_day(Date.new(2025, 3, 29)) + expect(deliveries.size).to eq(2) + expect(deliveries).to contain_exactly(start_of_gmt_day_delivery, end_of_gmt_day_delivery) + end + + it "returns deliveries on the day of the clocks change" do + deliveries = described_class.bounced_on_day(Date.new(2025, 3, 30)) + expect(deliveries.size).to eq(2) + expect(deliveries).to contain_exactly(start_of_change_day_delivery, end_of_change_day_delivery) + end + + it "returns deliveries on the day after the clocks change" do + deliveries = described_class.bounced_on_day(Date.new(2025, 3, 31)) + expect(deliveries.size).to eq(2) + expect(deliveries).to contain_exactly(start_of_bst_day_delivery, end_of_bst_day_delivery) + end + end + + context "when the date is around the end of BST" do + # London local date 2025-10-25 => UTC 2025-10-24 23:00:00..2025-10-25 22:59:59 + let!(:start_of_bst_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 10, 24, 23, 0, 0)) } + let!(:end_of_bst_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 10, 25, 22, 59, 59)) } + + # London local date 2025-10-26 => UTC 2025-10-25 23:00:00..2025-10-26 23:59:59 + let!(:start_of_change_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 10, 25, 23, 0, 0)) } + let!(:end_of_change_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 10, 26, 23, 59, 59)) } + + # London local date 2025-10-27 => UTC 2025-10-27 00:00:00..2025-10-27 23:59:59 + let!(:start_of_gmt_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 10, 27, 0, 0, 0)) } + let!(:end_of_gmt_day_delivery) { create(:delivery, :bounced, failed_at: Time.utc(2025, 10, 27, 23, 59, 59)) } + + it "returns deliveries on the day before the clocks change" do + deliveries = described_class.bounced_on_day(Date.new(2025, 10, 25)) + expect(deliveries.size).to eq(2) + expect(deliveries).to contain_exactly(start_of_bst_day_delivery, end_of_bst_day_delivery) + end + + it "returns deliveries on the day of the clocks change" do + deliveries = described_class.bounced_on_day(Date.new(2025, 10, 26)) + expect(deliveries.size).to eq(2) + expect(deliveries).to contain_exactly(start_of_change_day_delivery, end_of_change_day_delivery) + end + + it "returns deliveries on the day after the clocks change" do + deliveries = described_class.bounced_on_day(Date.new(2025, 10, 27)) + expect(deliveries.size).to eq(2) + expect(deliveries).to contain_exactly(start_of_gmt_day_delivery, end_of_gmt_day_delivery) + end + end + + it "does not include deliveries that were delivered after they bounced" do + date = Date.new(2022, 6, 1) + create(:delivery, :delivered_after_bounce, created_at: date) + + deliveries = described_class.bounced_on_day(date) + expect(deliveries).to be_empty + end + + it "includes deliveries that bounced after they were delivered" do + date = Date.new(2022, 6, 1) + delivery = create(:delivery, :bounced_after_delivery, created_at: date) + + deliveries = described_class.bounced_on_day(date) + expect(deliveries).to include(delivery) + end + end end describe "#new_attempt!" do diff --git a/spec/resources/api/v2/group_resource_spec.rb b/spec/resources/api/v2/group_resource_spec.rb new file mode 100644 index 000000000..0e3bc3bf9 --- /dev/null +++ b/spec/resources/api/v2/group_resource_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +RSpec.describe Api::V2::GroupResource do + let(:req_headers) { { "Accept" => "application/json" } } + let(:group) { build :group } + let(:form_id) { 123 } + + describe ".find" do + before do + ActiveResource::HttpMock.respond_to do |mock| + mock.get "/api/v2/forms/#{form_id}/group", req_headers, group.to_json, 200 + end + end + + it "finds a group for a given form ID" do + group = described_class.find(form_id) + expect(group).to be_a(described_class) + end + end +end