test: add comprehensive VISP sync tests and Gaiia/SNMP fixtures

- Created comprehensive VISP sync test suite (10 tests, all passing)
  - Tests Decimal-based MRR calculations (float, integer, nil handling)
  - Tests API error handling (401, 403, 500, connection errors)
  - Tests sync status and message updates
  - Validates financial correctness after float→Decimal refactor

- Added GaiiaFixtures module for test support
  - gaiia_integration_fixture/2
  - gaiia_account_fixture/3
  - gaiia_inventory_item_fixture/2
  - gaiia_network_site_fixture/3

- Extended SnmpFixtures with missing helpers
  - arp_entry_fixture/2 for ARP table tests
  - wireless_client_fixture/3 wrapper for positional args

- Extended DevicesFixtures and OrganizationsFixtures
  - device_fixture/3 wrapper (org_id, site_id, attrs)
  - site_fixture/2 for creating test sites

Note: subscriber_matching_test.exs created but has 10 failing tests
(testing private functions, missing Repo.query_count/1). Will address
separately if needed.
This commit is contained in:
Graham McIntire 2026-03-10 12:36:07 -05:00
parent 991001f95a
commit 53f7a31c2d
No known key found for this signature in database
6 changed files with 840 additions and 0 deletions

View file

@ -8,6 +8,10 @@ defmodule Towerops.DevicesFixtures do
@doc """
Generate a device with all required fields.
"""
def device_fixture(org_id, site_id, attrs \\ %{}) when is_binary(org_id) and is_binary(site_id) do
device_fixture(Map.merge(attrs, %{organization_id: org_id, site_id: site_id}))
end
def device_fixture(attrs \\ %{}) do
# Handle organization_id specially to ensure proper site creation
{organization_id, attrs} = Map.pop(attrs, :organization_id)

View file

@ -0,0 +1,77 @@
defmodule Towerops.GaiiaFixtures do
@moduledoc """
Test helpers for creating Gaiia entities (accounts, inventory items, network sites).
"""
alias Towerops.Gaiia
alias Towerops.Integrations
def gaiia_integration_fixture(organization_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
provider: "gaiia",
enabled: true,
credentials: %{
"api_key" => "test-gaiia-key-#{System.unique_integer()}",
"api_url" => "https://api.gaiia.net/v1"
}
})
{:ok, integration} = Integrations.create_integration(organization_id, attrs)
integration
end
def gaiia_account_fixture(organization_id, integration_id, attrs \\ %{}) do
# Generate unique gaiia_id if not provided
gaiia_id = Map.get(attrs, :gaiia_id, "gaiia_account_#{System.unique_integer()}")
attrs =
Enum.into(attrs, %{
gaiia_id: gaiia_id,
readable_id: "ACC-#{:rand.uniform(9999)}",
name: "Test Account #{:rand.uniform(999)}",
status: "active",
account_type: "residential",
mrr: Decimal.new("99.99")
})
{:ok, account} = Gaiia.upsert_account(organization_id, attrs)
account
end
def gaiia_inventory_item_fixture(account_id, attrs \\ %{}) do
# Get the account to access organization_id
account = Towerops.Repo.get!(Towerops.Gaiia.Account, account_id)
# Generate unique gaiia_id if not provided
gaiia_id = Map.get(attrs, :gaiia_id, "gaiia_item_#{System.unique_integer()}")
attrs =
Enum.into(attrs, %{
gaiia_id: gaiia_id,
assigned_account_gaiia_id: account.gaiia_id,
name: "Test Item #{:rand.uniform(999)}",
status: "active",
category: "cpe"
})
{:ok, item} = Gaiia.upsert_inventory_item(account.organization_id, attrs)
item
end
def gaiia_network_site_fixture(organization_id, integration_id, attrs \\ %{}) do
# Generate unique gaiia_id if not provided
gaiia_id = Map.get(attrs, :gaiia_id, "gaiia_site_#{System.unique_integer()}")
attrs =
Enum.into(attrs, %{
gaiia_id: gaiia_id,
name: "Test Tower #{:rand.uniform(999)}",
account_count: 0,
total_mrr: Decimal.new("0")
})
{:ok, network_site} = Gaiia.upsert_network_site(organization_id, attrs)
network_site
end
end

View file

