detached fda3c2b

mirrors_fixture_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"crypto/ed25519"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports/mocks"
17	"bnema.dev/ermite/internal/usecase"
18)
19
20// mirrorFixture is a Mirrors use case over in-memory state behind the mocks. Every mock
21// answers from the fields below under mu, so tests change the world with the setters and
22// never mutate a mock after construction.
23type mirrorFixture struct {
24	m      *usecase.Mirrors
25	push   *mocks.MirrorPusher
26	keys   *mocks.MirrorKeyStore
27	sealer *mocks.SecretSealer
28	repo   domain.RepoName
29
30	mu         sync.Mutex
31	at         time.Time
32	global     domain.MirrorGlobalConfig
33	repos      []domain.Repo
34	cfgs       map[domain.RepoName]domain.MirrorRepoConfig
35	status     map[domain.RepoName]domain.MirrorStatus
36	snaps      map[domain.RepoName]domain.MirrorSnapshot
37	refs       []domain.Ref
38	refsErr    error
39	branchErr  error
40	listRefs   int
41	statusPuts int
42	events     []domain.Event
43	onPutRepo  func() // runs inside PutRepoConfig, set before concurrency starts
44	statusErr  map[domain.RepoName]error
45}
46
47func newMirrorFixture(t *testing.T) *mirrorFixture {
48	return newMirrorFixtureWith(t, nil)
49}
50
51func newMirrorFixtureWith(t *testing.T, tweak func(*usecase.MirrorsConfig)) *mirrorFixture {
52	t.Helper()
53	f := &mirrorFixture{
54		push: mocks.NewMirrorPusher(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t),
55		at: now, repo: name(t, "tools"),
56		global: domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"},
57		cfgs:   map[domain.RepoName]domain.MirrorRepoConfig{}, status: map[domain.RepoName]domain.MirrorStatus{}, snaps: map[domain.RepoName]domain.MirrorSnapshot{}, statusErr: map[domain.RepoName]error{},
58		refs: []domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}},
59	}
60	f.addRepo(f.repo)
61	store, meta, git, reader, bus, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewRepoReader(t), mocks.NewEventBus(t), mocks.NewClock(t)
62	clock.EXPECT().Now().RunAndReturn(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }).Maybe()
63	meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.Repo, error) {
64		f.mu.Lock()
65		defer f.mu.Unlock()
66		for _, r := range f.repos {
67			if r.Name == n {
68				return r, nil
69			}
70		}
71		return domain.Repo{}, domain.ErrNotFound
72	}).Maybe()
73	meta.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Repo, error) {
74		f.mu.Lock()
75		defer f.mu.Unlock()
76		return append([]domain.Repo(nil), f.repos...), nil
77	}).Maybe()
78	store.EXPECT().GetGlobal(mock.Anything).RunAndReturn(func(context.Context) (domain.MirrorGlobalConfig, error) {
79		f.mu.Lock()
80		defer f.mu.Unlock()
81		return f.global, nil
82	}).Maybe()
83	store.EXPECT().PutGlobal(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, g domain.MirrorGlobalConfig) error {
84		f.mu.Lock()
85		defer f.mu.Unlock()
86		f.global = g
87		return nil
88	}).Maybe()
89	store.EXPECT().GetRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorRepoConfig, error) {
90		f.mu.Lock()
91		defer f.mu.Unlock()
92		if c, ok := f.cfgs[n]; ok {
93			return c, nil
94		}
95		return domain.MirrorRepoConfig{}, domain.ErrNotFound
96	}).Maybe()
97	store.EXPECT().PutRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, c domain.MirrorRepoConfig) error {
98		if f.onPutRepo != nil {
99			f.onPutRepo()
100		}
101		f.mu.Lock()
102		defer f.mu.Unlock()
103		f.cfgs[c.Repo] = c
104		return nil
105	}).Maybe()
106	store.EXPECT().DeleteRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) error {
107		f.mu.Lock()
108		defer f.mu.Unlock()
109		delete(f.cfgs, n)
110		return nil
111	}).Maybe()
112	store.EXPECT().ListRepoConfigs(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorRepoConfig, error) {
113		f.mu.Lock()
114		defer f.mu.Unlock()
115		var out []domain.MirrorRepoConfig
116		for _, c := range f.cfgs {
117			out = append(out, c)
118		}
119		return out, nil
120	}).Maybe()
121	store.EXPECT().GetStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorStatus, error) {
122		f.mu.Lock()
123		defer f.mu.Unlock()
124		if s, ok := f.status[n]; ok {
125			return s, nil
126		}
127		return domain.MirrorStatus{}, domain.ErrNotFound
128	}).Maybe()
129	store.EXPECT().PutStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, s domain.MirrorStatus) error {
130		f.mu.Lock()
131		defer f.mu.Unlock()
132		if err := f.statusErr[s.Repo]; err != nil {
133			return err
134		}
135		f.status[s.Repo] = s
136		f.statusPuts++
137		return nil
138	}).Maybe()
139	store.EXPECT().ListStatus(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorStatus, error) {
140		f.mu.Lock()
141		defer f.mu.Unlock()
142		var out []domain.MirrorStatus
143		for _, s := range f.status {
144			out = append(out, s)
145		}
146		return out, nil
147	}).Maybe()
148	store.EXPECT().GetSnapshot(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorSnapshot, error) {
149		f.mu.Lock()
150		defer f.mu.Unlock()
151		if s, ok := f.snaps[n]; ok {
152			return s, nil
153		}
154		return domain.MirrorSnapshot{}, domain.ErrNotFound
155	}).Maybe()
156	store.EXPECT().PutSnapshot(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName, s domain.MirrorSnapshot) error {
157		f.mu.Lock()
158		defer f.mu.Unlock()
159		f.snaps[n] = s
160		return nil
161	}).Maybe()
162	reader.EXPECT().ListRefs(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.Ref, error) {
163		f.mu.Lock()
164		defer f.mu.Unlock()
165		f.listRefs++
166		return f.refs, f.refsErr
167	}).Maybe()
168	git.EXPECT().DefaultBranch(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) (string, bool, error) {
169		f.mu.Lock()
170		defer f.mu.Unlock()
171		return "main", true, f.branchErr
172	}).Maybe()
173	bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
174		f.mu.Lock()
175		defer f.mu.Unlock()
176		f.events = append(f.events, ev)
177	}).Maybe()
178	f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("sealed"), now, nil).Maybe()
179	f.sealer.EXPECT().Open([]byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe()
180	cfg := usecase.MirrorsConfig{Store: store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: reader, Bus: bus, Clock: clock, ScheduleTick: time.Hour}
181	if tweak != nil {
182		tweak(&cfg)
183	}
184	f.m = usecase.NewMirrors(cfg)
185	f.m.Start()
186	t.Cleanup(func() {
187		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
188		defer cancel()
189		f.m.Close(ctx)
190	})
191	return f
192}
193
194// addRepo declares a public repository whose mirror starts OK.
195func (f *mirrorFixture) addRepo(n domain.RepoName) {
196	f.mu.Lock()
197	defer f.mu.Unlock()
198	f.repos = append(f.repos, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now})
199	f.status[n] = domain.MirrorStatus{Repo: n, State: domain.MirrorOK}
200}
201
202func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() }
203func (f *mirrorFixture) setStatus(s domain.MirrorStatus) {
204	f.mu.Lock()
205	defer f.mu.Unlock()
206	f.status[s.Repo] = s
207}
208func (f *mirrorFixture) setSnapshot(n domain.RepoName, s domain.MirrorSnapshot) {
209	f.mu.Lock()
210	defer f.mu.Unlock()
211	f.snaps[n] = s
212}
213func (f *mirrorFixture) setRepoConfig(c domain.MirrorRepoConfig) {
214	f.mu.Lock()
215	defer f.mu.Unlock()
216	f.cfgs[c.Repo] = c
217}
218func (f *mirrorFixture) setGlobal(g domain.MirrorGlobalConfig) {
219	f.mu.Lock()
220	defer f.mu.Unlock()
221	f.global = g
222}
223func (f *mirrorFixture) failStatusWrites(n domain.RepoName, err error) {
224	f.mu.Lock()
225	defer f.mu.Unlock()
226	f.statusErr[n] = err
227}
228func (f *mirrorFixture) snapshotOf(n domain.RepoName) (domain.MirrorSnapshot, bool) {
229	f.mu.Lock()
230	defer f.mu.Unlock()
231	s, ok := f.snaps[n]
232	return s, ok
233}
234func (f *mirrorFixture) statusOf(n domain.RepoName) domain.MirrorStatus {
235	f.mu.Lock()
236	defer f.mu.Unlock()
237	return f.status[n]
238}
239func (f *mirrorFixture) eventList() []domain.Event {
240	f.mu.Lock()
241	defer f.mu.Unlock()
242	return append([]domain.Event(nil), f.events...)
243}
244func (f *mirrorFixture) refReads() int { f.mu.Lock(); defer f.mu.Unlock(); return f.listRefs }
245func (f *mirrorFixture) statusWrites() int {
246	f.mu.Lock()
247	defer f.mu.Unlock()
248	return f.statusPuts
249}
250
251// drain waits until no sync is queued or running: the deterministic barrier before asserting
252// that something did not happen.
253func (f *mirrorFixture) drain(t *testing.T) {
254	t.Helper()
255	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
256	defer cancel()
257	f.m.Wait(ctx)
258	require.NoError(t, ctx.Err(), "syncs did not finish")
259}
260
261func mirrorOID(t *testing.T) domain.ObjectID {
262	t.Helper()
263	id, err := domain.ParseObjectID(oidA)
264	require.NoError(t, err)
265	return id
266}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close