aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/helpers_test.go
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2026-07-06 16:52:20 +0200
committertsne <tsne.dev@outlook.com>2026-07-06 21:37:42 +0200
commit84da43998f7cc42cc72652333ac6e3d293abb23e (patch)
tree6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/cli/helpers_test.go
downloadhopper-main.tar.gz
initialHEADmain
Diffstat (limited to 'internal/cli/helpers_test.go')
-rw-r--r--internal/cli/helpers_test.go231
1 files changed, 231 insertions, 0 deletions
diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go
new file mode 100644
index 0000000..8aa533f
--- /dev/null
+++ b/internal/cli/helpers_test.go
@@ -0,0 +1,231 @@
+package cli
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "testing"
+
+ "tsne.dev/hopper/internal/bunny"
+ "tsne.dev/hopper/internal/log"
+)
+
+// captureLogs redirects the global logger to fresh buffers for the duration of
+// a test and restores the defaults (and the dryRun global) afterward. Returns
+// the stdout and stderr buffers.
+func captureLogs(t *testing.T) (*bytes.Buffer, *bytes.Buffer) {
+ t.Helper()
+ var out, errb bytes.Buffer
+ log.Init(&out, &errb)
+ t.Cleanup(func() {
+ log.Init(os.Stdout, os.Stderr)
+ log.SetVerbose(false)
+ log.SetQuiet(false)
+ dryRun = false
+ })
+ return &out, &errb
+}
+
+// fakeAPI is an in-memory recording storageAPI: the reusable test seam for all
+// command tests. It records every attempted Upload/Delete and serves scripted
+// List results. It satisfies storageAPI, so it can be substituted for the real
+// *bunny.API anywhere (including wrapped by dryRunStorageAPI).
+type fakeAPI struct {
+ mu sync.Mutex
+ puts map[string][]byte
+ order []string
+ deletes []string
+ lists map[string][]bunny.Object
+ listErr error
+ failOn string
+ failAll bool
+ failWhen func(path string) bool
+ failErr error
+}
+
+func newFakeAPI() *fakeAPI {
+ return &fakeAPI{puts: map[string][]byte{}, lists: map[string][]bunny.Object{}}
+}
+
+func (f *fakeAPI) Upload(ctx context.Context, path string, body []byte) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.order = append(f.order, path)
+ if f.failAll || path == f.failOn || (f.failWhen != nil && f.failWhen(path)) {
+ if f.failErr != nil {
+ return f.failErr
+ }
+ return errors.New("forced failure")
+ }
+ b := make([]byte, len(body))
+ copy(b, body)
+ f.puts[path] = b
+ return nil
+}
+
+func (f *fakeAPI) Delete(ctx context.Context, path string) error {
+ return f.recordDelete(path)
+}
+
+// DeleteDir records the logical directory path (no trailing slash) so tests can
+// assert on it uniformly with file deletes; the real trailing-slash quirk lives
+// in the bunny package and is exercised by bunny's own tests.
+func (f *fakeAPI) DeleteDir(ctx context.Context, path string) error {
+ return f.recordDelete(path)
+}
+
+func (f *fakeAPI) recordDelete(path string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.deletes = append(f.deletes, path)
+ if f.failAll || path == f.failOn || (f.failWhen != nil && f.failWhen(path)) {
+ if f.failErr != nil {
+ return f.failErr
+ }
+ return errors.New("forced failure")
+ }
+ return nil
+}
+
+func (f *fakeAPI) List(ctx context.Context, dir string) *bunny.Iterator {
+ return bunny.NewIterator(ctx, dir, f.listDir)
+}
+
+func (f *fakeAPI) listDir(ctx context.Context, dir string) ([]bunny.Object, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.listErr != nil {
+ return nil, f.listErr
+ }
+ return f.lists[dir], nil
+}
+
+func (f *fakeAPI) paths() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var ps []string
+ for p := range f.puts {
+ ps = append(ps, p)
+ }
+ sort.Strings(ps)
+ return ps
+}
+
+// ctxCancelAPI simulates the real fail-fast scenario: the upload of triggerPath
+// fails immediately with a genuine (non-context) error, while every other
+// upload blocks until the pool cancels its context and then returns an error
+// that WRAPS context.Canceled (mirroring a real *bunny.apiError built from an
+// aborted in-flight request). It lets a test assert that cancellation collateral
+// is filtered out of the failure report while the genuine trigger survives.
+type ctxCancelAPI struct {
+ *fakeAPI
+ triggerPath string
+ triggerErr error
+}
+
+func (c *ctxCancelAPI) Upload(ctx context.Context, path string, body []byte) error {
+ c.fakeAPI.mu.Lock()
+ c.fakeAPI.order = append(c.fakeAPI.order, path)
+ c.fakeAPI.mu.Unlock()
+
+ if path == c.triggerPath {
+ return c.triggerErr
+ }
+ // Collateral: wait for the fail-fast cancellation, then report it as a
+ // context-wrapped error, exactly like a real aborted upload.
+ <-ctx.Done()
+ return fmt.Errorf("upload %s aborted: %w", path, ctx.Err())
+}
+
+// gatingAPI wraps fakeAPI to make each Upload block on a release channel while
+// recording the peak number of concurrent in-flight uploads, so tests can
+// assert the pool never exceeds --concurrency.
+type gatingAPI struct {
+ *fakeAPI
+ release chan struct{}
+
+ gmu sync.Mutex
+ cur int
+ maxSeen int
+}
+
+func (g *gatingAPI) Upload(ctx context.Context, path string, body []byte) error {
+ g.gmu.Lock()
+ g.cur++
+ if g.cur > g.maxSeen {
+ g.maxSeen = g.cur
+ }
+ g.gmu.Unlock()
+
+ <-g.release
+
+ g.gmu.Lock()
+ g.cur--
+ g.gmu.Unlock()
+ return g.fakeAPI.Upload(ctx, path, body)
+}
+
+func (g *gatingAPI) peak() int {
+ g.gmu.Lock()
+ defer g.gmu.Unlock()
+ return g.maxSeen
+}
+
+// tarEntry describes a single file/header to write into a test archive.
+type tarEntry struct {
+ name string
+ body string
+ typeflag byte
+ linkname string
+}
+
+func makeArchive(t *testing.T, entries []tarEntry) string {
+ t.Helper()
+ dir := t.TempDir()
+ p := filepath.Join(dir, "a.tar.gz")
+ f, err := os.Create(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ gz := gzip.NewWriter(f)
+ tw := tar.NewWriter(gz)
+ for _, e := range entries {
+ tf := e.typeflag
+ if tf == 0 {
+ tf = tar.TypeReg
+ }
+ hdr := &tar.Header{
+ Name: e.name,
+ Mode: 0o644,
+ Size: int64(len(e.body)),
+ Typeflag: tf,
+ Linkname: e.linkname,
+ }
+ if tf != tar.TypeReg {
+ hdr.Size = 0
+ }
+ if err := tw.WriteHeader(hdr); err != nil {
+ t.Fatal(err)
+ }
+ if tf == tar.TypeReg {
+ if _, err := tw.Write([]byte(e.body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gz.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}