feat(app): open repositories for push, create on first push and fix the default branch
- commit
- 16a8861be6ed0f789ec5403d39e72e521622af6d
- author
- brice · · 2026-10-10 07:10 UTC
- parent
- 1ab78bb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +622 -0
A internal/app/pushes.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // pushRetries bounds the retries of a metadata update that loses a race. | |
| 19 | + | const pushRetries = 5 | |
| 20 | + | ||
| 21 | + | // MaintenanceScheduler queues repository maintenance. *Maintainer fits. | |
| 22 | + | type MaintenanceScheduler interface { | |
| 23 | + | Schedule(name domain.RepoName) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Pushes are the use cases around a Git push. | |
| 27 | + | type Pushes struct { | |
| 28 | + | meta ports.RepoMetaStore | |
| 29 | + | repos *Repos | |
| 30 | + | git ports.GitRepos | |
| 31 | + | bus ports.EventBus | |
| 32 | + | clock ports.Clock | |
| 33 | + | maint MaintenanceScheduler | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // NewPushes builds the push use cases. maint may be nil, which disables | |
| 37 | + | // maintenance after a push. | |
| 38 | + | func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler) *Pushes { | |
| 39 | + | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Open decides whether the principal may push to the repository and, with | |
| 43 | + | // create, creates it when it does not exist yet (private, published as | |
| 44 | + | // RepoCreated). create is for the first request of a push only. | |
| 45 | + | // | |
| 46 | + | // Anonymous callers get domain.ErrUnauthenticated. A repository the caller | |
| 47 | + | // cannot read and cannot write, or a missing one it may not create, answers | |
| 48 | + | // domain.ErrNotFound, so that a private repository never shows itself. One it | |
| 49 | + | // can read but not write answers domain.ErrForbidden. | |
| 50 | + | func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) (domain.Repo, bool, error) { | |
| 51 | + | if p.IsAnonymous() { | |
| 52 | + | return domain.Repo{}, false, domain.ErrUnauthenticated | |
| 53 | + | } | |
| 54 | + | repo, err := u.meta.Get(ctx, name) | |
| 55 | + | switch { | |
| 56 | + | case err == nil: | |
| 57 | + | return u.openExisting(p, repo) | |
| 58 | + | case !errors.Is(err, domain.ErrNotFound): | |
| 59 | + | return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | if !create || !canWrite(p, name) { | |
| 63 | + | return domain.Repo{}, false, domain.ErrNotFound | |
| 64 | + | } | |
| 65 | + | repo, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate}) | |
| 66 | + | switch { | |
| 67 | + | case err == nil: | |
| 68 | + | u.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt}) | |
| 69 | + | return repo, true, nil | |
| 70 | + | case errors.Is(err, domain.ErrAlreadyExists): | |
| 71 | + | // A concurrent first push won the race: this one pushes to its repository. | |
| 72 | + | repo, gerr := u.meta.Get(ctx, name) | |
| 73 | + | if gerr != nil { | |
| 74 | + | return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, gerr) | |
| 75 | + | } | |
| 76 | + | return u.openExisting(p, repo) | |
| 77 | + | } | |
| 78 | + | return domain.Repo{}, false, err | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (u *Pushes) openExisting(p domain.Principal, repo domain.Repo) (domain.Repo, bool, error) { | |
| 82 | + | switch { | |
| 83 | + | case canWrite(p, repo.Name): | |
| 84 | + | return repo, false, nil | |
| 85 | + | case canRead(p, repo): | |
| 86 | + | return domain.Repo{}, false, domain.ErrForbidden | |
| 87 | + | } | |
| 88 | + | return domain.Repo{}, false, domain.ErrNotFound | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Check is the pre-receive decision. v1 accepts every push of a principal that | |
| 92 | + | // may write the repository; protected branches and limits will refuse here by | |
| 93 | + | // returning an error that matches domain.ErrRejected. | |
| 94 | + | func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 95 | + | if !canWrite(p, name) { | |
| 96 | + | return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name) | |
| 97 | + | } | |
| 98 | + | log := zerowrap.FromCtx(ctx) | |
| 99 | + | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | |
| 100 | + | return nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Record is the post-receive step of an accepted push: it sets pushed_at, | |
| 104 | + | // repairs HEAD when it points to a missing branch, publishes a PushEvent and | |
| 105 | + | // schedules maintenance. Every step runs even when an earlier one fails; the | |
| 106 | + | // failures are returned together and the push itself is not affected. | |
| 107 | + | func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 108 | + | log := zerowrap.FromCtx(ctx) | |
| 109 | + | now := u.clock.Now().UTC() | |
| 110 | + | var errs []error | |
| 111 | + | ||
| 112 | + | if err := u.touchPushedAt(ctx, name, now); err != nil { | |
| 113 | + | errs = append(errs, err) | |
| 114 | + | } | |
| 115 | + | if err := u.fixDefaultBranch(ctx, name, updates); err != nil { | |
| 116 | + | errs = append(errs, err) | |
| 117 | + | } | |
| 118 | + | u.bus.Publish(ctx, domain.PushEvent{Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now}) | |
| 119 | + | if u.maint != nil { | |
| 120 | + | u.maint.Schedule(name) | |
| 121 | + | } | |
| 122 | + | log.Info(). | |
| 123 | + | Str("repo", name.String()).Int("refs", len(updates)). | |
| 124 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |
| 125 | + | Msg("push received") | |
| 126 | + | return errors.Join(errs...) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // touchPushedAt stores pushed_at, retrying when another write got in between. | |
| 130 | + | // A repository deleted meanwhile is not an error. | |
| 131 | + | func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now time.Time) error { | |
| 132 | + | for range pushRetries { | |
| 133 | + | repo, err := u.meta.Get(ctx, name) | |
| 134 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | if err != nil { | |
| 138 | + | return fmt.Errorf("get repository %q: %w", name, err) | |
| 139 | + | } | |
| 140 | + | if !now.After(repo.PushedAt) { | |
| 141 | + | return nil // a later push already recorded | |
| 142 | + | } | |
| 143 | + | repo.PushedAt = now | |
| 144 | + | err = u.meta.Update(ctx, repo) | |
| 145 | + | if errors.Is(err, domain.ErrConflict) { | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | if err != nil { | |
| 152 | + | return fmt.Errorf("update repository %q: %w", name, err) | |
| 153 | + | } | |
| 154 | + | return nil | |
| 155 | + | } | |
| 156 | + | return fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // fixDefaultBranch points HEAD to a branch that exists when it points to a | |
| 160 | + | // missing one: main when it was pushed, else the first pushed branch. | |
| 161 | + | func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 162 | + | branch, exists, err := u.git.DefaultBranch(ctx, name) | |
| 163 | + | if err != nil { | |
| 164 | + | return fmt.Errorf("read default branch of %q: %w", name, err) | |
| 165 | + | } | |
| 166 | + | if exists { | |
| 167 | + | return nil | |
| 168 | + | } | |
| 169 | + | target := "" | |
| 170 | + | for _, up := range updates { | |
| 171 | + | b, ok := strings.CutPrefix(up.Ref, "refs/heads/") | |
| 172 | + | if !ok || up.IsDelete() { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | if b == "main" { | |
| 176 | + | target = b | |
| 177 | + | break | |
| 178 | + | } | |
| 179 | + | if target == "" { | |
| 180 | + | target = b | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | if target == "" || target == branch { | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | if err := u.git.SetDefaultBranch(ctx, name, target); err != nil { | |
| 187 | + | return fmt.Errorf("set default branch of %q to %q: %w", name, target, err) | |
| 188 | + | } | |
| 189 | + | log := zerowrap.FromCtx(ctx) | |
| 190 | + | log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set") | |
| 191 | + | return nil | |
| 192 | + | } | |
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // pushRetries bounds the retries of a metadata update that loses a race. | |||
| 19 | + | const pushRetries = 5 | |||
| 20 | + | ||||
| 21 | + | // MaintenanceScheduler queues repository maintenance. *Maintainer fits. | |||
| 22 | + | type MaintenanceScheduler interface { | |||
| 23 | + | Schedule(name domain.RepoName) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Pushes are the use cases around a Git push. | |||
| 27 | + | type Pushes struct { | |||
| 28 | + | meta ports.RepoMetaStore | |||
| 29 | + | repos *Repos | |||
| 30 | + | git ports.GitRepos | |||
| 31 | + | bus ports.EventBus | |||
| 32 | + | clock ports.Clock | |||
| 33 | + | maint MaintenanceScheduler | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // NewPushes builds the push use cases. maint may be nil, which disables | |||
| 37 | + | // maintenance after a push. | |||
| 38 | + | func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler) *Pushes { | |||
| 39 | + | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Open decides whether the principal may push to the repository and, with | |||
| 43 | + | // create, creates it when it does not exist yet (private, published as | |||
| 44 | + | // RepoCreated). create is for the first request of a push only. | |||
| 45 | + | // | |||
| 46 | + | // Anonymous callers get domain.ErrUnauthenticated. A repository the caller | |||
| 47 | + | // cannot read and cannot write, or a missing one it may not create, answers | |||
| 48 | + | // domain.ErrNotFound, so that a private repository never shows itself. One it | |||
| 49 | + | // can read but not write answers domain.ErrForbidden. | |||
| 50 | + | func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) (domain.Repo, bool, error) { | |||
| 51 | + | if p.IsAnonymous() { | |||
| 52 | + | return domain.Repo{}, false, domain.ErrUnauthenticated | |||
| 53 | + | } | |||
| 54 | + | repo, err := u.meta.Get(ctx, name) | |||
| 55 | + | switch { | |||
| 56 | + | case err == nil: | |||
| 57 | + | return u.openExisting(p, repo) | |||
| 58 | + | case !errors.Is(err, domain.ErrNotFound): | |||
| 59 | + | return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | if !create || !canWrite(p, name) { | |||
| 63 | + | return domain.Repo{}, false, domain.ErrNotFound | |||
| 64 | + | } | |||
| 65 | + | repo, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate}) | |||
| 66 | + | switch { | |||
| 67 | + | case err == nil: | |||
| 68 | + | u.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt}) | |||
| 69 | + | return repo, true, nil | |||
| 70 | + | case errors.Is(err, domain.ErrAlreadyExists): | |||
| 71 | + | // A concurrent first push won the race: this one pushes to its repository. | |||
| 72 | + | repo, gerr := u.meta.Get(ctx, name) | |||
| 73 | + | if gerr != nil { | |||
| 74 | + | return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, gerr) | |||
| 75 | + | } | |||
| 76 | + | return u.openExisting(p, repo) | |||
| 77 | + | } | |||
| 78 | + | return domain.Repo{}, false, err | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (u *Pushes) openExisting(p domain.Principal, repo domain.Repo) (domain.Repo, bool, error) { | |||
| 82 | + | switch { | |||
| 83 | + | case canWrite(p, repo.Name): | |||
| 84 | + | return repo, false, nil | |||
| 85 | + | case canRead(p, repo): | |||
| 86 | + | return domain.Repo{}, false, domain.ErrForbidden | |||
| 87 | + | } | |||
| 88 | + | return domain.Repo{}, false, domain.ErrNotFound | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Check is the pre-receive decision. v1 accepts every push of a principal that | |||
| 92 | + | // may write the repository; protected branches and limits will refuse here by | |||
| 93 | + | // returning an error that matches domain.ErrRejected. | |||
| 94 | + | func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 95 | + | if !canWrite(p, name) { | |||
| 96 | + | return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name) | |||
| 97 | + | } | |||
| 98 | + | log := zerowrap.FromCtx(ctx) | |||
| 99 | + | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | |||
| 100 | + | return nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Record is the post-receive step of an accepted push: it sets pushed_at, | |||
| 104 | + | // repairs HEAD when it points to a missing branch, publishes a PushEvent and | |||
| 105 | + | // schedules maintenance. Every step runs even when an earlier one fails; the | |||
| 106 | + | // failures are returned together and the push itself is not affected. | |||
| 107 | + | func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 108 | + | log := zerowrap.FromCtx(ctx) | |||
| 109 | + | now := u.clock.Now().UTC() | |||
| 110 | + | var errs []error | |||
| 111 | + | ||||
| 112 | + | if err := u.touchPushedAt(ctx, name, now); err != nil { | |||
| 113 | + | errs = append(errs, err) | |||
| 114 | + | } | |||
| 115 | + | if err := u.fixDefaultBranch(ctx, name, updates); err != nil { | |||
| 116 | + | errs = append(errs, err) | |||
| 117 | + | } | |||
| 118 | + | u.bus.Publish(ctx, domain.PushEvent{Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now}) | |||
| 119 | + | if u.maint != nil { | |||
| 120 | + | u.maint.Schedule(name) | |||
| 121 | + | } | |||
| 122 | + | log.Info(). | |||
| 123 | + | Str("repo", name.String()).Int("refs", len(updates)). | |||
| 124 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |||
| 125 | + | Msg("push received") | |||
| 126 | + | return errors.Join(errs...) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // touchPushedAt stores pushed_at, retrying when another write got in between. | |||
| 130 | + | // A repository deleted meanwhile is not an error. | |||
| 131 | + | func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now time.Time) error { | |||
| 132 | + | for range pushRetries { | |||
| 133 | + | repo, err := u.meta.Get(ctx, name) | |||
| 134 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | if err != nil { | |||
| 138 | + | return fmt.Errorf("get repository %q: %w", name, err) | |||
| 139 | + | } | |||
| 140 | + | if !now.After(repo.PushedAt) { | |||
| 141 | + | return nil // a later push already recorded | |||
| 142 | + | } | |||
| 143 | + | repo.PushedAt = now | |||
| 144 | + | err = u.meta.Update(ctx, repo) | |||
| 145 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 146 | + | continue | |||
| 147 | + | } | |||
| 148 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 149 | + | return nil | |||
| 150 | + | } | |||
| 151 | + | if err != nil { | |||
| 152 | + | return fmt.Errorf("update repository %q: %w", name, err) | |||
| 153 | + | } | |||
| 154 | + | return nil | |||
| 155 | + | } | |||
| 156 | + | return fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // fixDefaultBranch points HEAD to a branch that exists when it points to a | |||
| 160 | + | // missing one: main when it was pushed, else the first pushed branch. | |||
| 161 | + | func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 162 | + | branch, exists, err := u.git.DefaultBranch(ctx, name) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return fmt.Errorf("read default branch of %q: %w", name, err) | |||
| 165 | + | } | |||
| 166 | + | if exists { | |||
| 167 | + | return nil | |||
| 168 | + | } | |||
| 169 | + | target := "" | |||
| 170 | + | for _, up := range updates { | |||
| 171 | + | b, ok := strings.CutPrefix(up.Ref, "refs/heads/") | |||
| 172 | + | if !ok || up.IsDelete() { | |||
| 173 | + | continue | |||
| 174 | + | } | |||
| 175 | + | if b == "main" { | |||
| 176 | + | target = b | |||
| 177 | + | break | |||
| 178 | + | } | |||
| 179 | + | if target == "" { | |||
| 180 | + | target = b | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | if target == "" || target == branch { | |||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | if err := u.git.SetDefaultBranch(ctx, name, target); err != nil { | |||
| 187 | + | return fmt.Errorf("set default branch of %q to %q: %w", name, target, err) | |||
| 188 | + | } | |||
| 189 | + | log := zerowrap.FromCtx(ctx) | |||
| 190 | + | log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set") | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
A internal/app/pushes_test.go +283 -0
| @@ -0,0 +1,283 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/app" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | type scheduler struct { | |
| 23 | + | mu sync.Mutex | |
| 24 | + | names []domain.RepoName | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (s *scheduler) Schedule(n domain.RepoName) { | |
| 28 | + | s.mu.Lock() | |
| 29 | + | defer s.mu.Unlock() | |
| 30 | + | s.names = append(s.names, n) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type pushFixture struct { | |
| 34 | + | meta *mocks.RepoMetaStore | |
| 35 | + | git *mocks.GitRepos | |
| 36 | + | bus *mocks.EventBus | |
| 37 | + | clock *mocks.Clock | |
| 38 | + | sched *scheduler | |
| 39 | + | pushes *app.Pushes | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func newPushFixture(t *testing.T) *pushFixture { | |
| 43 | + | t.Helper() | |
| 44 | + | f := &pushFixture{ | |
| 45 | + | meta: mocks.NewRepoMetaStore(t), | |
| 46 | + | git: mocks.NewGitRepos(t), | |
| 47 | + | bus: mocks.NewEventBus(t), | |
| 48 | + | clock: mocks.NewClock(t), | |
| 49 | + | sched: &scheduler{}, | |
| 50 | + | } | |
| 51 | + | f.clock.EXPECT().Now().Return(now).Maybe() | |
| 52 | + | repos := app.NewRepos(f.meta, f.git, f.clock) | |
| 53 | + | f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched) | |
| 54 | + | return f | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | const ( | |
| 58 | + | oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | |
| 59 | + | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | |
| 60 | + | ) | |
| 61 | + | ||
| 62 | + | func TestPushesOpen(t *testing.T) { | |
| 63 | + | ctx := context.Background() | |
| 64 | + | n := name(t, "tools") | |
| 65 | + | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate} | |
| 66 | + | pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic} | |
| 67 | + | ||
| 68 | + | tests := []struct { | |
| 69 | + | name string | |
| 70 | + | p domain.Principal | |
| 71 | + | repo *domain.Repo // nil: missing | |
| 72 | + | create bool | |
| 73 | + | want error | |
| 74 | + | created bool | |
| 75 | + | }{ | |
| 76 | + | {"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated, false}, | |
| 77 | + | {"root, existing", domain.Root(), &priv, false, nil, false}, | |
| 78 | + | {"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil, false}, | |
| 79 | + | {"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil, false}, | |
| 80 | + | {"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound, false}, | |
| 81 | + | {"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden, false}, | |
| 82 | + | {"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden, false}, | |
| 83 | + | {"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden, false}, | |
| 84 | + | {"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound, false}, | |
| 85 | + | {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound, false}, | |
| 86 | + | {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound, false}, | |
| 87 | + | {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound, false}, | |
| 88 | + | } | |
| 89 | + | for _, tt := range tests { | |
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |
| 91 | + | f := newPushFixture(t) | |
| 92 | + | if !tt.p.IsAnonymous() { | |
| 93 | + | if tt.repo != nil { | |
| 94 | + | f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil) | |
| 95 | + | } else { | |
| 96 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | repo, created, err := f.pushes.Open(ctx, tt.p, n, tt.create) | |
| 100 | + | if tt.want != nil { | |
| 101 | + | assert.ErrorIs(t, err, tt.want) | |
| 102 | + | assert.False(t, created) | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | assert.Equal(t, tt.created, created) | |
| 107 | + | assert.Equal(t, n, repo.Name) | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) { | |
| 112 | + | f := newPushFixture(t) | |
| 113 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 114 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 115 | + | want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 116 | + | f.meta.EXPECT().Create(ctx, want).Return(nil) | |
| 117 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |
| 118 | + | f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return() | |
| 119 | + | ||
| 120 | + | repo, created, err := f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.True(t, created) | |
| 123 | + | assert.Equal(t, want, repo) | |
| 124 | + | }) | |
| 125 | + | ||
| 126 | + | t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) { | |
| 127 | + | f := newPushFixture(t) | |
| 128 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once() | |
| 129 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 130 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists) | |
| 131 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil) | |
| 132 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 133 | + | ||
| 134 | + | repo, created, err := f.pushes.Open(ctx, domain.Root(), n, true) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | assert.False(t, created) | |
| 137 | + | assert.Equal(t, priv, repo) | |
| 138 | + | }) | |
| 139 | + | ||
| 140 | + | t.Run("store failure", func(t *testing.T) { | |
| 141 | + | f := newPushFixture(t) | |
| 142 | + | boom := errors.New("boom") | |
| 143 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | |
| 144 | + | _, _, err := f.pushes.Open(ctx, domain.Root(), n, true) | |
| 145 | + | assert.ErrorIs(t, err, boom) | |
| 146 | + | }) | |
| 147 | + | ||
| 148 | + | t.Run("create failure is returned and nothing is published", func(t *testing.T) { | |
| 149 | + | f := newPushFixture(t) | |
| 150 | + | boom := errors.New("boom") | |
| 151 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 152 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom) | |
| 153 | + | _, _, err := f.pushes.Open(ctx, domain.Root(), n, true) | |
| 154 | + | assert.ErrorIs(t, err, boom) | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestPushesCheck(t *testing.T) { | |
| 159 | + | ctx := context.Background() | |
| 160 | + | n := name(t, "tools") | |
| 161 | + | f := newPushFixture(t) | |
| 162 | + | ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |
| 163 | + | ||
| 164 | + | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups)) | |
| 165 | + | require.NoError(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, ups)) | |
| 166 | + | assert.ErrorIs(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoRead), n, ups), domain.ErrRejected) | |
| 167 | + | assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestPushesRecord(t *testing.T) { | |
| 171 | + | ctx := context.Background() | |
| 172 | + | n := name(t, "tools") | |
| 173 | + | p := tokenPrincipal(domain.ScopeRepoWrite) | |
| 174 | + | ups := []domain.RefUpdate{ | |
| 175 | + | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"}, | |
| 176 | + | {Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"}, | |
| 177 | + | {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"}, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) { | |
| 181 | + | f := newPushFixture(t) | |
| 182 | + | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | |
| 183 | + | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |
| 184 | + | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | |
| 185 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 186 | + | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now}).Return() | |
| 187 | + | ||
| 188 | + | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| 189 | + | assert.Equal(t, []domain.RepoName{n}, f.sched.names) | |
| 190 | + | }) | |
| 191 | + | ||
| 192 | + | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | |
| 193 | + | f := newPushFixture(t) | |
| 194 | + | repo := domain.Repo{Name: n} | |
| 195 | + | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | |
| 196 | + | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | |
| 197 | + | later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)} | |
| 198 | + | f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile | |
| 199 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 200 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 201 | + | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| 202 | + | }) | |
| 203 | + | ||
| 204 | + | t.Run("a deleted repository is not an error", func(t *testing.T) { | |
| 205 | + | f := newPushFixture(t) | |
| 206 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 207 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 208 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 209 | + | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| 210 | + | }) | |
| 211 | + | ||
| 212 | + | t.Run("every step runs when one fails", func(t *testing.T) { | |
| 213 | + | f := newPushFixture(t) | |
| 214 | + | boom := errors.New("boom") | |
| 215 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | |
| 216 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | |
| 217 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 218 | + | err := f.pushes.Record(ctx, p, n, ups) | |
| 219 | + | assert.ErrorIs(t, err, boom) | |
| 220 | + | assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled") | |
| 221 | + | }) | |
| 222 | + | ||
| 223 | + | t.Run("nil scheduler is allowed", func(t *testing.T) { | |
| 224 | + | f := newPushFixture(t) | |
| 225 | + | repos := app.NewRepos(f.meta, f.git, f.clock) | |
| 226 | + | pushes := app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, nil) | |
| 227 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | |
| 228 | + | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |
| 229 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 230 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 231 | + | require.NoError(t, pushes.Record(ctx, p, n, ups)) | |
| 232 | + | }) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestPushesDefaultBranchRule(t *testing.T) { | |
| 236 | + | ctx := context.Background() | |
| 237 | + | n := name(t, "tools") | |
| 238 | + | p := domain.Root() | |
| 239 | + | up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} } | |
| 240 | + | del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} } | |
| 241 | + | ||
| 242 | + | tests := []struct { | |
| 243 | + | name string | |
| 244 | + | head string | |
| 245 | + | exists bool | |
| 246 | + | updates []domain.RefUpdate | |
| 247 | + | want string // "" = HEAD untouched | |
| 248 | + | }{ | |
| 249 | + | {"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""}, | |
| 250 | + | {"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"}, | |
| 251 | + | {"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"}, | |
| 252 | + | {"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"}, | |
| 253 | + | {"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""}, | |
| 254 | + | {"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"}, | |
| 255 | + | {"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""}, | |
| 256 | + | {"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""}, | |
| 257 | + | } | |
| 258 | + | for _, tt := range tests { | |
| 259 | + | t.Run(tt.name, func(t *testing.T) { | |
| 260 | + | f := newPushFixture(t) | |
| 261 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |
| 262 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | |
| 263 | + | if tt.want != "" { | |
| 264 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil) | |
| 265 | + | } | |
| 266 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 267 | + | require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates)) | |
| 268 | + | }) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | t.Run("set failure is reported", func(t *testing.T) { | |
| 272 | + | f := newPushFixture(t) | |
| 273 | + | boom := errors.New("boom") | |
| 274 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |
| 275 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | |
| 276 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |
| 277 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 278 | + | assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom) | |
| 279 | + | }) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // mockAny matches any argument of a mocked call. | |
| 283 | + | var mockAny = mock.Anything | |
| @@ -0,0 +1,283 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/app" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | type scheduler struct { | |||
| 23 | + | mu sync.Mutex | |||
| 24 | + | names []domain.RepoName | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (s *scheduler) Schedule(n domain.RepoName) { | |||
| 28 | + | s.mu.Lock() | |||
| 29 | + | defer s.mu.Unlock() | |||
| 30 | + | s.names = append(s.names, n) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type pushFixture struct { | |||
| 34 | + | meta *mocks.RepoMetaStore | |||
| 35 | + | git *mocks.GitRepos | |||
| 36 | + | bus *mocks.EventBus | |||
| 37 | + | clock *mocks.Clock | |||
| 38 | + | sched *scheduler | |||
| 39 | + | pushes *app.Pushes | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func newPushFixture(t *testing.T) *pushFixture { | |||
| 43 | + | t.Helper() | |||
| 44 | + | f := &pushFixture{ | |||
| 45 | + | meta: mocks.NewRepoMetaStore(t), | |||
| 46 | + | git: mocks.NewGitRepos(t), | |||
| 47 | + | bus: mocks.NewEventBus(t), | |||
| 48 | + | clock: mocks.NewClock(t), | |||
| 49 | + | sched: &scheduler{}, | |||
| 50 | + | } | |||
| 51 | + | f.clock.EXPECT().Now().Return(now).Maybe() | |||
| 52 | + | repos := app.NewRepos(f.meta, f.git, f.clock) | |||
| 53 | + | f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched) | |||
| 54 | + | return f | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | const ( | |||
| 58 | + | oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | |||
| 59 | + | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | |||
| 60 | + | ) | |||
| 61 | + | ||||
| 62 | + | func TestPushesOpen(t *testing.T) { | |||
| 63 | + | ctx := context.Background() | |||
| 64 | + | n := name(t, "tools") | |||
| 65 | + | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate} | |||
| 66 | + | pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic} | |||
| 67 | + | ||||
| 68 | + | tests := []struct { | |||
| 69 | + | name string | |||
| 70 | + | p domain.Principal | |||
| 71 | + | repo *domain.Repo // nil: missing | |||
| 72 | + | create bool | |||
| 73 | + | want error | |||
| 74 | + | created bool | |||
| 75 | + | }{ | |||
| 76 | + | {"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated, false}, | |||
| 77 | + | {"root, existing", domain.Root(), &priv, false, nil, false}, | |||
| 78 | + | {"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil, false}, | |||
| 79 | + | {"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil, false}, | |||
| 80 | + | {"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound, false}, | |||
| 81 | + | {"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden, false}, | |||
| 82 | + | {"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden, false}, | |||
| 83 | + | {"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden, false}, | |||
| 84 | + | {"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound, false}, | |||
| 85 | + | {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound, false}, | |||
| 86 | + | {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound, false}, | |||
| 87 | + | {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound, false}, | |||
| 88 | + | } | |||
| 89 | + | for _, tt := range tests { | |||
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 91 | + | f := newPushFixture(t) | |||
| 92 | + | if !tt.p.IsAnonymous() { | |||
| 93 | + | if tt.repo != nil { | |||
| 94 | + | f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil) | |||
| 95 | + | } else { | |||
| 96 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | repo, created, err := f.pushes.Open(ctx, tt.p, n, tt.create) | |||
| 100 | + | if tt.want != nil { | |||
| 101 | + | assert.ErrorIs(t, err, tt.want) | |||
| 102 | + | assert.False(t, created) | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | assert.Equal(t, tt.created, created) | |||
| 107 | + | assert.Equal(t, n, repo.Name) | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) { | |||
| 112 | + | f := newPushFixture(t) | |||
| 113 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 114 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |||
| 115 | + | want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 116 | + | f.meta.EXPECT().Create(ctx, want).Return(nil) | |||
| 117 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |||
| 118 | + | f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return() | |||
| 119 | + | ||||
| 120 | + | repo, created, err := f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | assert.True(t, created) | |||
| 123 | + | assert.Equal(t, want, repo) | |||
| 124 | + | }) | |||
| 125 | + | ||||
| 126 | + | t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) { | |||
| 127 | + | f := newPushFixture(t) | |||
| 128 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once() | |||
| 129 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |||
| 130 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists) | |||
| 131 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil) | |||
| 132 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |||
| 133 | + | ||||
| 134 | + | repo, created, err := f.pushes.Open(ctx, domain.Root(), n, true) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | assert.False(t, created) | |||
| 137 | + | assert.Equal(t, priv, repo) | |||
| 138 | + | }) | |||
| 139 | + | ||||
| 140 | + | t.Run("store failure", func(t *testing.T) { | |||
| 141 | + | f := newPushFixture(t) | |||
| 142 | + | boom := errors.New("boom") | |||
| 143 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | |||
| 144 | + | _, _, err := f.pushes.Open(ctx, domain.Root(), n, true) | |||
| 145 | + | assert.ErrorIs(t, err, boom) | |||
| 146 | + | }) | |||
| 147 | + | ||||
| 148 | + | t.Run("create failure is returned and nothing is published", func(t *testing.T) { | |||
| 149 | + | f := newPushFixture(t) | |||
| 150 | + | boom := errors.New("boom") | |||
| 151 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 152 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom) | |||
| 153 | + | _, _, err := f.pushes.Open(ctx, domain.Root(), n, true) | |||
| 154 | + | assert.ErrorIs(t, err, boom) | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestPushesCheck(t *testing.T) { | |||
| 159 | + | ctx := context.Background() | |||
| 160 | + | n := name(t, "tools") | |||
| 161 | + | f := newPushFixture(t) | |||
| 162 | + | ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |||
| 163 | + | ||||
| 164 | + | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups)) | |||
| 165 | + | require.NoError(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, ups)) | |||
| 166 | + | assert.ErrorIs(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoRead), n, ups), domain.ErrRejected) | |||
| 167 | + | assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestPushesRecord(t *testing.T) { | |||
| 171 | + | ctx := context.Background() | |||
| 172 | + | n := name(t, "tools") | |||
| 173 | + | p := tokenPrincipal(domain.ScopeRepoWrite) | |||
| 174 | + | ups := []domain.RefUpdate{ | |||
| 175 | + | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"}, | |||
| 176 | + | {Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"}, | |||
| 177 | + | {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"}, | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) { | |||
| 181 | + | f := newPushFixture(t) | |||
| 182 | + | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | |||
| 183 | + | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |||
| 184 | + | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | |||
| 185 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |||
| 186 | + | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now}).Return() | |||
| 187 | + | ||||
| 188 | + | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |||
| 189 | + | assert.Equal(t, []domain.RepoName{n}, f.sched.names) | |||
| 190 | + | }) | |||
| 191 | + | ||||
| 192 | + | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | |||
| 193 | + | f := newPushFixture(t) | |||
| 194 | + | repo := domain.Repo{Name: n} | |||
| 195 | + | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | |||
| 196 | + | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | |||
| 197 | + | later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)} | |||
| 198 | + | f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile | |||
| 199 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |||
| 200 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |||
| 201 | + | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |||
| 202 | + | }) | |||
| 203 | + | ||||
| 204 | + | t.Run("a deleted repository is not an error", func(t *testing.T) { | |||
| 205 | + | f := newPushFixture(t) | |||
| 206 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 207 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |||
| 208 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |||
| 209 | + | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |||
| 210 | + | }) | |||
| 211 | + | ||||
| 212 | + | t.Run("every step runs when one fails", func(t *testing.T) { | |||
| 213 | + | f := newPushFixture(t) | |||
| 214 | + | boom := errors.New("boom") | |||
| 215 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | |||
| 216 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | |||
| 217 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |||
| 218 | + | err := f.pushes.Record(ctx, p, n, ups) | |||
| 219 | + | assert.ErrorIs(t, err, boom) | |||
| 220 | + | assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled") | |||
| 221 | + | }) | |||
| 222 | + | ||||
| 223 | + | t.Run("nil scheduler is allowed", func(t *testing.T) { | |||
| 224 | + | f := newPushFixture(t) | |||
| 225 | + | repos := app.NewRepos(f.meta, f.git, f.clock) | |||
| 226 | + | pushes := app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, nil) | |||
| 227 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | |||
| 228 | + | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |||
| 229 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |||
| 230 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |||
| 231 | + | require.NoError(t, pushes.Record(ctx, p, n, ups)) | |||
| 232 | + | }) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 236 | + | ctx := context.Background() | |||
| 237 | + | n := name(t, "tools") | |||
| 238 | + | p := domain.Root() | |||
| 239 | + | up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} } | |||
| 240 | + | del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} } | |||
| 241 | + | ||||
| 242 | + | tests := []struct { | |||
| 243 | + | name string | |||
| 244 | + | head string | |||
| 245 | + | exists bool | |||
| 246 | + | updates []domain.RefUpdate | |||
| 247 | + | want string // "" = HEAD untouched | |||
| 248 | + | }{ | |||
| 249 | + | {"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""}, | |||
| 250 | + | {"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"}, | |||
| 251 | + | {"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"}, | |||
| 252 | + | {"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"}, | |||
| 253 | + | {"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""}, | |||
| 254 | + | {"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"}, | |||
| 255 | + | {"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""}, | |||
| 256 | + | {"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""}, | |||
| 257 | + | } | |||
| 258 | + | for _, tt := range tests { | |||
| 259 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 260 | + | f := newPushFixture(t) | |||
| 261 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |||
| 262 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | |||
| 263 | + | if tt.want != "" { | |||
| 264 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil) | |||
| 265 | + | } | |||
| 266 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |||
| 267 | + | require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates)) | |||
| 268 | + | }) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | t.Run("set failure is reported", func(t *testing.T) { | |||
| 272 | + | f := newPushFixture(t) | |||
| 273 | + | boom := errors.New("boom") | |||
| 274 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |||
| 275 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | |||
| 276 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |||
| 277 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |||
| 278 | + | assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom) | |||
| 279 | + | }) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // mockAny matches any argument of a mocked call. | |||
| 283 | + | var mockAny = mock.Anything | |||
A internal/ports/mocks/EventBus.go +147 -0
| @@ -0,0 +1,147 @@ | |||
| 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 | + | // NewEventBus creates a new instance of EventBus. 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 NewEventBus(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *EventBus { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &EventBus{} | |
| 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 | + | // EventBus is an autogenerated mock type for the EventBus type | |
| 38 | + | type EventBus struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type EventBus_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *EventBus) EXPECT() *EventBus_Expecter { | |
| 47 | + | return &EventBus_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Publish provides a mock function for the type EventBus | |
| 51 | + | func (_mock *EventBus) Publish(ctx context.Context, ev domain.Event) { | |
| 52 | + | _mock.Called(ctx, ev) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // EventBus_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish' | |
| 57 | + | type EventBus_Publish_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Publish is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - ev domain.Event | |
| 64 | + | func (_e *EventBus_Expecter) Publish(ctx any, ev any) *EventBus_Publish_Call { | |
| 65 | + | return &EventBus_Publish_Call{Call: _e.mock.On("Publish", ctx, ev)} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (_c *EventBus_Publish_Call) Run(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_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.Event | |
| 75 | + | if args[1] != nil { | |
| 76 | + | arg1 = args[1].(domain.Event) | |
| 77 | + | } | |
| 78 | + | run( | |
| 79 | + | arg0, | |
| 80 | + | arg1, | |
| 81 | + | ) | |
| 82 | + | }) | |
| 83 | + | return _c | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (_c *EventBus_Publish_Call) Return() *EventBus_Publish_Call { | |
| 87 | + | _c.Call.Return() | |
| 88 | + | return _c | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *EventBus_Publish_Call) RunAndReturn(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_Call { | |
| 92 | + | _c.Run(run) | |
| 93 | + | return _c | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Subscribe provides a mock function for the type EventBus | |
| 97 | + | func (_mock *EventBus) Subscribe(ctx context.Context) <-chan domain.Event { | |
| 98 | + | ret := _mock.Called(ctx) | |
| 99 | + | ||
| 100 | + | if len(ret) == 0 { | |
| 101 | + | panic("no return value specified for Subscribe") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | var r0 <-chan domain.Event | |
| 105 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) <-chan domain.Event); ok { | |
| 106 | + | r0 = returnFunc(ctx) | |
| 107 | + | } else { | |
| 108 | + | if ret.Get(0) != nil { | |
| 109 | + | r0 = ret.Get(0).(<-chan domain.Event) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return r0 | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // EventBus_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe' | |
| 116 | + | type EventBus_Subscribe_Call struct { | |
| 117 | + | *mock.Call | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Subscribe is a helper method to define mock.On call | |
| 121 | + | // - ctx context.Context | |
| 122 | + | func (_e *EventBus_Expecter) Subscribe(ctx any) *EventBus_Subscribe_Call { | |
| 123 | + | return &EventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", ctx)} | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *EventBus_Subscribe_Call) Run(run func(ctx context.Context)) *EventBus_Subscribe_Call { | |
| 127 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 128 | + | var arg0 context.Context | |
| 129 | + | if args[0] != nil { | |
| 130 | + | arg0 = args[0].(context.Context) | |
| 131 | + | } | |
| 132 | + | run( | |
| 133 | + | arg0, | |
| 134 | + | ) | |
| 135 | + | }) | |
| 136 | + | return _c | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (_c *EventBus_Subscribe_Call) Return(eventCh <-chan domain.Event) *EventBus_Subscribe_Call { | |
| 140 | + | _c.Call.Return(eventCh) | |
| 141 | + | return _c | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (_c *EventBus_Subscribe_Call) RunAndReturn(run func(ctx context.Context) <-chan domain.Event) *EventBus_Subscribe_Call { | |
| 145 | + | _c.Call.Return(run) | |
| 146 | + | return _c | |
| 147 | + | } | |
| @@ -0,0 +1,147 @@ | |||||
| 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 | + | // NewEventBus creates a new instance of EventBus. 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 NewEventBus(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *EventBus { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &EventBus{} | |||
| 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 | + | // EventBus is an autogenerated mock type for the EventBus type | |||
| 38 | + | type EventBus struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type EventBus_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *EventBus) EXPECT() *EventBus_Expecter { | |||
| 47 | + | return &EventBus_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Publish provides a mock function for the type EventBus | |||
| 51 | + | func (_mock *EventBus) Publish(ctx context.Context, ev domain.Event) { | |||
| 52 | + | _mock.Called(ctx, ev) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // EventBus_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish' | |||
| 57 | + | type EventBus_Publish_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Publish is a helper method to define mock.On call | |||
| 62 | + | // - ctx context.Context | |||
| 63 | + | // - ev domain.Event | |||
| 64 | + | func (_e *EventBus_Expecter) Publish(ctx any, ev any) *EventBus_Publish_Call { | |||
| 65 | + | return &EventBus_Publish_Call{Call: _e.mock.On("Publish", ctx, ev)} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (_c *EventBus_Publish_Call) Run(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_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.Event | |||
| 75 | + | if args[1] != nil { | |||
| 76 | + | arg1 = args[1].(domain.Event) | |||
| 77 | + | } | |||
| 78 | + | run( | |||
| 79 | + | arg0, | |||
| 80 | + | arg1, | |||
| 81 | + | ) | |||
| 82 | + | }) | |||
| 83 | + | return _c | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (_c *EventBus_Publish_Call) Return() *EventBus_Publish_Call { | |||
| 87 | + | _c.Call.Return() | |||
| 88 | + | return _c | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (_c *EventBus_Publish_Call) RunAndReturn(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_Call { | |||
| 92 | + | _c.Run(run) | |||
| 93 | + | return _c | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // Subscribe provides a mock function for the type EventBus | |||
| 97 | + | func (_mock *EventBus) Subscribe(ctx context.Context) <-chan domain.Event { | |||
| 98 | + | ret := _mock.Called(ctx) | |||
| 99 | + | ||||
| 100 | + | if len(ret) == 0 { | |||
| 101 | + | panic("no return value specified for Subscribe") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | var r0 <-chan domain.Event | |||
| 105 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) <-chan domain.Event); ok { | |||
| 106 | + | r0 = returnFunc(ctx) | |||
| 107 | + | } else { | |||
| 108 | + | if ret.Get(0) != nil { | |||
| 109 | + | r0 = ret.Get(0).(<-chan domain.Event) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return r0 | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // EventBus_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe' | |||
| 116 | + | type EventBus_Subscribe_Call struct { | |||
| 117 | + | *mock.Call | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Subscribe is a helper method to define mock.On call | |||
| 121 | + | // - ctx context.Context | |||
| 122 | + | func (_e *EventBus_Expecter) Subscribe(ctx any) *EventBus_Subscribe_Call { | |||
| 123 | + | return &EventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", ctx)} | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (_c *EventBus_Subscribe_Call) Run(run func(ctx context.Context)) *EventBus_Subscribe_Call { | |||
| 127 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 128 | + | var arg0 context.Context | |||
| 129 | + | if args[0] != nil { | |||
| 130 | + | arg0 = args[0].(context.Context) | |||
| 131 | + | } | |||
| 132 | + | run( | |||
| 133 | + | arg0, | |||
| 134 | + | ) | |||
| 135 | + | }) | |||
| 136 | + | return _c | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (_c *EventBus_Subscribe_Call) Return(eventCh <-chan domain.Event) *EventBus_Subscribe_Call { | |||
| 140 | + | _c.Call.Return(eventCh) | |||
| 141 | + | return _c | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (_c *EventBus_Subscribe_Call) RunAndReturn(run func(ctx context.Context) <-chan domain.Event) *EventBus_Subscribe_Call { | |||
| 145 | + | _c.Call.Return(run) | |||
| 146 | + | return _c | |||
| 147 | + | } | |||