Files
graph/storage/list_never_null_test.go
zeekayandHanzo Dev 843678ae8b fix(storage): an empty list is [], not null
A store getter that answers with a list built it from `var result
[]interface{}` — a nil slice — so "nothing matched" marshalled to JSON
`null` instead of `[]`.

On a GraphQL wire those are opposite diagnoses. `{"data":{"markets":[]}}`
says "deployed, healthy, nothing has happened yet"; `{"data":{"markets":
null}}` is the signature of a subgraph that is absent, unsubscribed or
erroring behind a 200. The `dex` subgraph shipped the second while being
the first: mounted at /v1/graph/cchain/dex, subscribed to the 0x9999
DEXFill topic and caught up to the chain head, it reported itself dead on
every query purely because no fill had been emitted yet — leaving no way
to tell a quiet DEX from a broken pipe.

Six getters were affected (ListByType, GetTokens, GetFactories, GetPools,
GetPairs via GetPools, GetSwaps) plus the shared FilterResults tail. Every
other list getter in both backends already returned []interface{}{}; this
restores the convention rather than introducing one.

The regression test is reflective — it walks every (interface{}, error)
getter on *Store and fails any that answers with a nil slice — so a getter
added later cannot reintroduce the bug by being forgotten in a hand-written
list. 26 getters are covered.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 14:03:37 -07:00

95 lines
3.1 KiB
Go

package storage
import (
"context"
"encoding/json"
"reflect"
"testing"
)
// A store getter that answers with a list must answer with an EMPTY list when it
// has nothing, never a nil slice.
//
// This is not cosmetic. A nil Go slice marshals to JSON `null`, and on a GraphQL
// wire `{"data":{"markets":null}}` is the signature of a subgraph that is absent,
// unsubscribed or erroring — while `{"data":{"markets":[]}}` says "deployed,
// healthy, nothing has happened yet". The two are opposite diagnoses, and the
// `dex` subgraph shipped the wrong one: it was mounted, subscribed to the 0x9999
// settlement precompile and caught up to the chain head, yet reported itself dead
// on every query because no DEXFill had been emitted.
//
// The check is reflective rather than a hand-written list so a getter added later
// cannot reintroduce the bug by being forgotten here.
func TestStoreGettersNeverReturnNilList(t *testing.T) {
store, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Init(context.Background()); err != nil {
t.Fatal(err)
}
var (
anyType = reflect.TypeOf((*interface{})(nil)).Elem()
errType = reflect.TypeOf((*error)(nil)).Elem()
)
v := reflect.ValueOf(store)
checked := 0
for i := range v.NumMethod() {
m, name := v.Type().Method(i), v.Type().Method(i).Name
ft := m.Type
// Select the resolver-facing getters: (…) (interface{}, error).
if ft.NumOut() != 2 || ft.Out(0) != anyType || ft.Out(1) != errType || ft.IsVariadic() {
continue
}
args := make([]reflect.Value, 0, ft.NumIn()-1)
for j := 1; j < ft.NumIn(); j++ { // skip the receiver
if ft.In(j).Kind() == reflect.Int {
args = append(args, reflect.ValueOf(100)) // limit
continue
}
args = append(args, reflect.Zero(ft.In(j)))
}
out := v.Method(i).Call(args)
if !out[1].IsNil() {
t.Fatalf("%s on an empty store: %v", name, out[1].Interface())
}
got := out[0].Interface()
// An untyped nil is a single-entity getter reporting a miss — legitimate.
rv := reflect.ValueOf(got)
if rv.Kind() != reflect.Slice {
continue
}
checked++
if rv.IsNil() {
t.Errorf("%s returned a nil slice on an empty store; it must return an empty list", name)
continue
}
if raw, _ := json.Marshal(got); string(raw) != "[]" {
t.Errorf("%s encoded as %s on an empty store, want []", name, raw)
}
}
if checked == 0 {
t.Fatal("no list getters exercised — the reflective selection is broken, not the store")
}
t.Logf("%d list getters return an empty list on an empty store", checked)
}
// FilterResults is the shared tail of the pool/token/swap getters, so it must
// preserve the same guarantee: filtering everything out yields an empty list.
func TestFilterResultsNeverReturnsNil(t *testing.T) {
in := []interface{}{map[string]interface{}{"id": "1", "pool": "0xA"}}
out := FilterResults(in, map[string]interface{}{"pool": "0xB"})
if out == nil {
t.Fatal("FilterResults returned nil for a total miss; want an empty list")
}
if raw, _ := json.Marshal(out); string(raw) != "[]" {
t.Fatalf("FilterResults encoded as %s, want []", raw)
}
}