1package container
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11
12 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16func testCtx() context.Context {
17 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
18}
19
20// ImagePushedHandler tests
21
22func TestImagePushedHandler_CanHandle(t *testing.T) {
23 containerSvc := inmocks.NewMockContainerService(t)
24 configSvc := inmocks.NewMockConfigService(t)
25
26 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
27
28 assert.True(t, handler.CanHandle(domain.EventImagePushed))
29 assert.False(t, handler.CanHandle(domain.EventConfigReload))
30 assert.False(t, handler.CanHandle(domain.EventManualReload))
31}
32
33func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
34 containerSvc := inmocks.NewMockContainerService(t)
35 configSvc := inmocks.NewMockConfigService(t)
36
37 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
38
39 // FindRoutesByImage returns matching routes directly
40 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
41 {Domain: "app1.example.com", Image: "myapp:latest"},
42 {Domain: "app2.example.com", Image: "myapp:latest"},
43 })
44
45 // Expect Deploy to be called for both matching routes
46 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
47 Domain: "app1.example.com",
48 Image: "myapp:latest",
49 }).Return(&domain.Container{ID: "container-1"}, nil)
50
51 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
52 Domain: "app2.example.com",
53 Image: "myapp:latest",
54 }).Return(&domain.Container{ID: "container-2"}, nil)
55
56 event := domain.Event{
57 ID: "event-123",
58 Type: domain.EventImagePushed,
59 ImageName: "myapp",
60 Tag: "latest",
61 }
62
63 err := handler.Handle(context.Background(), event)
64
65 assert.NoError(t, err)
66}
67
68func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
69 containerSvc := inmocks.NewMockContainerService(t)
70 configSvc := inmocks.NewMockConfigService(t)
71
72 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
73
74 // FindRoutesByImage returns no matching routes
75 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{})
76
77 // No Deploy calls expected
78
79 event := domain.Event{
80 ID: "event-123",
81 Type: domain.EventImagePushed,
82 ImageName: "myapp",
83 Tag: "latest",
84 }
85
86 err := handler.Handle(context.Background(), event)
87
88 assert.NoError(t, err)
89}
90
91func TestImagePushedHandler_Handle_EmptyImageName(t *testing.T) {
92 containerSvc := inmocks.NewMockContainerService(t)
93 configSvc := inmocks.NewMockConfigService(t)
94
95 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
96
97 event := domain.Event{
98 ID: "event-123",
99 Type: domain.EventImagePushed,
100 ImageName: "",
101 Tag: "latest",
102 }
103
104 err := handler.Handle(context.Background(), event)
105
106 assert.ErrorIs(t, err, domain.ErrInvalidImageFormat)
107}
108
109func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
110 containerSvc := inmocks.NewMockContainerService(t)
111 configSvc := inmocks.NewMockConfigService(t)
112
113 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
114
115 // Image name "myapp" with empty tag should become "myapp:latest"
116 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
117 {Domain: "app.example.com", Image: "myapp:latest"},
118 })
119
120 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
121 Domain: "app.example.com",
122 Image: "myapp:latest",
123 }).Return(&domain.Container{ID: "container-1"}, nil)
124
125 event := domain.Event{
126 ID: "event-123",
127 Type: domain.EventImagePushed,
128 ImageName: "myapp",
129 Tag: "", // Empty tag should default to "latest"
130 }
131
132 err := handler.Handle(context.Background(), event)
133
134 assert.NoError(t, err)
135}
136
137func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
138 containerSvc := inmocks.NewMockContainerService(t)
139 configSvc := inmocks.NewMockConfigService(t)
140
141 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
142
143 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
144 {Domain: "app.example.com", Image: "myapp:latest"},
145 })
146
147 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
148
149 event := domain.Event{
150 ID: "event-123",
151 Type: domain.EventImagePushed,
152 ImageName: "myapp",
153 Tag: "latest",
154 }
155
156 // Handler logs error but doesn't fail
157 err := handler.Handle(context.Background(), event)
158
159 assert.NoError(t, err)
160}
161
162func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
163 containerSvc := inmocks.NewMockContainerService(t)
164 configSvc := inmocks.NewMockConfigService(t)
165
166 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
167
168 // FindRoutesByImage handles registry domain stripping internally
169 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "docker.io/library/nginx:latest").Return([]domain.Route{
170 {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
171 })
172
173 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
174 Domain: "app.example.com",
175 Image: "registry.example.com/docker.io/library/nginx:latest",
176 }).Return(&domain.Container{ID: "container-1"}, nil)
177
178 event := domain.Event{
179 ID: "event-123",
180 Type: domain.EventImagePushed,
181 ImageName: "docker.io/library/nginx",
182 Tag: "latest",
183 }
184
185 err := handler.Handle(context.Background(), event)
186
187 assert.NoError(t, err)
188}
189
190// ConfigReloadHandler tests
191
192func TestConfigReloadHandler_CanHandle(t *testing.T) {
193 containerSvc := inmocks.NewMockContainerService(t)
194 configSvc := inmocks.NewMockConfigService(t)
195
196 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
197
198 assert.True(t, handler.CanHandle(domain.EventConfigReload))
199 assert.False(t, handler.CanHandle(domain.EventImagePushed))
200 assert.False(t, handler.CanHandle(domain.EventManualReload))
201}
202
203func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
204 containerSvc := inmocks.NewMockContainerService(t)
205 configSvc := inmocks.NewMockConfigService(t)
206
207 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
208
209 // SyncContainers is called first
210 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
211
212 // Attachment config is propagated after sync
213 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
214 containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
215
216 // No existing containers
217 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
218
219 // New route in config
220 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
221 {Domain: "newapp.example.com", Image: "newapp:latest"},
222 })
223
224 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
225 Domain: "newapp.example.com",
226 Image: "newapp:latest",
227 }).Return(&domain.Container{ID: "container-1"}, nil)
228
229 event := domain.Event{
230 ID: "event-123",
231 Type: domain.EventConfigReload,
232 }
233
234 err := handler.Handle(context.Background(), event)
235
236 assert.NoError(t, err)
237}
238
239func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
240 containerSvc := inmocks.NewMockContainerService(t)
241 configSvc := inmocks.NewMockConfigService(t)
242
243 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
244
245 // SyncContainers is called first
246 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
247
248 // Attachment config is propagated after sync
249 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
250 containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
251
252 // Existing container for a route that's no longer configured
253 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
254 "removed.example.com": {
255 ID: "container-old",
256 Labels: map[string]string{
257 "gordon.route": "removed.example.com",
258 "gordon.image": "oldapp:latest",
259 },
260 },
261 })
262
263 // Empty routes - the route was removed
264 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
265
266 containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil)
267 containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
268
269 event := domain.Event{
270 ID: "event-123",
271 Type: domain.EventConfigReload,
272 }
273
274 err := handler.Handle(context.Background(), event)
275
276 assert.NoError(t, err)
277}
278
279func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
280 containerSvc := inmocks.NewMockContainerService(t)
281 configSvc := inmocks.NewMockConfigService(t)
282
283 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
284
285 // SyncContainers is called first
286 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
287
288 // Attachment config is propagated after sync
289 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
290 containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
291
292 // Existing container with old image
293 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
294 "app.example.com": {
295 ID: "container-1",
296 Labels: map[string]string{
297 "gordon.route": "app.example.com",
298 "gordon.image": "myapp:v1",
299 },
300 },
301 })
302
303 // Config now has different image
304 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
305 {Domain: "app.example.com", Image: "myapp:v2"},
306 })
307
308 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
309 Domain: "app.example.com",
310 Image: "myapp:v2",
311 }).Return(&domain.Container{ID: "container-2"}, nil)
312
313 event := domain.Event{
314 ID: "event-123",
315 Type: domain.EventConfigReload,
316 }
317
318 err := handler.Handle(context.Background(), event)
319
320 assert.NoError(t, err)
321}
322
323func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
324 containerSvc := inmocks.NewMockContainerService(t)
325 configSvc := inmocks.NewMockConfigService(t)
326
327 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
328
329 // SyncContainers is called first
330 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
331
332 // Attachment config is propagated after sync
333 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
334 containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
335
336 // Existing container matches config
337 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
338 "app.example.com": {
339 ID: "container-1",
340 Labels: map[string]string{
341 "gordon.route": "app.example.com",
342 "gordon.image": "myapp:latest",
343 },
344 },
345 })
346
347 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
348 {Domain: "app.example.com", Image: "myapp:latest"},
349 })
350
351 // No Deploy, Stop, or Remove calls expected
352
353 event := domain.Event{
354 ID: "event-123",
355 Type: domain.EventConfigReload,
356 }
357
358 err := handler.Handle(context.Background(), event)
359
360 assert.NoError(t, err)
361}
362
363// ManualReloadHandler tests
364
365func TestManualReloadHandler_CanHandle(t *testing.T) {
366 containerSvc := inmocks.NewMockContainerService(t)
367 configSvc := inmocks.NewMockConfigService(t)
368
369 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
370
371 assert.True(t, handler.CanHandle(domain.EventManualReload))
372 assert.False(t, handler.CanHandle(domain.EventImagePushed))
373 assert.False(t, handler.CanHandle(domain.EventConfigReload))
374}
375
376func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
377 containerSvc := inmocks.NewMockContainerService(t)
378 configSvc := inmocks.NewMockConfigService(t)
379
380 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
381
382 // SyncContainers is called first
383 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
384
385 // Routes in config
386 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
387 {Domain: "app1.example.com", Image: "app1:latest"},
388 {Domain: "app2.example.com", Image: "app2:latest"},
389 })
390
391 // app1 has a running container, app2 does not
392 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
393 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
394
395 // Only app2 should be deployed (app1 is already running - never restart healthy containers)
396 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
397 Domain: "app2.example.com",
398 Image: "app2:latest",
399 }).Return(&domain.Container{ID: "container-2"}, nil)
400
401 event := domain.Event{
402 ID: "event-123",
403 Type: domain.EventManualReload,
404 }
405
406 err := handler.Handle(context.Background(), event)
407
408 assert.NoError(t, err)
409}
410
411func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
412 containerSvc := inmocks.NewMockContainerService(t)
413 configSvc := inmocks.NewMockConfigService(t)
414
415 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
416
417 // SyncContainers is called first
418 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
419
420 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
421 {Domain: "app.example.com", Image: "app:latest"},
422 })
423
424 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
425
426 // Deploy should be called to start the missing container
427 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
428 Domain: "app.example.com",
429 Image: "app:latest",
430 }).Return(&domain.Container{ID: "container-1"}, nil)
431
432 event := domain.Event{
433 ID: "event-123",
434 Type: domain.EventManualReload,
435 }
436
437 err := handler.Handle(context.Background(), event)
438
439 assert.NoError(t, err)
440}
441
442func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
443 containerSvc := inmocks.NewMockContainerService(t)
444 configSvc := inmocks.NewMockConfigService(t)
445
446 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
447
448 // SyncContainers is called first
449 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
450
451 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
452 {Domain: "app1.example.com", Image: "app1:latest"},
453 {Domain: "app2.example.com", Image: "app2:latest"},
454 })
455
456 // Both containers are missing
457 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
458 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
459
460 // app1 fails to deploy, app2 succeeds
461 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
462 Domain: "app1.example.com",
463 Image: "app1:latest",
464 }).Return(nil, errors.New("deploy failed"))
465
466 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
467 Domain: "app2.example.com",
468 Image: "app2:latest",
469 }).Return(&domain.Container{ID: "container-2"}, nil)
470
471 event := domain.Event{
472 ID: "event-123",
473 Type: domain.EventManualReload,
474 }
475
476 err := handler.Handle(context.Background(), event)
477
478 // Should return error indicating some failures
479 assert.Error(t, err)
480 assert.Contains(t, err.Error(), "1 errors")
481}
482
483func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
484 containerSvc := inmocks.NewMockContainerService(t)
485 configSvc := inmocks.NewMockConfigService(t)
486
487 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
488
489 // SyncContainers is called first
490 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
491
492 // Route with running container
493 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
494 {Domain: "app.example.com", Image: "app:latest"},
495 })
496
497 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
498
499 // NO Deploy call expected - container is already running, never restart healthy containers
500
501 event := domain.Event{
502 ID: "event-123",
503 Type: domain.EventManualReload,
504 }
505
506 err := handler.Handle(context.Background(), event)
507
508 assert.NoError(t, err)
509}
510
511// ManualDeployHandler tests
512
513func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) {
514 containerSvc := inmocks.NewMockContainerService(t)
515 configSvc := inmocks.NewMockConfigService(t)
516
517 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
518
519 // SyncContainers is called first
520 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
521
522 // No existing containers
523 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
524
525 // New attachments from config
526 newAttachments := map[string][]string{
527 "app.example.com": {"postgres:15", "redis:7"},
528 }
529 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(newAttachments)
530
531 // Expect UpdateAttachments to be called with new attachment config
532 containerSvc.EXPECT().UpdateAttachments(newAttachments).Return()
533
534 // No routes configured
535 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
536
537 event := domain.Event{
538 ID: "event-123",
539 Type: domain.EventConfigReload,
540 }
541
542 err := handler.Handle(context.Background(), event)
543
544 assert.NoError(t, err)
545}
546
547func TestManualDeployHandler_CanHandle(t *testing.T) {
548 containerSvc := inmocks.NewMockContainerService(t)
549 configSvc := inmocks.NewMockConfigService(t)
550
551 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
552
553 assert.True(t, handler.CanHandle(domain.EventManualDeploy))
554 assert.False(t, handler.CanHandle(domain.EventImagePushed))
555 assert.False(t, handler.CanHandle(domain.EventManualReload))
556}
557
558func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
559 containerSvc := inmocks.NewMockContainerService(t)
560 configSvc := inmocks.NewMockConfigService(t)
561
562 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
563
564 // Configure routes
565 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
566 {Domain: "app1.example.com", Image: "app1:latest"},
567 {Domain: "app2.example.com", Image: "app2:latest"},
568 })
569
570 // Only the requested route should be deployed
571 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
572 Domain: "app1.example.com",
573 Image: "app1:latest",
574 }).Return(&domain.Container{ID: "container-1"}, nil)
575
576 event := domain.Event{
577 ID: "event-123",
578 Type: domain.EventManualDeploy,
579 Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
580 }
581
582 err := handler.Handle(context.Background(), event)
583
584 assert.NoError(t, err)
585}
586
587func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
588 containerSvc := inmocks.NewMockContainerService(t)
589 configSvc := inmocks.NewMockConfigService(t)
590
591 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
592
593 // No matching routes
594 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
595 {Domain: "other.example.com", Image: "other:latest"},
596 })
597
598 event := domain.Event{
599 ID: "event-123",
600 Type: domain.EventManualDeploy,
601 Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
602 }
603
604 err := handler.Handle(context.Background(), event)
605
606 assert.Error(t, err)
607 assert.Contains(t, err.Error(), "route not found")
608}
609
610func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
611 containerSvc := inmocks.NewMockContainerService(t)
612 configSvc := inmocks.NewMockConfigService(t)
613
614 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
615
616 // Event with nil payload
617 event := domain.Event{
618 ID: "event-123",
619 Type: domain.EventManualDeploy,
620 Data: nil,
621 }
622
623 err := handler.Handle(context.Background(), event)
624
625 assert.Error(t, err)
626 assert.Contains(t, err.Error(), "invalid manual deploy payload")
627}
628
629func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
630 containerSvc := inmocks.NewMockContainerService(t)
631 configSvc := inmocks.NewMockConfigService(t)
632
633 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
634
635 // Event with empty domain
636 event := domain.Event{
637 ID: "event-123",
638 Type: domain.EventManualDeploy,
639 Data: &domain.ManualDeployPayload{Domain: ""},
640 }
641
642 err := handler.Handle(context.Background(), event)
643
644 assert.Error(t, err)
645 assert.Contains(t, err.Error(), "invalid manual deploy payload")
646}
647
648func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
649 containerSvc := inmocks.NewMockContainerService(t)
650 configSvc := inmocks.NewMockConfigService(t)
651
652 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
653
654 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
655 {Domain: "app.example.com", Image: "app:latest"},
656 })
657
658 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
659
660 event := domain.Event{
661 ID: "event-123",
662 Type: domain.EventManualDeploy,
663 Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
664 }
665
666 err := handler.Handle(context.Background(), event)
667
668 assert.Error(t, err)
669 assert.Contains(t, err.Error(), "failed to deploy")
670}