add preseem device baselines and fleet profiles schemas
This commit is contained in:
parent
b9db1d2224
commit
df2ca42404
6 changed files with 588 additions and 0 deletions
57
lib/towerops/preseem/device_baseline.ex
Normal file
57
lib/towerops/preseem/device_baseline.ex
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
defmodule Towerops.Preseem.DeviceBaseline do
|
||||
@moduledoc """
|
||||
Rolling 14-day baseline for individual Preseem access points.
|
||||
Computed nightly to detect deviations from normal behavior.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@valid_periods ~w(busy offpeak all)
|
||||
@valid_metrics ~w(qoe_score capacity_score rf_score p95_latency avg_latency avg_jitter avg_loss avg_throughput subscriber_count airtime_utilization)
|
||||
|
||||
schema "preseem_device_baselines" do
|
||||
field :metric_name, :string
|
||||
field :period, :string
|
||||
field :mean, :float
|
||||
field :stddev, :float
|
||||
field :p5, :float
|
||||
field :p95, :float
|
||||
field :sample_count, :integer
|
||||
field :computed_at, :utc_datetime
|
||||
|
||||
belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint
|
||||
end
|
||||
|
||||
def changeset(baseline, attrs) do
|
||||
baseline
|
||||
|> cast(attrs, [
|
||||
:preseem_access_point_id,
|
||||
:metric_name,
|
||||
:period,
|
||||
:mean,
|
||||
:stddev,
|
||||
:p5,
|
||||
:p95,
|
||||
:sample_count,
|
||||
:computed_at
|
||||
])
|
||||
|> validate_required([
|
||||
:preseem_access_point_id,
|
||||
:metric_name,
|
||||
:period,
|
||||
:mean,
|
||||
:sample_count,
|
||||
:computed_at
|
||||
])
|
||||
|> validate_inclusion(:period, @valid_periods)
|
||||
|> validate_inclusion(:metric_name, @valid_metrics)
|
||||
|> unique_constraint([:preseem_access_point_id, :metric_name, :period],
|
||||
name: :preseem_device_baselines_ap_metric_period_idx
|
||||
)
|
||||
|> foreign_key_constraint(:preseem_access_point_id)
|
||||
end
|
||||
end
|
||||
51
lib/towerops/preseem/fleet_profile.ex
Normal file
51
lib/towerops/preseem/fleet_profile.ex
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
defmodule Towerops.Preseem.FleetProfile do
|
||||
@moduledoc """
|
||||
Fleet-wide model performance profiles. Groups devices by manufacturer+model
|
||||
to identify patterns, capacity ceilings, and firmware correlations.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "preseem_fleet_profiles" do
|
||||
field :manufacturer, :string
|
||||
field :model, :string
|
||||
field :firmware_version, :string
|
||||
field :device_count, :integer
|
||||
field :avg_subscribers, :float
|
||||
field :avg_qoe_score, :float
|
||||
field :avg_capacity_score, :float
|
||||
field :capacity_ceiling, :integer
|
||||
field :avg_busy_hours, :float
|
||||
field :performance_data, :map
|
||||
field :computed_at, :utc_datetime
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
end
|
||||
|
||||
def changeset(profile, attrs) do
|
||||
profile
|
||||
|> cast(attrs, [
|
||||
:organization_id,
|
||||
:manufacturer,
|
||||
:model,
|
||||
:firmware_version,
|
||||
:device_count,
|
||||
:avg_subscribers,
|
||||
:avg_qoe_score,
|
||||
:avg_capacity_score,
|
||||
:capacity_ceiling,
|
||||
:avg_busy_hours,
|
||||
:performance_data,
|
||||
:computed_at
|
||||
])
|
||||
|> validate_required([:organization_id, :manufacturer, :model, :device_count, :computed_at])
|
||||
|> unique_constraint([:organization_id, :manufacturer, :model, :firmware_version],
|
||||
name: :preseem_fleet_profiles_org_model_fw_idx
|
||||
)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
defmodule Towerops.Repo.Migrations.CreatePreseemDeviceBaselines do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:preseem_device_baselines, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :preseem_access_point_id,
|
||||
references(:preseem_access_points, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :metric_name, :string, null: false
|
||||
add :period, :string, null: false
|
||||
add :mean, :float
|
||||
add :stddev, :float
|
||||
add :p5, :float
|
||||
add :p95, :float
|
||||
add :sample_count, :integer
|
||||
add :computed_at, :utc_datetime, null: false
|
||||
end
|
||||
|
||||
create index(:preseem_device_baselines, [:preseem_access_point_id])
|
||||
|
||||
create unique_index(
|
||||
:preseem_device_baselines,
|
||||
[:preseem_access_point_id, :metric_name, :period],
|
||||
name: :preseem_device_baselines_ap_metric_period_idx
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Towerops.Repo.Migrations.CreatePreseemFleetProfiles do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:preseem_fleet_profiles, 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 :manufacturer, :string, null: false
|
||||
add :model, :string, null: false
|
||||
add :firmware_version, :string
|
||||
add :device_count, :integer, null: false
|
||||
add :avg_subscribers, :float
|
||||
add :avg_qoe_score, :float
|
||||
add :avg_capacity_score, :float
|
||||
add :capacity_ceiling, :integer
|
||||
add :avg_busy_hours, :float
|
||||
add :performance_data, :map
|
||||
add :computed_at, :utc_datetime, null: false
|
||||
end
|
||||
|
||||
create index(:preseem_fleet_profiles, [:organization_id])
|
||||
|
||||
create unique_index(
|
||||
:preseem_fleet_profiles,
|
||||
[:organization_id, :manufacturer, :model, :firmware_version],
|
||||
name: :preseem_fleet_profiles_org_model_fw_idx
|
||||
)
|
||||
end
|
||||
end
|
||||
238
test/towerops/preseem/device_baseline_test.exs
Normal file
238
test/towerops/preseem/device_baseline_test.exs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
defmodule Towerops.Preseem.DeviceBaselineTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Preseem.AccessPoint
|
||||
alias Towerops.Preseem.DeviceBaseline
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
{:ok, ap} =
|
||||
%AccessPoint{}
|
||||
|> AccessPoint.changeset(%{organization_id: org.id, preseem_id: "ap-baseline-1", name: "Baseline AP"})
|
||||
|> Repo.insert()
|
||||
|
||||
%{access_point: ap}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with all fields", %{access_point: ap} do
|
||||
attrs = %{
|
||||
preseem_access_point_id: ap.id,
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
stddev: 4.2,
|
||||
p5: 72.0,
|
||||
p95: 95.3,
|
||||
sample_count: 336,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires preseem_access_point_id" do
|
||||
attrs = %{
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).preseem_access_point_id
|
||||
end
|
||||
|
||||
test "requires metric_name" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).metric_name
|
||||
end
|
||||
|
||||
test "requires period" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "qoe_score",
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).period
|
||||
end
|
||||
|
||||
test "requires mean" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).mean
|
||||
end
|
||||
|
||||
test "requires sample_count" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).sample_count
|
||||
end
|
||||
|
||||
test "requires computed_at" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
sample_count: 100
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).computed_at
|
||||
end
|
||||
|
||||
test "validates period inclusion" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "qoe_score",
|
||||
period: "invalid_period",
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).period
|
||||
end
|
||||
|
||||
test "accepts all valid periods" do
|
||||
for period <- ~w(busy offpeak all) do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "qoe_score",
|
||||
period: period,
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
assert changeset.valid?, "Expected period '#{period}' to be valid"
|
||||
end
|
||||
end
|
||||
|
||||
test "validates metric_name inclusion" do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: "invalid_metric",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).metric_name
|
||||
end
|
||||
|
||||
test "accepts all valid metric names" do
|
||||
valid_metrics =
|
||||
~w(qoe_score capacity_score rf_score p95_latency avg_latency avg_jitter avg_loss avg_throughput subscriber_count airtime_utilization)
|
||||
|
||||
for metric <- valid_metrics do
|
||||
attrs = %{
|
||||
preseem_access_point_id: Ecto.UUID.generate(),
|
||||
metric_name: metric,
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
sample_count: 100,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
|
||||
assert changeset.valid?, "Expected metric '#{metric}' to be valid"
|
||||
end
|
||||
end
|
||||
|
||||
test "persists and reloads from database", %{access_point: ap} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
attrs = %{
|
||||
preseem_access_point_id: ap.id,
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
stddev: 4.2,
|
||||
p5: 72.0,
|
||||
p95: 95.3,
|
||||
sample_count: 336,
|
||||
computed_at: now
|
||||
}
|
||||
|
||||
{:ok, baseline} = %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert()
|
||||
reloaded = Repo.get!(DeviceBaseline, baseline.id)
|
||||
|
||||
assert reloaded.preseem_access_point_id == ap.id
|
||||
assert reloaded.metric_name == "qoe_score"
|
||||
assert reloaded.period == "busy"
|
||||
assert reloaded.mean == 85.5
|
||||
assert reloaded.stddev == 4.2
|
||||
assert reloaded.p5 == 72.0
|
||||
assert reloaded.p95 == 95.3
|
||||
assert reloaded.sample_count == 336
|
||||
assert reloaded.computed_at == now
|
||||
end
|
||||
|
||||
test "enforces unique constraint on [access_point_id, metric_name, period]", %{access_point: ap} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
attrs = %{
|
||||
preseem_access_point_id: ap.id,
|
||||
metric_name: "qoe_score",
|
||||
period: "busy",
|
||||
mean: 85.5,
|
||||
sample_count: 336,
|
||||
computed_at: now
|
||||
}
|
||||
|
||||
{:ok, _} = %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert()
|
||||
|
||||
assert {:error, changeset} =
|
||||
%DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert()
|
||||
|
||||
assert errors_on(changeset).preseem_access_point_id != []
|
||||
end
|
||||
end
|
||||
end
|
||||
179
test/towerops/preseem/fleet_profile_test.exs
Normal file
179
test/towerops/preseem/fleet_profile_test.exs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
defmodule Towerops.Preseem.FleetProfileTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Preseem.FleetProfile
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{organization: org}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with all fields", %{organization: org} do
|
||||
attrs = %{
|
||||
organization_id: org.id,
|
||||
manufacturer: "Cambium",
|
||||
model: "ePMP 3000",
|
||||
firmware_version: "4.7.1",
|
||||
device_count: 25,
|
||||
avg_subscribers: 18.5,
|
||||
avg_qoe_score: 82.3,
|
||||
avg_capacity_score: 71.0,
|
||||
capacity_ceiling: 35,
|
||||
avg_busy_hours: 6.2,
|
||||
performance_data: %{"p50_qoe" => 84.0, "p10_qoe" => 65.2},
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires organization_id" do
|
||||
attrs = %{
|
||||
manufacturer: "Cambium",
|
||||
model: "ePMP 3000",
|
||||
device_count: 25,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).organization_id
|
||||
end
|
||||
|
||||
test "requires manufacturer" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
model: "ePMP 3000",
|
||||
device_count: 25,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).manufacturer
|
||||
end
|
||||
|
||||
test "requires model" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
manufacturer: "Cambium",
|
||||
device_count: 25,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).model
|
||||
end
|
||||
|
||||
test "requires device_count" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
manufacturer: "Cambium",
|
||||
model: "ePMP 3000",
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).device_count
|
||||
end
|
||||
|
||||
test "requires computed_at" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
manufacturer: "Cambium",
|
||||
model: "ePMP 3000",
|
||||
device_count: 25
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).computed_at
|
||||
end
|
||||
|
||||
test "persists and reloads from database", %{organization: org} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
attrs = %{
|
||||
organization_id: org.id,
|
||||
manufacturer: "Ubiquiti",
|
||||
model: "LiteAP AC",
|
||||
firmware_version: "8.7.11",
|
||||
device_count: 12,
|
||||
avg_subscribers: 22.7,
|
||||
avg_qoe_score: 88.1,
|
||||
avg_capacity_score: 76.5,
|
||||
capacity_ceiling: 40,
|
||||
avg_busy_hours: 5.0,
|
||||
performance_data: %{"percentiles" => %{"p50" => 85, "p90" => 92}},
|
||||
computed_at: now
|
||||
}
|
||||
|
||||
{:ok, profile} = %FleetProfile{} |> FleetProfile.changeset(attrs) |> Repo.insert()
|
||||
reloaded = Repo.get!(FleetProfile, profile.id)
|
||||
|
||||
assert reloaded.organization_id == org.id
|
||||
assert reloaded.manufacturer == "Ubiquiti"
|
||||
assert reloaded.model == "LiteAP AC"
|
||||
assert reloaded.firmware_version == "8.7.11"
|
||||
assert reloaded.device_count == 12
|
||||
assert reloaded.avg_subscribers == 22.7
|
||||
assert reloaded.avg_qoe_score == 88.1
|
||||
assert reloaded.avg_capacity_score == 76.5
|
||||
assert reloaded.capacity_ceiling == 40
|
||||
assert reloaded.avg_busy_hours == 5.0
|
||||
assert reloaded.computed_at == now
|
||||
end
|
||||
|
||||
test "stores and retrieves performance_data map", %{organization: org} do
|
||||
performance_data = %{
|
||||
"by_firmware" => %{
|
||||
"4.7.1" => %{"count" => 10, "avg_qoe" => 85.0},
|
||||
"4.6.0" => %{"count" => 5, "avg_qoe" => 78.2}
|
||||
},
|
||||
"capacity_curve" => [
|
||||
%{"subscribers" => 10, "avg_qoe" => 92.0},
|
||||
%{"subscribers" => 20, "avg_qoe" => 85.0},
|
||||
%{"subscribers" => 30, "avg_qoe" => 72.0}
|
||||
]
|
||||
}
|
||||
|
||||
attrs = %{
|
||||
organization_id: org.id,
|
||||
manufacturer: "Cambium",
|
||||
model: "ePMP 3000",
|
||||
device_count: 15,
|
||||
performance_data: performance_data,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
{:ok, profile} = %FleetProfile{} |> FleetProfile.changeset(attrs) |> Repo.insert()
|
||||
reloaded = Repo.get!(FleetProfile, profile.id)
|
||||
|
||||
assert reloaded.performance_data == performance_data
|
||||
end
|
||||
|
||||
test "firmware_version is optional", %{organization: org} do
|
||||
attrs = %{
|
||||
organization_id: org.id,
|
||||
manufacturer: "Cambium",
|
||||
model: "ePMP 3000",
|
||||
device_count: 25,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
|
||||
assert changeset.valid?
|
||||
|
||||
{:ok, profile} = Repo.insert(changeset)
|
||||
assert is_nil(profile.firmware_version)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue