more tests

This commit is contained in:
Graham McIntire 2026-02-03 11:02:38 -06:00
parent ec5c98781f
commit 8667d6a288
No known key found for this signature in database
6 changed files with 1344 additions and 0 deletions

View file

@ -0,0 +1,283 @@
defmodule Towerops.Snmp.Adapters.ReplayTest do
use ExUnit.Case, async: true
alias Towerops.Snmp.Adapters.Replay
describe "new/1" do
test "creates connection opts with OID map" do
oid_map = %{
"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software",
"1.3.6.1.2.1.2.2.1.1.1" => "1"
}
opts = Replay.new(oid_map)
assert Keyword.get(opts, :adapter) == Replay
assert Keyword.get(opts, :oid_map) == oid_map
assert Keyword.get(opts, :ip) == "replay"
assert Keyword.get(opts, :community) == "public"
assert Keyword.get(opts, :version) == "2c"
assert Keyword.get(opts, :port) == 161
end
test "works with empty OID map" do
opts = Replay.new(%{})
assert Keyword.get(opts, :adapter) == Replay
assert Keyword.get(opts, :oid_map) == %{}
end
end
describe "get/2" do
test "returns value for existing OID" do
oid_map = %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software"}
opts = Replay.new(oid_map)
assert {:ok, "Cisco IOS Software"} = Replay.get(opts, "1.3.6.1.2.1.1.1.0")
end
test "returns error for non-existent OID" do
opts = Replay.new(%{})
assert {:error, :no_such_name} = Replay.get(opts, "1.3.6.1.2.1.1.1.0")
end
test "parses integer values" do
oid_map = %{"1.3.6.1.2.1.2.2.1.1.1" => "42"}
opts = Replay.new(oid_map)
assert {:ok, 42} = Replay.get(opts, "1.3.6.1.2.1.2.2.1.1.1")
end
test "parses float values" do
oid_map = %{"1.3.6.1.2.1.25.3.3.1.2.1" => "98.6"}
opts = Replay.new(oid_map)
assert {:ok, 98.6} = Replay.get(opts, "1.3.6.1.2.1.25.3.3.1.2.1")
end
test "parses OID list values" do
oid_map = %{"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.516"}
opts = Replay.new(oid_map)
assert {:ok, [1, 3, 6, 1, 4, 1, 9, 1, 516]} =
Replay.get(opts, "1.3.6.1.2.1.1.2.0")
end
test "parses MAC address values" do
oid_map = %{"1.3.6.1.2.1.2.2.1.6.1" => "aa:bb:cc:dd:ee:ff"}
opts = Replay.new(oid_map)
assert {:ok, "aa:bb:cc:dd:ee:ff"} = Replay.get(opts, "1.3.6.1.2.1.2.2.1.6.1")
end
test "parses IPv4 address values" do
oid_map = %{"1.3.6.1.2.1.4.20.1.1.192.168.1.1" => "192.168.1.1"}
opts = Replay.new(oid_map)
assert {:ok, "192.168.1.1"} = Replay.get(opts, "1.3.6.1.2.1.4.20.1.1.192.168.1.1")
end
test "keeps string values as strings" do
oid_map = %{"1.3.6.1.2.1.1.5.0" => "router-01.example.com"}
opts = Replay.new(oid_map)
assert {:ok, "router-01.example.com"} = Replay.get(opts, "1.3.6.1.2.1.1.5.0")
end
end
describe "walk/2" do
test "returns all child OIDs under base" do
oid_map = %{
"1.3.6.1.2.1.2.2.1.1.1" => "1",
"1.3.6.1.2.1.2.2.1.1.2" => "2",
"1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1",
"1.3.6.1.2.1.2.2.1.2.2" => "GigabitEthernet0/2"
}
opts = Replay.new(oid_map)
assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1")
assert length(results) == 2
assert {"1.3.6.1.2.1.2.2.1.1.1", 1} in results
assert {"1.3.6.1.2.1.2.2.1.1.2", 2} in results
end
test "returns empty list when no children exist" do
oid_map = %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software"}
opts = Replay.new(oid_map)
assert {:ok, []} = Replay.walk(opts, "1.3.6.1.2.1.99")
end
test "does not include base OID itself" do
oid_map = %{
"1.3.6.1.2.1.2.2.1.1" => "base",
"1.3.6.1.2.1.2.2.1.1.1" => "child"
}
opts = Replay.new(oid_map)
assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1")
assert length(results) == 1
assert {"1.3.6.1.2.1.2.2.1.1.1", "child"} in results
refute {"1.3.6.1.2.1.2.2.1.1", "base"} in results
end
test "returns results in lexicographic order" do
oid_map = %{
"1.3.6.1.2.1.2.2.1.1.10" => "10",
"1.3.6.1.2.1.2.2.1.1.2" => "2",
"1.3.6.1.2.1.2.2.1.1.1" => "1"
}
opts = Replay.new(oid_map)
assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1")
# Extract OIDs
oids = Enum.map(results, fn {oid, _} -> oid end)
# Should be sorted: 1, 2, 10 (not "1", "10", "2" as strings)
assert oids == [
"1.3.6.1.2.1.2.2.1.1.1",
"1.3.6.1.2.1.2.2.1.1.2",
"1.3.6.1.2.1.2.2.1.1.10"
]
end
test "parses values in walk results" do
oid_map = %{
"1.3.6.1.2.1.2.2.1.1.1" => "42",
"1.3.6.1.2.1.2.2.1.1.2" => "98.6",
"1.3.6.1.2.1.2.2.1.1.3" => "192.168.1.1"
}
opts = Replay.new(oid_map)
assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1")
assert {"1.3.6.1.2.1.2.2.1.1.1", 42} in results
assert {"1.3.6.1.2.1.2.2.1.1.2", 98.6} in results
assert {"1.3.6.1.2.1.2.2.1.1.3", "192.168.1.1"} in results
end
end
describe "get_multiple/2" do
test "returns all requested OIDs" do
oid_map = %{
"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software",
"1.3.6.1.2.1.1.5.0" => "router-01",
"1.3.6.1.2.1.1.6.0" => "Data Center 1"
}
opts = Replay.new(oid_map)
oids = [
"1.3.6.1.2.1.1.1.0",
"1.3.6.1.2.1.1.5.0",
"1.3.6.1.2.1.1.6.0"
]
assert {:ok, results} = Replay.get_multiple(opts, oids)
assert results["1.3.6.1.2.1.1.1.0"] == "Cisco IOS Software"
assert results["1.3.6.1.2.1.1.5.0"] == "router-01"
assert results["1.3.6.1.2.1.1.6.0"] == "Data Center 1"
end
test "returns nil for non-existent OIDs" do
oid_map = %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software"}
opts = Replay.new(oid_map)
oids = [
"1.3.6.1.2.1.1.1.0",
"1.3.6.1.2.1.99.99.99"
]
assert {:ok, results} = Replay.get_multiple(opts, oids)
assert results["1.3.6.1.2.1.1.1.0"] == "Cisco IOS Software"
assert results["1.3.6.1.2.1.99.99.99"] == nil
end
test "works with empty OID list" do
opts = Replay.new(%{})
assert {:ok, %{}} = Replay.get_multiple(opts, [])
end
test "parses values correctly" do
oid_map = %{
"1.3.6.1.2.1.2.2.1.1.1" => "42",
"1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1"
}
opts = Replay.new(oid_map)
oids = [
"1.3.6.1.2.1.2.2.1.1.1",
"1.3.6.1.2.1.2.2.1.2.1"
]
assert {:ok, results} = Replay.get_multiple(opts, oids)
assert results["1.3.6.1.2.1.2.2.1.1.1"] == 42
assert results["1.3.6.1.2.1.2.2.1.2.1"] == "GigabitEthernet0/1"
end
end
describe "type inference edge cases" do
test "parses MAC address with hyphens" do
oid_map = %{"oid" => "AA-BB-CC-DD-EE-FF"}
opts = Replay.new(oid_map)
assert {:ok, "AA-BB-CC-DD-EE-FF"} = Replay.get(opts, "oid")
end
test "treats 4-component dotted number as IPv4, not OID" do
# Ambiguous case: "1.2.3.4" could be IPv4 or OID, we choose IPv4
oid_map = %{"oid" => "1.2.3.4"}
opts = Replay.new(oid_map)
assert {:ok, "1.2.3.4"} = Replay.get(opts, "oid")
end
test "parses 5-component dotted number as OID list" do
oid_map = %{"oid" => "1.2.3.4.5"}
opts = Replay.new(oid_map)
assert {:ok, [1, 2, 3, 4, 5]} = Replay.get(opts, "oid")
end
test "parses negative float" do
oid_map = %{"oid" => "-12.5"}
opts = Replay.new(oid_map)
assert {:ok, -12.5} = Replay.get(opts, "oid")
end
test "keeps mixed alphanumeric as string" do
oid_map = %{"oid" => "GigabitEthernet0/1"}
opts = Replay.new(oid_map)
assert {:ok, "GigabitEthernet0/1"} = Replay.get(opts, "oid")
end
test "handles zero value" do
oid_map = %{"oid" => "0"}
opts = Replay.new(oid_map)
assert {:ok, 0} = Replay.get(opts, "oid")
end
test "handles large integer" do
oid_map = %{"oid" => "4294967295"}
opts = Replay.new(oid_map)
assert {:ok, 4_294_967_295} = Replay.get(opts, "oid")
end
end
end

