package jobhistory import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "uuid" ) // readRawRecords reads and unmarshals the store file, failing the test on // error. func readRawRecords(t *testing.T, path string) []Record { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("Failed to read store file: %v", err) } var records []Record if err := json.Unmarshal(data, &records); err != nil { t.Fatalf("Store file is not valid JSON: %v", err) } return records } func testRecord(id string) Record { started := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) completed := started.Add(5 * time.Minute) return Record{ ID: id, Type: "install", MethodID: "mpv-test", AppName: "MPV", Status: "complete", Progress: 100, Message: "done", Output: []string{"line one", "line two"}, StartedAt: started, CompletedAt: &completed, } } // TestStoreRoundTrip verifies appended records are persisted with all fields // intact and that LoadRecords returns them newest first. func TestStoreRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) store := NewStore(path) rec1 := testRecord("1") rec2 := testRecord("2") rec2.Type = "update" rec2.Status = "error" rec2.Error = "hash mismatch" rec2.ErrorDetails = "blake3 differed" if err := store.Append(rec1); err != nil { t.Fatalf("Append failed: %v", err) } if err := store.Append(rec2); err != nil { t.Fatalf("Append failed: %v", err) } // Oldest-first on disk stored := readRawRecords(t, path) if len(stored) != 2 || stored[0].ID != "1" || stored[1].ID != "2" { t.Fatalf("Unexpected on-disk order: %+v", stored) } if stored[0].CompletedAt == nil || !stored[0].CompletedAt.Equal(*rec1.CompletedAt) { t.Errorf("CompletedAt not round-tripped: %+v", stored[0].CompletedAt) } if len(stored[0].Output) != 2 || stored[0].Output[0] != "line one" { t.Errorf("Output not round-tripped: %v", stored[0].Output) } // Newest-first for history views loaded, err := store.LoadRecords() if err != nil { t.Fatalf("LoadRecords failed: %v", err) } if len(loaded) != 2 || loaded[0].ID != "2" || loaded[1].ID != "1" { t.Fatalf("Expected newest first, got %+v", loaded) } if loaded[0].Error != "hash mismatch" || loaded[0].ErrorDetails != "blake3 differed" { t.Errorf("Error fields not round-tripped: %+v", loaded[0]) } } func TestStoreSanitizesTerminalControlsBeforePersistence(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) store := NewStore(path) record := testRecord("terminal-controls") record.AppName = "\x1b]0;title\aMPV" record.Error = "\x1b[31mfailed\x1b[0m" record.Output = []string{"first\nsecond\x1b]52;c;YQ==\a"} if err := store.Append(record); err != nil { t.Fatal(err) } persisted := readRawRecords(t, path) if len(persisted) != 1 { t.Fatalf("persisted records = %d, want 1", len(persisted)) } if persisted[0].AppName != "MPV" || persisted[0].Error != "failed" { t.Fatalf("control-bearing fields were persisted: %+v", persisted[0]) } if len(persisted[0].Output) != 2 || persisted[0].Output[1] != "second" { t.Fatalf("output was not sanitized into logical lines: %q", persisted[0].Output) } } func TestStoreUpsertReplacesHandedOffRecord(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) store := NewStore(path) handoff := testRecord("manager-job") handoff.Status = "handed_off" handoff.Output = []string{"helper started"} requireNoError := func(err error) { if err != nil { t.Fatal(err) } } requireNoError(store.Append(handoff)) resolved := testRecord("manager-job") resolved.Status = "error" resolved.Error = "update rolled back" resolved.StartedAt = time.Time{} resolved.Output = []string{"rollback completed"} requireNoError(store.Upsert(resolved)) records := readRawRecords(t, path) if len(records) != 1 || records[0].Status != "error" || records[0].Error != "update rolled back" { t.Fatalf("handed-off record was not replaced: %+v", records) } if records[0].StartedAt.IsZero() || len(records[0].Output) != 2 { t.Fatalf("upsert did not preserve start/output context: %+v", records[0]) } } // TestStoreRecordCap verifies the store keeps only the last MaxRecords // records, dropping the oldest. func TestStoreRecordCap(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) store := NewStore(path) for i := 0; i < MaxRecords+5; i++ { if err := store.Append(testRecord(fmt.Sprintf("id-%02d", i))); err != nil { t.Fatalf("Append %d failed: %v", i, err) } } records := readRawRecords(t, path) if len(records) != MaxRecords { t.Fatalf("Expected %d records, got %d", MaxRecords, len(records)) } if records[0].ID != "id-05" { t.Errorf("Oldest record was not dropped: first ID = %s, want id-05", records[0].ID) } if records[len(records)-1].ID != fmt.Sprintf("id-%02d", MaxRecords+4) { t.Errorf("Newest record missing: last ID = %s", records[len(records)-1].ID) } } // TestStoreOutputCap verifies only the last MaxOutputLines output lines are // persisted per record. func TestStoreOutputCap(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) store := NewStore(path) rec := testRecord("cap") output := make([]string, 0, MaxOutputLines+50) for i := 0; i < MaxOutputLines+50; i++ { output = append(output, fmt.Sprintf("line %03d", i)) } rec.Output = output if err := store.Append(rec); err != nil { t.Fatalf("Append failed: %v", err) } records := readRawRecords(t, path) if len(records) != 1 { t.Fatalf("Expected 1 record, got %d", len(records)) } out := records[0].Output if len(out) != MaxOutputLines { t.Fatalf("Expected %d stored output lines, got %d", MaxOutputLines, len(out)) } if out[0] != "line 050" || out[len(out)-1] != "line 149" { t.Errorf("Wrong lines kept: first %q, last %q", out[0], out[len(out)-1]) } // The caller's slice must not be aliased by the stored record. if &out[0] == &output[len(output)-MaxOutputLines] { t.Error("Stored output aliases the caller's slice") } } // TestStoreMissingAndCorrupt verifies a missing file reads as empty, a // corrupt file errors on read, and the next append discards the corrupt // content and recovers. func TestStoreMissingAndCorrupt(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, FileName) store := NewStore(path) records, err := store.ReadRecords() if err != nil || records != nil { t.Errorf("Expected (nil, nil) for missing file, got (%v, %v)", records, err) } loaded, err := store.LoadRecords() if err != nil || loaded != nil { t.Errorf("Expected (nil, nil) for missing file, got (%v, %v)", loaded, err) } if err := os.WriteFile(path, []byte("{not json!!!"), 0644); err != nil { t.Fatal(err) } if _, err := store.ReadRecords(); err == nil { t.Error("Expected error for corrupt file") } if _, err := store.LoadRecords(); err == nil { t.Error("Expected error for corrupt file") } if err := store.Append(testRecord("recovered")); err != nil { t.Fatalf("Append after corrupt file failed: %v", err) } records = readRawRecords(t, path) if len(records) != 1 || records[0].ID != "recovered" { t.Fatalf("Store did not recover after corrupt file: %+v", records) } quarantined, err := filepath.Glob(path + ".corrupt-*") if err != nil || len(quarantined) != 1 { t.Fatalf("corrupt history was not retained: matches=%v err=%v", quarantined, err) } content, err := os.ReadFile(quarantined[0]) if err != nil || string(content) != "{not json!!!" { t.Fatalf("quarantined history changed: content=%q err=%v", content, err) } } func TestIndependentStoresDoNotLoseConcurrentAppends(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) stores := []*Store{NewStore(path), NewStore(path)} const workers = 30 var wg sync.WaitGroup errs := make(chan error, workers) for i := 0; i < workers; i++ { wg.Add(1) go func(index int) { defer wg.Done() if err := stores[index%len(stores)].Append(testRecord(fmt.Sprintf("store-%02d", index))); err != nil { errs <- err } }(i) } wg.Wait() close(errs) for err := range errs { t.Fatal(err) } records := readRawRecords(t, path) if len(records) != workers { t.Fatalf("independent stores lost updates: got %d records, want %d", len(records), workers) } } // TestStoreAppendCreatesParentDir verifies append creates the parent // directory as needed. func TestStoreAppendCreatesParentDir(t *testing.T) { path := filepath.Join(t.TempDir(), "nested", "dir", FileName) if err := NewStore(path).Append(testRecord("1")); err != nil { t.Fatalf("Append failed: %v", err) } if records := readRawRecords(t, path); len(records) != 1 { t.Fatalf("Expected 1 record, got %d", len(records)) } } // TestStoreConcurrentAppends exercises concurrent appends and reads against // a shared store. Readers must never observe a partially written file (the // temp file + rename keeps writes atomic). Run with -race to detect data // races. func TestStoreConcurrentAppends(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) store := NewStore(path) // Pre-create the file so readers always have something complete to read. if err := os.WriteFile(path, []byte("[]"), 0644); err != nil { t.Fatal(err) } const workers = 20 var readerWg, appendWg sync.WaitGroup stop := make(chan struct{}) // Concurrent reader: every successful read must be valid JSON. var readErrCount atomic.Int64 readerWg.Add(1) go func() { defer readerWg.Done() for { select { case <-stop: return default: } if _, err := store.ReadRecords(); err != nil { readErrCount.Add(1) } } }() appendErrs := make(chan error, workers) for i := 0; i < workers; i++ { appendWg.Add(1) go func(n int) { defer appendWg.Done() if err := store.Append(testRecord(fmt.Sprintf("conc-%d", n))); err != nil { appendErrs <- err } }(i) } appendWg.Wait() close(stop) readerWg.Wait() close(appendErrs) for err := range appendErrs { t.Errorf("Concurrent append failed: %v", err) } if n := readErrCount.Load(); n > 0 { t.Errorf("Reader observed %d invalid/partial reads", n) } records := readRawRecords(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 } // No temp file may be left behind by the atomic rename. if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { t.Errorf("Temp file left behind: %v", err) } } // TestFilePath verifies the history file resolves inside the mpv config dir. func TestFilePath(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp) path, err := FilePath() if err != nil { t.Fatalf("FilePath failed: %v", err) } if filepath.Base(path) != FileName { t.Errorf("Expected file name %s, got %s", FileName, filepath.Base(path)) } if !strings.HasPrefix(path, tmp) { t.Errorf("Expected path under sandboxed home %s, got %s", tmp, path) } } // TestNewID verifies generated IDs are canonical RFC 9562 UUIDs and unique. func TestNewID(t *testing.T) { seen := make(map[string]bool, 1000) for i := 0; i < 1000; i++ { id := NewID() parsed, err := uuid.Parse(id) if err != nil { t.Fatalf("NewID returned invalid UUID %q: %v", id, err) } if canonical := parsed.String(); id != canonical { t.Fatalf("NewID returned non-canonical UUID %q; want %q", id, canonical) } if seen[id] { t.Fatalf("Duplicate ID %s", id) } seen[id] = true } }