aprs.me/test/aprsme/performance/insert_optimizer_test.exs
Graham McIntire 1e32ce7051
Improve test coverage to 87% and point vendor/aprs submodule at codeberg
Coverage:
- Adds focused tests across 13 files covering previously-uncovered
  branches: Packet changeset normalisation (course, wind_direction,
  struct data_extended); Packets store_packet edge cases (nested
  position shapes, ssid handling, string-keyed raw_packet);
  DeviceIdentification HTTP success/404 via Req plug stub; ResendAdapter
  catch-all body, deliver_many, nil/binary formatters; ApiDocsLive
  packets with missing fields; AprsSymbol normalize helpers;
  BadPacketsLive refresh and postgres_notify; MobileChannel handle_info
  catch-all and postgres_packet path; PacketProcessor existing-marker
  movement detection; PacketReplay sanitize_value type clauses;
  InsertOptimizer GenServer lifecycle and negative-duration metric;
  PageController shutdown-handler integration; UrlParams delegated
  helpers.
- mix.exs: configure test_coverage with summary threshold of 87 and
  ignore_modules for test fixtures and macro-only template modules.

Bug fix:
- MapLive.Index handle_info: add a clause for the raw {:new_deployment,
  _} tuple. Phoenix.PubSub.broadcast delivers the raw payload to plain
  subscribers, but the existing handler only matched the %Broadcast{}
  fast-lane wrapper. DeploymentNotifier broadcasts via the raw path,
  which crashed the LiveView under test concurrency.

Submodule:
- Update .gitmodules vendor/aprs URL from
  https://github.com/aprsme/aprs.git to
  ssh://git@codeberg.org/gmcintire/aprs.git.
2026-05-05 15:18:28 -05:00

181 lines
6.6 KiB
Elixir

defmodule Aprsme.Performance.InsertOptimizerTest do
use ExUnit.Case, async: true
alias Aprsme.Performance.InsertOptimizer
defp fresh_state do
%{
current_batch_size: 200,
insert_options: [returning: false, on_conflict: :nothing, timeout: 15_000],
performance_history: [],
last_optimization: System.monotonic_time(:millisecond)
}
end
defp metric(throughput, duration_ms) do
%{
batch_size: 200,
duration_ms: duration_ms,
success_count: 100,
throughput: throughput,
timestamp: System.monotonic_time(:millisecond)
}
end
describe "GenServer callbacks" do
test "handle_call :get_batch_size returns current batch size" do
state = fresh_state()
assert {:reply, 200, ^state} =
InsertOptimizer.handle_call(:get_batch_size, self(), state)
end
test "handle_call :get_insert_options returns insert_options" do
state = fresh_state()
opts = state.insert_options
assert {:reply, ^opts, ^state} = InsertOptimizer.handle_call(:get_insert_options, self(), state)
end
test "handle_cast records a new metric in performance_history" do
state = fresh_state()
{:noreply, new_state} =
InsertOptimizer.handle_cast({:record_metrics, 200, 1_000, 100}, state)
assert [entry | _] = new_state.performance_history
assert entry.batch_size == 200
assert entry.duration_ms == 1_000
assert entry.success_count == 100
# Throughput = 100 / (1000/1000) = 100 pps
assert entry.throughput == 100.0
end
test "handle_cast with zero duration avoids div-by-0, throughput is 0" do
state = fresh_state()
{:noreply, new_state} =
InsertOptimizer.handle_cast({:record_metrics, 100, 0, 50}, state)
assert [entry | _] = new_state.performance_history
assert entry.throughput == 0
end
test "performance history is capped at 20 entries" do
state = fresh_state()
final =
Enum.reduce(1..25, state, fn _, s ->
{:noreply, s2} = InsertOptimizer.handle_cast({:record_metrics, 200, 1000, 100}, s)
s2
end)
assert length(final.performance_history) == 20
end
end
describe "handle_info :optimize_settings" do
test "with fewer than 3 metrics, state is unchanged except for re-scheduled timer" do
state = %{fresh_state() | performance_history: [metric(100, 1000), metric(90, 1100)]}
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
# Batch size shouldn't have moved.
assert new_state.current_batch_size == state.current_batch_size
assert new_state.performance_history == state.performance_history
end
test "grows batch size when throughput is high and duration low" do
# With avg_throughput > 200 and avg_duration < 2000, batch size should scale up
# from 200 → min(@max, round(200 * 1.2)) = 240.
history =
Enum.map(1..5, fn _ -> metric(300, 1000) end)
state = %{fresh_state() | performance_history: history}
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
assert new_state.current_batch_size == 240
# Fast INSERTs keep default options.
assert Keyword.get(new_state.insert_options, :timeout) == 15_000
end
test "shrinks batch size when throughput is low and duration high" do
# avg_throughput < 50 and avg_duration > 5000 → batch_size drops by 20%.
history = Enum.map(1..5, fn _ -> metric(10, 6000) end)
state = %{fresh_state() | performance_history: history}
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
assert new_state.current_batch_size == 160
# Slow INSERTs bump timeout.
assert Keyword.get(new_state.insert_options, :timeout) == 30_000
end
test "keeps batch size when performance is middling" do
# 100 pps at 3000ms falls in neither bucket → no change to batch size.
history = Enum.map(1..5, fn _ -> metric(100, 3000) end)
state = %{fresh_state() | performance_history: history}
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
assert new_state.current_batch_size == 200
end
test "never drops below the minimum batch size" do
state = %{
fresh_state()
| current_batch_size: 110,
performance_history: Enum.map(1..5, fn _ -> metric(5, 10_000) end)
}
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
assert new_state.current_batch_size == 100
end
test "never exceeds the maximum batch size" do
state = %{
fresh_state()
| current_batch_size: 750,
performance_history: Enum.map(1..5, fn _ -> metric(500, 500) end)
}
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
assert new_state.current_batch_size == 800
end
end
describe "client API when GenServer isn't running" do
test "get_optimal_batch_size falls back to base size on :noproc" do
# The supervised instance isn't started in the app tree, so this should
# hit the catch clause and return the base batch size (200).
assert InsertOptimizer.get_optimal_batch_size() == 200
end
test "get_insert_options falls back to default options on :noproc" do
opts = InsertOptimizer.get_insert_options()
assert Keyword.get(opts, :returning) == false
assert Keyword.get(opts, :on_conflict) == :nothing
assert Keyword.get(opts, :timeout) == 15_000
end
end
describe "GenServer process lifecycle" do
test "start_link/init/record_insert_metrics/get_optimal_batch_size" do
# Stop any existing instance so start_link/0 doesn't conflict.
if pid = Process.whereis(InsertOptimizer) do
GenServer.stop(pid)
end
assert {:ok, pid} = InsertOptimizer.start_link([])
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
# Exercises the cast path that records metrics into performance_history.
InsertOptimizer.record_insert_metrics(200, 1500, 100)
# Sync via call so the cast above is processed first.
assert is_integer(InsertOptimizer.get_optimal_batch_size())
end
test "handle_cast records a metric with negative duration as zero throughput" do
state = fresh_state()
# Negative duration should hit `throughput(_count, duration_ms) when duration_ms < 0, do: 0`.
assert {:noreply, new_state} =
InsertOptimizer.handle_cast({:record_metrics, 200, -5, 100}, state)
[first | _] = new_state.performance_history
assert first.throughput == 0
end
end
end