package web import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "testing" "gitgud.io/mike/mpv-manager/pkg/jobhistory" ) // readJobRecords reads and unmarshals the store file, failing the test on error. func readJobRecords(t *testing.T, path string) []jobhistory.Record { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("Failed to read store file: %v", err) } var records []jobhistory.Record if err := json.Unmarshal(data, &records); err != nil { t.Fatalf("Store file is not valid JSON: %v", err) } return records } // TestJobStore_RecordWrittenOnArchive verifies a terminal job is persisted to // the store file, while an active job is not. func TestJobStore_RecordWrittenOnArchive(t *testing.T) { path := filepath.Join(t.TempDir(), jobhistory.FileName) jm := NewJobManagerWithStore(path) job := jm.CreateJob("install", "mpv-test-store", "MPV") jm.AddOutput(job.ID, "downloading") jm.CompleteJob(job.ID) records := readJobRecords(t, path) if len(records) != 1 { t.Fatalf("Expected 1 record, got %d", len(records)) } rec := records[0] if rec.ID != job.ID || rec.Type != "install" || rec.MethodID != "mpv-test-store" || rec.AppName != "MPV" { t.Errorf("Record identity mismatch: %+v", rec) } if rec.Status != "complete" || rec.Progress != 100 { t.Errorf("Expected complete/100, got %s/%d", rec.Status, rec.Progress) } if len(rec.Output) != 1 || !strings.Contains(rec.Output[0], "downloading") { t.Errorf("Output not persisted correctly: %v", rec.Output) } if rec.StartedAt.IsZero() || rec.CompletedAt == nil { t.Error("Timestamps not persisted") } // Errored jobs are terminal too and must be persisted job2 := jm.CreateJob("uninstall", "mpv-test-store2", "MPV") jm.SetError(job2.ID, fmt.Errorf("boom"), "it broke") records = readJobRecords(t, path) if len(records) != 2 || records[1].Status != "error" || records[1].Error != "boom" { t.Fatalf("Errored job not persisted: %+v", records) } // Active (non-terminal) jobs must NOT be persisted jm.CreateJob("install", "mpv-test-active", "MPV") records = readJobRecords(t, path) if len(records) != 2 { t.Errorf("Active job was persisted: %d records", len(records)) } // Partial success is terminal and retains reconciliation detail. job3 := jm.CreateJob("install", "mpv-test-partial", "MPV") jm.SetPartialSuccess(job3.ID, fmt.Errorf("disk full"), "installed but untracked") records = readJobRecords(t, path) if len(records) != 3 || records[2].Status != "partial" || records[2].ErrorDetails != "installed but untracked" { t.Fatalf("Partial job not persisted: %+v", records) } } // TestJobStore_CapEnforced verifies the store keeps only the last // jobhistory.MaxRecords records, dropping the oldest. func TestJobStore_CapEnforced(t *testing.T) { path := filepath.Join(t.TempDir(), jobhistory.FileName) jm := NewJobManagerWithStore(path) ids := make([]string, 0, jobhistory.MaxRecords+1) for i := 0; i < jobhistory.MaxRecords+1; i++ { job := jm.CreateJob("install", fmt.Sprintf("mpv-cap-%d", i), "MPV") jm.CompleteJob(job.ID) ids = append(ids, job.ID) } records := readJobRecords(t, path) if len(records) != jobhistory.MaxRecords { t.Fatalf("Expected %d records, got %d", jobhistory.MaxRecords, len(records)) } if records[0].ID != ids[1] { t.Errorf("Oldest record was not dropped: first ID = %s, want %s", records[0].ID, ids[1]) } if records[len(records)-1].ID != ids[len(ids)-1] { t.Errorf("Newest record missing: last ID = %s, want %s", records[len(records)-1].ID, ids[len(ids)-1]) } } // TestJobStore_RestartReload verifies a new manager created from the same // store path sees the persisted history (status, message, timestamps). func TestJobStore_RestartReload(t *testing.T) { path := filepath.Join(t.TempDir(), jobhistory.FileName) jm1 := NewJobManagerWithStore(path) job1 := jm1.CreateJob("install", "mpv-restart-1", "MPV") jm1.UpdateProgress(job1.ID, 40, "halfway") jm1.CompleteJob(job1.ID) job2 := jm1.CreateJob("update", "mpv-restart-2", "MPV") jm1.SetError(job2.ID, fmt.Errorf("network down"), "could not reach mirror") // "Restart": a brand-new manager over the same store file jm2 := NewJobManagerWithStore(path) recent := jm2.GetRecentJobs() if len(recent) != 2 { t.Fatalf("Expected 2 reloaded jobs, got %d", len(recent)) } // Newest first: the errored update job comes first loaded := recent[0] if loaded.ID != job2.ID || loaded.Status != "error" || loaded.Error != "network down" { t.Errorf("Reloaded job 2 mismatch: %+v", loaded) } if loaded.CompletedAt == nil || loaded.StartedAt.Unix() != job2.StartedAt.Unix() { t.Error("Reloaded job 2 timestamps mismatch") } older := recent[1] if older.ID != job1.ID || older.Status != "complete" || older.Progress != 100 { t.Errorf("Reloaded job 1 mismatch: %+v", older) } if older.Message != "halfway" { t.Errorf("Reloaded job 1 message = %q, want %q", older.Message, "halfway") } if older.CompletedAt == nil || older.StartedAt.Unix() != job1.StartedAt.Unix() { t.Error("Reloaded job 1 timestamps mismatch") } // Reloaded jobs are terminal snapshots: cancelling must be a no-op-safe call loaded.Cancel() if loaded.IsCancelled() { t.Error("Reloaded job must not report a cancelled context") } } // TestJobStore_CorruptAndMissingFile verifies the store tolerates a missing // or corrupt file (logs and starts empty, never crashes) and recovers on the // next archive. func TestJobStore_CorruptAndMissingFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, jobhistory.FileName) // Missing file: empty history, no error jm := NewJobManagerWithStore(path) if got := jm.GetRecentJobs(); len(got) != 0 { t.Fatalf("Expected empty history for missing file, got %d", len(got)) } // Corrupt file: empty history, no crash if err := os.WriteFile(path, []byte("{not json!!!"), 0644); err != nil { t.Fatal(err) } jm = NewJobManagerWithStore(path) if got := jm.GetRecentJobs(); len(got) != 0 { t.Fatalf("Expected empty history for corrupt file, got %d", len(got)) } // Next archive discards the corrupt content and recovers job := jm.CreateJob("install", "mpv-recover", "MPV") jm.CompleteJob(job.ID) records := readJobRecords(t, path) if len(records) != 1 || records[0].ID != job.ID { t.Fatalf("Store did not recover after corrupt file: %+v", records) } } // TestJobStore_OutputCapped verifies only the last jobhistory.MaxOutputLines // output lines are persisted per job. func TestJobStore_OutputCapped(t *testing.T) { path := filepath.Join(t.TempDir(), jobhistory.FileName) jm := NewJobManagerWithStore(path) job := jm.CreateJob("install", "mpv-output-cap", "MPV") const totalLines = jobhistory.MaxOutputLines + 50 for i := 0; i < totalLines; i++ { jm.AddOutput(job.ID, fmt.Sprintf("line %03d", i)) } jm.CompleteJob(job.ID) records := readJobRecords(t, path) if len(records) != 1 { t.Fatalf("Expected 1 record, got %d", len(records)) } out := records[0].Output if len(out) != jobhistory.MaxOutputLines { t.Fatalf("Expected %d stored output lines, got %d", jobhistory.MaxOutputLines, len(out)) } // The stored lines must be the LAST jobhistory.MaxOutputLines ones if !strings.Contains(out[0], "line 050") { t.Errorf("First stored line = %q, want it to contain %q", out[0], "line 050") } if !strings.Contains(out[len(out)-1], "line 149") { t.Errorf("Last stored line = %q, want it to contain %q", out[len(out)-1], "line 149") } } // TestJobStore_ConcurrentArchives exercises concurrent job archiving against // a shared store. Run with -race to detect data races. func TestJobStore_ConcurrentArchives(t *testing.T) { path := filepath.Join(t.TempDir(), jobhistory.FileName) jm := NewJobManagerWithStore(path) const workers = 20 var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func(n int) { defer wg.Done() job := jm.CreateJob("install", fmt.Sprintf("mpv-conc-%d", n), "MPV") jm.AddOutput(job.ID, "working") if n%3 == 0 { jm.SetError(job.ID, fmt.Errorf("fail %d", n), "details") } else { jm.CompleteJob(job.ID) } }(i) } wg.Wait() records := readJobRecords(t, path) if len(records) != workers { t.Fatalf("Expected %d records, got %d", workers, len(records)) } seen := make(map[string]bool, workers) for _, rec := range records { if seen[rec.ID] { t.Errorf("Duplicate record ID %s", rec.ID) } seen[rec.ID] = true } if got := jm.GetRecentJobs(); len(got) != maxRecentJobs { t.Errorf("Expected %d in-memory recent jobs, got %d", maxRecentJobs, len(got)) } }