mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-01 16:48:19 +00:00
A packet repeats once per observer in the site scan, so grouping can collapse a scan_depth window to fewer packets than the page asked for. That short page set hasMore=false and stranded all older history: YOW dead-ended after 45 packets, ~6 minutes back. Report scan saturation and the floor the scans covered, and treat a saturated short page as more data. Clamp the cursor to the floor so paging on cannot skip the band the scan never read.
645 lines
20 KiB
Go
645 lines
20 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
|
mockdb "github.com/MeshCore-Beacon/beacon-server/db/sqlc/mock"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/ingest"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"go.uber.org/mock/gomock"
|
|
)
|
|
|
|
func TestUpsertPacket_WithTransportCodes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
// little-endian: region=1, subregion=2
|
|
transportCodes := []byte{0x01, 0x00, 0x02, 0x00}
|
|
|
|
mock.EXPECT().
|
|
UpsertPacket(gomock.Any(), gomock.Any()).
|
|
Return(sqlc.UpsertPacketRow{Inserted: true}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
inserted, err := store.UpsertPacket(context.Background(), ingest.UpsertPacketParams{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
TransportCodes: transportCodes,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !inserted {
|
|
t.Error("expected inserted true")
|
|
}
|
|
}
|
|
|
|
func TestUpsertPacket_WithoutTransportCodes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
mock.EXPECT().
|
|
UpsertPacket(gomock.Any(), gomock.Any()).
|
|
Return(sqlc.UpsertPacketRow{Inserted: false}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
inserted, err := store.UpsertPacket(context.Background(), ingest.UpsertPacketParams{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if inserted {
|
|
t.Error("expected inserted false")
|
|
}
|
|
}
|
|
|
|
func TestListPackets_Pagination(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
rows := make([]sqlc.ListPacketsRow, 3)
|
|
for i := range rows {
|
|
rows[i] = sqlc.ListPacketsRow{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
}
|
|
}
|
|
|
|
mock.EXPECT().
|
|
ListPackets(gomock.Any(), gomock.Any()).
|
|
Return(rows, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 2)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(page.Items) != 2 {
|
|
t.Errorf("expected 2 items, got %d", len(page.Items))
|
|
}
|
|
if !page.HasMore {
|
|
t.Error("expected HasMore true")
|
|
}
|
|
if page.NextCursor == nil {
|
|
t.Error("expected NextCursor to be set")
|
|
}
|
|
}
|
|
|
|
func TestListPackets_LatestObserverNil(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
|
|
mock.EXPECT().
|
|
ListPackets(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsRow{
|
|
{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
LatestObserverID: uuid.UUID{}, // zero UUID
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if page.Items[0].LatestObserver != nil {
|
|
t.Error("expected nil LatestObserver for zero UUID")
|
|
}
|
|
}
|
|
|
|
func TestListPackets_LatestObserverSet(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
observerName := "test-observer"
|
|
observerIATA := "YVR"
|
|
|
|
mock.EXPECT().
|
|
ListPackets(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsRow{
|
|
{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
LatestObserverID: observerID,
|
|
LatestObserverName: &observerName,
|
|
LatestObserverIata: observerIATA,
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if page.Items[0].LatestObserver == nil {
|
|
t.Fatal("expected LatestObserver to be set")
|
|
}
|
|
if page.Items[0].LatestObserver.IATA != "" && page.Items[0].LatestObserver.IATA != "YVR" {
|
|
t.Errorf("expected IATA YVR, got %v", page.Items[0].LatestObserver.IATA)
|
|
}
|
|
}
|
|
|
|
func TestListPackets_LatestObserverPathFields(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
pathLengthByte := int16(0x42)
|
|
hashSize := int16(1)
|
|
hopCount := int16(2)
|
|
pathBytes := []byte{0xa1, 0xb2}
|
|
|
|
mock.EXPECT().
|
|
ListPackets(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsRow{
|
|
{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
LatestObserverID: observerID,
|
|
LatestObserverPathLengthByte: pathLengthByte,
|
|
LatestObserverHashSize: hashSize,
|
|
LatestObserverHopCount: hopCount,
|
|
LatestObserverPathBytes: pathBytes,
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
obs := page.Items[0].LatestObserver
|
|
if obs == nil {
|
|
t.Fatal("expected LatestObserver to be set")
|
|
}
|
|
if obs.PathLength == nil {
|
|
t.Fatal("expected PathLength to be set")
|
|
}
|
|
if obs.PathLength.HashSize != 1 || obs.PathLength.HopCount != 2 {
|
|
t.Errorf("expected hashSize=1 hopCount=2, got hashSize=%d hopCount=%d", obs.PathLength.HashSize, obs.PathLength.HopCount)
|
|
}
|
|
if obs.PathLength.Raw != "42" {
|
|
t.Errorf("expected raw 42, got %s", obs.PathLength.Raw)
|
|
}
|
|
if obs.PathBytes == nil || *obs.PathBytes != "a1b2" {
|
|
t.Errorf("expected pathBytes a1b2, got %v", obs.PathBytes)
|
|
}
|
|
// Resolution stays a detail-view-only feature on this list endpoint -- deliberately unset.
|
|
if obs.ResolvedPath != nil || obs.ResolvedSource != nil || obs.ResolvedDestination != nil {
|
|
t.Error("expected no resolved path/source/destination on the list endpoint")
|
|
}
|
|
}
|
|
|
|
func TestInsertObservation_Success(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
|
|
mock.EXPECT().
|
|
InsertObservation(gomock.Any(), gomock.Any()).
|
|
Return(sqlc.PacketObservation{ID: 1}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
inserted, err := store.InsertObservation(context.Background(), ingest.InsertObservationParams{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
ObserverID: observerID,
|
|
IATA: "YVR",
|
|
HeardAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !inserted {
|
|
t.Error("expected inserted true")
|
|
}
|
|
}
|
|
|
|
func TestInsertObservation_Conflict(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
mock.EXPECT().
|
|
InsertObservation(gomock.Any(), gomock.Any()).
|
|
Return(sqlc.PacketObservation{}, pgx.ErrNoRows)
|
|
|
|
store := &Store{q: mock}
|
|
inserted, err := store.InsertObservation(context.Background(), ingest.InsertObservationParams{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
HeardAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected nil error on conflict, got %v", err)
|
|
}
|
|
if inserted {
|
|
t.Error("expected inserted false on conflict")
|
|
}
|
|
}
|
|
|
|
func TestGetPacket_Basic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
packetHash := []byte{0xde, 0xad, 0xbe, 0xef}
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
sourceBroker := "mqtt://test"
|
|
|
|
mock.EXPECT().
|
|
GetPacketByHash(gomock.Any(), packetHash).
|
|
Return(sqlc.GetPacketByHashRow{
|
|
PacketHash: packetHash,
|
|
RawHeader: []byte{0x01},
|
|
RawPayload: []byte{0x02},
|
|
ParsedPayload: []byte(`{}`),
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
ListObservationsForPacket(gomock.Any(), packetHash).
|
|
Return([]sqlc.ListObservationsForPacketRow{
|
|
{
|
|
ID: 1,
|
|
HeardAt: heardAt,
|
|
Iata: "YVR",
|
|
SourceBroker: &sourceBroker,
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
packet, err := store.GetPacket(context.Background(), packetHash)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if packet.PacketHash != "deadbeef" {
|
|
t.Errorf("expected PacketHash deadbeef, got %s", packet.PacketHash)
|
|
}
|
|
if packet.ObservationCount != 1 {
|
|
t.Errorf("expected ObservationCount 1, got %d", packet.ObservationCount)
|
|
}
|
|
}
|
|
|
|
func TestGetPacket_TransportCodes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
packetHash := []byte{0xde, 0xad, 0xbe, 0xef}
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
sourceBroker := "mqtt://test"
|
|
hasTransport := true
|
|
regionCode := int32(1)
|
|
subRegionCode := int32(2)
|
|
|
|
mock.EXPECT().
|
|
GetPacketByHash(gomock.Any(), packetHash).
|
|
Return(sqlc.GetPacketByHashRow{
|
|
PacketHash: packetHash,
|
|
RawHeader: []byte{0x01},
|
|
RawPayload: []byte{0x02},
|
|
ParsedPayload: []byte(`{}`),
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
TransportCodesPresent: &hasTransport,
|
|
RegionCode: ®ionCode,
|
|
SubRegionCode: &subRegionCode,
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
ListObservationsForPacket(gomock.Any(), packetHash).
|
|
Return([]sqlc.ListObservationsForPacketRow{
|
|
{ID: 1, HeardAt: heardAt, Iata: "YVR", SourceBroker: &sourceBroker},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
packet, err := store.GetPacket(context.Background(), packetHash)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if packet.TransportCodes == nil {
|
|
t.Fatal("expected TransportCodes to be set")
|
|
}
|
|
if packet.TransportCodes.RegionCode != 1 {
|
|
t.Errorf("expected RegionCode 1, got %d", packet.TransportCodes.RegionCode)
|
|
}
|
|
if packet.TransportCodes.SubRegionCode != 2 {
|
|
t.Errorf("expected SubRegionCode 2, got %d", packet.TransportCodes.SubRegionCode)
|
|
}
|
|
}
|
|
|
|
func TestGetPacket_FirstToLastMs(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
packetHash := []byte{0xde, 0xad, 0xbe, 0xef}
|
|
sourceBroker := "mqtt://test"
|
|
t1 := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
t2 := pgtype.Timestamptz{Time: time.UnixMilli(1700000001000), Valid: true}
|
|
|
|
mock.EXPECT().
|
|
GetPacketByHash(gomock.Any(), packetHash).
|
|
Return(sqlc.GetPacketByHashRow{
|
|
PacketHash: packetHash,
|
|
RawHeader: []byte{0x01},
|
|
RawPayload: []byte{0x02},
|
|
ParsedPayload: []byte(`{}`),
|
|
FirstHeardAt: t1,
|
|
LastHeardAt: t2,
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
ListObservationsForPacket(gomock.Any(), packetHash).
|
|
Return([]sqlc.ListObservationsForPacketRow{
|
|
{ID: 1, HeardAt: t1, Iata: "YVR", SourceBroker: &sourceBroker},
|
|
{ID: 2, HeardAt: t2, Iata: "YVR", SourceBroker: &sourceBroker},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
packet, err := store.GetPacket(context.Background(), packetHash)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if packet.FirstToLastMs != 1000 {
|
|
t.Errorf("expected FirstToLastMs 1000, got %d", packet.FirstToLastMs)
|
|
}
|
|
}
|
|
|
|
func TestListPacketsAfterID_PassesIATAsAsArray(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
mock.EXPECT().
|
|
ListPacketsAfterID(gomock.Any(), sqlc.ListPacketsAfterIDParams{
|
|
ID: 0,
|
|
Column2: int16(-1),
|
|
Column3: int16(-1),
|
|
Column4: []string{"ALF", "YYZ"},
|
|
Column5: "",
|
|
Limit: 50,
|
|
}).
|
|
Return([]sqlc.ListPacketsAfterIDRow{}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
_, err := store.ListPacketsAfterID(context.Background(), 0, -1, -1, []string{"ALF", "YYZ"}, "", 50)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListPacketsAfterID_LatestObserverPathFields(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
|
|
mock.EXPECT().
|
|
ListPacketsAfterID(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsAfterIDRow{
|
|
{
|
|
PacketHash: []byte{0xde, 0xad},
|
|
FirstHeardAt: heardAt,
|
|
LastHeardAt: heardAt,
|
|
LatestObserverID: observerID,
|
|
LatestObserverPathLengthByte: 0x42,
|
|
LatestObserverHashSize: 1,
|
|
LatestObserverHopCount: 2,
|
|
LatestObserverPathBytes: []byte{0xa1, 0xb2},
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
items, err := store.ListPacketsAfterID(context.Background(), 0, -1, -1, nil, "", 50)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
obs := items[0].LatestObserver
|
|
if obs == nil || obs.PathLength == nil {
|
|
t.Fatal("expected LatestObserver and PathLength to be set")
|
|
}
|
|
if obs.PathLength.HashSize != 1 || obs.PathLength.HopCount != 2 {
|
|
t.Errorf("expected hashSize=1 hopCount=2, got hashSize=%d hopCount=%d", obs.PathLength.HashSize, obs.PathLength.HopCount)
|
|
}
|
|
if obs.PathBytes == nil || *obs.PathBytes != "a1b2" {
|
|
t.Errorf("expected pathBytes a1b2, got %v", obs.PathBytes)
|
|
}
|
|
}
|
|
|
|
func TestListNodeObservations_Pagination(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
|
|
|
rows := make([]sqlc.ListNodeObservationsRow, 3)
|
|
for i := range rows {
|
|
rows[i] = sqlc.ListNodeObservationsRow{
|
|
ID: int64(i + 1),
|
|
HeardAt: heardAt,
|
|
}
|
|
}
|
|
|
|
mock.EXPECT().
|
|
ListNodeObservations(gomock.Any(), gomock.Any()).
|
|
Return(rows, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListNodeObservations(context.Background(), nodeID, 0, 2)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(page.Items) != 2 {
|
|
t.Errorf("expected 2 items, got %d", len(page.Items))
|
|
}
|
|
if !page.HasMore {
|
|
t.Error("expected HasMore true")
|
|
}
|
|
if page.NextCursor == nil {
|
|
t.Error("expected NextCursor to be set")
|
|
}
|
|
}
|
|
|
|
func TestListPackets_IATAFilterRoutesToObservationIndex(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
siteHeard := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
globalHeard := time.Date(2026, 7, 2, 8, 0, 0, 0, time.UTC)
|
|
|
|
// limit=1 with 2 rows returned exercises the +1 trick and the trim.
|
|
mock.EXPECT().
|
|
ListPacketsByIATAs(gomock.Any(), gomock.Any()).
|
|
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsByIATAsParams) ([]sqlc.ListPacketsByIATAsRow, error) {
|
|
if len(p.Iatas) != 1 || p.Iatas[0] != "ALF" {
|
|
t.Errorf("iatas param = %v, want [ALF]", p.Iatas)
|
|
}
|
|
if p.PageLimit != 2 { // limit+1
|
|
t.Errorf("page limit = %d, want 2", p.PageLimit)
|
|
}
|
|
if p.ScanDepth != 16 { // (limit+1)*8
|
|
t.Errorf("scan depth = %d, want 16", p.ScanDepth)
|
|
}
|
|
return []sqlc.ListPacketsByIATAsRow{
|
|
{
|
|
PacketHash: []byte{0x01},
|
|
LastHeardAt: pgtype.Timestamptz{Time: globalHeard, Valid: true},
|
|
SiteHeardAt: pgtype.Timestamptz{Time: siteHeard, Valid: true},
|
|
},
|
|
{
|
|
PacketHash: []byte{0x02},
|
|
LastHeardAt: pgtype.Timestamptz{Time: globalHeard, Valid: true},
|
|
SiteHeardAt: pgtype.Timestamptz{Time: siteHeard.Add(-time.Hour), Valid: true},
|
|
},
|
|
}, nil
|
|
})
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, []string{"ALF"}, nil, time.Time{}, time.Time{}, 0, 1)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(page.Items) != 1 || !page.HasMore {
|
|
t.Fatalf("got %d items hasMore=%v, want 1 item hasMore=true", len(page.Items), page.HasMore)
|
|
}
|
|
// Cursor must follow site-local recency, not the packet's global last_heard_at.
|
|
if page.NextCursor == nil || *page.NextCursor != siteHeard.UnixMilli() {
|
|
t.Errorf("next cursor = %v, want %d (site heard_at)", page.NextCursor, siteHeard.UnixMilli())
|
|
}
|
|
}
|
|
|
|
// A site whose packets are heard by more observers than scan_depth allows
|
|
// for collapses to a short page while history remains below the floor.
|
|
// The page must still report more, or the client stops paging for good.
|
|
func TestListPackets_SaturatedShortPageKeepsPaging(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
oldest := time.Date(2026, 8, 7, 12, 57, 25, 0, time.UTC)
|
|
floor := time.Date(2026, 8, 7, 12, 50, 0, 0, time.UTC)
|
|
|
|
// Two rows for a limit of 5: the scan filled up but collapsed to a short page.
|
|
mock.EXPECT().
|
|
ListPacketsByIATAs(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsByIATAsRow{
|
|
{
|
|
PacketHash: []byte{0x01},
|
|
SiteHeardAt: pgtype.Timestamptz{Time: oldest.Add(time.Minute), Valid: true},
|
|
ScanSaturated: true,
|
|
ScanFloor: pgtype.Timestamptz{Time: floor, Valid: true},
|
|
},
|
|
{
|
|
PacketHash: []byte{0x02},
|
|
SiteHeardAt: pgtype.Timestamptz{Time: oldest, Valid: true},
|
|
ScanSaturated: true,
|
|
ScanFloor: pgtype.Timestamptz{Time: floor, Valid: true},
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW"}, nil, time.Time{}, time.Time{}, 0, 5)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(page.Items) != 2 {
|
|
t.Fatalf("got %d items, want 2", len(page.Items))
|
|
}
|
|
if !page.HasMore {
|
|
t.Error("hasMore = false on a saturated short page, want true")
|
|
}
|
|
// Oldest returned item sits above the floor, so it is the safe cursor.
|
|
if page.NextCursor == nil || *page.NextCursor != oldest.UnixMilli() {
|
|
t.Errorf("next cursor = %v, want %d (oldest item)", page.NextCursor, oldest.UnixMilli())
|
|
}
|
|
}
|
|
|
|
// The floor is newer than the oldest item, so paging past it would skip the
|
|
// band the saturated site never read.
|
|
func TestListPackets_CursorClampsToScanFloor(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
floor := time.Date(2026, 8, 7, 12, 50, 0, 0, time.UTC)
|
|
oldest := floor.Add(-30 * time.Minute)
|
|
|
|
mock.EXPECT().
|
|
ListPacketsByIATAs(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsByIATAsRow{{
|
|
PacketHash: []byte{0x01},
|
|
SiteHeardAt: pgtype.Timestamptz{Time: oldest, Valid: true},
|
|
ScanSaturated: true,
|
|
ScanFloor: pgtype.Timestamptz{Time: floor, Valid: true},
|
|
}}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW", "YYZ"}, nil, time.Time{}, time.Time{}, 0, 5)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if page.NextCursor == nil || *page.NextCursor != floor.UnixMilli() {
|
|
t.Errorf("next cursor = %v, want %d (clamped to floor)", page.NextCursor, floor.UnixMilli())
|
|
}
|
|
}
|
|
|
|
// An unsaturated scan read the site dry, so paging has to stop.
|
|
func TestListPackets_UnsaturatedShortPageEndsPaging(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
oldest := time.Date(2026, 8, 7, 12, 57, 25, 0, time.UTC)
|
|
|
|
mock.EXPECT().
|
|
ListPacketsByIATAs(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsByIATAsRow{{
|
|
PacketHash: []byte{0x01},
|
|
SiteHeardAt: pgtype.Timestamptz{Time: oldest, Valid: true},
|
|
ScanSaturated: false,
|
|
}}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW"}, nil, time.Time{}, time.Time{}, 0, 5)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if page.HasMore {
|
|
t.Error("hasMore = true on an exhausted site, want false")
|
|
}
|
|
if page.NextCursor != nil {
|
|
t.Errorf("next cursor = %v, want nil", page.NextCursor)
|
|
}
|
|
}
|
|
|
|
func TestListPackets_UnfilteredKeepsGlobalQuery(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
// gomock is strict: an unexpected ListPacketsByIATAs call fails the test.
|
|
mock.EXPECT().
|
|
ListPackets(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListPacketsRow{}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
if _, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 50); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|