From 36a3f6daad07098de67ecff24870cb8b6895340d Mon Sep 17 00:00:00 2001 From: Min Zeya Phyo Date: Tue, 28 Jul 2026 02:16:59 +0800 Subject: [PATCH] feat(ntfy): dispatch gateway_offline from CheckGatewayHealthJob after bulk update --- app/jobs/check_gateway_health_job.rb | 19 +++++++-- test/jobs/check_gateway_health_job_test.rb | 45 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 test/jobs/check_gateway_health_job_test.rb diff --git a/app/jobs/check_gateway_health_job.rb b/app/jobs/check_gateway_health_job.rb index e973e91..79b270c 100644 --- a/app/jobs/check_gateway_health_job.rb +++ b/app/jobs/check_gateway_health_job.rb @@ -2,13 +2,24 @@ class CheckGatewayHealthJob < ApplicationJob queue_as :default def perform - # Mark gateways as offline if no heartbeat in last 2 minutes - offline_count = Gateway.where("last_heartbeat_at < ?", 2.minutes.ago) - .where.not(status: "offline") - .update_all(status: "offline") + # Capture stale gateway details BEFORE update_all (update_all bypasses callbacks + # and would change the scope, so we snapshot the rows first) + stale_gateway_details = Gateway.where("last_heartbeat_at < ?", 2.minutes.ago) + .where.not(status: "offline") + .pluck(:id, :name, :device_id) + + offline_count = Gateway.where(id: stale_gateway_details.map(&:first)).update_all(status: "offline") if offline_count > 0 Rails.logger.warn("Marked #{offline_count} gateways as offline due to missing heartbeat") + + stale_gateway_details.each do |_id, name, device_id| + Gateway.dispatch_ntfy("gateway_offline", + title: "Gateway offline", + message: "#{name} (#{device_id}) went offline — no heartbeat for 2+ minutes", + priority: 4, + tags: ["rotating_light"]) + end end end end diff --git a/test/jobs/check_gateway_health_job_test.rb b/test/jobs/check_gateway_health_job_test.rb new file mode 100644 index 0000000..7ec3000 --- /dev/null +++ b/test/jobs/check_gateway_health_job_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class CheckGatewayHealthJobTest < ActiveJob::TestCase + setup do + ActiveJob::Base.queue_adapter = :test + @admin = AdminUser.create!( + name: "Health Admin", email: "health@example.com", + password: "password123", + ntfy_topic: "health-topic", ntfy_token: "tk_health", + ntfy_enabled: true, ntfy_server_url: "https://ntfy.example.com" + ) + stub_request(:post, "https://ntfy.example.com/health-topic").to_return(status: 200) + end + + test "dispatches gateway_offline for each stale gateway marked offline" do + stale_gw = Gateway.create!( + device_id: "stale-1", name: "Stale One", + api_key_digest: "a" * 64, status: "online", + last_heartbeat_at: 5.minutes.ago + ) + Gateway.create!( + device_id: "fresh-1", name: "Fresh", + api_key_digest: "b" * 64, status: "online", + last_heartbeat_at: 30.seconds.ago + ) + + assert_enqueued_jobs 1 do + CheckGatewayHealthJob.perform_now + end + + assert_equal "offline", stale_gw.reload.status + end + + test "does not dispatch when no gateways go stale" do + Gateway.create!( + device_id: "fresh-2", name: "Fresh Two", + api_key_digest: "c" * 64, status: "online", + last_heartbeat_at: 10.seconds.ago + ) + + assert_enqueued_jobs 0 do + CheckGatewayHealthJob.perform_now + end + end +end