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_SaveNowPersistsEmptyWindowSnapshotAsV2(t *testing.T) {
180 repo := repomocks.NewMockSessionStateRepository(t)
181 sessionID := entity.SessionID("20260501_empty_window_snap")
182
183 repo.EXPECT().
184 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
185 Run(func(_ context.Context, state *entity.SessionState) {
186 require.Equal(t, sessionID, state.SessionID)
187 require.Equal(t, entity.SessionStateVersion, state.Version)
188 require.Empty(t, state.Windows)
189 require.Empty(t, state.Tabs)
190 assert.Equal(t, 0, state.ActiveWindowIndex)
191 }).
192 Return(nil)
193
194 uc := usecase.NewSnapshotSessionUseCase(repo)
195 provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{}, 2)
196 svc := NewService(uc, provider, 1)
197 svc.ready = true
198 svc.dirty = true
199
200 err := svc.saveSnapshot(context.Background())
201 require.NoError(t, err)
202 assert.False(t, svc.dirty)
203}
204
205func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
206 repo := repomocks.NewMockSessionStateRepository(t)
207 sessionID := entity.SessionID("20260501_legacy_sentinel")
208
209 pane := entity.NewPane("p_legacy")
210 pane.URI = "https://legacy.example"
211 tab := entity.NewTab("t_legacy", "ws_legacy", pane)
212 tab.Name = "Legacy Tab"
213 tabs := entity.NewTabList()
214 tabs.Add(tab)
215
216 repo.EXPECT().
217 SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
218 Run(func(_ context.Context, state *entity.SessionState) {
219 require.Equal(t, sessionID, state.SessionID)
220 require.Equal(t, entity.LegacySessionStateVersion, state.Version)
221 require.Len(t, state.Tabs, 1)
222 assert.Equal(t, "Legacy Tab", state.Tabs[0].Name)
223 assert.Empty(t, state.Windows)
224 assert.Equal(t, 0, state.ActiveWindowIndex)
225 }).
226 Return(nil)
227
228 uc := usecase.NewSnapshotSessionUseCase(repo)
229 provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{{WindowID: "", Tabs: tabs}}, 3)
230 svc := NewService(uc, provider, 1)
231 svc.ready = true
232 svc.dirty = true
233
234 err := svc.saveSnapshot(context.Background())
235 require.NoError(t, err)
236 assert.False(t, svc.dirty)
237}