Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions db/packet_summary_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"context"
"os"
"testing"
"time"

sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5"
)

type summaryQueryCounter struct {
pgx.Tx
calls int
}

func (c *summaryQueryCounter) Query(ctx context.Context, query string, args ...any) (pgx.Rows, error) {
c.calls++
return c.Tx.Query(ctx, query, args...)
}

func TestPacketSummariesPostgres(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(), time.Minute)
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())
for _, table := range []string{"packets", "packet_observations", "observers", "transport_scopes"} {
if _, err := tx.Exec(ctx, "CREATE TEMP TABLE "+table+" (LIKE public."+table+" INCLUDING ALL) ON COMMIT DROP"); err != nil {
t.Fatal(err)
}
}
_, err = tx.Exec(ctx, `
INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,parsed_payload,first_heard_at,last_heard_at)
SELECT decode(lpad(to_hex(id),2,'0'),'hex'),kind,0,1,'\x00','\x00',parsed::jsonb,
'2026-01-01'::timestamptz+id*interval '1 second','2026-01-01'::timestamptz+id*interval '1 second'
FROM (VALUES
(1,4,'{"type":"ADVERT","appData":{"name":"MD00-Repeater"}}'),
(2,4,'{"type":"ADVERT","appData":{"name":"Relay 📡"}}'),
(3,4,'{"type":"ADVERT","appData":{"name":""}}'),
(4,4,'{"type":"ADVERT","appData":{}}'),
(5,4,NULL),
(6,4,'{"type":"ADVERT","appData":{"name":null}}'),
(7,4,'{"type":"ADVERT","appData":{"name":42}}'),
(8,2,'{"appData":{"name":"not an advert"}}'),
(9,4,'{"type":"ADVERT","appData":[]}')
) v(id,kind,parsed);
`)
if err != nil {
t.Fatal(err)
}
counter := &summaryQueryCounter{Tx: tx}
store := &Store{q: sqlc.New(counter)}
check := func(items []api.PacketSummary) {
t.Helper()
if len(items) != 9 {
t.Fatalf("got %d packets, want 9", len(items))
}
for _, item := range items {
want := map[string]string{"01": "MD00-Repeater", "02": "Relay 📡"}[item.PacketHash]
if want == "" {
if item.Summary != nil {
t.Fatalf("unexpected summary for %s: %q", item.PacketHash, *item.Summary)
}
} else if item.Summary == nil || *item.Summary != want {
t.Fatalf("summary for %s = %v, want %q", item.PacketHash, item.Summary, want)
}
}
if counter.calls != 1 {
t.Fatalf("list made %d queries, want 1", counter.calls)
}
counter.calls = 0
}
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 20)
if err != nil {
t.Fatal(err)
}
check(page.Items) // names remain available before any observation arrives
_, err = tx.Exec(ctx, `
INSERT INTO observers(id,public_key) VALUES ('00000000-0000-0000-0000-000000000001','\x01');
INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count)
SELECT id,decode(lpad(to_hex(id),2,'0'),'hex'),'00000000-0000-0000-0000-000000000001','YVR',
'2026-01-01'::timestamptz+id*interval '1 second',0,1,0 FROM generate_series(1,9) id;
`)
if err != nil {
t.Fatal(err)
}
for _, iatas := range [][]string{nil, {"YVR"}} {
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, 20)
if err != nil {
t.Fatal(err)
}
check(page.Items)
rows, err := store.ListPacketsAfterID(ctx, 0, -1, -1, iatas, "", 20)
if err != nil {
t.Fatal(err)
}
check(rows)
}
}
9 changes: 9 additions & 0 deletions db/packets.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ func (s *Store) ListPackets(ctx context.Context, payloadTypes, routeTypes []int1
LastHeardAt: v.LastHeardAt.Time.UnixMilli(),
ObservationCount: int32(v.ObservationCount),
}
if v.Summary != "" {
item.Summary = &v.Summary
}
if v.LatestObserverID != (uuid.UUID{}) {
endpoints, _ := decodePacketEndpointSnapshot(v.LatestObserverResolvedEndpoints)
item.LatestObserver = &api.PacketLatestObserver{
Expand Down Expand Up @@ -219,6 +222,9 @@ func (s *Store) listPacketsByIATAs(ctx context.Context, payloadTypes, routeTypes
LastHeardAt: v.LastHeardAt.Time.UnixMilli(),
ObservationCount: int32(v.ObservationCount),
}
if v.Summary != "" {
item.Summary = &v.Summary
}
if v.LatestObserverID != (uuid.UUID{}) {
endpoints, _ := decodePacketEndpointSnapshot(v.LatestObserverResolvedEndpoints)
item.LatestObserver = &api.PacketLatestObserver{
Expand Down Expand Up @@ -278,6 +284,9 @@ func (s *Store) ListPacketsAfterID(ctx context.Context, afterObservationID int64
LastHeardAt: v.LastHeardAt.Time.UnixMilli(),
ObservationCount: int32(v.ObservationCount),
}
if v.Summary != "" {
item.Summary = &v.Summary
}
if v.LatestObserverID != (uuid.UUID{}) {
endpoints, _ := decodePacketEndpointSnapshot(v.LatestObserverResolvedEndpoints)
item.LatestObserver = &api.PacketLatestObserver{
Expand Down
6 changes: 6 additions & 0 deletions db/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,8 @@ SELECT COUNT(*) FROM packet_observations WHERE packet_hash = $1;
SELECT
p.packet_hash,
p.payload_type,
COALESCE(CASE WHEN p.payload_type = 4 AND jsonb_typeof(p.parsed_payload #> '{appData,name}') = 'string'
THEN p.parsed_payload #>> '{appData,name}' END, '')::text AS summary,
p.route_type,
p.first_heard_at,
p.last_heard_at,
Expand Down Expand Up @@ -557,6 +559,8 @@ page AS (
SELECT
p.packet_hash,
p.payload_type,
COALESCE(CASE WHEN p.payload_type = 4 AND jsonb_typeof(p.parsed_payload #> '{appData,name}') = 'string'
THEN p.parsed_payload #>> '{appData,name}' END, '')::text AS summary,
p.route_type,
p.first_heard_at,
p.last_heard_at,
Expand Down Expand Up @@ -595,6 +599,8 @@ ORDER BY sh.site_heard_at DESC;
SELECT
p.packet_hash,
p.payload_type,
COALESCE(CASE WHEN p.payload_type = 4 AND jsonb_typeof(p.parsed_payload #> '{appData,name}') = 'string'
THEN p.parsed_payload #>> '{appData,name}' END, '')::text AS summary,
p.route_type,
p.first_heard_at,
p.last_heard_at,
Expand Down
12 changes: 12 additions & 0 deletions db/sqlc/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3535,7 +3535,7 @@ const docTemplate = `{
"type": "string"
},
"summary": {
"description": "human-readable payload summary",
"description": "advert name from this packet; omitted when unavailable or unsupported",
"type": "string"
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -3533,7 +3533,7 @@
"type": "string"
},
"summary": {
"description": "human-readable payload summary",
"description": "advert name from this packet; omitted when unavailable or unsupported",
"type": "string"
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ definitions:
description: matched transport scope name e.g. "#bc"
type: string
summary:
description: human-readable payload summary
description: advert name from this packet; omitted when unavailable or unsupported
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.PacketTransportCodes:
Expand Down
2 changes: 1 addition & 1 deletion internal/api/packets.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type PacketSummary struct {
LastHeardAt int64 `json:"lastHeardAt"` // epoch ms
ObservationCount int32 `json:"observationCount"`
LatestObserver *PacketLatestObserver `json:"latestObserver,omitempty"`
Summary *string `json:"summary,omitempty"` // human-readable payload summary
Summary *string `json:"summary,omitempty"` // advert name from this packet; omitted when unavailable or unsupported
}

// PacketPathLength is the decoded path_length byte from a packet observation.
Expand Down
6 changes: 6 additions & 0 deletions internal/ingest/packet.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type packetObservationEvent struct {
IsFirstObservation bool `json:"isFirstObservation"`
ObservationCount int64 `json:"observationCount"`
Scope *string `json:"scope,omitempty"`
Summary *string `json:"summary,omitempty"` // same advert name as REST list/backfill rows
} `json:"packet"`
Observation struct {
ObserverID string `json:"observerId"`
Expand Down Expand Up @@ -343,6 +344,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
var channelHash []byte
originPubkey := []byte(nil)
var parsedPayload json.RawMessage
var summary *string
var traceTag []byte
// For PayloadTypeTrace, packet.Path holds one SNR byte per hop (not hashes -- see
// below), so the "physical route" hashes for resolvedPath/known-route purposes come
Expand Down Expand Up @@ -415,6 +417,9 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
if hasName {
n := strings.ToValidUTF8(appData.Name, "\uFFFD")
name = &n
if n != "" {
summary = name
}
}

deviceRole := int(flags & 0x0F)
Expand Down Expand Up @@ -892,6 +897,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
evt.Packet.RouteType = packet.RouteType()
evt.Packet.RouteTypeName = api.RouteTypeName(int16(packet.RouteType()))
evt.Packet.IsFirstObservation = isNew
evt.Packet.Summary = summary
evt.Observation.ObserverID = id.String()
evt.Observation.ObserverName = observerName
evt.Observation.IATA = iata
Expand Down
Loading
Loading