package main import ( "context" "os" "path/filepath" "sync" "testing" "time" "git.toki-labs.com/toki/alt/services/worker/internal/config" "git.toki-labs.com/toki/alt/services/worker/internal/jobs" "git.toki-labs.com/toki/alt/services/worker/internal/providers/kis" "git.toki-labs.com/toki/alt/services/worker/internal/scheduler" "git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres" ) // TestStoreBackedDepsWiresBacktestSurfaces guards against a regression where the // worker process serves backtest reads but leaves the start surface (Starter) // unwired, which would advertise no backtest-start capability and reject every // start command. The store is built with a nil pool because the wiring path only // constructs adapters; it never touches the database at startup. func TestStoreBackedDepsWiresBacktestSurfaces(t *testing.T) { runner := jobs.NewRunner() deps := storeBackedDeps(runner, postgres.NewStore(nil)) if deps.Starter == nil { t.Error("expected backtest Starter to be wired") } if deps.Analysis == nil || deps.Results == nil { t.Error("expected backtest read stores to be wired") } if deps.Instruments == nil || deps.Bars == nil { t.Error("expected market stores to be wired") } if deps.DailyBarImporter == nil { t.Error("expected daily bar importer to be wired") } if deps.Paper == nil { t.Error("expected paper trading service to be wired") } if deps.Live == nil { t.Error("expected live trading service to be wired (audit query surface must be up even without a broker)") } // Wiring registers the import and run-backtest job handlers on the runner, so // it must hold at least those two handlers after wiring. if runner.Len() < 2 { t.Errorf("expected wiring to register job handlers, runner holds %d", runner.Len()) } } func TestStartScheduler(t *testing.T) { statusStore := scheduler.NewRefreshStatusStore() // 1. Scheduler disabled (no config path) cfg := config.Config{ SchedulerEnabled: false, } runner := jobs.NewRunner() ctx, cancel := context.WithCancel(context.Background()) defer cancel() err := startScheduler(ctx, cfg, runner, statusStore) if err != nil { t.Fatalf("expected nil error for disabled scheduler, got %v", err) } // 2. Scheduler enabled with invalid config path (should fail) cfg.SchedulerEnabled = true cfg.ScheduleConfigPath = "non_existent_file.yaml" err = startScheduler(ctx, cfg, runner, statusStore) if err == nil { t.Fatal("expected error for non existent schedule config, got nil") } // 3. Scheduler enabled with valid config tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "schedule.yaml") if err := os.WriteFile(tmpFile, []byte(` schedules: - name: kr-core-daily provider: kis selector: kind: watchlist market: KR venue: KRX name: kr-core symbols: ["005930"] timeframe: daily cadence: daily timezone: Asia/Seoul backfill_window: 3d parallelism_limit: 2 `), 0644); err != nil { t.Fatalf("failed to write temp file: %v", err) } cfg.ScheduleConfigPath = tmpFile err = startScheduler(ctx, cfg, runner, statusStore) if err != nil { t.Fatalf("expected no error for valid config, got %v", err) } } type mockExecutor struct { mu sync.Mutex dispatched int } func (m *mockExecutor) Execute(ctx context.Context, job jobs.Job) error { m.mu.Lock() m.dispatched++ m.mu.Unlock() return nil } func (m *mockExecutor) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) { return jobs.Result{}, false, nil } // mockExecutorSuccess always succeeds but returns NO import evidence. type mockExecutorSuccess struct{} func (m *mockExecutorSuccess) Execute(ctx context.Context, job jobs.Job) error { return nil } func (m *mockExecutorSuccess) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) { // No evidence: success but no import bars. return jobs.Result{}, false, nil } // mockExecutorWithEvidence always succeeds and returns actual import evidence. type mockExecutorWithEvidence struct { bars int instruments int } func (m *mockExecutorWithEvidence) Execute(ctx context.Context, job jobs.Job) error { return nil } func (m *mockExecutorWithEvidence) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) { return jobs.Result{ Bars: m.bars, Instruments: m.instruments, }, true, nil } func TestSchedulerLoopDispatchesTickAndStops(t *testing.T) { schedCfg := scheduler.Config{ Schedules: []scheduler.ScheduleConfig{ { Name: "kr-core-daily-mock", Provider: "kis", Selector: scheduler.SelectorConfig{ Kind: "watchlist", Market: "KR", Venue: "KRX", Name: "kr-core", Symbols: []string{"005930"}, }, Timeframe: "daily", Cadence: "daily", Timezone: "Asia/Seoul", BackfillWindow: "3d", ParallelismLimit: 2, }, }, } exec := &mockExecutor{} opts := scheduler.TickOptions{ Capabilities: scheduler.CapabilityMap(kis.Capability()), Executor: exec, } ctx, cancel := context.WithCancel(context.Background()) ticks := make(chan time.Time) loopDone := make(chan struct{}) statusStore := scheduler.NewRefreshStatusStore() go func() { runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore) close(loopDone) }() // Send 1 tick ticks <- time.Now() // Wait briefly for dispatch time.Sleep(10 * time.Millisecond) exec.mu.Lock() dispatchedVal := exec.dispatched exec.mu.Unlock() if dispatchedVal == 0 { t.Error("expected at least 1 job dispatch on tick") } // Cancel context to shutdown cancel() select { case <-loopDone: // success: loop stopped case <-time.After(1 * time.Second): t.Error("scheduler loop did not stop on context cancellation") } } // TestSchedulerLoopRecordsTickAndStatusStore verifies that a no-evidence success // is classified as "stale" (not "success") and does NOT update last_success. func TestSchedulerLoopRecordsTickAndStatusStore(t *testing.T) { schedCfg := scheduler.Config{ Schedules: []scheduler.ScheduleConfig{ { Name: "kr-core-daily-mock", Provider: "kis", Selector: scheduler.SelectorConfig{ Kind: "watchlist", Market: "KR", Venue: "KRX", Name: "kr-core", Symbols: []string{"005930"}, }, Timeframe: "daily", Cadence: "daily", Timezone: "Asia/Seoul", BackfillWindow: "3d", ParallelismLimit: 2, }, }, } // mockExecutorSuccess always succeeds but returns NO import evidence. execSucceed := &mockExecutorSuccess{} opts := scheduler.TickOptions{ Capabilities: scheduler.CapabilityMap(kis.Capability()), Executor: execSucceed, } ctx, cancel := context.WithCancel(context.Background()) ticks := make(chan time.Time) loopDone := make(chan struct{}) statusStore := scheduler.NewRefreshStatusStore() go func() { runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore) close(loopDone) }() // Send a tick and wait for processing. now := time.Now() ticks <- now time.Sleep(50 * time.Millisecond) // Verify that the status store recorded a "stale" entry (no evidence). entry, ok := statusStore.Get("kr-core-daily-mock") if !ok { t.Fatal("expected status store entry for schedule after tick") } if entry.Status != "stale" { t.Errorf("status = %q, want %q (no evidence → stale)", entry.Status, "stale") } // CRITICAL: without ImportSummary from executor, ImportedBarCount MUST be 0. if entry.ImportedBarCount != 0 { t.Errorf("imported_bar_count = %d, want 0 (no evidence from mock executor)", entry.ImportedBarCount) } // last_success must NOT be set for no-evidence. if !entry.LastSuccess.IsZero() { t.Errorf("last_success = %v, want zero (no evidence)", entry.LastSuccess) } cancel() select { case <-loopDone: case <-time.After(1 * time.Second): t.Error("scheduler loop did not stop on context cancellation") } } // TestSchedulerLoopWithActualEvidence proves that when the executor returns // actual RefreshEvidence, the status store correctly propagates imported bar counts. func TestSchedulerLoopWithActualEvidence(t *testing.T) { schedCfg := scheduler.Config{ Schedules: []scheduler.ScheduleConfig{ { Name: "kr-core-daily-evidence", Provider: "kis", Selector: scheduler.SelectorConfig{ Kind: "watchlist", Market: "KR", Venue: "KRX", Name: "kr-core", Symbols: []string{"005930"}, }, Timeframe: "daily", Cadence: "daily", Timezone: "Asia/Seoul", BackfillWindow: "3d", ParallelismLimit: 2, }, }, } // mockExecutorWithEvidence returns actual import evidence. execWithEvid := &mockExecutorWithEvidence{bars: 120, instruments: 1} opts := scheduler.TickOptions{ Capabilities: scheduler.CapabilityMap(kis.Capability()), Executor: execWithEvid, } ctx, cancel := context.WithCancel(context.Background()) ticks := make(chan time.Time) loopDone := make(chan struct{}) statusStore := scheduler.NewRefreshStatusStore() go func() { runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore) close(loopDone) }() now := time.Now() ticks <- now time.Sleep(50 * time.Millisecond) // Verify actual evidence propagation. entry, ok := statusStore.Get("kr-core-daily-evidence") if !ok { t.Fatal("expected status store entry after tick with evidence") } if entry.Status != "success" { t.Errorf("status = %q, want %q", entry.Status, "success") } if entry.ImportedBarCount != 120 { t.Errorf("imported_bar_count = %d, want 120", entry.ImportedBarCount) } if entry.LastSuccess.IsZero() { t.Error("last_success should be set when evidence is present") } cancel() select { case <-loopDone: case <-time.After(1 * time.Second): t.Error("scheduler loop did not stop on context cancellation") } } func TestRecordTickSkippedItemsNotSuccess(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // All items are skipped (e.g., duplicate window running). items := []scheduler.ItemResult{ {Schedule: "test", Symbol: "005930", Status: "skipped", Provider: "kis", Timeframe: "daily", Readiness: "stale", Freshness: "stale", Error: "duplicate window running"}, } store.RecordTick("test", items, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry after recording skipped items") } if entry.Status != "stale" { t.Errorf("status = %q, want %q", entry.Status, "stale") } } func TestRecordTickFailedItemsError(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() items := []scheduler.ItemResult{ {Schedule: "test", Symbol: "005930", Status: "failed", Provider: "kis", Timeframe: "daily", Readiness: "error", Freshness: "stale", Error: "connection timeout"}, } store.RecordTick("test", items, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry after recording failed items") } if entry.Status != "error" { t.Errorf("status = %q, want %q", entry.Status, "error") } if entry.LastError != "connection timeout" { t.Errorf("last_error = %q, want %q", entry.LastError, "connection timeout") } } func TestRecordTickMixedSucceededSkippedStale(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // Some succeeded, some skipped: should be "stale" not "success". items := []scheduler.ItemResult{ {Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily"}, {Schedule: "test", Symbol: "000660", Status: "skipped", Provider: "kis", Timeframe: "daily", Error: "duplicate window running"}, } store.RecordTick("test", items, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry after recording mixed items") } if entry.Status != "stale" { t.Errorf("status = %q, want %q", entry.Status, "stale") } // imported_bar_count should be 0 for stale. if entry.ImportedBarCount != 0 { t.Errorf("imported_bar_count = %d, want 0 for stale", entry.ImportedBarCount) } } func TestRecordTickPreservesLastSuccessOnStale(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // First: evidence-backed success. store.RecordTick("test", []scheduler.ItemResult{ { Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", ImportSummary: &scheduler.RefreshEvidence{ HasBars: true, ImportedBars: 100, }, }, }, now) prev, _ := store.Get("test") if prev.Status != "success" { t.Fatalf("first tick status = %q, want %q", prev.Status, "success") } if prev.LastSuccess.IsZero() { t.Fatal("last_success should be set after evidence-backed tick") } // Second: all skipped (stale). Should preserve last_success. store.RecordTick("test", []scheduler.ItemResult{ {Schedule: "test", Symbol: "005930", Status: "skipped", Provider: "kis", Timeframe: "daily", Error: "duplicate"}, }, now.Add(1*time.Minute)) stale, _ := store.Get("test") if stale.Status != "stale" { t.Errorf("stale status = %q, want %q", stale.Status, "stale") } if !stale.LastSuccess.Equal(prev.LastSuccess) { t.Errorf("last_success was updated on stale tick: was %v, still %v", prev.LastSuccess, stale.LastSuccess) } } func TestRecordTickUnknownStatusNoSuccessEvidence(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // Unknown status should not create success evidence. store.RecordTick("test", []scheduler.ItemResult{ {Schedule: "test", Symbol: "005930", Status: "unknown", Provider: "kis", Timeframe: "daily"}, }, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry after recording unknown status") } if entry.Status == "success" { t.Errorf("status = %q for unknown item, should NOT be %q", entry.Status, "success") } } func TestRecordTickZeroItemsSkipped(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // Zero items: should not create any entry. store.RecordTick("test", []scheduler.ItemResult{}, now) _, ok := store.Get("test") if ok { t.Error("expected no entry when zero items recorded") } } func TestSchedulerLoopConfigWiring(t *testing.T) { // Verify that startScheduler saves the schedule config to statusStore // so that next_run calculations work correctly. statusStore := scheduler.NewRefreshStatusStore() tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "schedule.yaml") if err := os.WriteFile(tmpFile, []byte(` schedules: - name: kr-core-daily-wire provider: kis selector: kind: watchlist market: KR venue: KRX name: kr-core symbols: ["005930"] timeframe: daily cadence: daily timezone: Asia/Seoul backfill_window: 3d parallelism_limit: 2 `), 0644); err != nil { t.Fatalf("failed to write temp file: %v", err) } cfg := config.Config{ SchedulerEnabled: true, ScheduleConfigPath: tmpFile, } runner := jobs.NewRunner() ctx, cancel := context.WithCancel(context.Background()) defer cancel() err := startScheduler(ctx, cfg, runner, statusStore) if err != nil { t.Fatalf("startScheduler: %v", err) } // Verify that the schedule config was stored. storedCfg := statusStore.ScheduleConfig() if len(storedCfg.Schedules) != 1 { t.Errorf("stored config has %d schedules, want 1", len(storedCfg.Schedules)) } if len(storedCfg.Schedules) > 0 && storedCfg.Schedules[0].Name != "kr-core-daily-wire" { t.Errorf("schedule name = %q, want %q", storedCfg.Schedules[0].Name, "kr-core-daily-wire") } } // TestSchedulerLoopMixedEvidenceNoEvidenceSuccessPreservesLastSuccess proves that // a no-evidence tick does NOT overwrite last_success from a previous evidence-backed // success. After this change, no-evidence succeeds are classified as "stale" (not // "success"), so the previous evidence-backed "success" state is preserved. func TestSchedulerLoopMixedEvidenceNoEvidenceSuccessPreservesLastSuccess(t *testing.T) { schedCfg := scheduler.Config{ Schedules: []scheduler.ScheduleConfig{ { Name: "kr-core-daily-mixed", Provider: "kis", Selector: scheduler.SelectorConfig{ Kind: "watchlist", Market: "KR", Venue: "KRX", Name: "kr-core", Symbols: []string{"005930"}, }, Timeframe: "daily", Cadence: "daily", Timezone: "Asia/Seoul", BackfillWindow: "3d", ParallelismLimit: 2, }, }, } execWithEvid := &mockExecutorWithEvidence{bars: 100, instruments: 1} opts := scheduler.TickOptions{ Capabilities: scheduler.CapabilityMap(kis.Capability()), Executor: execWithEvid, } ctx, cancel := context.WithCancel(context.Background()) ticks := make(chan time.Time) loopDone := make(chan struct{}) statusStore := scheduler.NewRefreshStatusStore() go func() { runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore) close(loopDone) }() now := time.Now() ticks <- now time.Sleep(50 * time.Millisecond) // Verify evidence-backed success. entry1, ok := statusStore.Get("kr-core-daily-mixed") if !ok { t.Fatal("expected entry after evidence-backed tick") } if entry1.Status != "success" { t.Errorf("status = %q, want %q", entry1.Status, "success") } if entry1.ImportedBarCount != 100 { t.Errorf("imported_bar_count = %d, want 100", entry1.ImportedBarCount) } if entry1.LastSuccess.IsZero() { t.Fatal("last_success should be set after evidence-backed tick") } lastSuccessAfterEvidence := entry1.LastSuccess cancel() select { case <-loopDone: case <-time.After(1 * time.Second): t.Error("scheduler loop did not stop on context cancellation") } // Now test no-evidence preservation via direct RecordTick calls. // This validates that calling RecordTick with no-evidence (ImportSummary == nil) // does NOT overwrite the previous evidence-backed "success" state. entry2, _ := statusStore.Get("kr-core-daily-mixed") if entry2.Status != "success" { t.Errorf("pre-test status = %q, want %q", entry2.Status, "success") } // RecordTick with no-evidence success item. statusStore.RecordTick("kr-core-daily-mixed", []scheduler.ItemResult{ {Schedule: "kr-core-daily-mixed", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh"}, }, now.Add(1*time.Minute)) entry3, ok := statusStore.Get("kr-core-daily-mixed") if !ok { t.Fatal("expected entry after RecordTick") } // No-evidence tick replaces previous "success" → the entry becomes "stale" // because all items have no evidence. The previous last_success must NOT // be overwritten, but the status itself is now "stale". if entry3.Status != "stale" { t.Errorf("post-no-evidence status = %q, want %q (no evidence → stale)", entry3.Status, "stale") } // last_success must NOT be updated by no-evidence tick. if !entry3.LastSuccess.Equal(lastSuccessAfterEvidence) { t.Errorf("last_success = %v, want %v (preserved from evidence tick)", entry3.LastSuccess, lastSuccessAfterEvidence) } // imported_bar_count stays the same. if entry3.ImportedBarCount != 100 { t.Errorf("imported_bar_count = %d, want 100 (no evidence to add)", entry3.ImportedBarCount) } } // TestDirectRecordTickNoEvidenceSuccessNoLastSuccess verifies that no-evidence // success is classified as "stale" and does NOT set last_success. func TestDirectRecordTickNoEvidenceSuccessNoLastSuccess(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // Direct RecordTick with no-evidence success (ImportSummary == nil). items := []scheduler.ItemResult{ {Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh"}, } store.RecordTick("test", items, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry") } if entry.Status != "stale" { t.Errorf("status = %q, want %q (no evidence → stale)", entry.Status, "stale") } // No evidence: status is "stale", so last_success must NOT be set. if !entry.LastSuccess.IsZero() { t.Errorf("last_success = %v, want zero (no evidence)", entry.LastSuccess) } // No evidence: ImportedBarCount should be 0. if entry.ImportedBarCount != 0 { t.Errorf("imported_bar_count = %d, want 0", entry.ImportedBarCount) } } // TestDirectRecordTickEvidenceSuccess sets last_success. func TestDirectRecordTickEvidenceSuccessSetsLastSuccess(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() items := []scheduler.ItemResult{ { Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh", ImportSummary: &scheduler.RefreshEvidence{ HasBars: true, ImportedBars: 150, }, }, } store.RecordTick("test", items, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry") } if entry.Status != "success" { t.Errorf("status = %q, want %q", entry.Status, "success") } if entry.ImportedBarCount != 150 { t.Errorf("imported_bar_count = %d, want 150", entry.ImportedBarCount) } if entry.LastSuccess.IsZero() { t.Error("last_success should be set when evidence is present") } } // TestDirectRecordTickMixedEvidenceDoesNotUpgradeSuccess proves that a mixed // tick (some succeeded with evidence, some succeeded without) does NOT silently // upgrade to full success evidence. Only items with evidence contribute bar // counts; the status is still "success" because all items succeeded, but // last_success is only updated when hasEvidence is true. func TestDirectRecordTickMixedEvidenceDoesNotUpgradeSuccess(t *testing.T) { store := scheduler.NewRefreshStatusStore() now := time.Now() // Two items: one with evidence, one without. items := []scheduler.ItemResult{ { Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", ImportSummary: &scheduler.RefreshEvidence{ HasBars: true, ImportedBars: 100, }, }, { Schedule: "test", Symbol: "000660", Status: "succeeded", Provider: "kis", Timeframe: "daily", // No ImportSummary — no evidence. }, } store.RecordTick("test", items, now) entry, ok := store.Get("test") if !ok { t.Fatal("expected entry") } // All items succeeded → status is "success" even with mixed evidence. if entry.Status != "success" { t.Errorf("status = %q, want %q", entry.Status, "success") } // Only the item with evidence contributes bar counts. if entry.ImportedBarCount != 100 { t.Errorf("imported_bar_count = %d, want 100", entry.ImportedBarCount) } // hasEvidence is true (at least one item has evidence) → last_success is set. if entry.LastSuccess.IsZero() { t.Error("last_success should be set when hasEvidence is true (mixed with at least one evidence)") } }