1package snapshot
2
3import (
4 "context"
5 "errors"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"
13 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17)
18
19func newWindowStateProvider(
20 t *testing.T,
21 sessionID entity.SessionID,
22 windows []entity.WindowTabListState,
23 activeWindowIndex int,
24) *mocks.MockWindowStateProvider {
25 provider := mocks.NewMockWindowStateProvider(t)
26 provider.EXPECT().GetSessionID().Return(sessionID).Once()
27 provider.EXPECT().GetWindowSnapshotState().Return(windows, activeWindowIndex).Once()
28 return provider
29}
30
31func TestService_DrainSettlesAfterDebounceAndSave(t *testing.T) {
32 repo := repomocks.NewMockSessionStateRepository(t)
33 repo.EXPECT().
34 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35 Return(nil).Once()
36 uc := usecase.NewSnapshotSessionUseCase(repo)
37 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_drain", nil, 0), 60000)
38 svc.ready = true
39 require.False(t, svc.Active())
40
41 svc.MarkDirty()
42 require.True(t, svc.Active(), "pending debounce is outstanding work")
43
44 require.NoError(t, svc.SaveNow(context.Background()))
45 require.False(t, svc.Active())
46 require.NoError(t, svc.LastError())
47}
48
49func TestService_FailedSaveIsSettledButFailed(t *testing.T) {
50 closedErr := errors.New("database is closed")
51 repo := repomocks.NewMockSessionStateRepository(t)
52 calls := 0
53 repo.EXPECT().
54 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
55 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
56 calls++
57 return closedErr
58 })
59 uc := usecase.NewSnapshotSessionUseCase(repo)
60 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_closed", nil, 0), 60000)
61 svc.ready = true
62 svc.dirty = true
63
64 err := svc.SaveNow(context.Background())
65 require.ErrorIs(t, err, closedErr)
66 require.Equal(t, 1, calls, "no write retries after terminal database failure")
67 require.True(t, svc.dirty, "dirty preserved for reporting and later retry")
68 require.ErrorIs(t, svc.LastError(), closedErr)
69 require.False(t, svc.Active(), "terminal failure settles the drain")
70 require.NoError(t, svc.WaitSettled(context.Background()))
71}
72
73func TestService_WaitSettledExpiry(t *testing.T) {
74 repo := repomocks.NewMockSessionStateRepository(t)
75 uc := usecase.NewSnapshotSessionUseCase(repo)
76 svc := NewService(uc, mocks.NewMockWindowStateProvider(t), 60000)
77 svc.MarkDirty()
78 require.True(t, svc.Active())
79
80 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
81 defer cancel()
82 require.Error(t, svc.WaitSettled(ctx), "drain must expire instead of looping forever")
83}
84
85func TestService_OnSettledFiresAfterSave(t *testing.T) {
86 repo := repomocks.NewMockSessionStateRepository(t)
87 repo.EXPECT().
88 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
89 Return(nil).Once()
90 uc := usecase.NewSnapshotSessionUseCase(repo)
91 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_settled", nil, 0), 60000)
92 svc.ready = true
93 svc.dirty = true
94 settled := make(chan struct{}, 1)
95 svc.OnSettled(func() { settled <- struct{}{} })
96
97 require.NoError(t, svc.SaveNow(context.Background()))
98 select {
99 case <-settled:
100 case <-time.After(5 * time.Second):
101 t.Fatal("settled callback must fire after save completes")
102 }
103}
104
105func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
106 repo := repomocks.NewMockSessionStateRepository(t)
107 calls := 0
108 repo.EXPECT().
109 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
110 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
111 calls++
112 if calls == 1 {
113 return errors.New("FOREIGN KEY constraint failed")
114 }
115 return nil
116 })
117
118 uc := usecase.NewSnapshotSessionUseCase(repo)
119 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_ok", nil, 0), 1)
120 svc.retryDelay = time.Millisecond
121 svc.ready = true
122 svc.dirty = true
123
124 err := svc.saveSnapshot(context.Background())
125 require.NoError(t, err)
126 assert.Equal(t, 2, calls)
127 assert.False(t, svc.dirty)
128}
129
130func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
131 repo := repomocks.NewMockSessionStateRepository(t)
132 calls := 0
133 fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed")
134 repo.EXPECT().
135 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
136 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
137 calls++
138 return fkErr
139 })
140
141 uc := usecase.NewSnapshotSessionUseCase(repo)
142 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_fail", nil, 0), 1)
143 svc.retryDelay = time.Millisecond
144 svc.ready = true
145 svc.dirty = true
146
147 err := svc.saveSnapshot(context.Background())
148 require.Error(t, err)
149 require.ErrorIs(t, err, fkErr)
150 assert.Equal(t, svc.retries+1, calls)
151 assert.True(t, svc.dirty)
152}
153
154func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
155 repo := repomocks.NewMockSessionStateRepository(t)
156 calls := 0
157 nonFKErr := errors.New("database is read-only")
158 repo.EXPECT().
159 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
160 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
161 calls++
162 return nonFKErr
163 })
164
165 uc := usecase.NewSnapshotSessionUseCase(repo)
166 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_non_fk", nil, 0), 1)
167 svc.retryDelay = time.Millisecond
168 svc.ready = true
169 svc.dirty = true
170
171 err := svc.saveSnapshot(context.Background())
172 require.Error(t, err)
173 require.ErrorIs(t, err, nonFKErr)
174 assert.Equal(t, 1, calls)
175 assert.True(t, svc.dirty)
176}
177
178func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
179 repo := repomocks.NewMockSessionStateRepository(t)
180 saved := make(chan struct{}, 1)
181 repo.EXPECT().
182 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
183 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
184 saved <- struct{}{}
185 return nil
186 })
187
188 uc := usecase.NewSnapshotSessionUseCase(repo)
189 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_ready_flush", nil, 0), 1)
190 svc.Start(context.Background())
191 svc.dirty = true
192
193 svc.SetReady()
194
195 select {
196 case <-saved:
197 case <-time.After(500 * time.Millisecond):
198 t.Fatal("expected pending snapshot to be saved after SetReady")
199 }
200 svc.mu.Lock()
201 dirty := svc.dirty
202 svc.mu.Unlock()
203 assert.False(t, dirty)
204}
205
206func TestService_SaveNowPassesWindowSnapshots(t *testing.T) {
207 repo := repomocks.NewMockSessionStateRepository(t)
208
209 pane1 := entity.NewPane("p_w1")
210 pane1.URI = "https://win1.com"
211 tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1)
212 tab1_1.Name = "Win1Tab"
213 tabs1 := entity.NewTabList()
214 tabs1.Add(tab1_1)
215
216 pane2 := entity.NewPane("p_w2")
217 pane2.URI = "https://win2.com"
218 tab2_1 := entity.NewTab("t1_w2", "ws1_w2", pane2)
219 tab2_1.Name = "Win2Tab"
220 tabs2 := entity.NewTabList()
221 tabs2.Add(tab2_1)
222
223 windows := []entity.WindowTabListState{
224 {WindowID: "w1", Tabs: tabs1},
225 {WindowID: "w2", Tabs: tabs2},
226 }
227
228 sessionID := entity.SessionID("20260501_window_snap")
229
230 repo.EXPECT().
231 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
232 Run(func(_ context.Context, state *entity.SessionState) {
233 require.Equal(t, sessionID, state.SessionID)
234 require.Equal(t, 2, state.Version)
235 require.Len(t, state.Windows, 2)
236 assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
237 assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
238 assert.Equal(t, 1, state.ActiveWindowIndex)
239 assert.Empty(t, state.Tabs)
240 }).
241 Return(nil)
242
243 uc := usecase.NewSnapshotSessionUseCase(repo)
244 provider := newWindowStateProvider(t, sessionID, windows, 1)
245 svc := NewService(uc, provider, 1)
246 svc.ready = true
247 svc.dirty = true
248
249 err := svc.saveSnapshot(context.Background())
250 require.NoError(t, err)
251 assert.False(t, svc.dirty)
252}
253
254func TestService_SaveSnapshotKeepsDirtyWhenWindowSnapshotUnavailable(t *testing.T) {
255 repo := repomocks.NewMockSessionStateRepository(t)
256 uc := usecase.NewSnapshotSessionUseCase(repo)
257 provider := mocks.NewMockWindowStateProvider(t)
258 provider.EXPECT().GetSessionID().Return(entity.SessionID("20260501_snapshot_unavailable")).Once()
259 provider.EXPECT().GetWindowSnapshotState().Return(nil, -1).Once()
260
261 svc := NewService(uc, provider, 1)
262 svc.ready = true
263 svc.dirty = true
264
265 err := svc.saveSnapshot(context.Background())
266 require.NoError(t, err)
267 assert.True(t, svc.dirty)
268}
269
270func TestService_SaveNowPersistsEmptyWindowSnapshotAsV2(t *testing.T) {
271 repo := repomocks.NewMockSessionStateRepository(t)
272 sessionID := entity.SessionID("20260501_empty_window_snap")
273
274 repo.EXPECT().
275 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
276 Run(func(_ context.Context, state *entity.SessionState) {
277 require.Equal(t, sessionID, state.SessionID)
278 require.Equal(t, entity.SessionStateVersion, state.Version)
279 require.Empty(t, state.Windows)
280 require.Empty(t, state.Tabs)
281 assert.Equal(t, 0, state.ActiveWindowIndex)
282 }).
283 Return(nil)
284
285 uc := usecase.NewSnapshotSessionUseCase(repo)
286 provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{}, 2)
287 svc := NewService(uc, provider, 1)
288 svc.ready = true
289 svc.dirty = true
290
291 err := svc.saveSnapshot(context.Background())
292 require.NoError(t, err)
293 assert.False(t, svc.dirty)
294}
295
296func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
297 repo := repomocks.NewMockSessionStateRepository(t)
298 sessionID := entity.SessionID("20260501_legacy_sentinel")
299
300 pane := entity.NewPane("p_legacy")
301 pane.URI = "https://legacy.example"
302 tab := entity.NewTab("t_legacy", "ws_legacy", pane)
303 tab.Name = "Legacy Tab"
304 tabs := entity.NewTabList()
305 tabs.Add(tab)
306
307 repo.EXPECT().
308 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
309 Run(func(_ context.Context, state *entity.SessionState) {
310 require.Equal(t, sessionID, state.SessionID)
311 require.Equal(t, entity.LegacySessionStateVersion, state.Version)
312 require.Len(t, state.Tabs, 1)
313 assert.Equal(t, "Legacy Tab", state.Tabs[0].Name)
314 assert.Empty(t, state.Windows)
315 assert.Equal(t, 0, state.ActiveWindowIndex)
316 }).
317 Return(nil)
318
319 uc := usecase.NewSnapshotSessionUseCase(repo)
320 provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{{WindowID: "", Tabs: tabs}}, 3)
321 svc := NewService(uc, provider, 1)
322 svc.ready = true
323 svc.dirty = true
324
325 err := svc.saveSnapshot(context.Background())
326 require.NoError(t, err)
327 assert.False(t, svc.dirty)
328}
329
330func TestService_DebounceTimerClearsOnFire(t *testing.T) {
331 repo := repomocks.NewMockSessionStateRepository(t)
332 repo.EXPECT().
333 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
334 Return(nil).Once()
335 uc := usecase.NewSnapshotSessionUseCase(repo)
336 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fire", nil, 0), 1)
337 svc.ready = true
338 svc.ctx = context.Background()
339 svc.MarkDirty()
340 require.True(t, svc.Active())
341 // The fired timer retires itself; the save settles the drain.
342 require.Eventually(t, func() bool { return !svc.Active() }, 5*time.Second, 5*time.Millisecond)
343}
344
345func TestService_StopJoinsInFlightSave(t *testing.T) {
346 released := make(chan struct{})
347 var calls atomic.Int32
348 repo := repomocks.NewMockSessionStateRepository(t)
349 repo.EXPECT().
350 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
351 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
352 calls.Add(1)
353 <-released
354 return nil
355 })
356 uc := usecase.NewSnapshotSessionUseCase(repo)
357 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_join", nil, 0), 1)
358 svc.ready = true
359 svc.dirty = true
360 saveDone := make(chan error, 1)
361 go func() { saveDone <- svc.saveSnapshot(context.Background()) }()
362 // The in-flight save reached the database: dirty is cleared and the
363 // provider was consumed exactly once, so Stop cannot start a second.
364 require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, 5*time.Millisecond)
365 stopDone := make(chan error, 1)
366 go func() { stopDone <- svc.Stop(context.Background()) }()
367 select {
368 case err := <-stopDone:
369 t.Fatalf("Stop returned while a save was still in flight: %v", err)
370 case <-time.After(200 * time.Millisecond):
371 }
372 close(released)
373 select {
374 case err := <-saveDone:
375 require.NoError(t, err)
376 case <-time.After(5 * time.Second):
377 t.Fatal("in-flight save never completed")
378 }
379 select {
380 case err := <-stopDone:
381 require.NoError(t, err)
382 case <-time.After(15 * time.Second):
383 t.Fatal("Stop did not join the in-flight save")
384 }
385 require.False(t, svc.Active())
386 require.Equal(t, int32(1), calls.Load(), "Stop must join, not duplicate, the save")
387}
388
389func TestService_MarkDirtyNoOpAfterStop(t *testing.T) {
390 repo := repomocks.NewMockSessionStateRepository(t)
391 uc := usecase.NewSnapshotSessionUseCase(repo)
392 svc := NewService(uc, mocks.NewMockWindowStateProvider(t), 1)
393 require.NoError(t, svc.Stop(context.Background()))
394 svc.MarkDirty()
395 require.False(t, svc.Active(), "stopped service schedules nothing")
396 time.Sleep(20 * time.Millisecond)
397 require.False(t, svc.Active())
398}
399
400func TestService_SupersededTimerSavesNothing(t *testing.T) {
401 var calls atomic.Int32
402 repo := repomocks.NewMockSessionStateRepository(t)
403 repo.EXPECT().
404 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
405 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
406 calls.Add(1)
407 return nil
408 })
409 uc := usecase.NewSnapshotSessionUseCase(repo)
410 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_superseded", nil, 0), 1)
411 svc.ready = true
412 svc.MarkDirty()
413 // Stop consumes the pending work in its own final save and retires
414 // the debounce timer; the stopped timer callback must save nothing.
415 require.NoError(t, svc.Stop(context.Background()))
416 require.Equal(t, int32(1), calls.Load())
417 time.Sleep(50 * time.Millisecond)
418 require.Equal(t, int32(1), calls.Load(), "stopped timer must not save")
419 require.False(t, svc.Active())
420}