alt/services/worker/cmd/alt-worker/main_test.go

176 lines
4.6 KiB
Go

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