package runner import ( "context" "os" "path/filepath" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/restic" ) // fakeSender collects sent envelopes for assertions. type fakeSender struct{ envs []api.Envelope } func (s *fakeSender) Send(e api.Envelope) error { s.envs = append(s.envs, e) return nil } // setupScript writes a shell script (without shebang) to a temp dir, // names it "restic", makes it executable, and returns the path. func setupScript(t *testing.T, body string) string { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "restic") if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { t.Fatalf("setupScript: %v", err) } return p } // firstEnvOfType returns the first envelope with the given type, or // fails the test if none is found. func firstEnvOfType(t *testing.T, envs []api.Envelope, mt api.MessageType) api.Envelope { t.Helper() for _, e := range envs { if e.Type == mt { return e } } t.Fatalf("no envelope of type %q found in %d envelopes", mt, len(envs)) return api.Envelope{} } // envelopeOrder returns the message types of all sent envelopes. func envelopeOrder(envs []api.Envelope) []api.MessageType { out := make([]api.MessageType, len(envs)) for i, e := range envs { out[i] = e.Type } return out } // TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake // binary that prints "prune" on stdout (for the log.stream envelope) // and emits valid stats JSON so reportStats can populate size fields. // Expected sequence: job.started → log.stream → repo.stats → job.finished. func TestRunPruneShipsExpectedEnvelopes(t *testing.T) { t.Parallel() // The fake "restic" handles both "prune" and "stats --json" calls. statsJSON := `{"total_size":1000,"total_uncompressed_size":2000,"snapshots_count":3,"total_file_count":10}` bin := setupScript(t, ` case "$1" in prune) echo "prune" ;; stats) echo '`+statsJSON+`' ;; *) echo "unknown: $*" ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunPrune(context.Background(), "job-1"); err != nil { t.Fatalf("RunPrune: %v", err) } order := envelopeOrder(tx.envs) // Confirm landmark envelope types appear in the required order. wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } // The repo.stats payload must have LastPruneAt set. statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var statsPayload api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&statsPayload); err != nil { t.Fatalf("unmarshal repo.stats payload: %v", err) } if statsPayload.LastPruneAt == nil { t.Error("expected LastPruneAt to be set in repo.stats after prune") } // The job.finished payload must indicate success. finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished) var finPayload api.JobFinishedPayload if err := finEnv.UnmarshalPayload(&finPayload); err != nil { t.Fatalf("unmarshal job.finished payload: %v", err) } if finPayload.Status != api.JobSucceeded { t.Errorf("expected job.finished status=%q, got %q", api.JobSucceeded, finPayload.Status) } } // TestRunCheckShipsCheckStatus verifies that a check run which emits // a stale-lock line on stderr (exit 0) reports LastCheckStatus="ok" // and LockPresent=true. func TestRunCheckShipsCheckStatus(t *testing.T) { t.Parallel() statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}` bin := setupScript(t, ` case "$1" in check) echo "Found stale lock" >&2; exit 0 ;; stats) echo '`+statsJSON+`' ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunCheck(context.Background(), "job-2", 0); err != nil { t.Fatalf("RunCheck: %v", err) } // Assert envelope ordering: job.started → log.stream → repo.stats → job.finished. order := envelopeOrder(tx.envs) wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var p api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.LastCheckStatus != "ok" { t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "ok") } if p.LockPresent == nil || !*p.LockPresent { t.Errorf("expected LockPresent=true, got %v", p.LockPresent) } if p.LastCheckAt == nil { t.Error("expected LastCheckAt to be set") } } // TestRunCheckErrorsFoundShipsErrorsStatus verifies that a check run // that exits 1 (errors found) reports LastCheckStatus="errors_found". func TestRunCheckErrorsFoundShipsErrorsStatus(t *testing.T) { t.Parallel() statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}` bin := setupScript(t, ` case "$1" in check) exit 1 ;; stats) echo '`+statsJSON+`' ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) // RunCheck returns nil for exit 1 (errors_found is not a wrapper failure). if err := r.RunCheck(context.Background(), "job-3", 0); err != nil { t.Fatalf("RunCheck: %v", err) } // Assert envelope ordering: job.started → repo.stats → job.finished. // (No log.stream expected because the fake script produces no // output before exit 1 — a real restic check would emit log lines // before exiting non-zero.) order := envelopeOrder(tx.envs) wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var p api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.LastCheckStatus != "errors_found" { t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "errors_found") } } // TestRunUnlockClearsLock verifies that a successful unlock ships a // repo.stats envelope with LockPresent=false. func TestRunUnlockClearsLock(t *testing.T) { t.Parallel() statsJSON := `{"total_size":100,"total_uncompressed_size":150,"snapshots_count":2,"total_file_count":8}` bin := setupScript(t, ` case "$1" in unlock) echo "removed 1 locks" ;; stats) echo '`+statsJSON+`' ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunUnlock(context.Background(), "job-4"); err != nil { t.Fatalf("RunUnlock: %v", err) } // Assert envelope ordering: job.started → log.stream → repo.stats → job.finished. order := envelopeOrder(tx.envs) wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} positions := map[api.MessageType]int{} for i, mt := range order { if _, seen := positions[mt]; !seen { positions[mt] = i } } for i := 0; i < len(wantTypes)-1; i++ { a, b := wantTypes[i], wantTypes[i+1] pa, aOK := positions[a] pb, bOK := positions[b] if !aOK { t.Errorf("envelope type %q not found in output %v", a, order) continue } if !bOK { t.Errorf("envelope type %q not found in output %v", b, order) continue } if pa >= pb { t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order) } } statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats) var p api.RepoStatsPayload if err := statsEnv.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.LockPresent == nil { t.Fatal("expected LockPresent to be set (non-nil)") } if *p.LockPresent { t.Errorf("expected LockPresent=false after unlock, got true") } } // TestRunInitShipsStartedAndFinished confirms the refactored RunInit // still produces job.started and job.finished envelopes. func TestRunInitShipsStartedAndFinished(t *testing.T) { t.Parallel() bin := setupScript(t, `echo "initialized repository"`) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunInit(context.Background(), "job-init"); err != nil { t.Fatalf("RunInit: %v", err) } _ = firstEnvOfType(t, tx.envs, api.MsgJobStarted) _ = firstEnvOfType(t, tx.envs, api.MsgJobFinished) } // TestRunForgetShipsStartedAndFinished confirms the refactored // RunForget still produces job.started and job.finished envelopes. func TestRunForgetShipsStartedAndFinished(t *testing.T) { t.Parallel() // Script handles both "forget --json ..." and "snapshots --json" calls. bin := setupScript(t, ` case "$1" in forget) echo "[]" ;; snapshots) echo "[]" ;; *) exit 0 ;; esac `) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) keepLast := 1 groups := []restic.ForgetGroup{{ Tag: "documents", Policy: restic.ForgetPolicy{KeepLast: &keepLast}, }} if err := r.RunForget(context.Background(), "job-forget", groups); err != nil { t.Fatalf("RunForget: %v", err) } _ = firstEnvOfType(t, tx.envs, api.MsgJobStarted) _ = firstEnvOfType(t, tx.envs, api.MsgJobFinished) }