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}