detached fcf895d

test(cmd,rpc): wait for events with sentinels and long timeouts, and table-test the enum mappers

Negative waits (quiet for 200-300 ms) become sentinel events that must come
next; positive waits are 10 s, except TestAC5PushEventWithinASecond which
keeps its explicit sub-second assertion. Every domain value maps to a
non-UNSPECIFIED value of the contract, and the inverse mappers round-trip.
commit
fcf895d4abf14d79bc3e12317a3342319ab8e2da
author
brice · · 2026-10-10 11:16 UTC
parent
fbcd64e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +205 -56
M cmd/ermite/api_helpers_test.go +0 -13
@@ -162,19 +162,6 @@ func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
162162 return nil
163163 }
164164
165-// quiet fails if an event arrives within d.
166-func (s *stream) quiet(t *testing.T, d time.Duration) {
167- t.Helper()
168- select {
169- case ev, ok := <-s.events:
170- if ok {
171- t.Fatalf("unexpected event: %v", ev)
172- }
173- t.Fatalf("the stream ended: %v", s.err)
174- case <-time.After(d):
175- }
176-}
177-
178165 // readBlob reads a whole GetBlob stream and returns its messages.
179166 func readBlob(ctx context.Context, c *clients, req *pb.GetBlobRequest) ([]*pb.GetBlobResponse, error) {
180167 st, err := c.git.GetBlob(ctx, connect.NewRequest(req))
@@ -162,19 +162,6 @@ func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
162 return nil162 return nil
163 }163 }
164 164
165-// quiet fails if an event arrives within d.
166-func (s *stream) quiet(t *testing.T, d time.Duration) {
167- t.Helper()
168- select {
169- case ev, ok := <-s.events:
170- if ok {
171- t.Fatalf("unexpected event: %v", ev)
172- }
173- t.Fatalf("the stream ended: %v", s.err)
174- case <-time.After(d):
175- }
176-}
177-
178 // readBlob reads a whole GetBlob stream and returns its messages.165 // readBlob reads a whole GetBlob stream and returns its messages.
179 func readBlob(ctx context.Context, c *clients, req *pb.GetBlobRequest) ([]*pb.GetBlobResponse, error) {166 func readBlob(ctx context.Context, c *clients, req *pb.GetBlobRequest) ([]*pb.GetBlobResponse, error) {
180 st, err := c.git.GetBlob(ctx, connect.NewRequest(req))167 st, err := c.git.GetBlob(ctx, connect.NewRequest(req))
M cmd/ermite/api_test.go +1 -1
@@ -815,7 +815,7 @@ func TestInProcessClient(t *testing.T) {
815815 select {
816816 case ev := <-got:
817817 assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription())
818- case <-time.After(5 * time.Second):
818+ case <-time.After(10 * time.Second):
819819 t.Fatal("no event through the in-process client")
820820 }
821821
@@ -815,7 +815,7 @@ func TestInProcessClient(t *testing.T) {
815 select {815 select {
816 case ev := <-got:816 case ev := <-got:
817 assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription())817 assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription())
818- case <-time.After(5 * time.Second):818+ case <-time.After(10 * time.Second):
819 t.Fatal("no event through the in-process client")819 t.Fatal("no event through the in-process client")
820 }820 }
821 821
M cmd/ermite/events_test.go +69 -42
@@ -23,6 +23,10 @@ import (
2323 "bnema.dev/ermite/internal/domain"
2424 )
2525
26+// watchWait is how long a test waits for an event that must arrive. Only
27+// TestAC5PushEventWithinASecond holds the server to a second.
28+const watchWait = 10 * time.Second
29+
2630 // AC5: a push shows up in Watch within a second, with the ref updates.
2731 func TestAC5PushEventWithinASecond(t *testing.T) {
2832 h := newHarness(t, harnessOpts{})
@@ -50,14 +54,14 @@ func TestAC5PushEventWithinASecond(t *testing.T) {
5054 old := up.GetNewId()
5155 h.writeFile(wt, "b.txt", []byte("b\n"))
5256 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
53- up2 := w.next(t, time.Second).GetPushed().GetRefUpdates()[0]
57+ up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
5458 assert.Equal(t, old, up2.GetOldId())
5559 assert.NotEqual(t, old, up2.GetNewId())
5660
5761 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp")
58- w.next(t, time.Second)
62+ w.next(t, watchWait)
5963 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp")
60- del := w.next(t, time.Second).GetPushed().GetRefUpdates()[0]
64+ del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
6165 assert.Equal(t, "refs/heads/tmp", del.GetRef())
6266 assert.NotEmpty(t, del.GetOldId())
6367 assert.Empty(t, del.GetNewId())
@@ -72,7 +76,7 @@ func TestAC5RepoLifecycleEvents(t *testing.T) {
7276
7377 _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC}))
7478 require.NoError(t, err)
75- created := w.next(t, time.Second).GetRepoCreated().GetRepo()
79+ created := w.next(t, watchWait).GetRepoCreated().GetRepo()
7680 require.NotNil(t, created)
7781 assert.Equal(t, "life", created.GetName())
7882 assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility())
@@ -82,20 +86,21 @@ func TestAC5RepoLifecycleEvents(t *testing.T) {
8286 Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
8387 }))
8488 require.NoError(t, err)
85- updated := w.next(t, time.Second).GetRepoUpdated().GetRepo()
89+ updated := w.next(t, watchWait).GetRepoUpdated().GetRepo()
8690 require.NotNil(t, updated)
8791 assert.Equal(t, "new", updated.GetDescription())
8892
8993 _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"}))
9094 require.NoError(t, err)
91- assert.Equal(t, "life", w.next(t, time.Second).GetRepoDeleted().GetRepo())
95+ assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo())
9296
9397 // Create on first push publishes RepoCreated once, then the push.
9498 wt := h.workTree("main", "a.txt")
9599 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main")
96- assert.Equal(t, "fresh", w.next(t, time.Second).GetRepoCreated().GetRepo().GetName())
97- assert.Equal(t, "fresh", w.next(t, time.Second).GetPushed().GetRepo())
98- w.quiet(t, 200*time.Millisecond)
100+ assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
101+ assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo())
102+ // Nothing else came in between: the next event is the sentinel.
103+ h.sentinel(t, w, "s-life")
99104 }
100105
101106 // AC5: resume from an id replays what came after, then goes live.
@@ -112,30 +117,30 @@ func TestAC5Resume(t *testing.T) {
112117 h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
113118 }
114119 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
115- ids = append(ids, w.next(t, 2*time.Second).GetId())
120+ ids = append(ids, w.next(t, watchWait).GetId())
116121 }
117122 w.cancel()
118123
119124 // The client lost its connection after ids[1]: it gets 2 and 3, then live.
120125 r := h.watch(api, ids[1])
121126 for _, want := range ids[2:] {
122- ev := r.next(t, time.Second)
127+ ev := r.next(t, watchWait)
123128 assert.Equal(t, want, ev.GetId())
124129 assert.NotNil(t, ev.GetPushed())
125130 }
126- r.quiet(t, 200*time.Millisecond)
131+ // Nothing was replayed twice: the next event is the live one, below.
127132 h.writeFile(wt, "live.txt", []byte("x\n"))
128133 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
129- live := r.next(t, time.Second)
134+ live := r.next(t, watchWait)
130135 assert.NotNil(t, live.GetPushed())
131136 assert.NotContains(t, ids, live.GetId())
132137
133138 // An id nobody issued is a gap, followed by the oldest buffered event: the
134139 // creation of the repository, which came before the first push.
135140 g := h.watch(api, "not-an-id")
136- assert.NotNil(t, g.next(t, time.Second).GetGap())
137- assert.Equal(t, "pub", g.next(t, time.Second).GetRepoCreated().GetRepo().GetName())
138- assert.Equal(t, ids[0], g.next(t, time.Second).GetId())
141+ assert.NotNil(t, g.next(t, watchWait).GetGap())
142+ assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
143+ assert.Equal(t, ids[0], g.next(t, watchWait).GetId())
139144 }
140145
141146 // AC5: a gap when the id fell out of a small ring.
@@ -152,21 +157,21 @@ func TestAC5GapAfterOverflow(t *testing.T) {
152157 h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
153158 }
154159 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
155- ids = append(ids, w.next(t, 2*time.Second).GetId())
160+ ids = append(ids, w.next(t, watchWait).GetId())
156161 }
157162
158163 // ids[2] is out of the ring (the last four are ids[4:]).
159164 r := h.watch(api, ids[2])
160- gap := r.next(t, time.Second)
165+ gap := r.next(t, watchWait)
161166 assert.NotNil(t, gap.GetGap())
162167 assert.Empty(t, gap.GetId())
163168 for _, want := range ids[4:] {
164- assert.Equal(t, want, r.next(t, time.Second).GetId())
169+ assert.Equal(t, want, r.next(t, watchWait).GetId())
165170 }
166171
167172 // The last buffered id still resumes without a gap.
168173 ok := h.watch(api, ids[4])
169- assert.Equal(t, ids[5], ok.next(t, time.Second).GetId())
174+ assert.Equal(t, ids[5], ok.next(t, watchWait).GetId())
170175 }
171176
172177 // AC5: private events never reach anonymous callers, nor tokens that may not
@@ -198,33 +203,40 @@ func TestAC5NoPrivateEventsToAnonymous(t *testing.T) {
198203 require.NoError(t, err)
199204
200205 // The reader of priv sees its push and its update, and pub's push.
201- assert.Equal(t, "priv", rd.next(t, time.Second).GetPushed().GetRepo())
202- assert.Equal(t, "pub", rd.next(t, time.Second).GetPushed().GetRepo(), "pub is public")
203- assert.Equal(t, "secret plans", rd.next(t, time.Second).GetRepoUpdated().GetRepo().GetDescription())
206+ assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo())
207+ assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public")
208+ assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription())
204209 // Root sees everything in order.
205- assert.Equal(t, "priv", root.next(t, time.Second).GetPushed().GetRepo())
206- assert.Equal(t, "pub", root.next(t, time.Second).GetPushed().GetRepo())
207- assert.Equal(t, "priv", root.next(t, time.Second).GetRepoUpdated().GetRepo().GetName())
210+ assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo())
211+ assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo())
212+ assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName())
208213
209214 // Deleting reaches those who could read the repository before, not others.
210215 _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"}))
211216 require.NoError(t, err)
212- assert.Equal(t, "priv", rd.next(t, time.Second).GetRepoDeleted().GetRepo())
213- assert.Equal(t, "priv", root.next(t, time.Second).GetRepoDeleted().GetRepo())
217+ assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo())
218+ assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo())
214219
215220 for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
216- ev := w.next(t, time.Second)
221+ ev := w.next(t, watchWait)
217222 assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name)
218- w.quiet(t, 300*time.Millisecond)
223+ }
224+ // Nothing about priv came before the sentinel, for either of them.
225+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic})
226+ require.NoError(t, err)
227+ for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
228+ assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name)
219229 }
220230
221231 // Resuming does not replay private events to anonymous either: after a
222- // gap, the creation and the push of pub, nothing about priv.
232+ // gap, the creation and the push of pub, the sentinels the watchers above
233+ // saw, and nothing about priv.
223234 r := h.watch(h.api(""), "bogus")
224- assert.NotNil(t, r.next(t, time.Second).GetGap())
225- assert.Equal(t, "pub", r.next(t, time.Second).GetRepoCreated().GetRepo().GetName())
226- assert.Equal(t, "pub", r.next(t, time.Second).GetPushed().GetRepo())
227- r.quiet(t, 300*time.Millisecond)
235+ assert.NotNil(t, r.next(t, watchWait).GetGap())
236+ assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
237+ assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo())
238+ assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
239+ h.sentinel(t, r, "s-resumed")
228240 }
229241
230242 // AC5: a repo topic only carries that repository; a revoked token cannot
@@ -238,8 +250,11 @@ func TestAC5RepoTopic(t *testing.T) {
238250 wt := h.workTree("main", "x.txt")
239251 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main")
240252 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
241- assert.Equal(t, "a", w.next(t, time.Second).GetPushed().GetRepo())
242- w.quiet(t, 300*time.Millisecond)
253+ assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo())
254+ // The push to b was not delivered: the next event is the next push to a.
255+ h.writeFile(wt, "y.txt", []byte("y\n"))
256+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
257+ assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId())
243258 }
244259
245260 // The stream ends cleanly when the client cancels, and Watch counts go back to zero.
@@ -250,7 +265,7 @@ func TestWatchEndsOnCancel(t *testing.T) {
250265 w.cancel()
251266 select {
252267 case <-w.done:
253- case <-time.After(5 * time.Second):
268+ case <-time.After(5 * watchWait):
254269 t.Fatal("the stream did not end")
255270 }
256271 require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond)
@@ -306,13 +321,13 @@ func TestServeShutdownEndsWatchStreams(t *testing.T) {
306321 select {
307322 case err := <-ended:
308323 assert.NoError(t, err, "the stream ends cleanly")
309- case <-time.After(15 * time.Second):
324+ case <-time.After(15 * watchWait):
310325 t.Fatalf("the stream did not end\n%s", logs.String())
311326 }
312327 select {
313328 case err := <-exited:
314329 require.NoError(t, err, "%s", logs.String())
315- case <-time.After(15 * time.Second):
330+ case <-time.After(15 * watchWait):
316331 t.Fatalf("the server did not exit\n%s", logs.String())
317332 }
318333 assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout")
@@ -332,7 +347,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
332347 w := h.watch(h.api(created.Secret), "")
333348 wt := h.workTree("main", "a.txt")
334349 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
335- assert.Equal(t, "priv", w.next(t, 5*time.Second).GetPushed().GetRepo(), "the token may read priv")
350+ assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv")
336351
337352 require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
338353 h.writeFile(wt, "b.txt", []byte("b\n"))
@@ -340,7 +355,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
340355
341356 select {
342357 case <-w.done:
343- case <-time.After(5 * time.Second):
358+ case <-time.After(5 * watchWait):
344359 t.Fatal("the stream of a revoked token did not end")
345360 }
346361 assert.Equal(t, "unauthenticated", codeName(w.err))
@@ -374,3 +389,15 @@ func TestWatchLimits(t *testing.T) {
374389 require.NoError(t, err)
375390 _ = st.Close()
376391 }
392+
393+// sentinel proves that nothing reached the stream since the last event it
394+// returned: it creates a public repository, which every watcher of "home" may
395+// see, and requires its RepoCreated to be the next event. Events arrive in the
396+// order they were published.
397+func (h *harness) sentinel(t *testing.T, s *stream, name string) {
398+ t.Helper()
399+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
400+ require.NoError(t, err)
401+ ev := s.next(t, watchWait)
402+ assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev)
403+}
@@ -23,6 +23,10 @@ import (
23 "bnema.dev/ermite/internal/domain"23 "bnema.dev/ermite/internal/domain"
24 )24 )
25 25
26+// watchWait is how long a test waits for an event that must arrive. Only
27+// TestAC5PushEventWithinASecond holds the server to a second.
28+const watchWait = 10 * time.Second
29+
26 // AC5: a push shows up in Watch within a second, with the ref updates.30 // AC5: a push shows up in Watch within a second, with the ref updates.
27 func TestAC5PushEventWithinASecond(t *testing.T) {31 func TestAC5PushEventWithinASecond(t *testing.T) {
28 h := newHarness(t, harnessOpts{})32 h := newHarness(t, harnessOpts{})
@@ -50,14 +54,14 @@ func TestAC5PushEventWithinASecond(t *testing.T) {
50 old := up.GetNewId()54 old := up.GetNewId()
51 h.writeFile(wt, "b.txt", []byte("b\n"))55 h.writeFile(wt, "b.txt", []byte("b\n"))
52 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")56 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
53- up2 := w.next(t, time.Second).GetPushed().GetRefUpdates()[0]57+ up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
54 assert.Equal(t, old, up2.GetOldId())58 assert.Equal(t, old, up2.GetOldId())
55 assert.NotEqual(t, old, up2.GetNewId())59 assert.NotEqual(t, old, up2.GetNewId())
56 60
57 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp")61 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp")
58- w.next(t, time.Second)62+ w.next(t, watchWait)
59 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp")63 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp")
60- del := w.next(t, time.Second).GetPushed().GetRefUpdates()[0]64+ del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
61 assert.Equal(t, "refs/heads/tmp", del.GetRef())65 assert.Equal(t, "refs/heads/tmp", del.GetRef())
62 assert.NotEmpty(t, del.GetOldId())66 assert.NotEmpty(t, del.GetOldId())
63 assert.Empty(t, del.GetNewId())67 assert.Empty(t, del.GetNewId())
@@ -72,7 +76,7 @@ func TestAC5RepoLifecycleEvents(t *testing.T) {
72 76
73 _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC}))77 _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC}))
74 require.NoError(t, err)78 require.NoError(t, err)
75- created := w.next(t, time.Second).GetRepoCreated().GetRepo()79+ created := w.next(t, watchWait).GetRepoCreated().GetRepo()
76 require.NotNil(t, created)80 require.NotNil(t, created)
77 assert.Equal(t, "life", created.GetName())81 assert.Equal(t, "life", created.GetName())
78 assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility())82 assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility())
@@ -82,20 +86,21 @@ func TestAC5RepoLifecycleEvents(t *testing.T) {
82 Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},86 Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
83 }))87 }))
84 require.NoError(t, err)88 require.NoError(t, err)
85- updated := w.next(t, time.Second).GetRepoUpdated().GetRepo()89+ updated := w.next(t, watchWait).GetRepoUpdated().GetRepo()
86 require.NotNil(t, updated)90 require.NotNil(t, updated)
87 assert.Equal(t, "new", updated.GetDescription())91 assert.Equal(t, "new", updated.GetDescription())
88 92
89 _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"}))93 _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"}))
90 require.NoError(t, err)94 require.NoError(t, err)
91- assert.Equal(t, "life", w.next(t, time.Second).GetRepoDeleted().GetRepo())95+ assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo())
92 96
93 // Create on first push publishes RepoCreated once, then the push.97 // Create on first push publishes RepoCreated once, then the push.
94 wt := h.workTree("main", "a.txt")98 wt := h.workTree("main", "a.txt")
95 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main")99 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main")
96- assert.Equal(t, "fresh", w.next(t, time.Second).GetRepoCreated().GetRepo().GetName())100+ assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
97- assert.Equal(t, "fresh", w.next(t, time.Second).GetPushed().GetRepo())101+ assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo())
98- w.quiet(t, 200*time.Millisecond)102+ // Nothing else came in between: the next event is the sentinel.
103+ h.sentinel(t, w, "s-life")
99 }104 }
100 105
101 // AC5: resume from an id replays what came after, then goes live.106 // AC5: resume from an id replays what came after, then goes live.
@@ -112,30 +117,30 @@ func TestAC5Resume(t *testing.T) {
112 h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))117 h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
113 }118 }
114 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")119 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
115- ids = append(ids, w.next(t, 2*time.Second).GetId())120+ ids = append(ids, w.next(t, watchWait).GetId())
116 }121 }
117 w.cancel()122 w.cancel()
118 123
119 // The client lost its connection after ids[1]: it gets 2 and 3, then live.124 // The client lost its connection after ids[1]: it gets 2 and 3, then live.
120 r := h.watch(api, ids[1])125 r := h.watch(api, ids[1])
121 for _, want := range ids[2:] {126 for _, want := range ids[2:] {
122- ev := r.next(t, time.Second)127+ ev := r.next(t, watchWait)
123 assert.Equal(t, want, ev.GetId())128 assert.Equal(t, want, ev.GetId())
124 assert.NotNil(t, ev.GetPushed())129 assert.NotNil(t, ev.GetPushed())
125 }130 }
126- r.quiet(t, 200*time.Millisecond)131+ // Nothing was replayed twice: the next event is the live one, below.
127 h.writeFile(wt, "live.txt", []byte("x\n"))132 h.writeFile(wt, "live.txt", []byte("x\n"))
128 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")133 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
129- live := r.next(t, time.Second)134+ live := r.next(t, watchWait)
130 assert.NotNil(t, live.GetPushed())135 assert.NotNil(t, live.GetPushed())
131 assert.NotContains(t, ids, live.GetId())136 assert.NotContains(t, ids, live.GetId())
132 137
133 // An id nobody issued is a gap, followed by the oldest buffered event: the138 // An id nobody issued is a gap, followed by the oldest buffered event: the
134 // creation of the repository, which came before the first push.139 // creation of the repository, which came before the first push.
135 g := h.watch(api, "not-an-id")140 g := h.watch(api, "not-an-id")
136- assert.NotNil(t, g.next(t, time.Second).GetGap())141+ assert.NotNil(t, g.next(t, watchWait).GetGap())
137- assert.Equal(t, "pub", g.next(t, time.Second).GetRepoCreated().GetRepo().GetName())142+ assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
138- assert.Equal(t, ids[0], g.next(t, time.Second).GetId())143+ assert.Equal(t, ids[0], g.next(t, watchWait).GetId())
139 }144 }
140 145
141 // AC5: a gap when the id fell out of a small ring.146 // AC5: a gap when the id fell out of a small ring.
@@ -152,21 +157,21 @@ func TestAC5GapAfterOverflow(t *testing.T) {
152 h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))157 h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
153 }158 }
154 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")159 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
155- ids = append(ids, w.next(t, 2*time.Second).GetId())160+ ids = append(ids, w.next(t, watchWait).GetId())
156 }161 }
157 162
158 // ids[2] is out of the ring (the last four are ids[4:]).163 // ids[2] is out of the ring (the last four are ids[4:]).
159 r := h.watch(api, ids[2])164 r := h.watch(api, ids[2])
160- gap := r.next(t, time.Second)165+ gap := r.next(t, watchWait)
161 assert.NotNil(t, gap.GetGap())166 assert.NotNil(t, gap.GetGap())
162 assert.Empty(t, gap.GetId())167 assert.Empty(t, gap.GetId())
163 for _, want := range ids[4:] {168 for _, want := range ids[4:] {
164- assert.Equal(t, want, r.next(t, time.Second).GetId())169+ assert.Equal(t, want, r.next(t, watchWait).GetId())
165 }170 }
166 171
167 // The last buffered id still resumes without a gap.172 // The last buffered id still resumes without a gap.
168 ok := h.watch(api, ids[4])173 ok := h.watch(api, ids[4])
169- assert.Equal(t, ids[5], ok.next(t, time.Second).GetId())174+ assert.Equal(t, ids[5], ok.next(t, watchWait).GetId())
170 }175 }
171 176
172 // AC5: private events never reach anonymous callers, nor tokens that may not177 // AC5: private events never reach anonymous callers, nor tokens that may not
@@ -198,33 +203,40 @@ func TestAC5NoPrivateEventsToAnonymous(t *testing.T) {
198 require.NoError(t, err)203 require.NoError(t, err)
199 204
200 // The reader of priv sees its push and its update, and pub's push.205 // The reader of priv sees its push and its update, and pub's push.
201- assert.Equal(t, "priv", rd.next(t, time.Second).GetPushed().GetRepo())206+ assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo())
202- assert.Equal(t, "pub", rd.next(t, time.Second).GetPushed().GetRepo(), "pub is public")207+ assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public")
203- assert.Equal(t, "secret plans", rd.next(t, time.Second).GetRepoUpdated().GetRepo().GetDescription())208+ assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription())
204 // Root sees everything in order.209 // Root sees everything in order.
205- assert.Equal(t, "priv", root.next(t, time.Second).GetPushed().GetRepo())210+ assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo())
206- assert.Equal(t, "pub", root.next(t, time.Second).GetPushed().GetRepo())211+ assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo())
207- assert.Equal(t, "priv", root.next(t, time.Second).GetRepoUpdated().GetRepo().GetName())212+ assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName())
208 213
209 // Deleting reaches those who could read the repository before, not others.214 // Deleting reaches those who could read the repository before, not others.
210 _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"}))215 _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"}))
211 require.NoError(t, err)216 require.NoError(t, err)
212- assert.Equal(t, "priv", rd.next(t, time.Second).GetRepoDeleted().GetRepo())217+ assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo())
213- assert.Equal(t, "priv", root.next(t, time.Second).GetRepoDeleted().GetRepo())218+ assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo())
214 219
215 for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {220 for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
216- ev := w.next(t, time.Second)221+ ev := w.next(t, watchWait)
217 assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name)222 assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name)
218- w.quiet(t, 300*time.Millisecond)223+ }
224+ // Nothing about priv came before the sentinel, for either of them.
225+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic})
226+ require.NoError(t, err)
227+ for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
228+ assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name)
219 }229 }
220 230
221 // Resuming does not replay private events to anonymous either: after a231 // Resuming does not replay private events to anonymous either: after a
222- // gap, the creation and the push of pub, nothing about priv.232+ // gap, the creation and the push of pub, the sentinels the watchers above
233+ // saw, and nothing about priv.
223 r := h.watch(h.api(""), "bogus")234 r := h.watch(h.api(""), "bogus")
224- assert.NotNil(t, r.next(t, time.Second).GetGap())235+ assert.NotNil(t, r.next(t, watchWait).GetGap())
225- assert.Equal(t, "pub", r.next(t, time.Second).GetRepoCreated().GetRepo().GetName())236+ assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
226- assert.Equal(t, "pub", r.next(t, time.Second).GetPushed().GetRepo())237+ assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo())
227- r.quiet(t, 300*time.Millisecond)238+ assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
239+ h.sentinel(t, r, "s-resumed")
228 }240 }
229 241
230 // AC5: a repo topic only carries that repository; a revoked token cannot242 // AC5: a repo topic only carries that repository; a revoked token cannot
@@ -238,8 +250,11 @@ func TestAC5RepoTopic(t *testing.T) {
238 wt := h.workTree("main", "x.txt")250 wt := h.workTree("main", "x.txt")
239 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main")251 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main")
240 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")252 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
241- assert.Equal(t, "a", w.next(t, time.Second).GetPushed().GetRepo())253+ assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo())
242- w.quiet(t, 300*time.Millisecond)254+ // The push to b was not delivered: the next event is the next push to a.
255+ h.writeFile(wt, "y.txt", []byte("y\n"))
256+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
257+ assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId())
243 }258 }
244 259
245 // The stream ends cleanly when the client cancels, and Watch counts go back to zero.260 // The stream ends cleanly when the client cancels, and Watch counts go back to zero.
@@ -250,7 +265,7 @@ func TestWatchEndsOnCancel(t *testing.T) {
250 w.cancel()265 w.cancel()
251 select {266 select {
252 case <-w.done:267 case <-w.done:
253- case <-time.After(5 * time.Second):268+ case <-time.After(5 * watchWait):
254 t.Fatal("the stream did not end")269 t.Fatal("the stream did not end")
255 }270 }
256 require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond)271 require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond)
@@ -306,13 +321,13 @@ func TestServeShutdownEndsWatchStreams(t *testing.T) {
306 select {321 select {
307 case err := <-ended:322 case err := <-ended:
308 assert.NoError(t, err, "the stream ends cleanly")323 assert.NoError(t, err, "the stream ends cleanly")
309- case <-time.After(15 * time.Second):324+ case <-time.After(15 * watchWait):
310 t.Fatalf("the stream did not end\n%s", logs.String())325 t.Fatalf("the stream did not end\n%s", logs.String())
311 }326 }
312 select {327 select {
313 case err := <-exited:328 case err := <-exited:
314 require.NoError(t, err, "%s", logs.String())329 require.NoError(t, err, "%s", logs.String())
315- case <-time.After(15 * time.Second):330+ case <-time.After(15 * watchWait):
316 t.Fatalf("the server did not exit\n%s", logs.String())331 t.Fatalf("the server did not exit\n%s", logs.String())
317 }332 }
318 assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout")333 assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout")
@@ -332,7 +347,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
332 w := h.watch(h.api(created.Secret), "")347 w := h.watch(h.api(created.Secret), "")
333 wt := h.workTree("main", "a.txt")348 wt := h.workTree("main", "a.txt")
334 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")349 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
335- assert.Equal(t, "priv", w.next(t, 5*time.Second).GetPushed().GetRepo(), "the token may read priv")350+ assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv")
336 351
337 require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))352 require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
338 h.writeFile(wt, "b.txt", []byte("b\n"))353 h.writeFile(wt, "b.txt", []byte("b\n"))
@@ -340,7 +355,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
340 355
341 select {356 select {
342 case <-w.done:357 case <-w.done:
343- case <-time.After(5 * time.Second):358+ case <-time.After(5 * watchWait):
344 t.Fatal("the stream of a revoked token did not end")359 t.Fatal("the stream of a revoked token did not end")
345 }360 }
346 assert.Equal(t, "unauthenticated", codeName(w.err))361 assert.Equal(t, "unauthenticated", codeName(w.err))
@@ -374,3 +389,15 @@ func TestWatchLimits(t *testing.T) {
374 require.NoError(t, err)389 require.NoError(t, err)
375 _ = st.Close()390 _ = st.Close()
376 }391 }
392+
393+// sentinel proves that nothing reached the stream since the last event it
394+// returned: it creates a public repository, which every watcher of "home" may
395+// see, and requires its RepoCreated to be the next event. Events arrive in the
396+// order they were published.
397+func (h *harness) sentinel(t *testing.T, s *stream, name string) {
398+ t.Helper()
399+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
400+ require.NoError(t, err)
401+ ev := s.next(t, watchWait)
402+ assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev)
403+}
A internal/adapters/rpc/convert_test.go +135 -0
@@ -0,0 +1,135 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// Every value of the domain maps to a real value of the contract, never to
16+// UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
17+// to the domain and forgotten here would reach clients as "not set".
18+func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
19+ t.Run("visibility", func(t *testing.T) {
20+ for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
21+ got := visibilityToPB(v)
22+ assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
23+ back, err := visibilityFromPB(got)
24+ require.NoError(t, err, v)
25+ assert.Equal(t, v, back)
26+ }
27+ assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
28+ _, err := visibilityFromPB(pb.Visibility(99))
29+ assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
30+ })
31+
32+ t.Run("scope", func(t *testing.T) {
33+ for _, s := range domain.AllScopes() {
34+ got, ok := scopeToPB[s]
35+ require.True(t, ok, "scope %s has no counterpart in the contract", s)
36+ assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
37+ back, err := scopeFromPB(got)
38+ require.NoError(t, err, s)
39+ assert.Equal(t, s, back)
40+ }
41+ _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
42+ assert.ErrorIs(t, err, domain.ErrInvalidScope)
43+ // Every scope of the contract is a scope of the domain.
44+ for v := range pb.Scope_name {
45+ if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
46+ continue
47+ }
48+ _, err := scopeFromPB(pb.Scope(v))
49+ assert.NoError(t, err, pb.Scope(v))
50+ }
51+ })
52+
53+ t.Run("ref kind", func(t *testing.T) {
54+ for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
55+ got := refKindToPB(k)
56+ assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
57+ back, err := refKindFromPB(got)
58+ require.NoError(t, err, k)
59+ assert.Equal(t, k, back)
60+ }
61+ _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
62+ assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
63+ })
64+
65+ t.Run("entry kind", func(t *testing.T) {
66+ seen := map[pb.EntryKind]domain.EntryKind{}
67+ for _, k := range []domain.EntryKind{
68+ domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
69+ } {
70+ got := entryKindToPB(k)
71+ assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
72+ assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
73+ seen[got] = k
74+ }
75+ })
76+
77+ t.Run("file status", func(t *testing.T) {
78+ seen := map[pb.FileStatus]domain.FileStatus{}
79+ for _, s := range []domain.FileStatus{
80+ domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
81+ } {
82+ got := fileStatusToPB(s)
83+ assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
84+ assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
85+ seen[got] = s
86+ }
87+ })
88+
89+ t.Run("line kind", func(t *testing.T) {
90+ seen := map[pb.LineKind]domain.LineKind{}
91+ for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
92+ got := lineKindToPB(k)
93+ assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
94+ assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
95+ seen[got] = k
96+ }
97+ })
98+
99+ // Every value of the contract except UNSPECIFIED is produced by some value
100+ // of the domain: nothing in the contract is unreachable.
101+ t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
102+ produced := func(names map[int32]string, from func(v int32) bool) {
103+ for v, n := range names {
104+ if v == 0 {
105+ continue
106+ }
107+ assert.True(t, from(v), "%s is never produced", n)
108+ }
109+ }
110+ produced(pb.EntryKind_name, func(v int32) bool {
111+ for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
112+ if entryKindToPB(k) == pb.EntryKind(v) {
113+ return true
114+ }
115+ }
116+ return false
117+ })
118+ produced(pb.FileStatus_name, func(v int32) bool {
119+ for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
120+ if fileStatusToPB(k) == pb.FileStatus(v) {
121+ return true
122+ }
123+ }
124+ return false
125+ })
126+ produced(pb.LineKind_name, func(v int32) bool {
127+ for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
128+ if lineKindToPB(k) == pb.LineKind(v) {
129+ return true
130+ }
131+ }
132+ return false
133+ })
134+ })
135+}
@@ -0,0 +1,135 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ pb "bnema.dev/ermite-api/gen/ermite"
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// Every value of the domain maps to a real value of the contract, never to
16+// UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
17+// to the domain and forgotten here would reach clients as "not set".
18+func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
19+ t.Run("visibility", func(t *testing.T) {
20+ for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
21+ got := visibilityToPB(v)
22+ assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
23+ back, err := visibilityFromPB(got)
24+ require.NoError(t, err, v)
25+ assert.Equal(t, v, back)
26+ }
27+ assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
28+ _, err := visibilityFromPB(pb.Visibility(99))
29+ assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
30+ })
31+
32+ t.Run("scope", func(t *testing.T) {
33+ for _, s := range domain.AllScopes() {
34+ got, ok := scopeToPB[s]
35+ require.True(t, ok, "scope %s has no counterpart in the contract", s)
36+ assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
37+ back, err := scopeFromPB(got)
38+ require.NoError(t, err, s)
39+ assert.Equal(t, s, back)
40+ }
41+ _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
42+ assert.ErrorIs(t, err, domain.ErrInvalidScope)
43+ // Every scope of the contract is a scope of the domain.
44+ for v := range pb.Scope_name {
45+ if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
46+ continue
47+ }
48+ _, err := scopeFromPB(pb.Scope(v))
49+ assert.NoError(t, err, pb.Scope(v))
50+ }
51+ })
52+
53+ t.Run("ref kind", func(t *testing.T) {
54+ for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
55+ got := refKindToPB(k)
56+ assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
57+ back, err := refKindFromPB(got)
58+ require.NoError(t, err, k)
59+ assert.Equal(t, k, back)
60+ }
61+ _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
62+ assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
63+ })
64+
65+ t.Run("entry kind", func(t *testing.T) {
66+ seen := map[pb.EntryKind]domain.EntryKind{}
67+ for _, k := range []domain.EntryKind{
68+ domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
69+ } {
70+ got := entryKindToPB(k)
71+ assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
72+ assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
73+ seen[got] = k
74+ }
75+ })
76+
77+ t.Run("file status", func(t *testing.T) {
78+ seen := map[pb.FileStatus]domain.FileStatus{}
79+ for _, s := range []domain.FileStatus{
80+ domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
81+ } {
82+ got := fileStatusToPB(s)
83+ assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
84+ assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
85+ seen[got] = s
86+ }
87+ })
88+
89+ t.Run("line kind", func(t *testing.T) {
90+ seen := map[pb.LineKind]domain.LineKind{}
91+ for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
92+ got := lineKindToPB(k)
93+ assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
94+ assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
95+ seen[got] = k
96+ }
97+ })
98+
99+ // Every value of the contract except UNSPECIFIED is produced by some value
100+ // of the domain: nothing in the contract is unreachable.
101+ t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
102+ produced := func(names map[int32]string, from func(v int32) bool) {
103+ for v, n := range names {
104+ if v == 0 {
105+ continue
106+ }
107+ assert.True(t, from(v), "%s is never produced", n)
108+ }
109+ }
110+ produced(pb.EntryKind_name, func(v int32) bool {
111+ for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
112+ if entryKindToPB(k) == pb.EntryKind(v) {
113+ return true
114+ }
115+ }
116+ return false
117+ })
118+ produced(pb.FileStatus_name, func(v int32) bool {
119+ for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
120+ if fileStatusToPB(k) == pb.FileStatus(v) {
121+ return true
122+ }
123+ }
124+ return false
125+ })
126+ produced(pb.LineKind_name, func(v int32) bool {
127+ for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
128+ if lineKindToPB(k) == pb.LineKind(v) {
129+ return true
130+ }
131+ }
132+ return false
133+ })
134+ })
135+}

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