towerops/lib/towerops/geocoding.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

109 lines
3.2 KiB
Elixir

defmodule Towerops.Geocoding do
@moduledoc """
Service for geocoding addresses using Google Maps Geocoding API.
Supports both system-wide API keys and organization-specific encrypted API keys.
"""
alias Towerops.HTTP
require Logger
@google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json"
@doc """
Geocode an address to latitude/longitude coordinates.
## Parameters
- `address`: The address string to geocode
## Returns
- `{:ok, %{latitude: float, longitude: float, formatted_address: string}}` on success
- `{:error, reason}` on failure
"""
def geocode(address) when is_binary(address) do
with {:ok, api_key} <- get_api_key(),
{:ok, response} <- make_geocoding_request(address, api_key) do
parse_geocoding_response(response)
end
end
defp get_api_key do
:towerops
|> Application.get_env(:google_maps_api_key)
|> fallback_env("GOOGLE_MAPS_API_KEY")
|> validate_key()
end
defp fallback_env(nil, env_var), do: System.get_env(env_var)
defp fallback_env(value, _env_var), do: value
defp validate_key(nil), do: {:error, :no_api_key}
defp validate_key(key) when is_binary(key) do
case String.trim(key) do
"" -> {:error, :no_api_key}
trimmed -> {:ok, trimmed}
end
end
defp make_geocoding_request(address, api_key) do
params = %{
"address" => address,
"key" => api_key
}
case HTTP.get(__MODULE__, @google_geocoding_url, params: params, receive_timeout: 10_000) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
{:ok, %Req.Response{status: status_code, body: body}} ->
Logger.warning("Geocoding API returned status #{status_code}: #{inspect(body)}")
{:error, "Geocoding API error (status #{status_code})"}
{:error, reason} ->
Logger.error("Failed to make geocoding request: #{inspect(reason)}")
{:error, "Network error: #{inspect(reason)}"}
end
end
defp parse_geocoding_response(%{"status" => "OK", "results" => [result | _]}) do
with %{"geometry" => %{"location" => %{"lat" => lat, "lng" => lng}}} <- result,
%{"formatted_address" => formatted_address} <- result do
{:ok,
%{
latitude: lat,
longitude: lng,
formatted_address: formatted_address
}}
else
_ -> {:error, "Invalid response format from Google Maps API"}
end
end
defp parse_geocoding_response(%{"status" => "ZERO_RESULTS"}) do
{:error, "No results found for the given address"}
end
defp parse_geocoding_response(%{"status" => "OVER_QUERY_LIMIT"}) do
{:error, "API quota exceeded. Please check your Google Maps API usage."}
end
defp parse_geocoding_response(%{"status" => "REQUEST_DENIED", "error_message" => message}) do
{:error, "API request denied: #{message}"}
end
defp parse_geocoding_response(%{"status" => "INVALID_REQUEST"}) do
{:error, "Invalid request. Please check the address format."}
end
defp parse_geocoding_response(%{"status" => status}) do
{:error, "Geocoding failed with status: #{status}"}
end
defp parse_geocoding_response(_response) do
{:error, "Unexpected response format from Google Maps API"}
end
end