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()) } }