more tests

This commit is contained in:
Graham McIntire 2026-02-03 10:19:56 -06:00
parent 5b84844e97
commit 1ce3763e5f
No known key found for this signature in database
8 changed files with 2047 additions and 7 deletions

View file

@ -81,7 +81,7 @@ defmodule Mix.Tasks.ImportProfiles do
end
defp import_all_profiles(source_path) do
Logger.info("Discovering YAML files in #{source_path}...")
Mix.shell().info("Discovering YAML files in #{source_path}...")
detection_path = Path.join(source_path, "os_detection")
@ -91,29 +91,29 @@ defmodule Mix.Tasks.ImportProfiles do
|> Path.wildcard()
|> Enum.sort()
Logger.info("Found #{length(yaml_files)} YAML files")
Mix.shell().info("Found #{length(yaml_files)} YAML files")
profile_names = Enum.map(yaml_files, &Path.basename(&1, ".yaml"))
import_profiles_by_name(source_path, profile_names)
end
defp import_specific_profiles(source_path, profiles) do
Logger.info("Importing #{length(profiles)} specified profiles")
Mix.shell().info("Importing #{length(profiles)} specified profiles")
import_profiles_by_name(source_path, profiles)
end
defp import_profiles_by_name(source_path, profile_names) do
results =
Enum.map(profile_names, fn profile_name ->
Logger.info("Importing profile: #{profile_name}")
Mix.shell().info("Importing profile: #{profile_name}")
case import_profile(source_path, profile_name) do
{:ok, _profile} ->
Logger.info("#{profile_name}: created/updated successfully")
Mix.shell().info("#{profile_name}: created/updated successfully")
{:ok, profile_name}
{:error, reason} ->
Logger.error("#{profile_name}: #{inspect(reason)}")
Mix.shell().error("#{profile_name}: #{inspect(reason)}")
{:error, profile_name, reason}
end
end)
@ -121,7 +121,7 @@ defmodule Mix.Tasks.ImportProfiles do
successes = Enum.count(results, &match?({:ok, _}, &1))
failures = Enum.count(results, &match?({:error, _, _}, &1))
Logger.info("Import complete: #{successes} succeeded, #{failures} failed")
Mix.shell().info("Import complete: #{successes} succeeded, #{failures} failed")
end
defp import_profile(source_path, profile_name) do

View file

