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