test: increase coverage for polling and Gaiia flows
Some checks failed
Production Deployment / Run ExUnit Tests (push) Failing after 2m10s
Production Deployment / Build and Push Docker Image (push) Has been skipped

This commit is contained in:
Graham McIntire 2026-07-26 09:57:09 -05:00
parent a1ea1ffb65
commit 213a6f2ef1
5 changed files with 811 additions and 0 deletions

View file

@ -0,0 +1,242 @@
defmodule Towerops.Gaiia.InventoryCreatorTest do
use Towerops.DataCase, async: false
import Towerops.DevicesFixtures
import Towerops.IntegrationsFixtures
import Towerops.SnmpFixtures
alias Towerops.Gaiia.InventoryCreator
alias Towerops.Snmp.Interface
setup do
previous_options = Application.get_env(:gaiia, :req_options)
Application.put_env(:gaiia, :req_options, plug: {Req.Test, __MODULE__}, retry: false)
on_exit(fn ->
if previous_options do
Application.put_env(:gaiia, :req_options, previous_options)
else
Application.delete_env(:gaiia, :req_options)
end
end)
device = device_fixture()
%{device: device, organization_id: device.organization_id}
end
describe "build_client/1" do
test "reports a missing integration", %{organization_id: organization_id} do
assert {:error, :no_gaiia_integration} = InventoryCreator.build_client(organization_id)
end
test "reports missing credentials", %{organization_id: organization_id} do
integration_fixture(organization_id, %{provider: "gaiia", credentials: %{}})
assert {:error, :no_api_key} = InventoryCreator.build_client(organization_id)
end
test "ignores disabled integrations", %{organization_id: organization_id} do
integration_fixture(organization_id, %{provider: "gaiia", enabled: false})
assert {:error, :no_gaiia_integration} = InventoryCreator.build_client(organization_id)
end
test "builds a client with the configured API key", %{organization_id: organization_id} do
integration_fixture(organization_id, %{
provider: "gaiia",
credentials: %{"api_key" => "secret-key"}
})
assert {:ok, client} = InventoryCreator.build_client(organization_id)
assert {"x-gaiia-api-key", "secret-key"} in client.headers
end
end
describe "list_manufacturers/1" do
test "deduplicates, removes missing manufacturers, and sorts by name", context do
create_gaiia_integration(context.organization_id)
stub_success(%{
"inventoryModels" => %{
"edges" => [
%{"node" => %{"manufacturer" => %{"id" => "m2", "name" => "Zeta"}}},
%{"node" => %{"manufacturer" => nil}},
%{"node" => %{"manufacturer" => %{"id" => "m1", "name" => "Alpha"}}},
%{"node" => %{"manufacturer" => %{"id" => "m1", "name" => "Alpha"}}}
]
}
})
assert {:ok, manufacturers} = InventoryCreator.list_manufacturers(context.organization_id)
assert Enum.map(manufacturers, & &1["id"]) == ["m1", "m2"]
end
test "maps API failures to a stable error", %{organization_id: organization_id} do
create_gaiia_integration(organization_id)
stub_http_error()
assert {:error, :gaiia_api_error} = InventoryCreator.list_manufacturers(organization_id)
end
end
describe "list_models/2" do
test "filters by manufacturer and sorts by model name", %{organization_id: organization_id} do
create_gaiia_integration(organization_id)
stub_success(%{
"inventoryModels" => %{
"edges" => [
model_edge("2", "Zulu", "wanted"),
model_edge("3", "Ignored", "other"),
model_edge("1", "Alpha", "wanted")
]
}
})
assert {:ok, models} = InventoryCreator.list_models(organization_id, "wanted")
assert Enum.map(models, & &1["id"]) == ["1", "2"]
end
test "maps API failures to a stable error", %{organization_id: organization_id} do
create_gaiia_integration(organization_id)
stub_http_error()
assert {:error, :gaiia_api_error} = InventoryCreator.list_models(organization_id, "manufacturer")
end
end
describe "create_inventory_item/4" do
test "creates an assigned item using the device MAC address", context do
create_gaiia_integration(context.organization_id)
add_interface(context.device, "AA:BB:CC:DD:EE:FF")
Req.Test.stub(__MODULE__, fn conn ->
body = read_json_body(conn)
if String.contains?(body["query"], "inventoryModels") do
graphql(conn, %{
"inventoryModels" => %{
"edges" => [
%{
"node" => %{
"fields" => %{
"edges" => [
%{"node" => %{"id" => "serial", "isMainIdentifier" => false}},
%{"node" => %{"id" => "mac-field", "isMainIdentifier" => true}}
]
}
}
}
]
}
})
else
input = body["variables"]["input"]
assert input["modelId"] == "model-1"
assert input["items"] == [%{"fields" => [%{"modelFieldId" => "mac-field", "data" => "AA:BB:CC:DD:EE:FF"}]}]
assert input["assignation"] == %{"assigneeId" => "site-1", "assigneeType" => "INVENTORY_LOCATION"}
graphql(conn, %{"startAddInventoryItemsJob" => "job-1"})
end
end)
assert {:ok, "job-1"} =
InventoryCreator.create_inventory_item(
context.organization_id,
context.device,
"model-1",
"site-1"
)
end
test "creates an unassigned item when no model fields are returned", context do
create_gaiia_integration(context.organization_id)
Req.Test.stub(__MODULE__, fn conn ->
body = read_json_body(conn)
if String.contains?(body["query"], "inventoryModels") do
graphql(conn, %{"inventoryModels" => %{"edges" => []}})
else
input = body["variables"]["input"]
assert input["items"] == [%{"fields" => []}]
refute Map.has_key?(input, "assignation")
graphql(conn, %{"startAddInventoryItemsJob" => %{"id" => "job-result"}})
end
end)
assert {:ok, %{"id" => "job-result"}} =
InventoryCreator.create_inventory_item(context.organization_id, context.device, "model-1")
end
test "returns an API error when model fields cannot be fetched", context do
create_gaiia_integration(context.organization_id)
stub_http_error()
assert {:error, :gaiia_api_error} =
InventoryCreator.create_inventory_item(context.organization_id, context.device, "model-1")
end
test "returns the mutation error", context do
create_gaiia_integration(context.organization_id)
Req.Test.stub(__MODULE__, fn conn ->
body = read_json_body(conn)
if String.contains?(body["query"], "inventoryModels") do
graphql(conn, %{
"inventoryModels" => %{
"edges" => [%{"node" => %{"fields" => %{"edges" => []}}}]
}
})
else
conn
|> Plug.Conn.put_status(500)
|> Req.Test.json(%{"error" => "failed"})
end
end)
assert {:error, %Gaiia.Error{kind: :http}} =
InventoryCreator.create_inventory_item(context.organization_id, context.device, "model-1")
end
end
defp create_gaiia_integration(organization_id) do
integration_fixture(organization_id, %{provider: "gaiia"})
end
defp model_edge(id, name, manufacturer_id) do
%{"node" => %{"id" => id, "name" => name, "manufacturer" => %{"id" => manufacturer_id}}}
end
defp add_interface(device, mac) do
snmp_device = snmp_device_fixture(%{device: device})
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_phys_address: mac
})
|> Repo.insert!()
end
defp stub_success(data) do
Req.Test.stub(__MODULE__, &graphql(&1, data))
end
defp stub_http_error do
Req.Test.stub(__MODULE__, fn conn ->
conn
|> Plug.Conn.put_status(500)
|> Req.Test.json(%{"error" => "failed"})
end)
end
defp graphql(conn, data), do: Req.Test.json(conn, %{"data" => data})
defp read_json_body(conn) do
{:ok, body, _conn} = Plug.Conn.read_body(conn)
Jason.decode!(body)
end
end

