1package app
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/usecase/client"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17)
18
19func TestParseRemoteAttachTarget(t *testing.T) {
20 tests := []struct {
21 name, input, target, session string
22 ok bool
23 }{
24 {name: "local", input: "work"},
25 {name: "remote", input: "user@example.com", target: "user@example.com", ok: true},
26 {name: "remote session", input: "user@example.com:work", target: "user@example.com", session: "work", ok: true},
27 {name: "empty session is ephemeral", input: "user@example.com:", target: "user@example.com", ok: true},
28 {name: "ipv6", input: "user@[2001:db8::1]:work", target: "user@[2001:db8::1]", session: "work", ok: true},
29 {name: "missing user", input: "@example.com"},
30 {name: "missing host", input: "user@"},
31 {name: "multiple user separators", input: "user@example@com"},
32 {name: "missing bracketed ipv6 host", input: "user@[]:work"},
33 {name: "unclosed ipv6", input: "user@[2001:db8::1:work"},
34 {name: "unexpected closing ipv6 bracket", input: "user@2001:db8::1]:work"},
35 {name: "unbracketed ipv6", input: "user@2001:db8::1:work"},
36 }
37 for _, tt := range tests {
38 t.Run(tt.name, func(t *testing.T) {
39 target, session, ok := parseRemoteAttachTarget(tt.input)
40 require.Equal(t, tt.target, target)
41 require.Equal(t, tt.session, session)
42 require.Equal(t, tt.ok, ok)
43 })
44 }
45}
46
47func TestRemoteHostDepsDefaultsStore(t *testing.T) {
48 stateDir := filepath.Join(t.TempDir(), "state")
49 deps := (remoteHostDeps{stateDir: func() string { return stateDir }}).withDefaults()
50 require.NotNil(t, deps.store)
51 require.Same(t, deps.store, deps.hostStore())
52 require.NoError(t, deps.store.AddPinned("arch"))
53 require.FileExists(t, filepath.Join(stateDir, "hosts.json"))
54}
55
56func TestDecodeSessionListErrorReply(t *testing.T) {
57 t.Run("valid", func(t *testing.T) {
58 _, err := decodeSessionListReply(ports.Frame{
59 Type: ports.MsgError,
60 Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Text: "denied"}),
61 })
62 require.EqualError(t, err, "vev: denied")
63 })
64
65 t.Run("malformed", func(t *testing.T) {
66 _, err := decodeSessionListReply(ports.Frame{Type: ports.MsgError, Payload: []byte{0}})
67 require.ErrorContains(t, err, "vev: decoding error reply")
68 })
69}
70
71func TestMergeRemoteHostsOrderAndSource(t *testing.T) {
72 got := mergeRemoteHosts([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"})
73 require.Equal(t, []domain.RemoteHost{
74 {Target: "zebra", Pinned: true},
75 {Target: "arch", Pinned: true, Learned: true},
76 {Target: "beta", Learned: true},
77 {Target: "mule", Learned: true},
78 }, got)
79}
80
81func TestRemoteHostCommands(t *testing.T) {
82 newDeps := func(store ports.RemoteHostStore, out *bytes.Buffer) remoteHostDeps {
83 return remoteHostDeps{
84 store: store,
85 stdout: out,
86 }
87 }
88
89 tests := []struct {
90 name string
91 run func(t *testing.T)
92 }{
93 {
94 name: "add pins in state store",
95 run: func(t *testing.T) {
96 store := portsmocks.NewMockRemoteHostStore(t)
97 store.EXPECT().AddPinned("mule").Return(nil).Once()
98 err := runHostCommand(context.Background(), command{hostAction: hostActionAdd, hostTarget: "mule"}, newDeps(store, nil))
99 require.NoError(t, err)
100 },
101 },
102 {
103 name: "remove known host uses one atomic store call",
104 run: func(t *testing.T) {
105 store := portsmocks.NewMockRemoteHostStore(t)
106 store.EXPECT().Remove("mule").Return(true, nil).Once()
107 err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
108 require.NoError(t, err)
109 },
110 },
111 {
112 name: "remove unknown host uses one atomic store call",
113 run: func(t *testing.T) {
114 store := portsmocks.NewMockRemoteHostStore(t)
115 store.EXPECT().Remove("mule").Return(false, nil).Once()
116 err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
117 require.ErrorContains(t, err, `unknown host "mule"`)
118 },
119 },
120 {
121 name: "remove atomic store error is surfaced",
122 run: func(t *testing.T) {
123 store := portsmocks.NewMockRemoteHostStore(t)
124 removeErr := errors.New("rename hosts")
125 store.EXPECT().Remove("mule").Return(false, removeErr).Once()
126 err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
127 require.ErrorIs(t, err, removeErr)
128 },
129 },
130 {
131 name: "list preserves source markers and order",
132 run: func(t *testing.T) {
133 store := portsmocks.NewMockRemoteHostStore(t)
134 store.EXPECT().Hosts().Return([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"}, nil).Once()
135 var out bytes.Buffer
136 err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
137 require.NoError(t, err)
138 got := out.String()
139 require.Contains(t, got, "pinned,learned")
140 require.True(t, strings.Index(got, "zebra") < strings.Index(got, "arch"))
141 require.True(t, strings.Index(got, "arch") < strings.Index(got, "beta"))
142 require.True(t, strings.Index(got, "beta") < strings.Index(got, "mule"))
143 },
144 },
145 {
146 name: "malformed store is surfaced",
147 run: func(t *testing.T) {
148 store := portsmocks.NewMockRemoteHostStore(t)
149 store.EXPECT().Hosts().Return(nil, nil, errors.New("malformed hosts file")).Once()
150 err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, nil))
151 require.ErrorContains(t, err, "malformed hosts file")
152 },
153 },
154 {
155 name: "list is active without config",
156 run: func(t *testing.T) {
157 store := portsmocks.NewMockRemoteHostStore(t)
158 store.EXPECT().Hosts().Return(nil, nil, nil).Once()
159 var out bytes.Buffer
160 err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
161 require.NoError(t, err)
162 },
163 },
164 }
165 for _, tt := range tests {
166 t.Run(tt.name, tt.run)
167 }
168}
169
170func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
171 store := portsmocks.NewMockRemoteHostStore(t)
172 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
173 catalog := portsmocks.NewMockRemoteCatalogClient(t)
174 catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once()
175 var out bytes.Buffer
176 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
177 store: store,
178 catalog: catalog,
179 stdout: &out,
180 })
181 require.NoError(t, err)
182 require.Contains(t, out.String(), "build@arch")
183}
184
185func TestListAllSessionsInvariants(t *testing.T) {
186 tests := []struct {
187 name string
188 run func(t *testing.T)
189 }{
190 {
191 name: "prints partial local results on error",
192 run: func(t *testing.T) {
193 localErr := errors.New("local release failed")
194 var out bytes.Buffer
195 err := listAllSessions(context.Background(), remoteHostDeps{
196 localList: func(context.Context) ([]ports.SessionInfo, error) {
197 return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, localErr
198 },
199 stdout: &out,
200 }, nil)
201 require.ErrorIs(t, err, localErr)
202 require.Contains(t, out.String(), "local")
203 },
204 },
205 {
206 name: "prints accumulated results and stops before next host on cancellation",
207 run: func(t *testing.T) {
208 ctx, cancel := context.WithCancel(context.Background())
209 defer cancel()
210 catalog := portsmocks.NewMockRemoteCatalogClient(t)
211 catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
212 cancel()
213 return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
214 }).Once()
215 var out bytes.Buffer
216 err := listAllSessions(ctx, remoteHostDeps{
217 catalog: catalog,
218 localList: func(context.Context) ([]ports.SessionInfo, error) {
219 return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
220 },
221 stdout: &out,
222 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
223 require.ErrorIs(t, err, context.Canceled)
224 require.Contains(t, out.String(), "local")
225 require.Contains(t, out.String(), "dev@arch")
226 require.NotContains(t, out.String(), "@mule")
227 },
228 },
229 {
230 name: "lists hosts sequentially in supplied order",
231 run: func(t *testing.T) {
232 catalog := portsmocks.NewMockRemoteCatalogClient(t)
233 var calls []string
234 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
235 calls = append(calls, target)
236 }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil).Once()
237 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
238 calls = append(calls, target)
239 }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "up"}}}, nil).Once()
240 var out bytes.Buffer
241 err := listAllSessions(context.Background(), remoteHostDeps{
242 catalog: catalog,
243 localList: func(context.Context) ([]ports.SessionInfo, error) {
244 return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
245 },
246 stdout: &out,
247 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
248 require.NoError(t, err)
249 require.Equal(t, []string{"arch", "mule"}, calls)
250 got := out.String()
251 require.Less(t, strings.Index(got, "local"), strings.Index(got, "dev@arch"))
252 require.Less(t, strings.Index(got, "dev@arch"), strings.Index(got, "ops@mule"))
253 },
254 },
255 }
256 for _, tt := range tests {
257 t.Run(tt.name, tt.run)
258 }
259}
260
261func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
262 tests := []struct {
263 name string
264 session ports.RemoteCatalogSession
265 wantState ports.SessionState
266 wantTabs uint16
267 }{
268 {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, wantTabs: 2},
269 {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: 2}, wantState: ports.SessionDown, wantTabs: 2},
270 {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271 {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
272 }
273 for _, tt := range tests {
274 t.Run(tt.name, func(t *testing.T) {
275 infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})
276 require.Len(t, infos, 1)
277 require.Equal(t, "dev@arch", infos[0].Name)
278 require.Equal(t, tt.wantState, infos[0].State)
279 require.Equal(t, tt.wantTabs, infos[0].Tabs)
280 })
281 }
282}
283
284func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
285 store := portsmocks.NewMockRemoteHostStore(t)
286 store.EXPECT().Remember("build@mule").Return(nil).Once()
287 factory := portsmocks.NewMockRemoteDialerFactory(t)
288 factory.EXPECT().DialerForRemote("build@mule", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
289
290 var learner ports.RemoteHostLearner
291 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
292 remoteDialerFactory: factory,
293 hostStore: store,
294 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
295 learner = deps.RemoteHostLearner
296 return nil
297 },
298 })
299 require.NoError(t, err)
300 require.NotNil(t, learner)
301 require.NoError(t, learner.RememberRemoteHost())
302}
303
304func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
305 store := portsmocks.NewMockRemoteHostStore(t)
306 store.EXPECT().Remember("arch").Return(nil).Once()
307 factory := portsmocks.NewMockRemoteDialerFactory(t)
308 factory.EXPECT().DialerForRemote("arch", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
309 var learner ports.RemoteHostLearner
310 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "arch", "", nil, runAttachDeps{
311 remoteDialerFactory: factory,
312 hostStore: store,
313 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
314 learner = deps.RemoteHostLearner
315 return nil
316 },
317 })
318 require.NoError(t, err)
319 require.NotNil(t, learner)
320 require.NoError(t, learner.RememberRemoteHost())
321}