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) { // 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) 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) 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) 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 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{}) go func() { runSchedulerLoop(ctx, schedCfg, opts, ticks) 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") } }