Skip to content
Merged
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,5 @@ test-e2e-bridge:
go test -v -tags e2e -timeout 15m ./tests/e2e/tests/bridge/...

test-e2e-indexer:
@echo "not yet implemented"
CANTON_MASTER_KEY=$${CANTON_MASTER_KEY:-$$(openssl rand -base64 32)} \
go test -v -tags e2e -timeout 20m ./tests/e2e/tests/indexer/...
133 changes: 128 additions & 5 deletions tests/e2e/devstack/dsl/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (d *DSL) WaitForRelayerReady(ctx context.Context, t *testing.T) {
func (d *DSL) WaitForCantonBalance(ctx context.Context, t *testing.T, partyID, admin, id, minAmount string) {
t.Helper()
if d.indexer == nil {
t.Fatal("WaitForCantonBalance not available: Indexer shim not initialized (use NewFullStack)")
t.Fatal("WaitForCantonBalance not available: Indexer shim not initialized (use NewFullStack or NewIndexerStack)")
return
}
deadline := time.Now().Add(cantonBalanceTimeout)
Expand Down Expand Up @@ -120,7 +120,7 @@ func (d *DSL) WaitForRelayerTransfer(ctx context.Context, t *testing.T, sourceTx
func (d *DSL) WaitForIndexerEvent(ctx context.Context, t *testing.T, contractID string) *indexer.ParsedEvent {
t.Helper()
if d.indexer == nil {
t.Fatal("WaitForIndexerEvent not available: Indexer shim not initialized (use NewFullStack)")
t.Fatal("WaitForIndexerEvent not available: Indexer shim not initialized (use NewFullStack or NewIndexerStack)")
return nil // unreachable; t.Fatal calls runtime.Goexit
}
deadline := time.Now().Add(indexerEventTimeout)
Expand All @@ -143,11 +143,134 @@ func (d *DSL) WaitForIndexerEvent(ctx context.Context, t *testing.T, contractID
return nil // unreachable; t.Fatalf calls runtime.Goexit
}

// WaitForPartyEvent polls until the indexer has at least one event of
// eventType for partyID, then returns it. Use WaitForPartyEventMatching when
// a specific event must be selected (e.g. by ExternalTxID or Fingerprint).
func (d *DSL) WaitForPartyEvent(ctx context.Context, t *testing.T, partyID string, eventType indexer.EventType) *indexer.ParsedEvent {
t.Helper()
return d.WaitForPartyEventMatching(ctx, t, partyID, eventType, func(_ *indexer.ParsedEvent) bool { return true })
}

// WaitForPartyEventMatching polls until an event of eventType for partyID
// satisfies the match predicate, then returns it. Each poll fetches the last
// page of results (most recently indexed events) so that new events are found
// regardless of how many prior events exist for the party. Use a predicate
// that checks ev.LedgerOffset > sinceOffset (obtained from MaxPartyEventOffset)
// to avoid matching stale events from earlier test runs.
func (d *DSL) WaitForPartyEventMatching(
ctx context.Context,
t *testing.T,
partyID string,
eventType indexer.EventType,
match func(*indexer.ParsedEvent) bool,
) *indexer.ParsedEvent {
t.Helper()
if d.indexer == nil {
t.Fatal("WaitForPartyEventMatching not available: Indexer shim not initialized (use NewFullStack or NewIndexerStack)")
return nil // unreachable; t.Fatal calls runtime.Goexit
}
const pageSize = 50
deadline := time.Now().Add(indexerEventTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for time.Now().Before(deadline) {
// Fetch page 1 to discover Total; derive which page is last so we
// always inspect the most recently indexed events.
page1, err := d.indexer.ListPartyEvents(ctx, partyID, eventType, 1, pageSize)
if err == nil && page1 != nil && page1.Total > 0 {
candidates := page1.Items
totalPages := (int(page1.Total) + pageSize - 1) / pageSize
if totalPages > 1 {
last, lerr := d.indexer.ListPartyEvents(ctx, partyID, eventType, totalPages, pageSize)
if lerr == nil && last != nil {
candidates = last.Items
}
}
Comment thread
sadiq1971 marked this conversation as resolved.
Outdated
for _, ev := range candidates {
if match(ev) {
return ev
}
}
Comment thread
sadiq1971 marked this conversation as resolved.
Outdated
Comment thread
sadiq1971 marked this conversation as resolved.
}
select {
case <-ctx.Done():
t.Fatalf("context canceled waiting for %s event for party %s", eventType, partyID)
case <-ticker.C:
}
}
t.Fatalf("timeout waiting for %s event for party %s", eventType, partyID)
return nil // unreachable; t.Fatalf calls runtime.Goexit
}

// MaxPartyEventOffset returns the highest ledger_offset currently indexed for
// events of eventType belonging to partyID, or 0 if no such events exist.
// Call this immediately before triggering an on-ledger operation and pass the
// result to WaitForPartyEventMatching as an ev.LedgerOffset > sinceOffset
// predicate to ensure only events produced by that operation are matched.
func (d *DSL) MaxPartyEventOffset(ctx context.Context, t *testing.T, partyID string, eventType indexer.EventType) int64 {
t.Helper()
if d.indexer == nil {
t.Fatal("MaxPartyEventOffset not available: Indexer shim not initialized (use NewFullStack or NewIndexerStack)")
return 0
}
const pageSize = 50
page1, err := d.indexer.ListPartyEvents(ctx, partyID, eventType, 1, pageSize)
if err != nil || page1 == nil || page1.Total == 0 {
return 0
}
items := page1.Items
totalPages := (int(page1.Total) + pageSize - 1) / pageSize
if totalPages > 1 {
last, lerr := d.indexer.ListPartyEvents(ctx, partyID, eventType, totalPages, pageSize)
if lerr == nil && last != nil {
items = last.Items
}
Comment thread
sadiq1971 marked this conversation as resolved.
Outdated
}
var max int64
for _, ev := range items {
if ev.LedgerOffset > max {
max = ev.LedgerOffset
}
}
return max
}

// WaitForHolderCount polls until GetToken reports HolderCount == expected for
// the token identified by (admin, id).
func (d *DSL) WaitForHolderCount(ctx context.Context, t *testing.T, admin, id string, expected int64) {
t.Helper()
if d.indexer == nil {
t.Fatal("WaitForHolderCount not available: Indexer shim not initialized (use NewFullStack or NewIndexerStack)")
return
}
deadline := time.Now().Add(indexerEventTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
var lastCount int64
for time.Now().Before(deadline) {
tok, err := d.indexer.GetToken(ctx, admin, id)
if err == nil && tok != nil {
lastCount = tok.HolderCount
if tok.HolderCount == expected {
return
}
}
select {
case <-ctx.Done():
t.Fatalf("context canceled waiting for holder count %d (token %s/%s)", expected, admin, id)
case <-ticker.C:
}
}
t.Fatalf("timeout waiting for holder count %d: last=%d (token %s/%s)", expected, lastCount, admin, id)
}

// amountGTE returns true when amount >= min, comparing both as decimal numbers.
// String comparison is intentionally avoided: "20" > "100" lexicographically.
// Uses big.Rat (exact rational arithmetic) to avoid precision loss with
// 18-decimal token amounts. String comparison is intentionally avoided:
// "20" > "100" lexicographically.
func amountGTE(amount, min string) bool {
a, ok1 := new(big.Float).SetString(amount)
m, ok2 := new(big.Float).SetString(min)
a, ok1 := new(big.Rat).SetString(amount)
m, ok2 := new(big.Rat).SetString(min)
if !ok1 || !ok2 {
return false
}
Expand Down
24 changes: 15 additions & 9 deletions tests/e2e/devstack/system/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,15 @@ func New(ctx context.Context, manifest *stack.ServiceManifest) (*System, error)
// Subset views
// ---------------------------------------------------------------------------

// IndexerSystem is a minimal view for indexer-focused tests. It only
// initializes the Canton and Indexer shims — no Postgres connection, no Anvil.
// IndexerSystem is a minimal view for indexer-focused tests. It initializes
// Canton and Indexer shims and wires a DSL with those two shims so that
// WaitForCantonBalance, WaitForPartyEvent*, and WaitForHolderCount are
// available. No Anvil, Postgres, APIServer, or Relayer shims are initialized.
type IndexerSystem struct {
Manifest *stack.ServiceManifest
Canton stack.Canton
Indexer stack.Indexer
DSL *dsl.DSL

closeFunc func()
}
Expand All @@ -217,23 +220,26 @@ func NewIndexerSystem(manifest *stack.ServiceManifest) (*IndexerSystem, error) {
if err != nil {
return nil, fmt.Errorf("canton shim: %w", err)
}
indexerShim := shim.NewIndexer(manifest)
return &IndexerSystem{
Manifest: manifest,
Canton: cantonShim,
Indexer: shim.NewIndexer(manifest),
Indexer: indexerShim,
DSL: dsl.New(nil, cantonShim, nil, indexerShim, nil, nil),
closeFunc: cantonShim.Close,
}, nil
}

// APISystem is a minimal view for api-server focused tests. It initializes
// Anvil, Canton, APIServer, and Postgres shims together with the DSL and
// pre-funded accounts.
// Anvil, Canton, APIServer, Postgres, and Indexer shims together with the DSL
// and pre-funded accounts. The Relayer shim is intentionally omitted.
type APISystem struct {
Manifest *stack.ServiceManifest
Anvil stack.Anvil
Canton stack.Canton
APIServer stack.APIServer
Postgres stack.APIDatabase
Indexer stack.Indexer
DSL *dsl.DSL
Accounts *Accounts
Tokens *Tokens
Expand Down Expand Up @@ -264,13 +270,13 @@ func NewAPISystem(ctx context.Context, manifest *stack.ServiceManifest) (*APISys
Canton: core.canton,
APIServer: core.apiServer,
Postgres: core.postgres,
Indexer: shim.NewIndexer(manifest),
Accounts: defaultAccounts,
Tokens: NewTokens(manifest),
closeFunc: core.close,
}
// Relayer and Indexer are not part of the API stack; nil is passed
// deliberately. DSL methods that require them call t.Fatal with a clear
// message rather than panicking.
sys.DSL = dsl.New(sys.APIServer, sys.Canton, nil, nil, sys.Postgres, sys.Anvil)
// Relayer is not part of the API stack; nil is passed deliberately. DSL
// methods that require it call t.Fatal with a clear message.
sys.DSL = dsl.New(sys.APIServer, sys.Canton, nil, sys.Indexer, sys.Postgres, sys.Anvil)
return sys, nil
}
27 changes: 27 additions & 0 deletions tests/e2e/tests/indexer/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build e2e

package indexer_test

import "math/big"

// amtGTE returns true when a >= b, comparing both as exact decimal strings.
// Uses big.Rat (exact rational arithmetic) to avoid the precision loss that
// big.Float can introduce with 18-decimal token amounts.
func amtGTE(a, b string) bool {
af, ok1 := new(big.Rat).SetString(a)
bf, ok2 := new(big.Rat).SetString(b)
if !ok1 || !ok2 {
return false
}
return af.Cmp(bf) >= 0
}
Comment thread
sadiq1971 marked this conversation as resolved.
Outdated

// amtLT returns true when a < b.
func amtLT(a, b string) bool {
af, ok1 := new(big.Rat).SetString(a)
bf, ok2 := new(big.Rat).SetString(b)
if !ok1 || !ok2 {
return false
}
return af.Cmp(bf) < 0
}
14 changes: 14 additions & 0 deletions tests/e2e/tests/indexer/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build e2e

package indexer_test

import (
"os"
"testing"

"github.com/chainsafe/canton-middleware/tests/e2e/devstack/presets"
)

func TestMain(m *testing.M) {
os.Exit(presets.DoMain(m))
}
Loading
Loading