internal/control/control_test.go
165 lines · 5618 bytes
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "io"
7 "slices"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/protocol"
12)
13
14// TestEveryCommandReachableFromBareSSH asserts that each registered command's
15// path, rendered exactly as a user would type it after `ssh <host>`, resolves
16// back to that command through the tokenizer and Lookup. This is the guard
17// that keeps the forge CLI optional.
18func TestEveryCommandReachableFromBareSSH(t *testing.T) {
19 cmds := Commands()
20 if len(cmds) == 0 {
21 t.Fatal("no commands registered")
22 }
23 for _, cmd := range cmds {
24 line := strings.Join(cmd.Path, " ")
25 argv, err := protocol.Tokenize(line)
26 if err != nil {
27 t.Errorf("command %q not tokenizable: %v", line, err)
28 continue
29 }
30 got, rest, ok := Lookup(argv)
31 if !ok {
32 t.Errorf("command %q not found by Lookup", line)
33 continue
34 }
35 if strings.Join(got.Path, " ") != line || len(rest) != 0 {
36 t.Errorf("Lookup(%q) resolved to %q with rest %v", line, strings.Join(got.Path, " "), rest)
37 }
38 if cmd.Run == nil {
39 t.Errorf("command %q has no Run", line)
40 }
41 if cmd.Summary == "" {
42 t.Errorf("command %q has no summary", line)
43 }
44 }
45}
46
47func TestLookupLongestMatch(t *testing.T) {
48 // "keys list" must not resolve to a hypothetical shorter prefix and
49 // unknown commands must not match.
50 if _, _, ok := Lookup([]string{"keys"}); ok {
51 t.Error("bare \"keys\" resolved; group prefixes must not be runnable")
52 }
53 if _, _, ok := Lookup([]string{"nope"}); ok {
54 t.Error("unknown command resolved")
55 }
56 cmd, rest, ok := Lookup([]string{"keys", "list", "--json"})
57 if !ok || strings.Join(cmd.Path, " ") != "keys list" || len(rest) != 1 {
58 t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok)
59 }
60}
61
62// TestBuildJobsIsAReadCommand pins the properties the surfaces depend on:
63// the web build page and a read-scoped API token both need it over GET.
64func TestBuildJobsIsAReadCommand(t *testing.T) {
65 cmd, _, ok := Lookup([]string{"build", "jobs"})
66 if !ok {
67 t.Fatal("build jobs not registered")
68 }
69 if !cmd.ReadOnly {
70 t.Error("build jobs must be ReadOnly; listing jobs changes nothing")
71 }
72 if cmd.SSHOnly {
73 t.Error("build jobs must not be SSHOnly; the web and the app need it")
74 }
75}
76
77// A merge request's dedup key must not collide with a commit sha. It did:
78// a bare "#N" in a commit message recorded (issue, sha) first, and the
79// description's "Closes #N" then found the key taken and silently gave
80// up. That is how krz/gitbay-ios#8 stayed open after its own MR merged.
81func TestMRDedupKeyCannotCollideWithASHA(t *testing.T) {
82 key := mrRefKey(24)
83 if key == "51b6a14eab49ab08e890597653fcf02f8f38f3d6" || len(key) == 40 {
84 t.Errorf("mrRefKey(24) = %q, which is shaped like a sha", key)
85 }
86 if key != "mr-24" {
87 t.Errorf("mrRefKey(24) = %q, want \"mr-24\"", key)
88 }
89 if mrRefKey(24) == mrRefKey(25) {
90 t.Error("different merge requests share a dedup key")
91 }
92}
93
94// TestEveryCommandDocumentsItsUsage is what makes `help <prefix>` and
95// `gitbay <cmd> --help` worth typing: both render Usage, so a command that
96// omits it documents nothing. Usage opens with the command path so the
97// printed line can be typed as-is, and the summary must not carry the
98// argument syntax it used to.
99func TestEveryCommandDocumentsItsUsage(t *testing.T) {
100 for _, cmd := range Commands() {
101 path := strings.Join(cmd.Path, " ")
102 if cmd.Usage == "" {
103 t.Errorf("command %q has no Usage", path)
104 continue
105 }
106 if cmd.Usage != path && !strings.HasPrefix(cmd.Usage, path+" ") {
107 t.Errorf("command %q has Usage %q, which does not open with the command path", path, cmd.Usage)
108 }
109 if strings.Contains(cmd.Summary, ": "+path) {
110 t.Errorf("command %q still carries its usage in the summary: %q", path, cmd.Summary)
111 }
112 }
113}
114
115// TestHelpPrefixNarrowsAndShowsFlags covers the reason the command exists:
116// before this, reading one command's flags meant reading all of them.
117func TestHelpPrefixNarrowsAndShowsFlags(t *testing.T) {
118 var buf bytes.Buffer
119 c := &Ctx{Stdout: &buf, Stderr: io.Discard}
120 if code := runHelp(c, []string{"issue"}); code != protocol.ExitOK {
121 t.Fatalf("help issue exited %d", code)
122 }
123 out := buf.String()
124 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
125 if strings.HasPrefix(line, " ") {
126 continue // the indented usage line
127 }
128 if !strings.HasPrefix(line, "issue ") {
129 t.Errorf("help issue listed an unrelated command: %q", line)
130 }
131 }
132 if !strings.Contains(out, "--state open|closed|all") {
133 t.Error("help issue did not print issue list's flags")
134 }
135}
136
137func TestHelpUnknownPrefixIsNotFound(t *testing.T) {
138 var buf bytes.Buffer
139 c := &Ctx{Stdout: &buf, Stderr: io.Discard}
140 if code := runHelp(c, []string{"nope"}); code != protocol.ExitNotFound {
141 t.Errorf("help nope exited %d, want %d", code, protocol.ExitNotFound)
142 }
143}
144
145// TestHelpListsEveryCommandSorted pins the unfiltered listing: one row per
146// registered command, ordered so a noun's commands sit together.
147func TestHelpListsEveryCommandSorted(t *testing.T) {
148 var buf bytes.Buffer
149 c := &Ctx{Stdout: &buf, Stderr: io.Discard, JSON: true}
150 if code := runHelp(c, nil); code != protocol.ExitOK {
151 t.Fatalf("help exited %d", code)
152 }
153 var env struct {
154 Data []helpEntry `json:"data"`
155 }
156 if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
157 t.Fatalf("help --json: %v", err)
158 }
159 if len(env.Data) != len(Commands()) {
160 t.Errorf("help listed %d commands, registry has %d", len(env.Data), len(Commands()))
161 }
162 if !slices.IsSortedFunc(env.Data, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) {
163 t.Error("help output is not sorted by path")
164 }
165}