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:
parent
991001f95a
commit
53f7a31c2d
6 changed files with 840 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
77
test/support/fixtures/gaiia_fixtures.ex
Normal file
77
test/support/fixtures/gaiia_fixtures.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ->
|
||||
|
|
|
|||
414
test/towerops/gaiia/subscriber_matching_test.exs
Normal file
414
test/towerops/gaiia/subscriber_matching_test.exs
Normal 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
|
||||
290
test/towerops/visp/sync_test.exs
Normal file
290
test/towerops/visp/sync_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue