Files
URBM/internal/service/safety_test.go
T

164 lines
5.8 KiB
Go

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 TestDashboardStatusUsesOnlyBackupsAndFindsOverdueJobs(t *testing.T) {
now := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
lastSuccess := now.Add(-30 * time.Hour)
failedAt := now.Add(-2 * time.Hour)
q := queue.New(nil, nil)
q.RestoreHistory([]model.Run{
{ID: "failed-backup", JobID: "job-1", TaskType: "backup", Status: "failed", CreatedAt: failedAt},
{ID: "successful-check", JobID: "repo-1", TaskType: "check", Status: "success", CreatedAt: now.Add(-time.Hour)},
})
config := model.DefaultConfig()
config.Jobs = []model.Job{{ID: "job-1", Name: "Daily", Type: model.JobShare, Enabled: true, Schedule: model.Schedule{Cron: "0 2 * * *", Timezone: "UTC"}}}
s := &Service{queue: q, config: config, backupMetrics: []model.BackupMetric{{RunID: "success", JobID: "job-1", Status: "success", FinishedAt: lastSuccess}}}
status := s.DashboardStatus(now)
if status.LastSuccessfulBackup == nil || status.LastSuccessfulBackup.RunID != "success" {
t.Fatalf("last successful backup = %#v", status.LastSuccessfulBackup)
}
if status.LastFailedBackup == nil || status.LastFailedBackup.ID != "failed-backup" {
t.Fatalf("last failed backup = %#v", status.LastFailedBackup)
}
if len(status.OverdueJobs) != 1 || status.OverdueJobs[0].JobID != "job-1" {
t.Fatalf("overdue jobs = %#v", status.OverdueJobs)
}
}
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)
}
}