v3.4.1 de8f68e

apps_test.go RawHistoryFolder

1package remote
2
3// Tests for the v3 app mutation transport (apps.go): every mutation
4// carries a client-generated Idempotency-Key, retryable gateway statuses
5// surface OutcomeUnknownError without replaying the mutation, and
6// ambiguous outcomes recover via the by-key endpoint.
7
8import (
9	"context"
10	"encoding/json"
11	"net/http"
12	"net/http/httptest"
13	"sync/atomic"
14	"testing"
15
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/require"
18
19	"github.com/bnema/gordon/internal/adapters/dto"
20)
21
22func TestClientApplyApp_SendsIdempotencyKey(t *testing.T) {
23	var gotKey string
24	var gotBody dto.AppApplyRequest
25	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26		require.Equal(t, "/admin/apps/apply", r.URL.Path)
27		require.Equal(t, http.MethodPost, r.Method)
28		gotKey = r.Header.Get("Idempotency-Key")
29		require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
30		w.Header().Set("Content-Type", "application/json")
31		_ = json.NewEncoder(w).Encode(dto.AppApplyResponse{
32			App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1",
33		})
34	}))
35	defer srv.Close()
36
37	resp, err := NewClient(srv.URL).ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "x"})
38	require.NoError(t, err)
39	assert.Equal(t, "blog", resp.App)
40	assert.NotEmpty(t, gotKey, "mutations must carry an Idempotency-Key")
41	assert.Equal(t, "x", gotBody.ManifestTOML)
42}
43
44func TestClientDeployApp_GatewayErrorIsOutcomeUnknownWithoutReplay(t *testing.T) {
45	var mutations int32
46	var gotKey string
47	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48		require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
49		atomic.AddInt32(&mutations, 1)
50		gotKey = r.Header.Get("Idempotency-Key")
51		w.WriteHeader(http.StatusServiceUnavailable)
52		_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
53	}))
54	defer srv.Close()
55
56	resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
57	require.Nil(t, resp)
58	require.Error(t, err)
59	var unknown *OutcomeUnknownError
60	require.ErrorAs(t, err, &unknown)
61	assert.EqualValues(t, 1, atomic.LoadInt32(&mutations), "mutations must never replay on ambiguity")
62	assert.NotEmpty(t, key)
63	assert.Equal(t, gotKey, key, "recovery re-queries with the SAME key")
64}
65
66func TestClientOperationByKey_RecoveryPath(t *testing.T) {
67	var gotPath string
68	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69		gotPath = r.URL.EscapedPath()
70		require.Equal(t, http.MethodGet, r.Method)
71		w.Header().Set("Content-Type", "application/json")
72		_ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
73			Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success",
74		})
75	}))
76	defer srv.Close()
77
78	resp, err := NewClient(srv.URL).OperationByKey(context.Background(), "blog", "key-abc")
79	require.NoError(t, err)
80	assert.Equal(t, "op-1", resp.Op)
81	assert.Equal(t, "/admin/apps/blog/operations/by-key/key-abc", gotPath)
82}
83
84func TestClientLifecycle_MutationsCarryKeys(t *testing.T) {
85	var keys []string
86	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87		keys = append(keys, r.Header.Get("Idempotency-Key"))
88		w.Header().Set("Content-Type", "application/json")
89		_ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
90	}))
91	defer srv.Close()
92
93	client := NewClient(srv.URL)
94	ctx := context.Background()
95	_, _, err := client.StopApp(ctx, "blog")
96	require.NoError(t, err)
97	_, _, err = client.StartApp(ctx, "blog")
98	require.NoError(t, err)
99	_, _, err = client.RemoveApp(ctx, "blog")
100	require.NoError(t, err)
101	require.Len(t, keys, 3)
102	for _, key := range keys {
103		assert.NotEmpty(t, key)
104	}
105	assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106}
107
108func TestClientRestartApp_SendsServiceScope(t *testing.T) {
109	var queries []string
110	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111		queries = append(queries, r.URL.RawQuery)
112		w.Header().Set("Content-Type", "application/json")
113		_ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
114	}))
115	defer srv.Close()
116
117	client := NewClient(srv.URL)
118	for _, tc := range []struct {
119		service string
120		all     bool
121	}{{"", false}, {"web", false}, {"", true}} {
122		_, _, err := client.RestartApp(context.Background(), "blog", tc.service, tc.all)
123		require.NoError(t, err)
124	}
125	assert.Equal(t, []string{"", "service=web", "all=true"}, queries)
126}
127
128// TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts
129// the 202 running response: it decodes the journal and returns the key so the
130// caller can poll operations/by-key.
131func TestClientDeployApp_AcceptedRunningJournal(t *testing.T) {
132	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133		require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
134		require.Equal(t, http.MethodPost, r.Method)
135		require.NotEmpty(t, r.Header.Get("Idempotency-Key"))
136		w.Header().Set("Content-Type", "application/json")
137		w.WriteHeader(http.StatusAccepted)
138		_ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
139			Op: "op-run", App: "blog", Revision: "rev-b", Status: dto.AppStatusRunning,
140		})
141	}))
142	defer srv.Close()
143
144	resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
145	require.NoError(t, err)
146	require.NotNil(t, resp)
147	assert.Equal(t, "op-run", resp.Op)
148	assert.Equal(t, dto.AppStatusRunning, resp.Status)
149	assert.NotEmpty(t, key)
150}
151
152func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) {
153	journal := dto.AppDeployResponse{
154		Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
155		Services: map[string]dto.AppServiceResultDTO{
156			"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
157		},
158		Steps: []dto.AppStepDTO{{ID: "service.web.start", State: "failed", Error: "nope"}},
159	}
160	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161		require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
162		w.Header().Set("Content-Type", "application/json")
163		w.WriteHeader(http.StatusConflict)
164		_ = json.NewEncoder(w).Encode(journal)
165	}))
166	defer srv.Close()
167
168	resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
169	require.Error(t, err)
170	require.NotNil(t, resp, "conflict must preserve the decoded journal")
171	assert.Equal(t, "op-9", resp.Op)
172	assert.Equal(t, "failed", resp.Services["web"].Result)
173	assert.NotEmpty(t, key)
174	var conflict *AppOpConflictError
175	require.ErrorAs(t, err, &conflict)
176	assert.Equal(t, http.StatusConflict, conflict.StatusCode)
177	assert.Equal(t, "op-9", conflict.Response.Op)
178	assert.Len(t, conflict.Response.Steps, 1)
179}
180
181func TestClientStopApp_ConflictDecodesJournal(t *testing.T) {
182	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183		require.Equal(t, "/admin/apps/blog/stop", r.URL.Path)
184		w.Header().Set("Content-Type", "application/json")
185		w.WriteHeader(http.StatusConflict)
186		_ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
187			Op: "op-7", App: "blog", Outcome: "partial",
188			Services: map[string]dto.AppServiceResultDTO{
189				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
190			},
191		})
192	}))
193	defer srv.Close()
194
195	resp, key, err := NewClient(srv.URL).StopApp(context.Background(), "blog")
196	require.Error(t, err)
197	require.NotNil(t, resp)
198	assert.Equal(t, "partial", resp.Outcome)
199	assert.NotEmpty(t, key)
200	var conflict *AppOpConflictError
201	require.ErrorAs(t, err, &conflict)
202}
203
204func TestClientDeployApp_ConflictWithoutJournalFallsBackToHTTPError(t *testing.T) {
205	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206		w.WriteHeader(http.StatusConflict)
207		_, _ = w.Write([]byte(`{"error":"state-conflict","message":"busy"}`))
208	}))
209	defer srv.Close()
210
211	resp, _, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
212	require.Error(t, err)
213	require.Nil(t, resp)
214	var httpErr *HTTPError
215	require.ErrorAs(t, err, &httpErr)
216	assert.Equal(t, http.StatusConflict, httpErr.StatusCode)
217}
218
219func TestClientListAppSecrets_ServiceFilterAndErrorContext(t *testing.T) {
220	var gotService string
221	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222		require.Equal(t, "/admin/apps/blog/secrets", r.URL.Path)
223		gotService = r.URL.Query().Get("service")
224		w.Header().Set("Content-Type", "application/json")
225		_ = json.NewEncoder(w).Encode([]dto.AppSecretMetadataDTO{
226			{Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
227		})
228	}))
229	defer srv.Close()
230
231	entries, err := NewClient(srv.URL).ListAppSecrets(context.Background(), "blog", "web")
232	require.NoError(t, err)
233	require.Len(t, entries, 1)
234	assert.Equal(t, "web", gotService)
235
236	errSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
237		w.WriteHeader(http.StatusInternalServerError)
238		_, _ = w.Write([]byte(`{"error":"internal"}`))
239	}))
240	defer errSrv.Close()
241
242	_, err = NewClient(errSrv.URL).ListAppSecrets(context.Background(), "blog", "")
243	require.Error(t, err)
244	assert.Contains(t, err.Error(), "list app secrets blog")
245}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close