1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "math"
11 "strconv"
12 "strings"
13 "testing"
14
15 "bnema.dev/ermite/internal/ports/mocks"
16 "github.com/bnema/zerowrap"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"
20
21 "bnema.dev/ermite-actions/workflow"
22 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/usecase"
24)
25
26// The title of a run is the run-name when it evaluates to something, else the
27// commit title; a run-name is cut to its first line and 256 characters.
28func TestRunTitle(t *testing.T) {
29 t.Parallel()
30 const commit = "fix: the commit title"
31 const githubJSON = `{"ref_name":"main","event":{"head_commit":{"message":"one\ntwo"}}}`
32 tests := []struct {
33 name string
34 runName string // the run-name expression, "" : the workflow has none
35 want string
36 }{
37 {"no run-name", "", commit},
38 {"a run-name", "deploy ${{ github.ref_name }}", "deploy main"},
39 {"an evaluation error", "${{ fromJSON('{') }}", commit},
40 {"an empty result", "${{ '' }}", commit},
41 {"a blank result", "${{ ' ' }}", commit},
42 {"a first line only", "${{ github.event.head_commit.message }}", "one"},
43 {"cut at 256 characters", strings.Repeat("é", 300), strings.Repeat("é", 256)},
44 {"exactly 256 characters", strings.Repeat("a", 256), strings.Repeat("a", 256)},
45 }
46 for _, tt := range tests {
47 t.Run(tt.name, func(t *testing.T) {
48 t.Parallel()
49 u := newCIUnit(t, nil)
50 yaml := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: 'true'\n"
51 if tt.runName != "" {
52 yaml = "run-name: \"" + strings.ReplaceAll(strings.ReplaceAll(tt.runName, `\`, `\\`), `"`, `\"`) + "\"\n" + yaml
53 }
54 wf, errs := workflow.Parse(".github/workflows/a.yml", []byte(yaml))
55 require.Empty(t, errs)
56 require.NotNil(t, wf)
57 assert.Equal(t, tt.want, u.ci.RunTitle(context.Background(), wf, githubJSON, commit))
58 })
59 }
60}
61
62// The check run id keeps the run, its attempt, the job and the leg apart, stays
63// below 2^53, and refuses what does not fit instead of wrapping.
64func TestCheckRunID(t *testing.T) {
65 t.Parallel()
66 ctx := context.Background()
67 run := func(id int64, attempt int32) domain.CIRun { return domain.CIRun{ID: id, Attempt: attempt} }
68 seen := map[int64]string{}
69 for _, id := range []int64{1, 2, 1<<27 - 1} {
70 for _, attempt := range []int32{1, 2, 127} {
71 for _, index := range []int{0, 1, 1023} {
72 for _, combo := range []int{0, 1, 255, 511} {
73 got := usecase.CheckRunID(ctx, run(id, attempt), index, combo)
74 require.NotZero(t, got)
75 assert.Less(t, got, int64(1)<<53)
76 key := fmt.Sprintf("run %d attempt %d job %d leg %d", id, attempt, index, combo)
77 assert.NotContains(t, seen, got, "%s collides", key)
78 seen[got] = key
79 }
80 }
81 }
82 }
83 a := usecase.CheckRunID(ctx, run(7, 1), 0, 0)
84 b := usecase.CheckRunID(ctx, run(7, 2), 0, 0)
85 assert.NotEqual(t, a, b, "a re-run gets new ids")
86 again := usecase.CheckRunID(ctx, run(7, 1), 0, 0)
87 assert.Equal(t, a, again, "stable per attempt")
88
89 // In range, the layout is fixed: leg in the low bits, then job, attempt, run.
90 assert.Equal(t, int64(5)<<26|int64(3)<<19|int64(2)<<9|1, usecase.CheckRunID(ctx, run(5, 3), 2, 1))
91
92 // Out of range, the id is 0 and the overflow is logged at warn, never an
93 // error: the key is informational.
94 for name, c := range map[string]struct {
95 run domain.CIRun
96 index, combo int
97 }{
98 "job index": {run(1, 1), 1024, 0},
99 "negative job": {run(1, 1), -1, 0},
100 "leg": {run(1, 1), 0, 512},
101 "attempt": {run(1, 128), 0, 0},
102 "run id": {run(1<<27, 1), 0, 0},
103 "huge run id": {run(math.MaxInt64, 1), 0, 0},
104 "negative run": {run(-1, 1), 0, 0},
105 } {
106 var logs bytes.Buffer
107 lctx := zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
108 assert.Zero(t, usecase.CheckRunID(lctx, c.run, c.index, c.combo), name)
109 assert.Contains(t, logs.String(), `"level":"warn"`, name)
110 assert.Contains(t, logs.String(), `"run_id":`+strconv.FormatInt(c.run.ID, 10), name)
111 assert.Contains(t, logs.String(), `"attempt":`+strconv.Itoa(int(c.run.Attempt)), name)
112 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
113 }
114}
115
116func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) {
117 for _, allow := range []bool{false, true} {
118 t.Run(strconv.FormatBool(allow), func(t *testing.T) {
119 repos := mocks.NewGitRepos(t)
120 u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos })
121 repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}
122 repo.Settings.AllowAutoMerge = allow
123 u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
124 repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil)
125 data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj")
126 require.NoError(t, err)
127 var obj map[string]any
128 require.NoError(t, json.Unmarshal(data, &obj))
129 assert.Equal(t, allow, obj["allow_auto_merge"])
130 })
131 }
132}