View file

@ -43,6 +43,24 @@ defmodule Towerops.GaiiaTest do
assert %{name: "Alice"} = Gaiia.get_account(org.id, "acct-1")
assert is_nil(Gaiia.get_account(org.id, "nonexistent"))
end
test "count_active_subscribers/1 counts accounts with subscriptions", %{org: org} do
{:ok, _} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "active",
name: "Active",
subscription_count: 2
})
{:ok, _} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "inactive",
name: "Inactive",
subscription_count: 0
})
assert Gaiia.count_active_subscribers(org.id) == 1
end
end
describe "network_sites" do
@ -71,6 +89,20 @@ defmodule Towerops.GaiiaTest do
{:ok, _} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 1"})
assert length(Gaiia.list_network_sites(org.id)) == 1
end
test "gets a network site by Gaiia ID and by mapped Towerops site", %{org: org} do
towerops_site = create_site(org.id)
{:ok, network_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "site-lookup",
name: "Lookup",
site_id: towerops_site.id
})
assert Gaiia.get_network_site(org.id, "site-lookup").id == network_site.id
assert Gaiia.get_network_site_for_site(towerops_site.id).id == network_site.id
end
end
describe "inventory_items" do
@ -96,6 +128,20 @@ defmodule Towerops.GaiiaTest do
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD"})
assert length(Gaiia.list_inventory_items(org.id)) == 1
end
test "gets an inventory item by Gaiia ID and mapped device", %{org: org} do
device = device_fixture(%{organization_id: org.id})
{:ok, item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "item-lookup",
name: "Lookup",
device_id: device.id
})
assert Gaiia.get_inventory_item(org.id, "item-lookup").id == item.id
assert Gaiia.get_inventory_item_for_device(device.id).id == item.id
end
end
describe "billing_subscriptions" do
@ -117,6 +163,29 @@ defmodule Towerops.GaiiaTest do
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-2"})
assert length(Gaiia.list_billing_subscriptions(org.id)) == 2
end
test "gets subscriptions and sums active MRR", %{org: org} do
{:ok, active} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "active-sub",
status: "ACTIVE",
mrr_amount: Decimal.new("42.50")
})
{:ok, _inactive} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "inactive-sub",
status: "CANCELLED",
mrr_amount: Decimal.new("100.00")
})
assert Gaiia.get_billing_subscription(org.id, "active-sub").id == active.id
assert Decimal.equal?(Gaiia.sum_active_mrr(org.id), Decimal.new("42.50"))
end
test "sum_active_mrr/1 returns zero when there are no subscriptions", %{org: org} do
assert Decimal.equal?(Gaiia.sum_active_mrr(org.id), Decimal.new(0))
end
end
defp create_site(organization_id) do
@ -172,6 +241,35 @@ defmodule Towerops.GaiiaTest do
end
end
describe "batch summaries" do
test "empty device and site lists return empty maps", %{org: org} do
assert Gaiia.get_device_subscriber_counts([]) == %{}
assert Gaiia.get_site_subscriber_summaries([]) == %{}
assert Gaiia.get_site_subscriber_summaries(:invalid) == %{}
assert Gaiia.get_device_impact(Ecto.UUID.generate()) == %{accounts: [], mrr: Decimal.new(0), subscriber_count: 0}
assert Gaiia.list_missing_subscribers(org.id) == []
end
test "returns mapped site summaries in one query", %{org: org} do
site = create_site(org.id)
{:ok, _network_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "batch-site",
name: "Batch",
site_id: site.id,
account_count: 7,
total_mrr: Decimal.new("80.00")
})
site_id = site.id
assert %{^site_id => summary} = Gaiia.get_site_subscriber_summaries([site.id])
assert summary.account_count == 7
assert Decimal.equal?(summary.total_mrr, Decimal.new("80.00"))
end
end
describe "get_devices_impact/1" do
test "deduplicates accounts across devices, preferring higher-confidence matches", %{org: org} do
site = create_site(org.id)

