1package cli
2
3// Tests for the `apps apply --deploy` chain over a 202 accepted deploy:
4// it must reuse the shared by-key watch (progress only on change, terminal
5// journal, Ctrl-C resume) and must never reissue the deploy POST. JSON mode
6// keeps stdout to exactly one final combined {apply,deploy} document while
7// progress and resume guidance go to stderr.
8
9import (
10 "bytes"
11 "context"
12 "encoding/json"
13 "io"
14 "strings"
15 "testing"
16
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"
20
21 "github.com/bnema/gordon/internal/adapters/dto"
22)
23
24func acceptedApply() *dto.AppApplyResponse {
25 return &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}
26}
27
28func TestRunAppsApply_DeployAcceptedPollsToSuccess(t *testing.T) {
29 shortenOperationPoll(t)
30 plane := appPlane(t)
31 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
32 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
33 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
34 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
35 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
36
37 var out, errOut bytes.Buffer
38 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
39 writeManifest(t, "[app]\nname = \"blog\"\n"), false, true, false))
40 text := out.String()
41 assert.Contains(t, text, "Applied blog (rev-b)", "the apply outcome stays visible")
42 assert.Contains(t, text, "op op-1:", "the accepted deploy reports progress through the watch path")
43 assert.Contains(t, text, "op-1 blog: success", "the terminal journal is rendered")
44}
45
46func TestRunAppsApply_DeployAcceptedNoSecondMutation(t *testing.T) {
47 shortenOperationPoll(t)
48 plane := appPlane(t)
49 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
50 // .Once() proves the accepted deploy is observed by key, never re-POSTed.
51 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
52 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
53 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
54
55 var out, errOut bytes.Buffer
56 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
57 writeManifest(t, "x"), false, true, false))
58}
59
60func TestRunAppsApply_DeployAcceptedUnchangedStateNotReprinted(t *testing.T) {
61 shortenOperationPoll(t)
62 plane := appPlane(t)
63 step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
64 advanced := runningOp("op-1",
65 dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
66 dto.AppStepDTO{ID: "service.web.health", State: "pending"})
67
68 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
69 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
70 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(runningOp("op-1", step), nil).Once()
71 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
72 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
73 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
74
75 var out, errOut bytes.Buffer
76 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
77 writeManifest(t, "x"), false, true, false))
78 assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
79 "initial and one advanced poll render progress; the unchanged poll is not reprinted")
80}
81
82func TestRunAppsApply_DeployAcceptedPartialFailsButKeepsApply(t *testing.T) {
83 shortenOperationPoll(t)
84 plane := appPlane(t)
85 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
86 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
87 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
88 Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
89 Services: map[string]dto.AppServiceResultDTO{
90 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
91 },
92 }, nil).Once()
93
94 var out, errOut bytes.Buffer
95 err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
96 writeManifest(t, "x"), false, true, false)
97 require.Error(t, err, "a terminal partial deploy must exit nonzero")
98 assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
99 assert.Contains(t, err.Error(), "partial")
100 assert.Contains(t, out.String(), "Applied blog (rev-b)", "the apply outcome stays visible")
101 assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
102}
103
104func TestRunAppsApply_DeployAcceptedJSONKeepsStdoutSingleCombinedDocument(t *testing.T) {
105 shortenOperationPoll(t)
106 plane := appPlane(t)
107 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
108 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
109 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
110 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
111 runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
112 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
113 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
114
115 var out, errOut bytes.Buffer
116 require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
117 writeManifest(t, "x"), false, true, true))
118
119 dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
120 var got appApplyDeployDocument
121 require.NoError(t, dec.Decode(&got))
122 assert.Equal(t, "apply-1", got.Apply.Intent)
123 assert.Equal(t, "op-1", got.Deploy.Op)
124 assert.Equal(t, "success", got.Deploy.Outcome)
125 var extra json.RawMessage
126 assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
127 assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
128 assert.NotContains(t, out.String(), dto.AppStatusRunning, "no initial running JSON is emitted")
129 assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130}
131
132func TestRunAppsApply_DeployAcceptedJSONFailureKeepsApplyDocument(t *testing.T) {
133 shortenOperationPoll(t)
134 plane := appPlane(t)
135 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
136 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
137 plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
138 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "failed", Outcome: "failed"}, nil).Once()
139
140 var out, errOut bytes.Buffer
141 err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
142 writeManifest(t, "x"), false, true, true)
143 require.Error(t, err)
144 assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
145
146 dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
147 var got appApplyDeployDocument
148 require.NoError(t, dec.Decode(&got))
149 assert.Equal(t, "rev-b", got.Apply.ResultingRevision, "the failed deploy must not hide the successful apply")
150 assert.Equal(t, "failed", got.Deploy.Outcome)
151 var extra json.RawMessage
152 assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
153}
154
155func TestRunAppsApply_DeployAcceptedCancelPrintsResume(t *testing.T) {
156 plane := appPlane(t)
157 ctx, cancel := context.WithCancel(context.Background())
158 cancel()
159 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
160 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
161
162 var out, errOut bytes.Buffer
163 err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
164 writeManifest(t, "x"), false, true, false)
165 require.Error(t, err)
166 assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
167 text := out.String()
168 assert.Contains(t, text, "not cancelled", "Ctrl-C must not imply a server-side abort")
169 assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
170}
171
172func TestRunAppsApply_DeployAcceptedCancelKeepsJSONStdoutClean(t *testing.T) {
173 plane := appPlane(t)
174 ctx, cancel := context.WithCancel(context.Background())
175 cancel()
176 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
177 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
178
179 var out, errOut bytes.Buffer
180 err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
181 writeManifest(t, "x"), false, true, true)
182 require.Error(t, err)
183 assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
184 assert.Empty(t, out.String(), "JSON stdout stays empty when interrupted before terminal")
185 assert.Contains(t, errOut.String(), "not cancelled")
186 assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
187}