internal/control/control_test.go

v1.8.1
gitbay/internal/control/control_test.go history · blame · raw

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}