alt/services/worker/internal/socket/scheduler_test.go
toki bdbfe8228e feat: scheduled market data refresh - parser_map, refresh status model, CLI output, scheduler backfill, socket handlers
- Add parser_map for market data refresh configuration propagation (cli/worker/api)
- Implement refresh status model with SQLite persistence (status_store.go)
- Add scheduler refresh status headless output to CLI operator
- Extend backfill scheduler with status tracking (start/complete/fail)
- Add socket events for scheduler refresh status (start/complete/fail)
- Update proto definitions for refresh status
- Add generated PB files for client
2026-06-22 18:46:45 +09:00

310 lines
9.5 KiB
Go

package socket
import (
"testing"
"time"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
"git.toki-labs.com/toki/alt/services/worker/internal/scheduler"
)
// TestSchedulerHandlersRegisterWhenRefreshStatusIsSet verifies that the
// SchedulerRefreshStatusRequest handler is registered only when the
// RefreshStatus dependency is wired, and that the handler returns all entries
// (success, stale, error) with the expected protobuf fields.
func TestSchedulerHandlersRegisterWhenRefreshStatusIsSet(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
store.SetScheduleConfig(scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-daily",
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,
},
},
})
store.Record("kr-daily", scheduler.RefreshStatus{
Schedule: "kr-daily",
Status: "success",
ImportedBarCount: 120,
MissingCount: 0,
})
installed := marketHandlers(Deps{RefreshStatus: store})
// Verify the SchedulerRefreshStatusRequest handler is present.
found := false
for _, h := range installed {
if h.requestType == "alt.v1.SchedulerRefreshStatusRequest" {
found = true
if h.register == nil {
t.Error("SchedulerRefreshStatusRequest handler has nil register")
}
break
}
}
if !found {
t.Error("marketHandlers should include SchedulerRefreshStatusRequest handler when RefreshStatus is set")
}
// Verify All() returns the entry we recorded.
all := store.All()
if len(all) != 1 {
t.Fatalf("expected 1 entry from store.All(), got %d", len(all))
}
if all[0].Schedule != "kr-daily" || all[0].Status != "success" {
t.Errorf("unexpected entry: %+v", all[0])
}
// Verify Get() by name works.
entry, ok := store.Get("kr-daily")
if !ok {
t.Error("store.Get('kr-daily') should return ok=true")
}
if entry.Schedule != "kr-daily" {
t.Errorf("expected schedule 'kr-daily', got %q", entry.Schedule)
}
}
func TestSchedulerHandlersOmittedWhenRefreshStatusIsNil(t *testing.T) {
installed := marketHandlers(Deps{})
for _, h := range installed {
if h.requestType == "alt.v1.SchedulerRefreshStatusRequest" {
t.Error("marketHandlers should NOT include SchedulerRefreshStatusRequest handler when RefreshStatus is nil")
}
}
}
func TestSchedulerRefreshStatusResponseFields(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
store.SetScheduleConfig(scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-daily",
Provider: "kis",
BackfillWindow: "3d",
Cadence: "daily",
Timeframe: "daily",
Timezone: "Asia/Seoul",
ParallelismLimit: 2,
Selector: scheduler.SelectorConfig{
Kind: "watchlist",
Market: "KR",
Venue: "KRX",
Name: "kr-core",
Symbols: []string{"005930"},
},
},
},
})
store.Record("kr-daily", scheduler.RefreshStatus{
Schedule: "kr-daily",
Status: "success",
LastSuccess: time.UnixMilli(1700000000000),
ImportedBarCount: 120,
MissingCount: 0,
GapCount: 0,
DuplicateCount: 0,
ProviderDelay: 1,
})
deps := Deps{RefreshStatus: store}
resp, err := handleSchedulerRefreshStatus(deps, &altv1.SchedulerRefreshStatusRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.GetEntries()) != 1 {
t.Fatalf("expected 1 entry, got %d", len(resp.GetEntries()))
}
entry := resp.GetEntries()[0]
if entry.GetSchedule() != "kr-daily" {
t.Errorf("schedule = %q, want %q", entry.GetSchedule(), "kr-daily")
}
if entry.GetStatus() != "success" {
t.Errorf("status = %q, want %q", entry.GetStatus(), "success")
}
if entry.GetImportedBarCount() != 120 {
t.Errorf("imported_bar_count = %d, want 120", entry.GetImportedBarCount())
}
if entry.GetMissingCount() != 0 {
t.Errorf("missing_count = %d, want 0", entry.GetMissingCount())
}
if entry.GetGapCount() != 0 {
t.Errorf("gap_count = %d, want 0", entry.GetGapCount())
}
if entry.GetDuplicateCount() != 0 {
t.Errorf("duplicate_count = %d, want 0", entry.GetDuplicateCount())
}
if entry.GetProviderDelayDays() != 1 {
t.Errorf("provider_delay_days = %d, want 1", entry.GetProviderDelayDays())
}
}
func TestSchedulerRefreshStatusZeroTimestamps(t *testing.T) {
// Test 1: Zero LastSuccess and zero NextRun with NO config.
// When config is absent, enrichWithNextRun does not compute NextRun,
// so both timestamps remain zero and unixMilliMs converts them to 0.
store := scheduler.NewRefreshStatusStore()
// Do NOT set schedule config — NextRun stays zero.
store.Record("kr-daily", scheduler.RefreshStatus{
Schedule: "kr-daily",
Status: "stale",
LastSuccess: time.Time{}, // zero time
NextRun: time.Time{}, // zero time (no config to enrich)
ImportedBarCount: 0,
MissingCount: 0,
GapCount: 0,
DuplicateCount: 0,
ProviderDelay: 0,
})
resp, err := handleSchedulerRefreshStatus(Deps{RefreshStatus: store}, &altv1.SchedulerRefreshStatusRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.GetEntries()) != 1 {
t.Fatalf("expected 1 entry, got %d", len(resp.GetEntries()))
}
entry := resp.GetEntries()[0]
if entry.GetLastSuccessUnixMs() != 0 {
t.Errorf("LastSuccessUnixMs = %d, want 0 for zero time", entry.GetLastSuccessUnixMs())
}
if entry.GetNextRunUnixMs() != 0 {
t.Errorf("NextRunUnixMs = %d, want 0 for zero time (no config)", entry.GetNextRunUnixMs())
}
// Test 2: Non-zero LastSuccess with config.
// When config is set, NextRun gets enriched by enrichWithNextRun,
// so only LastSuccess is tested for non-zero preservation.
store2 := scheduler.NewRefreshStatusStore()
store2.SetScheduleConfig(scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-daily",
Provider: "kis",
BackfillWindow: "3d",
Cadence: "daily",
Timeframe: "daily",
Timezone: "Asia/Seoul",
ParallelismLimit: 2,
Selector: scheduler.SelectorConfig{
Kind: "watchlist",
Market: "KR",
Venue: "KRX",
Name: "kr-core",
Symbols: []string{"005930"},
},
},
},
})
nonZeroSuccess := time.UnixMilli(1700000000000)
store2.Record("kr-daily", scheduler.RefreshStatus{
Schedule: "kr-daily",
Status: "success",
LastSuccess: nonZeroSuccess,
NextRun: time.Time{}, // will be enriched by enrichWithNextRun
ImportedBarCount: 120,
MissingCount: 0,
GapCount: 0,
DuplicateCount: 0,
ProviderDelay: 1,
})
resp2, err := handleSchedulerRefreshStatus(Deps{RefreshStatus: store2}, &altv1.SchedulerRefreshStatusRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp2.GetEntries()) != 1 {
t.Fatalf("expected 1 entry, got %d", len(resp2.GetEntries()))
}
entry2 := resp2.GetEntries()[0]
if entry2.GetLastSuccessUnixMs() != 1700000000000 {
t.Errorf("LastSuccessUnixMs = %d, want 1700000000000", entry2.GetLastSuccessUnixMs())
}
// NextRun is enriched by enrichWithNextRun — must NOT be zero.
if entry2.GetNextRunUnixMs() == 0 {
t.Errorf("NextRunUnixMs = 0, expected enriched non-zero value")
}
if entry2.GetImportedBarCount() != 120 {
t.Errorf("imported_bar_count = %d, want 120", entry2.GetImportedBarCount())
}
if entry2.GetProviderDelayDays() != 1 {
t.Errorf("provider_delay_days = %d, want 1", entry2.GetProviderDelayDays())
}
// Test 3: Error status with zero timestamps and NO config.
store3 := scheduler.NewRefreshStatusStore()
// Do NOT set schedule config.
store3.Record("kr-daily", scheduler.RefreshStatus{
Schedule: "kr-daily",
Status: "error",
LastSuccess: time.Time{}, // zero - never succeeded
LastError: "API key expired",
NextRun: time.Time{}, // zero
ImportedBarCount: 0,
MissingCount: 0,
GapCount: 0,
DuplicateCount: 0,
ProviderDelay: 0,
})
resp3, err := handleSchedulerRefreshStatus(Deps{RefreshStatus: store3}, &altv1.SchedulerRefreshStatusRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp3.GetEntries()) != 1 {
t.Fatalf("expected 1 entry, got %d", len(resp3.GetEntries()))
}
entry3 := resp3.GetEntries()[0]
if entry3.GetStatus() != "error" {
t.Errorf("status = %q, want %q", entry3.GetStatus(), "error")
}
if entry3.GetLastSuccessUnixMs() != 0 {
t.Errorf("LastSuccessUnixMs = %d, want 0 for zero time on error status", entry3.GetLastSuccessUnixMs())
}
if entry3.GetNextRunUnixMs() != 0 {
t.Errorf("NextRunUnixMs = %d, want 0 for zero time on error status (no config)", entry3.GetNextRunUnixMs())
}
if entry3.GetLastError() != "API key expired" {
t.Errorf("last_error = %q, want %q", entry3.GetLastError(), "API key expired")
}
}
func TestSchedulerCapabilitiesAddedWhenRefreshStatusIsWired(t *testing.T) {
capsNo := capabilitiesForDeps(Deps{})
capsYes := capabilitiesForDeps(Deps{
RefreshStatus: scheduler.NewRefreshStatusStore(),
})
foundNo := false
for _, c := range capsNo {
if c == "scheduler" {
foundNo = true
}
}
if foundNo {
t.Error("capabilities should NOT include 'scheduler' when RefreshStatus is nil")
}
foundYes := false
for _, c := range capsYes {
if c == "scheduler" {
foundYes = true
break
}
}
if !foundYes {
t.Error("capabilities should include 'scheduler' when RefreshStatus is wired")
}
}