1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
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"
17 "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21type pushFixture struct {
22 meta *mocks.RepoMetaStore
23 git *mocks.GitRepos
24 bus *mocks.EventBus
25 clock *mocks.Clock
26 tokens *mocks.TokenStore
27 sched *mocks.MaintenanceScheduler
28 pushes *usecase.Pushes
29}
30
31func newPushFixture(t *testing.T) *pushFixture {
32 t.Helper()
33 f := &pushFixture{
34 meta: mocks.NewRepoMetaStore(t),
35 git: mocks.NewGitRepos(t),
36 bus: mocks.NewEventBus(t),
37 clock: mocks.NewClock(t),
38 tokens: mocks.NewTokenStore(t),
39 sched: mocks.NewMaintenanceScheduler(t),
40 }
41 f.clock.EXPECT().Now().Return(now).Maybe()
42 repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
43 auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
44 f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
45 return f
46}
47
48const (
49 oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
50 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
51)
52
53func TestPushesOpen(t *testing.T) {
54 t.Parallel()
55 ctx := context.Background()
56 n := name(t, "tools")
57 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
58 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic}
59
60 tests := []struct {
61 name string
62 p domain.Principal
63 repo *domain.Repo // nil: missing
64 create bool
65 want error
66 }{
67 {"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated},
68 {"root, existing", domain.Root(), &priv, false, nil},
69 {"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil},
70 {"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil},
71 {"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound},
72 {"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden},
73 {"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden},
74 {"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden},
75 {"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound},
76 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
77 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
78 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79 {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80 {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81 {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
82 }
83 for _, tt := range tests {
84 t.Run(tt.name, func(t *testing.T) {
85 t.Parallel()
86 f := newPushFixture(t)
87 if !tt.p.IsAnonymous() {
88 if tt.repo != nil {
89 f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil)
90 } else {
91 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
92 }
93 }
94 // The bus mock expects no Publish: nothing is created here.
95 err := f.pushes.Open(ctx, tt.p, n, tt.create)
96 if tt.want != nil {
97 assert.ErrorIs(t, err, tt.want)
98 return
99 }
100 require.NoError(t, err)
101 })
102 }
103
104 t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) {
105 t.Parallel()
106 f := newPushFixture(t)
107 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
108 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
109 want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
110 f.meta.EXPECT().Create(ctx, want).Return(nil)
111 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
112 f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
113
114 require.NoError(t, f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true))
115 })
116
117 t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) {
118 t.Parallel()
119 f := newPushFixture(t)
120 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once()
121 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
122 f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
123 f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
124 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
125
126 // The bus mock expects no Publish: the repository was not created here.
127 require.NoError(t, f.pushes.Open(ctx, domain.Root(), n, true))
128 })
129
130 t.Run("store failure", func(t *testing.T) {
131 t.Parallel()
132 f := newPushFixture(t)
133 boom := errors.New("boom")
134 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
135 err := f.pushes.Open(ctx, domain.Root(), n, true)
136 assert.ErrorIs(t, err, boom)
137 })
138
139 t.Run("create failure is returned and nothing is published", func(t *testing.T) {
140 t.Parallel()
141 f := newPushFixture(t)
142 boom := errors.New("boom")
143 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
144 f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom)
145 err := f.pushes.Open(ctx, domain.Root(), n, true)
146 assert.ErrorIs(t, err, boom)
147 })
148}
149
150func TestPushesCheck(t *testing.T) {
151 t.Parallel()
152 ctx := context.Background()
153 n := name(t, "tools")
154 ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
155 writer := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "tools")})
156 reader := domain.TokenPrincipal(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)})
157
158 t.Run("root is always valid and needs no lookup", func(t *testing.T) {
159 t.Parallel()
160 f := newPushFixture(t) // the token store mock expects no call
161 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))
162 })
163
164 t.Run("a writer whose token still exists", func(t *testing.T) {
165 t.Parallel()
166 f := newPushFixture(t)
167 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
168 require.NoError(t, f.pushes.Check(ctx, writer, n, ups))
169 })
170
171 t.Run("a token revoked during the push is rejected", func(t *testing.T) {
172 t.Parallel()
173 f := newPushFixture(t)
174 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)
175 assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
176 })
177
178 t.Run("a token that expired during the push is rejected", func(t *testing.T) {
179 t.Parallel()
180 f := newPushFixture(t)
181 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)
182 assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
183 })
184
185 t.Run("a store failure fails closed", func(t *testing.T) {
186 t.Parallel()
187 f := newPushFixture(t)
188 boom := errors.New("boom")
189 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)
190 err := f.pushes.Check(ctx, writer, n, ups)
191 require.ErrorIs(t, err, boom)
192 assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")
193 })
194
195 t.Run("a token without write access is rejected", func(t *testing.T) {
196 t.Parallel()
197 f := newPushFixture(t)
198 f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)
199 assert.ErrorIs(t, f.pushes.Check(ctx, reader, n, ups), domain.ErrRejected)
200 })
201
202 t.Run("anonymous is rejected", func(t *testing.T) {
203 t.Parallel()
204 f := newPushFixture(t)
205 assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
206 })
207}
208
209// AC5: a token limited to branch prefixes pushes only under them.
210func TestPushesCheckBranchPrefixes(t *testing.T) {
211 t.Parallel()
212 ctx := context.Background()
213 n := name(t, "tools")
214 limited := domain.TokenPrincipal(domain.Token{
215 ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216 })
217 up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218
219 tests := []struct {
220 name string
221 updates []domain.RefUpdate
222 refused string // the ref named by the error; "" = accepted
223 }{
224 {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225 {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226 {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227 {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228 {"several under the prefixes", []domain.RefUpdate{
229 up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230 }, ""},
231 {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232 {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233 {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234 {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235 {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236 {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237 {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238 {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239 {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240 {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241 up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242 }, "refs/heads/main"},
243 {"mixed push, forbidden first", []domain.RefUpdate{
244 up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245 }, "refs/heads/main"},
246 }
247 for _, tt := range tests {
248 t.Run(tt.name, func(t *testing.T) {
249 t.Parallel()
250 f := newPushFixture(t)
251 f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252 err := f.pushes.Check(ctx, limited, n, tt.updates)
253 if tt.refused == "" {
254 require.NoError(t, err)
255 return
256 }
257 require.ErrorIs(t, err, domain.ErrRejected)
258 assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259 })
260 }
261
262 t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263 t.Parallel()
264 f := newPushFixture(t)
265 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266 free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267 require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268 up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269 }))
270 })
271
272 t.Run("root pushes anywhere", func(t *testing.T) {
273 t.Parallel()
274 f := newPushFixture(t)
275 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276 })
277
278 t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279 t.Parallel()
280 f := newPushFixture(t)
281 f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282 p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283 err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284 require.ErrorIs(t, err, domain.ErrRejected)
285 assert.Contains(t, err.Error(), "no write access")
286 })
287}
288
289func TestPushesRecord(t *testing.T) {
290 t.Parallel()
291 ctx := context.Background()
292 n := name(t, "tools")
293 p := tokenPrincipal(domain.ScopeRepoWrite)
294 ups := []domain.RefUpdate{
295 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"},
296 {Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"},
297 {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"},
298 }
299
300 t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
301 t.Parallel()
302 f := newPushFixture(t)
303 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
304 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
305 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
306 f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
307 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
308 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
309
310 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
311 })
312
313 t.Run("tells the reader that the repository changed", func(t *testing.T) {
314 t.Parallel()
315 f := newPushFixture(t)
316 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
317 fresh := mocks.NewRepoFreshener(t)
318 fresh.EXPECT().Refresh(ctx, n).Return().Once()
319 f.pushes.WithFreshener(fresh)
320 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
321 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
322 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
323 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
324 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
325 })
326
327 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
328 t.Parallel()
329 f := newPushFixture(t)
330 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
331 repo := domain.Repo{Name: n}
332 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
333 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
334 later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
335 f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
336 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
337 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
338 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
339 })
340
341 t.Run("a deleted repository is not an error", func(t *testing.T) {
342 t.Parallel()
343 f := newPushFixture(t)
344 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
345 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
346 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
347 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
348 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
349 })
350
351 t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {
352 t.Parallel()
353 f := newPushFixture(t)
354 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
355 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
356 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
357 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
358 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
359 })
360
361 t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {
362 t.Parallel()
363 f := newPushFixture(t)
364 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
365 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
366 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
367 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
368 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
369 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
370 })
371
372 t.Run("every step runs when one fails", func(t *testing.T) {
373 t.Parallel()
374 f := newPushFixture(t)
375 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
376 boom := errors.New("boom")
377 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
378 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
379 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
380 err := f.pushes.Record(ctx, p, n, ups)
381 assert.ErrorIs(t, err, boom)
382 })
383}
384
385func TestPushesDefaultBranchRule(t *testing.T) {
386 t.Parallel()
387 ctx := context.Background()
388 n := name(t, "tools")
389 p := domain.Root()
390 up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} }
391 del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} }
392
393 tests := []struct {
394 name string
395 head string
396 exists bool
397 updates []domain.RefUpdate
398 want string // "" = HEAD untouched
399 }{
400 {"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""},
401 {"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"},
402 {"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"},
403 {"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"},
404 {"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""},
405 {"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"},
406 {"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""},
407 {"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""},
408 }
409 for _, tt := range tests {
410 t.Run(tt.name, func(t *testing.T) {
411 t.Parallel()
412 f := newPushFixture(t)
413 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
414 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
415 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
416 if tt.want != "" {
417 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
418 }
419 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
420 require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates))
421 })
422 }
423
424 t.Run("set failure is reported", func(t *testing.T) {
425 t.Parallel()
426 f := newPushFixture(t)
427 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
428 boom := errors.New("boom")
429 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
430 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
431 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
432 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
433 assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom)
434 })
435}
436
437// mockAny matches any argument of a mocked call.
438var mockAny = mock.Anything