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 }