package service import ( "context" "encoding/json" "os" "path/filepath" "testing" "time" "git.casaderoll.de/michael/urbm/internal/model" "git.casaderoll.de/michael/urbm/internal/queue" "git.casaderoll.de/michael/urbm/internal/store" ) func TestEmptyBackupMetricsEncodeAsJSONArray(t *testing.T) { s := &Service{} encoded, err := json.Marshal(s.BackupMetrics()) if err != nil { t.Fatal(err) } if string(encoded) != "[]" { t.Fatalf("empty backup metrics JSON = %s", encoded) } } func TestRepositoryLockHonorsContextCancellation(t *testing.T) { s := &Service{repositoryLocks: map[string]chan struct{}{}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo"} unlock, err := s.lockRepository(context.Background(), repo) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() if _, err := s.lockRepository(ctx, repo); err == nil { t.Fatal("concurrent repository access ignored the request deadline") } unlock() } func TestBackupMetricsSurviveClearedRunSnapshot(t *testing.T) { dir := t.TempDir() st := store.New(dir) if err := st.Init(); err != nil { t.Fatal(err) } s := &Service{store: st, persistRuns: make(chan []model.Run, 1)} finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC) run := model.Run{ID: "run-1", JobID: "job-1", TaskType: "backup", Status: "success", FinishedAt: &finished, BytesProcessed: 1024, FilesProcessed: 12} s.queuePersistence([]model.Run{run}) before, err := os.ReadFile(filepath.Join(dir, "backup-metrics.jsonl")) if err != nil { t.Fatal(err) } s.queuePersistence(nil) after, err := os.ReadFile(filepath.Join(dir, "backup-metrics.jsonl")) if err != nil { t.Fatal(err) } if string(after) != string(before) { t.Fatal("clearing activities rewrote the backup metric file") } metrics := s.BackupMetrics() if len(metrics) != 1 || metrics[0].RunID != run.ID || metrics[0].FilesProcessed != 12 { t.Fatalf("backup metrics after clear = %#v", metrics) } } func TestLastRunUsesDurableMetricAfterActivityClear(t *testing.T) { created := time.Date(2026, 7, 13, 1, 0, 0, 0, time.UTC) finished := created.Add(3 * time.Hour) q := queue.New(nil, nil) q.RestoreHistory([]model.Run{{ID: "run-1", JobID: "job-1", Status: "success", CreatedAt: created}}) s := &Service{queue: q, backupMetrics: []model.BackupMetric{{RunID: "run-1", JobID: "job-1", Status: "success", FinishedAt: finished}}} q.ClearHistory() if got := s.LastRun("job-1"); !got.Equal(finished) { t.Fatalf("last run after activity clear = %v, want %v", got, finished) } } func TestBackupMetricsOnlyCaptureCompletedSuccessfulBackupsOnce(t *testing.T) { st := store.New(t.TempDir()) s := &Service{store: st} finished := time.Now().UTC() valid := model.Run{ID: "valid", JobID: "job-1", TaskType: "backup", Status: "warning", FinishedAt: &finished} s.captureBackupMetrics([]model.Run{ valid, valid, {ID: "failed", TaskType: "backup", Status: "failed", FinishedAt: &finished}, {ID: "active", TaskType: "backup", Status: "running"}, {ID: "rsync", TaskType: "rsync", Status: "success", FinishedAt: &finished}, }) metrics := s.BackupMetrics() if len(metrics) != 1 || metrics[0].RunID != valid.ID { t.Fatalf("captured backup metrics = %#v", metrics) } } func TestPersistentRunLogsAreWrittenAndPruned(t *testing.T) { dir := t.TempDir() config := model.DefaultConfig() config.Settings.PersistentLogDir = dir s := &Service{config: config} finished := time.Now() run := model.Run{ID: "run-1", FinishedAt: &finished, LiveLog: []string{"first", "second"}} if err := s.persistRunLogs([]model.Run{run}); err != nil { t.Fatal(err) } path := filepath.Join(dir, "urbm-run-1.log") if contents, err := os.ReadFile(path); err != nil || string(contents) != "first\nsecond\n" { t.Fatalf("persistent log = %q, %v", contents, err) } if err := s.persistRunLogs(nil); err != nil { t.Fatal(err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("cleared run log still exists: %v", err) } } func TestQueuePersistenceDoesNotBlockWhenWriterIsBusy(t *testing.T) { s := &Service{persistRuns: make(chan []model.Run, 1)} s.persistRuns <- []model.Run{{ID: "old"}} done := make(chan struct{}) go func() { s.queuePersistence([]model.Run{{ID: "latest"}}) close(done) }() select { case <-done: case <-time.After(100 * time.Millisecond): t.Fatal("queue persistence blocked the queue callback") } runs := <-s.persistRuns if len(runs) != 1 || runs[0].ID != "latest" { t.Fatalf("queued persistence state = %#v", runs) } }