Add CI/CD pipelines, unit tests, release workflow

CI:
- ci.yml: build, vet, unit tests, e2e (SQLite + in-memory), benchmarks
  on ubuntu + macos matrix
- release.yml: cross-compile binaries (linux/darwin x amd64/arm64),
  multi-platform Docker image to ghcr.io/luxfi/graph, GitHub Release

Tests:
- engine/engine_test.go: parser (fields, args, where filters, aliases,
  depth, paren matching, limit clamping)
- indexer/indexer_test.go: hex parsing, uint256 decoding, topic address
  extraction, event signature coverage
- storage/store_test.go: FilterResults (exact, gte, lt, empty, no-match),
  sortResults (numeric, asc, string fallback), compareValues (numeric
  strings, mixed types)
This commit is contained in:
2026-04-11 14:25:57 -07:00
parent de2e43e606
commit 6aeee65a92
5 changed files with 598 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: Build
run: CGO_ENABLED=1 go build ./...
- name: Vet
run: CGO_ENABLED=1 go vet ./...
- name: Unit tests
run: CGO_ENABLED=1 go test ./... -count=1 -race
- name: E2E tests (SQLite)
run: CGO_ENABLED=1 go test -tags=e2e ./e2e/ -count=1 -v
- name: E2E tests (in-memory fallback)
run: CGO_ENABLED=0 go test -tags="e2e nosqlite" ./e2e/ -count=1 -v
- name: Benchmarks
run: CGO_ENABLED=1 go test -tags=e2e -bench=. -benchmem -run='^$' ./e2e/ -count=1
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- uses: golangci/golangci-lint-action@v6
with:
version: latest
+115
View File
@@ -0,0 +1,115 @@
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
packages: write
jobs:
binaries:
strategy:
matrix:
include:
- os: ubuntu-latest
goos: linux
goarch: amd64
- os: ubuntu-latest
goos: linux
goarch: arm64
cc: aarch64-linux-gnu-gcc
- os: macos-latest
goos: darwin
goarch: arm64
- os: macos-latest
goos: darwin
goarch: amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: Install cross-compiler (Linux arm64)
if: matrix.goarch == 'arm64' && matrix.goos == 'linux'
run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "1"
CC: ${{ matrix.cc || '' }}
run: |
VERSION=${GITHUB_REF_NAME}
go build -ldflags="-s -w -X main.version=${VERSION}" \
-o graph-${GOOS}-${GOARCH} ./cmd/graph/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: graph-${{ matrix.goos }}-${{ matrix.goarch }}
path: graph-*
docker:
runs-on: ubuntu-latest
needs: binaries
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/luxfi/graph
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
release:
runs-on: ubuntu-latest
needs: [binaries, docker]
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist/
merge-multiple: true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: dist/graph-*
+171
View File
@@ -0,0 +1,171 @@
package engine
import (
"testing"
)
func TestParseTopFields_SingleField(t *testing.T) {
fields, err := parseTopFields(`{ tokens(first: 10) { id symbol } }`)
if err != nil {
t.Fatal(err)
}
if len(fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(fields))
}
if fields[0].name != "tokens" {
t.Errorf("expected tokens, got %s", fields[0].name)
}
if fields[0].args["first"] != "10" {
t.Errorf("expected first=10, got %v", fields[0].args["first"])
}
}
func TestParseTopFields_MultipleFields(t *testing.T) {
fields, err := parseTopFields("{\n bundle(id: \"1\") { ethPriceUSD }\n factories(first: 1) { poolCount }\n}")
if err != nil {
t.Fatal(err)
}
if len(fields) != 2 {
t.Fatalf("expected 2 fields, got %d", len(fields))
}
if fields[0].name != "bundle" {
t.Errorf("expected bundle, got %s", fields[0].name)
}
if fields[1].name != "factories" {
t.Errorf("expected factories, got %s", fields[1].name)
}
}
func TestParseTopFields_Alias(t *testing.T) {
fields, err := parseTopFields("{\n myTokens: tokens(first: 5) { id }\n}")
if err != nil {
t.Fatal(err)
}
if len(fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(fields))
}
if fields[0].alias != "myTokens" {
t.Errorf("expected alias myTokens, got %s", fields[0].alias)
}
if fields[0].name != "tokens" {
t.Errorf("expected name tokens, got %s", fields[0].name)
}
}
func TestParseArgs_WhereFilter(t *testing.T) {
args := parseArgs(`first: 200, orderBy: timestamp, orderDirection: desc, where: { pool: "0xabc", date_gte: 12345 }`)
if args["first"] != "200" {
t.Errorf("first: got %v", args["first"])
}
if args["orderBy"] != "timestamp" {
t.Errorf("orderBy: got %v", args["orderBy"])
}
if args["orderDirection"] != "desc" {
t.Errorf("orderDirection: got %v", args["orderDirection"])
}
where, ok := args["where"].(map[string]interface{})
if !ok {
t.Fatalf("where not a map: %T", args["where"])
}
if where["pool"] != "0xabc" {
t.Errorf("where.pool: got %v", where["pool"])
}
if where["date_gte"] != "12345" {
t.Errorf("where.date_gte: got %v", where["date_gte"])
}
}
func TestParseArgs_NestedBraces(t *testing.T) {
args := parseArgs(`where: { token: { id: "0x123" } }`)
where, ok := args["where"].(map[string]interface{})
if !ok {
t.Fatal("where not a map")
}
inner, ok := where["token"].(map[string]interface{})
if !ok {
t.Fatal("where.token not a map")
}
if inner["id"] != "0x123" {
t.Errorf("expected 0x123, got %v", inner["id"])
}
}
func TestParseArgs_QuotedString(t *testing.T) {
args := parseArgs(`id: "hello, world"`)
if args["id"] != "hello, world" {
t.Errorf("expected 'hello, world', got %v", args["id"])
}
}
func TestParseArgs_Empty(t *testing.T) {
args := parseArgs("")
if len(args) != 0 {
t.Errorf("expected empty, got %v", args)
}
}
func TestQueryDepth(t *testing.T) {
tests := []struct {
query string
depth int
}{
{`{ tokens { id } }`, 2},
{`{ tokens { id name } }`, 2},
{`{ pools { token0 { id } } }`, 3},
{`{ a { b { c { d } } } }`, 4},
{`{ factory(id: "1") { id } }`, 2},
{`{ tokens(first: 1) { id } "quoted { brace" }`, 2}, // quoted braces ignored
}
for _, tt := range tests {
got := queryDepth(tt.query)
if got != tt.depth {
t.Errorf("queryDepth(%q) = %d, want %d", tt.query, got, tt.depth)
}
}
}
func TestFindMatchingParen(t *testing.T) {
tests := []struct {
s string
pos int
end int
}{
{"(abc)", 0, 4},
{"(a, b: {c: 1})", 0, 13},
{`(id: "test (paren)")`, 0, 19},
{"(no close", 0, -1},
}
for _, tt := range tests {
got := findMatchingParen(tt.s, tt.pos)
if got != tt.end {
t.Errorf("findMatchingParen(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.end)
}
}
}
func TestParseListArgs_Clamping(t *testing.T) {
// Negative
args := map[string]interface{}{"first": "-5"}
limit, _, _, _ := parseListArgs(args, 100)
if limit < 1 {
t.Errorf("negative limit not clamped: %d", limit)
}
// Too large
args = map[string]interface{}{"first": "999999"}
limit, _, _, _ = parseListArgs(args, 100)
if limit > 1000 {
t.Errorf("large limit not clamped: %d", limit)
}
// Default
args = map[string]interface{}{}
limit, _, _, _ = parseListArgs(args, 50)
if limit != 50 {
t.Errorf("default not applied: %d", limit)
}
}
+100
View File
@@ -0,0 +1,100 @@
package indexer
import (
"math/big"
"testing"
)
func TestParseHexUint64(t *testing.T) {
tests := []struct {
input string
want uint64
err bool
}{
{"0x0", 0, false},
{"0x1", 1, false},
{"0xa", 10, false},
{"0xff", 255, false},
{"0x10932c", 1086252, false},
{"", 0, true},
{"xyz", 0, true},
}
for _, tt := range tests {
got, err := parseHexUint64(tt.input)
if tt.err && err == nil {
t.Errorf("parseHexUint64(%q) expected error", tt.input)
}
if !tt.err && err != nil {
t.Errorf("parseHexUint64(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parseHexUint64(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestDecodeUint256(t *testing.T) {
// 64 hex chars = 32 bytes = one word
data := "0x" +
"0000000000000000000000000000000000000000000000000000000000000064" + // word 0: 100
"00000000000000000000000000000000000000000000000000000000000000c8" // word 1: 200
w0 := decodeUint256(data, 0)
if w0.Cmp(big.NewInt(100)) != 0 {
t.Errorf("word 0: got %s, want 100", w0)
}
w1 := decodeUint256(data, 1)
if w1.Cmp(big.NewInt(200)) != 0 {
t.Errorf("word 1: got %s, want 200", w1)
}
// Short data should not panic
short := decodeUint256("0x00", 0)
if short.Sign() != 0 {
t.Errorf("short data: got %s, want 0", short)
}
// Out of range word
oob := decodeUint256(data, 5)
if oob.Sign() != 0 {
t.Errorf("out of range: got %s, want 0", oob)
}
}
func TestTopicAddr(t *testing.T) {
tests := []struct {
topic string
want string
}{
{"0x000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7", "0xdac17f958d2ee523a2206206994597c13d831ec7"},
{"0xdac17f958d2ee523a2206206994597c13d831ec7", "0xdac17f958d2ee523a2206206994597c13d831ec7"},
{"short", "short"}, // doesn't panic on short input
{"", ""},
}
for _, tt := range tests {
got := topicAddr(tt.topic)
if got != tt.want {
t.Errorf("topicAddr(%q) = %q, want %q", tt.topic, got, tt.want)
}
}
}
func TestKnownTopics(t *testing.T) {
topics := knownTopics()
if len(topics) < 16 {
t.Errorf("expected at least 16 known topics, got %d", len(topics))
}
// All should start with 0x and be 66 chars
for _, topic := range topics {
if len(topic) != 66 {
t.Errorf("topic %s has length %d, expected 66", topic[:10], len(topic))
}
if topic[:2] != "0x" {
t.Errorf("topic %s missing 0x prefix", topic[:10])
}
}
}
+159
View File
@@ -0,0 +1,159 @@
package storage
import (
"testing"
)
func TestFilterResults_ExactMatch(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "1", "pool": "0xA"},
map[string]interface{}{"id": "2", "pool": "0xB"},
map[string]interface{}{"id": "3", "pool": "0xA"},
}
where := map[string]interface{}{"pool": "0xA"}
filtered := FilterResults(results, where)
if len(filtered) != 2 {
t.Fatalf("expected 2, got %d", len(filtered))
}
}
func TestFilterResults_GTE(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "1", "date": int64(100)},
map[string]interface{}{"id": "2", "date": int64(200)},
map[string]interface{}{"id": "3", "date": int64(300)},
}
where := map[string]interface{}{"date_gte": "200"}
filtered := FilterResults(results, where)
if len(filtered) != 2 {
t.Fatalf("expected 2 (date >= 200), got %d", len(filtered))
}
}
func TestFilterResults_LT(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "1", "amount": "50.5"},
map[string]interface{}{"id": "2", "amount": "150.0"},
map[string]interface{}{"id": "3", "amount": "200.0"},
}
where := map[string]interface{}{"amount_lt": "100"}
filtered := FilterResults(results, where)
if len(filtered) != 1 {
t.Fatalf("expected 1 (amount < 100), got %d", len(filtered))
}
}
func TestFilterResults_Empty(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "1"},
}
filtered := FilterResults(results, nil)
if len(filtered) != 1 {
t.Fatal("nil where should return all")
}
filtered = FilterResults(results, map[string]interface{}{})
if len(filtered) != 1 {
t.Fatal("empty where should return all")
}
}
func TestFilterResults_NoMatch(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "1", "pool": "0xA"},
}
where := map[string]interface{}{"pool": "0xNONE"}
filtered := FilterResults(results, where)
if len(filtered) != 0 {
t.Fatalf("expected 0, got %d", len(filtered))
}
}
func TestSortResults_Numeric(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "a", "volumeUSD": "9.0"},
map[string]interface{}{"id": "b", "volumeUSD": "100000.0"},
map[string]interface{}{"id": "c", "volumeUSD": "50.0"},
}
sortResults(results, "volumeUSD", "desc")
first := results[0].(map[string]interface{})
if first["id"] != "b" {
t.Errorf("expected b (100000) first in desc sort, got %v", first["id"])
}
last := results[2].(map[string]interface{})
if last["id"] != "a" {
t.Errorf("expected a (9) last in desc sort, got %v", last["id"])
}
}
func TestSortResults_Asc(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "x", "date": int64(300)},
map[string]interface{}{"id": "y", "date": int64(100)},
map[string]interface{}{"id": "z", "date": int64(200)},
}
sortResults(results, "date", "asc")
first := results[0].(map[string]interface{})
if first["id"] != "y" {
t.Errorf("expected y (100) first in asc sort, got %v", first["id"])
}
}
func TestSortResults_StringFallback(t *testing.T) {
results := []interface{}{
map[string]interface{}{"id": "c", "name": "Charlie"},
map[string]interface{}{"id": "a", "name": "Alice"},
map[string]interface{}{"id": "b", "name": "Bob"},
}
sortResults(results, "name", "asc")
first := results[0].(map[string]interface{})
if first["name"] != "Alice" {
t.Errorf("expected Alice first, got %v", first["name"])
}
}
func TestCompareValues_NumericStrings(t *testing.T) {
// "9" vs "100000" — must sort numerically, not lexicographically
cmp := compareValues("9.0", "100000.0")
if cmp >= 0 {
t.Error("9.0 should be less than 100000.0")
}
cmp = compareValues("100000", "9")
if cmp <= 0 {
t.Error("100000 should be greater than 9")
}
// Equal
cmp = compareValues("42", "42")
if cmp != 0 {
t.Error("42 should equal 42")
}
}
func TestCompareValues_MixedTypes(t *testing.T) {
// int64 vs string
cmp := compareValues(int64(100), "50")
if cmp <= 0 {
t.Error("100 should be greater than 50")
}
// Non-numeric strings fall back to lexicographic
cmp = compareValues("abc", "xyz")
if cmp >= 0 {
t.Error("abc should be less than xyz lexicographically")
}
}