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
+213
View File
@@ -0,0 +1,213 @@
package bitcoind
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// ---- isRetryable unit tests ----
func TestIsRetryable_TransportErrors(t *testing.T) {
cases := []struct {
err error
want bool
}{
{fmt.Errorf("bitcoind rpc: connection refused"), true},
{fmt.Errorf("bitcoind rpc: connection reset by peer"), true},
{fmt.Errorf("bitcoind rpc: EOF"), true},
{fmt.Errorf("bitcoind rpc: i/o timeout"), true},
{fmt.Errorf("bitcoind rpc: deadline exceeded"), true},
{fmt.Errorf("bitcoind rpc: broken pipe"), true},
{fmt.Errorf("bitcoind rpc: 503: service unavailable"), true},
{fmt.Errorf("bitcoind rpc: 502: bad gateway"), true},
}
for _, tc := range cases {
if got := isRetryable(tc.err); got != tc.want {
t.Errorf("isRetryable(%q) = %v, want %v", tc.err, got, tc.want)
}
}
}
func TestIsRetryable_WarmupRPCError(t *testing.T) {
warmup := &rpcError{Code: -28, Message: "Loading block index"}
if !isRetryable(warmup) {
t.Error("warmup error (-28) should be retryable")
}
msgOnly := &rpcError{Code: -1, Message: "Bitcoin is warming up"}
if !isRetryable(msgOnly) {
t.Error("'warming up' message should be retryable")
}
}
func TestIsRetryable_SemanticRPCError(t *testing.T) {
blockNotFound := &rpcError{Code: -5, Message: "Block not found"}
if isRetryable(blockNotFound) {
t.Error("block-not-found (-5) should NOT be retryable")
}
invalidParam := &rpcError{Code: -1, Message: "Invalid parameter"}
if isRetryable(invalidParam) {
t.Error("invalid-parameter should NOT be retryable")
}
}
func TestIsRetryable_Nil(t *testing.T) {
if isRetryable(nil) {
t.Error("nil should not be retryable")
}
}
// ---- Call retry integration tests ----
// jsonRPCServer starts an httptest server that counts requests and serves the
// provided response JSON. If failFirst > 0, the first failFirst requests
// return HTTP 503 before that.
func jsonRPCServer(t *testing.T, failFirst int, result any) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if int(n) <= failFirst {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"result": result, "error": nil})
}))
t.Cleanup(srv.Close)
return srv, &calls
}
func TestCall_SuccessOnFirstTry(t *testing.T) {
srv, calls := jsonRPCServer(t, 0, "blockhash_abc")
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
var out string
if err := rpc.Call(context.Background(), "getblockhash", []any{100}, &out); err != nil {
t.Fatalf("Call: %v", err)
}
if out != "blockhash_abc" {
t.Errorf("result = %q, want blockhash_abc", out)
}
if n := atomic.LoadInt32(calls); n != 1 {
t.Errorf("calls = %d, want 1", n)
}
}
func TestCall_RetryOnTransient503(t *testing.T) {
srv, calls := jsonRPCServer(t, 2, "blockhash_xyz")
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
var out string
if err := rpc.Call(context.Background(), "getblockhash", []any{200}, &out); err != nil {
t.Fatalf("Call failed after retries: %v", err)
}
if out != "blockhash_xyz" {
t.Errorf("result = %q, want blockhash_xyz", out)
}
// First 2 calls fail, third succeeds.
if n := atomic.LoadInt32(calls); n != 3 {
t.Errorf("calls = %d, want 3", n)
}
}
func TestCall_ExhaustsRetries(t *testing.T) {
// Server always returns 503 — all 3 attempts should fail.
srv, calls := jsonRPCServer(t, 99, nil)
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
var out string
if err := rpc.Call(context.Background(), "getblockhash", []any{300}, &out); err == nil {
t.Fatal("expected error after exhausted retries, got nil")
}
if n := atomic.LoadInt32(calls); n != 3 {
t.Errorf("calls = %d, want 3 (maxAttempts)", n)
}
}
func TestCall_NoRetryOnSemanticRPCError(t *testing.T) {
// Server returns a semantic RPC error (block not found). Should not be
// retried — the answer is definitive.
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
json.NewEncoder(w).Encode(map[string]any{
"result": nil,
"error": map[string]any{"code": -5, "message": "Block not found"},
})
}))
t.Cleanup(srv.Close)
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
var out string
err := rpc.Call(context.Background(), "getblockhash", []any{999}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Errorf("calls = %d, want 1 (no retry on -5)", n)
}
}
func TestCall_RetryOnWarmup(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
json.NewEncoder(w).Encode(map[string]any{
"result": nil,
"error": map[string]any{"code": -28, "message": "Loading block index"},
})
return
}
json.NewEncoder(w).Encode(map[string]any{"result": "warmup_hash", "error": nil})
}))
t.Cleanup(srv.Close)
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
var out string
if err := rpc.Call(context.Background(), "getblockhash", []any{1}, &out); err != nil {
t.Fatalf("Call: %v", err)
}
if out != "warmup_hash" {
t.Errorf("result = %q, want warmup_hash", out)
}
if n := atomic.LoadInt32(&calls); n != 2 {
t.Errorf("calls = %d, want 2", n)
}
}
// ---- CoinbaseReward unit test ----
func TestCoinbaseReward(t *testing.T) {
blk := &BlockVerbose2{
Tx: []BlockTx{
{
Vout: []BlockVout{
{Value: 3.125},
{Value: 0.00042},
},
},
// Non-coinbase tx; should be ignored.
{Vout: []BlockVout{{Value: 99}}},
},
}
want := 3.125 + 0.00042
if got := blk.CoinbaseReward(); got != want {
t.Errorf("CoinbaseReward = %v, want %v", got, want)
}
}
func TestCoinbaseReward_EmptyBlock(t *testing.T) {
blk := &BlockVerbose2{}
if got := blk.CoinbaseReward(); got != 0 {
t.Errorf("empty block CoinbaseReward = %v, want 0", got)
}
}
+296
View File
@@ -0,0 +1,296 @@
package logmon
import (
"context"
"io"
"log/slog"
"os"
"sync"
"testing"
"time"
)
func discardLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// ---- handleLine unit tests (no filesystem, no goroutines) ----
func TestHandleLine_Solved(t *testing.T) {
tl := New("/unused", discardLog())
tl.handleLine("Solved and confirmed block 840123\n")
select {
case ev := <-tl.Events:
if ev.Height != 840123 {
t.Errorf("Height = %d, want 840123", ev.Height)
}
default:
t.Fatal("no BlockEvent emitted for solved line")
}
}
func TestHandleLine_AttemptCarriedToBlock(t *testing.T) {
tl := New("/unused", discardLog())
tl.handleLine("Possible block solve diff 32768 !\n")
tl.handleLine("Solved and confirmed block 900000\n")
select {
case <-tl.Attempts:
default:
t.Fatal("no AttemptEvent emitted for attempt line")
}
select {
case ev := <-tl.Events:
if ev.ShareDiff != 32768 {
t.Errorf("ShareDiff = %v, want 32768", ev.ShareDiff)
}
if ev.Height != 900000 {
t.Errorf("Height = %d, want 900000", ev.Height)
}
default:
t.Fatal("no BlockEvent emitted")
}
}
func TestHandleLine_SubmittingVariant(t *testing.T) {
tl := New("/unused", discardLog())
tl.handleLine("Submitting possible block solve share diff 65536 !\n")
select {
case ev := <-tl.Attempts:
if ev.ShareDiff != 65536 {
t.Errorf("ShareDiff = %v, want 65536", ev.ShareDiff)
}
default:
t.Fatal("no AttemptEvent for submitting variant")
}
// Must NOT also emit a BlockEvent.
select {
case ev := <-tl.Events:
t.Errorf("unexpected BlockEvent from attempt line: %+v", ev)
default:
}
}
func TestHandleLine_DiffResetAfterBlock(t *testing.T) {
tl := New("/unused", discardLog())
tl.handleLine("Possible block solve diff 16384 !\n")
tl.handleLine("Solved and confirmed block 100\n")
// Second block — no preceding attempt line; diff must NOT carry over.
tl.handleLine("Solved and confirmed block 101\n")
drain := func() BlockEvent {
select {
case ev := <-tl.Events:
return ev
default:
t.Fatal("no BlockEvent")
return BlockEvent{}
}
}
ev100 := drain()
ev101 := drain()
if ev100.ShareDiff != 16384 {
t.Errorf("block 100 ShareDiff = %v, want 16384", ev100.ShareDiff)
}
if ev101.ShareDiff != 0 {
t.Errorf("block 101 ShareDiff = %v, want 0 (no preceding attempt)", ev101.ShareDiff)
}
}
func TestHandleLine_Unrelated(t *testing.T) {
tl := New("/unused", discardLog())
for _, line := range []string{
"Stratifier started\n",
"New block hash 000000abc\n",
"Worker authenticated\n",
} {
tl.handleLine(line)
}
select {
case ev := <-tl.Events:
t.Errorf("unexpected BlockEvent from unrelated line: %+v", ev)
default:
}
select {
case ev := <-tl.Attempts:
t.Errorf("unexpected AttemptEvent from unrelated line: %+v", ev)
default:
}
}
// ---- Tailer.Run integration tests (filesystem + goroutines) ----
func tempLog(t *testing.T) string {
t.Helper()
f, err := os.CreateTemp("", "ckpool*.log")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { _ = os.Remove(f.Name()) })
return f.Name()
}
func appendLines(t *testing.T, path string, lines ...string) {
t.Helper()
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
defer f.Close()
for _, l := range lines {
if _, err := f.WriteString(l); err != nil {
t.Fatal(err)
}
}
}
// TestTailerRun_BasicRead starts the tailer on an empty file, appends log
// lines, and verifies the expected events are emitted.
func TestTailerRun_BasicRead(t *testing.T) {
path := tempLog(t)
tl := New(path, discardLog())
tl.PollWait = 20 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go tl.Run(ctx)
// Give the tailer time to open the empty file and park at EOF.
time.Sleep(40 * time.Millisecond)
appendLines(t, path,
"Possible block solve diff 16384 !\n",
"Solved and confirmed block 100\n",
)
select {
case <-tl.Attempts:
case <-ctx.Done():
t.Fatal("timed out waiting for AttemptEvent")
}
select {
case ev := <-tl.Events:
if ev.Height != 100 {
t.Errorf("Height = %d, want 100", ev.Height)
}
if ev.ShareDiff != 16384 {
t.Errorf("ShareDiff = %v, want 16384", ev.ShareDiff)
}
case <-ctx.Done():
t.Fatal("timed out waiting for BlockEvent")
}
}
// TestTailerRun_CursorResume verifies that a new tailer honours a saved
// cursor and does not re-emit lines that were already processed.
func TestTailerRun_CursorResume(t *testing.T) {
path := tempLog(t)
var mu sync.Mutex
var savedIno uint64
var savedOff int64
tl := New(path, discardLog())
tl.PollWait = 20 * time.Millisecond
tl.SaveCursor = func(ino uint64, off int64) {
mu.Lock()
savedIno, savedOff = ino, off
mu.Unlock()
}
ctx1, cancel1 := context.WithTimeout(context.Background(), 3*time.Second)
go tl.Run(ctx1)
time.Sleep(40 * time.Millisecond)
// Phase 1: write 2 blocks and drain both events.
appendLines(t, path,
"Solved and confirmed block 200\n",
"Solved and confirmed block 201\n",
)
for i := 0; i < 2; i++ {
select {
case <-tl.Events:
case <-ctx1.Done():
t.Fatalf("phase 1: only got %d events, want 2", i)
}
}
// Allow the tailer to hit EOF and save the cursor.
time.Sleep(100 * time.Millisecond)
cancel1()
// Give the goroutine time to exit and force-save.
time.Sleep(60 * time.Millisecond)
mu.Lock()
ino, off := savedIno, savedOff
mu.Unlock()
if ino == 0 {
t.Fatal("cursor was never saved after phase 1")
}
// Phase 2: append a third block, start a new tailer from the cursor.
// It must see ONLY block 202, not replays of 200 or 201.
appendLines(t, path, "Solved and confirmed block 202\n")
tl2 := New(path, discardLog())
tl2.PollWait = 20 * time.Millisecond
tl2.LoadCursor = func() (uint64, int64, bool) { return ino, off, true }
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel2()
go tl2.Run(ctx2)
select {
case ev := <-tl2.Events:
if ev.Height != 202 {
t.Errorf("resumed tailer emitted height %d, want 202 (cursor resume may be broken)", ev.Height)
}
case <-ctx2.Done():
t.Fatal("timed out waiting for block 202 after cursor resume")
}
}
// TestTailerRun_Rotation verifies that when the log file is replaced
// (inode changes), the tailer re-opens it from the start and reads the
// new content.
func TestTailerRun_Rotation(t *testing.T) {
path := tempLog(t)
tl := New(path, discardLog())
tl.PollWait = 30 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go tl.Run(ctx)
time.Sleep(50 * time.Millisecond)
// Write a block to the original file and collect it.
appendLines(t, path, "Solved and confirmed block 300\n")
select {
case ev := <-tl.Events:
if ev.Height != 300 {
t.Errorf("before rotation: height = %d, want 300", ev.Height)
}
case <-ctx.Done():
t.Fatal("timed out waiting for pre-rotation block")
}
// Simulate log rotation: replace the file at the same path.
newF, err := os.CreateTemp("", "ckpool_new*.log")
if err != nil {
t.Fatal(err)
}
newF.WriteString("Solved and confirmed block 301\n")
newF.Close()
if err := os.Rename(newF.Name(), path); err != nil {
t.Fatal(err)
}
select {
case ev := <-tl.Events:
if ev.Height != 301 {
t.Errorf("after rotation: height = %d, want 301", ev.Height)
}
case <-ctx.Done():
t.Fatal("timed out waiting for post-rotation block")
}
}
+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)
}
}
+270
View File
@@ -0,0 +1,270 @@
package store
import (
"database/sql"
"os"
"testing"
"time"
_ "modernc.org/sqlite"
)
func openTemp(t *testing.T) *BlockStore {
t.Helper()
f, err := os.CreateTemp("", "kamado*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { _ = os.Remove(f.Name()) })
s, err := Open(f.Name())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestInsertBlock_Roundtrip(t *testing.T) {
s := openTemp(t)
now := time.Now().UTC().Truncate(time.Second)
b := Block{
Height: 840000,
Hash: "000000000000000000024bead8df69990852c202db0e0097c1a12ea637d7e96d",
RewardBT: 3.125,
FoundAt: now,
Source: "logmon",
ShareDiff: 8_000_000,
Chain: "main",
}
inserted, err := s.InsertBlock(b)
if err != nil {
t.Fatalf("InsertBlock: %v", err)
}
if !inserted {
t.Fatal("expected inserted=true for a new block")
}
blocks, err := s.Recent(10)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(blocks) != 1 {
t.Fatalf("Recent returned %d blocks, want 1", len(blocks))
}
got := blocks[0]
if got.Height != b.Height {
t.Errorf("Height = %d, want %d", got.Height, b.Height)
}
if got.Hash != b.Hash {
t.Errorf("Hash = %q, want %q", got.Hash, b.Hash)
}
if got.RewardBT != b.RewardBT {
t.Errorf("RewardBT = %v, want %v", got.RewardBT, b.RewardBT)
}
if !got.FoundAt.Equal(b.FoundAt) {
t.Errorf("FoundAt = %v, want %v", got.FoundAt, b.FoundAt)
}
if got.Chain != "main" {
t.Errorf("Chain = %q, want \"main\"", got.Chain)
}
if !got.OrphanedAt.IsZero() {
t.Errorf("OrphanedAt should be zero, got %v", got.OrphanedAt)
}
}
func TestInsertBlock_Dedup(t *testing.T) {
s := openTemp(t)
b := Block{Height: 100, FoundAt: time.Now(), Chain: "main"}
inserted1, err := s.InsertBlock(b)
if err != nil || !inserted1 {
t.Fatalf("first insert: err=%v inserted=%v", err, inserted1)
}
inserted2, err := s.InsertBlock(b)
if err != nil {
t.Fatalf("second insert: %v", err)
}
if inserted2 {
t.Error("second insert at same height should return inserted=false")
}
blocks, _ := s.Recent(10)
if len(blocks) != 1 {
t.Errorf("store has %d blocks after dedup, want 1", len(blocks))
}
}
func TestMarkOrphaned_RoundTrip(t *testing.T) {
s := openTemp(t)
b := Block{Height: 200, Hash: "abc123", FoundAt: time.Now(), Chain: "main"}
s.InsertBlock(b)
orphanTime := time.Now().UTC().Truncate(time.Second)
if err := s.MarkOrphaned(200, orphanTime); err != nil {
t.Fatalf("MarkOrphaned: %v", err)
}
blocks, _ := s.Recent(10)
if len(blocks) == 0 {
t.Fatal("no blocks after MarkOrphaned")
}
if blocks[0].OrphanedAt.IsZero() {
t.Error("OrphanedAt should be set after MarkOrphaned")
}
if !blocks[0].OrphanedAt.Equal(orphanTime) {
t.Errorf("OrphanedAt = %v, want %v", blocks[0].OrphanedAt, orphanTime)
}
}
func TestUpdateEnrichment(t *testing.T) {
s := openTemp(t)
s.InsertBlock(Block{Height: 300, FoundAt: time.Now(), Chain: "main"})
if err := s.UpdateEnrichment(300, "newhash", 3.125); err != nil {
t.Fatalf("UpdateEnrichment: %v", err)
}
blocks, _ := s.Recent(10)
if blocks[0].Hash != "newhash" {
t.Errorf("Hash = %q, want newhash", blocks[0].Hash)
}
if blocks[0].RewardBT != 3.125 {
t.Errorf("RewardBT = %v, want 3.125", blocks[0].RewardBT)
}
}
func TestBlocksNeedingEnrichment(t *testing.T) {
s := openTemp(t)
now := time.Now()
// Block missing hash → needs enrichment.
s.InsertBlock(Block{Height: 1, FoundAt: now, Chain: "main"})
// Block with hash but no reward → needs enrichment.
s.InsertBlock(Block{Height: 2, Hash: "abc", RewardBT: 0, FoundAt: now, Chain: "main"})
// Block fully enriched → does NOT need enrichment.
s.InsertBlock(Block{Height: 3, Hash: "def", RewardBT: 3.125, FoundAt: now, Chain: "main"})
since := now.Add(-time.Hour)
missing, err := s.BlocksNeedingEnrichment(since)
if err != nil {
t.Fatalf("BlocksNeedingEnrichment: %v", err)
}
if len(missing) != 2 {
t.Errorf("got %d blocks needing enrichment, want 2", len(missing))
}
for _, b := range missing {
if b.Height == 3 {
t.Error("fully enriched block should not appear in missing list")
}
}
}
// TestMigrate_AddsChainColumn creates a database with the old schema
// (no chain column) and verifies Open() migrates it transparently.
func TestMigrate_AddsChainColumn(t *testing.T) {
f, err := os.CreateTemp("", "kamado_old*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { _ = os.Remove(f.Name()) })
// Build old-style schema without the chain column.
db, err := sql.Open("sqlite", f.Name())
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE blocks (
height INTEGER PRIMARY KEY,
hash TEXT NOT NULL DEFAULT '',
reward_btc REAL NOT NULL DEFAULT 0,
found_at INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT '',
share_diff REAL NOT NULL DEFAULT 0,
orphaned_at INTEGER NOT NULL DEFAULT 0
)`)
if err != nil {
db.Close()
t.Fatalf("create old schema: %v", err)
}
_, err = db.Exec(`INSERT INTO blocks(height, found_at) VALUES (999, ?)`, time.Now().Unix())
if err != nil {
db.Close()
t.Fatalf("insert old row: %v", err)
}
db.Close()
// Open via our store — migrate() must add the chain column.
s, err := Open(f.Name())
if err != nil {
t.Fatalf("Open after migration: %v", err)
}
defer s.Close()
// Insert a block with a chain value — would fail if migration didn't add
// the column.
inserted, err := s.InsertBlock(Block{
Height: 1000,
FoundAt: time.Now(),
Chain: "test",
})
if err != nil {
t.Fatalf("InsertBlock after migration: %v", err)
}
if !inserted {
t.Error("expected inserted=true")
}
blocks, err := s.Recent(10)
if err != nil {
t.Fatalf("Recent: %v", err)
}
// Row 999 (old, no chain) and row 1000 (new, chain="test").
if len(blocks) != 2 {
t.Fatalf("got %d blocks, want 2", len(blocks))
}
// Newest first; row 1000 is first.
if blocks[0].Chain != "test" {
t.Errorf("new block chain = %q, want test", blocks[0].Chain)
}
if blocks[1].Chain != "" {
t.Errorf("old block chain = %q, want \"\" (empty default)", blocks[1].Chain)
}
}
func TestKVRoundTrip(t *testing.T) {
s := openTemp(t)
if err := s.SetKV("foo", "bar"); err != nil {
t.Fatalf("SetKV: %v", err)
}
v, err := s.GetKV("foo")
if err != nil {
t.Fatalf("GetKV: %v", err)
}
if v != "bar" {
t.Errorf("GetKV = %q, want bar", v)
}
// Overwrite.
if err := s.SetKV("foo", "baz"); err != nil {
t.Fatalf("SetKV overwrite: %v", err)
}
v2, _ := s.GetKV("foo")
if v2 != "baz" {
t.Errorf("after overwrite GetKV = %q, want baz", v2)
}
// Missing key returns empty string.
v3, err := s.GetKV("nonexistent")
if err != nil {
t.Fatalf("GetKV missing: %v", err)
}
if v3 != "" {
t.Errorf("missing key GetKV = %q, want empty", v3)
}
}