package cli // Tests for the app CLI surface (apps.go). The commands go through one // ControlPlane served by the generated mock: JSON parity (text and --json // carry equivalent semantics), no secret values in output, plan-contract // errors, and outcome-unknown guidance. import ( "bytes" "context" "encoding/json" "errors" "net/http" "os" "path/filepath" "strings" "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/adapters/dto" climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" "github.com/bnema/gordon/internal/adapters/in/cli/remote" "github.com/bnema/gordon/internal/domain" ) // appPlane returns the generated control plane mock for one test. func appPlane(t *testing.T) *climocks.MockControlPlane { t.Helper() return climocks.NewMockControlPlane(t) } func writeManifest(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "app.toml") require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) return path } func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) { plane := appPlane(t) err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "x.toml", true, true, false) require.Error(t, err) assert.Contains(t, err.Error(), "--dry-run") assert.Contains(t, err.Error(), "--deploy") } func TestRunAppsApply_RequiresFile(t *testing.T) { plane := appPlane(t) err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", false, false, false) require.Error(t, err) assert.Contains(t, err.Error(), "--file") } func TestRunAppsApply_JSONParity(t *testing.T) { want := &dto.AppApplyResponse{ App: "blog", FormerRevision: "rev-a", ResultingRevision: "rev-b", Pending: true, Intent: "apply-123", Diff: dto.AppDiffSection{Added: []string{"service.web"}}, } plane := appPlane(t) plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once() var out bytes.Buffer require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true)) var got dto.AppApplyResponse require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, *want, got) } func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) { plane := appPlane(t) plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return( &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once() plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() var out bytes.Buffer require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, writeManifest(t, "x"), false, true, false)) assert.Contains(t, out.String(), "rev-b") } func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) { plane := appPlane(t) plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return( &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once() plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() var out bytes.Buffer require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, writeManifest(t, "x"), false, true, true)) var got struct { Apply dto.AppApplyResponse `json:"apply"` Deploy dto.AppDeployResponse `json:"deploy"` } require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, "apply-1", got.Apply.Intent) assert.Equal(t, "op-1", got.Deploy.Op) } func TestRunAppsList_JSONParityAndSorting(t *testing.T) { plane := appPlane(t) plane.EXPECT().ListApps(mock.Anything).Return([]dto.AppSummaryDTO{ {App: "zeta", Converged: true}, {App: "alpha", Stopped: true}, }, nil) var out bytes.Buffer require.NoError(t, runAppsList(context.Background(), plane, &out, true)) var got []dto.AppSummaryDTO require.NoError(t, json.Unmarshal(out.Bytes(), &got)) require.Len(t, got, 2) out.Reset() require.NoError(t, runAppsList(context.Background(), plane, &out, false)) text := out.String() assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"), "text lists apps sorted") assert.Contains(t, text, "stopped") } func TestRunAppsList_EmptyJSONIsArray(t *testing.T) { plane := appPlane(t) plane.EXPECT().ListApps(mock.Anything).Return(nil, nil).Once() var out bytes.Buffer require.NoError(t, runAppsList(context.Background(), plane, &out, true)) assert.JSONEq(t, `[]`, strings.TrimSpace(out.String())) } func TestRenderActiveServices_SingleWriterLegend(t *testing.T) { for _, tc := range []struct { name string flagged []string }{ {"none", nil}, {"one", []string{"db"}}, {"two", []string{"db", "web"}}, } { t.Run(tc.name, func(t *testing.T) { active := dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{ "db": {EffectiveRevision: "rev-1"}, "web": {EffectiveRevision: "rev-1"}, "worker": {EffectiveRevision: "rev-1"}, }} for _, name := range tc.flagged { svc := active.Services[name] svc.RestartUnsafe = true active.Services[name] = svc } text := strings.Join(renderActiveServices(active), "\n") assert.Equal(t, len(tc.flagged), strings.Count(text, "rev-1 single-writer")) legends := 0 if len(tc.flagged) > 0 { legends = 1 } assert.Equal(t, legends, strings.Count(text, singleWriterLegend)) }) } } func TestRunAppsShow_JSONParity(t *testing.T) { want := &dto.AppShowResponse{ App: "blog", Desired: dto.AppDesiredDTO{Revision: "rev-b", Status: "pending"}, Active: dto.AppActiveDTO{Converged: false, Services: map[string]dto.AppActiveServiceDTO{ "web": {EffectiveRevision: "rev-a", Container: "ctr-a", RestartUnsafe: true}, }}, Intent: dto.AppIntentDTO{Stopped: true}, LastOp: &dto.AppLastOpDTO{Op: "op-9", Outcome: "failed"}, } plane := appPlane(t) plane.EXPECT().ShowApp(mock.Anything, "blog").Return(want, nil) var out bytes.Buffer require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, true)) var got dto.AppShowResponse require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, *want, got) out.Reset() require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false)) assert.Contains(t, out.String(), "single-writer: has volumes or binds", "text explains the single-writer mark") assert.NotContains(t, out.String(), "restart_unsafe", "text does not print the bare JSON token") } func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) { plane := appPlane(t) var gotApp string var gotReq dto.AppSecretSetRequest plane.EXPECT().SetAppSecrets(mock.Anything, mock.Anything, mock.Anything). Run(func(_ context.Context, app string, req dto.AppSecretSetRequest) { gotApp, gotReq = app, req }).Return(nil).Once() var out bytes.Buffer require.NoError(t, runAppsSecretsSet(context.Background(), plane, strings.NewReader(""), &out, "blog", []string{"password=s3cr3t-hunter2", "token=abc"}, "web", false, false)) text := out.String() assert.NotContains(t, text, "s3cr3t-hunter2") assert.NotContains(t, text, "abc") assert.Contains(t, text, "password") assert.Contains(t, text, "token") assert.Equal(t, "blog", gotApp) assert.Equal(t, "web", gotReq.Service) assert.Equal(t, map[string]string{"password": "s3cr3t-hunter2", "token": "abc"}, gotReq.Secrets) } func TestRunAppsSecretsSet_RequiresService(t *testing.T) { plane := appPlane(t) err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "blog", []string{"k=v"}, "", false, false) require.Error(t, err) assert.Contains(t, err.Error(), "--service") } func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) { plane := appPlane(t) var gotReq dto.AppSecretSetRequest plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything). Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once() var out bytes.Buffer stdin := strings.NewReader("from_stdin=stdin-value\n\nspaced= preserved \n") require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin, &out, "blog", []string{"from_flag=flag-value"}, "web", true, false)) assert.Equal(t, map[string]string{ "from_stdin": "stdin-value", "spaced": " preserved ", "from_flag": "flag-value", }, gotReq.Secrets) err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "blog", []string{"no-equals-here"}, "web", false, false) require.Error(t, err) assert.Contains(t, err.Error(), "KEY=VALUE") } func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) { plane := appPlane(t) plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once() err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &bytes.Buffer{}, &bytes.Buffer{}, false) require.Error(t, err) assert.Contains(t, err.Error(), "outcome-unknown") assert.Contains(t, err.Error(), "key-abc") assert.Contains(t, err.Error(), "by-key") } func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) { plane := appPlane(t) plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{ StatusCode: http.StatusConflict, Status: "409 Conflict", Response: dto.AppDeployResponse{ Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", Services: map[string]dto.AppServiceResultDTO{ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"}, }, }, }).Once() var out bytes.Buffer err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, false) require.Error(t, err, "conflict must retain failure semantics") text := out.String() assert.Contains(t, text, "web:") assert.Contains(t, text, "nope") assert.Contains(t, err.Error(), "failed") assert.Contains(t, err.Error(), "op-9") assert.Contains(t, err.Error(), "key-9") assert.Contains(t, err.Error(), "by-key") } func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) { plane := appPlane(t) plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{ StatusCode: http.StatusConflict, Status: "409 Conflict", Response: dto.AppDeployResponse{ Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", Services: map[string]dto.AppServiceResultDTO{ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"}, }, }, }).Once() var out bytes.Buffer err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, true) require.Error(t, err) var got dto.AppDeployResponse require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, "op-9", got.Op) assert.Equal(t, "failed", got.Services["web"].Result) } func TestRunAppLifecycle_ConflictRendersJournal(t *testing.T) { plane := appPlane(t) plane.EXPECT().StopApp(mock.Anything, "blog").Return(nil, "key-7", &remote.AppOpConflictError{ StatusCode: http.StatusConflict, Status: "409 Conflict", Response: dto.AppDeployResponse{ Op: "op-7", App: "blog", Outcome: "partial", Services: map[string]dto.AppServiceResultDTO{ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"}, }, }, }).Once() var out bytes.Buffer err := runAppLifecycle(context.Background(), plane.StopApp, "stop", "blog", &out, false) require.Error(t, err) assert.Contains(t, out.String(), "busy") assert.Contains(t, err.Error(), "key-7") assert.Contains(t, err.Error(), "by-key") } func TestRenderAppDeployResponse_SortedServices(t *testing.T) { resp := &dto.AppDeployResponse{ Op: "op-1", App: "blog", Outcome: "partial", Services: map[string]dto.AppServiceResultDTO{ "web": {Result: "deployed", EffectiveRevision: "rev-b"}, "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true}, "runner": {Result: domain.AppServiceRemoved}, }, CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}}, Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}}, Retained: &dto.AppRetainedDTO{Volumes: []string{"blog-db"}}, } var out bytes.Buffer require.NoError(t, renderAppDeployResponse(&out, resp)) text := out.String() assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:")) assert.Contains(t, text, "runner: removed\n") assert.Contains(t, text, "cleanup:") assert.Contains(t, text, "blog-db") } func TestAppLogRef_Selection(t *testing.T) { multi := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ "web": {Container: "ctr-web"}, "db": {Container: "ctr-db"}, }}} _, err := appLogRef(multi, "") require.Error(t, err) assert.Contains(t, err.Error(), "--service") ref, err := appLogRef(multi, "db") require.NoError(t, err) assert.Equal(t, "blog/db", ref) _, err = appLogRef(multi, "missing") require.Error(t, err) single := &dto.AppShowResponse{App: "solo", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ "web": {Container: "ctr-solo"}, }}} ref, err = appLogRef(single, "") require.NoError(t, err) assert.Equal(t, "solo/web", ref) empty := &dto.AppShowResponse{App: "new", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ "web": {}, }}} _, err = appLogRef(empty, "web") require.Error(t, err) assert.Contains(t, err.Error(), "no recorded container") } func TestResolveAppPlane_FailsWithoutDaemon(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("GORDON_REMOTE", "") t.Setenv("GORDON_TOKEN", "") origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure }) remoteFlag, tokenFlag, insecureTLSFlag = "", "", false restore := newLocalAppClient newLocalAppClient = func() (*remote.Client, error) { return nil, remote.ErrDaemonUnavailable } t.Cleanup(func() { newLocalAppClient = restore }) _, err := resolveAppPlane() require.Error(t, err) assert.Contains(t, err.Error(), "daemon-unavailable") } // TestAppCommandSurface pins the frozen CLI surface (05-api-cli.md ยง5): // every command exists with its frozen Use string and flags. The commands // stay unregistered until cutover; this test is what keeps them referenced. func TestAppCommandSurface(t *testing.T) { root := newAppsCmd() require.Equal(t, "apps", root.Use) subs := map[string]bool{} for _, sub := range root.Commands() { subs[sub.Name()] = true } for _, name := range []string{"apply", "list", "show", "diff", "secrets", "deploy", "restart", "stop", "start", "remove", "status", "logs"} { assert.True(t, subs[name], "apps subcommand %s", name) } secrets := newAppsSecretsCmd() secretSubs := map[string]bool{} for _, sub := range secrets.Commands() { secretSubs[sub.Name()] = true } assert.True(t, secretSubs["set"]) assert.True(t, secretSubs["delete"]) flagged := map[*cobra.Command][]string{ newAppsApplyCmd(): {"file", "dry-run", "deploy", "json"}, newAppsListCmd(): {"json"}, newAppsShowCmd(): {"json"}, newAppsDiffCmd(): {"json"}, newAppsSecretsSetCmd(): {"service", "stdin", "json"}, newAppsSecretsDeleteCmd(): {"service", "json"}, newAppDeployCmd(): {"revision", "service", "json"}, newAppRestartCmd(): {"service", "json"}, newAppStopCmd(): {"json"}, newAppStartCmd(): {"json"}, newAppRemoveCmd(): {"json"}, newAppStatusCmd(): {"json"}, newAppLogsCmd(): {"service", "follow", "tail", "json"}, } for cmd, flags := range flagged { for _, flag := range flags { assert.NotNil(t, cmd.Flags().Lookup(flag), "%s must define --%s", cmd.Use, flag) } } assert.Equal(t, "deploy APP", newAppDeployCmd().Use) assert.Equal(t, "restart APP", newAppRestartCmd().Use) assert.Equal(t, "stop APP", newAppStopCmd().Use) assert.Equal(t, "start APP", newAppStartCmd().Use) assert.Equal(t, "remove APP", newAppRemoveCmd().Use) assert.Equal(t, "status APP", newAppStatusCmd().Use) assert.Equal(t, "logs APP", newAppLogsCmd().Use) } func TestRunAppsDiff_TextAndJSON(t *testing.T) { diff := &dto.AppDiffResponse{ App: "blog", Diff: dto.AppDiffSection{Added: []string{"b"}, Removed: []string{"a"}, Changed: []string{"c"}}, } plane := appPlane(t) plane.EXPECT().DiffApp(mock.Anything, "blog").Return(diff, nil).Times(2) var out bytes.Buffer require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false)) text := out.String() assert.Contains(t, text, "added:") assert.Contains(t, text, "removed:") assert.Contains(t, text, "changed:") out.Reset() require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, true)) var got dto.AppDiffResponse require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, *diff, got) plane.EXPECT().DiffApp(mock.Anything, "blog").Return(&dto.AppDiffResponse{App: "blog"}, nil).Once() out.Reset() require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false)) assert.Contains(t, out.String(), "No differences") } func TestRunAppsSecretsDelete(t *testing.T) { plane := appPlane(t) plane.EXPECT().DeleteAppSecret(mock.Anything, "blog", dto.AppSecretDeleteRequest{Service: "web", Key: "password"}).Return(nil).Times(2) var out bytes.Buffer require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", false)) assert.Contains(t, out.String(), "password") out.Reset() require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", true)) var got map[string]string require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, "password", got["deleted"]) err := runAppsSecretsDelete(context.Background(), plane, &bytes.Buffer{}, "blog", "password", "", false) require.Error(t, err) assert.Contains(t, err.Error(), "--service") } func TestRunAppRestart_AndLifecycle(t *testing.T) { resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"} plane := appPlane(t) plane.EXPECT().RestartApp(mock.Anything, "blog", "", false).Return(resp, "key-lc", nil).Times(2) plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() var out bytes.Buffer require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, false)) assert.Contains(t, out.String(), "success") out.Reset() require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, true)) var got dto.AppDeployResponse require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, *resp, got) for _, fn := range []appLifecycleFunc{plane.StopApp, plane.StartApp, plane.RemoveApp} { out.Reset() require.NoError(t, runAppLifecycle(context.Background(), fn, "stop", "blog", &out, false)) assert.Contains(t, out.String(), "op-2") } unknown := appPlane(t) unknown.EXPECT().StopApp(mock.Anything, "blog").Return( nil, "key-x", &remote.OutcomeUnknownError{Method: "POST", Path: "/x", Err: errors.New("boom")}).Once() err := runAppLifecycle(context.Background(), unknown.StopApp, "stop", "blog", &bytes.Buffer{}, false) require.Error(t, err) assert.Contains(t, err.Error(), "key-x") } func TestRunAppStatus_Observed(t *testing.T) { show := &dto.AppShowResponse{ App: "blog", Active: dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{ "web": {EffectiveRevision: "rev-b", Container: "ctr-b"}, }}, } plane := appPlane(t) plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(2) var out bytes.Buffer require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, false)) assert.Contains(t, out.String(), "ctr-b") out.Reset() require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, true)) var got dto.AppShowResponse require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, *show, got) } // fakeAppLogReader scripts container log reads for app log tests. type fakeAppLogReader struct { lines []string stream []string } func (f *fakeAppLogReader) GetContainerLogs(_ context.Context, _ string, _ int) ([]string, error) { return f.lines, nil } func (f *fakeAppLogReader) StreamContainerLogs(_ context.Context, _ string, _ int) (<-chan string, error) { ch := make(chan string, len(f.stream)) for _, line := range f.stream { ch <- line } close(ch) return ch, nil } func TestRunAppLogs_TextJSONFollow(t *testing.T) { show := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ "web": {Container: "ctr-web"}, }}} plane := appPlane(t) plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(3) reader := &fakeAppLogReader{lines: []string{"l1", "l2"}, stream: []string{"s1"}} var out bytes.Buffer require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "", false, 50, &out, false)) assert.Equal(t, "l1\nl2\n", out.String()) out.Reset() require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", false, 50, &out, true)) var got map[string]any require.NoError(t, json.Unmarshal(out.Bytes(), &got)) assert.Equal(t, "blog", got["app"]) out.Reset() require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false)) assert.Equal(t, "s1\n", out.String()) } func TestRunAppsSecretsList_RendersMetadataAndWrapsErrors(t *testing.T) { plane := appPlane(t) plane.EXPECT().ListAppSecrets(mock.Anything, "blog", "web").Return([]dto.AppSecretMetadataDTO{ {Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"}, }, nil).Once() var out bytes.Buffer require.NoError(t, runAppsSecretsList(context.Background(), plane, &out, "blog", "web", false)) assert.Contains(t, out.String(), "web/DATABASE_URL") assert.Contains(t, out.String(), "desired") errPlane := appPlane(t) errPlane.EXPECT().ListAppSecrets(mock.Anything, "blog", "").Return(nil, errors.New("boom")).Once() err := runAppsSecretsList(context.Background(), errPlane, &bytes.Buffer{}, "blog", "", false) require.Error(t, err) assert.Contains(t, err.Error(), "list app secrets for blog") assert.Contains(t, err.Error(), "boom") }