package statemachine import ( "context" "errors" "testing" "time" "overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/reconciliation" "overnight-trading-bot/internal/testutil" "overnight-trading-bot/internal/tinvest" ) func TestHeartbeatDoesNotClearHalt(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() system := New(repo, domain.ModeLiveTrade) if err := system.Halt(ctx, "manual kill switch"); err != nil { t.Fatal(err) } if err := system.Heartbeat(ctx, domain.StateSleep); err != nil { t.Fatal(err) } state, halted, reason, err := repo.GetSystemState(ctx) if err != nil { t.Fatal(err) } if state != domain.StateHalted || !halted || reason != "manual kill switch" { t.Fatalf("halt was not sticky: state=%s halted=%v reason=%q", state, halted, reason) } } func TestTransitionBlockedWhileHalted(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() system := New(repo, domain.ModePaper) if err := system.Halt(ctx, "risk"); err != nil { t.Fatal(err) } err := system.Transition(ctx, domain.StateHalted, domain.StateInit) if !errors.Is(err, ErrSystemHalted) { t.Fatalf("expected ErrSystemHalted, got %v", err) } } func TestUnhaltPreservesMode(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() if err := repo.SaveSystemState(ctx, domain.StateHalted, domain.ModeLiveTrade, true, "risk", "{}"); err != nil { t.Fatal(err) } if err := repo.Unhalt(ctx, "checked"); err != nil { t.Fatal(err) } _, halted, _, err := repo.GetSystemState(ctx) if err != nil { t.Fatal(err) } if halted || repo.Mode != domain.ModeLiveTrade { t.Fatalf("unhalt did not preserve mode: halted=%v mode=%s", halted, repo.Mode) } } func TestCalendarRecoveryAllowsRestartInsideExitWindow(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() system := New(repo, domain.ModePaper) if err := system.Transition(ctx, domain.StateInit, domain.StatePlaceExitOrders); err != nil { t.Fatalf("INIT -> PLACE_EXIT_ORDERS should be legal on restart: %v", err) } if err := repo.SaveSystemState(ctx, domain.StateHoldOvernight, domain.ModePaper, false, "", "{}"); err != nil { t.Fatal(err) } if err := system.Transition(ctx, domain.StateHoldOvernight, domain.StatePlaceExitOrders); err != nil { t.Fatalf("HOLD_OVERNIGHT -> PLACE_EXIT_ORDERS should be legal on restart: %v", err) } } func TestSyncStatesCanRecoverToInit(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() system := New(repo, domain.ModePaper) if err := system.Transition(ctx, domain.StateSyncInstruments, domain.StateInit); err != nil { t.Fatalf("SYNC_INSTRUMENTS -> INIT should be legal recovery: %v", err) } if err := system.Transition(ctx, domain.StateSyncMarketData, domain.StateInit); err != nil { t.Fatalf("SYNC_MARKET_DATA -> INIT should be legal recovery: %v", err) } } func TestRecoverFromMonitorEntryHaltsOnCriticalReconciliationDiff(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() if err := repo.SaveSystemState(ctx, domain.StateMonitorEntryOrders, domain.ModeSandbox, false, "", "{}"); err != nil { t.Fatal(err) } if err := repo.UpsertOrder(ctx, domain.Order{ ClientOrderID: "local", BrokerOrderID: "broker-missing", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: time.Now().UTC(), Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, QuantityLots: 1, Status: domain.OrderStatusSent, CreatedAt: time.Now().UTC().Add(-time.Minute), }); err != nil { t.Fatal(err) } system := New(repo, domain.ModeSandbox) state, err := system.Recover(ctx, reconciliation.New(repo, tinvest.NewFakeGateway(), "account", "hash")) if err == nil { t.Fatal("expected critical reconciliation error") } if state != domain.StateHalted || !repo.Halted { t.Fatalf("state=%s halted=%v, want HALTED", state, repo.Halted) } }