@ -40,6 +40,18 @@ defmodule Mix.Tasks.Oban.CancelStuckDiscovery do
1
:ok ->
# Some versions of Oban return :ok instead of {:ok, job}
Logger.info(
"Cancelled stuck discovery job",
job_id: job.id,
device_id: get_in(job.args, ["device_id"]),
state: job.state,
attempted_at: job.attempted_at
)
1
{:error, reason} ->
Logger.error(
"Failed to cancel job",

View file

@ -0,0 +1,473 @@
defmodule Mix.Tasks.GenVendorModulesTest do
use ExUnit.Case, async: false
import ExUnit.CaptureIO
alias Mix.Tasks.GenVendorModules
@moduletag :mix_task
setup do
# Save original directory
original_dir = File.cwd!()
# Create temporary directories for profiles and vendor modules
temp_dir = Path.join(System.tmp_dir!(), "gen_vendor_modules_#{System.unique_integer([:positive])}")
profiles_dir = Path.join([temp_dir, "priv", "profiles", "os_detection"])
vendors_dir = Path.join([temp_dir, "lib", "towerops", "snmp", "profiles", "vendors"])
File.mkdir_p!(profiles_dir)
File.mkdir_p!(vendors_dir)
on_exit(fn ->
File.cd!(original_dir)
File.rm_rf!(temp_dir)
end)
{:ok, temp_dir: temp_dir, profiles_dir: profiles_dir, vendors_dir: vendors_dir}
end
describe "run/1 with dry-run" do
test "reports vendors that would be generated in dry-run mode", %{
temp_dir: temp_dir,
profiles_dir: profiles_dir,
vendors_dir: _vendors_dir
} do
# Create profile YAML with group
File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """
os: mikrotik
group: mikrotik
text: MikroTik
discovery:
- sysObjectID:
- .1.3.6.1.4.1.14988.
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run(["--dry-run"])
end)
assert output =~ "Found 1 YAML profiles"
assert output =~ "Grouped into 1 vendors"
assert output =~ "1 new vendors to generate"
assert output =~ "mikrotik.ex (Mikrotik)"
assert output =~ "Profiles: mikrotik"
assert output =~ "Enterprise OID: 1.3.6.1.4.1.14988"
end)
end
test "reports no work needed when all vendors exist", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
# Create profile YAML
File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """
os: mikrotik
group: mikrotik
text: MikroTik
""")
# Create existing vendor module
File.write!(Path.join(vendors_dir, "mikrotik.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do
def profile_names, do: ["mikrotik"]
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run(["--dry-run"])
end)
assert output =~ "0 new vendors to generate"
assert output =~ "No new vendors to generate!"
end)
end
end
describe "run/1 without dry-run" do
test "generates vendor module from profile with group", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "cisco-ios.yaml"), """
os: cisco-ios
group: cisco
text: Cisco IOS
discovery:
- sysObjectID:
- .1.3.6.1.4.1.9.
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/cisco.ex"
assert output =~ "Generated 1 vendor modules"
# Verify file was created
vendor_file = Path.join(vendors_dir, "cisco.ex")
assert File.exists?(vendor_file)
# Verify file content
content = File.read!(vendor_file)
assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.Cisco do"
assert content =~ "@moduledoc"
assert content =~ "SNMP profile for Cisco devices"
assert content =~ "@behaviour Towerops.Snmp.Profiles.Vendors.Vendor"
assert content =~ "def profile_names, do: [\"cisco-ios\"]"
assert content =~ "# Enterprise OID: 1.3.6.1.4.1.9"
assert content =~ "def detect_hardware(client_opts)"
assert content =~ "def wireless_oid_defs"
assert content =~ "def discover_wireless_sensors(client_opts)"
end)
end
test "generates vendor module from profile without group using filename inference", %{
temp_dir: temp_dir,
profiles_dir: profiles_dir,
vendors_dir: vendors_dir
} do
File.write!(Path.join(profiles_dir, "cisco_ios.yaml"), """
os: cisco-ios
text: Cisco IOS
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
# Should infer cisco from filename
assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/cisco.ex"
vendor_file = Path.join(vendors_dir, "cisco.ex")
assert File.exists?(vendor_file)
end)
end
test "groups multiple profiles into single vendor module", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "cisco-ios.yaml"), """
os: cisco-ios
group: cisco
text: Cisco IOS
""")
File.write!(Path.join(profiles_dir, "cisco-nxos.yaml"), """
os: cisco-nxos
group: cisco
text: Cisco NX-OS
""")
File.write!(Path.join(profiles_dir, "cisco-asa.yaml"), """
os: cisco-asa
group: cisco
text: Cisco ASA
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
assert output =~ "Generated 1 vendor modules"
vendor_file = Path.join(vendors_dir, "cisco.ex")
content = File.read!(vendor_file)
# Should include all three profiles
assert content =~ ~s(["cisco-asa", "cisco-ios", "cisco-nxos"])
end)
end
test "generates modules for multiple vendors", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """
os: mikrotik
group: mikrotik
text: MikroTik
""")
File.write!(Path.join(profiles_dir, "ubnt.yaml"), """
os: ubnt
group: ubiquiti
text: Ubiquiti
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
assert output =~ "Generated 2 vendor modules"
assert File.exists?(Path.join(vendors_dir, "mikrotik.ex"))
assert File.exists?(Path.join(vendors_dir, "ubiquiti.ex"))
end)
end
test "generates module with specific group filter", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """
os: mikrotik
group: mikrotik
text: MikroTik
""")
File.write!(Path.join(profiles_dir, "cisco.yaml"), """
os: cisco
group: cisco
text: Cisco
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run(["--group", "mikrotik"])
end)
assert output =~ "Generated 1 vendor modules"
assert File.exists?(Path.join(vendors_dir, "mikrotik.ex"))
refute File.exists?(Path.join(vendors_dir, "cisco.ex"))
end)
end
test "handles vendor names starting with numbers", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "4rf.yaml"), """
os: 4rf
group: 4rf
text: 4RF
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
# Should prefix with "n" for filename and "N" for module
assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/n4rf.ex"
vendor_file = Path.join(vendors_dir, "n4rf.ex")
assert File.exists?(vendor_file)
content = File.read!(vendor_file)
assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.N4rf do"
end)
end
test "handles vendor names with special characters", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "device.yaml"), """
os: test-device
group: hp-aruba
text: HP Aruba
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
# Should convert to snake_case for filename
assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/hp_aruba.ex"
vendor_file = Path.join(vendors_dir, "hp_aruba.ex")
assert File.exists?(vendor_file)
content = File.read!(vendor_file)
assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.HpAruba do"
end)
end
test "extracts enterprise OID from sysObjectID", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "test.yaml"), """
os: test
group: testvendor
text: Test Vendor
discovery:
- sysObjectID:
- .1.3.6.1.4.1.12345.1.2.3
""")
File.cd!(temp_dir, fn ->
capture_io(fn ->
GenVendorModules.run([])
end)
vendor_file = Path.join(vendors_dir, "testvendor.ex")
content = File.read!(vendor_file)
assert content =~ "# Enterprise OID: 1.3.6.1.4.1.12345"
end)
end
test "handles profile without enterprise OID", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "generic.yaml"), """
os: generic
group: generic
text: Generic Device
""")
File.cd!(temp_dir, fn ->
capture_io(fn ->
GenVendorModules.run([])
end)
vendor_file = Path.join(vendors_dir, "generic.ex")
content = File.read!(vendor_file)
assert content =~ "# Enterprise OID: unknown - uses standard MIB OIDs"
end)
end
test "includes standard CPU sensor in wireless_oid_defs", %{
profiles_dir: profiles_dir,
vendors_dir: vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "test.yaml"), """
os: test
group: test
text: Test
""")
File.cd!(temp_dir, fn ->
capture_io(fn ->
GenVendorModules.run([])
end)
vendor_file = Path.join(vendors_dir, "test.ex")
content = File.read!(vendor_file)
assert content =~ "wireless_oid_defs do"
assert content =~ "1.3.6.1.2.1.25.3.3.1.2.1"
assert content =~ "CPU Load"
assert content =~ "sensor_type: \"load\""
end)
end
test "skips profiles that cannot be grouped to a vendor", %{
profiles_dir: profiles_dir,
vendors_dir: _vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "unknown_device.yaml"), """
os: unknown-device
text: Unknown Device
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
assert output =~ "0 new vendors to generate"
assert output =~ "No new vendors to generate!"
end)
end
test "includes instructions after generation", %{
profiles_dir: profiles_dir,
vendors_dir: _vendors_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(profiles_dir, "test.yaml"), """
os: test
group: test
text: Test
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
assert output =~ "Run `mix compile` to check for errors"
assert output =~ "Run `mix test` to verify"
assert output =~ "Add aliases to registry.ex"
assert output =~ "Add vendors to @vendors list in registry.ex"
assert output =~ "Write tests for each vendor"
assert output =~ "Add vendor-specific OIDs if available"
end)
end
end
describe "run/1 error handling" do
test "skips profiles with invalid YAML", %{profiles_dir: profiles_dir, vendors_dir: vendors_dir, temp_dir: temp_dir} do
File.write!(Path.join(profiles_dir, "valid.yaml"), """
os: valid
group: valid
text: Valid
""")
File.write!(Path.join(profiles_dir, "invalid.yaml"), """
this is: [invalid yaml that won't parse
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
# Should still generate valid vendor
assert output =~ "Generated 1 vendor modules"
assert File.exists?(Path.join(vendors_dir, "valid.ex"))
end)
end
test "skips profiles without os field", %{profiles_dir: profiles_dir, vendors_dir: _vendors_dir, temp_dir: temp_dir} do
File.write!(Path.join(profiles_dir, "no_os.yaml"), """
group: test
text: Test
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorModules.run([])
end)
assert output =~ "0 new vendors to generate"
end)
end
end
end

View file

