1package cli
2
3// Tests for the app CLI surface (apps.go). The commands go through one
4// ControlPlane served by the generated mock: JSON parity (text and --json
5// carry equivalent semantics), no secret values in output, plan-contract
6// errors, and outcome-unknown guidance.
7
8import (
9 "bytes"
10 "context"
11 "encoding/json"
12 "errors"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strings"
17 "testing"
18
19 "github.com/spf13/cobra"
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/mock"
22 "github.com/stretchr/testify/require"
23
24 "github.com/bnema/gordon/internal/adapters/dto"
25 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
26 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
27 "github.com/bnema/gordon/internal/domain"
28)
29
30// appPlane returns the generated control plane mock for one test.
31func appPlane(t *testing.T) *climocks.MockControlPlane {
32 t.Helper()
33 return climocks.NewMockControlPlane(t)
34}
35
36func writeManifest(t *testing.T, content string) string {
37 t.Helper()
38 path := filepath.Join(t.TempDir(), "app.toml")
39 require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
40 return path
41}
42
43func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
44 plane := appPlane(t)
45 err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "x.toml", true, true, false)
46 require.Error(t, err)
47 assert.Contains(t, err.Error(), "--dry-run")
48 assert.Contains(t, err.Error(), "--deploy")
49}
50
51func TestRunAppsApply_RequiresFile(t *testing.T) {
52 plane := appPlane(t)
53 err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", false, false, false)
54 require.Error(t, err)
55 assert.Contains(t, err.Error(), "--file")
56}
57
58func TestRunAppsApply_JSONParity(t *testing.T) {
59 want := &dto.AppApplyResponse{
60 App: "blog", FormerRevision: "rev-a", ResultingRevision: "rev-b",
61 Pending: true, Intent: "apply-123",
62 Diff: dto.AppDiffSection{Added: []string{"service.web"}},
63 }
64 plane := appPlane(t)
65 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()
66 var out bytes.Buffer
67 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
68 writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))
69 var got dto.AppApplyResponse
70 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
71 assert.Equal(t, *want, got)
72}
73
74func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) {
75 plane := appPlane(t)
76 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
77 &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
78 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
79 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
80 var out bytes.Buffer
81 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
82 writeManifest(t, "x"), false, true, false))
83 assert.Contains(t, out.String(), "rev-b")
84}
85
86func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) {
87 plane := appPlane(t)
88 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
89 &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
90 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
91 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
92 var out bytes.Buffer
93 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
94 writeManifest(t, "x"), false, true, true))
95 var got struct {
96 Apply dto.AppApplyResponse `json:"apply"`
97 Deploy dto.AppDeployResponse `json:"deploy"`
98 }
99 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
100 assert.Equal(t, "apply-1", got.Apply.Intent)
101 assert.Equal(t, "op-1", got.Deploy.Op)
102}
103
104func TestRunAppsList_JSONParityAndSorting(t *testing.T) {
105 plane := appPlane(t)
106 plane.EXPECT().ListApps(mock.Anything).Return([]dto.AppSummaryDTO{
107 {App: "zeta", Converged: true},
108 {App: "alpha", Stopped: true},
109 }, nil)
110 var out bytes.Buffer
111 require.NoError(t, runAppsList(context.Background(), plane, &out, true))
112 var got []dto.AppSummaryDTO
113 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
114 require.Len(t, got, 2)
115
116 out.Reset()
117 require.NoError(t, runAppsList(context.Background(), plane, &out, false))
118 text := out.String()
119 assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"), "text lists apps sorted")
120 assert.Contains(t, text, "stopped")
121}
122
123func TestRunAppsList_EmptyJSONIsArray(t *testing.T) {
124 plane := appPlane(t)
125 plane.EXPECT().ListApps(mock.Anything).Return(nil, nil).Once()
126 var out bytes.Buffer
127 require.NoError(t, runAppsList(context.Background(), plane, &out, true))
128 assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))
129}
130
131func TestRenderActiveServices_SingleWriterLegend(t *testing.T) {
132 for _, tc := range []struct {
133 name string
134 flagged []string
135 }{
136 {"none", nil},
137 {"one", []string{"db"}},
138 {"two", []string{"db", "web"}},
139 } {
140 t.Run(tc.name, func(t *testing.T) {
141 active := dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
142 "db": {EffectiveRevision: "rev-1"}, "web": {EffectiveRevision: "rev-1"}, "worker": {EffectiveRevision: "rev-1"},
143 }}
144 for _, name := range tc.flagged {
145 svc := active.Services[name]
146 svc.RestartUnsafe = true
147 active.Services[name] = svc
148 }
149 text := strings.Join(renderActiveServices(active), "\n")
150 assert.Equal(t, len(tc.flagged), strings.Count(text, "rev-1 single-writer"))
151 legends := 0
152 if len(tc.flagged) > 0 {
153 legends = 1
154 }
155 assert.Equal(t, legends, strings.Count(text, singleWriterLegend))
156 })
157 }
158}
159
160func TestRunAppsShow_JSONParity(t *testing.T) {
161 want := &dto.AppShowResponse{
162 App: "blog",
163 Desired: dto.AppDesiredDTO{Revision: "rev-b", Status: "pending"},
164 Active: dto.AppActiveDTO{Converged: false, Services: map[string]dto.AppActiveServiceDTO{
165 "web": {EffectiveRevision: "rev-a", Container: "ctr-a", RestartUnsafe: true},
166 }},
167 Intent: dto.AppIntentDTO{Stopped: true},
168 LastOp: &dto.AppLastOpDTO{Op: "op-9", Outcome: "failed"},
169 }
170 plane := appPlane(t)
171 plane.EXPECT().ShowApp(mock.Anything, "blog").Return(want, nil)
172 var out bytes.Buffer
173 require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, true))
174 var got dto.AppShowResponse
175 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
176 assert.Equal(t, *want, got)
177
178 out.Reset()
179 require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))
180 assert.Contains(t, out.String(), "single-writer: has volumes or binds", "text explains the single-writer mark")
181 assert.NotContains(t, out.String(), "restart_unsafe", "text does not print the bare JSON token")
182}
183
184func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {
185 plane := appPlane(t)
186 var gotApp string
187 var gotReq dto.AppSecretSetRequest
188 plane.EXPECT().SetAppSecrets(mock.Anything, mock.Anything, mock.Anything).
189 Run(func(_ context.Context, app string, req dto.AppSecretSetRequest) {
190 gotApp, gotReq = app, req
191 }).Return(nil).Once()
192 var out bytes.Buffer
193 require.NoError(t, runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
194 &out, "blog", []string{"password=s3cr3t-hunter2", "token=abc"}, "web", false, false))
195 text := out.String()
196 assert.NotContains(t, text, "s3cr3t-hunter2")
197 assert.NotContains(t, text, "abc")
198 assert.Contains(t, text, "password")
199 assert.Contains(t, text, "token")
200 assert.Equal(t, "blog", gotApp)
201 assert.Equal(t, "web", gotReq.Service)
202 assert.Equal(t, map[string]string{"password": "s3cr3t-hunter2", "token": "abc"}, gotReq.Secrets)
203}
204
205func TestRunAppsSecretsSet_RequiresService(t *testing.T) {
206 plane := appPlane(t)
207 err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
208 &bytes.Buffer{}, "blog", []string{"k=v"}, "", false, false)
209 require.Error(t, err)
210 assert.Contains(t, err.Error(), "--service")
211}
212
213func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) {
214 plane := appPlane(t)
215 var gotReq dto.AppSecretSetRequest
216 plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).
217 Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()
218 var out bytes.Buffer
219 stdin := strings.NewReader("from_stdin=stdin-value\n\nspaced= preserved \n")
220 require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,
221 &out, "blog", []string{"from_flag=flag-value"}, "web", true, false))
222 assert.Equal(t, map[string]string{
223 "from_stdin": "stdin-value",
224 "spaced": " preserved ",
225 "from_flag": "flag-value",
226 }, gotReq.Secrets)
227
228 err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
229 &bytes.Buffer{}, "blog", []string{"no-equals-here"}, "web", false, false)
230 require.Error(t, err)
231 assert.Contains(t, err.Error(), "KEY=VALUE")
232}
233
234func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
235 plane := appPlane(t)
236 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
237 nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
238 err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &bytes.Buffer{}, &bytes.Buffer{}, false)
239 require.Error(t, err)
240 assert.Contains(t, err.Error(), "outcome-unknown")
241 assert.Contains(t, err.Error(), "key-abc")
242 assert.Contains(t, err.Error(), "by-key")
243}
244
245func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
246 plane := appPlane(t)
247 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
248 StatusCode: http.StatusConflict, Status: "409 Conflict",
249 Response: dto.AppDeployResponse{
250 Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
251 Services: map[string]dto.AppServiceResultDTO{
252 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
253 },
254 },
255 }).Once()
256 var out bytes.Buffer
257 err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, false)
258 require.Error(t, err, "conflict must retain failure semantics")
259 text := out.String()
260 assert.Contains(t, text, "web:")
261 assert.Contains(t, text, "nope")
262 assert.Contains(t, err.Error(), "failed")
263 assert.Contains(t, err.Error(), "op-9")
264 assert.Contains(t, err.Error(), "key-9")
265 assert.Contains(t, err.Error(), "by-key")
266}
267
268func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
269 plane := appPlane(t)
270 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
271 StatusCode: http.StatusConflict, Status: "409 Conflict",
272 Response: dto.AppDeployResponse{
273 Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
274 Services: map[string]dto.AppServiceResultDTO{
275 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
276 },
277 },
278 }).Once()
279 var out bytes.Buffer
280 err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, true)
281 require.Error(t, err)
282 var got dto.AppDeployResponse
283 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
284 assert.Equal(t, "op-9", got.Op)
285 assert.Equal(t, "failed", got.Services["web"].Result)
286}
287
288func TestRunAppLifecycle_ConflictRendersJournal(t *testing.T) {
289 plane := appPlane(t)
290 plane.EXPECT().StopApp(mock.Anything, "blog").Return(nil, "key-7", &remote.AppOpConflictError{
291 StatusCode: http.StatusConflict, Status: "409 Conflict",
292 Response: dto.AppDeployResponse{
293 Op: "op-7", App: "blog", Outcome: "partial",
294 Services: map[string]dto.AppServiceResultDTO{
295 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
296 },
297 },
298 }).Once()
299 var out bytes.Buffer
300 err := runAppLifecycle(context.Background(), plane.StopApp, "stop", "blog", &out, false)
301 require.Error(t, err)
302 assert.Contains(t, out.String(), "busy")
303 assert.Contains(t, err.Error(), "key-7")
304 assert.Contains(t, err.Error(), "by-key")
305}
306
307func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
308 resp := &dto.AppDeployResponse{
309 Op: "op-1", App: "blog", Outcome: "partial",
310 Services: map[string]dto.AppServiceResultDTO{
311 "web": {Result: "deployed", EffectiveRevision: "rev-b"},
312 "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
313 "runner": {Result: domain.AppServiceRemoved},
314 },
315 CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},
316 Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},
317 Retained: &dto.AppRetainedDTO{Volumes: []string{"blog-db"}},
318 }
319 var out bytes.Buffer
320 require.NoError(t, renderAppDeployResponse(&out, resp))
321 text := out.String()
322 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
323 assert.Contains(t, text, "runner: removed\n")
324 assert.Contains(t, text, "cleanup:")
325 assert.Contains(t, text, "blog-db")
326}
327
328func TestAppLogRef_Selection(t *testing.T) {
329 multi := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
330 "web": {Container: "ctr-web"},
331 "db": {Container: "ctr-db"},
332 }}}
333 _, err := appLogRef(multi, "")
334 require.Error(t, err)
335 assert.Contains(t, err.Error(), "--service")
336
337 ref, err := appLogRef(multi, "db")
338 require.NoError(t, err)
339 assert.Equal(t, "blog/db", ref)
340
341 _, err = appLogRef(multi, "missing")
342 require.Error(t, err)
343
344 single := &dto.AppShowResponse{App: "solo", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
345 "web": {Container: "ctr-solo"},
346 }}}
347 ref, err = appLogRef(single, "")
348 require.NoError(t, err)
349 assert.Equal(t, "solo/web", ref)
350
351 empty := &dto.AppShowResponse{App: "new", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
352 "web": {},
353 }}}
354 _, err = appLogRef(empty, "web")
355 require.Error(t, err)
356 assert.Contains(t, err.Error(), "no recorded container")
357}
358
359func TestResolveAppPlane_FailsWithoutDaemon(t *testing.T) {
360 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
361 t.Setenv("GORDON_REMOTE", "")
362 t.Setenv("GORDON_TOKEN", "")
363 origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
364 t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
365 remoteFlag, tokenFlag, insecureTLSFlag = "", "", false
366
367 restore := newLocalAppClient
368 newLocalAppClient = func() (*remote.Client, error) { return nil, remote.ErrDaemonUnavailable }
369 t.Cleanup(func() { newLocalAppClient = restore })
370
371 _, err := resolveAppPlane()
372 require.Error(t, err)
373 assert.Contains(t, err.Error(), "daemon-unavailable")
374}
375
376// TestAppCommandSurface pins the frozen CLI surface (05-api-cli.md §5):
377// every command exists with its frozen Use string and flags. The commands
378// stay unregistered until cutover; this test is what keeps them referenced.
379func TestAppCommandSurface(t *testing.T) {
380 root := newAppsCmd()
381 require.Equal(t, "apps", root.Use)
382 subs := map[string]bool{}
383 for _, sub := range root.Commands() {
384 subs[sub.Name()] = true
385 }
386 for _, name := range []string{"apply", "list", "show", "diff", "secrets", "deploy", "restart", "stop", "start", "remove", "status", "logs"} {
387 assert.True(t, subs[name], "apps subcommand %s", name)
388 }
389
390 secrets := newAppsSecretsCmd()
391 secretSubs := map[string]bool{}
392 for _, sub := range secrets.Commands() {
393 secretSubs[sub.Name()] = true
394 }
395 assert.True(t, secretSubs["set"])
396 assert.True(t, secretSubs["delete"])
397
398 flagged := map[*cobra.Command][]string{
399 newAppsApplyCmd(): {"file", "dry-run", "deploy", "json"},
400 newAppsListCmd(): {"json"},
401 newAppsShowCmd(): {"json"},
402 newAppsDiffCmd(): {"json"},
403 newAppsSecretsSetCmd(): {"service", "stdin", "json"},
404 newAppsSecretsDeleteCmd(): {"service", "json"},
405 newAppDeployCmd(): {"revision", "service", "json"},
406 newAppRestartCmd(): {"service", "json"},
407 newAppStopCmd(): {"json"},
408 newAppStartCmd(): {"json"},
409 newAppRemoveCmd(): {"json"},
410 newAppStatusCmd(): {"json"},
411 newAppLogsCmd(): {"service", "follow", "tail", "json"},
412 }
413 for cmd, flags := range flagged {
414 for _, flag := range flags {
415 assert.NotNil(t, cmd.Flags().Lookup(flag), "%s must define --%s", cmd.Use, flag)
416 }
417 }
418
419 assert.Equal(t, "deploy APP", newAppDeployCmd().Use)
420 assert.Equal(t, "restart APP", newAppRestartCmd().Use)
421 assert.Equal(t, "stop APP", newAppStopCmd().Use)
422 assert.Equal(t, "start APP", newAppStartCmd().Use)
423 assert.Equal(t, "remove APP", newAppRemoveCmd().Use)
424 assert.Equal(t, "status APP", newAppStatusCmd().Use)
425 assert.Equal(t, "logs APP", newAppLogsCmd().Use)
426}
427
428func TestRunAppsDiff_TextAndJSON(t *testing.T) {
429 diff := &dto.AppDiffResponse{
430 App: "blog",
431 Diff: dto.AppDiffSection{Added: []string{"b"}, Removed: []string{"a"}, Changed: []string{"c"}},
432 }
433 plane := appPlane(t)
434 plane.EXPECT().DiffApp(mock.Anything, "blog").Return(diff, nil).Times(2)
435 var out bytes.Buffer
436 require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
437 text := out.String()
438 assert.Contains(t, text, "added:")
439 assert.Contains(t, text, "removed:")
440 assert.Contains(t, text, "changed:")
441
442 out.Reset()
443 require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, true))
444 var got dto.AppDiffResponse
445 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
446 assert.Equal(t, *diff, got)
447
448 plane.EXPECT().DiffApp(mock.Anything, "blog").Return(&dto.AppDiffResponse{App: "blog"}, nil).Once()
449 out.Reset()
450 require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
451 assert.Contains(t, out.String(), "No differences")
452}
453
454func TestRunAppsSecretsDelete(t *testing.T) {
455 plane := appPlane(t)
456 plane.EXPECT().DeleteAppSecret(mock.Anything, "blog", dto.AppSecretDeleteRequest{Service: "web", Key: "password"}).Return(nil).Times(2)
457 var out bytes.Buffer
458 require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", false))
459 assert.Contains(t, out.String(), "password")
460
461 out.Reset()
462 require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", true))
463 var got map[string]string
464 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
465 assert.Equal(t, "password", got["deleted"])
466
467 err := runAppsSecretsDelete(context.Background(), plane, &bytes.Buffer{}, "blog", "password", "", false)
468 require.Error(t, err)
469 assert.Contains(t, err.Error(), "--service")
470}
471
472func TestRunAppRestart_AndLifecycle(t *testing.T) {
473 resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}
474 plane := appPlane(t)
475 plane.EXPECT().RestartApp(mock.Anything, "blog", "", false).Return(resp, "key-lc", nil).Times(2)
476 plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
477 plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
478 plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
479 var out bytes.Buffer
480 require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, false))
481 assert.Contains(t, out.String(), "success")
482
483 out.Reset()
484 require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, true))
485 var got dto.AppDeployResponse
486 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
487 assert.Equal(t, *resp, got)
488
489 for _, fn := range []appLifecycleFunc{plane.StopApp, plane.StartApp, plane.RemoveApp} {
490 out.Reset()
491 require.NoError(t, runAppLifecycle(context.Background(), fn, "stop", "blog", &out, false))
492 assert.Contains(t, out.String(), "op-2")
493 }
494
495 unknown := appPlane(t)
496 unknown.EXPECT().StopApp(mock.Anything, "blog").Return(
497 nil, "key-x", &remote.OutcomeUnknownError{Method: "POST", Path: "/x", Err: errors.New("boom")}).Once()
498 err := runAppLifecycle(context.Background(), unknown.StopApp, "stop", "blog", &bytes.Buffer{}, false)
499 require.Error(t, err)
500 assert.Contains(t, err.Error(), "key-x")
501}
502
503func TestRunAppStatus_Observed(t *testing.T) {
504 show := &dto.AppShowResponse{
505 App: "blog",
506 Active: dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
507 "web": {EffectiveRevision: "rev-b", Container: "ctr-b"},
508 }},
509 }
510 plane := appPlane(t)
511 plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(2)
512 var out bytes.Buffer
513 require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, false))
514 assert.Contains(t, out.String(), "ctr-b")
515
516 out.Reset()
517 require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, true))
518 var got dto.AppShowResponse
519 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
520 assert.Equal(t, *show, got)
521}
522
523// fakeAppLogReader scripts container log reads for app log tests.
524type fakeAppLogReader struct {
525 lines []string
526 stream []string
527}
528
529func (f *fakeAppLogReader) GetContainerLogs(_ context.Context, _ string, _ int) ([]string, error) {
530 return f.lines, nil
531}
532
533func (f *fakeAppLogReader) StreamContainerLogs(_ context.Context, _ string, _ int) (<-chan string, error) {
534 ch := make(chan string, len(f.stream))
535 for _, line := range f.stream {
536 ch <- line
537 }
538 close(ch)
539 return ch, nil
540}
541
542func TestRunAppLogs_TextJSONFollow(t *testing.T) {
543 show := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
544 "web": {Container: "ctr-web"},
545 }}}
546 plane := appPlane(t)
547 plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(3)
548 reader := &fakeAppLogReader{lines: []string{"l1", "l2"}, stream: []string{"s1"}}
549
550 var out bytes.Buffer
551 require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "", false, 50, &out, false))
552 assert.Equal(t, "l1\nl2\n", out.String())
553
554 out.Reset()
555 require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", false, 50, &out, true))
556 var got map[string]any
557 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
558 assert.Equal(t, "blog", got["app"])
559
560 out.Reset()
561 require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))
562 assert.Equal(t, "s1\n", out.String())
563}
564
565func TestRunAppsSecretsList_RendersMetadataAndWrapsErrors(t *testing.T) {
566 plane := appPlane(t)
567 plane.EXPECT().ListAppSecrets(mock.Anything, "blog", "web").Return([]dto.AppSecretMetadataDTO{
568 {Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
569 }, nil).Once()
570 var out bytes.Buffer
571 require.NoError(t, runAppsSecretsList(context.Background(), plane, &out, "blog", "web", false))
572 assert.Contains(t, out.String(), "web/DATABASE_URL")
573 assert.Contains(t, out.String(), "desired")
574
575 errPlane := appPlane(t)
576 errPlane.EXPECT().ListAppSecrets(mock.Anything, "blog", "").Return(nil, errors.New("boom")).Once()
577 err := runAppsSecretsList(context.Background(), errPlane, &bytes.Buffer{}, "blog", "", false)
578 require.Error(t, err)
579 assert.Contains(t, err.Error(), "list app secrets for blog")
580 assert.Contains(t, err.Error(), "boom")
581}