package filetx import ( "context" "errors" "io" "math" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "testing" "github.com/gofrs/flock" ) type stubReadCloser struct { reader io.Reader closeErr error } func (s stubReadCloser) Read(body []byte) (int, error) { return s.reader.Read(body) } func (s stubReadCloser) Close() error { return s.closeErr } type errorReader struct { err error } func (r errorReader) Read([]byte) (int, error) { return 0, r.err } func TestReadReturnsAFileAtItsExactLimit(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") if err := os.WriteFile(path, []byte("1234"), 0o600); err != nil { t.Fatalf("write: %v", err) } body, err := Read(path, 4) if err != nil { t.Fatalf("read: %v", err) } if string(body) != "1234" { t.Fatalf("body = %q, want the complete file", body) } } func TestReadAcceptsTheMaximumByteLimitWithoutOverflowing(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") if err := os.WriteFile(path, []byte("complete"), 0o600); err != nil { t.Fatalf("write: %v", err) } body, err := Read(path, math.MaxInt64) if err != nil { t.Fatalf("read: %v", err) } if string(body) != "complete" { t.Fatalf("body = %q, want the complete file", body) } } func TestReadRejectsInvalidLimitsBeforeOpeningTheFile(t *testing.T) { for _, limit := range []int64{-1, 0} { _, err := Read(filepath.Join(t.TempDir(), "missing.json"), limit) if err == nil || !strings.Contains(err.Error(), "invalid byte limit") { t.Fatalf("Read limit %d error = %v, want the invalid limit", limit, err) } } } func TestReadReportsAMissingFile(t *testing.T) { _, err := Read(filepath.Join(t.TempDir(), "missing.json"), 10) if !errors.Is(err, os.ErrNotExist) { t.Fatalf("read error = %v, want the missing file", err) } } func TestReadRejectsADirectory(t *testing.T) { _, err := Read(t.TempDir(), 10) if err == nil { t.Fatal("a directory was returned as file contents") } } func TestReadRejectsAFileAboveItsLimit(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") if err := os.WriteFile(path, []byte("1234"), 0o600); err != nil { t.Fatalf("write: %v", err) } _, err := Read(path, 3) if err == nil || !strings.Contains(err.Error(), "larger than 3 bytes") { t.Fatalf("read error = %v, want the size limit", err) } } func TestReadReturnsACloseFailure(t *testing.T) { want := errors.New("close failed") file := stubReadCloser{ reader: strings.NewReader("ok"), closeErr: want, } body, err := read("state.json", file, 2) if body != nil { t.Fatalf("body = %q, want no body after close failure", body) } if !errors.Is(err, want) { t.Fatalf("read error = %v, want %v", err, want) } } func TestReadReturnsAReadFailureBeforeACloseFailure(t *testing.T) { wantRead := errors.New("read failed") file := stubReadCloser{ reader: errorReader{err: wantRead}, closeErr: errors.New("close failed"), } body, err := read("state.json", file, 2) if body != nil { t.Fatalf("body = %q, want no body after read failure", body) } if !errors.Is(err, wantRead) { t.Fatalf("read error = %v, want %v", err, wantRead) } } func TestExclusiveStopsWaitingWhenTheCallerIsCanceled(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") guard := flock.New(path + ".lock") locked, lockErr := guard.TryLock() if lockErr != nil { t.Fatalf("hold lock: %v", lockErr) } if !locked { t.Fatal("could not hold the lock") } t.Cleanup(func() { if err := guard.Unlock(); err != nil { t.Errorf("unlock: %v", err) } }) ctx, cancel := context.WithCancel(t.Context()) cancel() called := false err := Exclusive(ctx, path, func() error { called = true return nil }) if !errors.Is(err, context.Canceled) { t.Fatalf("exclusive error = %v, want cancellation", err) } if called { t.Fatal("the action ran without acquiring the lock") } } func TestReadLimitsAStreamWhoseSizeWasNotKnown(t *testing.T) { file := stubReadCloser{reader: strings.NewReader("1234")} _, err := read("state.json", file, 3) if err == nil || !strings.Contains(err.Error(), "larger than 3 bytes") { t.Fatalf("read error = %v, want the stream size limit", err) } } func TestExclusiveCreatesPrivateLockingState(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested") path := filepath.Join(dir, "state.json") called := false err := Exclusive(t.Context(), path, func() error { called = true return nil }) if err != nil { t.Fatalf("exclusive: %v", err) } if !called { t.Fatal("the exclusive action was not called") } info, statErr := os.Stat(dir) if statErr != nil { t.Fatalf("stat directory: %v", statErr) } if info.Mode().Perm() != dirMode { t.Fatalf("directory mode = %v, want %v", info.Mode().Perm(), dirMode) } lock, statErr := os.Stat(path + ".lock") if statErr != nil { t.Fatalf("stat lock: %v", statErr) } if lock.Mode().Perm() != fileMode { t.Fatalf("lock mode = %v, want %v", lock.Mode().Perm(), fileMode) } } func TestExclusiveDoesNotCallTheActionWhenItsDirectoryCannotBeCreated(t *testing.T) { blocked := filepath.Join(t.TempDir(), "blocked") if err := os.WriteFile(blocked, []byte("not a directory"), 0o600); err != nil { t.Fatalf("write blocker: %v", err) } called := false err := Exclusive(t.Context(), filepath.Join(blocked, "state.json"), func() error { called = true return nil }) if err == nil { t.Fatal("a transaction inside a file reported success") } if called { t.Fatal("the action ran without a transaction directory") } } func TestExclusiveDoesNotCallTheActionWhenTheLockCannotBeCreated(t *testing.T) { path := filepath.Join(t.TempDir(), strings.Repeat("x", 300)) called := false err := Exclusive(t.Context(), path, func() error { called = true return nil }) if err == nil { t.Fatal("a transaction with no lock reported success") } if called { t.Fatal("the action ran without a lock") } } func TestExclusiveReturnsTheActionFailureAndReleasesTheLock(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") want := errors.New("update failed") err := Exclusive(t.Context(), path, func() error { return want }) if !errors.Is(err, want) { t.Fatalf("exclusive error = %v, want %v", err, want) } called := false if retryErr := Exclusive(t.Context(), path, func() error { called = true return nil }); retryErr != nil { t.Fatalf("retry: %v", retryErr) } if !called { t.Fatal("the action failure left the lock held") } } func TestExclusiveReleasesTheLockWhenTheActionPanics(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") func() { defer func() { if recover() == nil { t.Fatal("the action panic was swallowed") } }() _ = Exclusive(t.Context(), path, func() error { panic("broken update") }) }() if err := Exclusive(t.Context(), path, func() error { return nil }); err != nil { t.Fatalf("transaction after panic: %v", err) } } func TestExclusiveSerializesConcurrentActions(t *testing.T) { const writers = 32 path := filepath.Join(t.TempDir(), "state.json") var active atomic.Int32 var completed atomic.Int32 var overlapped atomic.Bool errs := make(chan error, writers) var group sync.WaitGroup for range writers { group.Go(func() { errs <- Exclusive(t.Context(), path, func() error { if active.Add(1) != 1 { overlapped.Store(true) } for range 8 { runtime.Gosched() } active.Add(-1) completed.Add(1) return nil }) }) } group.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("exclusive: %v", err) } } if overlapped.Load() { t.Fatal("exclusive actions overlapped") } if completed.Load() != writers { t.Fatalf("completed = %d, want %d", completed.Load(), writers) } }