Files
KamadoPool/api/internal/logmon/tailer_test.go
T
satoshi d14dded7f0 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.
2026-04-28 23:06:20 +03:00

297 lines
7.3 KiB
Go

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")
}
}