service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close