Files
cctuip/cctuip_test.go
T
root c4e31f9880 fix: merge per-service statuses, bound SSH concurrency, fix filter and page size
Stacks usually run more than one service, so the last service read used
to wipe the status of the stack. Merge the labels into the existing map
instead.

Servers are now queried through an injectable fetcher with a concurrency
limit, so a large fleet no longer opens one SSH connection per server at
once, and the fetch is testable.

Also:
- only read the recipe catalogue when a deployed version exists, and
  render statuses anyway if it cannot be fetched
- truncate the error banner instead of joining every server error
- clamp the page size to at least one row, as bubble-table otherwise
  renders nothing at all
- stop treating keypresses as commands while the filter has focus, so q
  types a q rather than quitting
- parse flags on a dedicated flag set with long forms, so a bad flag
  reports against cctuip help instead of exiting
- drop golang.org/x/exp in favour of the built-in maps
2026-09-30 15:20:55 +00:00

837 lines
21 KiB
Go

package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
tea "github.com/charmbracelet/bubbletea"
"github.com/docker/docker/api/types/swarm"
"github.com/evertras/bubble-table/table"
)
// testApp builds an app pinned to an explicit stack name so that tests do not
// depend on abra's stack name sanitisation rules.
func testApp(domain, server, recipeName, stackName string) config.App {
return config.App{
Name: domain,
Domain: domain,
Server: server,
Recipe: recipeName,
Env: config.AppEnv{"STACK_NAME": stackName},
}
}
// labelledService builds a swarm service carrying the Co-op Cloud labels for a
// stack, using the same label keys as the production code.
func labelledService(stackName string, values map[string]string) swarm.Service {
labels := map[string]string{convert.LabelNamespace: stackName}
for suffix, value := range values {
labels[labelKey(stackName, suffix)] = value
}
return swarm.Service{
Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Labels: labels}},
}
}
// testModel builds a model backed by a table of the given apps.
func testModel(apps []config.App) model {
return model{
apps: apps,
table: newTable(newRows(apps), 200, 40),
}
}
// keyMsg builds a rune key message.
func keyMsg(r rune) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}
}
// send delivers a key to the model and returns the updated model.
func send(t *testing.T, m model, r rune) (model, tea.Cmd) {
t.Helper()
updated, cmd := m.Update(keyMsg(r))
next, ok := updated.(model)
if !ok {
t.Fatalf("Update returned a %T, want a model", updated)
}
return next, cmd
}
// containsQuit reports whether cmd is, or batches, bubbletea's quit command.
// The quit message type is unexported, so the commands are compared by code
// pointer. Only batch closures are invoked, which never run their children.
func containsQuit(cmd tea.Cmd) bool {
if cmd == nil {
return false
}
if reflect.ValueOf(cmd).Pointer() == reflect.ValueOf(tea.Quit).Pointer() {
return true
}
batch, ok := cmd().(tea.BatchMsg)
if !ok {
return false
}
for _, child := range batch {
if containsQuit(child) {
return true
}
}
return false
}
// rowFor returns the first visible row of a table.
func rowFor(t *testing.T, tbl table.Model) table.Row {
t.Helper()
rows := tbl.GetVisibleRows()
if len(rows) != 1 {
t.Fatalf("expected exactly one visible row, got %v", len(rows))
}
return rows[0]
}
// B3: most stacks run more than one service, and the last service read must not
// wipe out the status of the stack.
func TestParseServiceStatusesMultiServiceStack(t *testing.T) {
const name = "myapp_com_example"
statuses := parseServiceStatuses([]stack.StackStatus{{
Services: []swarm.Service{
labelledService(name, map[string]string{
"version": "1.0.0",
"chaos": "true",
}),
// A second service in the same stack, as any app with a database
// has, must not drop the keys the first service set.
labelledService(name, map[string]string{
"version": "1.0.0",
}),
},
}})
status, ok := statuses[name]
if !ok {
t.Fatalf("expected a status for stack %q, got %v", name, statuses)
}
if got := status[keyStatus]; got != "deployed" {
t.Errorf("status = %q, want %q", got, "deployed")
}
if got := status[keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
if got := status[keyChaos]; got != "true" {
t.Errorf("chaos = %q, want %q", got, "true")
}
}
// B4: a deployed stack with no version label is still deployed.
func TestParseServiceStatusesMissingVersionLabel(t *testing.T) {
const name = "myapp_com_example"
statuses := parseServiceStatuses([]stack.StackStatus{{
Services: []swarm.Service{
labelledService(name, map[string]string{"autoupdate": "true"}),
},
}})
status, ok := statuses[name]
if !ok {
t.Fatalf("expected a status for stack %q, got %v", name, statuses)
}
if got := status[keyStatus]; got != "deployed" {
t.Errorf("status = %q, want %q", got, "deployed")
}
if got, ok := status[keyVersion]; ok {
t.Errorf("version = %q, want it to be absent", got)
}
if got := status[keyAutoUpdate]; got != "true" {
t.Errorf("auto-update = %q, want %q", got, "true")
}
}
func TestParseServiceStatusesDefaults(t *testing.T) {
const name = "myapp_com_example"
statuses := parseServiceStatuses([]stack.StackStatus{
{Err: errors.New("ssh: unreachable")},
{Services: []swarm.Service{
{Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{
Labels: map[string]string{},
}}},
labelledService(name, map[string]string{"version": "1.0.0"}),
}},
})
status, ok := statuses[name]
if !ok {
t.Fatalf("expected a status for stack %q", name)
}
if got := status[keyAutoUpdate]; got != "false" {
t.Errorf("auto-update = %q, want %q", got, "false")
}
}
// B2: the auto-update column is keyed "auto-update" and must be rendered.
func TestRenderAppsDeployStatusAutoUpdate(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", name)})
rendered := renderAppsDeployStatus(&m, appsDeployStatusMsg{
statuses: map[string]map[string]string{
name: {
keyStatus: "deployed",
keyVersion: "1.0.0",
keyUpdates: "✅",
keyAutoUpdate: "true",
},
},
})
row := rowFor(t, rendered)
if got := row.Data[keyAutoUpdate]; got != "true" {
t.Errorf("auto-update = %q, want %q", got, "true")
}
if got := row.Data[keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
}
func TestRenderAppsDeployStatusUnknown(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", name)})
rendered := renderAppsDeployStatus(&m, appsDeployStatusMsg{
statuses: map[string]map[string]string{},
})
row := rowFor(t, rendered)
for _, key := range statusKeys {
if got := row.Data[key]; got != unknownStatus {
t.Errorf("%s = %q, want %q", key, got, unknownStatus)
}
}
}
// B7: an unparseable catalogue tag must not be masked by a later ✅.
func TestResolveUpdates(t *testing.T) {
tests := []struct {
name string
deployed string
versions []string
want string
}{
{name: "up to date", deployed: "1.1.0", versions: []string{"1.0.0", "1.1.0"}, want: "✅"},
{name: "no catalogue entry", deployed: "1.0.0", versions: nil, want: "✅"},
{name: "one update", deployed: "1.0.0", versions: []string{"1.0.0", "1.1.0"}, want: "1"},
{name: "several updates", deployed: "1.0.0", versions: []string{"1.1.0", "1.2.0", "2.0.0"}, want: "3"},
{name: "newer is excluded", deployed: "2.0.0", versions: []string{"1.0.0"}, want: "✅"},
{name: "unparseable catalogue tag", deployed: "1.0.0", versions: []string{"latest"}, want: unknownStatus},
{name: "unparseable deployed tag", deployed: "nightly", versions: []string{"1.0.0"}, want: unknownStatus},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const name = "myapp_com_example"
var versions recipe.RecipeVersions
for _, version := range test.versions {
versions = append(versions, map[string]map[string]recipe.ServiceMeta{
version: {"app": recipe.ServiceMeta{Image: "img", Tag: version}},
})
}
catl := recipe.RecipeCatalogue{
"myrecipe": recipe.RecipeMeta{Versions: versions},
}
statuses := map[string]map[string]string{
name: {keyStatus: "deployed", keyVersion: test.deployed},
}
resolveUpdates([]config.App{testApp("example.com", "srv", "myrecipe", name)}, statuses, catl)
if got := statuses[name][keyUpdates]; got != test.want {
t.Errorf("updates = %q, want %q", got, test.want)
}
})
}
}
func TestResolveUpdatesSkipsUnknownApps(t *testing.T) {
const name = "myapp_com_example"
statuses := map[string]map[string]string{
name: {keyStatus: "deployed", keyVersion: "1.0.0"},
}
apps := []config.App{
testApp("example.com", "srv", "myrecipe", name),
// Not deployed at all, so it must be left alone.
testApp("other.com", "srv", "myrecipe", "other_com_other"),
}
resolveUpdates(apps, statuses, recipe.RecipeCatalogue{})
if got := statuses[name][keyUpdates]; got != "✅" {
t.Errorf("updates = %q, want %q", got, "✅")
}
if _, ok := statuses["other_com_other"]; ok {
t.Error("expected no status for an app which is not deployed")
}
}
// B1: pressing q while the fuzzy filter has focus must type a q, not quit.
func TestUpdateFilterInputDoesNotQuit(t *testing.T) {
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", "myapp_com_example")})
m, _ = send(t, m, '/')
if !m.table.GetIsFilterInputFocused() {
t.Fatal("expected the filter input to have focus after pressing /")
}
m, cmd := send(t, m, 'q')
if containsQuit(cmd) {
t.Error("pressing q inside the filter quit cctuip")
}
if got := m.table.GetCurrentFilter(); got != "q" {
t.Errorf("filter = %q, want %q", got, "q")
}
}
func TestUpdateQuitsOutsideFilter(t *testing.T) {
m := testModel([]config.App{testApp("example.com", "srv", "myrecipe", "myapp_com_example")})
_, cmd := send(t, m, 'q')
if !containsQuit(cmd) {
t.Error("expected pressing q to quit when the filter is not focused")
}
}
// B5: bubble-table renders nothing at all on a non-positive page size.
func TestPageSizeClamped(t *testing.T) {
tests := []struct {
height int
want int
}{
{height: 40, want: 30},
{height: 11, want: 1},
{height: 10, want: 1},
{height: 4, want: 1},
{height: 0, want: 1},
{height: -5, want: 1},
}
for _, test := range tests {
if got := pageSize(test.height); got != test.want {
t.Errorf("pageSize(%v) = %v, want %v", test.height, got, test.want)
}
tbl := newTable(newRows([]config.App{testApp("a.com", "srv", "r", "s")}), 80, test.height)
if got := tbl.PageSize(); got < 1 {
t.Errorf("newTable(%v rows tall) gave page size %v, want at least 1", test.height, got)
}
// A non-positive page size makes bubble-table render no rows at all,
// so assert the row really does reach the screen.
if view := tbl.View(); !strings.Contains(view, "a.com") {
t.Errorf("newTable(%v rows tall) rendered no row:\n%v", test.height, view)
}
}
}
func TestFetchAllRespectsConcurrencyLimit(t *testing.T) {
var (
mu sync.Mutex
running int
peak int
)
fetcher := func(string) stack.StackStatus {
mu.Lock()
running++
if running > peak {
peak = running
}
mu.Unlock()
time.Sleep(2 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
return stack.StackStatus{}
}
var servers []string
for i := 0; i < 20; i++ {
servers = append(servers, fmt.Sprintf("srv-%v", i))
}
fetchAll(fetcher, servers, 3)
if peak > 3 {
t.Errorf("ran %v servers at once, want at most 3", peak)
}
if peak == 0 {
t.Error("expected the fetcher to be called")
}
}
func TestFetchAllCollectsServerErrors(t *testing.T) {
fetcher := func(server string) stack.StackStatus {
if server != "ok" {
return stack.StackStatus{Err: fmt.Errorf("ssh: cannot reach %s", server)}
}
return stack.StackStatus{Services: []swarm.Service{
labelledService("myapp_com_example", map[string]string{"version": "1.0.0"}),
}}
}
all, serverErrors := fetchAll(fetcher, []string{"ok", "broken-1", "broken-2"}, 2)
if len(serverErrors) != 2 {
t.Errorf("got %v server errors, want 2: %v", len(serverErrors), serverErrors)
}
for _, server := range []string{"broken-1", "broken-2"} {
if _, ok := serverErrors[server]; !ok {
t.Errorf("expected an error for server %q", server)
}
}
if len(all) != 1 {
t.Fatalf("got %v results, want 1: a healthy server must still be returned", len(all))
}
// The healthy server's statuses survive alongside the errors.
if got := parseServiceStatuses(all)["myapp_com_example"][keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
}
func TestFetchAllEmpty(t *testing.T) {
called := false
fetcher := func(string) stack.StackStatus {
called = true
return stack.StackStatus{}
}
all, serverErrors := fetchAll(fetcher, nil, 0)
if called {
t.Error("expected no servers to be queried")
}
if len(all) != 0 || len(serverErrors) != 0 {
t.Errorf("got %v results and %v errors, want none", len(all), len(serverErrors))
}
}
func TestSummariseProblems(t *testing.T) {
if err := summariseProblems(appsDeployStatusMsg{}); err != nil {
t.Errorf("expected no error when nothing went wrong, got %v", err)
}
tests := []struct {
name string
msg appsDeployStatusMsg
want string
}{
{
name: "single server",
msg: appsDeployStatusMsg{
serverErrors: map[string]error{"a": errors.New("boom")},
},
want: "a: boom",
},
{
name: "sorted",
msg: appsDeployStatusMsg{
serverErrors: map[string]error{
"b": errors.New("boom"),
"a": errors.New("boom"),
},
},
want: "a: boom, b: boom",
},
{
name: "truncated",
msg: appsDeployStatusMsg{
serverErrors: map[string]error{
"a": errors.New("boom"),
"b": errors.New("boom"),
"c": errors.New("boom"),
"d": errors.New("boom"),
},
},
want: "a: boom, b: boom, c: boom, …and 1 more",
},
{
name: "catalogue",
msg: appsDeployStatusMsg{catalogueErr: errors.New("offline")},
want: "catalogue: offline",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := summariseProblems(test.msg)
if err == nil {
t.Fatal("expected an error, got nil")
}
if err.Error() != test.want {
t.Errorf("error = %q, want %q", err.Error(), test.want)
}
})
}
}
func TestGetNumServersAndRecipes(t *testing.T) {
tests := []struct {
name string
apps []config.App
wantServers int
wantRecipes int
}{
{
name: "empty",
},
{
name: "deduplicated",
apps: []config.App{
testApp("a.com", "srv1", "r1", "s1"),
testApp("b.com", "srv1", "r1", "s2"),
testApp("c.com", "srv2", "r2", "s3"),
},
wantServers: 2,
wantRecipes: 2,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
servers, recipes := getNumServersAndRecipes(test.apps)
if servers != test.wantServers {
t.Errorf("servers = %v, want %v", servers, test.wantServers)
}
if recipes != test.wantRecipes {
t.Errorf("recipes = %v, want %v", recipes, test.wantRecipes)
}
})
}
}
func TestVisibleAppsCountsFiltered(t *testing.T) {
apps := []config.App{
testApp("a.com", "srv1", "r1", "s1"),
testApp("b.com", "srv2", "r2", "s2"),
}
m := testModel(apps)
m.numApps = len(apps)
m.updateCount()
if m.numFilteredApps != 2 {
t.Errorf("numFilteredApps = %v, want 2", m.numFilteredApps)
}
m, _ = send(t, m, '/')
m, _ = send(t, m, 'a')
if !m.table.GetIsFilterActive() {
t.Fatal("expected the filter to be active")
}
if got := visibleApps(&m); len(got) != 1 || got[0].Domain != "a.com" {
t.Errorf("visibleApps = %v, want just a.com", got)
}
}
func TestNewFlagSetLongForms(t *testing.T) {
tests := []struct {
arg string
wantHelp bool
wantVersion bool
}{
{arg: "-h", wantHelp: true},
{arg: "--help", wantHelp: true},
{arg: "-v", wantVersion: true},
{arg: "--version", wantVersion: true},
}
for _, test := range tests {
t.Run(test.arg, func(t *testing.T) {
var helpFlag bool
var versionFlag bool
fs := newFlagSet(&helpFlag, &versionFlag)
fs.SetOutput(io.Discard)
if err := fs.Parse([]string{test.arg}); err != nil {
t.Fatalf("unexpected error: %s", err)
}
if helpFlag != test.wantHelp {
t.Errorf("help = %v, want %v", helpFlag, test.wantHelp)
}
if versionFlag != test.wantVersion {
t.Errorf("version = %v, want %v", versionFlag, test.wantVersion)
}
})
}
}
func TestNewFlagSetRejectsUnknownFlag(t *testing.T) {
var helpFlag bool
var versionFlag bool
fs := newFlagSet(&helpFlag, &versionFlag)
fs.SetOutput(io.Discard)
if err := fs.Parse([]string{"--nope"}); err == nil {
t.Error("expected an error for an unknown flag")
}
}
func TestLabelKey(t *testing.T) {
if got, want := labelKey("myapp_com_example", "version"), "coop-cloud.myapp_com_example.version"; got != want {
t.Errorf("labelKey = %q, want %q", got, want)
}
}
// deployedStatus is a fully populated status for a deployed stack.
func deployedStatus(name string) map[string]string {
return map[string]string{
keyStatus: "deployed",
keyVersion: "1.0.0",
keyUpdates: "✅",
keyChaos: "false",
keyChaosVersion: "abc123",
keyAutoUpdate: "true",
}
}
func TestUpdateAppliesAppsDeployStatus(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", name)})
m.pollingStatus = true
m, _ = send(t, m, 'j') // any key, to prove the poll result lands
updated, _ := m.Update(appsDeployStatusMsg{
statuses: map[string]map[string]string{name: deployedStatus(name)},
})
m = updated.(model)
if m.pollingStatus {
t.Error("expected polling to have stopped")
}
if m.err != nil {
t.Errorf("unexpected error banner: %v", m.err)
}
row := rowFor(t, m.table)
for key, want := range deployedStatus(name) {
if got := row.Data[key]; got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
}
func TestUpdateReportsServerErrors(t *testing.T) {
const name = "myapp_com_example"
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", name)})
updated, _ := m.Update(appsDeployStatusMsg{
statuses: map[string]map[string]string{name: deployedStatus(name)},
serverErrors: map[string]error{"broken": errors.New("ssh: no route")},
})
m = updated.(model)
if m.err == nil {
t.Fatal("expected an error banner for an unreachable server")
}
if want := "broken: ssh: no route"; m.err.Error() != want {
t.Errorf("error = %q, want %q", m.err.Error(), want)
}
// The reachable server's status is still rendered alongside the banner.
if got := rowFor(t, m.table).Data[keyVersion]; got != "1.0.0" {
t.Errorf("version = %q, want %q", got, "1.0.0")
}
}
func TestUpdateClearsErrorOnKeypress(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", "s")})
m.err = errors.New("something went wrong")
m, _ = send(t, m, 'j')
if m.err != nil {
t.Errorf("expected the banner to be dismissed, got %v", m.err)
}
}
func TestUpdateStartsPolling(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", "s")})
m, cmd := send(t, m, 's')
if !m.pollingStatus {
t.Error("expected polling to have started")
}
if containsQuit(cmd) {
t.Error("pressing s must not quit")
}
// A second s while polling is ignored rather than starting a duplicate.
polling, _ := send(t, m, 's')
if !polling.pollingStatus {
t.Error("expected polling to still be in progress")
}
}
func TestUpdateResizes(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "myrecipe", "s")})
updated, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 4})
m = updated.(model)
if got := m.table.PageSize(); got != 1 {
t.Errorf("page size = %v, want 1 for a 4 row terminal", got)
}
if !strings.Contains(m.table.View(), "a.com") {
t.Errorf("expected the row to survive a resize to 4 rows:\n%v", m.table.View())
}
}
// TestGetApps exercises the local-first data path against a fake ~/.abra.
func TestGetApps(t *testing.T) {
dir := t.TempDir()
originalServers := config.SERVERS_DIR
t.Cleanup(func() { config.SERVERS_DIR = originalServers })
for _, app := range []struct {
server string
name string
body string
}{
{server: "srvB", name: "zebra.com", body: "DOMAIN=zebra.com\nTYPE=recipeZ\n"},
{server: "srvB", name: "alpha.com", body: "DOMAIN=alpha.com\nRECIPE=recipeA\n"},
{server: "srvA", name: "middle.net", body: "DOMAIN=middle.net\nTYPE=recipeA\n"},
} {
serverDir := filepath.Join(dir, "servers", app.server)
if err := os.MkdirAll(serverDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(serverDir, app.name+".env")
if err := os.WriteFile(path, []byte(app.body), 0o644); err != nil {
t.Fatal(err)
}
}
config.SERVERS_DIR = filepath.Join(dir, "servers")
apps, err := getApps()
if err != nil {
t.Fatalf("getApps: %s", err)
}
var domains []string
for _, app := range apps {
domains = append(domains, app.Domain)
}
want := []string{"alpha.com", "middle.net", "zebra.com"}
if !reflect.DeepEqual(domains, want) {
t.Errorf("domains = %v, want %v sorted by name", domains, want)
}
if got, recipes := getNumServersAndRecipes(apps); got != 2 || recipes != 2 {
t.Errorf("got %v servers and %v recipes, want 2 and 2", got, recipes)
}
}
func TestView(t *testing.T) {
m := testModel([]config.App{testApp("a.com", "srv", "r", "s")})
m.numFilteredApps = 1
m.numFilteredServers = 1
m.numFilteredRecipes = 1
view := m.View()
for _, want := range []string{"Servers: 1, Apps: 1, Recipes: 1", "a.com", "cctuip " + version} {
if !strings.Contains(view, want) {
t.Errorf("view is missing %q:\n%v", want, view)
}
}
if strings.Contains(view, "⚠") {
t.Errorf("view should have no error banner:\n%v", view)
}
m.err = errors.New("boom")
if !strings.Contains(m.View(), "⚠ boom") {
t.Errorf("view is missing the error banner:\n%v", m.View())
}
m.pollingStatus = true
if !strings.Contains(m.View(), "querying app status") {
t.Errorf("view is missing the polling indicator:\n%v", m.View())
}
}