defmodule Aprsme.DeviceIdentificationTest do use Aprsme.DataCase, async: true alias Aprsme.DeviceIdentification alias Aprsme.Devices alias Aprsme.Repo doctest DeviceIdentification describe "identify_device/1" do test "identifies Original MIC-E devices" do assert DeviceIdentification.identify_device(" " <> <<0, 0>>) == "Original MIC-E" end test "identifies Kenwood devices" do assert DeviceIdentification.identify_device(">" <> <<0>> <> "^") == "Kenwood TH-D74" assert DeviceIdentification.identify_device(">" <> <<0, 0>>) == "Kenwood TH-D74A" assert DeviceIdentification.identify_device("]" <> <<0>> <> "=") == "Kenwood DM-710" assert DeviceIdentification.identify_device("]" <> <<0, 0>>) == "Kenwood DM-700" end test "identifies Yaesu devices" do assert DeviceIdentification.identify_device("`_" <> " ") == "Yaesu VX-8" assert DeviceIdentification.identify_device("`_" <> "\"") == "Yaesu FTM-350" assert DeviceIdentification.identify_device("`_" <> "#") == "Yaesu VX-8G" assert DeviceIdentification.identify_device("`_" <> "$") == "Yaesu FT1D" assert DeviceIdentification.identify_device("`_" <> "%") == "Yaesu FTM-400DR" assert DeviceIdentification.identify_device("`_" <> ")") == "Yaesu FTM-100D" assert DeviceIdentification.identify_device("`_" <> "(") == "Yaesu FT2D" end test "identifies other devices" do assert DeviceIdentification.identify_device("` X") == "AP510" assert DeviceIdentification.identify_device("`" <> <<0, 0>>) == "Mic-Emsg" assert DeviceIdentification.identify_device("'|3") == "Byonics TinyTrack3" assert DeviceIdentification.identify_device("'|4") == "Byonics TinyTrack4" assert DeviceIdentification.identify_device("':4") == "SCS GmbH & Co. P4dragon DR-7400 modems" assert DeviceIdentification.identify_device("':8") == "SCS GmbH & Co. P4dragon DR-7800 modems" assert DeviceIdentification.identify_device("'" <> <<0, 0>>) == "McTrackr" assert DeviceIdentification.identify_device(<<0>> <> "\"" <> <<0>>) == "Hamhud" assert DeviceIdentification.identify_device(<<0>> <> "/" <> <<0>>) == "Argent" assert DeviceIdentification.identify_device(<<0>> <> "^" <> <<0>>) == "HinzTec anyfrog" assert DeviceIdentification.identify_device(<<0>> <> "*" <> <<0>>) == "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO" assert DeviceIdentification.identify_device(<<0>> <> "~" <> <<0>>) == "Other" end test "returns Unknown for unidentified devices" do assert DeviceIdentification.identify_device(<<0, 0, 0>>) == "Unknown" end end describe "known_manufacturers/0" do test "returns list of known manufacturers" do manufacturers = DeviceIdentification.known_manufacturers() assert is_list(manufacturers) assert "Kenwood" in manufacturers assert "Yaesu" in manufacturers assert "Byonics" in manufacturers end end describe "known_models/1" do test "returns list of known models for Kenwood" do models = DeviceIdentification.known_models("Kenwood") assert is_list(models) assert "TH-D74" in models assert "TH-D74A" in models assert "DM-710" in models assert "DM-700" in models end test "returns list of known models for Yaesu" do models = DeviceIdentification.known_models("Yaesu") assert is_list(models) assert "VX-8" in models assert "FTM-350" in models assert "VX-8G" in models assert "FT1D" in models assert "FTM-400DR" in models assert "FTM-100D" in models assert "FT2D" in models end test "returns empty list for unknown manufacturer" do assert DeviceIdentification.known_models("Unknown") == [] end test "returns list of known models for Byonics" do models = DeviceIdentification.known_models("Byonics") assert "TinyTrack3" in models assert "TinyTrack4" in models end test "returns list of known models for SCS GmbH & Co." do models = DeviceIdentification.known_models("SCS GmbH & Co.") assert "P4dragon DR-7400 modems" in models assert "P4dragon DR-7800 modems" in models end end describe "upsert_devices/1" do test "replaces the devices table with data from the JSON" do # Seed some dummy state to be wiped. Repo.delete_all(Devices) Repo.insert!(%Devices{ identifier: "WILLBEWIPED", vendor: "Old", model: "Old" }) json = %{ "tocalls" => %{ "APXYZ" => %{"vendor" => "NewVendor", "model" => "NewModel", "features" => ["a", "b"]} }, "mice" => %{ "]=" => %{"vendor" => "KenwoodMice", "model" => "TH-D7", "features" => "single"} } } assert :ok == DeviceIdentification.upsert_devices(json) # Previous row is gone, new rows inserted with correct attrs. assert Repo.get_by(Devices, identifier: "WILLBEWIPED") == nil assert %Devices{vendor: "NewVendor", model: "NewModel", features: ["a", "b"]} = Repo.get_by(Devices, identifier: "APXYZ") # Single-string feature gets wrapped into a list by process_device_attrs. assert %Devices{features: ["single"]} = Repo.get_by(Devices, identifier: "]=") end test "handles empty JSON groups" do Repo.delete_all(Devices) assert :ok == DeviceIdentification.upsert_devices(%{}) assert Repo.all(Devices) == [] end end describe "maybe_refresh_devices/0" do test "returns :ok when the most recent device is fresh (< 1 week old)" do Repo.delete_all(Devices) now = NaiveDateTime.utc_now(:second) Repo.insert!(%Devices{ identifier: "FRESHROW", vendor: "v", model: "m", inserted_at: now, updated_at: now }) assert :ok == DeviceIdentification.maybe_refresh_devices() end test "attempts to refresh when there are no devices at all" do Repo.delete_all(Devices) # fetch_and_upsert_devices goes through the circuit breaker. The short # Req timeout configured in test.exs makes the HTTP attempt fail fast. result = DeviceIdentification.maybe_refresh_devices() assert result == :ok or match?({:error, _}, result) end end describe "lookup_device_by_identifier/1 wildcard and escape handling" do test "matches with a multi-character ? wildcard pattern" do Repo.delete_all(Devices) Repo.insert!(%Devices{ identifier: "APS???", vendor: "TestVendor", model: "TestModel" }) devices = Repo.all(Devices) Aprsme.Cache.put(:device_cache, :all_devices, devices) # 'APSK21' has 6 characters, matching 'APS???'. assert %Devices{identifier: "APS???"} = DeviceIdentification.lookup_device_by_identifier("APSK21") # 'APS' alone is too short. refute DeviceIdentification.lookup_device_by_identifier("APS") # Different prefix should not match. refute DeviceIdentification.lookup_device_by_identifier("ZZZK21") end test "patterns with regex metacharacters are escaped properly" do Repo.delete_all(Devices) Repo.insert!(%Devices{ identifier: "A.B+C*", vendor: "Regex", model: "Edge" }) devices = Repo.all(Devices) Aprsme.Cache.put(:device_cache, :all_devices, devices) # The dot/plus/star should be treated as literals (no ? in pattern # means exact match is used). assert %Devices{identifier: "A.B+C*"} = DeviceIdentification.lookup_device_by_identifier("A.B+C*") # A string that would "match" if dot/plus/star were regex metachars # must NOT match — they're literals. refute DeviceIdentification.lookup_device_by_identifier("AXBCC") end end describe "fetch_devices_from_url/1 with direct URL" do test "returns an error tuple on non-200 response" do # httpbin.org-style path that returns non-200 is not reliable; # just pass an invalid URL to hit the error branch. result = DeviceIdentification.fetch_devices_from_url("http://invalid.local.test.invalid:1/404") assert match?({:error, _}, result) end end describe "fetch_and_upsert_devices circuit-breaker path" do test "returns an error tuple when circuit breaker rejects or HTTP fails" do # This just exercises the fetch_and_upsert_devices path, which runs via # Aprsme.CircuitBreaker.call/3. Without an HTTP stub, this will get an # error back from either the breaker or the underlying Req call. result = DeviceIdentification.fetch_and_upsert_devices() assert match?({:error, _}, result) or result == :ok end end describe "lookup_device_from_db fallback when cache is unavailable" do test "lookup_device_by_identifier falls back to direct DB when DeviceCache is unavailable" do Repo.delete_all(Devices) Repo.insert!(%Devices{ identifier: "DBEXACT", vendor: "Test", model: "Exact" }) # Clear the cache so the code path exercises the DB lookup. Aprsme.Cache.put(:device_cache, :all_devices, nil) # Cache miss → DeviceCache returns nil and also triggers async refresh. # Direct lookup_device_by_identifier goes via DeviceCache here. result = DeviceIdentification.lookup_device_by_identifier("DBEXACT") # Either cache miss → nil, or cache refresh filled and matched. assert is_nil(result) or (is_struct(result, Devices) and result.identifier == "DBEXACT") end end describe "upsert_devices/1 with all three groups" do test "processes devices from mice and micelegacy groups" do Repo.delete_all(Devices) json = %{ "tocalls" => %{}, "mice" => %{ "]=" => %{"vendor" => "Mice", "model" => "MA"} }, "micelegacy" => %{ "`_#" => %{"vendor" => "Legacy", "model" => "MB"} } } assert :ok = DeviceIdentification.upsert_devices(json) assert %Devices{vendor: "Mice"} = Repo.get_by(Devices, identifier: "]=") assert %Devices{vendor: "Legacy"} = Repo.get_by(Devices, identifier: "`_#") end test "filters out non-whitelisted JSON keys" do Repo.delete_all(Devices) # 'garbage' and 'unused_field' must be stripped by process_device_attrs. json = %{ "tocalls" => %{ "APRS" => %{ "vendor" => "V", "model" => "M", "garbage" => "should-be-dropped", "unused_field" => 42 } } } assert :ok = DeviceIdentification.upsert_devices(json) assert %Devices{vendor: "V", model: "M"} = Repo.get_by(Devices, identifier: "APRS") end test "handles nil features value" do Repo.delete_all(Devices) json = %{ "tocalls" => %{ "APNIL" => %{"vendor" => "V", "model" => "M", "features" => nil} } } assert :ok = DeviceIdentification.upsert_devices(json) dev = Repo.get_by(Devices, identifier: "APNIL") assert is_nil(dev.features) end end describe "maybe_refresh_devices/0 with stale device" do test "triggers refresh when latest device is older than one week" do Repo.delete_all(Devices) # 10 days ago. old_time = DateTime.utc_now() |> DateTime.add(-10 * 24 * 3600, :second) |> DateTime.to_naive() |> NaiveDateTime.truncate(:second) Repo.insert!(%Devices{ identifier: "STALEROW", vendor: "v", model: "m", inserted_at: old_time, updated_at: old_time }) # Will attempt a refresh via the CircuitBreaker. Without a real HTTP # stub, it may return an error tuple — either way the stale-branch was # exercised. result = DeviceIdentification.maybe_refresh_devices() assert result == :ok or match?({:error, _}, result) end end describe "lookup_device_by_identifier/1" do test "matches APSK21 to APS??? pattern" do # Seed the devices table from the JSON Aprsme.DevicesSeeder.seed_from_json() # Force cache refresh in test environment if Code.ensure_loaded?(Aprsme.DeviceCache) do # Manually refresh cache with seeded data devices = Repo.all(Devices) Aprsme.Cache.put(:device_cache, :all_devices, devices) end # Should match found = DeviceIdentification.lookup_device_by_identifier("APSK21") assert found assert found.identifier == "APS???" assert found.model assert found.vendor end test "handles non-wildcard exact match" do # Insert a device with an exact identifier (no ?). Repo.delete_all(Devices) Repo.insert!(%Devices{ identifier: "EXACTMATCH", vendor: "Vendor", model: "Model" }) # Refresh the cache so lookups see the new record. devices = Repo.all(Devices) Aprsme.Cache.put(:device_cache, :all_devices, devices) found = DeviceIdentification.lookup_device_by_identifier("EXACTMATCH") assert found assert found.identifier == "EXACTMATCH" refute DeviceIdentification.lookup_device_by_identifier("NOTEXACTMATCH") end test "returns nil for nil input" do assert DeviceIdentification.lookup_device_by_identifier(nil) == nil end test "matches Mic-E device identifier from raw packet" do # Seed the devices table from the JSON Aprsme.DevicesSeeder.seed_from_json() # Force cache refresh in test environment if Code.ensure_loaded?(Aprsme.DeviceCache) do # Manually refresh cache with seeded data devices = Repo.all(Devices) Aprsme.Cache.put(:device_cache, :all_devices, devices) end # The device identifier extracted from the raw packet is "]=" found = DeviceIdentification.lookup_device_by_identifier("]=") assert found assert found.identifier == "]=" assert found.model assert found.vendor end end end