1package cli
2
3// Tests for 202 deploy polling and `apps operations watch`: state-change
4// progress, machine-readable JSON, terminal rendering/exit, bounded
5// transient failures, fast 404, and Ctrl-C resume without server abort.
6
7import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "errors"
12 "io"
13 "net/http"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21
22 "github.com/bnema/gordon/internal/adapters/dto"
23 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
24)
25
26// shortenOperationPoll makes the polling cadence near-instant for tests.
27func shortenOperationPoll(t *testing.T) {
28 t.Helper()
29 prev := operationPollInterval
30 operationPollInterval = time.Millisecond
31 t.Cleanup(func() { operationPollInterval = prev })
32}
33
34func runningOp(op string, steps ...dto.AppStepDTO) *dto.AppDeployResponse {
35 return &dto.AppDeployResponse{Op: op, App: "blog", Status: dto.AppStatusRunning, Steps: steps}
36}
37
38func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) {
39 shortenOperationPoll(t)
40 plane := appPlane(t)
41 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
42 runningOp("op-1"), "key-1", nil).Once()
43 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
44 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
45 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
46 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
47
48 var out, errOut bytes.Buffer
49 require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
50 text := out.String()
51 assert.Contains(t, text, "op op-1:")
52 assert.Contains(t, text, "running")
53 assert.Contains(t, text, "success")
54}
55
56func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) {
57 shortenOperationPoll(t)
58 plane := appPlane(t)
59 step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
60 unchanged := runningOp("op-1", step)
61 advanced := runningOp("op-1",
62 dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
63 dto.AppStepDTO{ID: "service.web.health", State: "pending"})
64
65 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
66 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(unchanged, nil).Once()
67 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
68 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
69 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
70
71 var out, errOut bytes.Buffer
72 require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
73 assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
74 "initial and one advanced poll render progress; the unchanged poll is not reprinted")
75}
76
77func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) {
78 shortenOperationPoll(t)
79 plane := appPlane(t)
80 // .Once() on DeployApp proves the accepted path never re-POSTs; the
81 // by-key lookups are the only way the outcome is observed.
82 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
83 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
84 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
85
86 var out, errOut bytes.Buffer
87 require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
88}
89
90func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
91 shortenOperationPoll(t)
92 plane := appPlane(t)
93 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
94 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
95 Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
96 Services: map[string]dto.AppServiceResultDTO{
97 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
98 },
99 }, nil).Once()
100
101 var out, errOut bytes.Buffer
102 err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false)
103 require.Error(t, err, "a terminal partial outcome must exit nonzero")
104 assert.Contains(t, err.Error(), "partial")
105 assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
106}
107
108func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) {
109 shortenOperationPoll(t)
110 plane := appPlane(t)
111 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
112 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
113 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
114 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
115 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
116 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
117
118 var out, errOut bytes.Buffer
119 require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, true))
120
121 dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
122 var got dto.AppDeployResponse
123 require.NoError(t, dec.Decode(&got))
124 assert.Equal(t, "op-1", got.Op)
125 assert.Equal(t, "success", got.Outcome)
126 var extra json.RawMessage
127 assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
128 assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
129 assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130}
131
132func TestWatchOperation_CancelPrintsResumeWithoutAbort(t *testing.T) {
133 plane := appPlane(t)
134 ctx, cancel := context.WithCancel(context.Background())
135 cancel()
136
137 var out, errOut bytes.Buffer
138 err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, false)
139 require.Error(t, err)
140 assert.ErrorIs(t, err, context.Canceled)
141 text := out.String()
142 assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
143 assert.Contains(t, text, "not cancelled")
144 assert.NotContains(t, text, "cancel op")
145}
146
147func TestWatchOperation_CancelKeepsJSONStdoutClean(t *testing.T) {
148 plane := appPlane(t)
149 ctx, cancel := context.WithCancel(context.Background())
150 cancel()
151
152 var out, errOut bytes.Buffer
153 err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, true)
154 require.Error(t, err)
155 assert.Empty(t, out.String(), "JSON stdout stays empty on interruption")
156 assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
157}
158
159func TestRunAppsOperationsWatch_ResumesExisting(t *testing.T) {
160 shortenOperationPoll(t)
161 plane := appPlane(t)
162 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
163 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
164 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
165 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
166
167 var out, errOut bytes.Buffer
168 require.NoError(t, runAppsOperationsWatch(context.Background(), plane, &out, &errOut, "blog", "key-1", false))
169 assert.Contains(t, out.String(), "success")
170}
171
172func TestAwaitOperation_TransientErrorsAreBounded(t *testing.T) {
173 shortenOperationPoll(t)
174 plane := appPlane(t)
175 boom := errors.New("connection refused")
176 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(nil, boom).Times(operationPollMaxTransientErrors + 1)
177
178 var progress bytes.Buffer
179 _, err := awaitOperation(context.Background(), plane, "blog", "key-1", nil, &progress)
180 require.Error(t, err)
181 assert.Contains(t, err.Error(), "gave up watching")
182 assert.Contains(t, err.Error(), "gordon apps operations watch blog --key key-1")
183 assert.Contains(t, progress.String(), "transient polling error")
184}
185
186func TestAwaitOperation_NotFoundFailsFast(t *testing.T) {
187 plane := appPlane(t)
188 plane.EXPECT().OperationByKey(mock.Anything, "blog", "gone").Return(nil,
189 &remote.HTTPError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Body: "unknown operation"}).Once()
190
191 _, err := awaitOperation(context.Background(), plane, "blog", "gone", nil, &bytes.Buffer{})
192 require.Error(t, err)
193 assert.Contains(t, err.Error(), "no longer recorded")
194}
195
196func TestAppsOperationsWatchCmd_Flags(t *testing.T) {
197 cmd := newAppsOperationsWatchCmd()
198 assert.Equal(t, "watch APP", cmd.Use)
199 assert.NotNil(t, cmd.Flags().Lookup("key"))
200 assert.NotNil(t, cmd.Flags().Lookup("json"))
201
202 subs := map[string]bool{}
203 for _, sub := range newAppsOperationsCmd().Commands() {
204 subs[sub.Name()] = true
205 }
206 assert.True(t, subs["show"])
207 assert.True(t, subs["watch"])
208}