diff --git a/db/observer_adverts_integration_test.go b/db/observer_adverts_integration_test.go new file mode 100644 index 00000000..2531e37d --- /dev/null +++ b/db/observer_adverts_integration_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package db + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/MeshCore-Beacon/beacon-server/internal/api/handlers" + "github.com/jackc/pgx/v5" +) + +func TestObserverAdvertsPostgres(t *testing.T) { + dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set BEACON_TEST_POSTGRES_DSN for PostgreSQL regression") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + _, err = tx.Exec(ctx, ` +CREATE TEMP TABLE packets (LIKE public.packets INCLUDING ALL) ON COMMIT DROP; +CREATE TEMP TABLE packet_observations (LIKE public.packet_observations INCLUDING ALL) ON COMMIT DROP; +CREATE TEMP TABLE nodes (LIKE public.nodes INCLUDING ALL) ON COMMIT DROP; +INSERT INTO nodes(public_key,node_type,name) VALUES (decode(repeat('01',32),'hex'),1,'Known fixture node'); +INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,origin_pubkey,raw_payload,raw_header,first_heard_at,last_heard_at) VALUES + (decode(repeat('a1',32),'hex'),4,0,1,NULL,'\x00','\x00',now(),now()), + (decode(repeat('b2',32),'hex'),4,0,1,decode(repeat('01',32),'hex'),'\x00','\x00',now(),now()); +INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count) VALUES + (1,decode(repeat('a1',32),'hex'),'00000000-0000-0000-0000-000000000106','YOW',now(),0,1,0), + (2,decode(repeat('b2',32),'hex'),'00000000-0000-0000-0000-000000000106','YOW',now(),0,1,0);`) + if err != nil { + t.Fatal(err) + } + router := handlers.ObserversRouter(&Store{q: sqlc.New(tx)}) + request := func(query string) api.Page[api.AdvertObservation] { + t.Helper() + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/00000000-0000-0000-0000-000000000106/adverts?"+query, nil).WithContext(ctx)) + if response.Code != http.StatusOK { + t.Fatalf("advert page HTTP %d", response.Code) + } + var page api.Page[api.AdvertObservation] + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + return page + } + page := request("limit=50") + if len(page.Items) != 2 || page.Items[0].ID != 1 || page.Items[1].ID != 2 { + t.Fatal("missing-origin advert was not preserved in the ordered page") + } + if key := page.Items[0].NodePublicKey; key == nil || *key != "" { + t.Errorf("missing origin key = %v, want an empty string", key) + } + if key := page.Items[1].NodePublicKey; key == nil || *key != strings.Repeat("01", 32) { + t.Error("known origin key changed") + } + first := request("limit=1") + if !first.HasMore || first.NextCursor == nil || *first.NextCursor != 1 { + t.Fatal("missing-origin advert broke the cursor") + } + last := request("limit=1&cursor=1") + if len(last.Items) != 1 || last.Items[0].ID != 2 || last.HasMore { + t.Fatal("continuing after the missing-origin advert lost the known advert") + } +} diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 2e72472e..3bd18618 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -313,6 +313,7 @@ ORDER BY count DESC; -- name: ListObserverAdverts :many -- Returns advert packets (payload_type=4) heard by a specific observer. -- Pass cursor=0 to start from the beginning, or the last seen id for pagination. +-- Keep missing-origin adverts; the generated key field expects a string, not NULL. SELECT po.id, encode(po.packet_hash, 'hex') AS packet_hash_hex, @@ -323,7 +324,7 @@ SELECT po.snr, po.hop_count, n.name AS node_name, - encode(p.origin_pubkey, 'hex') AS node_public_key + COALESCE(encode(p.origin_pubkey, 'hex'), '')::text AS node_public_key FROM packet_observations po JOIN packets p ON p.packet_hash = po.packet_hash LEFT JOIN nodes n ON n.public_key = p.origin_pubkey diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 7a64432e..ec502af7 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -151,6 +151,7 @@ type Querier interface { ListObservationsForPacket(ctx context.Context, packetHash []byte) ([]ListObservationsForPacketRow, error) // Returns advert packets (payload_type=4) heard by a specific observer. // Pass cursor=0 to start from the beginning, or the last seen id for pagination. + // Keep missing-origin adverts; the generated key field expects a string, not NULL. ListObserverAdverts(ctx context.Context, arg ListObserverAdvertsParams) ([]ListObserverAdvertsRow, error) // Pass cursor=0 to start from the beginning, or the last seen observer's rownum for pagination. // Note: observers use UUID PKs so we order by last_seen and use a keyset on last_seen+id. diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 998889f2..fec385d6 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -2860,7 +2860,7 @@ SELECT po.snr, po.hop_count, n.name AS node_name, - encode(p.origin_pubkey, 'hex') AS node_public_key + COALESCE(encode(p.origin_pubkey, 'hex'), '')::text AS node_public_key FROM packet_observations po JOIN packets p ON p.packet_hash = po.packet_hash LEFT JOIN nodes n ON n.public_key = p.origin_pubkey @@ -2892,6 +2892,7 @@ type ListObserverAdvertsRow struct { // Returns advert packets (payload_type=4) heard by a specific observer. // Pass cursor=0 to start from the beginning, or the last seen id for pagination. +// Keep missing-origin adverts; the generated key field expects a string, not NULL. func (q *Queries) ListObserverAdverts(ctx context.Context, arg ListObserverAdvertsParams) ([]ListObserverAdvertsRow, error) { rows, err := q.db.Query(ctx, listObserverAdverts, arg.ObserverID, arg.Column2, arg.Limit) if err != nil {