diff --git a/Gemfile b/Gemfile index aa43920..8e7837e 100644 --- a/Gemfile +++ b/Gemfile @@ -72,6 +72,7 @@ group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" + gem "webmock" end gem "tailwindcss-rails", "~> 4.3" diff --git a/Gemfile.lock b/Gemfile.lock index 9757805..6a7bf89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,6 +98,9 @@ GEM chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.4) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) csv (3.3.5) date (3.4.1) @@ -116,6 +119,7 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + hashdiff (1.2.1) httparty (0.23.2) csv mini_mime (>= 1.0.0) @@ -388,6 +392,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -442,6 +450,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + webmock BUNDLED WITH 2.6.9 diff --git a/lib/my_smsa_pio/notifications/ntfy/publisher.rb b/lib/my_smsa_pio/notifications/ntfy/publisher.rb new file mode 100644 index 0000000..3aefccd --- /dev/null +++ b/lib/my_smsa_pio/notifications/ntfy/publisher.rb @@ -0,0 +1,49 @@ +require "httparty" + +module MySmsaPio + module Notifications + module Ntfy + class Publisher + def initialize(admin) + @admin = admin + end + + def publish(title:, message:, priority: 3, tags: [], click: nil) + return false unless @admin.ntfy_configured? + + body = { + topic: @admin.ntfy_topic, + title: title, + message: message, + priority: priority, + tags: Array(tags) + } + body[:click] = click if click.present? + + url = "#{server_url}/#{@admin.ntfy_topic}" + + response = HTTParty.post( + url, + body: body.to_json, + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@admin.ntfy_token}" + }, + timeout: 10 + ) + + response.success? + rescue StandardError => e + Rails.logger.error("ntfy publish failed for admin #{@admin.id}: #{e.message}") + false + end + + private + + def server_url + @admin.ntfy_server_url.presence || ENV["NTFY_SERVER_URL"] || "https://ntfy.sh" + end + end + end + end +end diff --git a/test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb b/test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb new file mode 100644 index 0000000..4ee130f --- /dev/null +++ b/test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb @@ -0,0 +1,105 @@ +require "test_helper" + +module MySmsaPio + module Notifications + module Ntfy + class PublisherTest < ActiveSupport::TestCase + setup do + @admin = AdminUser.create!( + name: "Notifier", email: "notif@example.com", + password: "password123", + ntfy_topic: "mysmsa-test-topic", + ntfy_token: "tk_test_token", + ntfy_enabled: true, + ntfy_server_url: "https://ntfy.example.com" + ) + end + + test "publish sends POST to the correct ntfy URL with JSON body and auth header" do + stubbed = stub_request(:post, "https://ntfy.example.com/mysmsa-test-topic") + .with( + headers: { + "Authorization" => "Bearer tk_test_token", + "Content-Type" => "application/json" + }, + body: hash_including( + "topic" => "mysmsa-test-topic", + "title" => "Gateway offline", + "message" => /gw-001 went offline/, + "priority" => 4, + "tags" => ->(tags) { tags.include?("rotating_light") } + ) + ) + .to_return(status: 200, body: "", headers: {}) + + result = Publisher.new(@admin).publish( + title: "Gateway offline", + message: "gw-001 went offline at #{Time.current}", + priority: 4, + tags: ["rotating_light"] + ) + + assert result + assert_requested stubbed + end + + test "publish returns false on HTTP failure without raising" do + stub_request(:post, "https://ntfy.example.com/mysmsa-test-topic") + .to_return(status: 500, body: "error") + + result = Publisher.new(@admin).publish( + title: "Test", + message: "body", + priority: 3, + tags: [] + ) + + assert_not result + end + + test "publish returns false if admin not ntfy_configured?" do + unconfigured = AdminUser.create!( + name: "No Ntfy", email: "none@example.com", + password: "password123" + ) + + result = Publisher.new(unconfigured).publish( + title: "X", message: "Y", priority: 3, tags: [] + ) + + assert_not result + end + + test "publish uses default server URL from ENV when ntfy_server_url is nil" do + ENV["NTFY_SERVER_URL"] = "https://default-ntfy.example.com" + admin = AdminUser.create!( + name: "Default", email: "default@example.com", + password: "password123", + ntfy_topic: "def-topic", ntfy_token: "tk_def", + ntfy_enabled: true + ) + + stub = stub_request(:post, "https://default-ntfy.example.com/def-topic") + .to_return(status: 200, body: "", headers: {}) + + Publisher.new(admin).publish(title: "T", message: "M", priority: 3, tags: []) + assert_requested stub + ensure + ENV.delete("NTFY_SERVER_URL") + end + + test "publish includes click URL when provided" do + stub = stub_request(:post, "https://ntfy.example.com/mysmsa-test-topic") + .with(body: hash_including("click" => "https://app.example.com/admin/gateways")) + .to_return(status: 200, body: "", headers: {}) + + Publisher.new(@admin).publish( + title: "T", message: "M", priority: 3, tags: [], + click: "https://app.example.com/admin/gateways" + ) + assert_requested stub + end + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..3910fec 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,7 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" +require "webmock/minitest" module ActiveSupport class TestCase