View file

@ -0,0 +1,234 @@
defmodule Towerops.Snmp.ProcessorReadingTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Snmp.ProcessorReading
describe "changeset/2" do
setup do
processor = insert_processor()
%{processor: processor}
end
test "valid changeset with all fields", %{processor: processor} do
attrs = %{
processor_id: processor.id,
load_percent: 45.5,
status: "ok",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert changeset.valid?
assert get_change(changeset, :processor_id) == processor.id
assert get_change(changeset, :load_percent) == 45.5
assert get_change(changeset, :status) == "ok"
end
test "valid changeset without optional load_percent", %{processor: processor} do
attrs = %{
processor_id: processor.id,
status: "ok",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert changeset.valid?
refute get_change(changeset, :load_percent)
end
test "valid changeset with different status values", %{processor: processor} do
for status <- ["ok", "warning", "critical", "error"] do
attrs = %{
processor_id: processor.id,
status: status,
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert changeset.valid?, "Status #{status} should be valid"
end
end
test "invalid changeset when processor_id is missing" do
attrs = %{
status: "ok",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
refute changeset.valid?
assert %{processor_id: ["can't be blank"]} = errors_on(changeset)
end
test "invalid changeset when status is missing", %{processor: processor} do
attrs = %{
processor_id: processor.id,
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
refute changeset.valid?
assert %{status: ["can't be blank"]} = errors_on(changeset)
end
test "invalid changeset when checked_at is missing", %{processor: processor} do
attrs = %{
processor_id: processor.id,
status: "ok"
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
refute changeset.valid?
assert %{checked_at: ["can't be blank"]} = errors_on(changeset)
end
test "invalid changeset with invalid status value", %{processor: processor} do
attrs = %{
processor_id: processor.id,
status: "invalid_status",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
refute changeset.valid?
assert %{status: ["is invalid"]} = errors_on(changeset)
end
test "invalid changeset with non-existent processor_id" do
attrs = %{
processor_id: Ecto.UUID.generate(),
status: "ok",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert changeset.valid?
# Foreign key constraint will be checked at insert time
assert {:error, changeset} = Repo.insert(changeset)
assert %{processor_id: ["does not exist"]} = errors_on(changeset)
end
end
describe "database operations" do
setup do
processor = insert_processor()
%{processor: processor}
end
test "inserts valid processor reading", %{processor: processor} do
attrs = %{
processor_id: processor.id,
load_percent: 75.5,
status: "warning",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert {:ok, reading} = Repo.insert(changeset)
assert reading.processor_id == processor.id
assert reading.load_percent == 75.5
assert reading.status == "warning"
assert reading.inserted_at
end
test "inserts reading without load_percent", %{processor: processor} do
attrs = %{
processor_id: processor.id,
status: "error",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert {:ok, reading} = Repo.insert(changeset)
assert is_nil(reading.load_percent)
assert reading.status == "error"
end
test "timestamps are set automatically", %{processor: processor} do
attrs = %{
processor_id: processor.id,
status: "ok",
checked_at: DateTime.utc_now()
}
changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs)
assert {:ok, reading} = Repo.insert(changeset)
assert reading.inserted_at
# updated_at should be false according to schema
refute Map.has_key?(reading, :updated_at)
end
end
# Test helper to create a processor
defp insert_processor do
snmp_device = insert_snmp_device()
Repo.insert!(%Towerops.Snmp.Processor{
snmp_device_id: snmp_device.id,
processor_index: "1",
processor_type: "hr_processor",
load_percent: 0.0
})
end
defp insert_snmp_device do
device = insert_device()
Repo.insert!(%Towerops.Snmp.Device{
device_id: device.id,
sys_descr: "Test Device",
sys_object_id: "1.3.6.1.4.1.9",
sys_name: "test-device"
})
end
defp insert_device do
org = insert_organization()
site = insert_site(org)
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id
})
device
end
defp insert_organization do
user = insert_user()
{:ok, org} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
org
end
defp insert_site(org) do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
site
end
defp insert_user do
user_fixture()
end
end

View file

@ -0,0 +1,231 @@
defmodule Towerops.Workers.BackupSummaryWorkerTest do
use Towerops.DataCase, async: false
import ExUnit.CaptureLog
import Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
alias Towerops.Organizations
alias Towerops.Sites
alias Towerops.Workers.BackupSummaryWorker
require Logger
setup do
# Ensure Logger level is set to info for capturing logs
original_level = Logger.level()
Logger.configure(level: :info)
on_exit(fn ->
Logger.configure(level: original_level)
end)
:ok
end
describe "perform/1" do
test "returns :ok when no backup requests exist" do
# Execute the worker
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end
test "logs summary when only successful backups exist" do
device = insert_device()
# Create successful requests
{:ok, req} = BackupRequests.create_request(device.id, "job1")
BackupRequests.update_request_status(req.id, "success", nil)
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify summary was logged
assert log =~ "Backup summary for past 24 hours"
refute log =~ "Backup failures detected"
end
test "logs warning when failed backups exist" do
device = insert_device()
# Create a failed request
{:ok, req} = BackupRequests.create_request(device.id, "job_failed")
BackupRequests.update_request_status(req.id, "failed", "Connection error")
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify warning was logged
assert log =~ "Backup summary for past 24 hours"
assert log =~ "Backup failures detected"
end
test "logs warning when timeout backups exist" do
device = insert_device()
# Create a timeout request
{:ok, req} = BackupRequests.create_request(device.id, "job_timeout")
BackupRequests.update_request_status(req.id, "timeout", "Request timed out")
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify warning was logged
assert log =~ "Backup summary for past 24 hours"
assert log =~ "Backup failures detected"
end
test "logs warning when both failed and timeout backups exist" do
device = insert_device()
# Create a failed request
{:ok, req1} = BackupRequests.create_request(device.id, "job_failed")
BackupRequests.update_request_status(req1.id, "failed", "Connection error")
# Create a timeout request
{:ok, req2} = BackupRequests.create_request(device.id, "job_timeout")
BackupRequests.update_request_status(req2.id, "timeout", "Request timed out")
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify warning was logged
assert log =~ "Backup summary for past 24 hours"
assert log =~ "Backup failures detected"
end
test "includes device IDs in warning log" do
device1 = insert_device()
device2 = insert_device()
# Create failed requests for multiple devices
{:ok, req1} = BackupRequests.create_request(device1.id, "job1")
BackupRequests.update_request_status(req1.id, "failed", "Error")
{:ok, req2} = BackupRequests.create_request(device2.id, "job2")
BackupRequests.update_request_status(req2.id, "timeout", "Timeout")
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify warnings were logged (metadata fields like device IDs aren't captured in text logs)
assert log =~ "Backup summary for past 24 hours"
assert log =~ "Backup failures detected"
end
test "only includes requests from past 24 hours" do
device = insert_device()
# Create an old failed request (30 hours ago)
{:ok, old_req} = BackupRequests.create_request(device.id, "job_old")
old_req
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-30 * 60 * 60, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
BackupRequests.update_request_status(old_req.id, "failed", "Old error")
# Create a recent failed request (12 hours ago)
{:ok, recent_req} = BackupRequests.create_request(device.id, "job_recent")
recent_req
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-12 * 60 * 60, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
BackupRequests.update_request_status(recent_req.id, "failed", "Recent error")
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify summary and failure warning are logged (only recent failure should be counted)
assert log =~ "Backup summary for past 24 hours"
assert log =~ "Backup failures detected"
end
test "handles mixed statuses correctly" do
device = insert_device()
# Create requests with various statuses
{:ok, req1} = BackupRequests.create_request(device.id, "job_success")
BackupRequests.update_request_status(req1.id, "success", nil)
{:ok, req2} = BackupRequests.create_request(device.id, "job_failed")
BackupRequests.update_request_status(req2.id, "failed", "Error")
{:ok, req3} = BackupRequests.create_request(device.id, "job_timeout")
BackupRequests.update_request_status(req3.id, "timeout", "Timeout")
{:ok, _req4} = BackupRequests.create_request(device.id, "job_pending")
# Leave as pending
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify summary and failure warnings are logged
assert log =~ "Backup summary for past 24 hours"
assert log =~ "Backup failures detected"
end
test "does not log warning when all backups are successful or pending" do
device = insert_device()
# Create successful and pending requests
{:ok, req1} = BackupRequests.create_request(device.id, "job_success")
BackupRequests.update_request_status(req1.id, "success", nil)
{:ok, _req2} = BackupRequests.create_request(device.id, "job_pending")
# Leave as pending
# Execute the worker and capture logs
log =
capture_log(fn ->
assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}})
end)
# Verify no warning was logged
assert log =~ "Backup summary for past 24 hours"
refute log =~ "Backup failures detected"
end
end
# Helper function to create a device
defp insert_device do
user = user_fixture()
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
{:ok, device} =
Devices.create_device(%{
name: "Test Device #{System.unique_integer()}",
ip_address: "192.168.1.#{:rand.uniform(254)}",
site_id: site.id
})
device
end
end

View file

@ -0,0 +1,205 @@
defmodule Towerops.Workers.BackupTimeoutWorkerTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
alias Towerops.Organizations
alias Towerops.Sites
alias Towerops.Workers.BackupTimeoutWorker
describe "perform/1" do
test "marks pending requests older than 5 minutes as timeout" do
device = insert_device()
# Create a pending request from 10 minutes ago (older than 5 minute timeout)
{:ok, old_request} = BackupRequests.create_request(device.id, "job_old")
old_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
# Verify request was marked as timeout
updated = Repo.reload(old_request)
assert updated.status == "timeout"
assert updated.error_message == "Request timed out"
assert updated.completed_at
end
test "does not mark recent pending requests" do
device = insert_device()
# Create a pending request from 2 minutes ago (within 5 minute timeout)
{:ok, recent_request} = BackupRequests.create_request(device.id, "job_recent")
recent_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-120, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
# Verify request still pending
updated = Repo.reload(recent_request)
assert updated.status == "pending"
assert is_nil(updated.completed_at)
end
test "does not mark completed requests" do
device = insert_device()
# Create an old request that already succeeded
{:ok, old_request} = BackupRequests.create_request(device.id, "job_old")
old_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
{:ok, _} = BackupRequests.update_request_status(old_request.id, "success", nil)
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
# Verify request still has success status
updated = Repo.reload(old_request)
assert updated.status == "success"
end
test "handles multiple requests with mixed statuses" do
device = insert_device()
# Create old pending request (should timeout)
{:ok, old_pending} = BackupRequests.create_request(device.id, "job_old_pending")
old_pending
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Create recent pending request (should stay pending)
{:ok, recent_pending} = BackupRequests.create_request(device.id, "job_recent_pending")
recent_pending
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-120, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Create old completed request (should stay success)
{:ok, old_success} = BackupRequests.create_request(device.id, "job_old_success")
old_success
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
{:ok, _} = BackupRequests.update_request_status(old_success.id, "success", nil)
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
# Verify only old pending was marked as timeout
assert Repo.reload(old_pending).status == "timeout"
assert Repo.reload(recent_pending).status == "pending"
assert Repo.reload(old_success).status == "success"
end
test "handles boundary case at exactly 5 minutes" do
device = insert_device()
# Create a request at exactly 5 minutes (300 seconds) ago
{:ok, boundary_request} = BackupRequests.create_request(device.id, "job_boundary")
boundary_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-300, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
# The cutoff uses `< cutoff`, so exactly 5 minutes should NOT timeout
updated = Repo.reload(boundary_request)
assert updated.status == "pending"
end
test "returns :ok when no requests need timeout" do
device = insert_device()
# Create only recent request
{:ok, recent_request} = BackupRequests.create_request(device.id, "job_recent")
recent_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
end
test "returns :ok when no requests exist at all" do
# Execute the worker with no requests in the database
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
end
test "handles multiple devices with stale requests" do
device1 = insert_device()
device2 = insert_device()
# Create old pending requests for both devices
{:ok, request1} = BackupRequests.create_request(device1.id, "job1")
request1
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
{:ok, request2} = BackupRequests.create_request(device2.id, "job2")
request2
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-700, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Execute the worker
assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}})
# Verify both were marked as timeout
assert Repo.reload(request1).status == "timeout"
assert Repo.reload(request2).status == "timeout"
end
end
# Helper function to create a device
defp insert_device do
user = user_fixture()
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
{:ok, device} =
Devices.create_device(%{
name: "Test Device #{System.unique_integer()}",
ip_address: "192.168.1.#{:rand.uniform(254)}",
site_id: site.id
})
device
end
end

View file

@ -0,0 +1,238 @@
defmodule Towerops.Workers.LoginHistoryCleanupWorkerTest do
use Towerops.DataCase, async: true
alias Towerops.Accounts.LoginAttempt
alias Towerops.Workers.LoginHistoryCleanupWorker
describe "perform/1" do
test "deletes active login attempts older than retention period" do
# Create an old active record (400 days old, past default 365 retention)
old_attempt =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -400, :day),
anonymized_at: nil
})
# Execute the worker
assert {:ok, %{deleted: 1, anonymized_deleted: 0}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify old active record was deleted
refute Repo.get(LoginAttempt, old_attempt.id)
end
test "keeps active login attempts within retention period" do
# Create a recent active record (100 days old, within default 365 retention)
recent_attempt =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -100, :day),
anonymized_at: nil
})
# Execute the worker
assert {:ok, %{deleted: 0, anonymized_deleted: 0}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify recent active record still exists
assert Repo.get(LoginAttempt, recent_attempt.id)
end
test "deletes anonymized records older than 90 days" do
# Create an anonymized record (100 days after anonymization)
anon_date = DateTime.add(DateTime.utc_now(), -100, :day)
old_anon_attempt =
insert_login_attempt(%{
inserted_at: DateTime.add(anon_date, -200, :day),
anonymized_at: anon_date
})
# Execute the worker
assert {:ok, %{deleted: 0, anonymized_deleted: 1}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify old anonymized record was deleted
refute Repo.get(LoginAttempt, old_anon_attempt.id)
end
test "keeps anonymized records within 90 day period" do
# Create an anonymized record (30 days after anonymization)
anon_date = DateTime.add(DateTime.utc_now(), -30, :day)
recent_anon_attempt =
insert_login_attempt(%{
inserted_at: DateTime.add(anon_date, -200, :day),
anonymized_at: anon_date
})
# Execute the worker
assert {:ok, %{deleted: 0, anonymized_deleted: 0}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify recent anonymized record still exists
assert Repo.get(LoginAttempt, recent_anon_attempt.id)
end
test "handles mixed active and anonymized records" do
# Create old active record (should be deleted)
old_active =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -400, :day),
anonymized_at: nil
})
# Create recent active record (should be kept)
recent_active =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -100, :day),
anonymized_at: nil
})
# Create old anonymized record (should be deleted)
old_anon =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -500, :day),
anonymized_at: DateTime.add(DateTime.utc_now(), -100, :day)
})
# Create recent anonymized record (should be kept)
recent_anon =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -400, :day),
anonymized_at: DateTime.add(DateTime.utc_now(), -30, :day)
})
# Execute the worker
assert {:ok, %{deleted: 1, anonymized_deleted: 1}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify old records were deleted
refute Repo.get(LoginAttempt, old_active.id)
refute Repo.get(LoginAttempt, old_anon.id)
# Verify recent records still exist
assert Repo.get(LoginAttempt, recent_active.id)
assert Repo.get(LoginAttempt, recent_anon.id)
end
test "returns zero counts when no records need deletion" do
# Create only recent records
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -10, :day),
anonymized_at: nil
})
# Execute the worker
assert {:ok, %{deleted: 0, anonymized_deleted: 0}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
end
test "works when no records exist at all" do
# Execute the worker with no records in the database
assert {:ok, %{deleted: 0, anonymized_deleted: 0}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
end
test "respects custom retention period from config" do
# Temporarily override config (if Application.put_env works in tests)
# Otherwise, this test documents expected behavior
# Create a record 200 days old (would be deleted if retention is 180 days)
old_attempt =
insert_login_attempt(%{
inserted_at: DateTime.add(DateTime.utc_now(), -200, :day),
anonymized_at: nil
})
# With default 365 day retention, this should NOT be deleted
assert {:ok, %{deleted: 0, anonymized_deleted: 0}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify record still exists
assert Repo.get(LoginAttempt, old_attempt.id)
end
test "handles boundary case at exactly retention cutoff" do
# Create a record at exactly 365 days old
cutoff_date = DateTime.add(DateTime.utc_now(), -365, :day)
boundary_attempt =
insert_login_attempt(%{
inserted_at: cutoff_date,
anonymized_at: nil
})
# Execute the worker
assert {:ok, %{deleted: count, anonymized_deleted: _}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# The query uses `<` not `<=`, so exactly at cutoff should NOT be deleted
assert count == 0
assert Repo.get(LoginAttempt, boundary_attempt.id)
end
test "handles boundary case for anonymized at exactly 90 days" do
# Create an anonymized record at exactly 90 days
anon_date = DateTime.add(DateTime.utc_now(), -90, :day)
boundary_anon =
insert_login_attempt(%{
inserted_at: DateTime.add(anon_date, -100, :day),
anonymized_at: anon_date
})
# Execute the worker
assert {:ok, %{deleted: _, anonymized_deleted: anon_count}} =
LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}})
# The query uses `<` not `<=`, so exactly at 90 days should NOT be deleted
assert anon_count == 0
assert Repo.get(LoginAttempt, boundary_anon.id)
end
end
# Helper function to create login attempts
defp insert_login_attempt(attrs) do
default_attrs = %{
email: "test#{System.unique_integer()}@example.com",
success: true,
method: "password",
ip_address: "192.168.1.1"
}
attrs =
default_attrs
|> Map.merge(attrs)
|> Map.update(:inserted_at, DateTime.utc_now(), fn dt -> dt end)
# Truncate datetime values to remove microseconds (Ecto requirement)
inserted_at = attrs |> Map.get(:inserted_at) |> DateTime.truncate(:second)
anonymized_at =
case Map.get(attrs, :anonymized_at) do
nil -> nil
dt -> DateTime.truncate(dt, :second)
end
# Remove timestamp fields from attrs (not castable)
attrs_for_changeset =
attrs
|> Map.delete(:inserted_at)
|> Map.delete(:anonymized_at)
changeset =
%LoginAttempt{}
|> LoginAttempt.changeset(attrs_for_changeset)
|> Ecto.Changeset.put_change(:inserted_at, inserted_at)
# Add anonymized_at if provided
changeset =
if anonymized_at do
Ecto.Changeset.put_change(changeset, :anonymized_at, anonymized_at)
else
changeset
end
Repo.insert!(changeset)
end
end

View file

@ -0,0 +1,153 @@
defmodule Towerops.Workers.SessionCleanupWorkerTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Accounts
alias Towerops.Accounts.BrowserSession
alias Towerops.Workers.SessionCleanupWorker
describe "perform/1" do
test "deletes expired browser sessions" do
user = user_fixture()
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
# Create an expired session (1 day ago)
expired_session =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
expires_at: DateTime.add(DateTime.utc_now(), -1, :day)
})
# Execute the worker
assert {:ok, %{sessions_deleted: 1}} =
SessionCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify expired session was deleted
refute Repo.get(BrowserSession, expired_session.id)
end
test "keeps non-expired browser sessions" do
user = user_fixture()
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
# Create a valid session (expires in 1 day)
valid_session =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
expires_at: DateTime.add(DateTime.utc_now(), 1, :day)
})
# Execute the worker
assert {:ok, %{sessions_deleted: 0}} =
SessionCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify valid session still exists
assert Repo.get(BrowserSession, valid_session.id)
end
test "deletes only expired sessions among mixed sessions" do
user = user_fixture()
{_token1, user_token1} = Accounts.generate_user_session_token_with_record(user)
{_token2, user_token2} = Accounts.generate_user_session_token_with_record(user)
{_token3, user_token3} = Accounts.generate_user_session_token_with_record(user)
# Create 2 expired sessions
expired1 =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token1.id,
expires_at: DateTime.add(DateTime.utc_now(), -2, :day)
})
expired2 =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token2.id,
expires_at: DateTime.add(DateTime.utc_now(), -1, :hour)
})
# Create 1 valid session
valid_session =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token3.id,
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
})
# Execute the worker
assert {:ok, %{sessions_deleted: 2}} =
SessionCleanupWorker.perform(%Oban.Job{args: %{}})
# Verify expired sessions were deleted
refute Repo.get(BrowserSession, expired1.id)
refute Repo.get(BrowserSession, expired2.id)
# Verify valid session still exists
assert Repo.get(BrowserSession, valid_session.id)
end
test "returns zero count when no expired sessions exist" do
user = user_fixture()
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
# Create only valid sessions
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
expires_at: DateTime.add(DateTime.utc_now(), 1, :day)
})
# Execute the worker
assert {:ok, %{sessions_deleted: 0}} =
SessionCleanupWorker.perform(%Oban.Job{args: %{}})
end
test "works when no sessions exist at all" do
# Execute the worker with no sessions in the database
assert {:ok, %{sessions_deleted: 0}} =
SessionCleanupWorker.perform(%Oban.Job{args: %{}})
end
test "handles sessions that expired exactly now" do
user = user_fixture()
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
now = DateTime.utc_now()
# Create a session that expires exactly now (should be considered expired)
expired_now =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
expires_at: now
})
# Execute the worker
assert {:ok, %{sessions_deleted: count}} =
SessionCleanupWorker.perform(%Oban.Job{args: %{}})
# The session should be deleted (expires_at < now, not <=)
# Actually, checking the code: where([bs], bs.expires_at < ^now)
# So expires_at == now should NOT be deleted
assert count == 0
assert Repo.get(BrowserSession, expired_now.id)
end
end
# Helper function to create browser sessions
defp insert_browser_session(attrs) do
default_attrs = %{
ip_address: "192.168.1.1",
last_activity_at: DateTime.utc_now()
}
attrs = Map.merge(default_attrs, attrs)
%BrowserSession{}
|> BrowserSession.create_changeset(attrs)
|> Repo.insert!()
end
end