aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/helpers_test.go
blob: 8aa533ffc1937eceac0a234bee2632a0a4f557ae (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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
}