defmodule Aprsme.MigrationValidationTest do use Aprsme.DataCase, async: false alias Aprsme.PartitionManager @moduletag :migration setup do # Ensure counters row exists Repo.query!(""" INSERT INTO packet_counters (counter_type, count, inserted_at, updated_at) VALUES ('total_packets', 0, NOW(), NOW()) ON CONFLICT (counter_type) DO NOTHING """) :ok end # ── 1. Geometry index (idx_packets_location) on partitions ────────────── describe "geometry index (idx_packets_location)" do test "parent table has the GiST index" do {:ok, %{rows: rows}} = Repo.query(""" SELECT indexname FROM pg_indexes WHERE tablename = 'packets' AND schemaname = 'public' AND indexname = 'idx_packets_location' """) assert length(rows) == 1 assert [["idx_packets_location"]] = rows end test "index is valid (indisvalid = true) on parent" do {:ok, %{rows: rows}} = Repo.query(""" SELECT indisvalid FROM pg_index WHERE indexrelid = 'idx_packets_location'::regclass """) assert [[true]] = rows end test "parent index has child partition indexes attached via pg_inherits" do # The parent GiST index should have child entries in pg_inherits # linking it to per-partition index relations. {:ok, %{rows: inherited}} = Repo.query(""" SELECT inhrelid::regclass::text FROM pg_inherits WHERE inhparent = 'idx_packets_location'::regclass """) # At least one child partition index must be attached assert inherited != [] # Each inherited index name ends with _location_idx (Postgres auto-named) # or _location_gist_idx (from migration build_and_attach). child_names = List.flatten(inherited) assert Enum.any?(child_names, fn name -> String.ends_with?(name, "_location_idx") or String.ends_with?(name, "_location_gist_idx") end) end test "idx_packets_location is a GiST index on location column" do # Verify the index uses GiST access method on the location column {:ok, %{rows: [[am_name]]}} = Repo.query(""" SELECT am.amname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am am ON am.oid = c.relam WHERE i.indexrelid = 'idx_packets_location'::regclass """) assert am_name == "gist" end end # ── 2. Counter reconciliation ─────────────────────────────────────────── describe "packet counter reconciliation" do test "reconciliation sets counter to exact row count after INSERT" do now = DateTime.truncate(DateTime.utc_now(), :second) # Insert packets via raw SQL (gen_random_uuid() for binary UUID) for i <- 1..5 do Repo.query!( """ INSERT INTO packets (id, sender, base_callsign, ssid, destination, raw_packet, data_type, lat, lon, received_at, inserted_at, updated_at) VALUES (gen_random_uuid(), 'RECON' || $1, 'RECON', $1::text, 'APRS', 'RECON' || $1 || '>APRS:test', 'position', 39.0, -98.0, $2, $2, $2) ON CONFLICT DO NOTHING """, ["#{i}", DateTime.add(now, -i * 60, :second)] ) end {:ok, %{rows: [[total]]}} = Repo.query("SELECT COUNT(*) FROM packets") assert total >= 5 # Set counter to wrong value (simulate drift) Repo.query!(""" UPDATE packet_counters SET count = 999, updated_at = NOW() WHERE counter_type = 'total_packets' """) # Run reconciliation (from migration 20260726000002) Repo.query!(""" UPDATE packet_counters SET count = (SELECT COUNT(*) FROM packets), updated_at = NOW() WHERE counter_type = 'total_packets' """) {:ok, %{rows: [[counter_val]]}} = Repo.query("SELECT count FROM packet_counters WHERE counter_type = 'total_packets'") {:ok, %{rows: [[actual_count]]}} = Repo.query("SELECT COUNT(*) FROM packets") assert counter_val == actual_count end test "reconciliation resets to zero on empty packets table" do # Set counter to wrong value Repo.query!(""" UPDATE packet_counters SET count = 123, updated_at = NOW() WHERE counter_type = 'total_packets' """) # Run reconciliation Repo.query!(""" UPDATE packet_counters SET count = (SELECT COUNT(*) FROM packets), updated_at = NOW() WHERE counter_type = 'total_packets' """) {:ok, %{rows: [[counter_val]]}} = Repo.query("SELECT count FROM packet_counters WHERE counter_type = 'total_packets'") {:ok, %{rows: [[real_count]]}} = Repo.query("SELECT COUNT(*) FROM packets") assert counter_val == real_count end test "get_packet_count() function returns the reconciled counter value" do Repo.query!(""" UPDATE packet_counters SET count = (SELECT COUNT(*) FROM packets), updated_at = NOW() WHERE counter_type = 'total_packets' """) {:ok, %{rows: [[func_count]]}} = Repo.query("SELECT get_packet_count()") {:ok, %{rows: [[real_count]]}} = Repo.query("SELECT COUNT(*) FROM packets") assert func_count == real_count end test "triggers keep counter in sync on insert and delete" do Repo.query!(""" UPDATE packet_counters SET count = (SELECT COUNT(*) FROM packets), updated_at = NOW() WHERE counter_type = 'total_packets' """) {:ok, %{rows: [[before]]}} = Repo.query("SELECT count FROM packet_counters WHERE counter_type = 'total_packets'") now = DateTime.truncate(DateTime.utc_now(), :second) # Insert via raw SQL — triggers should fire {:ok, %{rows: [[inserted_id]]}} = Repo.query( """ INSERT INTO packets (id, sender, base_callsign, ssid, destination, raw_packet, data_type, lat, lon, location, has_position, received_at, inserted_at, updated_at) VALUES (gen_random_uuid(), 'TRIGTST', 'TRIGTST', '0', 'APRS', 'TRIGTST>APRS:test', 'position', 39.0, -98.0, ST_SetSRID(ST_MakePoint(-98.0, 39.0), 4326), true, $1, $1, $1) RETURNING id """, [now] ) {:ok, %{rows: [[after_insert]]}} = Repo.query("SELECT count FROM packet_counters WHERE counter_type = 'total_packets'") assert after_insert == before + 1 # Delete the packet — trigger should decrement Repo.query!("DELETE FROM packets WHERE id = $1", [inserted_id]) {:ok, %{rows: [[after_delete]]}} = Repo.query("SELECT count FROM packet_counters WHERE counter_type = 'total_packets'") assert after_delete == before end end # ── 3. Partition boundary cutoff ──────────────────────────────────────── describe "partition boundary cutoff" do setup do # Ensure today's partition exists so raw INSERTs route correctly {:ok, _} = PartitionManager.ensure_partitions_exist() :ok end test "FOR VALUES FROM ... TO ... uses strict < upper bound" do date = Date.utc_today() partition_name = PartitionManager.partition_name(date) {from_dt, to_dt} = PartitionManager.partition_range(date) # Insert packet at exact partition start (>= FROM bound) {:ok, %{rows: [[start_id]]}} = Repo.query( """ INSERT INTO packets (id, sender, base_callsign, ssid, destination, raw_packet, data_type, received_at, inserted_at, updated_at) VALUES (gen_random_uuid(), 'BDSTART', 'BDSTART', '0', 'APRS', 'BDSTART>APRS:test', 'status', $1, $1, $1) RETURNING id """, [from_dt] ) # Insert packet at exact partition end (< TO bound) {:ok, %{rows: [[end_id]]}} = Repo.query( """ INSERT INTO packets (id, sender, base_callsign, ssid, destination, raw_packet, data_type, received_at, inserted_at, updated_at) VALUES (gen_random_uuid(), 'BDEND', 'BDEND', '0', 'APRS', 'BDEND>APRS:test', 'status', $1, $1, $1) RETURNING id """, [to_dt] ) # Packet at FROM should be in this partition (>= FROM) {:ok, %{rows: [[start_count]]}} = Repo.query("SELECT COUNT(*) FROM #{partition_name} WHERE id = $1::uuid", [start_id]) assert start_count == 1 # Packet at TO should NOT be in this partition (< TO, strict upper bound) {:ok, %{rows: [[end_count]]}} = Repo.query("SELECT COUNT(*) FROM #{partition_name} WHERE id = $1::uuid", [end_id]) assert end_count == 0 end test "packet at boundary timestamp is findable via parent table" do date = Date.utc_today() {_from_dt, to_dt} = PartitionManager.partition_range(date) {:ok, %{rows: [[end_id]]}} = Repo.query( """ INSERT INTO packets (id, sender, base_callsign, ssid, destination, raw_packet, data_type, received_at, inserted_at, updated_at) VALUES (gen_random_uuid(), 'ROUTABLE', 'ROUTABLE', '0', 'APRS', 'ROUTABLE>APRS:test', 'status', $1, $1, $1) RETURNING id """, [to_dt] ) # Packet should be findable via parent table query {:ok, %{rows: [[_]]}} = Repo.query("SELECT sender FROM packets WHERE id = $1::uuid", [end_id]) # Verify it's NOT in today's partition (boundary exclusive) today_name = PartitionManager.partition_name(date) {:ok, %{rows: [[in_today]]}} = Repo.query("SELECT COUNT(*) FROM #{today_name} WHERE id = $1::uuid", [end_id]) assert in_today == 0 end test "drop_old_partitions drops partitions whose upper bound <= cutoff" do old_date = Date.add(Date.utc_today(), -30) old_name = PartitionManager.partition_name(old_date) {from_dt, to_dt} = PartitionManager.partition_range(old_date) Repo.query!(""" CREATE TABLE IF NOT EXISTS #{old_name} PARTITION OF packets FOR VALUES FROM ('#{DateTime.to_iso8601(from_dt)}') TO ('#{DateTime.to_iso8601(to_dt)}') """) assert old_name in PartitionManager.list_partitions() {:ok, dropped} = PartitionManager.drop_old_partitions(8) assert old_name in dropped end test "boundary-day partition is retained when its upper bound > cutoff" do cutoff_date = Date.add(Date.utc_today(), -7) name = PartitionManager.partition_name(cutoff_date) {from_dt, to_dt} = PartitionManager.partition_range(cutoff_date) Repo.query!(""" CREATE TABLE IF NOT EXISTS #{name} PARTITION OF packets FOR VALUES FROM ('#{DateTime.to_iso8601(from_dt)}') TO ('#{DateTime.to_iso8601(to_dt)}') """) {:ok, dropped} = PartitionManager.drop_old_partitions(7) refute name in dropped end end end