1package cli
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/bnema/gordon/internal/adapters/dto"
10 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
11 "github.com/bnema/gordon/internal/domain"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15)
16
17type previewContainerService struct {
18 *inmocks.MockContainerService
19 preview func(context.Context, string) (*domain.CleanupReport, error)
20}
21
22func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
23 if s.preview == nil {
24 return nil, nil
25 }
26 return s.preview(ctx, routeDomain)
27}
28
29func TestLocalControlPlane_GetStatus(t *testing.T) {
30 t.Parallel()
31
32 configSvc := inmocks.NewMockConfigService(t)
33 containerSvc := inmocks.NewMockContainerService(t)
34
35 ctx := context.Background()
36 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}})
37 configSvc.EXPECT().GetRegistryDomain().Return("registry.local")
38 configSvc.EXPECT().GetRegistryPort().Return(5000)
39 configSvc.EXPECT().GetServerPort().Return(80)
40 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
41 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true)
42 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
43 "app.local": {ID: "abc123", Status: "running"},
44 })
45
46 cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
47 status, err := cp.GetStatus(ctx)
48 require.NoError(t, err)
49 require.Equal(t, 1, status.Routes)
50 require.Equal(t, "registry.local", status.RegistryDomain)
51 require.Equal(t, "running", status.ContainerStatus["app.local"])
52}
53
54func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) {
55 t.Parallel()
56
57 configSvc := inmocks.NewMockConfigService(t)
58 containerSvc := inmocks.NewMockContainerService(t)
59
60 ctx := context.Background()
61 route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"}
62
63 require.False(t, domain.IsInternalDeploy(ctx))
64
65 configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil)
66 containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) {
67 require.True(t, domain.IsInternalDeploy(deployCtx))
68 require.Equal(t, *route, deployedRoute)
69 return &domain.Container{ID: "container-1"}, nil
70 })
71
72 cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
73 result, err := cp.Deploy(ctx, "app.local")
74 require.NoError(t, err)
75 require.NotNil(t, result)
76 require.Equal(t, "deployed", result.Status)
77 require.Equal(t, "app.local", result.Domain)
78 require.Equal(t, "container-1", result.ContainerID)
79}
80
81func TestLocalControlPlane_Backups(t *testing.T) {
82 t.Parallel()
83
84 backupSvc := inmocks.NewMockBackupService(t)
85 ctx := context.Background()
86
87 now := time.Now().UTC()
88 jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}}
89 backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil)
90 backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil)
91 backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil)
92 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil)
93
94 cp := &localControlPlane{backupSvc: backupSvc}
95
96 list, err := cp.ListBackups(ctx, "app.local")
97 require.NoError(t, err)
98 require.Len(t, list, 1)
99
100 status, err := cp.BackupStatus(ctx)
101 require.NoError(t, err)
102 require.Len(t, status, 1)
103
104 run, err := cp.RunBackup(ctx, "app.local", "postgres")
105 require.NoError(t, err)
106 require.NotNil(t, run.Backup)
107 require.Equal(t, "b1", run.Backup.ID)
108
109 dbs, err := cp.DetectDatabases(ctx, "app.local")
110 require.NoError(t, err)
111 require.Len(t, dbs, 1)
112 require.Equal(t, "postgres", dbs[0].Name)
113}
114
115func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) {
116 t.Parallel()
117
118 volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
119 ctx := context.Background()
120 runErr := errors.New("one volume failed")
121 jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
122 volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr)
123
124 cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
125 result, err := cp.RunVolumeBackups(ctx, "app.local", "")
126
127 require.ErrorIs(t, err, runErr)
128 require.NotNil(t, result)
129 assert.Equal(t, "partial", result.Status)
130 assert.Equal(t, runErr.Error(), result.Error)
131 require.Len(t, result.Backups, 1)
132 assert.Equal(t, "v1", result.Backups[0].ID)
133}
134
135func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) {
136 t.Parallel()
137
138 cp := &localControlPlane{}
139 result, err := cp.RunVolumeBackups(context.Background(), "app.local", "")
140
141 require.Error(t, err)
142 assert.Nil(t, result)
143 assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable)
144}
145
146func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) {
147 t.Parallel()
148
149 volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
150 wantErr := errors.New("store unavailable")
151 volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr)
152
153 cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
154 jobs, err := cp.ListVolumeBackups(context.Background(), "app.local")
155
156 require.ErrorIs(t, err, wantErr)
157 assert.Contains(t, err.Error(), "list volume backups")
158 assert.Nil(t, jobs)
159}
160
161func TestLocalControlPlane_ListTags(t *testing.T) {
162 t.Parallel()
163
164 registrySvc := inmocks.NewMockRegistryService(t)
165 registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil)
166
167 cp := &localControlPlane{registrySvc: registrySvc}
168 tags, err := cp.ListTags(context.Background(), "repo/app")
169 require.NoError(t, err)
170 require.Equal(t, []string{"v1.0.0", "latest"}, tags)
171}
172
173func TestLocalControlPlane_RemoveRoutePersistsConfigThenReconcilesRuntime(t *testing.T) {
174 t.Parallel()
175
176 configSvc := inmocks.NewMockConfigService(t)
177 containerSvc := inmocks.NewMockContainerService(t)
178 report := &domain.CleanupReport{
179 Domain: "app.local",
180 RemovedContainers: []domain.CleanupContainer{{
181 ID: "container-1",
182 Name: "gordon-app.local",
183 }},
184 }
185
186 removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(nil).Once()
187 cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once()
188 mock.InOrder(removeCall, cleanupCall)
189
190 cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
191 err := cp.RemoveRoute(context.Background(), "app.local")
192 require.NoError(t, err)
193}
194
195func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
196 t.Parallel()
197
198 containerSvc := inmocks.NewMockContainerService(t)
199 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
200 containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil)
201
202 cp := &localControlPlane{containerSvc: containerSvc}
203 result, err := cp.Restart(context.Background(), "app.local", true)
204 require.NoError(t, err)
205 require.Equal(t, "app.local", result.Domain)
206}
207
208func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) {
209 t.Parallel()
210
211 containerSvc := inmocks.NewMockContainerService(t)
212 syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
213 listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{
214 Domain: "app.local",
215 Image: "repo/app:latest",
216 ContainerID: "container-1",
217 ContainerStatus: "running",
218 }}).Once()
219 mock.InOrder(syncCall, listCall)
220
221 cp := &localControlPlane{containerSvc: containerSvc}
222 routes, err := cp.ListRoutesWithDetails(context.Background())
223 require.NoError(t, err)
224 require.Len(t, routes, 1)
225 require.Equal(t, "app.local", routes[0].Domain)
226 require.Equal(t, "repo/app:latest", routes[0].Image)
227 require.Equal(t, "container-1", routes[0].ContainerID)
228 require.Equal(t, "running", routes[0].ContainerStatus)
229}
230
231func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) {
232 t.Parallel()
233
234 configSvc := inmocks.NewMockConfigService(t)
235 containerSvc := inmocks.NewMockContainerService(t)
236
237 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
238 Domain: "app.local",
239 Image: "repo/app:latest",
240 }}).Once()
241 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
242 containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
243
244 cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
245 routes, err := cp.ListRoutesWithDetails(context.Background())
246 require.NoError(t, err)
247 require.Len(t, routes, 1)
248 require.Equal(t, "app.local", routes[0].Domain)
249 require.Equal(t, "repo/app:latest", routes[0].Image)
250 require.Empty(t, routes[0].ContainerID)
251 require.Empty(t, routes[0].ContainerStatus)
252}
253
254func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {
255 t.Parallel()
256
257 cp := &localControlPlane{}
258 status, err := cp.GetTLSStatus(context.Background())
259 require.NoError(t, err)
260 require.Equal(t, &dto.TLSStatusResponse{
261 ACMEEnabled: false,
262 SelectionReason: "public TLS service not configured",
263 }, status)
264}
265
266func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
267 t.Parallel()
268
269 logSvc := inmocks.NewMockLogService(t)
270 logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil)
271
272 cp := &localControlPlane{logSvc: logSvc}
273 lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50)
274 require.NoError(t, err)
275 require.Equal(t, []string{"line1", "line2"}, lines)
276}
277
278func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) {
279 ctx := context.Background()
280 volumeSvc := inmocks.NewMockVolumeService(t)
281 containerSvc := &previewContainerService{
282 MockContainerService: inmocks.NewMockContainerService(t),
283 preview: func(context.Context, string) (*domain.CleanupReport, error) {
284 return nil, nil
285 },
286 }
287 cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc}
288
289 report, err := cp.GetRouteCleanupPreview(ctx, "app.local")
290 require.NoError(t, err)
291 assert.Nil(t, report)
292}
293
294func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {
295 ctx := context.Background()
296 configSvc := inmocks.NewMockConfigService(t)
297 containerSvc := inmocks.NewMockContainerService(t)
298 report := &domain.CleanupReport{Domain: "app.local"}
299
300 configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(domain.ErrRouteNotFound).Once()
301 containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once()
302
303 cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
304 resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local")
305 require.NoError(t, err)
306 require.NotNil(t, resp)
307 assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup)
308}