View file

@ -0,0 +1,78 @@
defmodule Towerops.StatusPages.StatusPageConfigTest do
use Towerops.DataCase, async: true
alias Towerops.StatusPages.StatusPageConfig
describe "changeset/2" do
test "accepts valid configuration" do
changeset =
StatusPageConfig.changeset(%StatusPageConfig{}, %{
slug: "network-status",
organization_id: Ecto.UUID.generate(),
custom_css: ".status { color: green; }"
})
assert changeset.valid?
end
test "requires a slug and organization" do
errors =
%StatusPageConfig{}
|> StatusPageConfig.changeset(%{})
|> errors_on()
assert "can't be blank" in errors.slug
assert "can't be blank" in errors.organization_id
end
test "validates slug format and length" do
organization_id = Ecto.UUID.generate()
for slug <- ["UPPERCASE", "-leading", "trailing-", "ab"] do
changeset =
StatusPageConfig.changeset(%StatusPageConfig{}, %{
slug: slug,
organization_id: organization_id
})
refute changeset.valid?
assert errors_on(changeset).slug != []
end
end
test "rejects external resources and HTML in custom CSS" do
organization_id = Ecto.UUID.generate()
unsafe_css = [
"body { background: URL(https://example.com/image.png); }",
"</style><div>unsafe</div>",
"</script>",
"<SCRIPT>alert('unsafe')</SCRIPT>"
]
for custom_css <- unsafe_css do
changeset =
StatusPageConfig.changeset(%StatusPageConfig{}, %{
slug: "network-status",
organization_id: organization_id,
custom_css: custom_css
})
refute changeset.valid?
assert errors_on(changeset).custom_css != []
end
end
test "does not revalidate unchanged custom CSS" do
config = %StatusPageConfig{custom_css: ".status { color: green; }"}
changeset =
StatusPageConfig.changeset(config, %{
slug: "network-status",
organization_id: Ecto.UUID.generate()
})
assert changeset.valid?
end
end
end

