mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-11-12 03:07:36 +00:00
* feat(db): packet type domains, row-cursor indexes and the inverted-heard index * feat(api): exact page cursors for packets, nodes and observers * feat(db): normalise packet list filters before choosing a statement * feat(db): one packets statement per filter shape with an exact row cursor * fix(db): let the window statements decide since after until * feat(db): exact keyset cursors for nodes and observers; observers page by last_seen * feat(api): pageCursor on packets, nodes and observers; it wins over cursor * test(planguard): packets statements and node/observer keysets leave pending; diff and storediff cases * chore: bump version to 2.1.0 * test(db): turn off JIT in the keyset cursor tests
785 lines
25 KiB
Go
785 lines
25 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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/api"
|
|
"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_ComputesClockDrift(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
deviceTime := uint32(time.Now().Add(-10 * time.Minute).Unix())
|
|
|
|
var captured sqlc.UpsertNodeParams
|
|
mock.EXPECT().
|
|
UpsertNode(gomock.Any(), gomock.Any()).
|
|
DoAndReturn(func(_ context.Context, p sqlc.UpsertNodeParams) (sqlc.Node, error) {
|
|
captured = p
|
|
return sqlc.Node{ID: nodeID}, nil
|
|
})
|
|
|
|
store := &Store{q: mock}
|
|
_, err := store.UpsertNode(context.Background(), ingest.UpsertNodeParams{
|
|
PublicKey: []byte{0x01},
|
|
NodeType: 2, // repeater
|
|
Name: "test-repeater",
|
|
AdvertTimestamp: deviceTime,
|
|
}, ingest.RadioSettings{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if captured.DeviceClockDriftSeconds == nil {
|
|
t.Fatal("expected DeviceClockDriftSeconds to be set")
|
|
}
|
|
// device clock is ~10 minutes (600s) behind; allow a few seconds of test-runtime slop
|
|
got := *captured.DeviceClockDriftSeconds
|
|
if got > -595 || got < -605 {
|
|
t.Errorf("expected drift near -600s, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestUpsertNode_NoAdvertTimestamp_OmitsDrift(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
|
|
var captured sqlc.UpsertNodeParams
|
|
mock.EXPECT().
|
|
UpsertNode(gomock.Any(), gomock.Any()).
|
|
DoAndReturn(func(_ context.Context, p sqlc.UpsertNodeParams) (sqlc.Node, error) {
|
|
captured = p
|
|
return sqlc.Node{ID: nodeID}, nil
|
|
})
|
|
|
|
store := &Store{q: mock}
|
|
_, err := store.UpsertNode(context.Background(), ingest.UpsertNodeParams{
|
|
PublicKey: []byte{0x01},
|
|
NodeType: 2, // repeater, but AdvertTimestamp left zero (e.g. decode failed upstream)
|
|
Name: "test-repeater",
|
|
}, ingest.RadioSettings{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if captured.DeviceClockDriftSeconds != nil {
|
|
t.Error("expected DeviceClockDriftSeconds to remain nil when AdvertTimestamp is zero")
|
|
}
|
|
}
|
|
|
|
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, nil, 2, false)
|
|
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, nil, 10, false)
|
|
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_Stale(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
staleID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
freshID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
|
unmeasuredID := uuid.MustParse("00000000-0000-0000-0000-000000000003")
|
|
|
|
mock.EXPECT().
|
|
ListNodes(gomock.Any(), gomock.Any()).
|
|
Return([]sqlc.ListNodesRow{
|
|
{ID: staleID, PublicKey: []byte{0x01}, LastSeen: pgtype.Timestamptz{Time: time.Now().Add(-48 * time.Hour), Valid: true}},
|
|
{ID: freshID, PublicKey: []byte{0x02}, LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}},
|
|
{ID: unmeasuredID, PublicKey: []byte{0x03}, LastSeen: pgtype.Timestamptz{Valid: false}},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock, staleThreshold: 24 * time.Hour}
|
|
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, false)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
byID := make(map[uuid.UUID]bool)
|
|
for _, n := range page.Items {
|
|
byID[n.ID] = n.Stale
|
|
}
|
|
if !byID[staleID] {
|
|
t.Error("expected node last seen 48h ago to be stale with a 24h threshold")
|
|
}
|
|
if byID[freshID] {
|
|
t.Error("expected node last seen just now to not be stale")
|
|
}
|
|
if byID[unmeasuredID] {
|
|
t.Error("expected a node with no last_seen at all to not be stale")
|
|
}
|
|
}
|
|
|
|
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, nil, 10, false)
|
|
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,125,7" {
|
|
t.Errorf("expected Radio 915,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 TestGetNode_Stale(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},
|
|
NodeType: 1, // companion -- Stale applies to every node type, unlike clock drift
|
|
FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-72 * time.Hour), Valid: true},
|
|
LastSeen: pgtype.Timestamptz{Time: time.Now().Add(-48 * time.Hour), Valid: true},
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
GetNodeNeighbors(gomock.Any(), nodeID).
|
|
Return([]sqlc.GetNodeNeighborsRow{}, nil)
|
|
|
|
store := &Store{q: mock, staleThreshold: 24 * time.Hour}
|
|
node, err := store.GetNode(context.Background(), nodeID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !node.Stale {
|
|
t.Error("expected node last seen 48h ago to be stale with a 24h threshold")
|
|
}
|
|
}
|
|
|
|
func TestGetNode_ClockDrift_OutOfSync(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
drift := int32(-600) // 10 minutes behind, beyond a 5m threshold
|
|
|
|
mock.EXPECT().
|
|
GetNodeByID(gomock.Any(), nodeID).
|
|
Return(sqlc.GetNodeByIDRow{
|
|
ID: nodeID,
|
|
PublicKey: []byte{0x01},
|
|
NodeType: 2, // repeater
|
|
FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
LastAdvertAt: pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true},
|
|
DeviceClockDriftSeconds: &drift,
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
GetNodeNeighbors(gomock.Any(), nodeID).
|
|
Return([]sqlc.GetNodeNeighborsRow{}, nil)
|
|
|
|
store := &Store{q: mock, clockDriftThreshold: 5 * time.Minute}
|
|
node, err := store.GetNode(context.Background(), nodeID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if node.ClockDriftSeconds == nil || *node.ClockDriftSeconds != -600 {
|
|
t.Fatalf("expected ClockDriftSeconds -600, got %v", node.ClockDriftSeconds)
|
|
}
|
|
if node.ClockOutOfSync == nil || !*node.ClockOutOfSync {
|
|
t.Errorf("expected ClockOutOfSync true, got %v", node.ClockOutOfSync)
|
|
}
|
|
if node.ClockCheckedAt == nil || *node.ClockCheckedAt != 1700000000000 {
|
|
t.Errorf("expected ClockCheckedAt 1700000000000, got %v", node.ClockCheckedAt)
|
|
}
|
|
}
|
|
|
|
func TestGetNode_ClockDrift_InSync(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
drift := int32(30) // well within a 5m threshold
|
|
|
|
mock.EXPECT().
|
|
GetNodeByID(gomock.Any(), nodeID).
|
|
Return(sqlc.GetNodeByIDRow{
|
|
ID: nodeID,
|
|
PublicKey: []byte{0x01},
|
|
NodeType: 3, // room server
|
|
FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
LastAdvertAt: pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true},
|
|
DeviceClockDriftSeconds: &drift,
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
GetNodeNeighbors(gomock.Any(), nodeID).
|
|
Return([]sqlc.GetNodeNeighborsRow{}, nil)
|
|
|
|
store := &Store{q: mock, clockDriftThreshold: 5 * time.Minute}
|
|
node, err := store.GetNode(context.Background(), nodeID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if node.ClockOutOfSync == nil || *node.ClockOutOfSync {
|
|
t.Errorf("expected ClockOutOfSync false, got %v", node.ClockOutOfSync)
|
|
}
|
|
}
|
|
|
|
func TestGetNode_ClockDrift_OmittedForCompanion(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
drift := int32(-600)
|
|
|
|
mock.EXPECT().
|
|
GetNodeByID(gomock.Any(), nodeID).
|
|
Return(sqlc.GetNodeByIDRow{
|
|
ID: nodeID,
|
|
PublicKey: []byte{0x01},
|
|
NodeType: 1, // companion -- clock drift only applies to 2/3
|
|
FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
LastAdvertAt: pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true},
|
|
DeviceClockDriftSeconds: &drift,
|
|
}, nil)
|
|
|
|
mock.EXPECT().
|
|
GetNodeNeighbors(gomock.Any(), nodeID).
|
|
Return([]sqlc.GetNodeNeighborsRow{}, nil)
|
|
|
|
store := &Store{q: mock, clockDriftThreshold: 5 * time.Minute}
|
|
node, err := store.GetNode(context.Background(), nodeID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if node.ClockDriftSeconds != nil || node.ClockOutOfSync != nil || node.ClockCheckedAt != nil {
|
|
t.Error("expected all three clock fields nil for a companion node")
|
|
}
|
|
}
|
|
|
|
func TestGetNode_ClockDrift_OmittedWhenUnmeasured(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},
|
|
NodeType: 2, // repeater, but no advert-derived drift yet
|
|
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, clockDriftThreshold: 5 * time.Minute}
|
|
node, err := store.GetNode(context.Background(), nodeID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if node.ClockDriftSeconds != nil || node.ClockOutOfSync != nil || node.ClockCheckedAt != nil {
|
|
t.Error("expected all three clock fields nil when no advert has been measured yet")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestListNodes_IncludeNeighbors_PassesFlagAndMapsIDs(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")
|
|
|
|
mock.EXPECT().
|
|
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
|
|
Column1: int16(0), Column2: nil, Column3: "any", Column4: "any",
|
|
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true},
|
|
Limit: 11, Column9: "", Column12: uuid.Nil, Column10: true,
|
|
})).
|
|
Return([]sqlc.ListNodesRow{
|
|
{
|
|
ID: nodeID,
|
|
PublicKey: []byte{0x01},
|
|
NeighborIds: []uuid.UUID{neighborID},
|
|
},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, true)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(page.Items[0].NeighborIDs) != 1 || page.Items[0].NeighborIDs[0] != neighborID {
|
|
t.Errorf("expected NeighborIDs [%s], got %v", neighborID, page.Items[0].NeighborIDs)
|
|
}
|
|
}
|
|
|
|
func TestListNodes_ExcludeNeighbors_LeavesIDsNil(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
|
|
mock.EXPECT().
|
|
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
|
|
Column1: int16(0), Column2: nil, Column3: "any", Column4: "any",
|
|
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true},
|
|
Limit: 11, Column9: "", Column12: uuid.Nil, Column10: false,
|
|
})).
|
|
Return([]sqlc.ListNodesRow{
|
|
{ID: nodeID, PublicKey: []byte{0x01}, NeighborIds: nil},
|
|
}, nil)
|
|
|
|
store := &Store{q: mock}
|
|
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, false)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if page.Items[0].NeighborIDs != nil {
|
|
t.Errorf("expected NeighborIDs to stay nil when includeNeighbors is false, got %v", page.Items[0].NeighborIDs)
|
|
}
|
|
}
|
|
|
|
func TestDeleteOldNodes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
|
|
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
|
|
|
mock.EXPECT().
|
|
DeleteOldNodes(gomock.Any(), gomock.Eq(pgtype.Timestamptz{Time: cutoff, Valid: true})).
|
|
Return(nil)
|
|
|
|
store := &Store{q: mock}
|
|
if err := store.DeleteOldNodes(context.Background(), cutoff); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Page 1 starts at infinity, a numeric cursor pairs its millisecond with the nil uuid, and a pageCursor sends its
|
|
// exact pair, winning over a numeric cursor sent alongside.
|
|
func TestListNodes_CursorParams(t *testing.T) {
|
|
const legacy = int64(1792442625019)
|
|
pc := &api.NodeCursor{LastSeen: time.UnixMicro(1792442625019123).UTC(), ID: uuid.MustParse("0b0b0b0b-0000-0000-0000-000000000001")}
|
|
for _, tc := range []struct {
|
|
name string
|
|
cursor int64
|
|
pc *api.NodeCursor
|
|
ts pgtype.Timestamptz
|
|
id uuid.UUID
|
|
}{
|
|
{"page 1", 0, nil, pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil},
|
|
{"negative cursor", -5, nil, pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil},
|
|
{"legacy cursor", legacy, nil, pgtype.Timestamptz{Time: time.UnixMilli(legacy), Valid: true}, uuid.Nil},
|
|
{"pageCursor", 0, pc, pgtype.Timestamptz{Time: pc.LastSeen, Valid: true}, pc.ID},
|
|
{"pageCursor wins", legacy, pc, pgtype.Timestamptz{Time: pc.LastSeen, Valid: true}, pc.ID},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
mock.EXPECT().
|
|
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
|
|
Column1: int16(2), Column2: []string{"YVR"}, Column3: "true", Column4: "any",
|
|
Column5: []byte{0x01}, Column6: "n", Column7: tc.ts,
|
|
Limit: 6, Column9: "#test", Column10: true, Column11: "ab", Column12: tc.id,
|
|
})).
|
|
Return(nil, nil)
|
|
yes := true
|
|
store := &Store{q: mock}
|
|
page, err := store.ListNodes(context.Background(), 2, []string{"YVR"}, &yes, nil, []byte{0x01}, "ab", "n", "#test", tc.cursor, tc.pc, 5, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.Items == nil || page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
|
|
t.Fatalf("empty page: %+v", page)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListNodes_NextPageCursor(t *testing.T) {
|
|
rows := make([]sqlc.ListNodesRow, 3)
|
|
for i := range rows {
|
|
rows[i] = sqlc.ListNodesRow{
|
|
ID: uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", i+1)),
|
|
PublicKey: []byte{byte(i)},
|
|
LastSeen: pgtype.Timestamptz{Time: time.UnixMicro(1792442625019123 - int64(i)).UTC(), Valid: true},
|
|
}
|
|
}
|
|
for _, limit := range []int32{2, 3} {
|
|
ctrl := gomock.NewController(t)
|
|
mock := mockdb.NewMockQuerier(ctrl)
|
|
mock.EXPECT().ListNodes(gomock.Any(), gomock.Any()).Return(rows, nil)
|
|
store := &Store{q: mock}
|
|
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, limit, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if limit == 3 {
|
|
if page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
|
|
t.Fatalf("last page carries a cursor: %+v", page)
|
|
}
|
|
continue
|
|
}
|
|
last := rows[1]
|
|
want := api.NodeCursor{LastSeen: last.LastSeen.Time, ID: last.ID}.String()
|
|
if !page.HasMore || page.NextCursor == nil || *page.NextCursor != last.LastSeen.Time.UnixMilli() ||
|
|
page.NextPageCursor == nil || *page.NextPageCursor != want {
|
|
t.Fatalf("limit 2: hasMore=%t nextCursor=%v nextPageCursor=%v, want %s", page.HasMore, page.NextCursor, page.NextPageCursor, want)
|
|
}
|
|
}
|
|
}
|