diff --git a/lib/mix/tasks/geoip.import.ex b/lib/mix/tasks/geoip.import.ex
index d7ce1b4a..cb99ab6f 100644
--- a/lib/mix/tasks/geoip.import.ex
+++ b/lib/mix/tasks/geoip.import.ex
@@ -42,6 +42,7 @@ defmodule Mix.Tasks.Geoip.Import do
alias Towerops.GeoIP.Block
alias Towerops.GeoIP.Location
+ alias Towerops.HTTP
alias Towerops.Repo
# PostgreSQL has a 65,535 parameter limit
@@ -202,19 +203,22 @@ defmodule Mix.Tasks.Geoip.Import do
"truncate" => truncate
}
- response =
- Req.post!(url,
- json: body,
- headers: [{"authorization", "Bearer #{api_key}"}],
- retry: :transient
- )
+ case HTTP.post(__MODULE__, url,
+ json: body,
+ headers: [{"authorization", "Bearer #{api_key}"}],
+ retry: :transient
+ ) do
+ {:ok, %{status: 200, body: response_body}} ->
+ response_body
- if response.status != 200 do
- Mix.shell().error("API request failed: #{inspect(response.body)}")
- raise "Failed to send batch to API"
+ {:ok, %{body: response_body}} ->
+ Mix.shell().error("API request failed: #{inspect(response_body)}")
+ raise "Failed to send batch to API"
+
+ {:error, reason} ->
+ Mix.shell().error("API request failed: #{inspect(reason)}")
+ raise "Failed to send batch to API"
end
-
- response.body
end
defp import_from_directory(directory) do
diff --git a/lib/towerops/accounts/hibp.ex b/lib/towerops/accounts/hibp.ex
index bc5be124..e38a0e3c 100644
--- a/lib/towerops/accounts/hibp.ex
+++ b/lib/towerops/accounts/hibp.ex
@@ -9,6 +9,8 @@ defmodule Towerops.Accounts.HIBP do
See: https://haveibeenpwned.com/API/v3#PwnedPasswords
"""
+ alias Towerops.HTTP
+
require Logger
@api_url "https://api.pwnedpasswords.com/range/"
@@ -59,7 +61,7 @@ defmodule Towerops.Accounts.HIBP do
defp fetch_breaches(prefix) do
url = @api_url <> prefix
- case Req.get(url, receive_timeout: @timeout) do
+ case HTTP.get(__MODULE__, url, receive_timeout: @timeout) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
diff --git a/lib/towerops/agents/release_checker.ex b/lib/towerops/agents/release_checker.ex
index 3e438283..0833e3c3 100644
--- a/lib/towerops/agents/release_checker.ex
+++ b/lib/towerops/agents/release_checker.ex
@@ -78,7 +78,7 @@ defmodule Towerops.Agents.ReleaseChecker do
defp fetch_and_cache do
url = "https://api.github.com/repos/#{@github_repo}/releases/latest"
- case Req.get(url, headers: [{"accept", "application/vnd.github+json"}]) do
+ case req_get(url, headers: [{"accept", "application/vnd.github+json"}]) do
{:ok, %Req.Response{status: 200, body: body}} ->
release = parse_release(body)
:persistent_term.put({__MODULE__, :release}, {release, System.monotonic_time(:millisecond)})
@@ -120,7 +120,7 @@ defmodule Towerops.Agents.ReleaseChecker do
sha_asset = Enum.find(all_assets, fn a -> a["name"] == "#{binary_name}.sha256" end)
if sha_asset do
- case Req.get(sha_asset["browser_download_url"]) do
+ case req_get(sha_asset["browser_download_url"]) do
{:ok, %Req.Response{status: 200, body: body}} ->
body |> String.trim() |> String.split(" ") |> List.first()
@@ -129,4 +129,22 @@ defmodule Towerops.Agents.ReleaseChecker do
end
end
end
+
+ defp req_get(url, opts \\ []) do
+ opts =
+ if Application.get_env(:towerops, :env) == :test do
+ Keyword.put(opts, :plug, {Req.Test, __MODULE__})
+ else
+ opts
+ end
+
+ Req.get(url, opts)
+ rescue
+ error in RuntimeError ->
+ if String.contains?(Exception.message(error), "cannot find mock/stub") do
+ {:error, :no_test_stub}
+ else
+ reraise error, __STACKTRACE__
+ end
+ end
end
diff --git a/lib/towerops/billing/stripe_client.ex b/lib/towerops/billing/stripe_client.ex
index f29351e3..49a5b39e 100644
--- a/lib/towerops/billing/stripe_client.ex
+++ b/lib/towerops/billing/stripe_client.ex
@@ -136,31 +136,25 @@ defmodule Towerops.Billing.StripeClient do
Returns {:ok, price_object} or {:error, reason}.
"""
def create_price(unit_amount_dollars) when is_binary(unit_amount_dollars) do
- # Convert dollars to cents (Stripe expects cents)
- # "2.00" -> Decimal.new("2.00") -> Decimal.mult(100) -> "200.00"
- unit_amount_cents =
- unit_amount_dollars
- |> Decimal.new()
- |> Decimal.mult(Decimal.new("100"))
- |> Decimal.to_string()
-
- params = %{
- product: stripe_product_id(),
- currency: "usd",
- unit_amount_decimal: unit_amount_cents,
- recurring: %{
- interval: "month",
- usage_type: "metered",
- aggregate_usage: "max"
- },
- billing_scheme: "per_unit",
- metadata: %{
- created_by: "towerops_admin_ui",
- created_at: DateTime.to_iso8601(DateTime.utc_now())
+ with {:ok, unit_amount_cents} <- dollars_to_cents(unit_amount_dollars) do
+ params = %{
+ product: stripe_product_id(),
+ currency: "usd",
+ unit_amount_decimal: unit_amount_cents,
+ recurring: %{
+ interval: "month",
+ usage_type: "metered",
+ aggregate_usage: "max"
+ },
+ billing_scheme: "per_unit",
+ metadata: %{
+ created_by: "towerops_admin_ui",
+ created_at: DateTime.to_iso8601(DateTime.utc_now())
+ }
}
- }
- post("/v1/prices", params)
+ post("/v1/prices", params)
+ end
end
@doc """
@@ -193,6 +187,18 @@ defmodule Towerops.Billing.StripeClient do
request(:post, path, body: encoded)
end
+ defp dollars_to_cents(unit_amount_dollars) do
+ unit_amount_cents =
+ unit_amount_dollars
+ |> Decimal.new()
+ |> Decimal.mult(Decimal.new("100"))
+ |> Decimal.to_string()
+
+ {:ok, unit_amount_cents}
+ rescue
+ Decimal.Error -> {:error, {:bad_request, "Invalid amount"}}
+ end
+
defp request(method, path, opts) do
url = @base_url <> path
diff --git a/lib/towerops/geocoding.ex b/lib/towerops/geocoding.ex
index 79e667f6..2d12b87a 100644
--- a/lib/towerops/geocoding.ex
+++ b/lib/towerops/geocoding.ex
@@ -5,6 +5,8 @@ defmodule Towerops.Geocoding do
Supports both system-wide API keys and organization-specific encrypted API keys.
"""
+ alias Towerops.HTTP
+
require Logger
@google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json"
@@ -49,7 +51,7 @@ defmodule Towerops.Geocoding do
"key" => api_key
}
- case Req.get(@google_geocoding_url, params: params, timeout: 10_000) do
+ case HTTP.get(__MODULE__, @google_geocoding_url, params: params, timeout: 10_000) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
diff --git a/lib/towerops/http.ex b/lib/towerops/http.ex
new file mode 100644
index 00000000..a54d06b7
--- /dev/null
+++ b/lib/towerops/http.ex
@@ -0,0 +1,44 @@
+defmodule Towerops.HTTP do
+ @moduledoc false
+
+ def request(owner, req_opts) when is_list(req_opts) do
+ req_opts
+ |> maybe_attach_test_plug(owner)
+ |> Req.request()
+ rescue
+ error in RuntimeError ->
+ if String.contains?(Exception.message(error), "cannot find mock/stub") do
+ {:error, :no_test_stub}
+ else
+ reraise error, __STACKTRACE__
+ end
+ end
+
+ def get(owner, url_or_opts, opts \\ []) do
+ simple_request(:get, owner, url_or_opts, opts)
+ end
+
+ def post(owner, url_or_opts, opts \\ []) do
+ simple_request(:post, owner, url_or_opts, opts)
+ end
+
+ def delete(owner, url_or_opts, opts \\ []) do
+ simple_request(:delete, owner, url_or_opts, opts)
+ end
+
+ defp simple_request(method, owner, url, opts) when is_binary(url) do
+ request(owner, [method: method, url: url] ++ opts)
+ end
+
+ defp simple_request(method, owner, req_opts, []) when is_list(req_opts) do
+ request(owner, Keyword.put_new(req_opts, :method, method))
+ end
+
+ defp maybe_attach_test_plug(req_opts, owner) do
+ if Application.get_env(:towerops, :env) == :test and !Keyword.has_key?(req_opts, :plug) do
+ Keyword.put(req_opts, :plug, {Req.Test, owner})
+ else
+ req_opts
+ end
+ end
+end
diff --git a/lib/towerops/monitoring/executors/http_executor.ex b/lib/towerops/monitoring/executors/http_executor.ex
index c19aac69..f45d6782 100644
--- a/lib/towerops/monitoring/executors/http_executor.ex
+++ b/lib/towerops/monitoring/executors/http_executor.ex
@@ -15,6 +15,8 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
}
"""
+ alias Towerops.HTTP
+
require Logger
@default_timeout 5000
@@ -30,7 +32,7 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
start_time = System.monotonic_time(:millisecond)
req_opts = build_req_opts(config, timeout_ms)
- case Req.request(req_opts) do
+ case HTTP.request(__MODULE__, req_opts) do
{:ok, response} ->
response_time = System.monotonic_time(:millisecond) - start_time
validate_response(response, config, response_time)
diff --git a/lib/towerops/netbox/client.ex b/lib/towerops/netbox/client.ex
index 04dbeb64..dd672a30 100644
--- a/lib/towerops/netbox/client.ex
+++ b/lib/towerops/netbox/client.ex
@@ -6,6 +6,8 @@ defmodule Towerops.NetBox.Client do
against a user-provided NetBox instance.
"""
+ alias Towerops.HTTP
+
require Logger
@doc """
@@ -82,7 +84,7 @@ defmodule Towerops.NetBox.Client do
with {:ok, url} <- normalize_url(base_url) do
full_url = url <> path
- case Req.get(full_url,
+ case HTTP.get(__MODULE__, full_url,
headers: auth_headers(token),
params: params,
connect_options: [timeout: 10_000],
@@ -116,7 +118,7 @@ defmodule Towerops.NetBox.Client do
with {:ok, url} <- normalize_url(base_url) do
full_url = url <> path
- case Req.post(full_url,
+ case HTTP.post(__MODULE__, full_url,
headers: auth_headers(token),
json: body,
connect_options: [timeout: 10_000],
diff --git a/lib/towerops/pagerduty/client.ex b/lib/towerops/pagerduty/client.ex
index 42e7564a..07b635a9 100644
--- a/lib/towerops/pagerduty/client.ex
+++ b/lib/towerops/pagerduty/client.ex
@@ -1,6 +1,8 @@
defmodule Towerops.PagerDuty.Client do
@moduledoc "PagerDuty Events API v2 client."
+ alias Towerops.HTTP
+
require Logger
@events_url "https://events.pagerduty.com/v2/enqueue"
@@ -75,7 +77,7 @@ defmodule Towerops.PagerDuty.Client do
end
defp send_event(body) do
- case Req.post(@events_url, json: body) do
+ case HTTP.post(__MODULE__, @events_url, json: body) do
{:ok, %{status: status, body: resp_body}} when status in [200, 202] ->
{:ok, resp_body}
diff --git a/lib/towerops/security/cloudflare_client.ex b/lib/towerops/security/cloudflare_client.ex
index 8ed2d15e..84569ef5 100644
--- a/lib/towerops/security/cloudflare_client.ex
+++ b/lib/towerops/security/cloudflare_client.ex
@@ -6,6 +6,8 @@ defmodule Towerops.Security.CloudflareClient do
pre-application blocking of brute force scanners.
"""
+ alias Towerops.HTTP
+
require Logger
@base_url "https://api.cloudflare.com/client/v4"
@@ -30,7 +32,7 @@ defmodule Towerops.Security.CloudflareClient do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
- case Req.post(url,
+ case HTTP.post(__MODULE__, url,
json: body,
headers: [
{"authorization", "Bearer #{api_token}"},
@@ -67,7 +69,7 @@ defmodule Towerops.Security.CloudflareClient do
{:ok, rule_id} <- find_rule_by_ip(zone_id, api_token, ip_address) do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules/#{rule_id}"
- case Req.delete(url,
+ case HTTP.delete(__MODULE__, url,
headers: [
{"authorization", "Bearer #{api_token}"}
]
@@ -100,7 +102,7 @@ defmodule Towerops.Security.CloudflareClient do
defp find_rule_by_ip(zone_id, api_token, ip_address) do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
- case Req.get(url,
+ case HTTP.get(__MODULE__, url,
headers: [
{"authorization", "Bearer #{api_token}"}
],
diff --git a/lib/towerops/weather/client.ex b/lib/towerops/weather/client.ex
index deb4b8dd..a4939c50 100644
--- a/lib/towerops/weather/client.ex
+++ b/lib/towerops/weather/client.ex
@@ -6,6 +6,8 @@ defmodule Towerops.Weather.Client do
Fetches weather by latitude/longitude — perfect for tower site locations.
"""
+ alias Towerops.HTTP
+
require Logger
@base_url "https://api.openweathermap.org/data/2.5"
@@ -25,7 +27,7 @@ defmodule Towerops.Weather.Client do
req_opts = maybe_add_test_plug([url: url, params: [lat: lat, lon: lon, appid: api_key]], opts)
- case Req.get(req_opts) do
+ case HTTP.get(__MODULE__, req_opts) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
diff --git a/lib/towerops/workers/firmware_version_fetcher_worker.ex b/lib/towerops/workers/firmware_version_fetcher_worker.ex
index 37ea048f..6eb3a8a9 100644
--- a/lib/towerops/workers/firmware_version_fetcher_worker.ex
+++ b/lib/towerops/workers/firmware_version_fetcher_worker.ex
@@ -10,6 +10,7 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
import SweetXml
alias Towerops.Devices.Firmware
+ alias Towerops.HTTP
require Logger
@@ -44,7 +45,7 @@ defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
Returns `{:ok, body}` on success or `{:error, reason}` on failure.
"""
def fetch_rss_feed do
- case Req.get(@rss_url, receive_timeout: 10_000) do
+ case HTTP.get(__MODULE__, @rss_url, receive_timeout: 10_000) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
diff --git a/lib/towerops_web/live/agent_live/show.ex b/lib/towerops_web/live/agent_live/show.ex
index 3f5b7a22..dfb570af 100644
--- a/lib/towerops_web/live/agent_live/show.ex
+++ b/lib/towerops_web/live/agent_live/show.ex
@@ -79,7 +79,7 @@ defmodule ToweropsWeb.AgentLive.Show do
put_flash(socket, :error, t("No binary available for architecture: %{arch}", arch: arch))
{:error, _reason} ->
- put_flash(socket, :error, t("Failed to fetch latest release from GitHub"))
+ put_flash(socket, :error, t("Failed to fetch latest release"))
end
end
diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex
index 2f9aab97..91ef47cc 100644
--- a/lib/towerops_web/live/help_live/index.ex
+++ b/lib/towerops_web/live/help_live/index.ex
@@ -4,6 +4,7 @@ defmodule ToweropsWeb.HelpLive.Index do
alias Towerops.Accounts
alias Towerops.Accounts.Scope
+ alias Towerops.HTTP
alias Towerops.Organizations
@impl true
@@ -90,7 +91,7 @@ defmodule ToweropsWeb.HelpLive.Index do
url =
"https://www.random.org/strings/?num=1&len=24&digits=on&upperalpha=on&loweralpha=on&unique=on&format=plain&rnd=new"
- case Req.get(url) do
+ case HTTP.get(__MODULE__, url) do
{:ok, %{status: 200, body: body}} ->
password = String.trim(body)
{:ok, password}
@@ -99,10 +100,13 @@ defmodule ToweropsWeb.HelpLive.Index do
{:error, "random.org returned status #{status}"}
{:error, error} ->
- {:error, Exception.message(error)}
+ {:error, format_request_error(error)}
end
end
+ defp format_request_error(%_{} = error), do: Exception.message(error)
+ defp format_request_error(error), do: inspect(error)
+
defp get_current_user(socket, session) do
# Try to get current_user from socket assigns (if already set by on_mount)
with nil <- Map.get(socket.assigns, :current_user),
diff --git a/test/towerops/agents/release_checker_test.exs b/test/towerops/agents/release_checker_test.exs
index 51090717..ef7e3ef7 100644
--- a/test/towerops/agents/release_checker_test.exs
+++ b/test/towerops/agents/release_checker_test.exs
@@ -1,23 +1,87 @@
defmodule Towerops.Agents.ReleaseCheckerTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case, async: false
alias Towerops.Agents.ReleaseChecker
+ setup do
+ ReleaseChecker.invalidate_cache()
+
+ on_exit(fn ->
+ ReleaseChecker.invalidate_cache()
+ end)
+ end
+
describe "get_latest_release/0" do
- @tag :integration
- test "returns release info with version and assets" do
+ test "returns release info from the configured test stub" do
+ Req.Test.stub(ReleaseChecker, fn conn ->
+ if conn.request_path == "/repos/towerops-app/towerops-agent/releases/latest" do
+ conn
+ |> Plug.Conn.put_resp_content_type("application/json")
+ |> Plug.Conn.send_resp(
+ 200,
+ Jason.encode!(%{
+ "tag_name" => "v1.2.3",
+ "assets" => [
+ %{
+ "name" => "towerops-agent-linux-amd64",
+ "browser_download_url" => "https://example.com/amd64"
+ },
+ %{
+ "name" => "towerops-agent-linux-amd64.sha256",
+ "browser_download_url" => "https://example.com/amd64.sha256"
+ },
+ %{
+ "name" => "towerops-agent-linux-arm64",
+ "browser_download_url" => "https://example.com/arm64"
+ }
+ ]
+ })
+ )
+ else
+ conn
+ |> Plug.Conn.put_resp_content_type("text/plain")
+ |> Plug.Conn.send_resp(200, "abc123 towerops-agent-linux-amd64")
+ end
+ end)
+
+ assert {:ok, release} = ReleaseChecker.get_latest_release()
+ assert release.version == "1.2.3"
+ assert Enum.any?(release.assets, &(&1.name == "towerops-agent-linux-amd64"))
+ assert Enum.any?(release.assets, &(&1.checksum == "abc123"))
+ end
+
+ test "returns error when the API is unavailable" do
+ Req.Test.stub(ReleaseChecker, fn conn ->
+ Plug.Conn.send_resp(conn, 503, "")
+ end)
+
+ assert {:error, {:github_api, 503}} = ReleaseChecker.get_latest_release()
+ end
+
+ test "uses cached release info until invalidated" do
+ parent = self()
+
+ Req.Test.stub(ReleaseChecker, fn conn ->
+ send(parent, {:release_request, conn.request_path})
+
+ conn
+ |> Plug.Conn.put_resp_content_type("application/json")
+ |> Plug.Conn.send_resp(
+ 200,
+ Jason.encode!(%{
+ "tag_name" => "v9.9.9",
+ "assets" => []
+ })
+ )
+ end)
+
{:ok, release} = ReleaseChecker.get_latest_release()
+ assert {:ok, cached_release} = ReleaseChecker.get_latest_release()
- assert is_binary(release.version)
- assert is_list(release.assets)
-
- assert Enum.any?(release.assets, fn asset ->
- String.contains?(asset.name, "amd64")
- end)
-
- assert Enum.any?(release.assets, fn asset ->
- String.contains?(asset.name, "arm64")
- end)
+ assert release.version == "9.9.9"
+ assert cached_release == release
+ assert_receive {:release_request, "/repos/towerops-app/towerops-agent/releases/latest"}
+ refute_receive {:release_request, "/repos/towerops-app/towerops-agent/releases/latest"}
end
end
diff --git a/test/towerops/http_test.exs b/test/towerops/http_test.exs
new file mode 100644
index 00000000..bd30b9e2
--- /dev/null
+++ b/test/towerops/http_test.exs
@@ -0,0 +1,30 @@
+defmodule Towerops.HTTPTest do
+ use ExUnit.Case, async: true
+
+ alias Towerops.HTTP
+ alias Towerops.HTTPTest.ExplicitStub
+
+ test "returns an error when no test stub is configured" do
+ assert {:error, :no_test_stub} = HTTP.get(__MODULE__, "https://example.com")
+ end
+
+ test "uses Req.Test stubs in test mode" do
+ Req.Test.stub(__MODULE__, fn conn ->
+ conn
+ |> Plug.Conn.put_resp_content_type("application/json")
+ |> Plug.Conn.send_resp(200, Jason.encode!(%{"ok" => true}))
+ end)
+
+ assert {:ok, %Req.Response{status: 200, body: %{"ok" => true}}} =
+ HTTP.get(__MODULE__, "https://example.com")
+ end
+
+ test "respects an explicit plug override" do
+ Req.Test.stub(ExplicitStub, fn conn ->
+ Plug.Conn.send_resp(conn, 204, "")
+ end)
+
+ assert {:ok, %Req.Response{status: 204}} =
+ HTTP.get(__MODULE__, "https://example.com", plug: {Req.Test, ExplicitStub})
+ end
+end
diff --git a/test/towerops/maintenance/maintenance_window_test.exs b/test/towerops/maintenance/maintenance_window_test.exs
new file mode 100644
index 00000000..0fde30e2
--- /dev/null
+++ b/test/towerops/maintenance/maintenance_window_test.exs
@@ -0,0 +1,80 @@
+defmodule Towerops.Maintenance.MaintenanceWindowTest do
+ use ExUnit.Case, async: true
+
+ alias Towerops.Maintenance.MaintenanceWindow
+
+ describe "changeset/2" do
+ test "is valid with required attributes" do
+ changeset = MaintenanceWindow.changeset(%MaintenanceWindow{}, valid_attrs())
+
+ assert changeset.valid?
+ end
+
+ test "requires ends_at to be after starts_at" do
+ attrs =
+ valid_attrs(%{
+ ends_at: ~U[2026-03-10 10:00:00Z]
+ })
+
+ changeset = MaintenanceWindow.changeset(%MaintenanceWindow{}, attrs)
+
+ refute changeset.valid?
+ assert "must be after starts_at" in errors_on(changeset).ends_at
+ end
+
+ test "validates name length" do
+ changeset =
+ MaintenanceWindow.changeset(
+ %MaintenanceWindow{},
+ valid_attrs(%{name: String.duplicate("a", 256)})
+ )
+
+ refute changeset.valid?
+ assert "should be at most 255 character(s)" in errors_on(changeset).name
+ end
+
+ test "validates reason length" do
+ changeset =
+ MaintenanceWindow.changeset(
+ %MaintenanceWindow{},
+ valid_attrs(%{reason: String.duplicate("a", 1001)})
+ )
+
+ refute changeset.valid?
+ assert "should be at most 1000 character(s)" in errors_on(changeset).reason
+ end
+
+ test "allows ends_at to be absent while other required fields are present" do
+ attrs = Map.delete(valid_attrs(), :ends_at)
+
+ changeset = MaintenanceWindow.changeset(%MaintenanceWindow{}, attrs)
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).ends_at
+ end
+ end
+
+ defp valid_attrs(overrides \\ %{}) do
+ Map.merge(
+ %{
+ name: "Fiber maintenance",
+ reason: "Planned upgrade",
+ starts_at: ~U[2026-03-10 10:00:00Z],
+ ends_at: ~U[2026-03-10 11:00:00Z],
+ organization_id: Ecto.UUID.generate(),
+ created_by_id: Ecto.UUID.generate(),
+ suppress_alerts: true,
+ recurring: false
+ },
+ overrides
+ )
+ end
+
+ defp errors_on(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
+ opts |> Keyword.fetch!(String.to_existing_atom(key)) |> to_string()
+ end)
+ end)
+ end
+end
diff --git a/test/towerops/snmp/batch_insert_test.exs b/test/towerops/snmp/batch_insert_test.exs
index 335d5e3b..7035f9d6 100644
--- a/test/towerops/snmp/batch_insert_test.exs
+++ b/test/towerops/snmp/batch_insert_test.exs
@@ -1,5 +1,5 @@
defmodule Towerops.Snmp.BatchInsertTest do
- use Towerops.DataCase, async: true
+ use Towerops.DataCase, async: false
import Towerops.AccountsFixtures
diff --git a/test/towerops_web/components/consent_prompt_test.exs b/test/towerops_web/components/consent_prompt_test.exs
new file mode 100644
index 00000000..49710485
--- /dev/null
+++ b/test/towerops_web/components/consent_prompt_test.exs
@@ -0,0 +1,53 @@
+defmodule ToweropsWeb.Components.ConsentPromptTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.Component
+ import Phoenix.LiveViewTest
+
+ alias ToweropsWeb.Components.ConsentPrompt
+
+ describe "consent_modal/1" do
+ test "renders nothing when there are no policies" do
+ assigns = %{user_id: "user-1", policies: []}
+
+ html =
+ rendered_to_string(~H"""
+
+ """)
+
+ assert html == ""
+ end
+
+ test "renders translated consent checkboxes and links for known policies" do
+ assigns = %{user_id: "user-1", policies: ["privacy_policy", "terms_of_service"]}
+
+ html =
+ rendered_to_string(~H"""
+
+ """)
+
+ assert html =~ ~s(id="consent-modal")
+ assert html =~ ~s(phx-value-user-id="user-1")
+ assert html =~ ~s(id="consent-privacy_policy")
+ assert html =~ ~s(href="/privacy")
+ assert html =~ "Privacy Policy"
+ assert html =~ ~s(id="consent-terms_of_service")
+ assert html =~ ~s(href="/terms")
+ assert html =~ "Terms of Service"
+ assert html =~ "Accept and Continue"
+ end
+
+ test "falls back to the generic policy label and root link for unknown policies" do
+ assigns = %{user_id: "user-1", policies: ["custom_policy"]}
+
+ html =
+ rendered_to_string(~H"""
+
+ """)
+
+ assert html =~ ~s(id="consent-custom_policy")
+ assert html =~ ~s(href="/")
+ assert html =~ "Policy"
+ end
+ end
+end
diff --git a/test/towerops_web/telemetry_filter_test.exs b/test/towerops_web/telemetry_filter_test.exs
new file mode 100644
index 00000000..fa58bc91
--- /dev/null
+++ b/test/towerops_web/telemetry_filter_test.exs
@@ -0,0 +1,42 @@
+defmodule ToweropsWeb.TelemetryFilterTest do
+ use ExUnit.Case, async: true
+
+ alias ToweropsWeb.TelemetryFilter
+
+ require Logger
+
+ describe "filter_health_checks/2" do
+ test "stops logs for health check request paths" do
+ log_event = {:info, self(), {Logger, "request completed", {{2026, 3, 10}, {12, 0, 0, 0}}, request_path: "/health"}}
+
+ assert :stop = TelemetryFilter.filter_health_checks(log_event, [])
+ end
+
+ test "stops logs when phoenix logging is disabled" do
+ log_event = {:info, self(), {Logger, "request completed", {{2026, 3, 10}, {12, 0, 0, 0}}, phoenix_log: false}}
+
+ assert :stop = TelemetryFilter.filter_health_checks(log_event, [])
+ end
+
+ test "stops logs for matching message text" do
+ log_event = {:info, self(), {Logger, "HEAD / responded 200", {{2026, 3, 10}, {12, 0, 0, 0}}, []}}
+
+ assert :stop = TelemetryFilter.filter_health_checks(log_event, [])
+ end
+
+ test "ignores unrelated log events" do
+ log_event =
+ {:info, self(), {Logger, "GET /devices responded 200", {{2026, 3, 10}, {12, 0, 0, 0}}, request_path: "/devices"}}
+
+ assert :ignore = TelemetryFilter.filter_health_checks(log_event, [])
+ end
+
+ test "ignores unexpected log event shapes" do
+ assert :ignore = TelemetryFilter.filter_health_checks(:unexpected, [])
+ end
+ end
+
+ test "attach/0 returns ok" do
+ assert :ok = TelemetryFilter.attach()
+ end
+end