View file

@ -0,0 +1,310 @@
defmodule ToweropsWeb.GraphLive.PollingTest do
use Towerops.DataCase, async: false
import Mox
import Towerops.DevicesFixtures
import Towerops.SnmpFixtures
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SnmpMock
alias ToweropsWeb.GraphLive.Polling
setup :set_mox_global
setup :verify_on_exit!
describe "build_snmp_client_opts/1" do
test "builds client options from the device configuration" do
device =
device_fixture(%{
ip_address: "192.0.2.10",
snmp_community: "monitoring",
snmp_version: "2c",
snmp_port: 1161
})
assert [
ip: "192.0.2.10",
community: "monitoring",
version: "2c",
port: 1161,
timeout: 3_000
] = Polling.build_snmp_client_opts(device)
end
end
describe "poll_regular_sensors/3" do
test "returns rounded points and drops failed or non-numeric readings" do
snmp_device = snmp_device_fixture()
successful = insert_sensor(snmp_device, %{sensor_index: "1", sensor_divisor: 10})
_failed = insert_sensor(snmp_device, %{sensor_index: "2", sensor_oid: "1.3.6.1.4.1.999.2"})
_non_numeric = insert_sensor(snmp_device, %{sensor_index: "3", sensor_oid: "1.3.6.1.4.1.999.3"})
expect(SnmpMock, :get, 3, fn _target, oid, _opts ->
case oid do
"1.3.6.1.4.1.999.1" -> {:ok, {:integer, 215}}
"1.3.6.1.4.1.999.2" -> {:error, :timeout}
"1.3.6.1.4.1.999.3" -> {:ok, {:octet_string, "unknown"}}
end
end)
assigns = %{sensor_id: nil, sensor_type: "temperature", device_id: snmp_device.device_id}
assert [point] = Polling.poll_regular_sensors(assigns, client_opts(), 123_456)
assert point == %{sensor_id: successful.id, label: "Temperature", value: 21.5, timestamp: 123_456}
end
test "poll_sensors dispatches regular sensor types" do
snmp_device = snmp_device_fixture()
sensor = insert_sensor(snmp_device, %{sensor_divisor: 2})
expect(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, {:integer, 15}} end)
assigns = %{sensor_id: sensor.id, sensor_type: "temperature", device_id: snmp_device.device_id}
assert [%{value: 7.5}] = Polling.poll_sensors(assigns, client_opts(), 100, nil)
end
end
describe "poll_traffic_with_state/4" do
test "returns empty state for missing devices and devices without interfaces" do
assert {[], nil} =
Polling.poll_traffic_with_state(
%{device_id: Ecto.UUID.generate()},
client_opts(),
100,
nil
)
snmp_device = snmp_device_fixture()
assert {[], nil} =
Polling.poll_traffic_with_state(
%{device_id: snmp_device.device_id},
client_opts(),
100,
nil
)
end
test "captures initial counters and calculates aggregate traffic on the next poll" do
snmp_device = snmp_device_fixture()
interface = insert_interface(snmp_device)
expect_octet_counters(1_000, 2_000)
assigns = %{device_id: snmp_device.device_id}
assert {[], [current]} = Polling.poll_traffic_with_state(assigns, client_opts(), 100, nil)
assert current.interface_id == interface.id
assert current.interface_name == "ether1"
previous = [
%{
current
| if_in_octets: 500,
if_out_octets: 1_000,
polled_at: DateTime.add(current.polled_at, -10, :second)
}
]
expect_octet_counters(1_500, 3_000)
assert {[inbound, outbound], [_updated]} =
Polling.poll_traffic_with_state(assigns, client_opts(), 200, previous)
assert inbound.sensor_id == "traffic_in"
assert inbound.value > 0
assert outbound.sensor_id == "traffic_out"
assert outbound.value > inbound.value
end
test "falls back to 32-bit counters when high-capacity counters fail" do
snmp_device = snmp_device_fixture()
_interface = insert_interface(snmp_device)
expect(SnmpMock, :get, 3, fn _target, oid, _opts ->
cond do
String.contains?(oid, ".31.1.1.1.") -> {:error, :no_such_object}
String.contains?(oid, ".2.2.1.10.") -> {:ok, {:counter32, 100}}
String.contains?(oid, ".2.2.1.16.") -> {:ok, {:counter32, 200}}
end
end)
assert {[], [%{if_in_octets: 100, if_out_octets: 200}]} =
Polling.poll_traffic_with_state(
%{device_id: snmp_device.device_id},
client_opts(version: "1"),
100,
nil
)
end
end
describe "request_agent_live_poll/3" do
test "publishes requested OIDs and converts valid replies into points" do
snmp_device = snmp_device_fixture()
sensor = insert_sensor(snmp_device, %{sensor_divisor: 10})
agent_token_id = Ecto.UUID.generate()
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token_id}:live_poll")
task =
Task.async(fn ->
Polling.request_agent_live_poll(
snmp_device.device_id,
agent_token_id,
%{sensor_id: sensor.id}
)
end)
assert_receive {:live_poll_requested, device_id, [oid], reply_topic}
assert device_id == snmp_device.device_id
assert oid == sensor.sensor_oid
Phoenix.PubSub.broadcast(
Towerops.PubSub,
reply_topic,
{:live_poll_result, %{sensor.sensor_oid => "215.5"}}
)
assert [%{sensor_id: sensor_id, value: 21.6, timestamp: timestamp}] = Task.await(task)
assert sensor_id == sensor.id
assert is_integer(timestamp)
end
test "drops missing and unparsable values from agent replies" do
snmp_device = snmp_device_fixture()
sensor = insert_sensor(snmp_device)
agent_token_id = Ecto.UUID.generate()
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token_id}:live_poll")
task =
Task.async(fn ->
Polling.request_agent_live_poll(
snmp_device.device_id,
agent_token_id,
%{sensor_id: sensor.id}
)
end)
assert_receive {:live_poll_requested, _, _, reply_topic}
Phoenix.PubSub.broadcast(Towerops.PubSub, reply_topic, {:live_poll_result, %{sensor.sensor_oid => "bad"}})
assert [] == Task.await(task)
end
end
describe "get_sensors_for_live_mode/1" do
test "returns a specifically selected sensor" do
snmp_device = snmp_device_fixture()
sensor = insert_sensor(snmp_device, %{sensor_type: "temperature", sensor_index: "2"})
assert [selected] = Polling.get_sensors_for_live_mode(%{sensor_id: sensor.id})
assert selected.id == sensor.id
end
test "returns an empty list for a missing selected sensor" do
assert [] == Polling.get_sensors_for_live_mode(%{sensor_id: Ecto.UUID.generate()})
end
test "maps graph types, filters sensors, and sorts by index" do
snmp_device = snmp_device_fixture()
later = insert_sensor(snmp_device, %{sensor_type: "cpu_temperature", sensor_index: "20"})
earlier = insert_sensor(snmp_device, %{sensor_type: "temperature", sensor_index: "10"})
_other = insert_sensor(snmp_device, %{sensor_type: "voltage", sensor_index: "1"})
sensors =
Polling.get_sensors_for_live_mode(%{
sensor_id: nil,
sensor_type: "temperature",
device_id: snmp_device.device_id
})
assert Enum.map(sensors, & &1.id) == [earlier.id, later.id]
end
test "returns no sensors for traffic, unknown devices, or unknown sensors" do
snmp_device = snmp_device_fixture()
assert [] ==
Polling.get_sensors_for_live_mode(%{
sensor_id: nil,
sensor_type: "traffic",
device_id: snmp_device.device_id
})
assert [] ==
Polling.get_sensors_for_live_mode(%{
sensor_id: nil,
sensor_type: "temperature",
device_id: Ecto.UUID.generate()
})
end
end
describe "update_buffer/4" do
test "prepends points into independent sensor buffers" do
points = [
%{sensor_id: "temperature", timestamp: 100, value: 21.5},
%{sensor_id: "voltage", timestamp: 100, value: 48.2}
]
assert {buffer, 1} = Polling.update_buffer(%{}, 0, points, 3)
assert buffer["temperature"] == [%{x: 100, y: 21.5}]
assert buffer["voltage"] == [%{x: 100, y: 48.2}]
end
test "trims each sensor buffer and caps the count" do
buffer = %{
sensor: [
%{x: 2, y: 2.0},
%{x: 1, y: 1.0},
%{x: 0, y: 0.0}
]
}
points = [%{sensor_id: :sensor, timestamp: 3, value: 3.0}]
assert {%{sensor: values}, 3} = Polling.update_buffer(buffer, 3, points, 3)
assert values == [%{x: 3, y: 3.0}, %{x: 2, y: 2.0}, %{x: 1, y: 1.0}]
end
test "increments the polling count even when no points arrive" do
assert {%{}, 2} = Polling.update_buffer(%{}, 1, [], 3)
end
end
defp insert_sensor(snmp_device, attrs \\ %{}) do
defaults = %{
snmp_device_id: snmp_device.id,
sensor_index: "1",
sensor_type: "temperature",
sensor_descr: "Temperature",
sensor_oid: "1.3.6.1.4.1.999.1",
sensor_divisor: 1
}
%Sensor{}
|> Sensor.changeset(Map.merge(defaults, attrs))
|> Repo.insert!()
end
defp insert_interface(snmp_device) do
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "ether1"
})
|> Repo.insert!()
end
defp expect_octet_counters(in_octets, out_octets) do
expect(SnmpMock, :get, 2, fn _target, oid, _opts ->
if String.contains?(oid, ".1.10."), do: {:ok, {:counter64, out_octets}}, else: {:ok, {:counter64, in_octets}}
end)
end
defp client_opts(overrides \\ []) do
Keyword.merge(
[ip: "192.0.2.10", community: "public", version: "2c", port: 161, timeout: 3_000],
overrides
)
end
end

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
setup :register_and_log_in_user
@ -33,4 +34,86 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLiveTest do
assert html =~ "Gaiia"
end
test "refreshes and attempts automatic matching", %{conn: conn, org: org} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
render_hook(view, "refresh", %{})
render_hook(view, "auto_match", %{})
assert has_element?(view, "h1")
end
test "opens and cancels the link search for an untracked device", %{conn: conn, org: org} do
device = device_fixture(%{organization_id: org.id})
{:ok, view, _html} =
live(
conn,
~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=untracked"
)
render_hook(view, "start_link_search", %{"device_id" => device.id})
assert has_element?(view, "#search-gaiia-items-form-#{device.id}")
render_hook(view, "cancel_link_search", %{})
refute has_element?(view, "#search-gaiia-items-form-#{device.id}")
end
test "short searches return no matches", %{conn: conn, org: org} do
device = device_fixture(%{organization_id: org.id})
{:ok, view, _html} =
live(
conn,
~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=untracked"
)
render_hook(view, "start_link_search", %{"device_id" => device.id})
render_hook(view, "search_gaiia_items", %{"query" => "x"})
assert has_element?(
view,
"#search-gaiia-items-form-#{device.id} input[name=query][value=x]"
)
end
test "clears manufacturer selection and cancels create state", %{conn: conn, org: org} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
render_hook(view, "select_manufacturer", %{"manufacturer_id" => ""})
render_hook(view, "cancel_create", %{})
assert has_element?(view, "h1")
end
test "rejects creation without a model and handles a missing device", %{conn: conn, org: org} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
render_hook(view, "create_gaiia_item", %{
"device_id" => Ecto.UUID.generate(),
"model_id" => ""
})
render_hook(view, "create_gaiia_item", %{
"device_id" => Ecto.UUID.generate(),
"model_id" => "model-1"
})
assert has_element?(view, "h1")
end
test "handles attempts to link or unlink missing inventory items", %{conn: conn, org: org} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation")
render_hook(view, "link_to_gaiia", %{
"device_id" => Ecto.UUID.generate(),
"gaiia_id" => "missing"
})
render_hook(view, "unlink_ghost", %{"gaiia_id" => "missing"})
assert has_element?(view, "h1")
end
end