mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
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:
@@ -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
|
||||
@@ -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-*
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user