Wednesday, July 15, 2026 2:56:24 AM
> send_xm8_notification_spec.rb
# frozen_string_literal: true

describe ESM::Event::SendXm8Notification, :requires_connection, v2: true do
  include_context "connection"

  # Multiple channels so the "separate channels" test can route to distinct ones.
  let(:discord_server) { build(:discord_server, channels: %i[logging general]) }
  let!(:second_user) { create(:user) }
  let!(:territory_owner) { user.steam_uid }
  let!(:territory_moderators) { [second_user.steam_uid] }

  let(:recipient_uids) { [user.steam_uid, second_user.steam_uid] }

  let(:notification_type) { "base-raid" }
  let(:xm8_sqf_function) { "ExileServer_system_xm8_sendBaseRaid" }
  let(:notification_state_details) { ESM::Xm8Notification::DETAILS_DM }
  let(:notification_content) do
    {
      territory_id: territory.encoded_id,
      territory_name: territory.name
    }.stringify_keys
  end

  let(:notification_sqf) do
    <<~SQF
      private _territory = #{territory.id} call ESMs_system_territory_get;
      _territory call #{xm8_sqf_function}
    SQF
  end

  subject(:trigger_notification) { execute_sqf!(notification_sqf) }

  before do
    second_user.exile_account
    territory.create_flag
  end

  shared_examples "sends" do
    it "is expected to send the notification and update the server's database" do
      trigger_notification

      # Check outbound messages
      ESM.discord_bot.test_outbox.await_size(recipient_uids.size)

      # Check database update
      notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_SENT)
      wait_for { notifications.size }.to eq(recipient_uids.size)

      notifications.each do |notification|
        expect(notification.recipient_uid).to be_in(recipient_uids)

        # These don't have a territory ID
        if !["custom", "marxet-item-sold"].include?(notification_type)
          expect(notification.territory_id).to eq(territory.id)
        end

        expect(notification.type).to eq(notification_type)
        expect(notification.content).to match(notification_content)
        expect(notification.state_details).to eq(notification_state_details)
        expect(notification.acknowledged_at).not_to be(nil)
      end
    end
  end

  context "when the notification type is base-raid" do
    let(:notification_type) { "base-raid" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendBaseRaid" }

    include_examples "sends"
  end

  context "when the notification type is charge-plant-started" do
    let(:notification_type) { "charge-plant-started" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendChargePlantStarted" }

    include_examples "sends"
  end

  context "when the notification type is flag-restored" do
    let(:notification_type) { "flag-restored" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendFlagRestored" }

    include_examples "sends"
  end

  context "when the notification type is flag-steal-started" do
    let(:notification_type) { "flag-steal-started" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendFlagStealStarted" }

    include_examples "sends"
  end

  context "when the notification type is flag-stolen" do
    let(:notification_type) { "flag-stolen" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendFlagStolen" }

    include_examples "sends"
  end

  context "when the notification type is grind-started" do
    let(:notification_type) { "grind-started" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendGrindingStarted" }

    include_examples "sends"
  end

  context "when the notification type is hack-started" do
    let(:notification_type) { "hack-started" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendHackingStarted" }

    include_examples "sends"
  end

  context "when the notification type is protection-money-due" do
    let(:notification_type) { "protection-money-due" }
    let(:notification_sqf) { "call ExileServer_system_xm8_sendProtectionMoneyDue" }

    before do
      territory.update!(last_paid_at: 20.days.ago, xm8_protectionmoney_notified: false)
    end

    include_examples "sends"
  end

  context "when the notification type is protection-money-paid" do
    let(:notification_type) { "protection-money-paid" }
    let(:xm8_sqf_function) { "ExileServer_system_xm8_sendProtectionMoneyPaid" }

    include_examples "sends"
  end

  context "when the notification type is custom" do
    let(:notification_type) { "custom" }
    let(:notification_content) do
      hash = embed_data.to_h
      hash["fields"] = be_a(String)
      hash
    end

    let(:embed_data) do
      ESM::Arma::HashMap.new(
        title: "This is a title",
        description: "This is a description",
        fields: [
          {name: "field title", value: "field value", inline: true}
        ]
      )
    end

    let(:notification_sqf) do
      <<~SQF
        [#{recipient_uids.to_json}, #{embed_data.to_json}] call ExileServer_system_xm8_sendCustom
      SQF
    end

    include_examples "sends"

    context "when the embed data is valid" do
      it "includes title, description, fields, etc." do
        trigger_notification

        # For timing to ensure everything completes
        notifications = ESM::ExileXm8Notification.where(state: "sent")
        wait_for { notifications.size }.to eq(recipient_uids.size)

        embed = ESM.discord_bot.test_outbox.first.content
        expect(embed.title).to eq(embed_data[:title])
        expect(embed.description).to eq(embed_data[:description])
        embed.fields.zip(embed_data[:fields]).each do |embed_field, input_field|
          expect(embed_field.name).to eq(input_field[:name])
          expect(embed_field.value).to eq(input_field[:value])
          expect(embed_field.inline).to eq(input_field[:inline])
        end
      end
    end

    context "when the embed data is not valid" do
      let(:embed_data) { [] }

      it "logs a message to the server and discord" do
        trigger_notification

        ESM.discord_bot.test_outbox.await_size(1)
        message = ESM.discord_bot.test_outbox.first.content
        expect(message).to match("has encountered an error")
      end
    end
  end

  context "when the notification type is marxet-item-sold" do
    let(:recipient_uids) { [user.steam_uid] }
    let(:notification_type) { "marxet-item-sold" }
    let(:notification_content) { {item_name:, poptabs_received:}.stringify_keys }

    let(:item_name) { ESM::Arma::ClassLookup.all.values.sample.display_name }
    let(:poptabs_received) { Faker::Number.positive.to_i.to_s }

    let(:notification_sqf) do
      <<~SQF
        [#{recipient_uids.first.to_json}, #{item_name.to_json}, #{poptabs_received.to_json}] call ExileServer_system_xm8_sendItemSold
      SQF
    end

    include_examples "sends"
  end

  context "when the notification type is invalid" do
    let(:notification_sqf) do
      <<~SQF
        ["some-invalid-type", #{recipient_uids.to_json}, []] call ExileServer_system_xm8_send;
      SQF
    end

    it "logs a message to the server and discord" do
      trigger_notification

      ESM.discord_bot.test_outbox.await_size(1)
      message = ESM.discord_bot.test_outbox.first.content
      expect(message).to match("has encountered an error")
    end
  end

  context "when the notification has unregistered users" do
    before do
      recipient_uids # Cache before they're removed
      user.update!(steam_uid: nil)
      second_user.update!(steam_uid: nil)
    end

    it "updates the database with a failure for no registration" do
      trigger_notification

      notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_FAILED)
      wait_for { notifications.size }.to eq(recipient_uids.size)

      # Nothing is sent
      expect(ESM.discord_bot.test_outbox.size).to eq(0)

      notifications.each do |notification|
        expect(notification.state_details).to eq(ESM::Xm8Notification::DETAILS_NOT_REGISTERED)
        expect(notification.acknowledged_at).not_to be(nil)
      end
    end
  end

  context "when the notification fails to send to direct message" do
    before do
      # Failures are when the message fails to send
      allow(ESM.discord_bot).to receive(:deliver).and_return(nil)
    end

    it "updates the database with a failure to send" do
      trigger_notification

      notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_FAILED)
      wait_for { notifications.size }.to eq(recipient_uids.size)

      # Nothing is sent
      expect(ESM.discord_bot.test_outbox.size).to eq(0)

      notifications.each do |notification|
        expect(notification.state_details).to eq(ESM::Xm8Notification::DETAILS_DM)
        expect(notification.acknowledged_at).not_to be(nil)
      end
    end
  end

  context "when the recipients have direct messages disallowed and they have no custom routes" do
    let!(:territory_moderators) { [] }
    let!(:recipient_uids) { [user.steam_uid] }

    before do
      create(:user_notification_preference, user:, server:, base_raid: false)
    end

    it "updates the database with a failure because of no destinations" do
      trigger_notification

      notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_FAILED)
      wait_for { notifications.size }.to eq(recipient_uids.size)

      expect(ESM.discord_bot.test_outbox.size).to eq(0)

      notifications.each do |notification|
        expect(notification.state_details).to eq(ESM::Xm8Notification::DETAILS_NO_DESTINATION)
      end
    end
  end

  context "when the recipients have custom routes" do
    let(:channel_id) { community.discord_server.channels.first.id }
    let(:destination_community) { community }

    before do
      # Disable DM notifications to allow for easier testing
      create(:user_notification_preference, user:, server:, base_raid: false)
      create(:user_notification_preference, user: second_user, server:, base_raid: false)
    end

    context "when the custom route is disabled" do
      let!(:territory_moderators) { [] }
      let!(:recipient_uids) { [user.steam_uid] }

      let!(:routes) do
        [
          create(
            :user_notification_route,
            user:,
            destination_community:,
            channel_id:,
            enabled: false
          )
        ]
      end

      it "does not send" do
        trigger_notification

        notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_FAILED)
        wait_for { notifications.size }.to eq(recipient_uids.size)

        expect(ESM.discord_bot.test_outbox.size).to eq(0)
      end
    end

    context "when the custom route is not accepted yet" do
      let!(:routes) do
        [
          create(
            :user_notification_route,
            user:,
            destination_community:,
            channel_id:,
            user_accepted: false
          ),

          create(
            :user_notification_route,
            user: second_user,
            destination_community:,
            channel_id:,
            community_accepted: false
          )
        ]
      end

      it "does not send" do
        trigger_notification

        notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_FAILED)
        wait_for { notifications.size }.to eq(recipient_uids.size)

        expect(ESM.discord_bot.test_outbox.size).to eq(0)
      end
    end

    context "when the custom route sends to any server and they are separate channels" do
      let!(:routes) do
        [
          create(
            :user_notification_route,
            user:,
            destination_community:,
            channel_id:
          ),

          create(
            :user_notification_route,
            user: second_user,
            destination_community:,
            channel_id: destination_community.discord_server.channels.last.id
          )
        ]
      end

      it "sends to the channel" do
        trigger_notification

        notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_SENT)
        wait_for { notifications.size }.to eq(recipient_uids.size)

        expect(ESM.discord_bot.test_outbox.size).to eq(2)

        channel_ids = ESM.discord_bot.test_outbox.destinations.map { |c| c.id.to_s }
        expect(channel_ids).to match(routes.map(&:channel_id))
      end
    end

    context "when the custom route sends to a specific server" do
      let!(:territory_moderators) { [] }
      let!(:recipient_uids) { [user.steam_uid] }

      let!(:routes) do
        [
          create(
            :user_notification_route,
            user:,
            destination_community:,
            channel_id:,
            source_server_id: server.id
          ),

          create(
            :user_notification_route,
            user: second_user,
            destination_community:,
            channel_id:,
            source_server_id: create(:server, community_id: community.id).id
          )
        ]
      end

      it "sends to channels that match the one source server" do
        trigger_notification

        notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_SENT)
        wait_for { notifications.size }.to eq(recipient_uids.size)

        expect(ESM.discord_bot.test_outbox.size).to eq(1)

        channel = ESM.discord_bot.test_outbox.first.destination
        expect(channel.id.to_s).to eq(routes.first.channel_id)
      end
    end

    context "when the custom route sends to any server and they are the same channels" do
      let!(:routes) do
        [
          create(
            :user_notification_route,
            user:,
            destination_community:,
            channel_id:
          ),

          create(
            :user_notification_route,
            user: second_user,
            destination_community:,
            channel_id:
          )
        ]
      end

      it "sends to channels that match the one source server" do
        trigger_notification

        notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_SENT)
        wait_for { notifications.size }.to eq(recipient_uids.size)

        expect(ESM.discord_bot.test_outbox.size).to eq(1)

        channel = ESM.discord_bot.test_outbox.first.destination
        routes.each do |route|
          expect(route.channel_id).to eq(channel.id.to_s)
        end
      end
    end
  end

  context "when a recipient is reached by direct message but the custom route fails" do
    let(:channel_id) { community.discord_server.channels.first.id }
    let!(:territory_moderators) { [] }
    let!(:recipient_uids) { [user.steam_uid] }

    let!(:routes) do
      [
        create(:user_notification_route, user:, destination_community: community, channel_id:)
      ]
    end

    before do
      # DM delivers; the custom-route channel rejects, mirroring a stale route.
      allow(ESM.discord_bot).to receive(:deliver).and_wrap_original do |original, embed, **kwargs|
        next nil if kwargs[:to].to_s == channel_id.to_s

        original.call(embed, **kwargs)
      end
    end

    it "marks the notification sent and records the partial failure in the details" do
      trigger_notification

      notifications = ESM::ExileXm8Notification.where(state: ESM::Xm8Notification::STATE_SENT)
      wait_for { notifications.size }.to eq(recipient_uids.size)

      notification = notifications.first
      expect(notification.state_details).to eq(
        "sent: #{ESM::Xm8Notification::DETAILS_DM}; failed: #{ESM::Xm8Notification::DETAILS_CUSTOM}"
      )
      expect(notification.acknowledged_at).not_to be(nil)
    end
  end
end

# Connection-free coverage for the recipient-filtering path. The integration
# examples above each carry a single notification, so they never exercise a
# batched message. Production sends multiple notifications at once (e.g. one
# per overdue territory), which means the recipient UIDs must be flattened
# before the User lookup runs.
describe ESM::Event::SendXm8Notification, v2: true do
  let(:community) { create(:community) }
  let(:server) { create(:server, community_id: community.id) }
  let(:message) { ESM::Message.new }

  subject(:event) { described_class.new(server, message) }

  describe "#filter_unregistered_recipients" do
    let!(:first_user) { create(:user) }
    let!(:second_user) { create(:user) }
    let!(:third_user) { create(:user) }

    # A single user can own territories across several notifications, so a UID
    # legitimately repeats across the batch.
    let(:notifications) do
      [
        {
          type: "protection-money-due",
          content: {territory_id: "aaaaa", territory_name: "Alpha"},
          created_at: "2026-06-14T05:03:43",
          recipient_uids: [first_user.steam_uid, second_user.steam_uid],
          uuids: [SecureRandom.uuid, SecureRandom.uuid]
        },
        {
          type: "protection-money-due",
          content: {territory_id: "bbbbb", territory_name: "Bravo"},
          created_at: "2026-06-14T05:03:43",
          recipient_uids: [second_user.steam_uid, third_user.steam_uid],
          uuids: [SecureRandom.uuid, SecureRandom.uuid]
        }
      ]
    end

    subject(:filtered) { event.send(:filter_unregistered_recipients, notifications) }

    it "is expected to map every registered recipient across a batched message" do
      expect { filtered }.not_to raise_error

      expect(filtered.size).to eq(2)

      filtered.each do |notification|
        expect(notification).not_to have_key(:recipient_uids)
        expect(notification).not_to have_key(:uuids)
        expect(notification[:server]).to eq(server)
      end

      expect(filtered.first[:recipient_notification_mapping].keys).to contain_exactly(first_user, second_user)
      expect(filtered.second[:recipient_notification_mapping].keys).to contain_exactly(second_user, third_user)
    end
  end
end
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 43aa0d7