v3.3.1 31f1eec

apps_local_test.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"encoding/json"
6	"net/http"
7	"net/http/httptest"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/gordon/internal/adapters/dto"
15	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
16	"github.com/bnema/gordon/internal/adapters/localadmin"
17)
18
19// appPlaneFixtureHandler serves the same app DTO payloads a daemon would,
20// independent of transport, so local and remote seams can be compared.
21func appPlaneFixtureHandler(t *testing.T) http.Handler {
22	t.Helper()
23	mux := http.NewServeMux()
24	mux.HandleFunc("/admin/apps", func(w http.ResponseWriter, _ *http.Request) {
25		writeFixtureJSON(t, w, map[string]any{
26			"apps": []dto.AppSummaryDTO{{App: "blog", Desired: "rev-2", Active: "rev-1", Converged: false}},
27		})
28	})
29	mux.HandleFunc("/admin/apps/blog", func(w http.ResponseWriter, _ *http.Request) {
30		writeFixtureJSON(t, w, dto.AppShowResponse{
31			App:     "blog",
32			Desired: dto.AppDesiredDTO{Revision: "rev-2", Status: "pending"},
33			Active: dto.AppActiveDTO{
34				Converged: true,
35				Services: map[string]dto.AppActiveServiceDTO{
36					"web": {EffectiveRevision: "rev-1", Container: "ctr-web"},
37				},
38			},
39		})
40	})
41	mux.HandleFunc("/admin/apps/blog/diff", func(w http.ResponseWriter, _ *http.Request) {
42		writeFixtureJSON(t, w, dto.AppDiffResponse{App: "blog"})
43	})
44	mux.HandleFunc("/admin/apps/apply", func(w http.ResponseWriter, r *http.Request) {
45		if r.Method != http.MethodPost {
46			w.WriteHeader(http.StatusMethodNotAllowed)
47			return
48		}
49		writeFixtureJSON(t, w, dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-3"})
50	})
51	return mux
52}
53
54func writeFixtureJSON(t *testing.T, w http.ResponseWriter, payload any) {
55	t.Helper()
56	w.Header().Set("Content-Type", "application/json")
57	require.NoError(t, json.NewEncoder(w).Encode(payload))
58}
59
60// startUnixAppPlane serves the fixture over an owner-only Unix socket.
61func startUnixAppPlane(t *testing.T) string {
62	t.Helper()
63	dir := t.TempDir()
64	ln, _, err := localadmin.Listen(dir)
65	require.NoError(t, err)
66
67	srv := &http.Server{Handler: appPlaneFixtureHandler(t), ReadHeaderTimeout: 5 * time.Second}
68	go func() { _ = srv.Serve(ln) }()
69	t.Cleanup(func() { _ = srv.Close() })
70
71	return localadmin.SocketPath(dir)
72}
73
74// withRemoteTarget pins the CLI remote flags and clears the environment.
75func withRemoteTarget(t *testing.T, target string) {
76	t.Helper()
77	origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
78	t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
79	remoteFlag, tokenFlag, insecureTLSFlag = target, "", false
80
81	t.Setenv("GORDON_REMOTE", "")
82	t.Setenv("GORDON_TOKEN", "")
83	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
84}
85
86func stubLocalAppClient(t *testing.T, fn func() (*remote.Client, error)) {
87	t.Helper()
88	restore := newLocalAppClient
89	newLocalAppClient = fn
90	t.Cleanup(func() { newLocalAppClient = restore })
91}
92
93func TestResolveAppPlane_UsesLocalAdminSocket(t *testing.T) {
94	socketPath := startUnixAppPlane(t)
95	withRemoteTarget(t, "")
96	stubLocalAppClient(t, func() (*remote.Client, error) {
97		return remote.NewLocalClientForSocket(socketPath), nil
98	})
99
100	handle, err := resolveAppPlane()
101	require.NoError(t, err)
102	defer handle.close()
103	plane := handle.plane
104
105	apps, err := plane.ListApps(context.Background())
106	require.NoError(t, err)
107	require.Len(t, apps, 1)
108	assert.Equal(t, "blog", apps[0].App)
109
110	show, err := plane.ShowApp(context.Background(), "blog")
111	require.NoError(t, err)
112	assert.Equal(t, "rev-2", show.Desired.Revision)
113
114	diff, err := plane.DiffApp(context.Background(), "blog")
115	require.NoError(t, err)
116	assert.Equal(t, "blog", diff.App)
117
118	apply, err := plane.ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "name = \"blog\"\n"})
119	require.NoError(t, err)
120	assert.Equal(t, "rev-3", apply.ResultingRevision)
121}
122
123// TestResolveAppControlPlane_LocalAndRemoteDTOParity asserts the shared DTO
124// seam yields identical values over both transports.
125func TestControlPlane_LocalAndRemoteDTOParity(t *testing.T) {
126	fixture := appPlaneFixtureHandler(t)
127
128	tcp := httptest.NewServer(fixture)
129	t.Cleanup(tcp.Close)
130
131	dir := t.TempDir()
132	ln, _, err := localadmin.Listen(dir)
133	require.NoError(t, err)
134	unixSrv := &http.Server{Handler: fixture, ReadHeaderTimeout: 5 * time.Second}
135	go func() { _ = unixSrv.Serve(ln) }()
136	t.Cleanup(func() { _ = unixSrv.Close() })
137
138	localPlane := remote.NewLocalClientForSocket(localadmin.SocketPath(dir))
139	remotePlane := remote.NewClient(tcp.URL)
140
141	ctx := context.Background()
142
143	localApps, err := localPlane.ListApps(ctx)
144	require.NoError(t, err)
145	remoteApps, err := remotePlane.ListApps(ctx)
146	require.NoError(t, err)
147	assert.Equal(t, remoteApps, localApps)
148
149	localShow, err := localPlane.ShowApp(ctx, "blog")
150	require.NoError(t, err)
151	remoteShow, err := remotePlane.ShowApp(ctx, "blog")
152	require.NoError(t, err)
153	assert.Equal(t, remoteShow, localShow)
154
155	localDiff, err := localPlane.DiffApp(ctx, "blog")
156	require.NoError(t, err)
157	remoteDiff, err := remotePlane.DiffApp(ctx, "blog")
158	require.NoError(t, err)
159	assert.Equal(t, remoteDiff, localDiff)
160}
161
162// TestResolveAppControlPlane_ExplicitRemoteNeverProbesLocal pins that an
163// explicit remote is authoritative: the local socket is never consulted, and
164// a remote failure never falls back to it.
165func TestResolveAppPlane_ExplicitRemoteNeverProbesLocal(t *testing.T) {
166	t.Run("remote succeeds", func(t *testing.T) {
167		tcp := httptest.NewServer(appPlaneFixtureHandler(t))
168		t.Cleanup(tcp.Close)
169		withRemoteTarget(t, tcp.URL)
170
171		probed := false
172		stubLocalAppClient(t, func() (*remote.Client, error) {
173			probed = true
174			return nil, remote.ErrDaemonUnavailable
175		})
176
177		handle, err := resolveAppPlane()
178		require.NoError(t, err)
179		defer handle.close()
180		plane := handle.plane
181
182		apps, err := plane.ListApps(context.Background())
183		require.NoError(t, err)
184		require.Len(t, apps, 1)
185		assert.False(t, probed, "local socket must not be probed when a remote is selected")
186	})
187
188	t.Run("remote fails without local fallback", func(t *testing.T) {
189		tcp := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
190		url := tcp.URL
191		tcp.Close()
192
193		withRemoteTarget(t, url)
194
195		probed := false
196		stubLocalAppClient(t, func() (*remote.Client, error) {
197			probed = true
198			return remote.NewLocalClientForSocket("/nonexistent/admin.sock"), nil
199		})
200
201		handle, err := resolveAppPlane()
202		require.NoError(t, err, "client construction succeeds; the failure must surface on the request")
203		defer handle.close()
204		plane := handle.plane
205
206		_, err = plane.ListApps(context.Background())
207		require.Error(t, err)
208		assert.False(t, probed, "a failed remote must never fall back to the local socket")
209	})
210}
211
212func TestResolveAppPlane_ReportsDaemonUnavailable(t *testing.T) {
213	withRemoteTarget(t, "")
214	stubLocalAppClient(t, func() (*remote.Client, error) {
215		return nil, remote.ErrDaemonUnavailable
216	})
217
218	_, err := resolveAppPlane()
219	require.Error(t, err)
220	assert.ErrorContains(t, err, "daemon-unavailable")
221	assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
222}

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