From c03abb1f535daaa8a4b30c1fb56f1fa345f3dd82 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Mon, 15 Jun 2026 13:47:31 -0700 Subject: [PATCH] tests(db): finish integration db/api tests adds nodes, observers and packets --- db/nodes_test.go | 368 +++++++++++++++++++++++++++++ db/observers_test.go | 549 +++++++++++++++++++++++++++++++++++++++++++ db/packets_test.go | 363 ++++++++++++++++++++++++++++ 3 files changed, 1280 insertions(+) create mode 100644 db/nodes_test.go create mode 100644 db/observers_test.go create mode 100644 db/packets_test.go diff --git a/db/nodes_test.go b/db/nodes_test.go new file mode 100644 index 0000000..5576d2f --- /dev/null +++ b/db/nodes_test.go @@ -0,0 +1,368 @@ +package db + +import ( + "context" + "errors" + "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/pgtype" + "go.uber.org/mock/gomock" +) + +func TestUpsertNode_WithRadio(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + freq := float32(915.0) + sf := int16(7) + bw := float32(125.0) + + mock.EXPECT(). + UpsertNode(gomock.Any(), gomock.Any()). + Return(sqlc.Node{ID: nodeID}, nil) + + store := &Store{q: mock} + id, err := store.UpsertNode(context.Background(), ingest.UpsertNodeParams{ + PublicKey: []byte{0x01}, + NodeType: 1, + Name: "test-node", + }, ingest.RadioSettings{FreqMHz: freq, SF: sf, BWKHz: bw}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != nodeID { + t.Errorf("expected ID %s, got %s", nodeID, id) + } +} + +func TestUpsertNode_WithoutRadio(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + UpsertNode(gomock.Any(), gomock.Any()). + Return(sqlc.Node{ID: nodeID}, nil) + + store := &Store{q: mock} + id, err := store.UpsertNode(context.Background(), ingest.UpsertNodeParams{ + PublicKey: []byte{0x01}, + NodeType: 1, + Name: "test-node", + }, ingest.RadioSettings{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != nodeID { + t.Errorf("expected ID %s, got %s", nodeID, id) + } +} + +func TestSetNodeCapability_BothTrue(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT().SetNodeMultibytePaths(gomock.Any(), nodeID).Return(nil) + mock.EXPECT().SetNodeMultibyteTraces(gomock.Any(), nodeID).Return(nil) + + store := &Store{q: mock} + err := store.SetNodeCapability(context.Background(), nodeID, true, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetNodeCapability_PathsOnly(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT().SetNodeMultibytePaths(gomock.Any(), nodeID).Return(nil) + + store := &Store{q: mock} + err := store.SetNodeCapability(context.Background(), nodeID, true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetNodeCapability_NeitherSet(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + // no EXPECT — neither sqlc method should be called + store := &Store{q: mock} + err := store.SetNodeCapability(context.Background(), nodeID, false, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestListNodes_Pagination(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + lastSeen := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + + rows := make([]sqlc.ListNodesRow, 3) + for i := range rows { + rows[i] = sqlc.ListNodesRow{ + ID: nodeID, + PublicKey: []byte{0x01}, + LastSeen: lastSeen, + } + } + + mock.EXPECT(). + ListNodes(gomock.Any(), gomock.Any()). + Return(rows, nil) + + store := &Store{q: mock} + page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", 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 TestListNodes_IATAsUnmarshal(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + iatasJSON := []byte(`[{"iata":"YVR","last_seen":1700000000000}]`) + + mock.EXPECT(). + ListNodes(gomock.Any(), gomock.Any()). + Return([]sqlc.ListNodesRow{ + { + ID: nodeID, + PublicKey: []byte{0x01}, + Iatas: iatasJSON, + }, + }, nil) + + store := &Store{q: mock} + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page.Items[0].IATAs) != 1 { + t.Errorf("expected 1 IATA, got %d", len(page.Items[0].IATAs)) + } + if page.Items[0].IATAs[0].IATA != "YVR" { + t.Errorf("expected IATA YVR, got %s", page.Items[0].IATAs[0].IATA) + } +} + +func TestListNodes_RadioStringFormatting(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + freq := float32(915.0) + sf := int16(7) + bw := float32(125.0) + + mock.EXPECT(). + ListNodes(gomock.Any(), gomock.Any()). + Return([]sqlc.ListNodesRow{ + { + ID: nodeID, + PublicKey: []byte{0x01}, + RadioFreqMhz: &freq, + RadioSf: &sf, + RadioBwKhz: &bw, + }, + }, nil) + + store := &Store{q: mock} + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if page.Items[0].Radio == nil { + t.Fatal("expected Radio to be set") + } + if *page.Items[0].Radio != "915.0,125,7" { + t.Errorf("expected Radio 915.0,125,7, got %s", *page.Items[0].Radio) + } +} + +func TestGetNode_LastAdvertAt(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + lastAdvert := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + + mock.EXPECT(). + GetNodeByID(gomock.Any(), nodeID). + Return(sqlc.GetNodeByIDRow{ + ID: nodeID, + PublicKey: []byte{0x01}, + FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastAdvertAt: lastAdvert, + }, nil) + + mock.EXPECT(). + GetNodeNeighbors(gomock.Any(), nodeID). + Return([]sqlc.GetNodeNeighborsRow{}, nil) + + store := &Store{q: mock} + node, err := store.GetNode(context.Background(), nodeID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if node.LastAdvertAt == nil { + t.Fatal("expected LastAdvertAt to be set") + } + if *node.LastAdvertAt != 1700000000000 { + t.Errorf("expected LastAdvertAt 1700000000000, got %d", *node.LastAdvertAt) + } +} + +func TestGetNode_LastAdvertAtNil(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + GetNodeByID(gomock.Any(), nodeID). + Return(sqlc.GetNodeByIDRow{ + ID: nodeID, + PublicKey: []byte{0x01}, + FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }, nil) + + mock.EXPECT(). + GetNodeNeighbors(gomock.Any(), nodeID). + Return([]sqlc.GetNodeNeighborsRow{}, nil) + + store := &Store{q: mock} + node, err := store.GetNode(context.Background(), nodeID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if node.LastAdvertAt != nil { + t.Errorf("expected nil LastAdvertAt, got %d", *node.LastAdvertAt) + } +} + +func TestGetNodesByIDs_Mapping(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + name := "test-node" + + mock.EXPECT(). + GetNodesByIDs(gomock.Any(), []uuid.UUID{nodeID}). + Return([]sqlc.GetNodesByIDsRow{ + { + ID: nodeID, + Name: &name, + PublicKey: []byte{0xde, 0xad}, + }, + }, nil) + + store := &Store{q: mock} + result, err := store.GetNodesByIDs(context.Background(), []uuid.UUID{nodeID}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + node, ok := result[nodeID] + if !ok { + t.Fatal("expected nodeID in result map") + } + if node.PublicKey != "dead" { + t.Errorf("expected PublicKey dead, got %s", node.PublicKey) + } +} + +func TestGetNodeNeighbors_Deduplication(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + neighborID := uuid.MustParse("00000000-0000-0000-0000-000000000002") + earlier := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + later := pgtype.Timestamptz{Time: time.UnixMilli(1700000001000), Valid: true} + + mock.EXPECT(). + GetNodeNeighbors(gomock.Any(), nodeID). + Return([]sqlc.GetNodeNeighborsRow{ + { + ID: neighborID, + PublicKey: []byte{0x01}, + Iata: "YVR", + ObservationCount: 3, + FirstSeen: earlier, + LastSeen: earlier, + }, + { + ID: neighborID, + PublicKey: []byte{0x01}, + Iata: "YYJ", + ObservationCount: 2, + FirstSeen: earlier, + LastSeen: later, + }, + }, nil) + + store := &Store{q: mock} + neighbors, err := store.GetNodeNeighbors(context.Background(), nodeID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(neighbors) != 1 { + t.Fatalf("expected 1 deduplicated neighbor, got %d", len(neighbors)) + } + if neighbors[0].ObservationCount != 5 { + t.Errorf("expected ObservationCount 5, got %d", neighbors[0].ObservationCount) + } + if neighbors[0].IATA != "YYJ" { + t.Errorf("expected IATA YYJ (most recent), got %s", neighbors[0].IATA) + } +} + +func TestGetNodeNeighbors_DBError(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + GetNodeNeighbors(gomock.Any(), nodeID). + Return(nil, errors.New("db error")) + + store := &Store{q: mock} + _, err := store.GetNodeNeighbors(context.Background(), nodeID) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/db/observers_test.go b/db/observers_test.go new file mode 100644 index 0000000..ab0b560 --- /dev/null +++ b/db/observers_test.go @@ -0,0 +1,549 @@ +package db + +import ( + "context" + "errors" + "testing" + "time" + + sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" + mockdb "github.com/MeshCore-Beacon/beacon-server/db/sqlc/mock" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "go.uber.org/mock/gomock" +) + +func TestUpsertObserver_NilDisplayName(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + pubkey := []byte{0x01, 0x02} + + mock.EXPECT(). + UpsertObserver(gomock.Any(), pubkey). + Return(sqlc.Observer{ID: observerID, DisplayName: nil}, nil) + + store := &Store{q: mock} + id, displayName, err := store.UpsertObserver(context.Background(), pubkey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != observerID { + t.Errorf("expected ID %s, got %s", observerID, id) + } + if displayName != "" { + t.Errorf("expected empty displayName, got %s", displayName) + } +} + +func TestUpsertObserver_WithDisplayName(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + pubkey := []byte{0x01, 0x02} + name := "test-observer" + + mock.EXPECT(). + UpsertObserver(gomock.Any(), pubkey). + Return(sqlc.Observer{ID: observerID, DisplayName: &name}, nil) + + store := &Store{q: mock} + _, displayName, err := store.UpsertObserver(context.Background(), pubkey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if displayName != "test-observer" { + t.Errorf("expected displayName test-observer, got %s", displayName) + } +} + +func TestListObservers_Pagination(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + lastStatusAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + rows := make([]sqlc.ListObserversRow, 3) + for i := range rows { + rows[i] = sqlc.ListObserversRow{ + ID: observerID, + LastStatusAt: lastStatusAt, + } + } + + mock.EXPECT(). + ListObservers(gomock.Any(), gomock.Any()). + Return(rows, nil) + + store := &Store{q: mock} + page, err := store.ListObservers(context.Background(), []string{"YVR"}, "", "", "", "", "", 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 TestListObservers_RadioStringFormatting(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + freq := float32(915.0) + sf := int16(7) + bw := float32(125.0) + + mock.EXPECT(). + ListObservers(gomock.Any(), gomock.Any()). + Return([]sqlc.ListObserversRow{ + { + ID: observerID, + RadioFreqMhz: &freq, + RadioSf: &sf, + RadioBwKhz: &bw, + }, + }, nil) + + store := &Store{q: mock} + page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(page.Items)) + } + if page.Items[0].Radio == nil { + t.Fatal("expected Radio to be set") + } + if *page.Items[0].Radio != "915.0,125,7" { + t.Errorf("expected Radio 915.0,125,7, got %s", *page.Items[0].Radio) + } +} + +func TestListObservers_NilRadioFields(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + ListObservers(gomock.Any(), gomock.Any()). + Return([]sqlc.ListObserversRow{ + {ID: observerID, RadioFreqMhz: nil, RadioSf: nil, RadioBwKhz: nil}, + }, nil) + + store := &Store{q: mock} + page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if page.Items[0].Radio != nil { + t.Errorf("expected nil Radio, got %s", *page.Items[0].Radio) + } +} + +func TestListObservers_DBError(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + mock.EXPECT(). + ListObservers(gomock.Any(), gomock.Any()). + Return(nil, errors.New("db error")) + + store := &Store{q: mock} + _, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, 10) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestGetObserver_OnlineStatus(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + obsCount := int64(10) + + mock.EXPECT(). + GetObserverByID(gomock.Any(), observerID). + Return(sqlc.Observer{ + ID: observerID, + PublicKey: []byte{0x01}, + ObservationCount: &obsCount, + FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastStatusAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Minute), Valid: true}, + }, nil) + + mock.EXPECT(). + GetObserverBrokers(gomock.Any(), observerID). + Return([]sqlc.GetObserverBrokersRow{}, nil) + + mock.EXPECT(). + GetObserverScopes(gomock.Any(), observerID). + Return([]string{"default"}, nil) + + mock.EXPECT(). + GetObserverLastIATA(gomock.Any(), observerID). + Return("YVR", nil) + + store := &Store{q: mock} + observer, err := store.GetObserver(context.Background(), observerID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if observer.Status != "online" { + t.Errorf("expected status online, got %s", observer.Status) + } + if observer.IATA != "YVR" { + t.Errorf("expected IATA YVR, got %s", observer.IATA) + } +} + +func TestGetObserver_OfflineStatus(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + obsCount := int64(10) + + mock.EXPECT(). + GetObserverByID(gomock.Any(), observerID). + Return(sqlc.Observer{ + ID: observerID, + PublicKey: []byte{0x01}, + ObservationCount: &obsCount, + FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + LastStatusAt: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true}, + }, nil) + + mock.EXPECT(). + GetObserverBrokers(gomock.Any(), observerID). + Return([]sqlc.GetObserverBrokersRow{}, nil) + + mock.EXPECT(). + GetObserverScopes(gomock.Any(), observerID). + Return([]string{}, nil) + + mock.EXPECT(). + GetObserverLastIATA(gomock.Any(), observerID). + Return("YVR", nil) + + store := &Store{q: mock} + observer, err := store.GetObserver(context.Background(), observerID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if observer.Status != "offline" { + t.Errorf("expected status offline, got %s", observer.Status) + } +} + +func TestGetObserver_BrokerLastPacketAtNil(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + obsCount := int64(10) + + mock.EXPECT(). + GetObserverByID(gomock.Any(), observerID). + Return(sqlc.Observer{ + ID: observerID, + PublicKey: []byte{0x01}, + ObservationCount: &obsCount, + FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, + LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }, nil) + + mock.EXPECT(). + GetObserverBrokers(gomock.Any(), observerID). + Return([]sqlc.GetObserverBrokersRow{ + { + BrokerName: "mqtt://test", + LastPacketAt: pgtype.Timestamptz{Valid: false}, + LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }, + }, nil) + + mock.EXPECT(). + GetObserverScopes(gomock.Any(), observerID). + Return([]string{}, nil) + + mock.EXPECT(). + GetObserverLastIATA(gomock.Any(), observerID). + Return("YVR", nil) + + store := &Store{q: mock} + observer, err := store.GetObserver(context.Background(), observerID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(observer.Brokers) != 1 { + t.Fatalf("expected 1 broker, got %d", len(observer.Brokers)) + } + if observer.Brokers[0].LastPacketAt != 0 { + t.Errorf("expected LastPacketAt 0 for nil, got %d", observer.Brokers[0].LastPacketAt) + } +} + +func TestGetObserver_DBError(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + GetObserverByID(gomock.Any(), observerID). + Return(sqlc.Observer{}, errors.New("db error")) + + store := &Store{q: mock} + _, err := store.GetObserver(context.Background(), observerID) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestGetObserverTelemetry_Mapping(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + reportedAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + batteryMV := int32(3700) + noiseFloor := float32(-90.0) + uptime := int64(3600) + + mock.EXPECT(). + GetObserverTelemetry(gomock.Any(), gomock.Any()). + Return([]sqlc.GetObserverTelemetryRow{ + { + ReportedAt: reportedAt, + BatteryVoltageMv: &batteryMV, + NoiseFloorDb: &noiseFloor, + UptimeSeconds: &uptime, + }, + }, nil) + + store := &Store{q: mock} + result, err := store.GetObserverTelemetry(context.Background(), observerID, time.Time{}, time.Time{}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Points) != 1 { + t.Fatalf("expected 1 point, got %d", len(result.Points)) + } + if result.Points[0].T != 1700000000000 { + t.Errorf("expected T 1700000000000, got %d", result.Points[0].T) + } + if *result.Points[0].BatteryMV != 3700 { + t.Errorf("expected BatteryMV 3700, got %d", *result.Points[0].BatteryMV) + } +} + +func TestGetObserverTelemetry_Empty(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + GetObserverTelemetry(gomock.Any(), gomock.Any()). + Return([]sqlc.GetObserverTelemetryRow{}, nil) + + store := &Store{q: mock} + result, err := store.GetObserverTelemetry(context.Background(), observerID, time.Time{}, time.Time{}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Points) != 0 { + t.Errorf("expected 0 points, got %d", len(result.Points)) + } +} + +func TestGetObserverTelemetryBucketed_Mapping(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + bucket := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + + mock.EXPECT(). + GetObserverTelemetryBucketed(gomock.Any(), gomock.Any()). + Return([]sqlc.GetObserverTelemetryBucketedRow{ + { + Bucket: bucket, + BatteryVoltageMv: 3700, + NoiseFloorDb: -90.0, + UptimeSeconds: 3600, + }, + }, nil) + + store := &Store{q: mock} + points, err := store.GetObserverTelemetryBucketed(context.Background(), observerID, time.Time{}, time.Time{}, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(points) != 1 { + t.Fatalf("expected 1 point, got %d", len(points)) + } + if points[0].T != 1700000000000 { + t.Errorf("expected T 1700000000000, got %d", points[0].T) + } + if *points[0].BatteryMV != 3700 { + t.Errorf("expected BatteryMV 3700, got %d", *points[0].BatteryMV) + } +} + +func TestListObserverAdverts_Pagination(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true} + + rows := make([]sqlc.ListObserverAdvertsRow, 3) + for i := range rows { + rows[i] = sqlc.ListObserverAdvertsRow{ + ID: int64(i + 1), + HeardAt: heardAt, + } + } + + mock.EXPECT(). + ListObserverAdverts(gomock.Any(), gomock.Any()). + Return(rows, nil) + + store := &Store{q: mock} + page, err := store.ListObserverAdverts(context.Background(), observerID, 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 TestListObserverAdverts_DBError(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + ListObserverAdverts(gomock.Any(), gomock.Any()). + Return(nil, errors.New("db error")) + + store := &Store{q: mock} + _, err := store.ListObserverAdverts(context.Background(), observerID, 0, 10) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestGetObserverRadio_NilFields(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + GetObserverRadio(gomock.Any(), observerID). + Return(sqlc.GetObserverRadioRow{}, nil) + + store := &Store{q: mock} + settings, err := store.GetObserverRadio(context.Background(), observerID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.FreqMHz != 0 { + t.Errorf("expected FreqMHz 0, got %f", settings.FreqMHz) + } + if settings.SF != 0 { + t.Errorf("expected SF 0, got %d", settings.SF) + } +} + +func TestGetObserverRadio_WithFields(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + freq := float32(915.0) + sf := int16(7) + bw := float32(125.0) + cr := int16(5) + + mock.EXPECT(). + GetObserverRadio(gomock.Any(), observerID). + Return(sqlc.GetObserverRadioRow{ + RadioFreqMhz: &freq, + RadioSf: &sf, + RadioBwKhz: &bw, + RadioCr: &cr, + }, nil) + + store := &Store{q: mock} + settings, err := store.GetObserverRadio(context.Background(), observerID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.FreqMHz != 915.0 { + t.Errorf("expected FreqMHz 915.0, got %f", settings.FreqMHz) + } + if settings.SF != 7 { + t.Errorf("expected SF 7, got %d", settings.SF) + } + if settings.BWKHz != 125.0 { + t.Errorf("expected BWKHz 125.0, got %f", settings.BWKHz) + } +} + +func TestIsObserverByPubkey_Found(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + pubkey := []byte{0x01, 0x02} + + mock.EXPECT(). + GetObserverByPubkey(gomock.Any(), pubkey). + Return(sqlc.Observer{}, nil) + + store := &Store{q: mock} + if !store.IsObserverByPubkey(context.Background(), pubkey) { + t.Error("expected true for found observer") + } +} + +func TestIsObserverByPubkey_NotFound(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + pubkey := []byte{0x01, 0x02} + + mock.EXPECT(). + GetObserverByPubkey(gomock.Any(), pubkey). + Return(sqlc.Observer{}, errors.New("not found")) + + store := &Store{q: mock} + if store.IsObserverByPubkey(context.Background(), pubkey) { + t.Error("expected false for missing observer") + } +} diff --git a/db/packets_test.go b/db/packets_test.go new file mode 100644 index 0000000..590fc30 --- /dev/null +++ b/db/packets_test.go @@ -0,0 +1,363 @@ +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(), 0, 0, 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(), 0, 0, 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(), 0, 0, 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 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 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") + } +}