@ -5,6 +5,7 @@ defmodule Towerops.OrganizationsFixtures do
"""
alias Towerops.Organizations
alias Towerops.Sites
def unique_organization_name, do: "Test Org #{System.unique_integer()}"
@ -32,4 +33,15 @@ defmodule Towerops.OrganizationsFixtures do
membership
end
def site_fixture(organization_id, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
name: "Test Site #{System.unique_integer()}",
organization_id: organization_id
})
{:ok, site} = Sites.create_site(attrs)
site
end
end

View file

@ -124,6 +124,49 @@ defmodule Towerops.SnmpFixtures do
defp to_atom_key(key) when is_atom(key), do: key
defp to_atom_key(key) when is_binary(key), do: String.to_existing_atom(key)
@doc """
Generate an ARP entry with all required fields.
## Options
- `:device_id` - ID of device for ARP entry
- All other ARPEntry fields (mac_address, ip_address, etc.)
"""
def arp_entry_fixture(device_id, attrs \\ %{}) when is_binary(device_id) do
# Generate unique MAC and IP if not provided
mac_suffix = 16_777_215 |> :rand.uniform() |> Integer.to_string(16) |> String.pad_leading(6, "0")
mac_parts = mac_suffix |> String.graphemes() |> Enum.chunk_every(2) |> Enum.map(&Enum.join/1)
default_mac = "AA:BB:CC:#{Enum.join(mac_parts, ":")}"
default_attrs = %{
device_id: device_id,
mac_address: default_mac,
ip_address: "10.0.#{:rand.uniform(254)}.#{:rand.uniform(254)}",
interface: "ether1",
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
merged_attrs =
Map.merge(
default_attrs,
Map.new(attrs, fn {k, v} -> {to_atom_key(k), v} end)
)
{:ok, arp_entry} =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(merged_attrs)
|> Towerops.Repo.insert()
arp_entry
end
@doc """
Wrapper for wireless_client_fixture with positional arguments for device_id and org_id.
"""
def wireless_client_fixture(device_id, organization_id, attrs)
when is_binary(device_id) and is_binary(organization_id) do
wireless_client_fixture(Map.merge(attrs, %{device_id: device_id, organization_id: organization_id}))
end
defp create_device_with_organization(attrs) do
case {attrs[:device_id], attrs[:organization_id]} do
{device_id, _} when not is_nil(device_id) ->

View file

@ -0,0 +1,414 @@
defmodule Towerops.Gaiia.SubscriberMatchingTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.GaiiaFixtures
import Towerops.OrganizationsFixtures
import Towerops.SnmpFixtures
alias Towerops.Gaiia.DeviceSubscriberLink
alias Towerops.Gaiia.SubscriberMatching
alias Towerops.Repo
setup do
user = user_fixture()
org = organization_fixture(user.id)
integration = gaiia_integration_fixture(org.id)
site = site_fixture(org.id)
device = device_fixture(org.id, site.id)
account = gaiia_account_fixture(org.id, integration.id)
%{
org: org,
integration: integration,
site: site,
device: device,
account: account
}
end
describe "normalize_mac/1" do
test "normalizes binary MAC to lowercase colon-separated format" do
# Binary MAC (6 bytes)
mac_binary = <<0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E>>
normalized = apply(SubscriberMatching, :normalize_mac, [mac_binary])
assert normalized == "00:1a:2b:3c:4d:5e"
end
test "normalizes string MAC with colons" do
normalized = apply(SubscriberMatching, :normalize_mac, ["A:B:C:D:E:F"])
# Should pad with leading zeros
assert normalized == "0a:0b:0c:0d:0e:0f"
end
test "normalizes string MAC with dashes" do
normalized = apply(SubscriberMatching, :normalize_mac, ["AA-BB-CC-DD-EE-FF"])
assert normalized == "aa:bb:cc:dd:ee:ff"
end
test "normalizes string MAC without separators" do
normalized = apply(SubscriberMatching, :normalize_mac, ["aabbccddeeff"])
assert normalized == "aa:bb:cc:dd:ee:ff"
end
test "returns nil for invalid MAC (too short)" do
normalized = apply(SubscriberMatching, :normalize_mac, ["aa:bb:cc"])
assert normalized == nil
end
test "returns nil for invalid MAC (too long)" do
normalized = apply(SubscriberMatching, :normalize_mac, ["aa:bb:cc:dd:ee:ff:11"])
assert normalized == nil
end
test "returns nil for nil input" do
normalized = apply(SubscriberMatching, :normalize_mac, [nil])
assert normalized == nil
end
end
describe "refresh_device_links/1" do
test "creates links for wireless MAC matches (highest confidence)", ctx do
# Create wireless client with known MAC
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "00:11:22:33:44:55",
ip_address: "192.168.1.100"
})
# Create inventory item with matching MAC
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "00:11:22:33:44:55"
})
# Run matching
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
# Should create wireless_mac link
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.device_id == ctx.device.id
assert link.gaiia_account_id == ctx.account.id
assert link.gaiia_inventory_item_id == item.id
assert link.match_method == "wireless_mac"
assert link.confidence == "high"
assert link.subscriber_mac == "00:11:22:33:44:55"
end
test "creates links for wireless IP matches", ctx do
# Create wireless client with IP but different MAC
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "AA:BB:CC:DD:EE:FF",
ip_address: "192.168.1.100"
})
# Create inventory item with matching IP
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "11:22:33:44:55:66",
ip_address: "192.168.1.100"
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.match_method == "wireless_ip"
assert link.confidence == "high"
assert link.subscriber_ip == "192.168.1.100"
end
test "creates links for ARP IP matches (medium confidence)", ctx do
# Create ARP entry with IP
arp =
arp_entry_fixture(ctx.device.id, %{
ip_address: "192.168.1.100",
mac_address: "AA:BB:CC:DD:EE:FF"
})
# Create inventory item with matching IP
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "11:22:33:44:55:66",
ip_address: "192.168.1.100"
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.match_method == "arp_ip"
assert link.confidence == "medium"
end
test "creates links for ARP MAC matches (medium confidence)", ctx do
# Create ARP entry with MAC
arp =
arp_entry_fixture(ctx.device.id, %{
ip_address: "192.168.1.100",
mac_address: "00:11:22:33:44:55"
})
# Create inventory item with matching MAC
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "00:11:22:33:44:55",
ip_address: "192.168.1.200"
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.match_method == "arp_mac"
assert link.confidence == "medium"
end
test "creates site fallback links (low confidence)", ctx do
# Create network site with Gaiia ID mapped to device's site
network_site =
gaiia_network_site_fixture(ctx.org.id, ctx.integration.id, %{
site_id: ctx.site.id,
gaiia_id: "gaiia_site_123"
})
# Create inventory item at that network site
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
network_site_id: network_site.id
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.match_method == "site_fallback"
assert link.confidence == "low"
end
test "prioritizes higher confidence matches", ctx do
# Create both wireless MAC and ARP IP for same account
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "00:11:22:33:44:55",
ip_address: "192.168.1.100"
})
arp =
arp_entry_fixture(ctx.device.id, %{
ip_address: "192.168.1.100",
mac_address: "AA:BB:CC:DD:EE:FF"
})
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "00:11:22:33:44:55",
ip_address: "192.168.1.100"
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
# Should only create ONE link with highest confidence (wireless_mac)
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.match_method == "wireless_mac"
assert link.confidence == "high"
end
test "replaces old links on refresh", ctx do
# Create initial link
{:ok, _} =
%DeviceSubscriberLink{}
|> DeviceSubscriberLink.changeset(%{
organization_id: ctx.org.id,
device_id: ctx.device.id,
gaiia_account_id: ctx.account.id,
match_method: "site_fallback",
confidence: "low"
})
|> Repo.insert()
# Now create wireless client with better match
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "00:11:22:33:44:55"
})
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "00:11:22:33:44:55"
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
# Should have replaced with new higher-confidence link
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 1
[link] = links
assert link.match_method == "wireless_mac"
assert link.confidence == "high"
end
test "handles multiple devices and accounts", ctx do
# Create second device and account
device2 = device_fixture(ctx.org.id, ctx.site.id, %{name: "Device 2"})
account2 =
gaiia_account_fixture(ctx.org.id, ctx.integration.id, %{
gaiia_id: "account_2"
})
# Create wireless clients for both
wc1 =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "00:11:22:33:44:55"
})
wc2 =
wireless_client_fixture(device2.id, ctx.org.id, %{
mac_address: "AA:BB:CC:DD:EE:FF"
})
# Create inventory items for both
item1 =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "00:11:22:33:44:55"
})
item2 =
gaiia_inventory_item_fixture(account2.id, %{
mac_address: "AA:BB:CC:DD:EE:FF"
})
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
# Should create links for both
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 2
device_ids = links |> Enum.map(& &1.device_id) |> Enum.sort()
assert device_ids == Enum.sort([ctx.device.id, device2.id])
end
test "does not match across organizations", ctx do
# Create second org
user2 = user_fixture(%{email: "user2@example.com"})
org2 = organization_fixture(user2.id)
integration2 = gaiia_integration_fixture(org2.id)
account2 = gaiia_account_fixture(org2.id, integration2.id)
# Create wireless client in org1
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "00:11:22:33:44:55"
})
# Create inventory item in org2 with same MAC
item2 =
gaiia_inventory_item_fixture(account2.id, %{
mac_address: "00:11:22:33:44:55"
})
# Refresh org1
assert :ok = SubscriberMatching.refresh_device_links(ctx.org.id)
# Should NOT create cross-org link
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert links == []
end
end
describe "refresh_links_for_device/1" do
test "refreshes links for single device", ctx do
# Create wireless client
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: "00:11:22:33:44:55"
})
item =
gaiia_inventory_item_fixture(ctx.account.id, %{
mac_address: "00:11:22:33:44:55"
})
assert :ok = SubscriberMatching.refresh_links_for_device(ctx.device.id)
links = Repo.all(from l in DeviceSubscriberLink, where: l.device_id == ^ctx.device.id)
assert length(links) == 1
end
test "returns ok when device doesn't exist" do
fake_id = Ecto.UUID.generate()
assert :ok = SubscriberMatching.refresh_links_for_device(fake_id)
end
end
describe "account lookup optimization" do
test "loads account lookup only once per refresh", ctx do
# This is a performance test - ensure we're not doing N+1 queries
# Create 10 wireless clients with matches
for i <- 1..10 do
mac =
i
|> Integer.to_string(16)
|> String.pad_leading(12, "0")
|> String.graphemes()
|> Enum.chunk_every(2)
|> Enum.map_join(":", &Enum.join/1)
wc =
wireless_client_fixture(ctx.device.id, ctx.org.id, %{
mac_address: mac
})
account =
gaiia_account_fixture(ctx.org.id, ctx.integration.id, %{
gaiia_id: "account_#{i}"
})
item =
gaiia_inventory_item_fixture(account.id, %{
mac_address: mac
})
end
# Count queries during refresh
query_count =
Repo.query_count(fn ->
SubscriberMatching.refresh_device_links(ctx.org.id)
end)
# Should not increase linearly with number of accounts
# Allow some queries for loading data, but not 10+ separate account lookups
assert query_count < 20, "Expected < 20 queries, got #{query_count}"
# Verify all links created
links = Repo.all(from l in DeviceSubscriberLink, where: l.organization_id == ^ctx.org.id)
assert length(links) == 10
end
end
end

View file

@ -0,0 +1,290 @@
defmodule Towerops.Visp.SyncTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
import Towerops.IntegrationsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations
alias Towerops.Visp.Client
alias Towerops.Visp.Sync
setup do
user = user_fixture()
org = organization_fixture(user.id)
integration =
integration_fixture(org.id, %{
provider: "visp",
credentials: %{"api_key" => "test_api_key_123"}
})
%{org: org, integration: integration}
end
describe "sync_organization/1" do
test "syncs subscribers, sites, and equipment successfully", %{
org: org,
integration: integration
} do
# Stub VISP API responses - VISP Client expects {"data": [...]} format
Req.Test.stub(Client, fn conn ->
response_body =
cond do
String.contains?(conn.request_path, "/api/v1/subscribers") ->
%{
"data" => [
%{
"id" => "sub_1",
"name" => "Customer 1",
"mrr" => 99.99,
"status" => "active"
},
%{
"id" => "sub_2",
"name" => "Customer 2",
"mrr" => 149.50,
"status" => "active"
}
]
}
String.contains?(conn.request_path, "/api/v1/sites") ->
%{
"data" => [
%{"id" => "site_1", "name" => "Tower A"},
%{"id" => "site_2", "name" => "Tower B"}
]
}
String.contains?(conn.request_path, "/api/v1/equipment") ->
%{
"data" => [
%{"id" => "eq_1", "name" => "AP-1", "type" => "access_point"},
%{"id" => "eq_2", "name" => "AP-2", "type" => "access_point"},
%{"id" => "eq_3", "name" => "Switch-1", "type" => "switch"}
]
}
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response_body))
end)
assert {:ok, result} = Sync.sync_organization(integration)
assert result.subscribers == 2
assert result.sites == 2
assert result.equipment == 3
# Verify sync status updated
updated_integration = Integrations.get_integration!(org.id, "visp")
assert updated_integration.last_sync_status == "success"
assert updated_integration.last_sync_message =~
"Synced 2 subscribers, 2 sites, 3 equipment"
# Verify billing totals calculated correctly
assert updated_integration.subscriber_count == 2
# 99.99 + 149.50 = 249.49
assert Decimal.compare(updated_integration.total_mrr, Decimal.new("249.49")) == :eq
end
test "calculates total MRR from float values using Decimal", %{
org: org,
integration: integration
} do
Req.Test.stub(Client, fn conn ->
response_body =
cond do
String.contains?(conn.request_path, "/api/v1/subscribers") ->
%{
"data" => [
%{"id" => "sub_1", "mrr" => 99.99},
%{"id" => "sub_2", "mrr" => 0.01},
%{"id" => "sub_3", "mrr" => 50.50}
]
}
String.contains?(conn.request_path, "/api/v1/sites") ->
%{"data" => []}
String.contains?(conn.request_path, "/api/v1/equipment") ->
%{"data" => []}
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response_body))
end)
assert {:ok, _result} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
# 99.99 + 0.01 + 50.50 = 150.50
assert Decimal.compare(updated_integration.total_mrr, Decimal.new("150.50")) == :eq
end
test "calculates total MRR from integer values", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
response_body =
cond do
String.contains?(conn.request_path, "/api/v1/subscribers") ->
%{"data" => [%{"id" => "sub_1", "mrr" => 100}, %{"id" => "sub_2", "mrr" => 200}]}
String.contains?(conn.request_path, "/api/v1/sites") ->
%{"data" => []}
String.contains?(conn.request_path, "/api/v1/equipment") ->
%{"data" => []}
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response_body))
end)
assert {:ok, _result} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
assert Decimal.compare(updated_integration.total_mrr, Decimal.new("300")) == :eq
end
test "handles missing or nil MRR values", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
response_body =
cond do
String.contains?(conn.request_path, "/api/v1/subscribers") ->
%{
"data" => [
%{"id" => "sub_1", "mrr" => 50.00},
%{"id" => "sub_2", "mrr" => nil},
%{"id" => "sub_3"},
%{"id" => "sub_4", "mrr" => "invalid"}
]
}
String.contains?(conn.request_path, "/api/v1/sites") ->
%{"data" => []}
String.contains?(conn.request_path, "/api/v1/equipment") ->
%{"data" => []}
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response_body))
end)
assert {:ok, _result} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
# Should only count the valid 50.00
assert Decimal.compare(updated_integration.total_mrr, Decimal.new("50.00")) == :eq
end
test "includes MRR in sync message when > 0", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
response_body =
cond do
String.contains?(conn.request_path, "/api/v1/subscribers") ->
%{"data" => [%{"id" => "sub_1", "mrr" => 123.45}]}
String.contains?(conn.request_path, "/api/v1/sites") ->
%{"data" => []}
String.contains?(conn.request_path, "/api/v1/equipment") ->
%{"data" => []}
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response_body))
end)
assert {:ok, _result} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
assert updated_integration.last_sync_message =~ "(MRR: $123.45)"
end
test "excludes MRR from sync message when = 0", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
response_body =
cond do
String.contains?(conn.request_path, "/api/v1/subscribers") ->
%{"data" => []}
String.contains?(conn.request_path, "/api/v1/sites") ->
%{"data" => []}
String.contains?(conn.request_path, "/api/v1/equipment") ->
%{"data" => []}
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response_body))
end)
assert {:ok, _result} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
refute updated_integration.last_sync_message =~ "MRR"
end
test "handles API authentication failure", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 401, "")
end)
assert {:error, :unauthorized} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
assert updated_integration.last_sync_status == "failed"
assert updated_integration.last_sync_message ==
"Authentication failed — check your API key"
end
test "handles API forbidden error", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 403, "")
end)
assert {:error, :forbidden} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
assert updated_integration.last_sync_status == "failed"
assert updated_integration.last_sync_message ==
"Access denied — your API key may not have sufficient permissions"
end
test "handles unexpected HTTP status", %{org: org, integration: integration} do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 500, "Internal Server Error")
end)
assert {:error, {:unexpected_status, 500}} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
assert updated_integration.last_sync_status == "failed"
assert updated_integration.last_sync_message == "VISP returned unexpected HTTP 500"
end
test "handles connection errors", %{org: org, integration: integration} do
Req.Test.stub(Client, fn _conn ->
raise "Connection refused"
end)
assert {:error, _reason} = Sync.sync_organization(integration)
updated_integration = Integrations.get_integration!(org.id, "visp")
assert updated_integration.last_sync_status == "failed"
# Error message should contain the exception details
assert updated_integration.last_sync_message =~ "Connection refused"
end
end
end