- 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
649 lines
20 KiB
Go
649 lines
20 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|