package scheduler import ( "bytes" "encoding/json" "strings" "testing" "time" "git.toki-labs.com/toki/alt/packages/domain/market" ) func TestDecideBackfillFreshState(t *testing.T) { obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Timeframe: "daily", Timezone: "Asia/Seoul", Cadence: "24h0m0s", BackfillWindow: "7d", Status: "success", } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "fresh" { t.Errorf("expected status=fresh, got %s", dec.Status) } if dec.HasBackfill { t.Error("expected has_backfill=false for fresh state") } if dec.Retry { t.Error("expected retry=false for fresh state") } if dec.Reason != "all expected dates are present" { t.Errorf("unexpected reason: %s", dec.Reason) } } func TestDecideBackfillMissingDates(t *testing.T) { missingDates := []time.Time{ time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC), } obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Timeframe: "daily", Timezone: "Asia/Seoul", Cadence: "24h0m0s", BackfillWindow: "7d", Status: "success", MissingDates: missingDates, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "stale" { t.Errorf("expected status=stale for missing dates, got %s", dec.Status) } if !dec.HasBackfill { t.Error("expected has_backfill=true for missing dates") } if !dec.Retry { t.Error("expected retry=true for missing dates") } if dec.MissingCount != 3 { t.Errorf("expected missing_count=3, got %d", dec.MissingCount) } if dec.BackfillFrom.IsZero() { t.Error("expected backfill_from to be set") } // The earliest missing date is 2026-06-15 if !dec.BackfillFrom.Equal(time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)) { t.Errorf("unexpected backfill_from: %s", dec.BackfillFrom.Format("2006-01-02")) } if !strings.Contains(dec.Reason, "3 missing date(s)") { t.Errorf("unexpected reason: %s", dec.Reason) } } func TestDecideBackfillGapBuckets(t *testing.T) { gapBuckets := []GapBucket{ {From: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)}, {From: time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)}, } obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Timeframe: "daily", Timezone: "Asia/Seoul", Cadence: "24h0m0s", BackfillWindow: "7d", Status: "success", GapBuckets: gapBuckets, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), ExpectedFrom: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "stale" { t.Errorf("expected status=stale for gap buckets, got %s", dec.Status) } if !dec.HasBackfill { t.Error("expected has_backfill=true for gap buckets") } if dec.GapCount != 2 { t.Errorf("expected gap_count=2, got %d", dec.GapCount) } if !strings.Contains(dec.Reason, "2 gap bucket(s)") { t.Errorf("unexpected reason: %s", dec.Reason) } } func TestDecideBackfillProviderDelayOnly(t *testing.T) { obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Timeframe: "daily", Timezone: "Asia/Seoul", Cadence: "24h0m0s", BackfillWindow: "7d", Status: "success", ProviderDelay: 2, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "stale" { t.Errorf("expected status=stale for provider delay, got %s", dec.Status) } if dec.HasBackfill { t.Error("expected has_backfill=false for provider delay only") } if dec.Retry { t.Error("expected retry=false for provider delay only") } if dec.MissingCount != 0 { t.Errorf("expected missing_count=0, got %d", dec.MissingCount) } if dec.ProviderDelay != 2 { t.Errorf("expected provider_delay=2, got %d", dec.ProviderDelay) } } func TestDecideBackfillPreservesErrorState(t *testing.T) { obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Timeframe: "daily", Timezone: "Asia/Seoul", Cadence: "24h0m0s", BackfillWindow: "7d", Status: "error", LastError: "connection timeout to KIS API", ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "error" { t.Errorf("expected status=error, got %s", dec.Status) } if dec.HasBackfill { t.Error("expected has_backfill=false for error state") } if !dec.Retry { t.Error("expected retry=true for error state") } if !strings.Contains(dec.Reason, "connection timeout to KIS API") { t.Errorf("unexpected reason: %s", dec.Reason) } if dec.LastError != "connection timeout to KIS API" { t.Errorf("unexpected last_error: %s", dec.LastError) } } func TestDecideBackfillDistinguishesMissingGapProviderDelay(t *testing.T) { window := 7 * 24 * time.Hour // Case 1: Missing dates only obs1 := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Status: "success", MissingDates: []time.Time{time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)}, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } dec1 := DecideBackfill(obs1, window) if dec1.Status != "stale" { t.Errorf("case 1 (missing): expected stale, got %s", dec1.Status) } if !dec1.HasBackfill { t.Error("case 1 (missing): expected has_backfill=true") } // Case 2: Gap buckets only obs2 := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Status: "success", GapBuckets: []GapBucket{{From: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)}}, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), ExpectedFrom: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), } dec2 := DecideBackfill(obs2, window) if dec2.Status != "stale" { t.Errorf("case 2 (gap): expected stale, got %s", dec2.Status) } if !dec2.HasBackfill { t.Error("case 2 (gap): expected has_backfill=true") } // Case 3: Provider delay only obs3 := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Status: "success", ProviderDelay: 3, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } dec3 := DecideBackfill(obs3, window) if dec3.Status != "stale" { t.Errorf("case 3 (delay): expected stale, got %s", dec3.Status) } if dec3.HasBackfill { t.Error("case 3 (delay): expected has_backfill=false") } if dec3.Retry { t.Error("case 3 (delay): expected retry=false") } // Case 4: Error obs4 := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Status: "error", LastError: "api timeout", ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } dec4 := DecideBackfill(obs4, window) if dec4.Status != "error" { t.Errorf("case 4 (error): expected error, got %s", dec4.Status) } } func TestDecideBackfillDuplicateOnly(t *testing.T) { obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Status: "success", DuplicateCount: 5, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) // Duplicate-only should still be fresh if no missing/gap/error. if dec.Status != "fresh" { t.Errorf("expected status=fresh for duplicate-only, got %s", dec.Status) } if dec.HasBackfill { t.Error("expected has_backfill=false for duplicate-only") } } func TestDecideBackfillBackfillWindowCutoff(t *testing.T) { // Missing dates older than the backfill window should be clamped. missingDates := []time.Time{ time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), // older than 7-day window time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC), } obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Selector: "kospi", Status: "success", MissingDates: missingDates, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "stale" { t.Errorf("expected status=stale, got %s", dec.Status) } if !dec.HasBackfill { t.Error("expected has_backfill=true") } // Should be clamped to window start (2026-06-13) windowStart := time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC) if !dec.BackfillFrom.Equal(windowStart) { t.Errorf("expected backfill_from=%s, got %s", windowStart.Format("2006-01-02"), dec.BackfillFrom.Format("2006-01-02")) } } func TestDecideBackfillNilSlices(t *testing.T) { obs := FreshnessObservation{ Schedule: "daily-daily", Provider: market.ProviderKIS, Status: "success", MissingDates: nil, GapBuckets: nil, ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), } window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) if dec.Status != "fresh" { t.Errorf("expected status=fresh for nil slices, got %s", dec.Status) } } func TestWriteBackfillTextAndJSONL(t *testing.T) { decisions := []BackfillDecision{ { Schedule: "daily-daily", Status: "fresh", Reason: "all expected dates are present", HasBackfill: false, Retry: false, }, { Schedule: "daily-daily", Status: "stale", Reason: "backfill needed: 3 missing date(s) from 2026-06-15 to 2026-06-20", HasBackfill: true, Retry: true, BackfillFrom: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), BackfillTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC), MissingCount: 3, StaleDates: []time.Time{ time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC), }, }, { Schedule: "daily-daily", Status: "error", Reason: "previous operation failed: connection timeout", LastError: "connection timeout", HasBackfill: false, Retry: true, }, } // Test text output var textBuf bytes.Buffer if err := WriteBackfillText(&textBuf, decisions); err != nil { t.Fatalf("WriteBackfillText: %v", err) } text := textBuf.String() textLines := strings.Split(strings.TrimSpace(text), "\n") if len(textLines) != 3 { t.Errorf("expected 3 text lines, got %d", len(textLines)) } // Check line 1 (fresh) if !strings.Contains(textLines[0], "status=fresh") { t.Errorf("line 1 should contain status=fresh: %s", textLines[0]) } if !strings.Contains(textLines[0], "backfill_from=NONE") { t.Errorf("line 1 should contain backfill_from=NONE: %s", textLines[0]) } // Check line 2 (stale) if !strings.Contains(textLines[1], "status=stale") { t.Errorf("line 2 should contain status=stale: %s", textLines[1]) } if !strings.Contains(textLines[1], "backfill_from=20260615") { t.Errorf("line 2 should contain backfill_from=20260615: %s", textLines[1]) } if !strings.Contains(textLines[1], "backfill_to=20260620") { t.Errorf("line 2 should contain backfill_to=20260620: %s", textLines[1]) } // Check line 3 (error) if !strings.Contains(textLines[2], "status=error") { t.Errorf("line 3 should contain status=error: %s", textLines[2]) } // Test JSONL output (single-line JSON per decision, no SetIndent) var jsonBuf bytes.Buffer if err := WriteBackfillJSONL(&jsonBuf, decisions); err != nil { t.Fatalf("WriteBackfillJSONL: %v", err) } jsonLines := strings.Split(strings.TrimSpace(jsonBuf.String()), "\n") if len(jsonLines) != 3 { t.Fatalf("expected 3 JSON lines, got %d", len(jsonLines)) } // Parse and verify each JSON line for i, jsonLine := range jsonLines { var parsed map[string]interface{} if err := json.Unmarshal([]byte(jsonLine), &parsed); err != nil { t.Fatalf("line %d: failed to parse JSON: %v", i, err) } if parsed["schedule"] != "daily-daily" { t.Errorf("line %d: expected schedule=daily-daily, got %v", i, parsed["schedule"]) } expectedStatus := []string{"fresh", "stale", "error"}[i] if parsed["status"] != expectedStatus { t.Errorf("line %d: expected status=%s, got %v", i, expectedStatus, parsed["status"]) } // Fresh case: backfill_from should not be present if i == 0 { if val, ok := parsed["backfill_from"]; ok && val != nil { t.Errorf("line %d: expected backfill_from absent or nil for fresh, got %v", i, val) } } // Stale case: should have stale_dates if i == 1 { staleDates, ok := parsed["stale_dates"].([]interface{}) if !ok { t.Errorf("line %d: expected stale_dates to be an array, got %T", i, parsed["stale_dates"]) } else if len(staleDates) != 3 { t.Errorf("line %d: expected 3 stale_dates, got %d", i, len(staleDates)) } } // Error case: should have last_error if i == 2 { if parsed["last_error"] != "connection timeout" { t.Errorf("line %d: expected last_error=connection timeout, got %v", i, parsed["last_error"]) } } } } func TestDecideBackfillNilObservation(t *testing.T) { // Empty observation with no status should not trigger error or backfill. obs := FreshnessObservation{} window := 7 * 24 * time.Hour dec := DecideBackfill(obs, window) // Should be fresh (empty = no missing, no gaps, no error). if dec.Status != "fresh" { t.Errorf("expected status=fresh for empty observation, got %s", dec.Status) } } func TestMakeRefreshStatus(t *testing.T) { now := time.Now().UTC() nextRun := now.Add(24 * time.Hour) // Case 1: Success status (obs is success, dec is fresh) obsSuccess := FreshnessObservation{ Schedule: "daily-success", Status: "success", DuplicateCount: 2, ProviderDelay: 0, } decFresh := BackfillDecision{ Status: "fresh", MissingCount: 0, GapCount: 0, } status1 := MakeRefreshStatus(obsSuccess, decFresh, now, nextRun, 150) if status1.Status != "success" { t.Errorf("expected status=success, got %s", status1.Status) } if status1.ImportedBarCount != 150 { t.Errorf("expected imported_bar_count=150, got %d", status1.ImportedBarCount) } if !status1.LastSuccess.Equal(now) { t.Errorf("expected last_success=%v, got %v", now, status1.LastSuccess) } if !status1.NextRun.Equal(nextRun) { t.Errorf("expected next_run=%v, got %v", nextRun, status1.NextRun) } // Case 2: Stale status (obs is success, dec is stale) obsStale := FreshnessObservation{ Schedule: "daily-stale", Status: "success", DuplicateCount: 0, ProviderDelay: 1, } decStale := BackfillDecision{ Status: "stale", MissingCount: 3, GapCount: 1, } status2 := MakeRefreshStatus(obsStale, decStale, now, nextRun, 0) if status2.Status != "stale" { t.Errorf("expected status=stale, got %s", status2.Status) } if status2.ImportedBarCount != 0 { t.Errorf("expected imported_bar_count=0, got %d", status2.ImportedBarCount) } // Case 3: Error status (obs is error or dec is error) obsErr := FreshnessObservation{ Schedule: "daily-err", Status: "error", LastError: "connection timeout", } decErr := BackfillDecision{ Status: "error", LastError: "previous operation failed: connection timeout", } status3 := MakeRefreshStatus(obsErr, decErr, time.Time{}, nextRun, 0) if status3.Status != "error" { t.Errorf("expected status=error, got %s", status3.Status) } if status3.LastError != "connection timeout" { t.Errorf("expected last_error='connection timeout', got %q", status3.LastError) } } func TestWriteRefreshStatusTextAndJSONL(t *testing.T) { lastSuccess := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC) nextRun := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) statuses := []RefreshStatus{ { Schedule: "daily-success", Status: "success", LastSuccess: lastSuccess, LastError: "", NextRun: nextRun, ImportedBarCount: 100, MissingCount: 0, GapCount: 0, DuplicateCount: 1, ProviderDelay: 0, }, { Schedule: "daily-error", Status: "error", LastSuccess: time.Time{}, // Zero time LastError: "API key expired", NextRun: time.Time{}, ImportedBarCount: 0, MissingCount: 0, GapCount: 0, DuplicateCount: 0, ProviderDelay: 0, }, } // Test Text output var textBuf bytes.Buffer if err := WriteRefreshStatusText(&textBuf, statuses); err != nil { t.Fatalf("WriteRefreshStatusText: %v", err) } text := textBuf.String() textLines := strings.Split(strings.TrimSpace(text), "\n") if len(textLines) != 2 { t.Errorf("expected 2 text lines, got %d", len(textLines)) } // Check success line if !strings.Contains(textLines[0], "status=success") { t.Errorf("line 1 should contain status=success: %s", textLines[0]) } if !strings.Contains(textLines[0], "last_success=2026-06-20T12:00:00Z") { t.Errorf("line 1 should contain last_success timestamp: %s", textLines[0]) } if !strings.Contains(textLines[0], "imported_bar_count=100") { t.Errorf("line 1 should contain imported_bar_count=100: %s", textLines[0]) } // Check error line if !strings.Contains(textLines[1], "status=error") { t.Errorf("line 2 should contain status=error: %s", textLines[1]) } if !strings.Contains(textLines[1], "last_success=NONE") { t.Errorf("line 2 should contain last_success=NONE: %s", textLines[1]) } if !strings.Contains(textLines[1], `last_error="API key expired"`) { t.Errorf("line 2 should contain last_error=\"API key expired\": %s", textLines[1]) } // Test JSONL output var jsonBuf bytes.Buffer if err := WriteRefreshStatusJSONL(&jsonBuf, statuses); err != nil { t.Fatalf("WriteRefreshStatusJSONL: %v", err) } jsonLines := strings.Split(strings.TrimSpace(jsonBuf.String()), "\n") if len(jsonLines) != 2 { t.Fatalf("expected 2 JSON lines, got %d", len(jsonLines)) } // Parse line 1 var parsed1 map[string]interface{} if err := json.Unmarshal([]byte(jsonLines[0]), &parsed1); err != nil { t.Fatalf("failed to parse JSON line 1: %v", err) } if parsed1["schedule"] != "daily-success" { t.Errorf("expected schedule=daily-success, got %v", parsed1["schedule"]) } if parsed1["status"] != "success" { t.Errorf("expected status=success, got %v", parsed1["status"]) } if parsed1["last_success"] != "2026-06-20T12:00:00Z" { t.Errorf("expected last_success=2026-06-20T12:00:00Z, got %v", parsed1["last_success"]) } if val, ok := parsed1["last_error"]; !ok || val != "" { t.Errorf("expected empty last_error to be present, got %v", val) } if val, ok := parsed1["imported_bar_count"]; !ok || val != 100.0 { // float64 in JSON unmarshal t.Errorf("expected imported_bar_count=100, got %v", val) } // Parse line 2 var parsed2 map[string]interface{} if err := json.Unmarshal([]byte(jsonLines[1]), &parsed2); err != nil { t.Fatalf("failed to parse JSON line 2: %v", err) } if parsed2["status"] != "error" { t.Errorf("expected status=error, got %v", parsed2["status"]) } if parsed2["last_success"] != nil { t.Errorf("expected last_success=nil, got %v", parsed2["last_success"]) } if parsed2["last_error"] != "API key expired" { t.Errorf("expected last_error=API key expired, got %v", parsed2["last_error"]) } if val, ok := parsed2["imported_bar_count"]; !ok || val != 0.0 { t.Errorf("expected zero imported_bar_count to be present, got %v", val) } }