1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "testing"
15)
16
17// isolateGit makes git invocations hermetic: inherited GIT_* variables are
18// dropped and no user or system configuration is read.
19func isolateGit(t *testing.T) {
20 t.Helper()
21 if _, err := exec.LookPath("git"); err != nil {
22 t.Skip("git not installed")
23 }
24 for _, kv := range os.Environ() {
25 if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "GIT_") {
26 t.Setenv(k, "") // registers restoration of the original value
27 os.Unsetenv(k)
28 }
29 }
30 t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
31 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
32}
33
34func gitT(t *testing.T, dir string, args ...string) string {
35 t.Helper()
36 full := append([]string{
37 "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
38 "-c", "user.name=test", "-c", "user.email=test@example.com",
39 }, args...)
40 out, err := git(context.Background(), dir, full...)
41 if err != nil {
42 t.Fatal(err)
43 }
44 return out
45}
46
47func writeFile(t *testing.T, dir, name, content string) {
48 t.Helper()
49 p := filepath.Join(dir, filepath.FromSlash(name))
50 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
51 t.Fatal(err)
52 }
53 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
54 t.Fatal(err)
55 }
56}
57
58func newRepo(t *testing.T) (dir, head string) {
59 t.Helper()
60 isolateGit(t)
61 dir = t.TempDir()
62 gitT(t, dir, "init", "-q")
63 writeFile(t, dir, "keep.txt", "keep\n")
64 writeFile(t, dir, "modify.txt", "old\n")
65 writeFile(t, dir, "delete.txt", "bye\n")
66 writeFile(t, dir, "sub/rename-me.txt", "rename content that is long enough to be detected as a rename\n")
67 gitT(t, dir, "add", "-A")
68 gitT(t, dir, "commit", "-q", "-m", "init")
69 return dir, strings.TrimSpace(gitT(t, dir, "rev-parse", "HEAD"))
70}
71
72func env(m map[string]string) func(string) string {
73 return func(k string) string { return m[k] }
74}
75
76func TestRunRequestShape(t *testing.T) {
77 dir, head := newRepo(t)
78 writeFile(t, dir, "modify.txt", "new\n")
79 writeFile(t, dir, "added/dir/new.bin", "\x00\x01binary")
80 if err := os.Remove(filepath.Join(dir, "delete.txt")); err != nil {
81 t.Fatal(err)
82 }
83 gitT(t, dir, "mv", "sub/rename-me.txt", "renamed.txt")
84
85 var gotAuth string
86 var got request
87 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 gotAuth = r.Header.Get("Authorization")
89 if r.Method != http.MethodPost {
90 t.Errorf("method = %s", r.Method)
91 }
92 if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
93 t.Error(err)
94 }
95 _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"abc123"}}}}`))
96 }))
97 defer srv.Close()
98
99 var out bytes.Buffer
100 err := run(context.Background(), []string{
101 "-repo", "o/r", "-branch", "main", "-api", srv.URL,
102 "-message", "feat: headline\n\nbody line 1\nbody line 2\n",
103 }, env(map[string]string{"GITHUB_TOKEN": "tok"}), &out, dir)
104 if err != nil {
105 t.Fatal(err)
106 }
107 if out.String() != "abc123\n" {
108 t.Errorf("stdout = %q", out.String())
109 }
110 if gotAuth != "Bearer tok" {
111 t.Errorf("auth = %q", gotAuth)
112 }
113 if !strings.Contains(got.Query, "createCommitOnBranch") {
114 t.Errorf("query = %q", got.Query)
115 }
116 in := got.Variables["input"]
117 if in.Branch.RepositoryNameWithOwner != "o/r" || in.Branch.BranchName != "main" {
118 t.Errorf("branch = %+v", in.Branch)
119 }
120 if in.ExpectedHeadOid != head {
121 t.Errorf("expectedHeadOid = %q, want %q", in.ExpectedHeadOid, head)
122 }
123 if in.Message.Headline != "feat: headline" || in.Message.Body != "body line 1\nbody line 2" {
124 t.Errorf("message = %+v", in.Message)
125 }
126
127 adds := map[string]string{}
128 for _, a := range in.FileChanges.Additions {
129 b, err := base64.StdEncoding.DecodeString(a.Contents)
130 if err != nil {
131 t.Fatal(err)
132 }
133 adds[a.Path] = string(b)
134 }
135 wantAdds := map[string]string{
136 "modify.txt": "new\n",
137 "added/dir/new.bin": "\x00\x01binary",
138 "renamed.txt": "rename content that is long enough to be detected as a rename\n",
139 }
140 if len(adds) != len(wantAdds) {
141 t.Errorf("additions = %v", adds)
142 }
143 for p, c := range wantAdds {
144 if adds[p] != c {
145 t.Errorf("addition %s = %q, want %q", p, adds[p], c)
146 }
147 }
148 var dels []string
149 for _, d := range in.FileChanges.Deletions {
150 dels = append(dels, d.Path)
151 }
152 if strings.Join(dels, ",") != "delete.txt,sub/rename-me.txt" {
153 t.Errorf("deletions = %v", dels)
154 }
155}
156
157func TestRunEmptyBodyOmitted(t *testing.T) {
158 dir, _ := newRepo(t)
159 writeFile(t, dir, "x.txt", "x")
160 var raw map[string]any
161 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162 _ = json.NewDecoder(r.Body).Decode(&raw)
163 _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"o"}}}}`))
164 }))
165 defer srv.Close()
166 err := run(context.Background(), []string{"-repo", "o/r", "-branch", "main", "-api", srv.URL, "-message", "only headline"},
167 env(map[string]string{"GITHUB_TOKEN": "t"}), &bytes.Buffer{}, dir)
168 if err != nil {
169 t.Fatal(err)
170 }
171 msg := raw["variables"].(map[string]any)["input"].(map[string]any)["message"].(map[string]any)
172 if _, ok := msg["body"]; ok {
173 t.Errorf("body should be omitted: %v", msg)
174 }
175}
176
177func TestRunErrors(t *testing.T) {
178 ok := env(map[string]string{"GITHUB_TOKEN": "t"})
179 base := func(api string) []string {
180 return []string{"-repo", "o/r", "-branch", "main", "-api", api, "-message", "m"}
181 }
182 serve := func(status int, body string) *httptest.Server {
183 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184 w.WriteHeader(status)
185 _, _ = w.Write([]byte(body))
186 }))
187 }
188
189 t.Run("missing token", func(t *testing.T) {
190 dir, _ := newRepo(t)
191 writeFile(t, dir, "x.txt", "x")
192 err := run(context.Background(), base("http://127.0.0.1:1"), env(nil), &bytes.Buffer{}, dir)
193 if err == nil || !strings.Contains(err.Error(), "GITHUB_TOKEN") {
194 t.Errorf("err = %v", err)
195 }
196 })
197 t.Run("missing flags", func(t *testing.T) {
198 dir, _ := newRepo(t)
199 err := run(context.Background(), []string{"-repo", "o/r"}, ok, &bytes.Buffer{}, dir)
200 if err == nil {
201 t.Error("expected error")
202 }
203 })
204 t.Run("no changes", func(t *testing.T) {
205 dir, _ := newRepo(t)
206 called := false
207 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
208 defer srv.Close()
209 err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
210 if err == nil || !strings.Contains(err.Error(), "no changes") {
211 t.Errorf("err = %v", err)
212 }
213 if called {
214 t.Error("API called despite no changes")
215 }
216 })
217 t.Run("symlink", func(t *testing.T) {
218 dir, _ := newRepo(t)
219 if err := os.Symlink("keep.txt", filepath.Join(dir, "link")); err != nil {
220 t.Skipf("symlinks unsupported: %v", err)
221 }
222 called := false
223 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
224 defer srv.Close()
225 err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
226 if err == nil || !strings.Contains(err.Error(), "symbolic link") {
227 t.Errorf("err = %v", err)
228 }
229 if called {
230 t.Error("API called despite symlink")
231 }
232 })
233 t.Run("non-200", func(t *testing.T) {
234 dir, _ := newRepo(t)
235 writeFile(t, dir, "x.txt", "x")
236 srv := serve(http.StatusUnauthorized, `{"message":"Bad credentials"}`)
237 defer srv.Close()
238 err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
239 if err == nil || !strings.Contains(err.Error(), "401") {
240 t.Errorf("err = %v", err)
241 }
242 })
243 t.Run("graphql errors", func(t *testing.T) {
244 dir, _ := newRepo(t)
245 writeFile(t, dir, "x.txt", "x")
246 srv := serve(http.StatusOK, `{"data":null,"errors":[{"message":"Expected head oid mismatch"}]}`)
247 defer srv.Close()
248 var out bytes.Buffer
249 err := run(context.Background(), base(srv.URL), ok, &out, dir)
250 if err == nil || !strings.Contains(err.Error(), "Expected head oid mismatch") {
251 t.Errorf("err = %v", err)
252 }
253 if out.Len() != 0 {
254 t.Errorf("stdout = %q", out.String())
255 }
256 })
257 t.Run("no oid", func(t *testing.T) {
258 dir, _ := newRepo(t)
259 writeFile(t, dir, "x.txt", "x")
260 srv := serve(http.StatusOK, `{"data":{}}`)
261 defer srv.Close()
262 if err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir); err == nil {
263 t.Error("expected error")
264 }
265 })
266}