package artifacts import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func TestEnsureLayoutCreatesExpectedDirectories(t *testing.T) { store := NewLocalStore(t.TempDir()) paths, err := store.EnsureLayoutFor("sample-campaign", "session-1") if err != nil { t.Fatalf("EnsureLayoutFor() error = %v", err) } checkDirExists(t, paths.Root) checkDirExists(t, paths.InputsDir) checkDirExists(t, paths.AudioDir) checkDirExists(t, paths.TranscriptsDir) checkDirExists(t, paths.TranscriptsRawDir) checkDirExists(t, paths.TranscriptsTrimmedDir) checkDirExists(t, paths.ArtifactsDir) checkDirExists(t, paths.ReportsDir) checkDirExists(t, paths.ConfigDir) checkDirExists(t, paths.LogsDir) checkDirExists(t, paths.CurrentDir) checkDirExists(t, paths.RunsDir) checkDirExists(t, paths.PreviousDir) checkDirExists(t, paths.PreviousArtifactsDir) if filepath.Base(paths.ManifestPath) != "manifest.json" { t.Fatalf("ManifestPath = %q, want basename manifest.json", paths.ManifestPath) } if filepath.Base(paths.PreviousManifestPath) != "manifest.json" { t.Fatalf("PreviousManifestPath = %q, want basename manifest.json", paths.PreviousManifestPath) } if filepath.Base(paths.LockPath) != ".lock" { t.Fatalf("LockPath = %q, want basename .lock", paths.LockPath) } } func TestEnsureLayoutForRequiresCampaign(t *testing.T) { store := NewLocalStore(t.TempDir()) _, err := store.EnsureLayoutFor("", "session-1") if err == nil { t.Fatal("expected campaign-required error, got nil") } if !strings.Contains(err.Error(), "campaign is required") { t.Fatalf("error = %v, want campaign-required error", err) } } func TestChecksumCalculation(t *testing.T) { store := NewLocalStore(t.TempDir()) path := filepath.Join(t.TempDir(), "sample.txt") if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } checksum, err := store.Checksum(path) if err != nil { t.Fatalf("Checksum() error = %v", err) } const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" if checksum != want { t.Fatalf("checksum = %q, want %q", checksum, want) } } func TestLockAcquireRelease(t *testing.T) { store := NewLocalStore(t.TempDir()) lock, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("AcquireSessionLockFor() error = %v", err) } exists, err := store.Exists(lock.path) if err != nil { t.Fatalf("Exists() error = %v", err) } if !exists { t.Fatalf("expected lock file %q to exist", lock.path) } if err := store.ReleaseSessionLock(lock); err != nil { t.Fatalf("ReleaseSessionLock() error = %v", err) } exists, err = store.Exists(lock.path) if err != nil { t.Fatalf("Exists() error = %v", err) } if !exists { t.Fatalf("expected lock metadata file %q to remain", lock.path) } secondLock, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("AcquireSessionLockFor() after release error = %v", err) } if err := store.ReleaseSessionLock(secondLock); err != nil { t.Fatalf("ReleaseSessionLock(second lock) error = %v", err) } } func TestLockWaitHonorsCancellation(t *testing.T) { store := NewLocalStore(t.TempDir()) lock, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("AcquireSessionLockFor() error = %v", err) } defer func() { _ = store.ReleaseSessionLock(lock) }() ctx, cancel := context.WithCancel(context.Background()) cancel() _, err = store.AcquireSessionLockForContext(ctx, "sample-campaign", "session-1") if !errors.Is(err, context.Canceled) { t.Fatalf("AcquireSessionLockForContext() error = %v, want context cancellation", err) } } func TestLockWaitsUntilRelease(t *testing.T) { store := NewLocalStore(t.TempDir()) first, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("AcquireSessionLockFor() error = %v", err) } acquired := make(chan *LockHandle, 1) errs := make(chan error, 1) go func() { lock, err := store.AcquireSessionLockForContext(context.Background(), "sample-campaign", "session-1") if err != nil { errs <- err return } acquired <- lock }() select { case err := <-errs: t.Fatalf("AcquireSessionLockForContext() error = %v", err) case lock := <-acquired: _ = store.ReleaseSessionLock(lock) t.Fatal("AcquireSessionLockForContext() acquired before the held lock was released") case <-time.After(150 * time.Millisecond): } if err := store.ReleaseSessionLock(first); err != nil { t.Fatalf("ReleaseSessionLock(first) error = %v", err) } select { case err := <-errs: t.Fatalf("AcquireSessionLockForContext() error = %v", err) case lock := <-acquired: if err := store.ReleaseSessionLock(lock); err != nil { t.Fatalf("ReleaseSessionLock(waiting lock) error = %v", err) } case <-time.After(2 * time.Second): t.Fatal("AcquireSessionLockForContext() did not acquire after release") } } func TestLockRecoversAfterLockHolderDies(t *testing.T) { if os.Getenv("NARRATIO_LOCK_PROCESS_HELPER") == "1" { store := NewLocalStore(os.Getenv("NARRATIO_LOCK_PROCESS_ROOT")) lock, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { os.Exit(2) } if err := os.WriteFile(os.Getenv("NARRATIO_LOCK_PROCESS_READY"), []byte(lock.path), 0o600); err != nil { os.Exit(3) } select {} } root := t.TempDir() ready := filepath.Join(t.TempDir(), "ready") cmd := exec.Command(os.Args[0], "-test.run=^TestLockRecoversAfterLockHolderDies$") cmd.Env = append(os.Environ(), "NARRATIO_LOCK_PROCESS_HELPER=1", "NARRATIO_LOCK_PROCESS_ROOT="+root, "NARRATIO_LOCK_PROCESS_READY="+ready, ) if err := cmd.Start(); err != nil { t.Fatalf("start lock-holder process: %v", err) } defer func() { if cmd.Process != nil { _ = cmd.Process.Kill() } _ = cmd.Wait() }() deadline := time.Now().Add(2 * time.Second) for { if _, err := os.Stat(ready); err == nil { break } if time.Now().After(deadline) { t.Fatal("lock-holder process did not signal readiness") } time.Sleep(10 * time.Millisecond) } store := NewLocalStore(root) if _, err := store.AcquireSessionLockFor("sample-campaign", "session-1"); !errors.Is(err, ErrLockConflict) { t.Fatalf("AcquireSessionLockFor() error = %v, want lock conflict", err) } if err := cmd.Process.Kill(); err != nil { t.Fatalf("kill lock-holder process: %v", err) } if err := cmd.Wait(); err == nil { t.Fatal("lock-holder process exited without being killed") } cmd.Process = nil lock, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("AcquireSessionLockFor() after holder death error = %v", err) } if err := store.ReleaseSessionLock(lock); err != nil { t.Fatalf("ReleaseSessionLock() error = %v", err) } } func TestReleaseSessionLockReportsUnlockFailure(t *testing.T) { store := NewLocalStore(t.TempDir()) lock, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("AcquireSessionLockFor() error = %v", err) } original := releaseHeldFileLockFn releaseHeldFileLockFn = func(*os.File) error { return errors.New("unlock failed") } t.Cleanup(func() { releaseHeldFileLockFn = original }) if err := store.ReleaseSessionLock(lock); err == nil || !strings.Contains(err.Error(), "unlock failed") { t.Fatalf("ReleaseSessionLock() error = %v, want unlock failure", err) } } func TestLockConflict(t *testing.T) { store := NewLocalStore(t.TempDir()) lock1, err := store.AcquireSessionLockFor("sample-campaign", "session-1") if err != nil { t.Fatalf("first AcquireSessionLockFor() error = %v", err) } defer func() { _ = store.ReleaseSessionLock(lock1) }() _, err = store.AcquireSessionLockFor("sample-campaign", "session-1") if err == nil { t.Fatal("expected lock conflict error, got nil") } if !errors.Is(err, ErrLockConflict) { t.Fatalf("error = %v, want ErrLockConflict", err) } } func TestWriteFileAtomicAndOverwrite(t *testing.T) { store := NewLocalStore(t.TempDir()) path := filepath.Join(t.TempDir(), "out", "value.txt") if err := store.WriteFileAtomic(path, []byte("one"), 0o644); err != nil { t.Fatalf("first WriteFileAtomic() error = %v", err) } if err := store.WriteFileAtomic(path, []byte("two"), 0o644); err != nil { t.Fatalf("second WriteFileAtomic() error = %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile() error = %v", err) } if string(data) != "two" { t.Fatalf("file content = %q, want %q", string(data), "two") } entries, err := os.ReadDir(filepath.Dir(path)) if err != nil { t.Fatalf("ReadDir() error = %v", err) } for _, e := range entries { if strings.Contains(e.Name(), ".tmp-") { t.Fatalf("unexpected temp file residue: %s", e.Name()) } } } func TestCopyInput(t *testing.T) { root := t.TempDir() store := NewLocalStore(root) srcPath := filepath.Join(t.TempDir(), "speakers.yml") content := "alice: alice.flac\n" if err := os.WriteFile(srcPath, []byte(content), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } ref, err := store.CopyInputFor("sample-campaign", "session-1", srcPath, "inputs/speakers.yml") if err != nil { t.Fatalf("CopyInputFor() error = %v", err) } if ref.Kind != "input" { t.Fatalf("ref.Kind = %q, want %q", ref.Kind, "input") } if ref.RelativePath != filepath.Clean("inputs/speakers.yml") { t.Fatalf("ref.RelativePath = %q, want %q", ref.RelativePath, filepath.Clean("inputs/speakers.yml")) } if !strings.HasPrefix(ref.AbsolutePath, root) { t.Fatalf("ref.AbsolutePath = %q, want under workspace root %q", ref.AbsolutePath, root) } data, err := os.ReadFile(ref.AbsolutePath) if err != nil { t.Fatalf("ReadFile() error = %v", err) } if string(data) != content { t.Fatalf("copied content = %q, want %q", string(data), content) } } func checkDirExists(t *testing.T, path string) { t.Helper() info, err := os.Stat(path) if err != nil { t.Fatalf("Stat(%q) error = %v", path, err) } if !info.IsDir() { t.Fatalf("%q is not a directory", path) } }