1package container
2
3import (
4 "context"
5 "sync/atomic"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
14 "github.com/bnema/gordon/internal/domain"
15)
16
17func TestSecretsChangedHandler_CanHandle(t *testing.T) {
18 containerSvc := inmocks.NewMockContainerService(t)
19 configSvc := inmocks.NewMockConfigService(t)
20
21 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond)
22 defer h.Stop()
23
24 assert.True(t, h.CanHandle(domain.EventSecretsChanged))
25 assert.False(t, h.CanHandle(domain.EventConfigReload))
26}
27
28func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) {
29 containerSvc := inmocks.NewMockContainerService(t)
30 configSvc := inmocks.NewMockConfigService(t)
31
32 debounceDelay := 200 * time.Millisecond
33 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
34 defer h.Stop()
35
36 route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
37 configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
38
39 var deployCalls atomic.Int32
40 containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) {
41 deployCalls.Add(1)
42 return &domain.Container{ID: "container-1"}, nil
43 }).Once()
44
45 event := domain.Event{
46 Type: domain.EventSecretsChanged,
47 Data: domain.SecretsChangedPayload{
48 Domain: route.Domain,
49 Operation: "set",
50 Keys: []string{"A", "B"},
51 },
52 }
53
54 for range 5 {
55 assert.NoError(t, h.Handle(context.Background(), event))
56 time.Sleep(10 * time.Millisecond)
57 }
58
59 require.Eventually(t, func() bool {
60 return deployCalls.Load() == 1
61 }, debounceDelay+time.Second, 10*time.Millisecond, "expected exactly 1 deploy call after debounce")
62}
63
64func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
65 containerSvc := inmocks.NewMockContainerService(t)
66 configSvc := inmocks.NewMockConfigService(t)
67
68 debounceDelay := 100 * time.Millisecond
69 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
70 defer h.Stop()
71
72 routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"}
73 routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"}
74
75 configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()
76 configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()
77
78 var deployCalls atomic.Int32
79 containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
80 deployCalls.Add(1)
81 return &domain.Container{ID: "container-1"}, nil
82 }).Once()
83 containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
84 deployCalls.Add(1)
85 return &domain.Container{ID: "container-2"}, nil
86 }).Once()
87
88 assert.NoError(t, h.Handle(context.Background(), domain.Event{
89 Type: domain.EventSecretsChanged,
90 Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}},
91 }))
92 assert.NoError(t, h.Handle(context.Background(), domain.Event{
93 Type: domain.EventSecretsChanged,
94 Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},
95 }))
96
97 require.Eventually(t, func() bool {
98 return deployCalls.Load() == 2
99 }, debounceDelay+time.Second, 10*time.Millisecond, "expected 2 independent deploy calls")
100}
101
102func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {
103 containerSvc := inmocks.NewMockContainerService(t)
104 configSvc := inmocks.NewMockConfigService(t)
105
106 debounceDelay := 100 * time.Millisecond
107 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
108 defer h.Stop()
109
110 configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once()
111
112 assert.NoError(t, h.Handle(context.Background(), domain.Event{
113 Type: domain.EventSecretsChanged,
114 Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}},
115 }))
116
117 time.Sleep(debounceDelay + 100*time.Millisecond)
118}
119
120func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) {
121 containerSvc := inmocks.NewMockContainerService(t)
122 configSvc := inmocks.NewMockConfigService(t)
123
124 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond)
125 defer h.Stop()
126
127 err := h.Handle(context.Background(), domain.Event{
128 Type: domain.EventSecretsChanged,
129 Data: "wrong-payload",
130 })
131
132 assert.NoError(t, err)
133}
134
135func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) {
136 containerSvc := inmocks.NewMockContainerService(t)
137 configSvc := inmocks.NewMockConfigService(t)
138
139 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
140 defer h.Stop()
141
142 const domainName = "app.example.com"
143
144 // White-box: directly set internal state to simulate a stale generation.
145 // This exercises the generation-based debounce logic that prevents
146 // already-fired timers from triggering duplicate deploys.
147 h.mu.Lock()
148 h.generations[domainName] = 2
149 h.timers[domainName] = time.NewTimer(time.Hour)
150 h.mu.Unlock()
151
152 staleDone := make(chan struct{})
153 go func() {
154 defer close(staleDone)
155 h.fireDeploy(context.Background(), domainName, 1)
156 }()
157
158 select {
159 case <-staleDone:
160 case <-time.After(2 * time.Second):
161 t.Fatal("stale fireDeploy did not return")
162 }
163
164 h.mu.Lock()
165 _, timerExists := h.timers[domainName]
166 gen := h.generations[domainName]
167 h.mu.Unlock()
168
169 assert.True(t, timerExists)
170 assert.Equal(t, uint64(2), gen)
171}
172
173func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) {
174 containerSvc := inmocks.NewMockContainerService(t)
175 configSvc := inmocks.NewMockConfigService(t)
176
177 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
178 defer h.Stop()
179
180 route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
181 configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
182 containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once()
183
184 h.mu.Lock()
185 h.generations[route.Domain] = 1
186 h.timers[route.Domain] = time.NewTimer(time.Hour)
187 h.mu.Unlock()
188
189 h.fireDeploy(context.Background(), route.Domain, 1)
190
191 h.mu.Lock()
192 _, timerExists := h.timers[route.Domain]
193 _, generationExists := h.generations[route.Domain]
194 h.mu.Unlock()
195
196 assert.False(t, timerExists)
197 assert.False(t, generationExists)
198}
199
200func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) {
201 containerSvc := inmocks.NewMockContainerService(t)
202 configSvc := inmocks.NewMockConfigService(t)
203
204 debounceDelay := 50 * time.Millisecond
205 h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
206
207 assert.NoError(t, h.Handle(context.Background(), domain.Event{
208 Type: domain.EventSecretsChanged,
209 Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}},
210 }))
211
212 h.Stop()
213 time.Sleep(debounceDelay + 50*time.Millisecond)
214}