@ -0,0 +1,335 @@
defmodule Mix.Tasks.GenVendorTestsTest do
use ExUnit.Case, async: false
import ExUnit.CaptureIO
alias Mix.Tasks.GenVendorTests
@moduletag :mix_task
setup do
# Save original directory
original_dir = File.cwd!()
# Create temporary directories for vendor modules and tests
temp_dir = Path.join(System.tmp_dir!(), "vendor_tests_#{System.unique_integer([:positive])}")
vendors_dir = Path.join(temp_dir, "lib/towerops/snmp/profiles/vendors")
tests_dir = Path.join(temp_dir, "test/towerops/snmp/profiles/vendors")
File.mkdir_p!(vendors_dir)
File.mkdir_p!(tests_dir)
on_exit(fn ->
File.cd!(original_dir)
File.rm_rf!(temp_dir)
end)
{:ok, temp_dir: temp_dir, vendors_dir: vendors_dir, tests_dir: tests_dir}
end
describe "run/1 with dry-run" do
test "reports vendors without tests in dry-run mode", %{
vendors_dir: vendors_dir,
tests_dir: _tests_dir,
temp_dir: temp_dir
} do
# Create vendor module
File.write!(Path.join(vendors_dir, "mikrotik.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
@impl true
def profile_names, do: ["mikrotik"]
end
""")
# Change to temp directory to run task
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run(["--dry-run"])
end)
assert output =~ "Found 1 vendor modules"
assert output =~ "Found 0 existing tests"
assert output =~ "1 vendors need tests"
assert output =~ "Would generate: test/towerops/snmp/profiles/vendors/mikrotik_test.exs"
end)
end
test "reports no work needed when all vendors have tests", %{
vendors_dir: vendors_dir,
tests_dir: tests_dir,
temp_dir: temp_dir
} do
# Create vendor module
File.write!(Path.join(vendors_dir, "mikrotik.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do
def profile_names, do: ["mikrotik"]
end
""")
# Create corresponding test
File.write!(Path.join(tests_dir, "mikrotik_test.exs"), """
defmodule Towerops.Snmp.Profiles.Vendors.MikrotikTest do
use ExUnit.Case
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run(["--dry-run"])
end)
assert output =~ "All vendor modules have tests!"
end)
end
end
describe "run/1 without dry-run" do
test "generates test file for vendor without test", %{
vendors_dir: vendors_dir,
tests_dir: tests_dir,
temp_dir: temp_dir
} do
# Create vendor module with profile_names
File.write!(Path.join(vendors_dir, "cisco.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Cisco do
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
@impl true
def profile_names, do: ["cisco-ios", "cisco-nxos"]
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run([])
end)
assert output =~ "Generated: test/towerops/snmp/profiles/vendors/cisco_test.exs"
assert output =~ "Generated 1 test files"
# Verify test file was created
test_file = Path.join(tests_dir, "cisco_test.exs")
assert File.exists?(test_file)
content = File.read!(test_file)
assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.CiscoTest do"
assert content =~ "use Towerops.DataCase, async: true"
assert content =~ "alias Towerops.Snmp.Profiles.Vendors.Cisco"
assert content =~ ~s{profile_names() == ["cisco-ios", "cisco-nxos"]}
assert content =~ "describe \"profile_names/0\" do"
assert content =~ "describe \"detect_hardware/1\" do"
assert content =~ "describe \"wireless_oid_defs/0\" do"
assert content =~ "describe \"discover_wireless_sensors/1\" do"
end)
end
test "generates tests for multiple vendors", %{vendors_dir: vendors_dir, tests_dir: tests_dir, temp_dir: temp_dir} do
# Create multiple vendor modules
File.write!(Path.join(vendors_dir, "mikrotik.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do
def profile_names, do: ["mikrotik"]
end
""")
File.write!(Path.join(vendors_dir, "ubiquiti.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Ubiquiti do
def profile_names, do: ["ubnt"]
end
""")
File.write!(Path.join(vendors_dir, "cisco.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Cisco do
def profile_names, do: ["cisco"]
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run([])
end)
assert output =~ "Generated 3 test files"
assert File.exists?(Path.join(tests_dir, "mikrotik_test.exs"))
assert File.exists?(Path.join(tests_dir, "ubiquiti_test.exs"))
assert File.exists?(Path.join(tests_dir, "cisco_test.exs"))
end)
end
test "skips vendors that already have tests", %{vendors_dir: vendors_dir, tests_dir: tests_dir, temp_dir: temp_dir} do
# Create vendor modules
File.write!(Path.join(vendors_dir, "mikrotik.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do
def profile_names, do: ["mikrotik"]
end
""")
File.write!(Path.join(vendors_dir, "cisco.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Cisco do
def profile_names, do: ["cisco"]
end
""")
# Create test for mikrotik (but not cisco)
File.write!(Path.join(tests_dir, "mikrotik_test.exs"), """
defmodule Towerops.Snmp.Profiles.Vendors.MikrotikTest do
use ExUnit.Case
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run([])
end)
assert output =~ "Found 2 vendor modules"
assert output =~ "Found 1 existing tests"
assert output =~ "1 vendors need tests"
assert output =~ "Generated 1 test files"
# Only cisco test should be generated
assert File.exists?(Path.join(tests_dir, "cisco_test.exs"))
# Mikrotik test should be the old one (not regenerated)
mikrotik_test = File.read!(Path.join(tests_dir, "mikrotik_test.exs"))
assert mikrotik_test =~ "use ExUnit.Case"
refute mikrotik_test =~ "use Towerops.DataCase"
end)
end
test "excludes registry and vendor files from generation", %{
vendors_dir: vendors_dir,
tests_dir: tests_dir,
temp_dir: temp_dir
} do
# Create vendor modules
File.write!(Path.join(vendors_dir, "registry.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Registry do
end
""")
File.write!(Path.join(vendors_dir, "vendor.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Vendor do
end
""")
File.write!(Path.join(vendors_dir, "mikrotik.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do
def profile_names, do: ["mikrotik"]
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run([])
end)
# Should only count mikrotik as vendor module
assert output =~ "Found 1 vendor modules"
assert output =~ "Generated 1 test files"
# Only mikrotik test should be generated
assert File.exists?(Path.join(tests_dir, "mikrotik_test.exs"))
refute File.exists?(Path.join(tests_dir, "registry_test.exs"))
refute File.exists?(Path.join(tests_dir, "vendor_test.exs"))
end)
end
test "handles vendor module without profile_names function", %{
vendors_dir: vendors_dir,
tests_dir: tests_dir,
temp_dir: temp_dir
} do
# Create vendor module without profile_names
File.write!(Path.join(vendors_dir, "custom.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.Custom do
# No profile_names function
end
""")
File.cd!(temp_dir, fn ->
output =
capture_io(fn ->
GenVendorTests.run([])
end)
assert output =~ "Generated 1 test files"
# Test should be generated with empty profile list
test_file = Path.join(tests_dir, "custom_test.exs")
assert File.exists?(test_file)
content = File.read!(test_file)
assert content =~ "assert Custom.profile_names() == []"
end)
end
test "generates proper module name for snake_case files", %{
vendors_dir: vendors_dir,
tests_dir: tests_dir,
temp_dir: temp_dir
} do
# Create vendor module with snake_case filename
File.write!(Path.join(vendors_dir, "palo_alto.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.PaloAlto do
def profile_names, do: ["panos"]
end
""")
File.cd!(temp_dir, fn ->
_output =
capture_io(fn ->
GenVendorTests.run([])
end)
# Test file should be palo_alto_test.exs
test_file = Path.join(tests_dir, "palo_alto_test.exs")
assert File.exists?(test_file)
# Module should be PaloAltoTest
content = File.read!(test_file)
assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.PaloAltoTest do"
assert content =~ "alias Towerops.Snmp.Profiles.Vendors.PaloAlto"
end)
end
test "generates tests with proper expect counts for wireless_oid_defs", %{
vendors_dir: vendors_dir,
tests_dir: tests_dir,
temp_dir: temp_dir
} do
# Create vendor module with multiple OID defs
File.write!(Path.join(vendors_dir, "test_vendor.ex"), """
defmodule Towerops.Snmp.Profiles.Vendors.TestVendor do
def profile_names, do: ["test"]
def wireless_oid_defs do
[
%{oid: "1.2.3.4.0", sensor_descr: "Temp", sensor_type: "temperature", sensor_unit: "C", sensor_divisor: 1},
%{oid: "1.2.3.5.0", sensor_descr: "Voltage", sensor_type: "voltage", sensor_unit: "V", sensor_divisor: 1},
%{oid: "1.2.3.6.0", sensor_descr: "Power", sensor_type: "power", sensor_unit: "W", sensor_divisor: 1}
]
end
end
""")
File.cd!(temp_dir, fn ->
capture_io(fn ->
GenVendorTests.run([])
end)
test_file = Path.join(tests_dir, "test_vendor_test.exs")
content = File.read!(test_file)
# Should verify list has >= 1 items (not hardcoded count)
assert content =~ "assert length(defs) >= 1"
end)
end
end
end

View file

@ -0,0 +1,312 @@
defmodule Mix.Tasks.Geoip.ImportTest do
use Towerops.DataCase, async: true
import ExUnit.CaptureIO
import Mix.Tasks.Geoip.Import, only: [load_locations: 1, load_blocks: 2, run: 1]
alias Towerops.GeoIP.Block
alias Towerops.GeoIP.Location
alias Towerops.Repo
setup do
# Create temporary directory for test CSV files
temp_dir = Path.join(System.tmp_dir!(), "geoip_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn ->
File.rm_rf!(temp_dir)
end)
{:ok, temp_dir: temp_dir}
end
describe "load_locations/1" do
test "imports locations from CSV file", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
6167865,en,NA,North America,CA,Canada,ON,Ontario,,,Toronto,0,America/Toronto,0
2643743,en,EU,Europe,GB,United Kingdom,ENG,England,,,London,0,Europe/London,1
""")
{count, geoname_ids} = load_locations(csv_file)
assert count == 3
assert MapSet.size(geoname_ids) == 3
assert MapSet.member?(geoname_ids, 5_391_959)
assert MapSet.member?(geoname_ids, 6_167_865)
assert MapSet.member?(geoname_ids, 2_643_743)
# Verify database records
locations = Repo.all(Location)
assert length(locations) == 3
san_francisco = Enum.find(locations, &(&1.geoname_id == 5_391_959))
assert san_francisco.country_code == "US"
assert san_francisco.country_name == "United States"
assert san_francisco.city_name == "San Francisco"
assert san_francisco.subdivision_1_name == "California"
end
test "skips locations without country code", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
9999999,en,NA,North America,,,,,,,,0,,0
""")
{count, geoname_ids} = load_locations(csv_file)
assert count == 1
assert MapSet.size(geoname_ids) == 1
assert MapSet.member?(geoname_ids, 5_391_959)
end
test "skips locations without geoname_id", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
,en,NA,North America,CA,Canada,ON,Ontario,,,Toronto,0,America/Toronto,0
""")
{count, geoname_ids} = load_locations(csv_file)
assert count == 1
assert MapSet.size(geoname_ids) == 1
end
test "handles empty optional fields", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,,,,,,,,America/Los_Angeles,0
""")
{count, _geoname_ids} = load_locations(csv_file)
assert count == 1
location = Repo.get_by!(Location, geoname_id: 5_391_959)
assert location.country_name == nil
assert location.city_name == nil
assert location.subdivision_1_name == nil
assert location.subdivision_2_name == nil
end
test "processes large batches correctly", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
# Generate 10,000 locations (more than batch size of 5,000)
header =
"geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union\n"
rows =
Enum.map(1..10_000, fn i ->
"#{i},en,NA,North America,US,United States,CA,California,,,City#{i},807,America/Los_Angeles,0"
end)
File.write!(csv_file, header <> Enum.join(rows, "\n") <> "\n")
{count, geoname_ids} = load_locations(csv_file)
assert count == 10_000
assert MapSet.size(geoname_ids) == 10_000
assert Repo.aggregate(Location, :count) == 10_000
end
end
describe "load_blocks/2" do
test "imports IP blocks from CSV file", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
# First, create some locations
location1 = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
location2 = insert(:geoip_location, geoname_id: 6_167_865, country_code: "CA")
valid_geoname_ids = MapSet.new([location1.geoname_id, location2.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
1.0.1.0/24,6167865,6167865,,,0,M5H,43.6532,-79.3832,1000
1.0.2.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 3
# Verify database records
blocks = Repo.all(Block)
assert length(blocks) == 3
block1 = Enum.find(blocks, &(&1.network == "1.0.0.0/24"))
assert block1.geoname_id == 5_391_959
assert block1.start_ip_int == 16_777_216
assert block1.end_ip_int == 16_777_471
end
test "filters out blocks with invalid geoname_id", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
1.0.1.0/24,9999999,9999999,,,0,M5H,43.6532,-79.3832,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 1
assert Repo.aggregate(Block, :count) == 1
end
test "uses registered_country_geoname_id as fallback", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,,5391959,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 1
block = Repo.one!(Block)
assert block.geoname_id == 5_391_959
assert block.registered_country_geoname_id == 5_391_959
end
test "skips blocks with no valid geoname_id", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
valid_geoname_ids = MapSet.new()
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,,,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 0
assert Repo.aggregate(Block, :count) == 0
end
test "calculates IP ranges correctly", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
192.168.1.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
10.0.0.0/8,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
172.16.0.0/12,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 3
blocks = Repo.all(Block)
# 192.168.1.0/24 = 192.168.1.0 to 192.168.1.255
block1 = Enum.find(blocks, &(&1.network == "192.168.1.0/24"))
assert block1.start_ip_int == 3_232_235_776
assert block1.end_ip_int == 3_232_236_031
# 10.0.0.0/8 = 10.0.0.0 to 10.255.255.255
block2 = Enum.find(blocks, &(&1.network == "10.0.0.0/8"))
assert block2.start_ip_int == 167_772_160
assert block2.end_ip_int == 184_549_375
# 172.16.0.0/12 = 172.16.0.0 to 172.31.255.255
block3 = Enum.find(blocks, &(&1.network == "172.16.0.0/12"))
assert block3.start_ip_int == 2_886_729_728
assert block3.end_ip_int == 2_887_778_303
end
test "processes large batches correctly", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
# Generate 10,000 blocks (more than batch size of 5,000)
header =
"network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius\n"
rows =
Enum.map(1..10_000, fn i ->
# Use sequential /32 blocks
octet1 = div(i, 256 * 256)
octet2 = rem(div(i, 256), 256)
octet3 = rem(i, 256)
"#{octet1}.#{octet2}.#{octet3}.0/32,5391959,5391959,,,0,94102,37.7749,-122.4194,1000"
end)
File.write!(csv_file, header <> Enum.join(rows, "\n") <> "\n")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 10_000
assert Repo.aggregate(Block, :count) == 10_000
end
end
describe "run/1 with invalid arguments" do
test "shows usage help when no directory is provided" do
output = capture_io(:stderr, fn -> run([]) end)
assert output =~ "Usage: mix geoip.import <directory>"
end
test "shows usage help when too many arguments are provided" do
output = capture_io(:stderr, fn -> run(["dir1", "dir2"]) end)
assert output =~ "Usage: mix geoip.import <directory>"
end
test "requires TOWEROPS_KEY env var for production mode" do
System.delete_env("TOWEROPS_KEY")
assert catch_exit(
capture_io(:stderr, fn ->
run(["~/geoip", "--production"])
end)
) == {:shutdown, 1}
end
end
# Helper to insert location for tests
defp insert(:geoip_location, attrs) do
defaults = %{
country_code: "US",
country_name: "United States",
city_name: "Test City",
subdivision_1_name: nil,
subdivision_2_name: nil,
latitude: nil,
longitude: nil,
inserted_at: DateTime.truncate(DateTime.utc_now(), :second),
updated_at: DateTime.truncate(DateTime.utc_now(), :second)
}
attrs = Map.merge(defaults, Map.new(attrs))
%Location{}
|> Ecto.Changeset.change(attrs)
|> Repo.insert!()
end
end

View file

@ -0,0 +1,455 @@
defmodule Mix.Tasks.ImportProfilesTest do
use Towerops.DataCase, async: true
import ExUnit.CaptureIO
alias Mix.Tasks.ImportProfiles
alias Towerops.Profiles
alias Towerops.Profiles.DeviceOid
alias Towerops.Profiles.SensorOid
alias Towerops.Repo
setup do
# Create temporary directory for test YAML files
temp_dir = Path.join(System.tmp_dir!(), "profiles_test_#{System.unique_integer([:positive])}")
detection_dir = Path.join([temp_dir, "os_detection"])
discovery_dir = Path.join([temp_dir, "os_discovery"])
File.mkdir_p!(detection_dir)
File.mkdir_p!(discovery_dir)
on_exit(fn ->
File.rm_rf!(temp_dir)
end)
{:ok, temp_dir: temp_dir, detection_dir: detection_dir, discovery_dir: discovery_dir}
end
describe "run/1 with valid profiles" do
test "imports a basic profile from YAML", %{
detection_dir: detection_dir,
discovery_dir: discovery_dir,
temp_dir: temp_dir
} do
# Create a simple detection file
detection_yaml = Path.join(detection_dir, "mikrotik.yaml")
File.write!(detection_yaml, """
os: mikrotik
text: MikroTik
priority: 100
discovery:
- sysDescr:
- RouterOS
- sysObjectID:
- .1.3.6.1.4.1.14988.
""")
# Create a simple discovery file
discovery_yaml = Path.join(discovery_dir, "mikrotik.yaml")
File.write!(discovery_yaml, """
modules:
os:
serial: SNMPv2-MIB::sysContact.0
version: SNMPv2-MIB::sysName.0
sensors:
temperature:
data:
- oid: MIKROTIK-MIB::mtxrHlTemperature.0
descr: System Temperature
unit: C
divisor: 10
""")
output =
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
assert output =~ "✓ mikrotik: created/updated successfully"
assert output =~ "Import complete: 1 succeeded, 0 failed"
# Verify profile was created
profile = Profiles.get_profile("mikrotik")
assert profile
assert profile.vendor == "MikroTik"
assert profile.priority == 100
assert profile.enabled == true
# Verify device OIDs were created
device_oids = Repo.all(from o in DeviceOid, where: o.profile_id == ^profile.id)
assert length(device_oids) == 2
serial_oid = Enum.find(device_oids, &(&1.field == "serial_number"))
assert serial_oid.mib_name == "SNMPv2-MIB::sysContact.0"
version_oid = Enum.find(device_oids, &(&1.field == "firmware_version"))
assert version_oid.mib_name == "SNMPv2-MIB::sysName.0"
# Verify sensor OIDs were created
sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id)
assert length(sensor_oids) == 1
temp_sensor = List.first(sensor_oids)
assert temp_sensor.sensor_type == "temperature"
assert temp_sensor.mib_name == "MIKROTIK-MIB::mtxrHlTemperature.0"
assert temp_sensor.sensor_descr == "System Temperature"
assert temp_sensor.sensor_unit == "C"
assert temp_sensor.sensor_divisor == 10
end
test "updates existing profile", %{detection_dir: detection_dir, temp_dir: temp_dir} do
# Create initial profile
{:ok, profile} =
Profiles.create_profile(%{
name: "cisco",
vendor: "Cisco Old",
detection_pattern: "old pattern",
priority: 50,
enabled: true
})
# Create YAML file with updated info
detection_yaml = Path.join(detection_dir, "cisco.yaml")
File.write!(detection_yaml, """
os: cisco
text: Cisco Updated
priority: 200
discovery:
- sysDescr:
- Cisco IOS
""")
output =
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
assert output =~ "✓ cisco: created/updated successfully"
# Verify profile was updated
updated_profile = Profiles.get_profile("cisco")
assert updated_profile.id == profile.id
assert updated_profile.vendor == "Cisco Updated"
assert updated_profile.priority == 200
end
test "imports specific profiles only", %{detection_dir: detection_dir, temp_dir: temp_dir} do
# Create multiple detection files
File.write!(Path.join(detection_dir, "mikrotik.yaml"), """
os: mikrotik
text: MikroTik
priority: 100
""")
File.write!(Path.join(detection_dir, "cisco.yaml"), """
os: cisco
text: Cisco
priority: 100
""")
File.write!(Path.join(detection_dir, "ubiquiti.yaml"), """
os: ubiquiti
text: Ubiquiti
priority: 100
""")
output =
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir, "--profiles", "mikrotik,cisco"])
end)
assert output =~ "Importing 2 specified profiles"
assert output =~ "✓ mikrotik"
assert output =~ "✓ cisco"
refute output =~ "ubiquiti"
# Verify only specified profiles were created
assert Profiles.get_profile("mikrotik")
assert Profiles.get_profile("cisco")
assert Profiles.get_profile("ubiquiti") == nil
end
test "handles profile without discovery file", %{detection_dir: detection_dir, temp_dir: temp_dir} do
detection_yaml = Path.join(detection_dir, "generic.yaml")
File.write!(detection_yaml, """
os: generic
text: Generic Device
priority: 1
""")
output =
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
assert output =~ "✓ generic: created/updated successfully"
profile = Profiles.get_profile("generic")
assert profile
assert profile.vendor == "Generic Device"
# Should have no device OIDs or sensor OIDs
assert Repo.aggregate(from(o in DeviceOid, where: o.profile_id == ^profile.id), :count) == 0
assert Repo.aggregate(from(o in SensorOid, where: o.profile_id == ^profile.id), :count) == 0
end
test "extracts detection patterns from sysDescr", %{detection_dir: detection_dir, temp_dir: temp_dir} do
detection_yaml = Path.join(detection_dir, "test.yaml")
File.write!(detection_yaml, """
os: test
text: Test Device
discovery:
- sysDescr:
- "Test Pattern 1"
- "Test Pattern 2"
""")
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
profile = Profiles.get_profile("test")
assert profile.detection_pattern == "Test Pattern 1"
end
test "extracts detection OID from sysObjectID", %{detection_dir: detection_dir, temp_dir: temp_dir} do
detection_yaml = Path.join(detection_dir, "test.yaml")
File.write!(detection_yaml, """
os: test
text: Test Device
discovery:
- sysObjectID:
- ".1.3.6.1.4.1.9."
- ".1.3.6.1.4.1.10."
""")
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
profile = Profiles.get_profile("test")
assert profile.detection_oid == ".1.3.6.1.4.1.9."
end
test "imports all standard device OID fields", %{
detection_dir: detection_dir,
discovery_dir: discovery_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(detection_dir, "test.yaml"), """
os: test
text: Test Device
""")
File.write!(Path.join(discovery_dir, "test.yaml"), """
modules:
os:
serial: DEVICE-MIB::serialNumber.0
version: DEVICE-MIB::firmwareVersion.0
hardware: DEVICE-MIB::hardwareModel.0
lat: DEVICE-MIB::latitude.0
long: DEVICE-MIB::longitude.0
features: DEVICE-MIB::features.0
sysName: SNMPv2-MIB::sysName.0
""")
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
profile = Profiles.get_profile("test")
device_oids = Repo.all(from o in DeviceOid, where: o.profile_id == ^profile.id)
assert length(device_oids) == 7
assert Enum.find(device_oids, &(&1.field == "serial_number")).mib_name == "DEVICE-MIB::serialNumber.0"
assert Enum.find(device_oids, &(&1.field == "firmware_version")).mib_name == "DEVICE-MIB::firmwareVersion.0"
assert Enum.find(device_oids, &(&1.field == "hardware")).mib_name == "DEVICE-MIB::hardwareModel.0"
assert Enum.find(device_oids, &(&1.field == "latitude")).mib_name == "DEVICE-MIB::latitude.0"
assert Enum.find(device_oids, &(&1.field == "longitude")).mib_name == "DEVICE-MIB::longitude.0"
assert Enum.find(device_oids, &(&1.field == "features")).mib_name == "DEVICE-MIB::features.0"
assert Enum.find(device_oids, &(&1.field == "sys_name_oid")).mib_name == "SNMPv2-MIB::sysName.0"
end
test "imports all sensor types", %{detection_dir: detection_dir, discovery_dir: discovery_dir, temp_dir: temp_dir} do
File.write!(Path.join(detection_dir, "test.yaml"), """
os: test
text: Test Device
""")
File.write!(Path.join(discovery_dir, "test.yaml"), """
modules:
sensors:
temperature:
data:
- oid: DEVICE-MIB::temp.0
descr: Temperature
unit: C
voltage:
data:
- oid: DEVICE-MIB::voltage.0
descr: Voltage
unit: V
current:
data:
- oid: DEVICE-MIB::current.0
descr: Current
unit: A
power:
data:
- oid: DEVICE-MIB::power.0
descr: Power
unit: W
fanspeed:
data:
- oid: DEVICE-MIB::fan.0
descr: Fan Speed
unit: RPM
""")
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
profile = Profiles.get_profile("test")
sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id)
assert length(sensor_oids) == 5
assert Enum.any?(sensor_oids, &(&1.sensor_type == "temperature"))
assert Enum.any?(sensor_oids, &(&1.sensor_type == "voltage"))
assert Enum.any?(sensor_oids, &(&1.sensor_type == "current"))
assert Enum.any?(sensor_oids, &(&1.sensor_type == "power"))
assert Enum.any?(sensor_oids, &(&1.sensor_type == "fanspeed"))
end
test "skips table sensors with {{ $index }}", %{
detection_dir: detection_dir,
discovery_dir: discovery_dir,
temp_dir: temp_dir
} do
File.write!(Path.join(detection_dir, "test.yaml"), """
os: test
text: Test Device
""")
File.write!(Path.join(discovery_dir, "test.yaml"), """
modules:
sensors:
temperature:
data:
- oid: DEVICE-MIB::temp.0
descr: Scalar Temperature
unit: C
- oid: DEVICE-MIB::tempTable
num_oid: .1.3.6.1.4.1.9999.1.2.{{ $index }}
descr: Table Temperature
unit: C
""")
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
profile = Profiles.get_profile("test")
sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id)
# Only scalar sensor should be imported
assert length(sensor_oids) == 1
assert List.first(sensor_oids).sensor_descr == "Scalar Temperature"
end
test "replaces existing OIDs when reimporting", %{
detection_dir: detection_dir,
discovery_dir: discovery_dir,
temp_dir: temp_dir
} do
# Create initial profile with OIDs
{:ok, profile} = Profiles.create_profile(%{name: "test", vendor: "Test", priority: 100, enabled: true})
{:ok, _} = Profiles.add_device_oid(profile.id, "serial_number", "OLD-MIB::serial.0")
{:ok, _} =
Profiles.add_sensor_oid(profile.id, %{
sensor_type: "temperature",
mib_name: "OLD-MIB::temp.0",
sensor_descr: "Old Temp",
sensor_unit: "C",
sensor_divisor: 1
})
# Create new YAML with different OIDs
File.write!(Path.join(detection_dir, "test.yaml"), """
os: test
text: Test Device
""")
File.write!(Path.join(discovery_dir, "test.yaml"), """
modules:
os:
serial: NEW-MIB::serialNumber.0
sensors:
voltage:
data:
- oid: NEW-MIB::voltage.0
descr: New Voltage
unit: V
""")
capture_io(fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
# Old OIDs should be replaced
device_oids = Repo.all(from o in DeviceOid, where: o.profile_id == ^profile.id)
assert length(device_oids) == 1
assert List.first(device_oids).mib_name == "NEW-MIB::serialNumber.0"
sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id)
assert length(sensor_oids) == 1
assert List.first(sensor_oids).mib_name == "NEW-MIB::voltage.0"
assert List.first(sensor_oids).sensor_type == "voltage"
end
end
describe "run/1 error handling" do
test "raises error when source directory doesn't exist" do
assert_raise RuntimeError, "Source directory not found: /nonexistent/path", fn ->
capture_io(fn ->
ImportProfiles.run(["--source-path", "/nonexistent/path"])
end)
end
end
test "reports failed imports", %{detection_dir: detection_dir, temp_dir: temp_dir} do
# Create invalid YAML
File.write!(Path.join(detection_dir, "invalid.yaml"), """
this is not: [valid yaml: that will parse
""")
output =
capture_io([capture_prompt: false], fn ->
ImportProfiles.run(["--source-path", temp_dir])
end)
assert output =~ "✗ invalid" or output =~ "Import complete: 0 succeeded, 1 failed"
assert output =~ "Import complete: 0 succeeded, 1 failed"
end
test "uses default source path when not provided" do
# This will fail because priv/profiles doesn't have test data,
# but we can verify it tries to use the right path
output =
capture_io(fn ->
ImportProfiles.run([])
end)
# Should try to load from default location
assert output =~ "Discovering YAML files" or output =~ "Found 0 YAML files" or output =~ "succeeded"
end
end
end

