From 5f17d5aed14dc040f636bb466ba87f455072e792 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:11:36 -0600 Subject: [PATCH] add preseem access points schema and migration --- lib/towerops/preseem/access_point.ex | 65 +++++++++++++++ ...212231026_create_preseem_access_points.exs | 35 ++++++++ test/towerops/preseem/access_point_test.exs | 82 +++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 lib/towerops/preseem/access_point.ex create mode 100644 priv/repo/migrations/20260212231026_create_preseem_access_points.exs create mode 100644 test/towerops/preseem/access_point_test.exs diff --git a/lib/towerops/preseem/access_point.ex b/lib/towerops/preseem/access_point.ex new file mode 100644 index 00000000..4a56cea0 --- /dev/null +++ b/lib/towerops/preseem/access_point.ex @@ -0,0 +1,65 @@ +defmodule Towerops.Preseem.AccessPoint do + @moduledoc """ + Schema for access points synced from Preseem API. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @valid_match_confidences ~w(auto_mac auto_ip auto_hostname manual ambiguous unmatched) + + schema "preseem_access_points" do + field :preseem_id, :string + field :name, :string + field :mac_address, :string + field :ip_address, :string + field :model, :string + field :firmware, :string + field :capacity_score, :float + field :qoe_score, :float + field :rf_score, :float + field :busy_hours, :integer + field :airtime_utilization, :float + field :subscriber_count, :integer + field :match_confidence, :string, default: "unmatched" + field :raw_data, :map + + belongs_to :organization, Towerops.Organizations.Organization + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime) + end + + def changeset(access_point, attrs) do + access_point + |> cast(attrs, [ + :organization_id, + :preseem_id, + :name, + :mac_address, + :ip_address, + :model, + :firmware, + :capacity_score, + :qoe_score, + :rf_score, + :busy_hours, + :airtime_utilization, + :subscriber_count, + :device_id, + :match_confidence, + :raw_data + ]) + |> validate_required([:organization_id, :preseem_id]) + |> validate_inclusion(:match_confidence, @valid_match_confidences) + |> unique_constraint([:organization_id, :preseem_id], + error_key: :preseem_id, + message: "has already been taken" + ) + |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:device_id) + end +end diff --git a/priv/repo/migrations/20260212231026_create_preseem_access_points.exs b/priv/repo/migrations/20260212231026_create_preseem_access_points.exs new file mode 100644 index 00000000..6769bd31 --- /dev/null +++ b/priv/repo/migrations/20260212231026_create_preseem_access_points.exs @@ -0,0 +1,35 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemAccessPoints do + use Ecto.Migration + + def change do + create table(:preseem_access_points, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :preseem_id, :string, null: false + add :name, :string + add :mac_address, :string + add :ip_address, :string + add :model, :string + add :firmware, :string + add :capacity_score, :float + add :qoe_score, :float + add :rf_score, :float + add :busy_hours, :integer + add :airtime_utilization, :float + add :subscriber_count, :integer + add :device_id, references(:devices, type: :binary_id, on_delete: :nilify_all) + add :match_confidence, :string, default: "unmatched" + add :raw_data, :map + + timestamps(type: :utc_datetime) + end + + create unique_index(:preseem_access_points, [:organization_id, :preseem_id]) + create index(:preseem_access_points, [:organization_id]) + create index(:preseem_access_points, [:device_id]) + create index(:preseem_access_points, [:match_confidence]) + end +end diff --git a/test/towerops/preseem/access_point_test.exs b/test/towerops/preseem/access_point_test.exs new file mode 100644 index 00000000..d22cd8f3 --- /dev/null +++ b/test/towerops/preseem/access_point_test.exs @@ -0,0 +1,82 @@ +defmodule Towerops.Preseem.AccessPointTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{organization: org} + end + + describe "changeset/2" do + test "valid changeset with required fields", %{organization: org} do + attrs = %{organization_id: org.id, preseem_id: "ap-123", name: "Tower1-AP1"} + changeset = AccessPoint.changeset(%AccessPoint{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + changeset = AccessPoint.changeset(%AccessPoint{}, %{preseem_id: "ap-1"}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires preseem_id" do + changeset = AccessPoint.changeset(%AccessPoint{}, %{organization_id: Ecto.UUID.generate()}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).preseem_id + end + + test "validates match_confidence is a known value" do + attrs = %{organization_id: Ecto.UUID.generate(), preseem_id: "ap-1", match_confidence: "bogus"} + changeset = AccessPoint.changeset(%AccessPoint{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).match_confidence + end + + test "accepts all valid match_confidence values" do + for confidence <- ~w(auto_mac auto_ip auto_hostname manual ambiguous unmatched) do + attrs = %{organization_id: Ecto.UUID.generate(), preseem_id: "ap-1", match_confidence: confidence} + changeset = AccessPoint.changeset(%AccessPoint{}, attrs) + assert changeset.valid?, "Expected #{confidence} to be valid" + end + end + + test "enforces unique preseem_id per org", %{organization: org} do + attrs = %{organization_id: org.id, preseem_id: "ap-123"} + {:ok, _} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + assert {:error, changeset} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + assert "has already been taken" in errors_on(changeset).preseem_id + end + + test "stores and retrieves raw_data", %{organization: org} do + raw = %{"extra_field" => "value", "nested" => %{"key" => 42}} + attrs = %{organization_id: org.id, preseem_id: "ap-raw", raw_data: raw} + {:ok, ap} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(AccessPoint, ap.id) + assert reloaded.raw_data == raw + end + + test "stores numeric scores", %{organization: org} do + attrs = %{ + organization_id: org.id, + preseem_id: "ap-scores", + qoe_score: 85.5, + capacity_score: 72.0, + rf_score: 90.1, + busy_hours: 4, + airtime_utilization: 65.3, + subscriber_count: 42 + } + + {:ok, ap} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(AccessPoint, ap.id) + assert reloaded.qoe_score == 85.5 + assert reloaded.subscriber_count == 42 + end + end +end