From b04d25e6079c7eb32cbd21765a1c4a2420176f83 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:56:52 -0600 Subject: [PATCH] add preseem sync worker oban cron job --- config/dev.exs | 4 +- config/runtime.exs | 4 +- lib/towerops/workers/preseem_sync_worker.ex | 61 +++++++++ .../workers/preseem_sync_worker_test.exs | 125 ++++++++++++++++++ 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 lib/towerops/workers/preseem_sync_worker.ex create mode 100644 test/towerops/workers/preseem_sync_worker_test.exs diff --git a/config/dev.exs b/config/dev.exs index c0b48381..6dcea6b0 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -79,7 +79,9 @@ config :towerops, Oban, # Delete expired IP bans every hour {"0 * * * *", Towerops.Workers.ExpiredBanCleanupWorker}, # Delete stale violation records daily at 2 AM - {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker} + {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker}, + # Sync Preseem data every 10 minutes + {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/config/runtime.exs b/config/runtime.exs index 10ac33ac..520fabf2 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -192,7 +192,9 @@ if config_env() == :prod do # Delete expired IP bans every hour {"0 * * * *", Towerops.Workers.ExpiredBanCleanupWorker}, # Delete stale violation records daily at 2 AM - {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker} + {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker}, + # Sync Preseem data every 10 minutes + {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/lib/towerops/workers/preseem_sync_worker.ex b/lib/towerops/workers/preseem_sync_worker.ex new file mode 100644 index 00000000..13944fb0 --- /dev/null +++ b/lib/towerops/workers/preseem_sync_worker.ex @@ -0,0 +1,61 @@ +defmodule Towerops.Workers.PreseemSyncWorker do + @moduledoc """ + Oban cron worker that syncs Preseem data for all enabled integrations. + + Runs every 10 minutes. For each org with an enabled Preseem integration, + checks if enough time has elapsed since the last sync (based on the + integration's sync_interval_minutes), then calls Preseem.Sync.sync_organization/1. + """ + use Oban.Worker, queue: :maintenance + + alias Towerops.Integrations + alias Towerops.Preseem.Sync + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + integrations = Integrations.list_enabled_integrations("preseem") + + results = + Enum.map(integrations, fn integration -> + if should_sync?(integration) do + case Sync.sync_organization(integration) do + {:ok, result} -> + Logger.info("Preseem sync completed for org #{integration.organization_id}: #{inspect(result)}") + + {:ok, result} + + {:error, reason} -> + Logger.error("Preseem sync failed for org #{integration.organization_id}: #{inspect(reason)}") + + {:error, reason} + end + else + :skipped + end + end) + + synced = Enum.count(results, &match?({:ok, _}, &1)) + failed = Enum.count(results, &match?({:error, _}, &1)) + skipped = Enum.count(results, &(&1 == :skipped)) + + if synced > 0 or failed > 0 do + Logger.info("Preseem sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped") + end + + :ok + end + + defp should_sync?(integration) do + case integration.last_synced_at do + nil -> + true + + last_synced_at -> + interval_seconds = (integration.sync_interval_minutes || 10) * 60 + elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second) + elapsed >= interval_seconds + end + end +end diff --git a/test/towerops/workers/preseem_sync_worker_test.exs b/test/towerops/workers/preseem_sync_worker_test.exs new file mode 100644 index 00000000..3f14c8f7 --- /dev/null +++ b/test/towerops/workers/preseem_sync_worker_test.exs @@ -0,0 +1,125 @@ +defmodule Towerops.Workers.PreseemSyncWorkerTest do + use Towerops.DataCase, async: true + use Oban.Testing, repo: Towerops.Repo + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.Client + alias Towerops.Workers.PreseemSyncWorker + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "perform/1" do + test "syncs enabled preseem integrations", %{org: org} do + integration = integration_fixture(org.id) + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => [ + %{ + "id" => "ap-1", + "name" => "Test AP", + "mac_address" => "AA:BB:CC:DD:EE:FF", + "ip_address" => "10.0.0.1" + } + ] + }) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + + updated = Repo.reload!(integration) + assert updated.last_synced_at + assert updated.last_sync_status in ["success", "partial"] + end + + test "skips integrations synced recently", %{org: org} do + _integration = + integration_fixture(org.id, %{ + last_synced_at: DateTime.truncate(DateTime.utc_now(), :second), + last_sync_status: "success" + }) + + # No Client stub needed - should skip without calling API + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "handles no enabled integrations" do + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "handles API errors without crashing", %{org: org} do + _integration = integration_fixture(org.id) + + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "respects sync_interval_minutes", %{org: org} do + # Last synced 5 minutes ago with a 10-minute interval -> should skip + five_minutes_ago = + DateTime.utc_now() + |> DateTime.add(-5, :minute) + |> DateTime.truncate(:second) + + _integration = + integration_fixture(org.id, %{ + sync_interval_minutes: 10, + last_synced_at: five_minutes_ago, + last_sync_status: "success" + }) + + # No Client stub needed - should skip without calling API + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "syncs when sync interval has elapsed", %{org: org} do + # Last synced 15 minutes ago with a 10-minute interval -> should sync + fifteen_minutes_ago = + DateTime.utc_now() + |> DateTime.add(-15, :minute) + |> DateTime.truncate(:second) + + integration = + integration_fixture(org.id, %{ + sync_interval_minutes: 10, + last_synced_at: fifteen_minutes_ago, + last_sync_status: "success" + }) + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => []}) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + + updated = Repo.reload!(integration) + assert DateTime.after?(updated.last_synced_at, fifteen_minutes_ago) + end + + test "syncs integrations that have never been synced", %{org: org} do + integration = integration_fixture(org.id) + assert integration.last_synced_at == nil + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => []}) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + + updated = Repo.reload!(integration) + assert updated.last_synced_at + end + end +end