feat(ntfy): add Ntfy::Publisher service with HTTP POST + auth
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -72,6 +72,7 @@ group :test do
|
|||||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||||
gem "capybara"
|
gem "capybara"
|
||||||
gem "selenium-webdriver"
|
gem "selenium-webdriver"
|
||||||
|
gem "webmock"
|
||||||
end
|
end
|
||||||
|
|
||||||
gem "tailwindcss-rails", "~> 4.3"
|
gem "tailwindcss-rails", "~> 4.3"
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ GEM
|
|||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.4)
|
||||||
|
crack (1.0.1)
|
||||||
|
bigdecimal
|
||||||
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
@@ -116,6 +119,7 @@ GEM
|
|||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
|
hashdiff (1.2.1)
|
||||||
httparty (0.23.2)
|
httparty (0.23.2)
|
||||||
csv
|
csv
|
||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
@@ -388,6 +392,10 @@ GEM
|
|||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.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 (1.2.11)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
@@ -442,6 +450,7 @@ DEPENDENCIES
|
|||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
web-console
|
web-console
|
||||||
|
webmock
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.6.9
|
2.6.9
|
||||||
|
|||||||
49
lib/my_smsa_pio/notifications/ntfy/publisher.rb
Normal file
49
lib/my_smsa_pio/notifications/ntfy/publisher.rb
Normal file
@@ -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
|
||||||
105
test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb
Normal file
105
test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb
Normal file
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
ENV["RAILS_ENV"] ||= "test"
|
ENV["RAILS_ENV"] ||= "test"
|
||||||
require_relative "../config/environment"
|
require_relative "../config/environment"
|
||||||
require "rails/test_help"
|
require "rails/test_help"
|
||||||
|
require "webmock/minitest"
|
||||||
|
|
||||||
module ActiveSupport
|
module ActiveSupport
|
||||||
class TestCase
|
class TestCase
|
||||||
|
|||||||
Reference in New Issue
Block a user