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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user