Add tests for the critical mining path

Tests cover the four subsystems that sit between a share submission and
a confirmed block reward:

  bitcoind/rpc_test.go  — isRetryable classification, 3-attempt backoff
    on 503, no retry on semantic RPC errors (-5), retry on warm-up (-28),
    CoinbaseReward calculation.

  logmon/tailer_test.go — handleLine regex parsing, share-diff carry from
    attempt line to block event, diff reset after use, unrelated-line
    silence, file-read via goroutine, cursor save/resume across restart,
    log rotation re-open.

  store/blocks_test.go  — InsertBlock roundtrip (all fields including
    chain), idempotent dedup, MarkOrphaned, UpdateEnrichment,
    BlocksNeedingEnrichment, KV get/set/overwrite, and migration of an
    existing DB that lacks the chain column.

  state/blocks_test.go  — reconcileOnce chain-filter (testnet block not
    orphaned on mainnet), genuine reorg detection (different canonical
    hash → orphaned), transient RPC error (no orphan on -5), legacy
    empty-chain block still checked for reorgs, IngestBlockEvents new
    block persisted + confirmed counter incremented, dedup skips counter
    and OnRefresh, chain stamped from current snapshot at ingest time.
This commit is contained in:
satoshi
2026-04-28 23:06:20 +03:00
parent 40c60922c8
commit d14dded7f0
4 changed files with 1157 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
package state
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"github.com/kamadopool/kamado-api/internal/bitcoind"
"github.com/kamadopool/kamado-api/internal/logmon"
"github.com/kamadopool/kamado-api/internal/store"
)
func discardLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func openTempStore(t *testing.T) *store.BlockStore {
t.Helper()
f, err := os.CreateTemp("", "state_test*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { _ = os.Remove(f.Name()) })
s, err := store.Open(f.Name())
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
// mockRPC starts an httptest server whose responses are determined by the
// supplied handler. The handler receives the RPC method name and the raw
// params JSON and returns the value to place in "result". Returning nil
// sends an empty result; returning a non-nil error (as rpcErrResp) sends
// an RPC error response.
type rpcErrResp struct {
Code int
Message string
}
func mockRPC(t *testing.T, handler func(method string) (any, *rpcErrResp)) *bitcoind.RPC {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
result, rpcErr := handler(req.Method)
if rpcErr != nil {
json.NewEncoder(w).Encode(map[string]any{
"result": nil,
"error": map[string]any{"code": rpcErr.Code, "message": rpcErr.Message},
})
return
}
json.NewEncoder(w).Encode(map[string]any{"result": result, "error": nil})
}))
t.Cleanup(srv.Close)
return bitcoind.NewRPC(srv.URL, "u", "p", 5*time.Second)
}
func newAgg(st *store.BlockStore, rpc *bitcoind.RPC) *Aggregator {
return &Aggregator{
Store: st,
RPC: rpc,
Log: discardLog(),
ready: make(chan struct{}),
}
}
// setChain sets the aggregator's snapshot chain without going through refresh.
func setChain(agg *Aggregator, chain string) {
agg.mu.Lock()
agg.snap.Chain = &bitcoind.BlockchainInfo{Chain: chain}
agg.mu.Unlock()
}
// ---- reconcileOnce tests ----
// TestReconcileOnce_ChainFilter verifies that a block recorded on testnet
// is NOT orphaned when the node is now on mainnet — even if the canonical
// hash at that height on mainnet is different.
func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
st := openTempStore(t)
// Insert a testnet block with a known hash.
_, err := st.InsertBlock(store.Block{
Height: 100,
Hash: "testnet_hash_aaa",
RewardBT: 50,
FoundAt: time.Now(),
Source: "test",
Chain: "test",
})
if err != nil {
t.Fatal(err)
}
// Mock RPC returns a DIFFERENT hash for height 100 — mainnet canonical.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
return "mainnet_hash_bbb", nil
}
return nil, nil
})
agg := newAgg(st, rpc)
setChain(agg, "main") // switched to mainnet
agg.reconcileOnce(context.Background())
blocks, err := st.Recent(10)
if err != nil {
t.Fatal(err)
}
for _, b := range blocks {
if b.Height == 100 && !b.OrphanedAt.IsZero() {
t.Error("testnet block was falsely orphaned after switching to mainnet — chain filter broken")
}
}
}
// TestReconcileOnce_GenuineReorg verifies that when the canonical block at a
// recorded height has a different hash (real on-chain reorg), the block is
// marked orphaned.
func TestReconcileOnce_GenuineReorg(t *testing.T) {
st := openTempStore(t)
_, err := st.InsertBlock(store.Block{
Height: 200,
Hash: "our_hash_aaaa",
RewardBT: 3.125,
FoundAt: time.Now(),
Source: "test",
Chain: "main",
})
if err != nil {
t.Fatal(err)
}
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
return "canonical_bbbb", nil // different — reorg happened
}
return nil, nil
})
agg := newAgg(st, rpc)
setChain(agg, "main")
agg.reconcileOnce(context.Background())
blocks, err := st.Recent(10)
if err != nil {
t.Fatal(err)
}
found := false
for _, b := range blocks {
if b.Height == 200 {
found = true
if b.OrphanedAt.IsZero() {
t.Error("block at reorged height was not marked orphaned")
}
}
}
if !found {
t.Error("block 200 not found in store after reconcile")
}
}
// TestReconcileOnce_RPCError_NoOrphan verifies that a transient RPC error
// (getblockhash fails) does not cause a block to be orphaned. The next
// reconcile sweep will retry.
func TestReconcileOnce_RPCError_NoOrphan(t *testing.T) {
st := openTempStore(t)
st.InsertBlock(store.Block{
Height: 300,
Hash: "our_hash_cccc",
RewardBT: 3.125,
FoundAt: time.Now(),
Source: "test",
Chain: "main",
})
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
return nil, &rpcErrResp{Code: -5, Message: "Block not found"}
}
return nil, nil
})
agg := newAgg(st, rpc)
setChain(agg, "main")
agg.reconcileOnce(context.Background())
blocks, _ := st.Recent(10)
for _, b := range blocks {
if b.Height == 300 && !b.OrphanedAt.IsZero() {
t.Error("block was orphaned on RPC error — should only orphan on definitive hash mismatch")
}
}
}
// TestReconcileOnce_LegacyBlock_StillChecked verifies that a legacy block
// (Chain == "") is still checked for reorgs on the current chain, preserving
// backward compatibility for installs that existed before chain was tracked.
func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
st := openTempStore(t)
st.InsertBlock(store.Block{
Height: 400,
Hash: "legacy_hash",
RewardBT: 3.125,
FoundAt: time.Now(),
Source: "test",
Chain: "", // legacy — no chain recorded
})
// Mainnet canonical hash differs: should orphan even though Chain is empty.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
return "different_canonical", nil
}
return nil, nil
})
agg := newAgg(st, rpc)
setChain(agg, "main")
agg.reconcileOnce(context.Background())
blocks, _ := st.Recent(10)
for _, b := range blocks {
if b.Height == 400 && b.OrphanedAt.IsZero() {
t.Error("legacy block (empty chain) should still be checked for reorgs")
}
}
}
// ---- IngestBlockEvents tests ----
// TestIngestBlockEvents_NewBlock verifies that a fresh block event is
// persisted to the store and the confirmed counter increments.
func TestIngestBlockEvents_NewBlock(t *testing.T) {
st := openTempStore(t)
agg := newAgg(st, nil) // nil RPC skips enrichment
setChain(agg, "main")
events := make(chan logmon.BlockEvent, 1)
done := make(chan struct{})
agg.OnRefresh = func(_ Snapshot) { close(done) }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go agg.IngestBlockEvents(ctx, events)
events <- logmon.BlockEvent{Height: 500, SeenAt: time.Now(), ShareDiff: 32768}
select {
case <-done:
case <-ctx.Done():
t.Fatal("timed out waiting for block to be processed")
}
blocks, _ := st.Recent(10)
if len(blocks) == 0 {
t.Fatal("block not persisted to store")
}
if blocks[0].Height != 500 {
t.Errorf("Height = %d, want 500", blocks[0].Height)
}
if blocks[0].Chain != "main" {
t.Errorf("Chain = %q, want main", blocks[0].Chain)
}
agg.mu.RLock()
confirmed := agg.blockSubmitsConfirmed
agg.mu.RUnlock()
if confirmed != 1 {
t.Errorf("blockSubmitsConfirmed = %d, want 1", confirmed)
}
}
// TestIngestBlockEvents_Dedup verifies that sending the same block height
// twice does not double-count the confirmed counter or call OnRefresh twice.
func TestIngestBlockEvents_Dedup(t *testing.T) {
st := openTempStore(t)
// Pre-insert the block so the first ingest sees it as a duplicate.
st.InsertBlock(store.Block{Height: 600, FoundAt: time.Now(), Chain: "main"})
var refreshCount int
var mu sync.Mutex
agg := newAgg(st, nil)
agg.OnRefresh = func(_ Snapshot) {
mu.Lock()
refreshCount++
mu.Unlock()
}
setChain(agg, "main")
events := make(chan logmon.BlockEvent, 1)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
agg.IngestBlockEvents(ctx, events)
}()
events <- logmon.BlockEvent{Height: 600, SeenAt: time.Now()}
close(events)
wg.Wait()
agg.mu.RLock()
confirmed := agg.blockSubmitsConfirmed
agg.mu.RUnlock()
if confirmed != 0 {
t.Errorf("blockSubmitsConfirmed = %d, want 0 for duplicate", confirmed)
}
mu.Lock()
rc := refreshCount
mu.Unlock()
if rc != 0 {
t.Errorf("OnRefresh called %d times, want 0 for duplicate block", rc)
}
}
// TestIngestBlockEvents_ChainStamping verifies that the block record stored
// carries the chain name from the current snapshot at ingest time.
func TestIngestBlockEvents_ChainStamping(t *testing.T) {
st := openTempStore(t)
agg := newAgg(st, nil)
setChain(agg, "test") // testnet at the time of the solve
events := make(chan logmon.BlockEvent, 1)
done := make(chan struct{})
agg.OnRefresh = func(_ Snapshot) { close(done) }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go agg.IngestBlockEvents(ctx, events)
events <- logmon.BlockEvent{Height: 700, SeenAt: time.Now()}
select {
case <-done:
case <-ctx.Done():
t.Fatal("timed out waiting for block to be processed")
}
blocks, _ := st.Recent(10)
if len(blocks) == 0 {
t.Fatal("block not found in store")
}
if blocks[0].Chain != "test" {
t.Errorf("Chain = %q, want test — chain stamping broken", blocks[0].Chain)
}
}