464 lines
14 KiB
Go
464 lines
14 KiB
Go
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)
|
|
}
|
|
}
|