fix(observers): preserve advert pages with missing origin keys (#138)

* test(observers): cover adverts with a missing origin key

* fix(observers): preserve advert pages with missing origin keys
This commit is contained in:
n30nex
2026-09-18 06:17:33 -04:00
committed by GitHub
parent e15f873597
commit b44769bef8
4 changed files with 90 additions and 2 deletions
+85
View File
@@ -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")
}
}
+2 -1
View File
@@ -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
+1
View File
@@ -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.
+2 -1
View File
@@ -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 {