diff --git a/api/internal/bitcoind/rpc_test.go b/api/internal/bitcoind/rpc_test.go new file mode 100644 index 0000000..d21f661 --- /dev/null +++ b/api/internal/bitcoind/rpc_test.go @@ -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) + } +} diff --git a/api/internal/logmon/tailer_test.go b/api/internal/logmon/tailer_test.go new file mode 100644 index 0000000..c26bd62 --- /dev/null +++ b/api/internal/logmon/tailer_test.go @@ -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") + } +} diff --git a/api/internal/state/blocks_test.go b/api/internal/state/blocks_test.go new file mode 100644 index 0000000..0d1c77e --- /dev/null +++ b/api/internal/state/blocks_test.go @@ -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) + } +} diff --git a/api/internal/store/blocks_test.go b/api/internal/store/blocks_test.go new file mode 100644 index 0000000..a2c15fa --- /dev/null +++ b/api/internal/store/blocks_test.go @@ -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) + } +}