- 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
310 lines
9.5 KiB
Go
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")
|
|
}
|
|
}
|