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