diff options
| author | tsne <tsne.dev@outlook.com> | 2026-07-06 16:52:20 +0200 |
|---|---|---|
| committer | tsne <tsne.dev@outlook.com> | 2026-07-06 21:37:42 +0200 |
| commit | 84da43998f7cc42cc72652333ac6e3d293abb23e (patch) | |
| tree | 6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/cli/helpers_test.go | |
| download | hopper-main.tar.gz | |
Diffstat (limited to 'internal/cli/helpers_test.go')
| -rw-r--r-- | internal/cli/helpers_test.go | 231 |
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 +} |