View file

@ -0,0 +1,206 @@
defmodule Mix.Tasks.Oban.CancelStuckDiscoveryTest do
use Towerops.DataCase, async: true
import ExUnit.CaptureIO
import Mix.Tasks.Oban.CancelStuckDiscovery, only: [run: 1]
alias Towerops.Repo
describe "run/1" do
test "cancels stuck discovery jobs in scheduled state" do
# Insert a scheduled discovery job
{:ok, job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
assert job.state == "available"
# Change state to scheduled
job = Repo.update!(Ecto.Changeset.change(job, state: "scheduled"))
output = capture_io(fn -> run([]) end)
# Verify job was cancelled
updated_job = Repo.reload(job)
assert updated_job.state == "cancelled"
assert output =~ "Cancelled 1 stuck discovery jobs"
end
test "cancels stuck discovery jobs in retryable state" do
{:ok, job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
# Change state to retryable
job = Repo.update!(Ecto.Changeset.change(job, state: "retryable"))
output = capture_io(fn -> run([]) end)
updated_job = Repo.reload(job)
assert updated_job.state == "cancelled"
assert output =~ "Cancelled 1 stuck discovery jobs"
end
test "cancels stuck discovery jobs in executing state" do
{:ok, job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
# Change state to executing
job =
Repo.update!(
Ecto.Changeset.change(job,
state: "executing",
attempted_at: DateTime.utc_now()
)
)
output = capture_io(fn -> run([]) end)
updated_job = Repo.reload(job)
assert updated_job.state == "cancelled"
assert output =~ "Cancelled 1 stuck discovery jobs"
end
test "cancels multiple stuck discovery jobs" do
device_id1 = Ecto.UUID.generate()
device_id2 = Ecto.UUID.generate()
device_id3 = Ecto.UUID.generate()
# Create multiple stuck jobs
{:ok, job1} =
%{device_id: device_id1}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
{:ok, job2} =
%{device_id: device_id2}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
{:ok, job3} =
%{device_id: device_id3}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
# Make them stuck
Repo.update!(Ecto.Changeset.change(job1, state: "scheduled"))
Repo.update!(Ecto.Changeset.change(job2, state: "retryable"))
Repo.update!(Ecto.Changeset.change(job3, state: "executing", attempted_at: DateTime.utc_now()))
output = capture_io(fn -> run([]) end)
# Verify all were cancelled
assert Repo.reload(job1).state == "cancelled"
assert Repo.reload(job2).state == "cancelled"
assert Repo.reload(job3).state == "cancelled"
assert output =~ "Cancelled 3 stuck discovery jobs"
end
test "does not cancel completed discovery jobs" do
{:ok, job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
# Change state to completed
job = Repo.update!(Ecto.Changeset.change(job, state: "completed"))
output = capture_io(fn -> run([]) end)
# Job should remain completed
updated_job = Repo.reload(job)
assert updated_job.state == "completed"
assert output =~ "Cancelled 0 stuck discovery jobs"
end
test "does not cancel cancelled discovery jobs" do
{:ok, job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
# Change state to cancelled
job = Repo.update!(Ecto.Changeset.change(job, state: "cancelled"))
output = capture_io(fn -> run([]) end)
# Job should remain cancelled
updated_job = Repo.reload(job)
assert updated_job.state == "cancelled"
assert output =~ "Cancelled 0 stuck discovery jobs"
end
test "does not cancel jobs from other workers" do
{:ok, discovery_job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
{:ok, poller_job} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DevicePollerWorker", queue: :pollers)
|> Repo.insert()
# Make both stuck
discovery_job = Repo.update!(Ecto.Changeset.change(discovery_job, state: "scheduled"))
poller_job = Repo.update!(Ecto.Changeset.change(poller_job, state: "scheduled"))
output = capture_io(fn -> run([]) end)
# Only discovery job should be cancelled
assert Repo.reload(discovery_job).state == "cancelled"
assert Repo.reload(poller_job).state == "scheduled"
assert output =~ "Cancelled 1 stuck discovery jobs"
end
test "reports zero cancellations when no stuck jobs exist" do
output = capture_io(fn -> run([]) end)
assert output =~ "Cancelled 0 stuck discovery jobs"
end
test "handles jobs with no device_id in args" do
{:ok, job} =
%{some_other_field: "value"}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
job = Repo.update!(Ecto.Changeset.change(job, state: "scheduled"))
output = capture_io(fn -> run([]) end)
updated_job = Repo.reload(job)
assert updated_job.state == "cancelled"
assert output =~ "Cancelled 1 stuck discovery jobs"
end
test "continues on cancellation failure for one job" do
# Create two stuck jobs
{:ok, job1} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
{:ok, job2} =
%{device_id: Ecto.UUID.generate()}
|> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery)
|> Repo.insert()
job1 = Repo.update!(Ecto.Changeset.change(job1, state: "scheduled"))
job2 = Repo.update!(Ecto.Changeset.change(job2, state: "scheduled"))
# Delete job1 to simulate a cancellation failure (job not found)
Repo.delete!(job1)
output = capture_io(fn -> run([]) end)
# job2 should still be cancelled
assert Repo.reload(job2).state == "cancelled"
# Should report 1 successful cancellation (job2 only)
assert output =~ "Cancelled 1 stuck discovery jobs"
end
end
end

View file

@ -0,0 +1,247 @@
defmodule Mix.Tasks.PopulateEnglishTest do
use ExUnit.Case, async: false
import ExUnit.CaptureIO
import Mix.Tasks.PopulateEnglish, only: [run: 1]
setup do
# Create temporary directory for test files
temp_dir = Path.join(System.tmp_dir!(), "populate_english_test_#{System.unique_integer([:positive])}")
po_dir = Path.join([temp_dir, "priv", "gettext", "en", "LC_MESSAGES"])
File.mkdir_p!(po_dir)
on_exit(fn ->
File.rm_rf!(temp_dir)
end)
{:ok, temp_dir: temp_dir, po_dir: po_dir}
end
describe "run/1" do
test "populates empty msgstr with msgid", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid "Hello"
msgstr ""
msgid "Goodbye"
msgstr ""
""")
# Change to temp directory so the task finds the files
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
assert content =~ ~s(msgid "Hello"\nmsgstr "Hello")
assert content =~ ~s(msgid "Goodbye"\nmsgstr "Goodbye")
end
test "preserves existing non-empty msgstr", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid "Hello"
msgstr "Hola"
msgid "Goodbye"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
assert content =~ ~s(msgid "Hello"\nmsgstr "Hola")
assert content =~ ~s(msgid "Goodbye"\nmsgstr "Goodbye")
end
test "handles multiline msgid and msgstr", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid ""
"This is a "
"multiline string"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
assert content =~ ~s(msgstr ""\n"This is a "\n"multiline string")
end
test "preserves comments and metadata", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
# Comment line
## Another comment
msgid "Hello"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
assert content =~ "# Comment line"
assert content =~ "## Another comment"
assert content =~ ~s(msgid "Hello"\nmsgstr "Hello")
end
test "skips plural forms", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid "One item"
msgid_plural "%{count} items"
msgstr[0] ""
msgstr[1] ""
msgid "Regular message"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
# Plural forms should not be modified
assert content =~ ~s(msgstr[0] "")
assert content =~ ~s(msgstr[1] "")
# Regular message should be populated
assert content =~ ~s(msgid "Regular message"\nmsgstr "Regular message")
end
test "handles empty msgid", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid ""
msgstr ""
msgid "Hello"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
# Empty msgid should not be populated
lines = String.split(content, "\n")
empty_msgid_index = Enum.find_index(lines, &(&1 == ~s(msgid "")))
assert Enum.at(lines, empty_msgid_index + 1) == ~s(msgstr "")
# Non-empty msgid should be populated
assert content =~ ~s(msgid "Hello"\nmsgstr "Hello")
end
test "handles escaped characters in strings", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid "Line one\\nLine two"
msgstr ""
msgid "Tab\\there"
msgstr ""
msgid "Quote \\"here\\""
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
capture_io(fn -> run([]) end)
end)
content = File.read!(po_file)
assert content =~ ~s(msgid "Line one\\nLine two"\nmsgstr "Line one\\nLine two")
assert content =~ ~s(msgid "Tab\\there"\nmsgstr "Tab\\there")
assert content =~ ~s(msgid "Quote \\"here\\""\nmsgstr "Quote \\"here\\"")
end
test "processes multiple .po files", %{po_dir: po_dir} do
po_file1 = Path.join(po_dir, "test1.po")
po_file2 = Path.join(po_dir, "test2.po")
File.write!(po_file1, """
msgid "Hello"
msgstr ""
""")
File.write!(po_file2, """
msgid "Goodbye"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
output = capture_io(fn -> run([]) end)
assert output =~ "2 English translation file(s)"
end)
assert File.read!(po_file1) =~ ~s(msgid "Hello"\nmsgstr "Hello")
assert File.read!(po_file2) =~ ~s(msgid "Goodbye"\nmsgstr "Goodbye")
end
test "reports no changes when file already populated", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid "Hello"
msgstr "Hello"
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
output = capture_io(fn -> run([]) end)
assert output =~ "No changes: test.po"
end)
end
test "reports updated when file is modified", %{po_dir: po_dir} do
po_file = Path.join(po_dir, "test.po")
File.write!(po_file, """
msgid "Hello"
msgstr ""
""")
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
output = capture_io(fn -> run([]) end)
assert output =~ "Updated: test.po"
end)
end
test "handles missing directory gracefully" do
temp_dir = Path.join(System.tmp_dir!(), "nonexistent_#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
try do
File.cd!(temp_dir, fn ->
assert_raise Mix.Error, fn ->
capture_io(fn -> run([]) end)
end
end)
after
File.rm_rf!(temp_dir)
end
end
test "reports when no .po files found", %{po_dir: po_dir} do
File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn ->
output = capture_io(fn -> run([]) end)
assert output =~ "No .po files found"
end)
end
end
end