test: add coverage for query modules, sync paths, agent discovery
This commit is contained in:
parent
fde6158251
commit
cea0912ed1
5 changed files with 537 additions and 0 deletions
75
test/towerops/agents/agent_token_query_test.exs
Normal file
75
test/towerops/agents/agent_token_query_test.exs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule Towerops.Agents.AgentTokenQueryTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Agents.AgentToken
|
||||
alias Towerops.Agents.AgentTokenQuery
|
||||
alias Towerops.Repo
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
{:ok, t1, _raw1} = Agents.create_agent_token(org.id, "Token One")
|
||||
{:ok, t2, _raw2} = Agents.create_agent_token(org.id, "Token Two")
|
||||
|
||||
%{org: org, t1: t1, t2: t2}
|
||||
end
|
||||
|
||||
describe "base/0" do
|
||||
test "is the AgentToken schema" do
|
||||
assert AgentTokenQuery.base() == AgentToken
|
||||
end
|
||||
end
|
||||
|
||||
describe "for_organization/2" do
|
||||
test "scopes to a single organization", %{org: org} do
|
||||
results =
|
||||
AgentTokenQuery.base()
|
||||
|> AgentTokenQuery.for_organization(org.id)
|
||||
|> Repo.all()
|
||||
|
||||
assert length(results) == 2
|
||||
assert Enum.all?(results, &(&1.organization_id == org.id))
|
||||
end
|
||||
|
||||
test "returns [] for unknown org" do
|
||||
assert Ecto.UUID.generate() |> AgentTokenQuery.for_organization() |> Repo.all() == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "enabled/1" do
|
||||
test "returns only enabled tokens", %{org: org, t1: t1} do
|
||||
# Disable t1
|
||||
{:ok, _} =
|
||||
t1
|
||||
|> Ecto.Changeset.change(enabled: false)
|
||||
|> Repo.update()
|
||||
|
||||
results =
|
||||
AgentTokenQuery.base()
|
||||
|> AgentTokenQuery.for_organization(org.id)
|
||||
|> AgentTokenQuery.enabled()
|
||||
|> Repo.all()
|
||||
|
||||
assert length(results) == 1
|
||||
assert Enum.all?(results, & &1.enabled)
|
||||
end
|
||||
end
|
||||
|
||||
describe "excluding_cloud_pollers/1" do
|
||||
test "returns all org-scoped tokens (none are cloud pollers in this fixture)", %{org: org} do
|
||||
results =
|
||||
AgentTokenQuery.base()
|
||||
|> AgentTokenQuery.for_organization(org.id)
|
||||
|> AgentTokenQuery.excluding_cloud_pollers()
|
||||
|> Repo.all()
|
||||
|
||||
assert length(results) == 2
|
||||
assert Enum.all?(results, &(&1.is_cloud_poller == false))
|
||||
end
|
||||
end
|
||||
end
|
||||
126
test/towerops/devices/device_query_test.exs
Normal file
126
test/towerops/devices/device_query_test.exs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
defmodule Towerops.Devices.DeviceQueryTest do
|
||||
use Towerops.DataCase, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
import Towerops.DevicesFixtures
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Devices.DeviceQuery
|
||||
alias Towerops.Repo
|
||||
|
||||
describe "base/0" do
|
||||
test "returns the Device schema" do
|
||||
assert DeviceQuery.base() == Device
|
||||
end
|
||||
end
|
||||
|
||||
describe "for_organization/2" do
|
||||
test "scopes to a single organization" do
|
||||
d = device_fixture()
|
||||
_other = device_fixture()
|
||||
|
||||
[%{id: id}] =
|
||||
DeviceQuery.base()
|
||||
|> DeviceQuery.for_organization(d.organization_id)
|
||||
|> Repo.all()
|
||||
|
||||
assert id == d.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "for_organizations/2" do
|
||||
test "matches any of the given org ids" do
|
||||
d1 = device_fixture()
|
||||
d2 = device_fixture()
|
||||
_ = device_fixture()
|
||||
|
||||
ids =
|
||||
[d1.organization_id, d2.organization_id]
|
||||
|> DeviceQuery.for_organizations()
|
||||
|> Repo.all()
|
||||
|> MapSet.new(& &1.id)
|
||||
|
||||
assert MapSet.member?(ids, d1.id)
|
||||
assert MapSet.member?(ids, d2.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "for_site/2 and for_sites/2" do
|
||||
test "for_site narrows to one site" do
|
||||
d = device_fixture()
|
||||
_ = device_fixture()
|
||||
|
||||
[%{id: id}] = d.site_id |> DeviceQuery.for_site() |> Repo.all()
|
||||
assert id == d.id
|
||||
end
|
||||
|
||||
test "for_sites matches any in list" do
|
||||
d1 = device_fixture()
|
||||
d2 = device_fixture()
|
||||
|
||||
ids =
|
||||
[d1.site_id, d2.site_id]
|
||||
|> DeviceQuery.for_sites()
|
||||
|> Repo.all()
|
||||
|> MapSet.new(& &1.id)
|
||||
|
||||
assert ids == MapSet.new([d1.id, d2.id])
|
||||
end
|
||||
end
|
||||
|
||||
describe "with_status/2" do
|
||||
test "filters by status" do
|
||||
d_down = device_fixture(%{status: "down"})
|
||||
_d_up = device_fixture(%{status: "up"})
|
||||
|
||||
[%{id: id}] = "down" |> DeviceQuery.with_status() |> Repo.all()
|
||||
assert id == d_down.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "order_by_display/1" do
|
||||
test "orders by display_order ascending then name" do
|
||||
d_a = device_fixture(%{name: "Alpha", display_order: 2})
|
||||
d_b = device_fixture(%{name: "Bravo", display_order: 1})
|
||||
d_c = device_fixture(%{name: "Charlie", display_order: 1})
|
||||
|
||||
ids =
|
||||
DeviceQuery.base()
|
||||
|> DeviceQuery.for_organizations([
|
||||
d_a.organization_id,
|
||||
d_b.organization_id,
|
||||
d_c.organization_id
|
||||
])
|
||||
|> DeviceQuery.order_by_display()
|
||||
|> Repo.all()
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
# display_order: b,c (=1) come before a (=2). Within (=1), alphabetical name
|
||||
assert Enum.find_index(ids, &(&1 == d_a.id)) > Enum.find_index(ids, &(&1 == d_b.id))
|
||||
assert Enum.find_index(ids, &(&1 == d_a.id)) > Enum.find_index(ids, &(&1 == d_c.id))
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: composing for_organization with another filter narrows results" do
|
||||
property "for_organization + with_status returns subset of for_organization" do
|
||||
check all(status <- StreamData.member_of(["up", "down", "unknown"]), max_runs: 6) do
|
||||
d = device_fixture(%{status: status})
|
||||
|
||||
a =
|
||||
d.organization_id
|
||||
|> DeviceQuery.for_organization()
|
||||
|> Repo.all()
|
||||
|> MapSet.new(& &1.id)
|
||||
|
||||
b =
|
||||
d.organization_id
|
||||
|> DeviceQuery.for_organization()
|
||||
|> DeviceQuery.with_status(status)
|
||||
|> Repo.all()
|
||||
|> MapSet.new(& &1.id)
|
||||
|
||||
assert MapSet.subset?(b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
69
test/towerops/snmp/agent_discovery_test.exs
Normal file
69
test/towerops/snmp/agent_discovery_test.exs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
defmodule Towerops.Snmp.AgentDiscoveryTest do
|
||||
@moduledoc """
|
||||
Tests for `Towerops.Snmp.AgentDiscovery.process_agent_discovery/2`.
|
||||
|
||||
Covers the public entry point's branches:
|
||||
|
||||
* empty `oid_values` map → `{:error, :agent_collected_no_data}` (with the
|
||||
community-obscuring log path executed)
|
||||
* SNMP-disabled device → `{:error, :snmp_not_enabled}` from the underlying
|
||||
`Discovery.discover_device_with_opts/2`
|
||||
* happy path: a minimally populated OID map is replayed through the
|
||||
Replay adapter and produces a `Towerops.Snmp.Device`.
|
||||
"""
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.DevicesFixtures
|
||||
alias Towerops.Snmp.AgentDiscovery
|
||||
alias Towerops.Snmp.Device, as: DiscoveredDevice
|
||||
|
||||
describe "process_agent_discovery/2 — failure paths" do
|
||||
test "empty oid_values returns :agent_collected_no_data" do
|
||||
device = DevicesFixtures.device_fixture()
|
||||
|
||||
assert {:error, :agent_collected_no_data} =
|
||||
AgentDiscovery.process_agent_discovery(device, %{})
|
||||
end
|
||||
|
||||
test "empty oid_values for a device with a short community still returns :agent_collected_no_data" do
|
||||
# Exercises the `obscure_community/1` else-branch where community is ≤ 3 chars.
|
||||
device = DevicesFixtures.device_fixture(%{snmp_community: "ab"})
|
||||
|
||||
assert {:error, :agent_collected_no_data} =
|
||||
AgentDiscovery.process_agent_discovery(device, %{})
|
||||
end
|
||||
|
||||
test "snmp-disabled device propagates :snmp_not_enabled from Discovery" do
|
||||
device = DevicesFixtures.device_fixture(%{snmp_enabled: false})
|
||||
|
||||
oid_values = %{"1.3.6.1.2.1.1.1.0" => "Anything"}
|
||||
|
||||
assert {:error, :snmp_not_enabled} =
|
||||
AgentDiscovery.process_agent_discovery(device, oid_values)
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_agent_discovery/2 — happy path" do
|
||||
test "replays a minimal OID map through the Discovery pipeline" do
|
||||
device = DevicesFixtures.device_fixture()
|
||||
|
||||
# Minimum sysObjectID + sysDescr + sysUptime so build_device_info / select_profile
|
||||
# have something to work with. Empty interface table is fine — discovery
|
||||
# returns an empty list.
|
||||
oid_values = %{
|
||||
"1.3.6.1.2.1.1.1.0" => "Test Device",
|
||||
"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.1",
|
||||
"1.3.6.1.2.1.1.3.0" => "123456",
|
||||
"1.3.6.1.2.1.1.4.0" => "admin@test.com",
|
||||
"1.3.6.1.2.1.1.5.0" => "test-device",
|
||||
"1.3.6.1.2.1.1.6.0" => "Test Lab"
|
||||
}
|
||||
|
||||
assert {:ok, %DiscoveredDevice{} = discovered} =
|
||||
AgentDiscovery.process_agent_discovery(device, oid_values)
|
||||
|
||||
assert discovered.device_id == device.id
|
||||
assert is_list(discovered.interfaces)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -111,4 +111,44 @@ defmodule Towerops.Uisp.GpsSyncTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_gps/1" do
|
||||
test "empty pairs list returns zero counts" do
|
||||
assert {:ok, %{devices_updated: 0, sites_updated: 0}} = GpsSync.sync_gps([])
|
||||
end
|
||||
|
||||
test "skips pairs without GPS coordinates (no metadata or site access)" do
|
||||
# Without lat/lon in either location.* or gps.* the early-return path skips
|
||||
# both updates without touching device.metadata, so passing a plain map is safe.
|
||||
pair = {%{"location" => %{}, "gps" => %{}}, %{metadata: %{}, site_id: nil}}
|
||||
|
||||
assert {:ok, %{devices_updated: 0, sites_updated: 0}} =
|
||||
GpsSync.sync_gps([pair])
|
||||
end
|
||||
|
||||
test "skips pairs whose coordinates fail null-island validation" do
|
||||
pair = {%{"location" => %{"latitude" => 0.0, "longitude" => 0.0}}, %{}}
|
||||
|
||||
assert {:ok, %{devices_updated: 0, sites_updated: 0}} =
|
||||
GpsSync.sync_gps([pair])
|
||||
end
|
||||
|
||||
test "skips pairs with nil coordinates" do
|
||||
pair =
|
||||
{%{"location" => %{"latitude" => nil, "longitude" => nil}, "gps" => %{}}, %{}}
|
||||
|
||||
assert {:ok, %{devices_updated: 0, sites_updated: 0}} =
|
||||
GpsSync.sync_gps([pair])
|
||||
end
|
||||
|
||||
test "multiple pairs all invalid still aggregates to zero" do
|
||||
pairs = [
|
||||
{%{"gps" => %{}}, %{}},
|
||||
{%{"location" => %{"latitude" => 0.0, "longitude" => 5.0}}, %{}},
|
||||
{%{"location" => %{"longitude" => -74.0}}, %{}}
|
||||
]
|
||||
|
||||
assert {:ok, %{devices_updated: 0, sites_updated: 0}} = GpsSync.sync_gps(pairs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
227
test/towerops/uisp/statistics_sync_integration_test.exs
Normal file
227
test/towerops/uisp/statistics_sync_integration_test.exs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
defmodule Towerops.Uisp.StatisticsSyncIntegrationTest do
|
||||
@moduledoc """
|
||||
DB-backed tests for `Towerops.Uisp.StatisticsSync` covering
|
||||
`recently_polled?/3` and the `sync_statistics/2` orchestration that
|
||||
shells out through the UISP HTTP client (stubbed via `Req.Test.stub`).
|
||||
|
||||
Pure helper functions are exercised in `statistics_sync_test.exs`.
|
||||
"""
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.AccountsFixtures
|
||||
alias Towerops.DevicesFixtures
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.OrganizationsFixtures
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.SnmpFixtures
|
||||
alias Towerops.Uisp.Client
|
||||
alias Towerops.Uisp.StatisticsSync
|
||||
|
||||
defp setup_org_and_integration(extra_creds \\ %{}) do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
org = OrganizationsFixtures.organization_fixture(user.id)
|
||||
|
||||
creds =
|
||||
Map.merge(
|
||||
%{"url" => "https://uisp.example.com/nms/api/v2.1", "api_key" => "test-key"},
|
||||
extra_creds
|
||||
)
|
||||
|
||||
{:ok, integration} =
|
||||
Integrations.create_integration(org.id, %{
|
||||
provider: "uisp",
|
||||
enabled: true,
|
||||
credentials: creds
|
||||
})
|
||||
|
||||
{org, integration}
|
||||
end
|
||||
|
||||
defp insert_interface(snmp_device_id, attrs \\ %{}) do
|
||||
base = %{
|
||||
snmp_device_id: snmp_device_id,
|
||||
if_index: System.unique_integer([:positive])
|
||||
}
|
||||
|
||||
%Interface{}
|
||||
|> Interface.changeset(Map.merge(base, attrs))
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
defp insert_interface_stat(interface_id, checked_at, attrs \\ %{}) do
|
||||
base = %{
|
||||
interface_id: interface_id,
|
||||
if_in_octets: 0,
|
||||
if_out_octets: 0,
|
||||
if_in_errors: 0,
|
||||
if_out_errors: 0,
|
||||
if_in_discards: 0,
|
||||
if_out_discards: 0,
|
||||
checked_at: DateTime.truncate(checked_at, :second)
|
||||
}
|
||||
|
||||
%InterfaceStat{}
|
||||
|> InterfaceStat.changeset(Map.merge(base, attrs))
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
describe "recently_polled?/3" do
|
||||
test "returns false when no readings exist for the device" do
|
||||
{_org, _integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
|
||||
_iface = insert_interface(snmp.id)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
# NOTE: StatisticsSync queries Interfaces by `snmp_device_id == device_id`,
|
||||
# so we mirror that call signature here.
|
||||
refute StatisticsSync.recently_polled?(snmp.id, "any", now)
|
||||
end
|
||||
|
||||
test "returns true when a reading exists within the dedup window" do
|
||||
{_org, _integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
|
||||
iface = insert_interface(snmp.id)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
recent = DateTime.add(now, -30, :second)
|
||||
_ = insert_interface_stat(iface.id, recent)
|
||||
|
||||
assert StatisticsSync.recently_polled?(snmp.id, "any", now)
|
||||
end
|
||||
|
||||
test "returns false when readings exist but only outside the dedup window" do
|
||||
{_org, _integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
|
||||
iface = insert_interface(snmp.id)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
old = DateTime.add(now, -10 * 60, :second)
|
||||
_ = insert_interface_stat(iface.id, old)
|
||||
|
||||
refute StatisticsSync.recently_polled?(snmp.id, "any", now)
|
||||
end
|
||||
|
||||
test "returns false for a different device id even if readings exist nearby" do
|
||||
{_org, _integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
|
||||
iface = insert_interface(snmp.id)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
_ = insert_interface_stat(iface.id, DateTime.add(now, -10, :second))
|
||||
|
||||
other_device = DevicesFixtures.device_fixture()
|
||||
other_snmp = SnmpFixtures.snmp_device_fixture(%{device: other_device})
|
||||
|
||||
refute StatisticsSync.recently_polled?(other_snmp.id, "any", now)
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_statistics/2 — empty / error paths" do
|
||||
test "empty device map returns zero counts without making HTTP calls" do
|
||||
{_org, integration} = setup_org_and_integration()
|
||||
|
||||
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
|
||||
StatisticsSync.sync_statistics(integration, %{})
|
||||
end
|
||||
|
||||
test "HTTP errors are swallowed, leaving counts at zero" do
|
||||
{_org, integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(404)
|
||||
|> Req.Test.json(%{"error" => "not found"})
|
||||
end)
|
||||
|
||||
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
|
||||
StatisticsSync.sync_statistics(integration, %{"uisp-device-1" => device})
|
||||
end
|
||||
|
||||
test "non-list response body is ignored without crashing" do
|
||||
{_org, integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{"unexpected" => "shape"})
|
||||
end)
|
||||
|
||||
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
|
||||
StatisticsSync.sync_statistics(integration, %{"uisp-device-1" => device})
|
||||
end
|
||||
|
||||
test "stats with missing/invalid timestamps are counted as skipped_dedup" do
|
||||
{_org, integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, [
|
||||
%{"interfaceName" => "eth0", "timestamp" => nil, "receive" => 100, "transmit" => 200},
|
||||
%{"interfaceName" => "eth1", "timestamp" => "garbage", "receive" => 1, "transmit" => 2}
|
||||
])
|
||||
end)
|
||||
|
||||
assert {:ok, %{inserted: 0, skipped_dedup: 2}} =
|
||||
StatisticsSync.sync_statistics(integration, %{"uisp-1" => device})
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_statistics/2 — valid stats" do
|
||||
test "no primary interface (none for device.id) leaves counts at zero" do
|
||||
# StatisticsSync queries Interfaces by `snmp_device_id == device.id`,
|
||||
# so an Interface attached to the Device's Snmp.Device id is invisible
|
||||
# to the lookup — the insert path returns {:error, :no_interface}.
|
||||
{_org, integration} = setup_org_and_integration()
|
||||
device = DevicesFixtures.device_fixture()
|
||||
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
|
||||
_iface = insert_interface(snmp.id, %{if_index: 1})
|
||||
|
||||
ts = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, [
|
||||
%{
|
||||
"interfaceName" => "eth0",
|
||||
"timestamp" => ts,
|
||||
"receive" => 1024,
|
||||
"transmit" => 2048,
|
||||
"errors" => 5
|
||||
}
|
||||
])
|
||||
end)
|
||||
|
||||
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
|
||||
StatisticsSync.sync_statistics(integration, %{"uisp-1" => device})
|
||||
|
||||
assert Repo.all(InterfaceStat) == []
|
||||
end
|
||||
|
||||
test "non-list response from one device + valid (timestamped) stat from another" do
|
||||
{_org, integration} = setup_org_and_integration()
|
||||
d1 = DevicesFixtures.device_fixture()
|
||||
d2 = DevicesFixtures.device_fixture()
|
||||
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
cond do
|
||||
String.contains?(conn.request_path, "/devices/uisp-1/statistics") ->
|
||||
Req.Test.json(conn, %{"unexpected" => "shape"})
|
||||
|
||||
String.contains?(conn.request_path, "/devices/uisp-2/statistics") ->
|
||||
ts = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||
Req.Test.json(conn, [%{"timestamp" => ts}])
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
|
||||
StatisticsSync.sync_statistics(integration, %{
|
||||
"uisp-1" => d1,
|
||||
"uisp-2" => d2
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue