1package snapshot
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/bnema/dumber/internal/application/port/mocks"
10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"
12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16)
17
18func newWindowStateProvider(
19 t *testing.T,
20 sessionID entity.SessionID,
21 windows []entity.WindowTabListState,
22 activeWindowIndex int,
23) *mocks.MockWindowStateProvider {
24 provider := mocks.NewMockWindowStateProvider(t)
25 provider.EXPECT().GetSessionID().Return(sessionID).Once()
26 provider.EXPECT().GetWindowSnapshotState().Return(windows, activeWindowIndex).Once()
27 return provider
28}
29
30func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
31 repo := repomocks.NewMockSessionStateRepository(t)
32 calls := 0
33 repo.EXPECT().
34 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
36 calls++
37 if calls == 1 {
38 return errors.New("FOREIGN KEY constraint failed")
39 }
40 return nil
41 })
42
43 uc := usecase.NewSnapshotSessionUseCase(repo)
44 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_ok", nil, 0), 1)
45 svc.retryDelay = time.Millisecond
46 svc.ready = true
47 svc.dirty = true
48
49 err := svc.saveSnapshot(context.Background())
50 require.NoError(t, err)
51 assert.Equal(t, 2, calls)
52 assert.False(t, svc.dirty)
53}
54
55func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
56 repo := repomocks.NewMockSessionStateRepository(t)
57 calls := 0
58 fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed")
59 repo.EXPECT().
60 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
61 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
62 calls++
63 return fkErr
64 })
65
66 uc := usecase.NewSnapshotSessionUseCase(repo)
67 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_fail", nil, 0), 1)
68 svc.retryDelay = time.Millisecond
69 svc.ready = true
70 svc.dirty = true
71
72 err := svc.saveSnapshot(context.Background())
73 require.Error(t, err)
74 require.ErrorIs(t, err, fkErr)
75 assert.Equal(t, svc.retries+1, calls)
76 assert.True(t, svc.dirty)
77}
78
79func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
80 repo := repomocks.NewMockSessionStateRepository(t)
81 calls := 0
82 nonFKErr := errors.New("database is read-only")
83 repo.EXPECT().
84 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
85 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
86 calls++
87 return nonFKErr
88 })
89
90 uc := usecase.NewSnapshotSessionUseCase(repo)
91 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_non_fk", nil, 0), 1)
92 svc.retryDelay = time.Millisecond
93 svc.ready = true
94 svc.dirty = true
95
96 err := svc.saveSnapshot(context.Background())
97 require.Error(t, err)
98 require.ErrorIs(t, err, nonFKErr)
99 assert.Equal(t, 1, calls)
100 assert.True(t, svc.dirty)
101}
102
103func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
104 repo := repomocks.NewMockSessionStateRepository(t)
105 saved := make(chan struct{}, 1)
106 repo.EXPECT().
107 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
108 RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
109 saved <- struct{}{}
110 return nil
111 })
112
113 uc := usecase.NewSnapshotSessionUseCase(repo)
114 svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_ready_flush", nil, 0), 1)
115 svc.Start(context.Background())
116 svc.dirty = true
117
118 svc.SetReady()
119
120 select {
121 case <-saved:
122 case <-time.After(500 * time.Millisecond):
123 t.Fatal("expected pending snapshot to be saved after SetReady")
124 }
125 svc.mu.Lock()
126 dirty := svc.dirty
127 svc.mu.Unlock()
128 assert.False(t, dirty)
129}
130
131func TestService_SaveNowPassesWindowSnapshots(t *testing.T) {
132 repo := repomocks.NewMockSessionStateRepository(t)
133
134 pane1 := entity.NewPane("p_w1")
135 pane1.URI = "https://win1.com"
136 tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1)
137 tab1_1.Name = "Win1Tab"
138 tabs1 := entity.NewTabList()
139 tabs1.Add(tab1_1)
140
141 pane2 := entity.NewPane("p_w2")
142 pane2.URI = "https://win2.com"
143 tab2_1 := entity.NewTab("t1_w2", "ws1_w2", pane2)
144 tab2_1.Name = "Win2Tab"
145 tabs2 := entity.NewTabList()
146 tabs2.Add(tab2_1)
147
148 windows := []entity.WindowTabListState{
149 {WindowID: "w1", Tabs: tabs1},
150 {WindowID: "w2", Tabs: tabs2},
151 }
152
153 sessionID := entity.SessionID("20260501_window_snap")
154
155 repo.EXPECT().
156 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
157 Run(func(_ context.Context, state *entity.SessionState) {
158 require.Equal(t, sessionID, state.SessionID)
159 require.Equal(t, 2, state.Version)
160 require.Len(t, state.Windows, 2)
161 assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
162 assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
163 assert.Equal(t, 1, state.ActiveWindowIndex)
164 assert.Empty(t, state.Tabs)
165 }).
166 Return(nil)
167
168 uc := usecase.NewSnapshotSessionUseCase(repo)
169 provider := newWindowStateProvider(t, sessionID, windows, 1)
170 svc := NewService(uc, provider, 1)
171 svc.ready = true
172 svc.dirty = true
173
174 err := svc.saveSnapshot(context.Background())
175 require.NoError(t, err)
176 assert.False(t, svc.dirty)
177}
178
179func TestService_SaveSnapshotKeepsDirtyWhenWindowSnapshotUnavailable(t *testing.T) {
180 repo := repomocks.NewMockSessionStateRepository(t)
181 uc := usecase.NewSnapshotSessionUseCase(repo)
182 provider := mocks.NewMockWindowStateProvider(t)
183 provider.EXPECT().GetSessionID().Return(entity.SessionID("20260501_snapshot_unavailable")).Once()
184 provider.EXPECT().GetWindowSnapshotState().Return(nil, -1).Once()
185
186 svc := NewService(uc, provider, 1)
187 svc.ready = true
188 svc.dirty = true
189
190 err := svc.saveSnapshot(context.Background())
191 require.NoError(t, err)
192 assert.True(t, svc.dirty)
193}
194
195func TestService_SaveNowPersistsEmptyWindowSnapshotAsV2(t *testing.T) {
196 repo := repomocks.NewMockSessionStateRepository(t)
197 sessionID := entity.SessionID("20260501_empty_window_snap")
198
199 repo.EXPECT().
200 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
201 Run(func(_ context.Context, state *entity.SessionState) {
202 require.Equal(t, sessionID, state.SessionID)
203 require.Equal(t, entity.SessionStateVersion, state.Version)
204 require.Empty(t, state.Windows)
205 require.Empty(t, state.Tabs)
206 assert.Equal(t, 0, state.ActiveWindowIndex)
207 }).
208 Return(nil)
209
210 uc := usecase.NewSnapshotSessionUseCase(repo)
211 provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{}, 2)
212 svc := NewService(uc, provider, 1)
213 svc.ready = true
214 svc.dirty = true
215
216 err := svc.saveSnapshot(context.Background())
217 require.NoError(t, err)
218 assert.False(t, svc.dirty)
219}
220
221func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
222 repo := repomocks.NewMockSessionStateRepository(t)
223 sessionID := entity.SessionID("20260501_legacy_sentinel")
224
225 pane := entity.NewPane("p_legacy")
226 pane.URI = "https://legacy.example"
227 tab := entity.NewTab("t_legacy", "ws_legacy", pane)
228 tab.Name = "Legacy Tab"
229 tabs := entity.NewTabList()
230 tabs.Add(tab)
231
232 repo.EXPECT().
233 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
234 Run(func(_ context.Context, state *entity.SessionState) {
235 require.Equal(t, sessionID, state.SessionID)
236 require.Equal(t, entity.LegacySessionStateVersion, state.Version)
237 require.Len(t, state.Tabs, 1)
238 assert.Equal(t, "Legacy Tab", state.Tabs[0].Name)
239 assert.Empty(t, state.Windows)
240 assert.Equal(t, 0, state.ActiveWindowIndex)
241 }).
242 Return(nil)
243
244 uc := usecase.NewSnapshotSessionUseCase(repo)
245 provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{{WindowID: "", Tabs: tabs}}, 3)
246 svc := NewService(uc, provider, 1)
247 svc.ready = true
248 svc.dirty = true
249
250 err := svc.saveSnapshot(context.Background())
251 require.NoError(t, err)
252 assert.False(t, svc.dirty)
253}