detached 4fbe895

test: replace hand-written fakes with mockery mocks and map hexcheck roles

- mockery covers ports, githttp and app consumer interfaces with all: true
- app and githttp tests use generated mocks instead of 10 hand-written fakes
- token labels and repo list CLI tests are table-driven
- hexcheck maps clientlimit and readertest and enables recommended rules
commit
4fbe895af54e605fa8f842ac9ba3315b70d444b0
author
brice · · 2026-10-11 18:43 UTC
parent
c33e43c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +1278 -372
M .hexcheck.yaml +34 -0
@@ -16,6 +16,11 @@ components:
1616 role: adapter
1717 paths:
1818 - internal/adapters/**
19+ clientlimit:
20+ # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters.
21+ role: adapter
22+ paths:
23+ - internal/clientlimit/**
1924 entrypoints:
2025 role: entrypoint
2126 paths:
@@ -30,8 +35,37 @@ components:
3035 - gen/**
3136 - '**/mocks/**'
3237 - '**/*_templ.go'
38+ readertest:
39+ # Test-only fixtures and conformance suite for ports.RepoReader.
40+ role: ignore
41+ paths:
42+ - internal/readertest/**
43+rules:
44+ no-adapter-imports-in-core: error
45+ no-infra-imports-in-usecase: error
46+ no-framework-types-in-core: error
47+ no-infra-types-in-ports: error
48+ no-adapter-to-adapter-imports: warn
49+ suspicious-business-logic-in-adapter: warn
50+ no-local-fakes-for-ports: warn
51+ missing-generated-mock-for-port: warn
52+ prefer-generated-mocks: warn
3353 mocking:
3454 generatedMockPaths:
3555 - internal/ports/mocks/**
56+ - internal/app/mocks/**
57+ - internal/adapters/*/mocks/**
3658 generatedMockNamePatterns:
3759 - '{{Interface}}'
60+allow:
61+ # clientlimit is a shared infrastructure library, not a sibling adapter: the
62+ # HTTP adapters that rate-limit clients import it by design.
63+ - rule: no-adapter-to-adapter-imports
64+ path: internal/adapters/githttp/**
65+ reason: rate-limits Git clients with internal/clientlimit
66+ - rule: no-adapter-to-adapter-imports
67+ path: internal/adapters/rpc/**
68+ reason: rate-limits API clients with internal/clientlimit
69+ - rule: no-adapter-to-adapter-imports
70+ path: internal/adapters/web/**
71+ reason: rate-limits login attempts with internal/clientlimit
@@ -16,6 +16,11 @@ components:
16 role: adapter16 role: adapter
17 paths:17 paths:
18 - internal/adapters/**18 - internal/adapters/**
19+ clientlimit:
20+ # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters.
21+ role: adapter
22+ paths:
23+ - internal/clientlimit/**
19 entrypoints:24 entrypoints:
20 role: entrypoint25 role: entrypoint
21 paths:26 paths:
@@ -30,8 +35,37 @@ components:
30 - gen/**35 - gen/**
31 - '**/mocks/**'36 - '**/mocks/**'
32 - '**/*_templ.go'37 - '**/*_templ.go'
38+ readertest:
39+ # Test-only fixtures and conformance suite for ports.RepoReader.
40+ role: ignore
41+ paths:
42+ - internal/readertest/**
43+rules:
44+ no-adapter-imports-in-core: error
45+ no-infra-imports-in-usecase: error
46+ no-framework-types-in-core: error
47+ no-infra-types-in-ports: error
48+ no-adapter-to-adapter-imports: warn
49+ suspicious-business-logic-in-adapter: warn
50+ no-local-fakes-for-ports: warn
51+ missing-generated-mock-for-port: warn
52+ prefer-generated-mocks: warn
33 mocking:53 mocking:
34 generatedMockPaths:54 generatedMockPaths:
35 - internal/ports/mocks/**55 - internal/ports/mocks/**
56+ - internal/app/mocks/**
57+ - internal/adapters/*/mocks/**
36 generatedMockNamePatterns:58 generatedMockNamePatterns:
37 - '{{Interface}}'59 - '{{Interface}}'
60+allow:
61+ # clientlimit is a shared infrastructure library, not a sibling adapter: the
62+ # HTTP adapters that rate-limit clients import it by design.
63+ - rule: no-adapter-to-adapter-imports
64+ path: internal/adapters/githttp/**
65+ reason: rate-limits Git clients with internal/clientlimit
66+ - rule: no-adapter-to-adapter-imports
67+ path: internal/adapters/rpc/**
68+ reason: rate-limits API clients with internal/clientlimit
69+ - rule: no-adapter-to-adapter-imports
70+ path: internal/adapters/web/**
71+ reason: rate-limits login attempts with internal/clientlimit
M .mockery.yml +14 -19
@@ -1,22 +1,17 @@
1-# Mocks of the ports. Regenerate with: go generate ./internal/ports
1+# Mocks of the ports and of the consumer-side interfaces of the adapters and
2+# the use cases. Regenerate with: go generate ./internal/ports
23 template: testify
4+_anchors:
5+ mocks: &mocks
6+ dir: "{{.InterfaceDir}}/mocks"
7+ pkgname: mocks
8+ filename: "{{.InterfaceName}}.go"
9+ structname: "{{.InterfaceName}}"
10+ all: true
311 packages:
412 bnema.dev/ermite/internal/ports:
5- config:
6- dir: "{{.InterfaceDir}}/mocks"
7- pkgname: mocks
8- filename: "{{.InterfaceName}}.go"
9- structname: "{{.InterfaceName}}"
10- interfaces:
11- RepoMetaStore:
12- GitRepos:
13- Clock:
14- TokenStore:
15- SessionStore:
16- GitBackend:
17- EventBus:
18- RepoReader:
19- MergeRequestStore:
20- MergeGit:
21- CommitSigner:
22- RepoFreshener:
13+ config: *mocks
14+ bnema.dev/ermite/internal/adapters/githttp:
15+ config: *mocks
16+ bnema.dev/ermite/internal/app:
17+ config: *mocks
@@ -1,22 +1,17 @@
1-# Mocks of the ports. Regenerate with: go generate ./internal/ports1+# Mocks of the ports and of the consumer-side interfaces of the adapters and
2+# the use cases. Regenerate with: go generate ./internal/ports
2 template: testify3 template: testify
4+_anchors:
5+ mocks: &mocks
6+ dir: "{{.InterfaceDir}}/mocks"
7+ pkgname: mocks
8+ filename: "{{.InterfaceName}}.go"
9+ structname: "{{.InterfaceName}}"
10+ all: true
3 packages:11 packages:
4 bnema.dev/ermite/internal/ports:12 bnema.dev/ermite/internal/ports:
5- config:13+ config: *mocks
6- dir: "{{.InterfaceDir}}/mocks"14+ bnema.dev/ermite/internal/adapters/githttp:
7- pkgname: mocks15+ config: *mocks
8- filename: "{{.InterfaceName}}.go"16+ bnema.dev/ermite/internal/app:
9- structname: "{{.InterfaceName}}"17+ config: *mocks
10- interfaces:
11- RepoMetaStore:
12- GitRepos:
13- Clock:
14- TokenStore:
15- SessionStore:
16- GitBackend:
17- EventBus:
18- RepoReader:
19- MergeRequestStore:
20- MergeGit:
21- CommitSigner:
22- RepoFreshener:
M cmd/ermite/cli_repo_test.go +49 -35
@@ -34,41 +34,55 @@ func envFor(url string) map[string]string {
3434 return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
3535 }
3636
37-func TestRepoListTable(t *testing.T) {
38- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
39- return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
40- }}
41- res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
42- assert.Equal(t, ""+
43- "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+
44- "alpha public main 2026-03-04 05:06 The first one\n"+
45- "beta private trunk never \n", res.stdout)
46- assert.Empty(t, res.stderr)
47- assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
48- assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
49-}
50-
51-func TestRepoListJSON(t *testing.T) {
52- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
53- return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
54- }}
55- res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
56- assert.JSONEq(t, `[
57- {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
59- ]`, res.stdout)
60-}
61-
62-func TestRepoListEmpty(t *testing.T) {
63- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
64- return &ermite.ListReposResponse{}, nil
65- }}
66- url := f.serve(t)
67- res := runCLI(t, envFor(url), "", "repo", "list").ok(t)
68- assert.Empty(t, res.stdout)
69- assert.Equal(t, "ermite: no repositories\n", res.stderr)
70- res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t)
71- assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null")
37+func TestRepoList(t *testing.T) {
38+ both := []*ermite.Repo{repoAlpha, repoBeta}
39+ tests := []struct {
40+ name string
41+ repos []*ermite.Repo
42+ args []string
43+ wantStdout string
44+ wantJSON bool // compare stdout as JSON rather than byte for byte
45+ wantStderr string
46+ }{
47+ {
48+ name: "table", repos: both, args: []string{"repo", "list"},
49+ wantStdout: "" +
50+ "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" +
51+ "alpha public main 2026-03-04 05:06 The first one\n" +
52+ "beta private trunk never \n",
53+ },
54+ {
55+ name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56+ wantStdout: `[
57+ {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58+ {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
59+ ]`,
60+ },
61+ {
62+ name: "empty, table", args: []string{"repo", "list"},
63+ wantStderr: "ermite: no repositories\n",
64+ },
65+ {
66+ name: "empty, json is [] not null", args: []string{"repo", "list", "--json"},
67+ wantStdout: "[]\n",
68+ },
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
73+ return &ermite.ListReposResponse{Repos: tt.repos}, nil
74+ }}
75+ res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t)
76+ if tt.wantJSON {
77+ assert.JSONEq(t, tt.wantStdout, res.stdout)
78+ } else {
79+ assert.Equal(t, tt.wantStdout, res.stdout)
80+ }
81+ assert.Equal(t, tt.wantStderr, res.stderr)
82+ assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
83+ assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
84+ })
85+ }
7286 }
7387
7488 func TestRepoListFollowsPages(t *testing.T) {
@@ -34,41 +34,55 @@ func envFor(url string) map[string]string {
34 return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}34 return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
35 }35 }
36 36
37-func TestRepoListTable(t *testing.T) {37+func TestRepoList(t *testing.T) {
38- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {38+ both := []*ermite.Repo{repoAlpha, repoBeta}
39- return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil39+ tests := []struct {
40- }}40+ name string
41- res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)41+ repos []*ermite.Repo
42- assert.Equal(t, ""+42+ args []string
43- "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+43+ wantStdout string
44- "alpha public main 2026-03-04 05:06 The first one\n"+44+ wantJSON bool // compare stdout as JSON rather than byte for byte
45- "beta private trunk never \n", res.stdout)45+ wantStderr string
46- assert.Empty(t, res.stderr)46+ }{
47- assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))47+ {
48- assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))48+ name: "table", repos: both, args: []string{"repo", "list"},
49-}49+ wantStdout: "" +
50-50+ "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" +
51-func TestRepoListJSON(t *testing.T) {51+ "alpha public main 2026-03-04 05:06 The first one\n" +
52- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {52+ "beta private trunk never \n",
53- return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil53+ },
54- }}54+ {
55- res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)55+ name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56- assert.JSONEq(t, `[56+ wantStdout: `[
57- {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},57+ {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}58+ {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
59- ]`, res.stdout)59+ ]`,
60-}60+ },
61-61+ {
62-func TestRepoListEmpty(t *testing.T) {62+ name: "empty, table", args: []string{"repo", "list"},
63- f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {63+ wantStderr: "ermite: no repositories\n",
64- return &ermite.ListReposResponse{}, nil64+ },
65- }}65+ {
66- url := f.serve(t)66+ name: "empty, json is [] not null", args: []string{"repo", "list", "--json"},
67- res := runCLI(t, envFor(url), "", "repo", "list").ok(t)67+ wantStdout: "[]\n",
68- assert.Empty(t, res.stdout)68+ },
69- assert.Equal(t, "ermite: no repositories\n", res.stderr)69+ }
70- res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t)70+ for _, tt := range tests {
71- assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null")71+ t.Run(tt.name, func(t *testing.T) {
72+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
73+ return &ermite.ListReposResponse{Repos: tt.repos}, nil
74+ }}
75+ res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t)
76+ if tt.wantJSON {
77+ assert.JSONEq(t, tt.wantStdout, res.stdout)
78+ } else {
79+ assert.Equal(t, tt.wantStdout, res.stdout)
80+ }
81+ assert.Equal(t, tt.wantStderr, res.stderr)
82+ assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
83+ assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
84+ })
85+ }
72 }86 }
73 87
74 func TestRepoListFollowsPages(t *testing.T) {88 func TestRepoListFollowsPages(t *testing.T) {
M internal/adapters/githttp/handler_test.go +114 -85
@@ -20,74 +20,13 @@ import (
2020 "github.com/stretchr/testify/require"
2121
2222 "bnema.dev/ermite/internal/adapters/githttp"
23+ ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks"
2324 "bnema.dev/ermite/internal/clientlimit"
2425 "bnema.dev/ermite/internal/domain"
2526 "bnema.dev/ermite/internal/ports"
2627 "bnema.dev/ermite/internal/ports/mocks"
2728 )
2829
29-type fakeAuth struct {
30- mu sync.Mutex
31- seen []string
32-}
33-
34-func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) {
35- f.mu.Lock()
36- f.seen = append(f.seen, secret)
37- f.mu.Unlock()
38- switch secret {
39- case "root-secret":
40- return domain.Root(), nil
41- case "reader-secret":
42- return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
43- }
44- return domain.Principal{}, domain.ErrUnauthenticated
45-}
46-
47-type fakeRepos struct{}
48-
49-func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
50- switch n.String() {
51- case "pub":
52- return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
53- case "priv":
54- if p.Allows(domain.ScopeRepoRead, n) {
55- return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
56- }
57- }
58- return domain.Repo{}, domain.ErrNotFound
59-}
60-
61-type fakePushes struct {
62- mu sync.Mutex
63- calls []bool // the create flag of each Open
64- err error
65-}
66-
67-func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- f.calls = append(f.calls, create)
71- if p.IsAnonymous() {
72- return domain.ErrUnauthenticated
73- }
74- return f.err
75-}
76-
77-type fakeTokens struct {
78- mu sync.Mutex
79- released int
80- repo domain.RepoName
81-}
82-
83-func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) {
84- f.mu.Lock()
85- defer f.mu.Unlock()
86- f.repo = r
87- return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() }
88-}
89-func (f *fakeTokens) Socket() string { return "/run/hook.sock" }
90-
9130 // cgiScript is a stand-in for git-http-backend: it answers with the
9231 // environment and the body it received. Like Git, it reads the whole request
9332 // before it answers (net/http cannot read a request body once the response
@@ -103,20 +42,115 @@ func cgiScript(t *testing.T) string {
10342 type rig struct {
10443 h *githttp.Handler
10544 srv *httptest.Server
106- pushes *fakePushes
45+ pushes *ghmocks.Pushes
10746 backend *mocks.GitBackend
108- hooks *fakeTokens
109- auth *fakeAuth
47+ hooks *ghmocks.HookTokens
48+ auth *ghmocks.Authenticator
11049
11150 pushFlags []bool
11251 backendErr error
52+
53+ mu sync.Mutex // guards what the mocks record while the server runs
54+ pushErr error // what Pushes.Open answers a named caller
55+ pushCalls []bool // the create flag of each Pushes.Open
56+ authCalls int // Authenticator.Authenticate calls
57+ released int // hook tokens released
58+ hookRepo domain.RepoName
59+}
60+
61+func (r *rig) setPushErr(err error) {
62+ r.mu.Lock()
63+ defer r.mu.Unlock()
64+ r.pushErr = err
65+}
66+
67+func (r *rig) takePushCalls() []bool {
68+ r.mu.Lock()
69+ defer r.mu.Unlock()
70+ c := r.pushCalls
71+ r.pushCalls = nil
72+ return c
73+}
74+
75+func (r *rig) authenticateCalls() int {
76+ r.mu.Lock()
77+ defer r.mu.Unlock()
78+ return r.authCalls
79+}
80+
81+func (r *rig) releasedTokens() int {
82+ r.mu.Lock()
83+ defer r.mu.Unlock()
84+ return r.released
85+}
86+
87+func (r *rig) hookedRepo() domain.RepoName {
88+ r.mu.Lock()
89+ defer r.mu.Unlock()
90+ return r.hookRepo
91+}
92+
93+// stubUseCases answers like the use cases behind the handler: a root and a
94+// reader token, a public repository "pub" and a private one "priv".
95+func (r *rig) stubUseCases(repos *ghmocks.Repos) {
96+ r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn(
97+ func(_ context.Context, secret string) (domain.Principal, error) {
98+ r.mu.Lock()
99+ r.authCalls++
100+ r.mu.Unlock()
101+ switch secret {
102+ case "root-secret":
103+ return domain.Root(), nil
104+ case "reader-secret":
105+ return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
106+ }
107+ return domain.Principal{}, domain.ErrUnauthenticated
108+ }).Maybe()
109+
110+ repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
111+ func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
112+ switch n.String() {
113+ case "pub":
114+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
115+ case "priv":
116+ if p.Allows(domain.ScopeRepoRead, n) {
117+ return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
118+ }
119+ }
120+ return domain.Repo{}, domain.ErrNotFound
121+ }).Maybe()
122+
123+ r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
124+ func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
125+ r.mu.Lock()
126+ defer r.mu.Unlock()
127+ r.pushCalls = append(r.pushCalls, create)
128+ if p.IsAnonymous() {
129+ return domain.ErrUnauthenticated
130+ }
131+ return r.pushErr
132+ }).Maybe()
133+
134+ r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
135+ func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) {
136+ r.mu.Lock()
137+ defer r.mu.Unlock()
138+ r.hookRepo = n
139+ return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() }
140+ }).Maybe()
141+ r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe()
113142 }
114143
115144 func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
116145 t.Helper()
117- r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}}
146+ r := &rig{
147+ pushes: ghmocks.NewPushes(t), backend: mocks.NewGitBackend(t),
148+ hooks: ghmocks.NewHookTokens(t), auth: ghmocks.NewAuthenticator(t),
149+ }
150+ repos := ghmocks.NewRepos(t)
151+ r.stubUseCases(repos)
118152 cfg := githttp.Config{
119- Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
153+ Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
120154 }
121155 script := cgiScript(t)
122156 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -222,25 +256,24 @@ func TestHandlerPushAuthorizationAnswers(t *testing.T) {
222256 return code
223257 }
224258 assert.Equal(t, 200, get())
225- r.pushes.err = domain.ErrForbidden
259+ r.setPushErr(domain.ErrForbidden)
226260 assert.Equal(t, 403, get())
227- r.pushes.err = domain.ErrNotFound
261+ r.setPushErr(domain.ErrNotFound)
228262 assert.Equal(t, 404, get())
229- r.pushes.err = domain.ErrUnauthenticated
263+ r.setPushErr(domain.ErrUnauthenticated)
230264 assert.Equal(t, 401, get())
231- r.pushes.err = errBoom{}
265+ r.setPushErr(errBoom{})
232266 assert.Equal(t, 500, get())
233267
234268 // Only the first request of a push may create.
235- r.pushes.err = nil
236- r.pushes.calls = nil
269+ r.setPushErr(nil)
270+ r.takePushCalls()
237271 r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
238272 r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})
239- assert.Equal(t, []bool{true, false}, r.pushes.calls)
273+ assert.Equal(t, []bool{true, false}, r.takePushCalls())
240274 // Reads never reach the push use case.
241- r.pushes.calls = nil
242275 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
243- assert.Empty(t, r.pushes.calls)
276+ assert.Empty(t, r.takePushCalls())
244277 }
245278
246279 type errBoom struct{}
@@ -295,12 +328,12 @@ func TestHandlerBackendEnvironment(t *testing.T) {
295328 assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks")
296329 assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN")
297330 assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR")
298- assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends")
299- assert.Equal(t, "pub", r.hooks.repo.String())
331+ assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends")
332+ assert.Equal(t, "pub", r.hookedRepo().String())
300333
301334 // The info/refs advertisement of a push does not register a hook token.
302335 r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
303- assert.Equal(t, 1, r.hooks.released)
336+ assert.Equal(t, 1, r.releasedTokens())
304337 }
305338
306339 func TestHandlerNormalizesRepositoryPath(t *testing.T) {
@@ -420,13 +453,9 @@ func TestHandlerLimiter(t *testing.T) {
420453 assert.Equal(t, 429, code)
421454 assert.NotEmpty(t, hdr.Get("Retry-After"))
422455 // A blocked request does not even reach the authenticator.
423- r.auth.mu.Lock()
424- n := len(r.auth.seen)
425- r.auth.mu.Unlock()
456+ n := r.authenticateCalls()
426457 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
427- r.auth.mu.Lock()
428- assert.Equal(t, n, len(r.auth.seen))
429- r.auth.mu.Unlock()
458+ assert.Equal(t, n, r.authenticateCalls())
430459 }
431460
432461 func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
@@ -20,74 +20,13 @@ import (
20 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
21 21
22 "bnema.dev/ermite/internal/adapters/githttp"22 "bnema.dev/ermite/internal/adapters/githttp"
23+ ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks"
23 "bnema.dev/ermite/internal/clientlimit"24 "bnema.dev/ermite/internal/clientlimit"
24 "bnema.dev/ermite/internal/domain"25 "bnema.dev/ermite/internal/domain"
25 "bnema.dev/ermite/internal/ports"26 "bnema.dev/ermite/internal/ports"
26 "bnema.dev/ermite/internal/ports/mocks"27 "bnema.dev/ermite/internal/ports/mocks"
27 )28 )
28 29
29-type fakeAuth struct {
30- mu sync.Mutex
31- seen []string
32-}
33-
34-func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) {
35- f.mu.Lock()
36- f.seen = append(f.seen, secret)
37- f.mu.Unlock()
38- switch secret {
39- case "root-secret":
40- return domain.Root(), nil
41- case "reader-secret":
42- return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
43- }
44- return domain.Principal{}, domain.ErrUnauthenticated
45-}
46-
47-type fakeRepos struct{}
48-
49-func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
50- switch n.String() {
51- case "pub":
52- return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
53- case "priv":
54- if p.Allows(domain.ScopeRepoRead, n) {
55- return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
56- }
57- }
58- return domain.Repo{}, domain.ErrNotFound
59-}
60-
61-type fakePushes struct {
62- mu sync.Mutex
63- calls []bool // the create flag of each Open
64- err error
65-}
66-
67-func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- f.calls = append(f.calls, create)
71- if p.IsAnonymous() {
72- return domain.ErrUnauthenticated
73- }
74- return f.err
75-}
76-
77-type fakeTokens struct {
78- mu sync.Mutex
79- released int
80- repo domain.RepoName
81-}
82-
83-func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) {
84- f.mu.Lock()
85- defer f.mu.Unlock()
86- f.repo = r
87- return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() }
88-}
89-func (f *fakeTokens) Socket() string { return "/run/hook.sock" }
90-
91 // cgiScript is a stand-in for git-http-backend: it answers with the30 // cgiScript is a stand-in for git-http-backend: it answers with the
92 // environment and the body it received. Like Git, it reads the whole request31 // environment and the body it received. Like Git, it reads the whole request
93 // before it answers (net/http cannot read a request body once the response32 // before it answers (net/http cannot read a request body once the response
@@ -103,20 +42,115 @@ func cgiScript(t *testing.T) string {
103 type rig struct {42 type rig struct {
104 h *githttp.Handler43 h *githttp.Handler
105 srv *httptest.Server44 srv *httptest.Server
106- pushes *fakePushes45+ pushes *ghmocks.Pushes
107 backend *mocks.GitBackend46 backend *mocks.GitBackend
108- hooks *fakeTokens47+ hooks *ghmocks.HookTokens
109- auth *fakeAuth48+ auth *ghmocks.Authenticator
110 49
111 pushFlags []bool50 pushFlags []bool
112 backendErr error51 backendErr error
52+
53+ mu sync.Mutex // guards what the mocks record while the server runs
54+ pushErr error // what Pushes.Open answers a named caller
55+ pushCalls []bool // the create flag of each Pushes.Open
56+ authCalls int // Authenticator.Authenticate calls
57+ released int // hook tokens released
58+ hookRepo domain.RepoName
59+}
60+
61+func (r *rig) setPushErr(err error) {
62+ r.mu.Lock()
63+ defer r.mu.Unlock()
64+ r.pushErr = err
65+}
66+
67+func (r *rig) takePushCalls() []bool {
68+ r.mu.Lock()
69+ defer r.mu.Unlock()
70+ c := r.pushCalls
71+ r.pushCalls = nil
72+ return c
73+}
74+
75+func (r *rig) authenticateCalls() int {
76+ r.mu.Lock()
77+ defer r.mu.Unlock()
78+ return r.authCalls
79+}
80+
81+func (r *rig) releasedTokens() int {
82+ r.mu.Lock()
83+ defer r.mu.Unlock()
84+ return r.released
85+}
86+
87+func (r *rig) hookedRepo() domain.RepoName {
88+ r.mu.Lock()
89+ defer r.mu.Unlock()
90+ return r.hookRepo
91+}
92+
93+// stubUseCases answers like the use cases behind the handler: a root and a
94+// reader token, a public repository "pub" and a private one "priv".
95+func (r *rig) stubUseCases(repos *ghmocks.Repos) {
96+ r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn(
97+ func(_ context.Context, secret string) (domain.Principal, error) {
98+ r.mu.Lock()
99+ r.authCalls++
100+ r.mu.Unlock()
101+ switch secret {
102+ case "root-secret":
103+ return domain.Root(), nil
104+ case "reader-secret":
105+ return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
106+ }
107+ return domain.Principal{}, domain.ErrUnauthenticated
108+ }).Maybe()
109+
110+ repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
111+ func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
112+ switch n.String() {
113+ case "pub":
114+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
115+ case "priv":
116+ if p.Allows(domain.ScopeRepoRead, n) {
117+ return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
118+ }
119+ }
120+ return domain.Repo{}, domain.ErrNotFound
121+ }).Maybe()
122+
123+ r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
124+ func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
125+ r.mu.Lock()
126+ defer r.mu.Unlock()
127+ r.pushCalls = append(r.pushCalls, create)
128+ if p.IsAnonymous() {
129+ return domain.ErrUnauthenticated
130+ }
131+ return r.pushErr
132+ }).Maybe()
133+
134+ r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
135+ func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) {
136+ r.mu.Lock()
137+ defer r.mu.Unlock()
138+ r.hookRepo = n
139+ return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() }
140+ }).Maybe()
141+ r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe()
113 }142 }
114 143
115 func newRig(t *testing.T, mut func(*githttp.Config)) *rig {144 func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
116 t.Helper()145 t.Helper()
117- r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}}146+ r := &rig{
147+ pushes: ghmocks.NewPushes(t), backend: mocks.NewGitBackend(t),
148+ hooks: ghmocks.NewHookTokens(t), auth: ghmocks.NewAuthenticator(t),
149+ }
150+ repos := ghmocks.NewRepos(t)
151+ r.stubUseCases(repos)
118 cfg := githttp.Config{152 cfg := githttp.Config{
119- Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,153+ Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
120 }154 }
121 script := cgiScript(t)155 script := cgiScript(t)
122 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(156 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -222,25 +256,24 @@ func TestHandlerPushAuthorizationAnswers(t *testing.T) {
222 return code256 return code
223 }257 }
224 assert.Equal(t, 200, get())258 assert.Equal(t, 200, get())
225- r.pushes.err = domain.ErrForbidden259+ r.setPushErr(domain.ErrForbidden)
226 assert.Equal(t, 403, get())260 assert.Equal(t, 403, get())
227- r.pushes.err = domain.ErrNotFound261+ r.setPushErr(domain.ErrNotFound)
228 assert.Equal(t, 404, get())262 assert.Equal(t, 404, get())
229- r.pushes.err = domain.ErrUnauthenticated263+ r.setPushErr(domain.ErrUnauthenticated)
230 assert.Equal(t, 401, get())264 assert.Equal(t, 401, get())
231- r.pushes.err = errBoom{}265+ r.setPushErr(errBoom{})
232 assert.Equal(t, 500, get())266 assert.Equal(t, 500, get())
233 267
234 // Only the first request of a push may create.268 // Only the first request of a push may create.
235- r.pushes.err = nil269+ r.setPushErr(nil)
236- r.pushes.calls = nil270+ r.takePushCalls()
237 r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)271 r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
238 r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})272 r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})
239- assert.Equal(t, []bool{true, false}, r.pushes.calls)273+ assert.Equal(t, []bool{true, false}, r.takePushCalls())
240 // Reads never reach the push use case.274 // Reads never reach the push use case.
241- r.pushes.calls = nil
242 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)275 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
243- assert.Empty(t, r.pushes.calls)276+ assert.Empty(t, r.takePushCalls())
244 }277 }
245 278
246 type errBoom struct{}279 type errBoom struct{}
@@ -295,12 +328,12 @@ func TestHandlerBackendEnvironment(t *testing.T) {
295 assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks")328 assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks")
296 assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN")329 assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN")
297 assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR")330 assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR")
298- assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends")331+ assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends")
299- assert.Equal(t, "pub", r.hooks.repo.String())332+ assert.Equal(t, "pub", r.hookedRepo().String())
300 333
301 // The info/refs advertisement of a push does not register a hook token.334 // The info/refs advertisement of a push does not register a hook token.
302 r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)335 r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
303- assert.Equal(t, 1, r.hooks.released)336+ assert.Equal(t, 1, r.releasedTokens())
304 }337 }
305 338
306 func TestHandlerNormalizesRepositoryPath(t *testing.T) {339 func TestHandlerNormalizesRepositoryPath(t *testing.T) {
@@ -420,13 +453,9 @@ func TestHandlerLimiter(t *testing.T) {
420 assert.Equal(t, 429, code)453 assert.Equal(t, 429, code)
421 assert.NotEmpty(t, hdr.Get("Retry-After"))454 assert.NotEmpty(t, hdr.Get("Retry-After"))
422 // A blocked request does not even reach the authenticator.455 // A blocked request does not even reach the authenticator.
423- r.auth.mu.Lock()456+ n := r.authenticateCalls()
424- n := len(r.auth.seen)
425- r.auth.mu.Unlock()
426 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)457 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
427- r.auth.mu.Lock()458+ assert.Equal(t, n, r.authenticateCalls())
428- assert.Equal(t, n, len(r.auth.seen))
429- r.auth.mu.Unlock()
430 }459 }
431 460
432 func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {461 func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
M internal/adapters/githttp/hooks_test.go +49 -91
@@ -18,9 +18,11 @@ import (
1818 "time"
1919
2020 "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/mock"
2122 "github.com/stretchr/testify/require"
2223
2324 "bnema.dev/ermite/internal/adapters/githttp"
25+ ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks"
2426 "bnema.dev/ermite/internal/domain"
2527 )
2628
@@ -29,46 +31,6 @@ const (
2931 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
3032 )
3133
32-type call struct {
33- hook string
34- p domain.Principal
35- repo domain.RepoName
36- updates []domain.RefUpdate
37-}
38-
39-type fakeHooks struct {
40- mu sync.Mutex
41- calls []call
42- checkErr error
43- recErr error
44- // onRecord, when set, runs inside Record with its context.
45- onRecord func(ctx context.Context)
46-}
47-
48-func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
49- f.mu.Lock()
50- defer f.mu.Unlock()
51- f.calls = append(f.calls, call{"check", p, n, u})
52- return f.checkErr
53-}
54-
55-func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
56- f.mu.Lock()
57- f.calls = append(f.calls, call{"record", p, n, u})
58- err, on := f.recErr, f.onRecord
59- f.mu.Unlock()
60- if on != nil {
61- on(ctx)
62- }
63- return err
64-}
65-
66-func (f *fakeHooks) last() call {
67- f.mu.Lock()
68- defer f.mu.Unlock()
69- return f.calls[len(f.calls)-1]
70-}
71-
7234 func repoName(t *testing.T, s string) domain.RepoName {
7335 t.Helper()
7436 n, err := domain.ParseRepoName(s)
@@ -117,7 +79,7 @@ func raw(t *testing.T, sock, line string) githttp.HookReply {
11779 }
11880
11981 func TestHookSocketMode(t *testing.T) {
120- _, sock := startHooks(t, &fakeHooks{})
82+ _, sock := startHooks(t, ghmocks.NewPushHooks(t))
12183 fi, err := os.Stat(sock)
12284 require.NoError(t, err)
12385 assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
@@ -130,21 +92,21 @@ func TestHookServerReplacesStaleSocketFile(t *testing.T) {
13092 defer func() { _ = os.RemoveAll(dir) }()
13193 sock := filepath.Join(dir, "hook.sock")
13294 require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
133- s := githttp.NewHookServer(sock, &fakeHooks{})
95+ s := githttp.NewHookServer(sock, ghmocks.NewPushHooks(t))
13496 require.NoError(t, s.Listen(context.Background()))
13597 s.Close(context.Background())
13698 }
13799
138100 func TestHookServerRejectsTooLongSocketPath(t *testing.T) {
139- s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{})
101+ s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", ghmocks.NewPushHooks(t))
140102 err := s.Listen(context.Background())
141103 require.Error(t, err)
142104 assert.Contains(t, err.Error(), "too long")
143105 }
144106
145107 func TestHookProtocol(t *testing.T) {
146- fh := &fakeHooks{}
147- srv, sock := startHooks(t, fh)
108+ hooks := ghmocks.NewPushHooks(t)
109+ srv, sock := startHooks(t, hooks)
148110 p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
149111 tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p)
150112 tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root())
@@ -154,46 +116,42 @@ func TestHookProtocol(t *testing.T) {
154116 upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
155117
156118 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
119+ updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
120+ hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }),
121+ repoName(t, "alpha"), updates).Return(nil).Once()
157122 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
158123 assert.True(t, r.OK)
159- c := fh.last()
160- assert.Equal(t, "check", c.hook)
161- assert.Equal(t, repoName(t, "alpha"), c.repo)
162- assert.Equal(t, "tok1", c.p.TokenID())
163- assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates)
164124
125+ hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }),
126+ repoName(t, "beta"), updates).Return(nil).Once()
165127 r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
166128 assert.True(t, r.OK)
167- c = fh.last()
168- assert.Equal(t, "record", c.hook)
169- assert.Equal(t, repoName(t, "beta"), c.repo)
170- assert.True(t, c.p.IsRoot())
171129 })
172130
173131 t.Run("a repository sent by the client is ignored", func(t *testing.T) {
174- raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
175- assert.Equal(t, repoName(t, "alpha"), fh.last().repo)
132+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once()
133+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
134+ assert.True(t, r.OK)
176135 })
177136
178137 t.Run("refusal carries the message", func(t *testing.T) {
179- fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))
180- defer func() { fh.checkErr = nil }()
138+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
139+ Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once()
181140 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
182141 assert.False(t, r.OK)
183142 assert.Contains(t, r.Message, "rejected")
184143 })
185144
186145 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
187- fh.checkErr = errors.New("disk /secret/path exploded")
188- defer func() { fh.checkErr = nil }()
146+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
147+ Return(errors.New("disk /secret/path exploded")).Once()
189148 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
190149 assert.False(t, r.OK)
191150 assert.NotContains(t, r.Message, "/secret/path")
192151 })
193152
194153 t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
195- fh.recErr = errors.New("boom")
196- defer func() { fh.recErr = nil }()
154+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once()
197155 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
198156 assert.True(t, r.OK)
199157 assert.NotEmpty(t, r.Message)
@@ -205,18 +163,16 @@ func TestHookProtocol(t *testing.T) {
205163 assert.False(t, r.OK, tok)
206164 }
207165 releaseA()
208- before := len(fh.calls)
209166 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
210167 assert.False(t, r.OK, "released token")
211168 assert.Contains(t, r.Message, "token")
212- assert.Len(t, fh.calls, before, "no use case ran")
169+ // The hooks mock expects no call: no use case ran.
213170 releaseB()
214171 })
215172
216173 t.Run("malformed requests are refused", func(t *testing.T) {
217174 tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root())
218175 defer rel()
219- before := len(fh.calls)
220176 for _, line := range []string{
221177 `not json`,
222178 `{}`,
@@ -227,7 +183,7 @@ func TestHookProtocol(t *testing.T) {
227183 r := raw(t, sock, line)
228184 assert.False(t, r.OK, line)
229185 }
230- assert.Len(t, fh.calls, before)
186+ // The hooks mock expects no call: no use case ran.
231187 })
232188
233189 t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {
@@ -242,24 +198,26 @@ func TestHookProtocol(t *testing.T) {
242198 // A shutdown while a push is being recorded must not drop the recording: the
243199 // push is already in the repository.
244200 func TestHookRecordSurvivesShutdown(t *testing.T) {
245- fh := &fakeHooks{}
201+ hooks := ghmocks.NewPushHooks(t)
246202 started, proceed := make(chan struct{}), make(chan struct{})
247203 var cancelled, hasDeadline bool
248204 var ctxMu sync.Mutex
249- fh.onRecord = func(ctx context.Context) {
250- close(started)
251- <-proceed
252- ctxMu.Lock()
253- defer ctxMu.Unlock()
254- cancelled = ctx.Err() != nil
255- _, hasDeadline = ctx.Deadline()
256- }
205+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
206+ func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error {
207+ close(started)
208+ <-proceed
209+ ctxMu.Lock()
210+ defer ctxMu.Unlock()
211+ cancelled = ctx.Err() != nil
212+ _, hasDeadline = ctx.Deadline()
213+ return nil
214+ }).Once()
257215
258216 dir, err := os.MkdirTemp("", "hk")
259217 require.NoError(t, err)
260218 t.Cleanup(func() { _ = os.RemoveAll(dir) })
261219 sock := filepath.Join(dir, "hook.sock")
262- srv := githttp.NewHookServer(sock, fh)
220+ srv := githttp.NewHookServer(sock, hooks)
263221 require.NoError(t, srv.Listen(context.Background()))
264222 ctx, cancel := context.WithCancel(context.Background())
265223 done := make(chan error, 1)
@@ -294,9 +252,13 @@ func TestHookRecordSurvivesShutdown(t *testing.T) {
294252 }
295253
296254 func TestHookConcurrentTokens(t *testing.T) {
297- fh := &fakeHooks{}
298- srv, sock := startHooks(t, fh)
255+ hooks := ghmocks.NewPushHooks(t)
256+ srv, sock := startHooks(t, hooks)
299257 var wg sync.WaitGroup
258+ // Each of the 16 tokens reaches the use case for its own repository, once.
259+ for i := range 16 {
260+ hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "r"+string(rune('a'+i))), mock.Anything).Return(nil).Once()
261+ }
300262 for i := range 16 {
301263 wg.Add(1)
302264 go func() {
@@ -309,39 +271,35 @@ func TestHookConcurrentTokens(t *testing.T) {
309271 }()
310272 }
311273 wg.Wait()
312- seen := map[domain.RepoName]bool{}
313- for _, c := range fh.calls {
314- seen[c.repo] = true
315- }
316- assert.Len(t, seen, 16)
317274 }
318275
319276 func TestRunHook(t *testing.T) {
320277 ctx := context.Background()
321- fh := &fakeHooks{}
322- srv, sock := startHooks(t, fh)
278+ hooks := ghmocks.NewPushHooks(t)
279+ srv, sock := startHooks(t, hooks)
323280 tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
324281 defer rel()
325282 input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
326283
327284 t.Run("sends the lines git feeds the hook", func(t *testing.T) {
328285 var errOut bytes.Buffer
329- require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
330- assert.Equal(t, []domain.RefUpdate{
286+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{
331287 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
332288 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
333- }, fh.last().updates)
289+ }).Return(nil).Once()
290+ require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
334291 assert.Empty(t, errOut.String())
335292 })
336293
337294 t.Run("a ref name with spaces survives", func(t *testing.T) {
295+ hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"),
296+ []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once()
338297 require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
339- assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref)
340298 })
341299
342300 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
343- fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))
344- defer func() { fh.checkErr = nil }()
301+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
302+ Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once()
345303 var errOut bytes.Buffer
346304 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
347305 require.ErrorIs(t, err, githttp.ErrHookRejected)
@@ -18,9 +18,11 @@ import (
18 "time"18 "time"
19 19
20 "github.com/stretchr/testify/assert"20 "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"22 "github.com/stretchr/testify/require"
22 23
23 "bnema.dev/ermite/internal/adapters/githttp"24 "bnema.dev/ermite/internal/adapters/githttp"
25+ ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks"
24 "bnema.dev/ermite/internal/domain"26 "bnema.dev/ermite/internal/domain"
25 )27 )
26 28
@@ -29,46 +31,6 @@ const (
29 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"31 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
30 )32 )
31 33
32-type call struct {
33- hook string
34- p domain.Principal
35- repo domain.RepoName
36- updates []domain.RefUpdate
37-}
38-
39-type fakeHooks struct {
40- mu sync.Mutex
41- calls []call
42- checkErr error
43- recErr error
44- // onRecord, when set, runs inside Record with its context.
45- onRecord func(ctx context.Context)
46-}
47-
48-func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
49- f.mu.Lock()
50- defer f.mu.Unlock()
51- f.calls = append(f.calls, call{"check", p, n, u})
52- return f.checkErr
53-}
54-
55-func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
56- f.mu.Lock()
57- f.calls = append(f.calls, call{"record", p, n, u})
58- err, on := f.recErr, f.onRecord
59- f.mu.Unlock()
60- if on != nil {
61- on(ctx)
62- }
63- return err
64-}
65-
66-func (f *fakeHooks) last() call {
67- f.mu.Lock()
68- defer f.mu.Unlock()
69- return f.calls[len(f.calls)-1]
70-}
71-
72 func repoName(t *testing.T, s string) domain.RepoName {34 func repoName(t *testing.T, s string) domain.RepoName {
73 t.Helper()35 t.Helper()
74 n, err := domain.ParseRepoName(s)36 n, err := domain.ParseRepoName(s)
@@ -117,7 +79,7 @@ func raw(t *testing.T, sock, line string) githttp.HookReply {
117 }79 }
118 80
119 func TestHookSocketMode(t *testing.T) {81 func TestHookSocketMode(t *testing.T) {
120- _, sock := startHooks(t, &fakeHooks{})82+ _, sock := startHooks(t, ghmocks.NewPushHooks(t))
121 fi, err := os.Stat(sock)83 fi, err := os.Stat(sock)
122 require.NoError(t, err)84 require.NoError(t, err)
123 assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())85 assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
@@ -130,21 +92,21 @@ func TestHookServerReplacesStaleSocketFile(t *testing.T) {
130 defer func() { _ = os.RemoveAll(dir) }()92 defer func() { _ = os.RemoveAll(dir) }()
131 sock := filepath.Join(dir, "hook.sock")93 sock := filepath.Join(dir, "hook.sock")
132 require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))94 require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
133- s := githttp.NewHookServer(sock, &fakeHooks{})95+ s := githttp.NewHookServer(sock, ghmocks.NewPushHooks(t))
134 require.NoError(t, s.Listen(context.Background()))96 require.NoError(t, s.Listen(context.Background()))
135 s.Close(context.Background())97 s.Close(context.Background())
136 }98 }
137 99
138 func TestHookServerRejectsTooLongSocketPath(t *testing.T) {100 func TestHookServerRejectsTooLongSocketPath(t *testing.T) {
139- s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{})101+ s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", ghmocks.NewPushHooks(t))
140 err := s.Listen(context.Background())102 err := s.Listen(context.Background())
141 require.Error(t, err)103 require.Error(t, err)
142 assert.Contains(t, err.Error(), "too long")104 assert.Contains(t, err.Error(), "too long")
143 }105 }
144 106
145 func TestHookProtocol(t *testing.T) {107 func TestHookProtocol(t *testing.T) {
146- fh := &fakeHooks{}108+ hooks := ghmocks.NewPushHooks(t)
147- srv, sock := startHooks(t, fh)109+ srv, sock := startHooks(t, hooks)
148 p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})110 p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
149 tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p)111 tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p)
150 tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root())112 tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root())
@@ -154,46 +116,42 @@ func TestHookProtocol(t *testing.T) {
154 upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`116 upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
155 117
156 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {118 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
119+ updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
120+ hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }),
121+ repoName(t, "alpha"), updates).Return(nil).Once()
157 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)122 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
158 assert.True(t, r.OK)123 assert.True(t, r.OK)
159- c := fh.last()
160- assert.Equal(t, "check", c.hook)
161- assert.Equal(t, repoName(t, "alpha"), c.repo)
162- assert.Equal(t, "tok1", c.p.TokenID())
163- assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates)
164 124
125+ hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }),
126+ repoName(t, "beta"), updates).Return(nil).Once()
165 r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)127 r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
166 assert.True(t, r.OK)128 assert.True(t, r.OK)
167- c = fh.last()
168- assert.Equal(t, "record", c.hook)
169- assert.Equal(t, repoName(t, "beta"), c.repo)
170- assert.True(t, c.p.IsRoot())
171 })129 })
172 130
173 t.Run("a repository sent by the client is ignored", func(t *testing.T) {131 t.Run("a repository sent by the client is ignored", func(t *testing.T) {
174- raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)132+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once()
175- assert.Equal(t, repoName(t, "alpha"), fh.last().repo)133+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
134+ assert.True(t, r.OK)
176 })135 })
177 136
178 t.Run("refusal carries the message", func(t *testing.T) {137 t.Run("refusal carries the message", func(t *testing.T) {
179- fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))138+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
180- defer func() { fh.checkErr = nil }()139+ Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once()
181 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)140 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
182 assert.False(t, r.OK)141 assert.False(t, r.OK)
183 assert.Contains(t, r.Message, "rejected")142 assert.Contains(t, r.Message, "rejected")
184 })143 })
185 144
186 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {145 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
187- fh.checkErr = errors.New("disk /secret/path exploded")146+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
188- defer func() { fh.checkErr = nil }()147+ Return(errors.New("disk /secret/path exploded")).Once()
189 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)148 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
190 assert.False(t, r.OK)149 assert.False(t, r.OK)
191 assert.NotContains(t, r.Message, "/secret/path")150 assert.NotContains(t, r.Message, "/secret/path")
192 })151 })
193 152
194 t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {153 t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
195- fh.recErr = errors.New("boom")154+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once()
196- defer func() { fh.recErr = nil }()
197 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)155 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
198 assert.True(t, r.OK)156 assert.True(t, r.OK)
199 assert.NotEmpty(t, r.Message)157 assert.NotEmpty(t, r.Message)
@@ -205,18 +163,16 @@ func TestHookProtocol(t *testing.T) {
205 assert.False(t, r.OK, tok)163 assert.False(t, r.OK, tok)
206 }164 }
207 releaseA()165 releaseA()
208- before := len(fh.calls)
209 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)166 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
210 assert.False(t, r.OK, "released token")167 assert.False(t, r.OK, "released token")
211 assert.Contains(t, r.Message, "token")168 assert.Contains(t, r.Message, "token")
212- assert.Len(t, fh.calls, before, "no use case ran")169+ // The hooks mock expects no call: no use case ran.
213 releaseB()170 releaseB()
214 })171 })
215 172
216 t.Run("malformed requests are refused", func(t *testing.T) {173 t.Run("malformed requests are refused", func(t *testing.T) {
217 tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root())174 tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root())
218 defer rel()175 defer rel()
219- before := len(fh.calls)
220 for _, line := range []string{176 for _, line := range []string{
221 `not json`,177 `not json`,
222 `{}`,178 `{}`,
@@ -227,7 +183,7 @@ func TestHookProtocol(t *testing.T) {
227 r := raw(t, sock, line)183 r := raw(t, sock, line)
228 assert.False(t, r.OK, line)184 assert.False(t, r.OK, line)
229 }185 }
230- assert.Len(t, fh.calls, before)186+ // The hooks mock expects no call: no use case ran.
231 })187 })
232 188
233 t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {189 t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {
@@ -242,24 +198,26 @@ func TestHookProtocol(t *testing.T) {
242 // A shutdown while a push is being recorded must not drop the recording: the198 // A shutdown while a push is being recorded must not drop the recording: the
243 // push is already in the repository.199 // push is already in the repository.
244 func TestHookRecordSurvivesShutdown(t *testing.T) {200 func TestHookRecordSurvivesShutdown(t *testing.T) {
245- fh := &fakeHooks{}201+ hooks := ghmocks.NewPushHooks(t)
246 started, proceed := make(chan struct{}), make(chan struct{})202 started, proceed := make(chan struct{}), make(chan struct{})
247 var cancelled, hasDeadline bool203 var cancelled, hasDeadline bool
248 var ctxMu sync.Mutex204 var ctxMu sync.Mutex
249- fh.onRecord = func(ctx context.Context) {205+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
250- close(started)206+ func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error {
251- <-proceed207+ close(started)
252- ctxMu.Lock()208+ <-proceed
253- defer ctxMu.Unlock()209+ ctxMu.Lock()
254- cancelled = ctx.Err() != nil210+ defer ctxMu.Unlock()
255- _, hasDeadline = ctx.Deadline()211+ cancelled = ctx.Err() != nil
256- }212+ _, hasDeadline = ctx.Deadline()
213+ return nil
214+ }).Once()
257 215
258 dir, err := os.MkdirTemp("", "hk")216 dir, err := os.MkdirTemp("", "hk")
259 require.NoError(t, err)217 require.NoError(t, err)
260 t.Cleanup(func() { _ = os.RemoveAll(dir) })218 t.Cleanup(func() { _ = os.RemoveAll(dir) })
261 sock := filepath.Join(dir, "hook.sock")219 sock := filepath.Join(dir, "hook.sock")
262- srv := githttp.NewHookServer(sock, fh)220+ srv := githttp.NewHookServer(sock, hooks)
263 require.NoError(t, srv.Listen(context.Background()))221 require.NoError(t, srv.Listen(context.Background()))
264 ctx, cancel := context.WithCancel(context.Background())222 ctx, cancel := context.WithCancel(context.Background())
265 done := make(chan error, 1)223 done := make(chan error, 1)
@@ -294,9 +252,13 @@ func TestHookRecordSurvivesShutdown(t *testing.T) {
294 }252 }
295 253
296 func TestHookConcurrentTokens(t *testing.T) {254 func TestHookConcurrentTokens(t *testing.T) {
297- fh := &fakeHooks{}255+ hooks := ghmocks.NewPushHooks(t)
298- srv, sock := startHooks(t, fh)256+ srv, sock := startHooks(t, hooks)
299 var wg sync.WaitGroup257 var wg sync.WaitGroup
258+ // Each of the 16 tokens reaches the use case for its own repository, once.
259+ for i := range 16 {
260+ hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "r"+string(rune('a'+i))), mock.Anything).Return(nil).Once()
261+ }
300 for i := range 16 {262 for i := range 16 {
301 wg.Add(1)263 wg.Add(1)
302 go func() {264 go func() {
@@ -309,39 +271,35 @@ func TestHookConcurrentTokens(t *testing.T) {
309 }()271 }()
310 }272 }
311 wg.Wait()273 wg.Wait()
312- seen := map[domain.RepoName]bool{}
313- for _, c := range fh.calls {
314- seen[c.repo] = true
315- }
316- assert.Len(t, seen, 16)
317 }274 }
318 275
319 func TestRunHook(t *testing.T) {276 func TestRunHook(t *testing.T) {
320 ctx := context.Background()277 ctx := context.Background()
321- fh := &fakeHooks{}278+ hooks := ghmocks.NewPushHooks(t)
322- srv, sock := startHooks(t, fh)279+ srv, sock := startHooks(t, hooks)
323 tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())280 tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
324 defer rel()281 defer rel()
325 input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"282 input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
326 283
327 t.Run("sends the lines git feeds the hook", func(t *testing.T) {284 t.Run("sends the lines git feeds the hook", func(t *testing.T) {
328 var errOut bytes.Buffer285 var errOut bytes.Buffer
329- require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))286+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{
330- assert.Equal(t, []domain.RefUpdate{
331 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},287 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
332 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},288 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
333- }, fh.last().updates)289+ }).Return(nil).Once()
290+ require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
334 assert.Empty(t, errOut.String())291 assert.Empty(t, errOut.String())
335 })292 })
336 293
337 t.Run("a ref name with spaces survives", func(t *testing.T) {294 t.Run("a ref name with spaces survives", func(t *testing.T) {
295+ hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"),
296+ []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once()
338 require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))297 require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
339- assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref)
340 })298 })
341 299
342 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {300 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
343- fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))301+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
344- defer func() { fh.checkErr = nil }()302+ Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once()
345 var errOut bytes.Buffer303 var errOut bytes.Buffer
346 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)304 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
347 require.ErrorIs(t, err, githttp.ErrHookRejected)305 require.ErrorIs(t, err, githttp.ErrHookRejected)
A internal/adapters/githttp/mocks/Authenticator.go +114 -0
@@ -0,0 +1,114 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewAuthenticator creates a new instance of Authenticator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewAuthenticator(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Authenticator {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Authenticator{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Authenticator is an autogenerated mock type for the Authenticator type
38+type Authenticator struct {
39+ mock.Mock
40+}
41+
42+type Authenticator_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Authenticator) EXPECT() *Authenticator_Expecter {
47+ return &Authenticator_Expecter{mock: &_m.Mock}
48+}
49+
50+// Authenticate provides a mock function for the type Authenticator
51+func (_mock *Authenticator) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
52+ ret := _mock.Called(ctx, secret)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Authenticate")
56+ }
57+
58+ var r0 domain.Principal
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, error)); ok {
61+ return returnFunc(ctx, secret)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok {
64+ r0 = returnFunc(ctx, secret)
65+ } else {
66+ r0 = ret.Get(0).(domain.Principal)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
69+ r1 = returnFunc(ctx, secret)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// Authenticator_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
77+type Authenticator_Authenticate_Call struct {
78+ *mock.Call
79+}
80+
81+// Authenticate is a helper method to define mock.On call
82+// - ctx context.Context
83+// - secret string
84+func (_e *Authenticator_Expecter) Authenticate(ctx any, secret any) *Authenticator_Authenticate_Call {
85+ return &Authenticator_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, secret)}
86+}
87+
88+func (_c *Authenticator_Authenticate_Call) Run(run func(ctx context.Context, secret string)) *Authenticator_Authenticate_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *Authenticator_Authenticate_Call) Return(principal domain.Principal, err error) *Authenticator_Authenticate_Call {
107+ _c.Call.Return(principal, err)
108+ return _c
109+}
110+
111+func (_c *Authenticator_Authenticate_Call) RunAndReturn(run func(ctx context.Context, secret string) (domain.Principal, error)) *Authenticator_Authenticate_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
@@ -0,0 +1,114 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewAuthenticator creates a new instance of Authenticator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewAuthenticator(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Authenticator {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Authenticator{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Authenticator is an autogenerated mock type for the Authenticator type
38+type Authenticator struct {
39+ mock.Mock
40+}
41+
42+type Authenticator_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Authenticator) EXPECT() *Authenticator_Expecter {
47+ return &Authenticator_Expecter{mock: &_m.Mock}
48+}
49+
50+// Authenticate provides a mock function for the type Authenticator
51+func (_mock *Authenticator) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
52+ ret := _mock.Called(ctx, secret)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Authenticate")
56+ }
57+
58+ var r0 domain.Principal
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, error)); ok {
61+ return returnFunc(ctx, secret)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok {
64+ r0 = returnFunc(ctx, secret)
65+ } else {
66+ r0 = ret.Get(0).(domain.Principal)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
69+ r1 = returnFunc(ctx, secret)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// Authenticator_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
77+type Authenticator_Authenticate_Call struct {
78+ *mock.Call
79+}
80+
81+// Authenticate is a helper method to define mock.On call
82+// - ctx context.Context
83+// - secret string
84+func (_e *Authenticator_Expecter) Authenticate(ctx any, secret any) *Authenticator_Authenticate_Call {
85+ return &Authenticator_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, secret)}
86+}
87+
88+func (_c *Authenticator_Authenticate_Call) Run(run func(ctx context.Context, secret string)) *Authenticator_Authenticate_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 string
95+ if args[1] != nil {
96+ arg1 = args[1].(string)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *Authenticator_Authenticate_Call) Return(principal domain.Principal, err error) *Authenticator_Authenticate_Call {
107+ _c.Call.Return(principal, err)
108+ return _c
109+}
110+
111+func (_c *Authenticator_Authenticate_Call) RunAndReturn(run func(ctx context.Context, secret string) (domain.Principal, error)) *Authenticator_Authenticate_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
A internal/adapters/githttp/mocks/HookTokens.go +166 -0
@@ -0,0 +1,166 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewHookTokens creates a new instance of HookTokens. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewHookTokens(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *HookTokens {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &HookTokens{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// HookTokens is an autogenerated mock type for the HookTokens type
38+type HookTokens struct {
39+ mock.Mock
40+}
41+
42+type HookTokens_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *HookTokens) EXPECT() *HookTokens_Expecter {
47+ return &HookTokens_Expecter{mock: &_m.Mock}
48+}
49+
50+// Register provides a mock function for the type HookTokens
51+func (_mock *HookTokens) Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func()) {
52+ ret := _mock.Called(ctx, repo, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Register")
56+ }
57+
58+ var r0 string
59+ var r1 func()
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) (string, func())); ok {
61+ return returnFunc(ctx, repo, p)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) string); ok {
64+ r0 = returnFunc(ctx, repo, p)
65+ } else {
66+ r0 = ret.Get(0).(string)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Principal) func()); ok {
69+ r1 = returnFunc(ctx, repo, p)
70+ } else {
71+ if ret.Get(1) != nil {
72+ r1 = ret.Get(1).(func())
73+ }
74+ }
75+ return r0, r1
76+}
77+
78+// HookTokens_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register'
79+type HookTokens_Register_Call struct {
80+ *mock.Call
81+}
82+
83+// Register is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - p domain.Principal
87+func (_e *HookTokens_Expecter) Register(ctx any, repo any, p any) *HookTokens_Register_Call {
88+ return &HookTokens_Register_Call{Call: _e.mock.On("Register", ctx, repo, p)}
89+}
90+
91+func (_c *HookTokens_Register_Call) Run(run func(ctx context.Context, repo domain.RepoName, p domain.Principal)) *HookTokens_Register_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.RepoName
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.RepoName)
100+ }
101+ var arg2 domain.Principal
102+ if args[2] != nil {
103+ arg2 = args[2].(domain.Principal)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *HookTokens_Register_Call) Return(token string, release func()) *HookTokens_Register_Call {
115+ _c.Call.Return(token, release)
116+ return _c
117+}
118+
119+func (_c *HookTokens_Register_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func())) *HookTokens_Register_Call {
120+ _c.Call.Return(run)
121+ return _c
122+}
123+
124+// Socket provides a mock function for the type HookTokens
125+func (_mock *HookTokens) Socket() string {
126+ ret := _mock.Called()
127+
128+ if len(ret) == 0 {
129+ panic("no return value specified for Socket")
130+ }
131+
132+ var r0 string
133+ if returnFunc, ok := ret.Get(0).(func() string); ok {
134+ r0 = returnFunc()
135+ } else {
136+ r0 = ret.Get(0).(string)
137+ }
138+ return r0
139+}
140+
141+// HookTokens_Socket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Socket'
142+type HookTokens_Socket_Call struct {
143+ *mock.Call
144+}
145+
146+// Socket is a helper method to define mock.On call
147+func (_e *HookTokens_Expecter) Socket() *HookTokens_Socket_Call {
148+ return &HookTokens_Socket_Call{Call: _e.mock.On("Socket")}
149+}
150+
151+func (_c *HookTokens_Socket_Call) Run(run func()) *HookTokens_Socket_Call {
152+ _c.Call.Run(func(args mock.Arguments) {
153+ run()
154+ })
155+ return _c
156+}
157+
158+func (_c *HookTokens_Socket_Call) Return(s string) *HookTokens_Socket_Call {
159+ _c.Call.Return(s)
160+ return _c
161+}
162+
163+func (_c *HookTokens_Socket_Call) RunAndReturn(run func() string) *HookTokens_Socket_Call {
164+ _c.Call.Return(run)
165+ return _c
166+}
@@ -0,0 +1,166 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewHookTokens creates a new instance of HookTokens. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewHookTokens(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *HookTokens {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &HookTokens{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// HookTokens is an autogenerated mock type for the HookTokens type
38+type HookTokens struct {
39+ mock.Mock
40+}
41+
42+type HookTokens_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *HookTokens) EXPECT() *HookTokens_Expecter {
47+ return &HookTokens_Expecter{mock: &_m.Mock}
48+}
49+
50+// Register provides a mock function for the type HookTokens
51+func (_mock *HookTokens) Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func()) {
52+ ret := _mock.Called(ctx, repo, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Register")
56+ }
57+
58+ var r0 string
59+ var r1 func()
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) (string, func())); ok {
61+ return returnFunc(ctx, repo, p)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) string); ok {
64+ r0 = returnFunc(ctx, repo, p)
65+ } else {
66+ r0 = ret.Get(0).(string)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Principal) func()); ok {
69+ r1 = returnFunc(ctx, repo, p)
70+ } else {
71+ if ret.Get(1) != nil {
72+ r1 = ret.Get(1).(func())
73+ }
74+ }
75+ return r0, r1
76+}
77+
78+// HookTokens_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register'
79+type HookTokens_Register_Call struct {
80+ *mock.Call
81+}
82+
83+// Register is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - p domain.Principal
87+func (_e *HookTokens_Expecter) Register(ctx any, repo any, p any) *HookTokens_Register_Call {
88+ return &HookTokens_Register_Call{Call: _e.mock.On("Register", ctx, repo, p)}
89+}
90+
91+func (_c *HookTokens_Register_Call) Run(run func(ctx context.Context, repo domain.RepoName, p domain.Principal)) *HookTokens_Register_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.RepoName
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.RepoName)
100+ }
101+ var arg2 domain.Principal
102+ if args[2] != nil {
103+ arg2 = args[2].(domain.Principal)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *HookTokens_Register_Call) Return(token string, release func()) *HookTokens_Register_Call {
115+ _c.Call.Return(token, release)
116+ return _c
117+}
118+
119+func (_c *HookTokens_Register_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func())) *HookTokens_Register_Call {
120+ _c.Call.Return(run)
121+ return _c
122+}
123+
124+// Socket provides a mock function for the type HookTokens
125+func (_mock *HookTokens) Socket() string {
126+ ret := _mock.Called()
127+
128+ if len(ret) == 0 {
129+ panic("no return value specified for Socket")
130+ }
131+
132+ var r0 string
133+ if returnFunc, ok := ret.Get(0).(func() string); ok {
134+ r0 = returnFunc()
135+ } else {
136+ r0 = ret.Get(0).(string)
137+ }
138+ return r0
139+}
140+
141+// HookTokens_Socket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Socket'
142+type HookTokens_Socket_Call struct {
143+ *mock.Call
144+}
145+
146+// Socket is a helper method to define mock.On call
147+func (_e *HookTokens_Expecter) Socket() *HookTokens_Socket_Call {
148+ return &HookTokens_Socket_Call{Call: _e.mock.On("Socket")}
149+}
150+
151+func (_c *HookTokens_Socket_Call) Run(run func()) *HookTokens_Socket_Call {
152+ _c.Call.Run(func(args mock.Arguments) {
153+ run()
154+ })
155+ return _c
156+}
157+
158+func (_c *HookTokens_Socket_Call) Return(s string) *HookTokens_Socket_Call {
159+ _c.Call.Return(s)
160+ return _c
161+}
162+
163+func (_c *HookTokens_Socket_Call) RunAndReturn(run func() string) *HookTokens_Socket_Call {
164+ _c.Call.Return(run)
165+ return _c
166+}
A internal/adapters/githttp/mocks/PushHooks.go +186 -0
@@ -0,0 +1,186 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPushHooks creates a new instance of PushHooks. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPushHooks(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PushHooks {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PushHooks{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// PushHooks is an autogenerated mock type for the PushHooks type
38+type PushHooks struct {
39+ mock.Mock
40+}
41+
42+type PushHooks_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PushHooks) EXPECT() *PushHooks_Expecter {
47+ return &PushHooks_Expecter{mock: &_m.Mock}
48+}
49+
50+// Check provides a mock function for the type PushHooks
51+func (_mock *PushHooks) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
52+ ret := _mock.Called(ctx, p, name, updates)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Check")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok {
60+ r0 = returnFunc(ctx, p, name, updates)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// PushHooks_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
68+type PushHooks_Check_Call struct {
69+ *mock.Call
70+}
71+
72+// Check is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Principal
75+// - name domain.RepoName
76+// - updates []domain.RefUpdate
77+func (_e *PushHooks_Expecter) Check(ctx any, p any, name any, updates any) *PushHooks_Check_Call {
78+ return &PushHooks_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)}
79+}
80+
81+func (_c *PushHooks_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Check_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.Principal
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.Principal)
90+ }
91+ var arg2 domain.RepoName
92+ if args[2] != nil {
93+ arg2 = args[2].(domain.RepoName)
94+ }
95+ var arg3 []domain.RefUpdate
96+ if args[3] != nil {
97+ arg3 = args[3].([]domain.RefUpdate)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *PushHooks_Check_Call) Return(err error) *PushHooks_Check_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *PushHooks_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Check_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// Record provides a mock function for the type PushHooks
120+func (_mock *PushHooks) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
121+ ret := _mock.Called(ctx, p, name, updates)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for Record")
125+ }
126+
127+ var r0 error
128+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok {
129+ r0 = returnFunc(ctx, p, name, updates)
130+ } else {
131+ r0 = ret.Error(0)
132+ }
133+ return r0
134+}
135+
136+// PushHooks_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record'
137+type PushHooks_Record_Call struct {
138+ *mock.Call
139+}
140+
141+// Record is a helper method to define mock.On call
142+// - ctx context.Context
143+// - p domain.Principal
144+// - name domain.RepoName
145+// - updates []domain.RefUpdate
146+func (_e *PushHooks_Expecter) Record(ctx any, p any, name any, updates any) *PushHooks_Record_Call {
147+ return &PushHooks_Record_Call{Call: _e.mock.On("Record", ctx, p, name, updates)}
148+}
149+
150+func (_c *PushHooks_Record_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Record_Call {
151+ _c.Call.Run(func(args mock.Arguments) {
152+ var arg0 context.Context
153+ if args[0] != nil {
154+ arg0 = args[0].(context.Context)
155+ }
156+ var arg1 domain.Principal
157+ if args[1] != nil {
158+ arg1 = args[1].(domain.Principal)
159+ }
160+ var arg2 domain.RepoName
161+ if args[2] != nil {
162+ arg2 = args[2].(domain.RepoName)
163+ }
164+ var arg3 []domain.RefUpdate
165+ if args[3] != nil {
166+ arg3 = args[3].([]domain.RefUpdate)
167+ }
168+ run(
169+ arg0,
170+ arg1,
171+ arg2,
172+ arg3,
173+ )
174+ })
175+ return _c
176+}
177+
178+func (_c *PushHooks_Record_Call) Return(err error) *PushHooks_Record_Call {
179+ _c.Call.Return(err)
180+ return _c
181+}
182+
183+func (_c *PushHooks_Record_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Record_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
@@ -0,0 +1,186 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPushHooks creates a new instance of PushHooks. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPushHooks(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PushHooks {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PushHooks{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// PushHooks is an autogenerated mock type for the PushHooks type
38+type PushHooks struct {
39+ mock.Mock
40+}
41+
42+type PushHooks_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PushHooks) EXPECT() *PushHooks_Expecter {
47+ return &PushHooks_Expecter{mock: &_m.Mock}
48+}
49+
50+// Check provides a mock function for the type PushHooks
51+func (_mock *PushHooks) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
52+ ret := _mock.Called(ctx, p, name, updates)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Check")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok {
60+ r0 = returnFunc(ctx, p, name, updates)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// PushHooks_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
68+type PushHooks_Check_Call struct {
69+ *mock.Call
70+}
71+
72+// Check is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Principal
75+// - name domain.RepoName
76+// - updates []domain.RefUpdate
77+func (_e *PushHooks_Expecter) Check(ctx any, p any, name any, updates any) *PushHooks_Check_Call {
78+ return &PushHooks_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)}
79+}
80+
81+func (_c *PushHooks_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Check_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.Principal
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.Principal)
90+ }
91+ var arg2 domain.RepoName
92+ if args[2] != nil {
93+ arg2 = args[2].(domain.RepoName)
94+ }
95+ var arg3 []domain.RefUpdate
96+ if args[3] != nil {
97+ arg3 = args[3].([]domain.RefUpdate)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *PushHooks_Check_Call) Return(err error) *PushHooks_Check_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *PushHooks_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Check_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// Record provides a mock function for the type PushHooks
120+func (_mock *PushHooks) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
121+ ret := _mock.Called(ctx, p, name, updates)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for Record")
125+ }
126+
127+ var r0 error
128+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok {
129+ r0 = returnFunc(ctx, p, name, updates)
130+ } else {
131+ r0 = ret.Error(0)
132+ }
133+ return r0
134+}
135+
136+// PushHooks_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record'
137+type PushHooks_Record_Call struct {
138+ *mock.Call
139+}
140+
141+// Record is a helper method to define mock.On call
142+// - ctx context.Context
143+// - p domain.Principal
144+// - name domain.RepoName
145+// - updates []domain.RefUpdate
146+func (_e *PushHooks_Expecter) Record(ctx any, p any, name any, updates any) *PushHooks_Record_Call {
147+ return &PushHooks_Record_Call{Call: _e.mock.On("Record", ctx, p, name, updates)}
148+}
149+
150+func (_c *PushHooks_Record_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Record_Call {
151+ _c.Call.Run(func(args mock.Arguments) {
152+ var arg0 context.Context
153+ if args[0] != nil {
154+ arg0 = args[0].(context.Context)
155+ }
156+ var arg1 domain.Principal
157+ if args[1] != nil {
158+ arg1 = args[1].(domain.Principal)
159+ }
160+ var arg2 domain.RepoName
161+ if args[2] != nil {
162+ arg2 = args[2].(domain.RepoName)
163+ }
164+ var arg3 []domain.RefUpdate
165+ if args[3] != nil {
166+ arg3 = args[3].([]domain.RefUpdate)
167+ }
168+ run(
169+ arg0,
170+ arg1,
171+ arg2,
172+ arg3,
173+ )
174+ })
175+ return _c
176+}
177+
178+func (_c *PushHooks_Record_Call) Return(err error) *PushHooks_Record_Call {
179+ _c.Call.Return(err)
180+ return _c
181+}
182+
183+func (_c *PushHooks_Record_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Record_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
A internal/adapters/githttp/mocks/Pushes.go +117 -0
@@ -0,0 +1,117 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPushes creates a new instance of Pushes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPushes(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Pushes {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Pushes{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Pushes is an autogenerated mock type for the Pushes type
38+type Pushes struct {
39+ mock.Mock
40+}
41+
42+type Pushes_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Pushes) EXPECT() *Pushes_Expecter {
47+ return &Pushes_Expecter{mock: &_m.Mock}
48+}
49+
50+// Open provides a mock function for the type Pushes
51+func (_mock *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
52+ ret := _mock.Called(ctx, p, name, create)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Open")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok {
60+ r0 = returnFunc(ctx, p, name, create)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// Pushes_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
68+type Pushes_Open_Call struct {
69+ *mock.Call
70+}
71+
72+// Open is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Principal
75+// - name domain.RepoName
76+// - create bool
77+func (_e *Pushes_Expecter) Open(ctx any, p any, name any, create any) *Pushes_Open_Call {
78+ return &Pushes_Open_Call{Call: _e.mock.On("Open", ctx, p, name, create)}
79+}
80+
81+func (_c *Pushes_Open_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool)) *Pushes_Open_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.Principal
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.Principal)
90+ }
91+ var arg2 domain.RepoName
92+ if args[2] != nil {
93+ arg2 = args[2].(domain.RepoName)
94+ }
95+ var arg3 bool
96+ if args[3] != nil {
97+ arg3 = args[3].(bool)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *Pushes_Open_Call) Return(err error) *Pushes_Open_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *Pushes_Open_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error) *Pushes_Open_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
@@ -0,0 +1,117 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPushes creates a new instance of Pushes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPushes(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Pushes {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Pushes{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Pushes is an autogenerated mock type for the Pushes type
38+type Pushes struct {
39+ mock.Mock
40+}
41+
42+type Pushes_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Pushes) EXPECT() *Pushes_Expecter {
47+ return &Pushes_Expecter{mock: &_m.Mock}
48+}
49+
50+// Open provides a mock function for the type Pushes
51+func (_mock *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
52+ ret := _mock.Called(ctx, p, name, create)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Open")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok {
60+ r0 = returnFunc(ctx, p, name, create)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// Pushes_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
68+type Pushes_Open_Call struct {
69+ *mock.Call
70+}
71+
72+// Open is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Principal
75+// - name domain.RepoName
76+// - create bool
77+func (_e *Pushes_Expecter) Open(ctx any, p any, name any, create any) *Pushes_Open_Call {
78+ return &Pushes_Open_Call{Call: _e.mock.On("Open", ctx, p, name, create)}
79+}
80+
81+func (_c *Pushes_Open_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool)) *Pushes_Open_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.Principal
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.Principal)
90+ }
91+ var arg2 domain.RepoName
92+ if args[2] != nil {
93+ arg2 = args[2].(domain.RepoName)
94+ }
95+ var arg3 bool
96+ if args[3] != nil {
97+ arg3 = args[3].(bool)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *Pushes_Open_Call) Return(err error) *Pushes_Open_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *Pushes_Open_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error) *Pushes_Open_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
A internal/adapters/githttp/mocks/Repos.go +120 -0
@@ -0,0 +1,120 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewRepos creates a new instance of Repos. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewRepos(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Repos {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Repos{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Repos is an autogenerated mock type for the Repos type
38+type Repos struct {
39+ mock.Mock
40+}
41+
42+type Repos_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Repos) EXPECT() *Repos_Expecter {
47+ return &Repos_Expecter{mock: &_m.Mock}
48+}
49+
50+// Get provides a mock function for the type Repos
51+func (_mock *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
52+ ret := _mock.Called(ctx, p, name)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Get")
56+ }
57+
58+ var r0 domain.Repo
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok {
61+ return returnFunc(ctx, p, name)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok {
64+ r0 = returnFunc(ctx, p, name)
65+ } else {
66+ r0 = ret.Get(0).(domain.Repo)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
69+ r1 = returnFunc(ctx, p, name)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// Repos_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
77+type Repos_Get_Call struct {
78+ *mock.Call
79+}
80+
81+// Get is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - name domain.RepoName
85+func (_e *Repos_Expecter) Get(ctx any, p any, name any) *Repos_Get_Call {
86+ return &Repos_Get_Call{Call: _e.mock.On("Get", ctx, p, name)}
87+}
88+
89+func (_c *Repos_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *Repos_Get_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Principal
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Principal)
98+ }
99+ var arg2 domain.RepoName
100+ if args[2] != nil {
101+ arg2 = args[2].(domain.RepoName)
102+ }
103+ run(
104+ arg0,
105+ arg1,
106+ arg2,
107+ )
108+ })
109+ return _c
110+}
111+
112+func (_c *Repos_Get_Call) Return(repo domain.Repo, err error) *Repos_Get_Call {
113+ _c.Call.Return(repo, err)
114+ return _c
115+}
116+
117+func (_c *Repos_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *Repos_Get_Call {
118+ _c.Call.Return(run)
119+ return _c
120+}
@@ -0,0 +1,120 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewRepos creates a new instance of Repos. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewRepos(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Repos {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Repos{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Repos is an autogenerated mock type for the Repos type
38+type Repos struct {
39+ mock.Mock
40+}
41+
42+type Repos_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Repos) EXPECT() *Repos_Expecter {
47+ return &Repos_Expecter{mock: &_m.Mock}
48+}
49+
50+// Get provides a mock function for the type Repos
51+func (_mock *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
52+ ret := _mock.Called(ctx, p, name)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Get")
56+ }
57+
58+ var r0 domain.Repo
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok {
61+ return returnFunc(ctx, p, name)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok {
64+ r0 = returnFunc(ctx, p, name)
65+ } else {
66+ r0 = ret.Get(0).(domain.Repo)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
69+ r1 = returnFunc(ctx, p, name)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// Repos_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
77+type Repos_Get_Call struct {
78+ *mock.Call
79+}
80+
81+// Get is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - name domain.RepoName
85+func (_e *Repos_Expecter) Get(ctx any, p any, name any) *Repos_Get_Call {
86+ return &Repos_Get_Call{Call: _e.mock.On("Get", ctx, p, name)}
87+}
88+
89+func (_c *Repos_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *Repos_Get_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Principal
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Principal)
98+ }
99+ var arg2 domain.RepoName
100+ if args[2] != nil {
101+ arg2 = args[2].(domain.RepoName)
102+ }
103+ run(
104+ arg0,
105+ arg1,
106+ arg2,
107+ )
108+ })
109+ return _c
110+}
111+
112+func (_c *Repos_Get_Call) Return(repo domain.Repo, err error) *Repos_Get_Call {
113+ _c.Call.Return(repo, err)
114+ return _c
115+}
116+
117+func (_c *Repos_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *Repos_Get_Call {
118+ _c.Call.Return(run)
119+ return _c
120+}
M internal/app/events_test.go +20 -43
@@ -15,50 +15,20 @@ import (
1515 "github.com/stretchr/testify/require"
1616
1717 "bnema.dev/ermite/internal/app"
18+ appmocks "bnema.dev/ermite/internal/app/mocks"
1819 "bnema.dev/ermite/internal/domain"
1920 "bnema.dev/ermite/internal/ports/mocks"
2021 )
2122
22-// fakeAuth is the Revalidator of the rig: tokens are valid until revoked.
23-type fakeAuth struct {
24- mu sync.Mutex
25- revoked map[string]bool
26- calls int
27-}
28-
29-func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error {
30- a.mu.Lock()
31- defer a.mu.Unlock()
32- a.calls++
33- if a.revoked[p.TokenID()] {
34- return domain.ErrUnauthenticated
35- }
36- return nil
37-}
38-
39-func (a *fakeAuth) revoke(id string) {
40- a.mu.Lock()
41- defer a.mu.Unlock()
42- if a.revoked == nil {
43- a.revoked = map[string]bool{}
44- }
45- a.revoked[id] = true
46-}
47-
48-func (a *fakeAuth) count() int {
49- a.mu.Lock()
50- defer a.mu.Unlock()
51- return a.calls
52-}
53-
5423 // eventRig is an Events use case fed by a channel the test writes to.
5524 type eventRig struct {
56- t *testing.T
57- meta *mocks.RepoMetaStore
58- git *mocks.GitRepos
59- feed chan domain.Event
60- auth *fakeAuth
61- events *app.Events
25+ t *testing.T
26+ meta *mocks.RepoMetaStore
27+ git *mocks.GitRepos
28+ feed chan domain.Event
29+ auth *appmocks.Revalidator
30+ revoked sync.Map // token IDs revoked by the test
31+ events *app.Events
6232 }
6333
6434 func newEventRig(t *testing.T, size int) *eventRig {
@@ -73,8 +43,15 @@ func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
7343 meta: mocks.NewRepoMetaStore(t),
7444 git: mocks.NewGitRepos(t),
7545 feed: make(chan domain.Event, 4096),
76- auth: &fakeAuth{},
46+ auth: appmocks.NewRevalidator(t),
7747 }
48+ // Tokens are valid until the test revokes them.
49+ r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {
50+ if _, gone := r.revoked.Load(p.TokenID()); gone {
51+ return domain.ErrUnauthenticated
52+ }
53+ return nil
54+ }).Maybe()
7855 cfg.Auth = r.auth
7956 bus := mocks.NewEventBus(t)
8057 bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
@@ -463,7 +440,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
463440 r.push(pub, 1)
464441 w.next(t)
465442
466- r.auth.revoke("tok")
443+ r.revoked.Store("tok", true)
467444 r.push(priv, 2) // would be delivered to a token that may read priv
468445 select {
469446 case err := <-w.done:
@@ -499,7 +476,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
499476 r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
500477 w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
501478 require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
502- r.auth.revoke("tok")
479+ r.revoked.Store("tok", true)
503480 select {
504481 case err := <-w.done:
505482 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -519,7 +496,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
519496 for range 20 {
520497 w.next(t)
521498 }
522- assert.Zero(t, r.auth.count(), "no store lookup within the interval")
499+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) // no store lookup within the interval
523500 })
524501
525502 t.Run("root and anonymous never ask", func(t *testing.T) {
@@ -531,7 +508,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
531508 r.push(pub, 1)
532509 a.next(t)
533510 b.next(t)
534- assert.Zero(t, r.auth.count())
511+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
535512 })
536513 }
537514
@@ -15,50 +15,20 @@ import (
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17 "bnema.dev/ermite/internal/app"17 "bnema.dev/ermite/internal/app"
18+ appmocks "bnema.dev/ermite/internal/app/mocks"
18 "bnema.dev/ermite/internal/domain"19 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports/mocks"20 "bnema.dev/ermite/internal/ports/mocks"
20 )21 )
21 22
22-// fakeAuth is the Revalidator of the rig: tokens are valid until revoked.
23-type fakeAuth struct {
24- mu sync.Mutex
25- revoked map[string]bool
26- calls int
27-}
28-
29-func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error {
30- a.mu.Lock()
31- defer a.mu.Unlock()
32- a.calls++
33- if a.revoked[p.TokenID()] {
34- return domain.ErrUnauthenticated
35- }
36- return nil
37-}
38-
39-func (a *fakeAuth) revoke(id string) {
40- a.mu.Lock()
41- defer a.mu.Unlock()
42- if a.revoked == nil {
43- a.revoked = map[string]bool{}
44- }
45- a.revoked[id] = true
46-}
47-
48-func (a *fakeAuth) count() int {
49- a.mu.Lock()
50- defer a.mu.Unlock()
51- return a.calls
52-}
53-
54 // eventRig is an Events use case fed by a channel the test writes to.23 // eventRig is an Events use case fed by a channel the test writes to.
55 type eventRig struct {24 type eventRig struct {
56- t *testing.T25+ t *testing.T
57- meta *mocks.RepoMetaStore26+ meta *mocks.RepoMetaStore
58- git *mocks.GitRepos27+ git *mocks.GitRepos
59- feed chan domain.Event28+ feed chan domain.Event
60- auth *fakeAuth29+ auth *appmocks.Revalidator
61- events *app.Events30+ revoked sync.Map // token IDs revoked by the test
31+ events *app.Events
62 }32 }
63 33
64 func newEventRig(t *testing.T, size int) *eventRig {34 func newEventRig(t *testing.T, size int) *eventRig {
@@ -73,8 +43,15 @@ func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
73 meta: mocks.NewRepoMetaStore(t),43 meta: mocks.NewRepoMetaStore(t),
74 git: mocks.NewGitRepos(t),44 git: mocks.NewGitRepos(t),
75 feed: make(chan domain.Event, 4096),45 feed: make(chan domain.Event, 4096),
76- auth: &fakeAuth{},46+ auth: appmocks.NewRevalidator(t),
77 }47 }
48+ // Tokens are valid until the test revokes them.
49+ r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {
50+ if _, gone := r.revoked.Load(p.TokenID()); gone {
51+ return domain.ErrUnauthenticated
52+ }
53+ return nil
54+ }).Maybe()
78 cfg.Auth = r.auth55 cfg.Auth = r.auth
79 bus := mocks.NewEventBus(t)56 bus := mocks.NewEventBus(t)
80 bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {57 bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
@@ -463,7 +440,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
463 r.push(pub, 1)440 r.push(pub, 1)
464 w.next(t)441 w.next(t)
465 442
466- r.auth.revoke("tok")443+ r.revoked.Store("tok", true)
467 r.push(priv, 2) // would be delivered to a token that may read priv444 r.push(priv, 2) // would be delivered to a token that may read priv
468 select {445 select {
469 case err := <-w.done:446 case err := <-w.done:
@@ -499,7 +476,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
499 r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})476 r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
500 w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})477 w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
501 require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)478 require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
502- r.auth.revoke("tok")479+ r.revoked.Store("tok", true)
503 select {480 select {
504 case err := <-w.done:481 case err := <-w.done:
505 assert.ErrorIs(t, err, domain.ErrUnauthenticated)482 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -519,7 +496,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
519 for range 20 {496 for range 20 {
520 w.next(t)497 w.next(t)
521 }498 }
522- assert.Zero(t, r.auth.count(), "no store lookup within the interval")499+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) // no store lookup within the interval
523 })500 })
524 501
525 t.Run("root and anonymous never ask", func(t *testing.T) {502 t.Run("root and anonymous never ask", func(t *testing.T) {
@@ -531,7 +508,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
531 r.push(pub, 1)508 r.push(pub, 1)
532 a.next(t)509 a.next(t)
533 b.next(t)510 b.next(t)
534- assert.Zero(t, r.auth.count())511+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
535 })512 })
536 }513 }
537 514
M internal/app/maintenance_test.go +5 -3
@@ -103,12 +103,14 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
103103 for _, failure := range []error{nil, errors.New("boom")} {
104104 git := mocks.NewGitRepos(t)
105105 git.EXPECT().Maintain(mockAny, n).Return(failure)
106- fresh := &freshener{}
106+ told := make(chan struct{}, 2)
107+ fresh := mocks.NewRepoFreshener(t)
108+ fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()
107109 m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)
108110 m.Schedule(ctx, n)
109- waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told")
111+ waitFor(t, func() bool { return len(told) == 1 }, "the reader to be told")
110112 m.Close(ctx)
111- assert.Equal(t, []domain.RepoName{n}, fresh.seen())
113+ assert.Len(t, told, 1, "told exactly once")
112114 }
113115 }
114116
@@ -103,12 +103,14 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
103 for _, failure := range []error{nil, errors.New("boom")} {103 for _, failure := range []error{nil, errors.New("boom")} {
104 git := mocks.NewGitRepos(t)104 git := mocks.NewGitRepos(t)
105 git.EXPECT().Maintain(mockAny, n).Return(failure)105 git.EXPECT().Maintain(mockAny, n).Return(failure)
106- fresh := &freshener{}106+ told := make(chan struct{}, 2)
107+ fresh := mocks.NewRepoFreshener(t)
108+ fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()
107 m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)109 m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)
108 m.Schedule(ctx, n)110 m.Schedule(ctx, n)
109- waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told")111+ waitFor(t, func() bool { return len(told) == 1 }, "the reader to be told")
110 m.Close(ctx)112 m.Close(ctx)
111- assert.Equal(t, []domain.RepoName{n}, fresh.seen())113+ assert.Len(t, told, 1, "told exactly once")
112 }114 }
113 }115 }
114 116
M internal/app/mergerequests_integration_test.go +29 -4
@@ -16,6 +16,7 @@ import (
1616 "time"
1717
1818 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
1920 "github.com/stretchr/testify/require"
2021
2122 "bnema.dev/ermite/internal/adapters/eventbus"
@@ -23,9 +24,28 @@ import (
2324 "bnema.dev/ermite/internal/adapters/kvstore"
2425 "bnema.dev/ermite/internal/adapters/sshsig"
2526 "bnema.dev/ermite/internal/app"
27+ appmocks "bnema.dev/ermite/internal/app/mocks"
2628 "bnema.dev/ermite/internal/domain"
2729 )
2830
31+// queuedRepos records the repositories whose maintenance was queued.
32+type queuedRepos struct {
33+ mu sync.Mutex
34+ rs []domain.RepoName
35+}
36+
37+func (q *queuedRepos) add(_ context.Context, n domain.RepoName) {
38+ q.mu.Lock()
39+ defer q.mu.Unlock()
40+ q.rs = append(q.rs, n)
41+}
42+
43+func (q *queuedRepos) names() []domain.RepoName {
44+ q.mu.Lock()
45+ defer q.mu.Unlock()
46+ return append([]domain.RepoName(nil), q.rs...)
47+}
48+
2949 // mrRig runs the merge request use cases over the real adapters: a data
3050 // directory with ermite-kv and bare repositories, and a work tree to push
3151 // branches from.
@@ -39,7 +59,8 @@ type mrRig struct {
3959 store *kvstore.Store
4060 git *gitcli.Git
4161 bus *eventbus.Bus
42- sched *scheduler
62+ sched *appmocks.MaintenanceScheduler
63+ queued *queuedRepos
4364 events <-chan domain.Event
4465 work string
4566 repo domain.RepoName
@@ -74,8 +95,12 @@ func newMRRig(t *testing.T) *mrRig {
7495 require.NoError(t, err)
7596 bus := eventbus.NewWithBuffer(1024)
7697 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
98+ sched := appmocks.NewMaintenanceScheduler(t)
99+ queued := &queuedRepos{}
100+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Run(queued.add).Return().Maybe()
77101 r := &mrRig{
78- t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx),
102+ sched: sched, queued: queued,
103+ t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
79104 repos: app.NewRepos(store, git, bus, fixedClock{}),
80105 key: app.NewSigningKey(signer, committer),
81106 work: t.TempDir(),
@@ -1238,7 +1263,7 @@ func TestMergeRecordsAPush(t *testing.T) {
12381263 before, err := r.repos.Get(r.ctx, root, r.repo)
12391264 require.NoError(t, err)
12401265 require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1241- assert.Empty(t, r.sched.names)
1266+ assert.Empty(t, r.queued.names())
12421267
12431268 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head})
12441269 require.NoError(t, err)
@@ -1246,7 +1271,7 @@ func TestMergeRecordsAPush(t *testing.T) {
12461271 after, err := r.repos.Get(r.ctx, root, r.repo)
12471272 require.NoError(t, err)
12481273 assert.Equal(t, now, after.PushedAt)
1249- assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued")
1274+ assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued")
12501275 })
12511276 }
12521277 }
@@ -16,6 +16,7 @@ import (
16 "time"16 "time"
17 17
18 "github.com/stretchr/testify/assert"18 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
20 21
21 "bnema.dev/ermite/internal/adapters/eventbus"22 "bnema.dev/ermite/internal/adapters/eventbus"
@@ -23,9 +24,28 @@ import (
23 "bnema.dev/ermite/internal/adapters/kvstore"24 "bnema.dev/ermite/internal/adapters/kvstore"
24 "bnema.dev/ermite/internal/adapters/sshsig"25 "bnema.dev/ermite/internal/adapters/sshsig"
25 "bnema.dev/ermite/internal/app"26 "bnema.dev/ermite/internal/app"
27+ appmocks "bnema.dev/ermite/internal/app/mocks"
26 "bnema.dev/ermite/internal/domain"28 "bnema.dev/ermite/internal/domain"
27 )29 )
28 30
31+// queuedRepos records the repositories whose maintenance was queued.
32+type queuedRepos struct {
33+ mu sync.Mutex
34+ rs []domain.RepoName
35+}
36+
37+func (q *queuedRepos) add(_ context.Context, n domain.RepoName) {
38+ q.mu.Lock()
39+ defer q.mu.Unlock()
40+ q.rs = append(q.rs, n)
41+}
42+
43+func (q *queuedRepos) names() []domain.RepoName {
44+ q.mu.Lock()
45+ defer q.mu.Unlock()
46+ return append([]domain.RepoName(nil), q.rs...)
47+}
48+
29 // mrRig runs the merge request use cases over the real adapters: a data49 // mrRig runs the merge request use cases over the real adapters: a data
30 // directory with ermite-kv and bare repositories, and a work tree to push50 // directory with ermite-kv and bare repositories, and a work tree to push
31 // branches from.51 // branches from.
@@ -39,7 +59,8 @@ type mrRig struct {
39 store *kvstore.Store59 store *kvstore.Store
40 git *gitcli.Git60 git *gitcli.Git
41 bus *eventbus.Bus61 bus *eventbus.Bus
42- sched *scheduler62+ sched *appmocks.MaintenanceScheduler
63+ queued *queuedRepos
43 events <-chan domain.Event64 events <-chan domain.Event
44 work string65 work string
45 repo domain.RepoName66 repo domain.RepoName
@@ -74,8 +95,12 @@ func newMRRig(t *testing.T) *mrRig {
74 require.NoError(t, err)95 require.NoError(t, err)
75 bus := eventbus.NewWithBuffer(1024)96 bus := eventbus.NewWithBuffer(1024)
76 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}97 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
98+ sched := appmocks.NewMaintenanceScheduler(t)
99+ queued := &queuedRepos{}
100+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Run(queued.add).Return().Maybe()
77 r := &mrRig{101 r := &mrRig{
78- t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx),102+ sched: sched, queued: queued,
103+ t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
79 repos: app.NewRepos(store, git, bus, fixedClock{}),104 repos: app.NewRepos(store, git, bus, fixedClock{}),
80 key: app.NewSigningKey(signer, committer),105 key: app.NewSigningKey(signer, committer),
81 work: t.TempDir(),106 work: t.TempDir(),
@@ -1238,7 +1263,7 @@ func TestMergeRecordsAPush(t *testing.T) {
1238 before, err := r.repos.Get(r.ctx, root, r.repo)1263 before, err := r.repos.Get(r.ctx, root, r.repo)
1239 require.NoError(t, err)1264 require.NoError(t, err)
1240 require.True(t, before.PushedAt.IsZero(), "no push recorded yet")1265 require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1241- assert.Empty(t, r.sched.names)1266+ assert.Empty(t, r.queued.names())
1242 1267
1243 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head})1268 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head})
1244 require.NoError(t, err)1269 require.NoError(t, err)
@@ -1246,7 +1271,7 @@ func TestMergeRecordsAPush(t *testing.T) {
1246 after, err := r.repos.Get(r.ctx, root, r.repo)1271 after, err := r.repos.Get(r.ctx, root, r.repo)
1247 require.NoError(t, err)1272 require.NoError(t, err)
1248 assert.Equal(t, now, after.PushedAt)1273 assert.Equal(t, now, after.PushedAt)
1249- assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued")1274+ assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued")
1250 })1275 })
1251 }1276 }
1252 }1277 }
M internal/app/mergerequests_test.go +4 -1
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "bnema.dev/ermite/internal/app"
15+ appmocks "bnema.dev/ermite/internal/app/mocks"
1516 "bnema.dev/ermite/internal/domain"
1617 "bnema.dev/ermite/internal/ports/mocks"
1718 )
@@ -36,9 +37,11 @@ func newMRUnit(t *testing.T) *mrUnit {
3637 }
3738 clock := mocks.NewClock(t)
3839 clock.EXPECT().Now().Return(now).Maybe()
40+ sched := appmocks.NewMaintenanceScheduler(t)
41+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
3942 u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
4043 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
41- Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: &scheduler{},
44+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
4245 })
4346 return u
4447 }
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "bnema.dev/ermite/internal/app"14 "bnema.dev/ermite/internal/app"
15+ appmocks "bnema.dev/ermite/internal/app/mocks"
15 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports/mocks"17 "bnema.dev/ermite/internal/ports/mocks"
17 )18 )
@@ -36,9 +37,11 @@ func newMRUnit(t *testing.T) *mrUnit {
36 }37 }
37 clock := mocks.NewClock(t)38 clock := mocks.NewClock(t)
38 clock.EXPECT().Now().Return(now).Maybe()39 clock.EXPECT().Now().Return(now).Maybe()
40+ sched := appmocks.NewMaintenanceScheduler(t)
41+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
39 u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{42 u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
40 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,43 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
41- Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: &scheduler{},44+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
42 })45 })
43 return u46 return u
44 }47 }
A internal/app/mocks/MaintenanceScheduler.go +94 -0
@@ -0,0 +1,94 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMaintenanceScheduler creates a new instance of MaintenanceScheduler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMaintenanceScheduler(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MaintenanceScheduler {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MaintenanceScheduler{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MaintenanceScheduler is an autogenerated mock type for the MaintenanceScheduler type
38+type MaintenanceScheduler struct {
39+ mock.Mock
40+}
41+
42+type MaintenanceScheduler_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MaintenanceScheduler) EXPECT() *MaintenanceScheduler_Expecter {
47+ return &MaintenanceScheduler_Expecter{mock: &_m.Mock}
48+}
49+
50+// Schedule provides a mock function for the type MaintenanceScheduler
51+func (_mock *MaintenanceScheduler) Schedule(ctx context.Context, name domain.RepoName) {
52+ _mock.Called(ctx, name)
53+ return
54+}
55+
56+// MaintenanceScheduler_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule'
57+type MaintenanceScheduler_Schedule_Call struct {
58+ *mock.Call
59+}
60+
61+// Schedule is a helper method to define mock.On call
62+// - ctx context.Context
63+// - name domain.RepoName
64+func (_e *MaintenanceScheduler_Expecter) Schedule(ctx any, name any) *MaintenanceScheduler_Schedule_Call {
65+ return &MaintenanceScheduler_Schedule_Call{Call: _e.mock.On("Schedule", ctx, name)}
66+}
67+
68+func (_c *MaintenanceScheduler_Schedule_Call) Run(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.RepoName
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.RepoName)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *MaintenanceScheduler_Schedule_Call) Return() *MaintenanceScheduler_Schedule_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *MaintenanceScheduler_Schedule_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call {
92+ _c.Run(run)
93+ return _c
94+}
@@ -0,0 +1,94 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMaintenanceScheduler creates a new instance of MaintenanceScheduler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMaintenanceScheduler(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MaintenanceScheduler {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MaintenanceScheduler{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MaintenanceScheduler is an autogenerated mock type for the MaintenanceScheduler type
38+type MaintenanceScheduler struct {
39+ mock.Mock
40+}
41+
42+type MaintenanceScheduler_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MaintenanceScheduler) EXPECT() *MaintenanceScheduler_Expecter {
47+ return &MaintenanceScheduler_Expecter{mock: &_m.Mock}
48+}
49+
50+// Schedule provides a mock function for the type MaintenanceScheduler
51+func (_mock *MaintenanceScheduler) Schedule(ctx context.Context, name domain.RepoName) {
52+ _mock.Called(ctx, name)
53+ return
54+}
55+
56+// MaintenanceScheduler_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule'
57+type MaintenanceScheduler_Schedule_Call struct {
58+ *mock.Call
59+}
60+
61+// Schedule is a helper method to define mock.On call
62+// - ctx context.Context
63+// - name domain.RepoName
64+func (_e *MaintenanceScheduler_Expecter) Schedule(ctx any, name any) *MaintenanceScheduler_Schedule_Call {
65+ return &MaintenanceScheduler_Schedule_Call{Call: _e.mock.On("Schedule", ctx, name)}
66+}
67+
68+func (_c *MaintenanceScheduler_Schedule_Call) Run(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.RepoName
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.RepoName)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *MaintenanceScheduler_Schedule_Call) Return() *MaintenanceScheduler_Schedule_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *MaintenanceScheduler_Schedule_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call {
92+ _c.Run(run)
93+ return _c
94+}
A internal/app/mocks/Revalidator.go +105 -0
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewRevalidator creates a new instance of Revalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewRevalidator(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Revalidator {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Revalidator{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Revalidator is an autogenerated mock type for the Revalidator type
38+type Revalidator struct {
39+ mock.Mock
40+}
41+
42+type Revalidator_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Revalidator) EXPECT() *Revalidator_Expecter {
47+ return &Revalidator_Expecter{mock: &_m.Mock}
48+}
49+
50+// Revalidate provides a mock function for the type Revalidator
51+func (_mock *Revalidator) Revalidate(ctx context.Context, p domain.Principal) error {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Revalidate")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok {
60+ r0 = returnFunc(ctx, p)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// Revalidator_Revalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revalidate'
68+type Revalidator_Revalidate_Call struct {
69+ *mock.Call
70+}
71+
72+// Revalidate is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Principal
75+func (_e *Revalidator_Expecter) Revalidate(ctx any, p any) *Revalidator_Revalidate_Call {
76+ return &Revalidator_Revalidate_Call{Call: _e.mock.On("Revalidate", ctx, p)}
77+}
78+
79+func (_c *Revalidator_Revalidate_Call) Run(run func(ctx context.Context, p domain.Principal)) *Revalidator_Revalidate_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 domain.Principal
86+ if args[1] != nil {
87+ arg1 = args[1].(domain.Principal)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *Revalidator_Revalidate_Call) Return(err error) *Revalidator_Revalidate_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *Revalidator_Revalidate_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *Revalidator_Revalidate_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewRevalidator creates a new instance of Revalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewRevalidator(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *Revalidator {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &Revalidator{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// Revalidator is an autogenerated mock type for the Revalidator type
38+type Revalidator struct {
39+ mock.Mock
40+}
41+
42+type Revalidator_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *Revalidator) EXPECT() *Revalidator_Expecter {
47+ return &Revalidator_Expecter{mock: &_m.Mock}
48+}
49+
50+// Revalidate provides a mock function for the type Revalidator
51+func (_mock *Revalidator) Revalidate(ctx context.Context, p domain.Principal) error {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Revalidate")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok {
60+ r0 = returnFunc(ctx, p)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// Revalidator_Revalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revalidate'
68+type Revalidator_Revalidate_Call struct {
69+ *mock.Call
70+}
71+
72+// Revalidate is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Principal
75+func (_e *Revalidator_Expecter) Revalidate(ctx any, p any) *Revalidator_Revalidate_Call {
76+ return &Revalidator_Revalidate_Call{Call: _e.mock.On("Revalidate", ctx, p)}
77+}
78+
79+func (_c *Revalidator_Revalidate_Call) Run(run func(ctx context.Context, p domain.Principal)) *Revalidator_Revalidate_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 domain.Principal
86+ if args[1] != nil {
87+ arg1 = args[1].(domain.Principal)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *Revalidator_Revalidate_Call) Return(err error) *Revalidator_Revalidate_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *Revalidator_Revalidate_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *Revalidator_Revalidate_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
M internal/app/pushes_test.go +14 -36
@@ -5,7 +5,6 @@ package app_test
55 import (
66 "context"
77 "errors"
8- "sync"
98 "testing"
109 "time"
1110
@@ -14,47 +13,19 @@ import (
1413 "github.com/stretchr/testify/require"
1514
1615 "bnema.dev/ermite/internal/app"
16+ appmocks "bnema.dev/ermite/internal/app/mocks"
1717 "bnema.dev/ermite/internal/domain"
1818 "bnema.dev/ermite/internal/ports"
1919 "bnema.dev/ermite/internal/ports/mocks"
2020 )
2121
22-type scheduler struct {
23- mu sync.Mutex
24- names []domain.RepoName
25-}
26-
27-func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) {
28- s.mu.Lock()
29- defer s.mu.Unlock()
30- s.names = append(s.names, n)
31-}
32-
33-// freshener records the repositories it was told changed.
34-type freshener struct {
35- mu sync.Mutex
36- names []domain.RepoName
37-}
38-
39-func (f *freshener) Refresh(_ context.Context, n domain.RepoName) {
40- f.mu.Lock()
41- defer f.mu.Unlock()
42- f.names = append(f.names, n)
43-}
44-
45-func (f *freshener) seen() []domain.RepoName {
46- f.mu.Lock()
47- defer f.mu.Unlock()
48- return append([]domain.RepoName(nil), f.names...)
49-}
50-
5122 type pushFixture struct {
5223 meta *mocks.RepoMetaStore
5324 git *mocks.GitRepos
5425 bus *mocks.EventBus
5526 clock *mocks.Clock
5627 tokens *mocks.TokenStore
57- sched *scheduler
28+ sched *appmocks.MaintenanceScheduler
5829 pushes *app.Pushes
5930 }
6031
@@ -66,7 +37,7 @@ func newPushFixture(t *testing.T) *pushFixture {
6637 bus: mocks.NewEventBus(t),
6738 clock: mocks.NewClock(t),
6839 tokens: mocks.NewTokenStore(t),
69- sched: &scheduler{},
40+ sched: appmocks.NewMaintenanceScheduler(t),
7041 }
7142 f.clock.EXPECT().Now().Return(now).Maybe()
7243 repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)
@@ -231,6 +202,7 @@ func TestPushesRecord(t *testing.T) {
231202
232203 t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
233204 f := newPushFixture(t)
205+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
234206 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
235207 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
236208 f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
@@ -238,23 +210,24 @@ func TestPushesRecord(t *testing.T) {
238210 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
239211
240212 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
241- assert.Equal(t, []domain.RepoName{n}, f.sched.names)
242213 })
243214
244215 t.Run("tells the reader that the repository changed", func(t *testing.T) {
245216 f := newPushFixture(t)
246- fresh := &freshener{}
217+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
218+ fresh := mocks.NewRepoFreshener(t)
219+ fresh.EXPECT().Refresh(ctx, n).Return().Once()
247220 f.pushes.WithFreshener(fresh)
248221 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
249222 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
250223 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
251224 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
252225 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
253- assert.Equal(t, []domain.RepoName{n}, fresh.seen())
254226 })
255227
256228 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
257229 f := newPushFixture(t)
230+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
258231 repo := domain.Repo{Name: n}
259232 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
260233 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
@@ -267,6 +240,7 @@ func TestPushesRecord(t *testing.T) {
267240
268241 t.Run("a deleted repository is not an error", func(t *testing.T) {
269242 f := newPushFixture(t)
243+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
270244 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
271245 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
272246 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -275,6 +249,7 @@ func TestPushesRecord(t *testing.T) {
275249
276250 t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {
277251 f := newPushFixture(t)
252+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
278253 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
279254 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
280255 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -283,6 +258,7 @@ func TestPushesRecord(t *testing.T) {
283258
284259 t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {
285260 f := newPushFixture(t)
261+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
286262 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
287263 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
288264 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
@@ -292,13 +268,13 @@ func TestPushesRecord(t *testing.T) {
292268
293269 t.Run("every step runs when one fails", func(t *testing.T) {
294270 f := newPushFixture(t)
271+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
295272 boom := errors.New("boom")
296273 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
297274 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
298275 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
299276 err := f.pushes.Record(ctx, p, n, ups)
300277 assert.ErrorIs(t, err, boom)
301- assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled")
302278 })
303279 }
304280
@@ -328,6 +304,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
328304 for _, tt := range tests {
329305 t.Run(tt.name, func(t *testing.T) {
330306 f := newPushFixture(t)
307+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
331308 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
332309 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
333310 if tt.want != "" {
@@ -340,6 +317,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
340317
341318 t.Run("set failure is reported", func(t *testing.T) {
342319 f := newPushFixture(t)
320+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
343321 boom := errors.New("boom")
344322 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
345323 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
@@ -5,7 +5,6 @@ package app_test
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8- "sync"
9 "testing"8 "testing"
10 "time"9 "time"
11 10
@@ -14,47 +13,19 @@ import (
14 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
15 14
16 "bnema.dev/ermite/internal/app"15 "bnema.dev/ermite/internal/app"
16+ appmocks "bnema.dev/ermite/internal/app/mocks"
17 "bnema.dev/ermite/internal/domain"17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"19 "bnema.dev/ermite/internal/ports/mocks"
20 )20 )
21 21
22-type scheduler struct {
23- mu sync.Mutex
24- names []domain.RepoName
25-}
26-
27-func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) {
28- s.mu.Lock()
29- defer s.mu.Unlock()
30- s.names = append(s.names, n)
31-}
32-
33-// freshener records the repositories it was told changed.
34-type freshener struct {
35- mu sync.Mutex
36- names []domain.RepoName
37-}
38-
39-func (f *freshener) Refresh(_ context.Context, n domain.RepoName) {
40- f.mu.Lock()
41- defer f.mu.Unlock()
42- f.names = append(f.names, n)
43-}
44-
45-func (f *freshener) seen() []domain.RepoName {
46- f.mu.Lock()
47- defer f.mu.Unlock()
48- return append([]domain.RepoName(nil), f.names...)
49-}
50-
51 type pushFixture struct {22 type pushFixture struct {
52 meta *mocks.RepoMetaStore23 meta *mocks.RepoMetaStore
53 git *mocks.GitRepos24 git *mocks.GitRepos
54 bus *mocks.EventBus25 bus *mocks.EventBus
55 clock *mocks.Clock26 clock *mocks.Clock
56 tokens *mocks.TokenStore27 tokens *mocks.TokenStore
57- sched *scheduler28+ sched *appmocks.MaintenanceScheduler
58 pushes *app.Pushes29 pushes *app.Pushes
59 }30 }
60 31
@@ -66,7 +37,7 @@ func newPushFixture(t *testing.T) *pushFixture {
66 bus: mocks.NewEventBus(t),37 bus: mocks.NewEventBus(t),
67 clock: mocks.NewClock(t),38 clock: mocks.NewClock(t),
68 tokens: mocks.NewTokenStore(t),39 tokens: mocks.NewTokenStore(t),
69- sched: &scheduler{},40+ sched: appmocks.NewMaintenanceScheduler(t),
70 }41 }
71 f.clock.EXPECT().Now().Return(now).Maybe()42 f.clock.EXPECT().Now().Return(now).Maybe()
72 repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)43 repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)
@@ -231,6 +202,7 @@ func TestPushesRecord(t *testing.T) {
231 202
232 t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {203 t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
233 f := newPushFixture(t)204 f := newPushFixture(t)
205+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
234 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}206 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
235 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)207 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
236 f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)208 f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
@@ -238,23 +210,24 @@ func TestPushesRecord(t *testing.T) {
238 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()210 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
239 211
240 require.NoError(t, f.pushes.Record(ctx, p, n, ups))212 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
241- assert.Equal(t, []domain.RepoName{n}, f.sched.names)
242 })213 })
243 214
244 t.Run("tells the reader that the repository changed", func(t *testing.T) {215 t.Run("tells the reader that the repository changed", func(t *testing.T) {
245 f := newPushFixture(t)216 f := newPushFixture(t)
246- fresh := &freshener{}217+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
218+ fresh := mocks.NewRepoFreshener(t)
219+ fresh.EXPECT().Refresh(ctx, n).Return().Once()
247 f.pushes.WithFreshener(fresh)220 f.pushes.WithFreshener(fresh)
248 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)221 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
249 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)222 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
250 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)223 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
251 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()224 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
252 require.NoError(t, f.pushes.Record(ctx, p, n, ups))225 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
253- assert.Equal(t, []domain.RepoName{n}, fresh.seen())
254 })226 })
255 227
256 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {228 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
257 f := newPushFixture(t)229 f := newPushFixture(t)
230+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
258 repo := domain.Repo{Name: n}231 repo := domain.Repo{Name: n}
259 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()232 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
260 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()233 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
@@ -267,6 +240,7 @@ func TestPushesRecord(t *testing.T) {
267 240
268 t.Run("a deleted repository is not an error", func(t *testing.T) {241 t.Run("a deleted repository is not an error", func(t *testing.T) {
269 f := newPushFixture(t)242 f := newPushFixture(t)
243+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
270 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)244 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
271 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)245 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
272 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()246 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -275,6 +249,7 @@ func TestPushesRecord(t *testing.T) {
275 249
276 t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {250 t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {
277 f := newPushFixture(t)251 f := newPushFixture(t)
252+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
278 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)253 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
279 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)254 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
280 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()255 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -283,6 +258,7 @@ func TestPushesRecord(t *testing.T) {
283 258
284 t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {259 t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {
285 f := newPushFixture(t)260 f := newPushFixture(t)
261+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
286 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)262 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
287 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)263 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
288 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)264 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
@@ -292,13 +268,13 @@ func TestPushesRecord(t *testing.T) {
292 268
293 t.Run("every step runs when one fails", func(t *testing.T) {269 t.Run("every step runs when one fails", func(t *testing.T) {
294 f := newPushFixture(t)270 f := newPushFixture(t)
271+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
295 boom := errors.New("boom")272 boom := errors.New("boom")
296 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)273 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
297 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)274 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
298 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()275 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
299 err := f.pushes.Record(ctx, p, n, ups)276 err := f.pushes.Record(ctx, p, n, ups)
300 assert.ErrorIs(t, err, boom)277 assert.ErrorIs(t, err, boom)
301- assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled")
302 })278 })
303 }279 }
304 280
@@ -328,6 +304,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
328 for _, tt := range tests {304 for _, tt := range tests {
329 t.Run(tt.name, func(t *testing.T) {305 t.Run(tt.name, func(t *testing.T) {
330 f := newPushFixture(t)306 f := newPushFixture(t)
307+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
331 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)308 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
332 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)309 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
333 if tt.want != "" {310 if tt.want != "" {
@@ -340,6 +317,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
340 317
341 t.Run("set failure is reported", func(t *testing.T) {318 t.Run("set failure is reported", func(t *testing.T) {
342 f := newPushFixture(t)319 f := newPushFixture(t)
320+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
343 boom := errors.New("boom")321 boom := errors.New("boom")
344 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)322 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
345 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)323 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
M internal/app/sessions_test.go +30 -50
@@ -6,70 +6,50 @@ import (
66 "context"
77 "strings"
88 "sync"
9+ "sync/atomic"
910 "testing"
1011 "time"
1112
1213 "github.com/golang-jwt/jwt/v5"
1314 "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
1416 "github.com/stretchr/testify/require"
1517
1618 "bnema.dev/ermite/internal/app"
1719 "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports/mocks"
1821 )
1922
20-// memSessions is an in-memory ports.SessionStore.
21-type memSessions struct {
22- mu sync.Mutex
23- key []byte
24- epoch uint64
25-}
26-
27-func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil }
28-func (m *memSessions) Epoch(context.Context) (uint64, error) {
29- m.mu.Lock()
30- defer m.mu.Unlock()
31- return m.epoch, nil
32-}
33-
34-func (m *memSessions) BumpEpoch(context.Context) (uint64, error) {
35- m.mu.Lock()
36- defer m.mu.Unlock()
37- m.epoch++
38- return m.epoch, nil
39-}
40-
41-// testClock is a clock the test moves.
42-type testClock struct {
43- mu sync.Mutex
44- t time.Time
45-}
46-
47-func (c *testClock) Now() time.Time {
48- c.mu.Lock()
49- defer c.mu.Unlock()
50- return c.t
51-}
52-
53-func (c *testClock) advance(d time.Duration) {
54- c.mu.Lock()
55- defer c.mu.Unlock()
56- c.t = c.t.Add(d)
57-}
58-
5923 type sessionFixture struct {
60- store *memSessions
61- clock *testClock
24+ store *mocks.SessionStore
25+ clock *mocks.Clock
26+ at atomic.Pointer[time.Time] // what the clock reads; the test moves it
6227 sessions *app.Sessions
6328 key []byte
6429 }
6530
31+// advance moves the clock forward by d.
32+func (f *sessionFixture) advance(d time.Duration) {
33+ next := f.at.Load().Add(d)
34+ f.at.Store(&next)
35+}
36+
6637 func newSessionFixture(t *testing.T) *sessionFixture {
6738 t.Helper()
6839 f := &sessionFixture{
69- store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")},
70- clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)},
40+ store: mocks.NewSessionStore(t),
41+ clock: mocks.NewClock(t),
42+ key: []byte("0123456789abcdef0123456789abcdef"),
7143 }
72- f.key = f.store.key
44+ start := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
45+ f.at.Store(&start)
46+ f.clock.EXPECT().Now().RunAndReturn(func() time.Time { return *f.at.Load() }).Maybe()
47+
48+ var epoch atomic.Uint64
49+ f.store.EXPECT().Key(mock.Anything).Return(f.key, nil).Maybe()
50+ f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe()
51+ f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe()
52+
7353 s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")
7454 require.NoError(t, err)
7555 f.sessions = s
@@ -155,7 +135,7 @@ func TestLoginLinkLifecycle(t *testing.T) {
155135
156136 // Expired.
157137 nonce = f.nonce(t)
158- f.clock.advance(app.LoginLinkTTL)
138+ f.advance(app.LoginLinkTTL)
159139 assert.False(t, f.sessions.LoginLinkValid(nonce))
160140 _, _, err = f.sessions.Login(ctx, nonce)
161141 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
@@ -213,17 +193,17 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
213193 // It stops working when the token would have.
214194 tok, _, err := login(tokenExpiring(2 * time.Hour))
215195 require.NoError(t, err)
216- f.clock.advance(2*time.Hour - time.Second)
196+ f.advance(2*time.Hour - time.Second)
217197 _, err = f.sessions.Verify(ctx, tok)
218198 assert.NoError(t, err)
219- f.clock.advance(time.Second)
199+ f.advance(time.Second)
220200 _, err = f.sessions.Verify(ctx, tok)
221201 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session")
222202
223203 // A token that expires before the link is used opens nothing.
224204 link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second))
225205 require.NoError(t, err)
226- f.clock.advance(31 * time.Second)
206+ f.advance(31 * time.Second)
227207 _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
228208 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
229209 }
@@ -259,7 +239,7 @@ func TestLoginLinksAreBounded(t *testing.T) {
259239 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())
260240 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
261241
262- f.clock.advance(app.LoginLinkTTL)
242+ f.advance(app.LoginLinkTTL)
263243 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())
264244 assert.NoError(t, err, "expired links make room")
265245 }
@@ -322,7 +302,7 @@ func TestSessionTokenVerification(t *testing.T) {
322302 assert.NoError(t, err)
323303
324304 // Expiry after the session lifetime; and the epoch ends every session.
325- f.clock.advance(app.SessionTTL)
305+ f.advance(app.SessionTTL)
326306 _, err = f.sessions.Verify(ctx, token)
327307 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
328308 }
@@ -6,70 +6,50 @@ import (
6 "context"6 "context"
7 "strings"7 "strings"
8 "sync"8 "sync"
9+ "sync/atomic"
9 "testing"10 "testing"
10 "time"11 "time"
11 12
12 "github.com/golang-jwt/jwt/v5"13 "github.com/golang-jwt/jwt/v5"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
15 17
16 "bnema.dev/ermite/internal/app"18 "bnema.dev/ermite/internal/app"
17 "bnema.dev/ermite/internal/domain"19 "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports/mocks"
18 )21 )
19 22
20-// memSessions is an in-memory ports.SessionStore.
21-type memSessions struct {
22- mu sync.Mutex
23- key []byte
24- epoch uint64
25-}
26-
27-func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil }
28-func (m *memSessions) Epoch(context.Context) (uint64, error) {
29- m.mu.Lock()
30- defer m.mu.Unlock()
31- return m.epoch, nil
32-}
33-
34-func (m *memSessions) BumpEpoch(context.Context) (uint64, error) {
35- m.mu.Lock()
36- defer m.mu.Unlock()
37- m.epoch++
38- return m.epoch, nil
39-}
40-
41-// testClock is a clock the test moves.
42-type testClock struct {
43- mu sync.Mutex
44- t time.Time
45-}
46-
47-func (c *testClock) Now() time.Time {
48- c.mu.Lock()
49- defer c.mu.Unlock()
50- return c.t
51-}
52-
53-func (c *testClock) advance(d time.Duration) {
54- c.mu.Lock()
55- defer c.mu.Unlock()
56- c.t = c.t.Add(d)
57-}
58-
59 type sessionFixture struct {23 type sessionFixture struct {
60- store *memSessions24+ store *mocks.SessionStore
61- clock *testClock25+ clock *mocks.Clock
26+ at atomic.Pointer[time.Time] // what the clock reads; the test moves it
62 sessions *app.Sessions27 sessions *app.Sessions
63 key []byte28 key []byte
64 }29 }
65 30
31+// advance moves the clock forward by d.
32+func (f *sessionFixture) advance(d time.Duration) {
33+ next := f.at.Load().Add(d)
34+ f.at.Store(&next)
35+}
36+
66 func newSessionFixture(t *testing.T) *sessionFixture {37 func newSessionFixture(t *testing.T) *sessionFixture {
67 t.Helper()38 t.Helper()
68 f := &sessionFixture{39 f := &sessionFixture{
69- store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")},40+ store: mocks.NewSessionStore(t),
70- clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)},41+ clock: mocks.NewClock(t),
42+ key: []byte("0123456789abcdef0123456789abcdef"),
71 }43 }
72- f.key = f.store.key44+ start := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
45+ f.at.Store(&start)
46+ f.clock.EXPECT().Now().RunAndReturn(func() time.Time { return *f.at.Load() }).Maybe()
47+
48+ var epoch atomic.Uint64
49+ f.store.EXPECT().Key(mock.Anything).Return(f.key, nil).Maybe()
50+ f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe()
51+ f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe()
52+
73 s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")53 s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")
74 require.NoError(t, err)54 require.NoError(t, err)
75 f.sessions = s55 f.sessions = s
@@ -155,7 +135,7 @@ func TestLoginLinkLifecycle(t *testing.T) {
155 135
156 // Expired.136 // Expired.
157 nonce = f.nonce(t)137 nonce = f.nonce(t)
158- f.clock.advance(app.LoginLinkTTL)138+ f.advance(app.LoginLinkTTL)
159 assert.False(t, f.sessions.LoginLinkValid(nonce))139 assert.False(t, f.sessions.LoginLinkValid(nonce))
160 _, _, err = f.sessions.Login(ctx, nonce)140 _, _, err = f.sessions.Login(ctx, nonce)
161 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")141 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
@@ -213,17 +193,17 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
213 // It stops working when the token would have.193 // It stops working when the token would have.
214 tok, _, err := login(tokenExpiring(2 * time.Hour))194 tok, _, err := login(tokenExpiring(2 * time.Hour))
215 require.NoError(t, err)195 require.NoError(t, err)
216- f.clock.advance(2*time.Hour - time.Second)196+ f.advance(2*time.Hour - time.Second)
217 _, err = f.sessions.Verify(ctx, tok)197 _, err = f.sessions.Verify(ctx, tok)
218 assert.NoError(t, err)198 assert.NoError(t, err)
219- f.clock.advance(time.Second)199+ f.advance(time.Second)
220 _, err = f.sessions.Verify(ctx, tok)200 _, err = f.sessions.Verify(ctx, tok)
221 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session")201 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session")
222 202
223 // A token that expires before the link is used opens nothing.203 // A token that expires before the link is used opens nothing.
224 link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second))204 link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second))
225 require.NoError(t, err)205 require.NoError(t, err)
226- f.clock.advance(31 * time.Second)206+ f.advance(31 * time.Second)
227 _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))207 _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
228 assert.ErrorIs(t, err, domain.ErrUnauthenticated)208 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
229 }209 }
@@ -259,7 +239,7 @@ func TestLoginLinksAreBounded(t *testing.T) {
259 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())239 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())
260 assert.ErrorIs(t, err, domain.ErrResourceExhausted)240 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
261 241
262- f.clock.advance(app.LoginLinkTTL)242+ f.advance(app.LoginLinkTTL)
263 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())243 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())
264 assert.NoError(t, err, "expired links make room")244 assert.NoError(t, err, "expired links make room")
265 }245 }
@@ -322,7 +302,7 @@ func TestSessionTokenVerification(t *testing.T) {
322 assert.NoError(t, err)302 assert.NoError(t, err)
323 303
324 // Expiry after the session lifetime; and the epoch ends every session.304 // Expiry after the session lifetime; and the epoch ends every session.
325- f.clock.advance(app.SessionTTL)305+ f.advance(app.SessionTTL)
326 _, err = f.sessions.Verify(ctx, token)306 _, err = f.sessions.Verify(ctx, token)
327 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")307 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
328 }308 }
M internal/app/tokens_test.go +14 -5
@@ -52,11 +52,20 @@ func names(t *testing.T, ss ...string) []domain.RepoName {
5252 // Labels that only contain a reserved word are fine.
5353 func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) {
5454 ctx := context.Background()
55- for _, label := range []string{"owner2", "my owner", "roots", "ow ner", "root-ci"} {
56- f := newTokenFixture(t)
57- f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
58- _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: label, Scopes: scopes(domain.ScopeRepoRead)})
59- assert.NoError(t, err, label)
55+ tests := []struct{ name, label string }{
56+ {"digit suffix", "owner2"},
57+ {"prefixed with a word", "my owner"},
58+ {"plural", "roots"},
59+ {"split word", "ow ner"},
60+ {"hyphenated", "root-ci"},
61+ }
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ f := newTokenFixture(t)
65+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
66+ _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: tt.label, Scopes: scopes(domain.ScopeRepoRead)})
67+ assert.NoError(t, err)
68+ })
6069 }
6170 }
6271
@@ -52,11 +52,20 @@ func names(t *testing.T, ss ...string) []domain.RepoName {
52 // Labels that only contain a reserved word are fine.52 // Labels that only contain a reserved word are fine.
53 func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) {53 func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) {
54 ctx := context.Background()54 ctx := context.Background()
55- for _, label := range []string{"owner2", "my owner", "roots", "ow ner", "root-ci"} {55+ tests := []struct{ name, label string }{
56- f := newTokenFixture(t)56+ {"digit suffix", "owner2"},
57- f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)57+ {"prefixed with a word", "my owner"},
58- _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: label, Scopes: scopes(domain.ScopeRepoRead)})58+ {"plural", "roots"},
59- assert.NoError(t, err, label)59+ {"split word", "ow ner"},
60+ {"hyphenated", "root-ci"},
61+ }
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ f := newTokenFixture(t)
65+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
66+ _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: tt.label, Scopes: scopes(domain.ScopeRepoRead)})
67+ assert.NoError(t, err)
68+ })
60 }69 }
61 }70 }
62 71

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