package server import ( "math" "regexp" "strings" "testing" "time" "scm.bstein.dev/bstein/soteria/internal/k8s" "scm.bstein.dev/bstein/soteria/internal/longhorn" ) func TestBuildBackupRecordsSortsAndMarksLatestCompleted(t *testing.T) { records := buildBackupRecords([]longhorn.Backup{ { Name: "backup-b", SnapshotName: "snap-b", Created: "not-a-time", State: "Completed", URL: "s3://bucket/backup-b", Size: "2Gi", }, { Name: "backup-a", SnapshotName: "snap-a", Created: "2026-04-20T10:00:00Z", State: "Completed", URL: "s3://bucket/backup-a", Size: "1Gi", }, { Name: "backup-c", SnapshotName: "snap-c", Created: "2026-04-20T11:00:00Z", State: "Failed", URL: "s3://bucket/backup-c", Size: "3Gi", }, }) if len(records) != 3 { t.Fatalf("expected three backup records, got %#v", records) } if records[0].Name != "backup-c" || records[1].Name != "backup-a" || records[2].Name != "backup-b" { t.Fatalf("expected records sorted newest-first with invalid timestamps last, got %#v", records) } if records[1].Latest != true { t.Fatalf("expected latest completed backup to be marked latest, got %#v", records[1]) } if records[0].Latest || records[2].Latest { t.Fatalf("expected only the latest completed backup to be marked latest, got %#v", records) } } func TestBuildBackupRecordsWithoutCompletedBackupsLeavesLatestUnset(t *testing.T) { records := buildBackupRecords([]longhorn.Backup{ {Name: "backup-b", Created: "2026-04-20T10:00:00Z", State: "Failed"}, {Name: "backup-a", Created: "2026-04-20T09:00:00Z", State: "Pending"}, }) if len(records) != 2 { t.Fatalf("expected two backup records, got %#v", records) } if records[0].Latest || records[1].Latest { t.Fatalf("expected no latest marker when no completed backups exist, got %#v", records) } } func TestBuildBackupRecordsFallsBackToNameOrderForInvalidTimes(t *testing.T) { records := buildBackupRecords([]longhorn.Backup{ {Name: "backup-a", Created: "invalid-a", State: "Completed"}, {Name: "backup-z", Created: "invalid-z", State: "Completed"}, }) if len(records) != 2 { t.Fatalf("expected two backup records, got %#v", records) } if records[0].Name != "backup-z" || records[1].Name != "backup-a" { t.Fatalf("expected invalid timestamps to sort by name fallback, got %#v", records) } if records[0].Latest || !records[1].Latest { t.Fatalf("expected first invalid completed backup encountered pre-sort to retain latest marker, got %#v", records) } } func TestDecodeResticSelectorCoversValidAndInvalidValues(t *testing.T) { if repository, snapshot, ok := decodeResticSelector(""); ok || repository != "" || snapshot != "" { t.Fatalf("expected empty selector to be invalid, got repo=%q snapshot=%q ok=%v", repository, snapshot, ok) } if repository, snapshot, ok := decodeResticSelector("latest"); !ok || repository != "" || snapshot != "latest" { t.Fatalf("expected latest selector to decode, got repo=%q snapshot=%q ok=%v", repository, snapshot, ok) } encoded := encodeResticSelector(" s3://bucket/repository ") if repository, snapshot, ok := decodeResticSelector(encoded); !ok || repository != "s3://bucket/repository" || snapshot != "latest" { t.Fatalf("expected encoded selector to round-trip, got repo=%q snapshot=%q ok=%v", repository, snapshot, ok) } invalidInputs := []string{ "plain-text", resticSelectorPrefix, resticSelectorPrefix + "###", resticSelectorPrefix + "ICAg", } for _, input := range invalidInputs { if repository, snapshot, ok := decodeResticSelector(input); ok || repository != "" || snapshot != "" { t.Fatalf("expected invalid selector %q to fail decode, got repo=%q snapshot=%q ok=%v", input, repository, snapshot, ok) } } } func TestBackupJobTimestampPrefersCompletionTime(t *testing.T) { createdAt := time.Date(2026, 4, 20, 9, 0, 0, 0, time.UTC) completedAt := createdAt.Add(15 * time.Minute) if got := backupJobTimestamp(k8sBackupJobSummary(createdAt, completedAt)); !got.Equal(completedAt) { t.Fatalf("expected completion timestamp to win, got %s", got) } if got := backupJobTimestamp(k8sBackupJobSummary(createdAt, time.Time{})); !got.Equal(createdAt) { t.Fatalf("expected created timestamp fallback, got %s", got) } } func TestBackupNameTruncatesToKubernetesSafeLength(t *testing.T) { name := backupName("backup", strings.Repeat("very-long-volume-name-", 6)) if len(name) > 63 { t.Fatalf("expected backup name <= 63 chars, got %d: %q", len(name), name) } if !strings.HasPrefix(name, "soteria-backup-") { t.Fatalf("expected sanitized backup name prefix, got %q", name) } if matched, err := regexp.MatchString(`^[a-z0-9-]+-\d{8}-\d{6}$`, name); err != nil || !matched { t.Fatalf("expected backup name to end with timestamp, got %q (matched=%v err=%v)", name, matched, err) } } func TestBackupNameKeepsShortNamesReadable(t *testing.T) { name := backupName("restore", "My.Volume_Name") if len(name) > 63 { t.Fatalf("expected short backup name <= 63 chars, got %d: %q", len(name), name) } if !strings.HasPrefix(name, "soteria-restore-my-volume-name-") { t.Fatalf("expected readable sanitized prefix, got %q", name) } } func TestBackupJobProgressPctCoversKnownStates(t *testing.T) { testCases := []struct { state string expected int }{ {state: "pending", expected: 20}, {state: "running", expected: 70}, {state: "completed", expected: 100}, {state: "failed", expected: 100}, {state: "unknown", expected: 0}, } for _, tc := range testCases { if got := backupJobProgressPct(tc.state); got != tc.expected { t.Fatalf("state=%q: expected %d, got %d", tc.state, tc.expected, got) } } } func TestParseSizeBytesHandlesNumericQuantityAndInvalidForms(t *testing.T) { testCases := []struct { name string raw string value int64 }{ {name: "blank", raw: "", value: 0}, {name: "int", raw: "123", value: 123}, {name: "float", raw: "42.9", value: 42}, {name: "negative-float", raw: "-1.5", value: 0}, {name: "quantity", raw: "2Gi", value: 2147483648}, {name: "invalid", raw: "definitely-not-a-size", value: 0}, } for _, tc := range testCases { if got := parseSizeBytes(tc.raw); got != tc.value { t.Fatalf("%s: expected %d, got %d", tc.name, tc.value, got) } } } func TestFormatBytesIECCoversEdgeCasesAndUnits(t *testing.T) { testCases := []struct { name string value float64 want string }{ {name: "zero", value: 0, want: "0 B"}, {name: "nan", value: nanValue(), want: "0 B"}, {name: "inf", value: infValue(), want: "0 B"}, {name: "bytes", value: 12, want: "12 B"}, {name: "kib", value: 1536, want: "1.50 KiB"}, } for _, tc := range testCases { if got := formatBytesIEC(tc.value); got != tc.want { t.Fatalf("%s: expected %q, got %q", tc.name, tc.want, got) } } } func TestKeepLastDefaultAndValidateKeepLast(t *testing.T) { if got := keepLastDefault(nil); got != 0 { t.Fatalf("expected nil keep_last default to 0, got %d", got) } if got := keepLastDefault(intPtr(-3)); got != 0 { t.Fatalf("expected negative keep_last default to clamp to 0, got %d", got) } if got := keepLastDefault(intPtr(7)); got != 7 { t.Fatalf("expected positive keep_last to pass through, got %d", got) } if err := validateKeepLast(nil); err != nil { t.Fatalf("expected nil keep_last to validate, got %v", err) } if err := validateKeepLast(intPtr(-1)); err == nil || !strings.Contains(err.Error(), ">= 0") { t.Fatalf("expected negative keep_last validation error, got %v", err) } if err := validateKeepLast(intPtr(maxPolicyKeepLast + 1)); err == nil || !strings.Contains(err.Error(), "<=") { t.Fatalf("expected too-large keep_last validation error, got %v", err) } if err := validateKeepLast(intPtr(maxPolicyKeepLast)); err != nil { t.Fatalf("expected max valid keep_last to pass, got %v", err) } } func TestKeepLastStricterTruthTable(t *testing.T) { testCases := []struct { candidate int current int expected bool }{ {candidate: 5, current: 0, expected: true}, {candidate: -1, current: 0, expected: true}, {candidate: 0, current: 5, expected: false}, {candidate: 0, current: 0, expected: false}, {candidate: 5, current: 10, expected: true}, {candidate: 10, current: 5, expected: false}, } for _, tc := range testCases { if got := keepLastStricter(tc.candidate, tc.current); got != tc.expected { t.Fatalf("candidate=%d current=%d: expected %v, got %v", tc.candidate, tc.current, tc.expected, got) } } } func nanValue() float64 { return math.NaN() } func infValue() float64 { return math.Inf(1) } func k8sBackupJobSummary(createdAt, completedAt time.Time) k8s.BackupJobSummary { return k8s.BackupJobSummary{ CreatedAt: createdAt, CompletionTime: completedAt, } }