detached 6fa0e3d

feat(session): implement persistent session management and stale session protection

commit
6fa0e3d36518c14f0ee4a0f88fbd44d0874b02b1
author
brice · · 2025-12-21 22:20 UTC
committer
brice · · 2025-12-21 22:17 UTC
parent
5cbd7d8
signature
unsigned
18 files · +1356 -0
M .mockery.yaml +2 -0
@@ -8,6 +8,7 @@ packages:
88 filename: mocks.go
99 pkgname: mocks
1010 interfaces:
11+ SessionLogger: {}
1112 WebView: {}
1213 WebViewFactory: {}
1314 WebViewPool: {}
@@ -21,6 +22,7 @@ packages:
2122 FavoriteRepository: {}
2223 FolderRepository: {}
2324 HistoryRepository: {}
25+ SessionRepository: {}
2426 TagRepository: {}
2527 ZoomRepository: {}
2628 github.com/bnema/dumber/internal/infrastructure/filtering:
@@ -8,6 +8,7 @@ packages:
8 filename: mocks.go8 filename: mocks.go
9 pkgname: mocks9 pkgname: mocks
10 interfaces:10 interfaces:
11+ SessionLogger: {}
11 WebView: {}12 WebView: {}
12 WebViewFactory: {}13 WebViewFactory: {}
13 WebViewPool: {}14 WebViewPool: {}
@@ -21,6 +22,7 @@ packages:
21 FavoriteRepository: {}22 FavoriteRepository: {}
22 FolderRepository: {}23 FolderRepository: {}
23 HistoryRepository: {}24 HistoryRepository: {}
25+ SessionRepository: {}
24 TagRepository: {}26 TagRepository: {}
25 ZoomRepository: {}27 ZoomRepository: {}
26 github.com/bnema/dumber/internal/infrastructure/filtering:28 github.com/bnema/dumber/internal/infrastructure/filtering:
M internal/application/port/mocks/mocks.go +109 -0
@@ -8,9 +8,118 @@ import (
88 "context"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/rs/zerolog"
1113 mock "github.com/stretchr/testify/mock"
1214 )
1315
16+// NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewMockSessionLogger(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *MockSessionLogger {
22+ mock := &MockSessionLogger{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() { mock.AssertExpectations(t) })
26+
27+ return mock
28+}
29+
30+// MockSessionLogger is an autogenerated mock type for the SessionLogger type
31+type MockSessionLogger struct {
32+ mock.Mock
33+}
34+
35+type MockSessionLogger_Expecter struct {
36+ mock *mock.Mock
37+}
38+
39+func (_m *MockSessionLogger) EXPECT() *MockSessionLogger_Expecter {
40+ return &MockSessionLogger_Expecter{mock: &_m.Mock}
41+}
42+
43+// CreateLogger provides a mock function for the type MockSessionLogger
44+func (_mock *MockSessionLogger) CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error) {
45+ ret := _mock.Called(ctx, sessionID, cfg)
46+
47+ if len(ret) == 0 {
48+ panic("no return value specified for CreateLogger")
49+ }
50+
51+ var r0 zerolog.Logger
52+ var r1 func()
53+ var r2 error
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) (zerolog.Logger, func(), error)); ok {
55+ return returnFunc(ctx, sessionID, cfg)
56+ }
57+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) zerolog.Logger); ok {
58+ r0 = returnFunc(ctx, sessionID, cfg)
59+ } else {
60+ r0 = ret.Get(0).(zerolog.Logger)
61+ }
62+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok {
63+ r1 = returnFunc(ctx, sessionID, cfg)
64+ } else {
65+ if ret.Get(1) != nil {
66+ r1 = ret.Get(1).(func())
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(2).(func(context.Context, entity.SessionID, port.SessionLogConfig) error); ok {
70+ r2 = returnFunc(ctx, sessionID, cfg)
71+ } else {
72+ r2 = ret.Error(2)
73+ }
74+ return r0, r1, r2
75+}
76+
77+// MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger'
78+type MockSessionLogger_CreateLogger_Call struct {
79+ *mock.Call
80+}
81+
82+// CreateLogger is a helper method to define mock.On call
83+// - ctx context.Context
84+// - sessionID entity.SessionID
85+// - cfg port.SessionLogConfig
86+func (_e *MockSessionLogger_Expecter) CreateLogger(ctx interface{}, sessionID interface{}, cfg interface{}) *MockSessionLogger_CreateLogger_Call {
87+ return &MockSessionLogger_CreateLogger_Call{Call: _e.mock.On("CreateLogger", ctx, sessionID, cfg)}
88+}
89+
90+func (_c *MockSessionLogger_CreateLogger_Call) Run(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig)) *MockSessionLogger_CreateLogger_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 entity.SessionID
97+ if args[1] != nil {
98+ arg1 = args[1].(entity.SessionID)
99+ }
100+ var arg2 port.SessionLogConfig
101+ if args[2] != nil {
102+ arg2 = args[2].(port.SessionLogConfig)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *MockSessionLogger_CreateLogger_Call) Return(logger zerolog.Logger, fn func(), err error) *MockSessionLogger_CreateLogger_Call {
114+ _c.Call.Return(logger, fn, err)
115+ return _c
116+}
117+
118+func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error)) *MockSessionLogger_CreateLogger_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
14123 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15124 // The first argument is typically a *testing.T value.
16125 func NewMockWebView(t interface {
@@ -8,9 +8,118 @@ import (
8 "context"8 "context"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/rs/zerolog"
11 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
12 )14 )
13 15
16+// NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewMockSessionLogger(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *MockSessionLogger {
22+ mock := &MockSessionLogger{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() { mock.AssertExpectations(t) })
26+
27+ return mock
28+}
29+
30+// MockSessionLogger is an autogenerated mock type for the SessionLogger type
31+type MockSessionLogger struct {
32+ mock.Mock
33+}
34+
35+type MockSessionLogger_Expecter struct {
36+ mock *mock.Mock
37+}
38+
39+func (_m *MockSessionLogger) EXPECT() *MockSessionLogger_Expecter {
40+ return &MockSessionLogger_Expecter{mock: &_m.Mock}
41+}
42+
43+// CreateLogger provides a mock function for the type MockSessionLogger
44+func (_mock *MockSessionLogger) CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error) {
45+ ret := _mock.Called(ctx, sessionID, cfg)
46+
47+ if len(ret) == 0 {
48+ panic("no return value specified for CreateLogger")
49+ }
50+
51+ var r0 zerolog.Logger
52+ var r1 func()
53+ var r2 error
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) (zerolog.Logger, func(), error)); ok {
55+ return returnFunc(ctx, sessionID, cfg)
56+ }
57+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) zerolog.Logger); ok {
58+ r0 = returnFunc(ctx, sessionID, cfg)
59+ } else {
60+ r0 = ret.Get(0).(zerolog.Logger)
61+ }
62+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok {
63+ r1 = returnFunc(ctx, sessionID, cfg)
64+ } else {
65+ if ret.Get(1) != nil {
66+ r1 = ret.Get(1).(func())
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(2).(func(context.Context, entity.SessionID, port.SessionLogConfig) error); ok {
70+ r2 = returnFunc(ctx, sessionID, cfg)
71+ } else {
72+ r2 = ret.Error(2)
73+ }
74+ return r0, r1, r2
75+}
76+
77+// MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger'
78+type MockSessionLogger_CreateLogger_Call struct {
79+ *mock.Call
80+}
81+
82+// CreateLogger is a helper method to define mock.On call
83+// - ctx context.Context
84+// - sessionID entity.SessionID
85+// - cfg port.SessionLogConfig
86+func (_e *MockSessionLogger_Expecter) CreateLogger(ctx interface{}, sessionID interface{}, cfg interface{}) *MockSessionLogger_CreateLogger_Call {
87+ return &MockSessionLogger_CreateLogger_Call{Call: _e.mock.On("CreateLogger", ctx, sessionID, cfg)}
88+}
89+
90+func (_c *MockSessionLogger_CreateLogger_Call) Run(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig)) *MockSessionLogger_CreateLogger_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 entity.SessionID
97+ if args[1] != nil {
98+ arg1 = args[1].(entity.SessionID)
99+ }
100+ var arg2 port.SessionLogConfig
101+ if args[2] != nil {
102+ arg2 = args[2].(port.SessionLogConfig)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *MockSessionLogger_CreateLogger_Call) Return(logger zerolog.Logger, fn func(), err error) *MockSessionLogger_CreateLogger_Call {
114+ _c.Call.Return(logger, fn, err)
115+ return _c
116+}
117+
118+func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error)) *MockSessionLogger_CreateLogger_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
14 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.123 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15 // The first argument is typically a *testing.T value.124 // The first argument is typically a *testing.T value.
16 func NewMockWebView(t interface {125 func NewMockWebView(t interface {
A internal/application/port/session_logger.go +22 -0
@@ -0,0 +1,22 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/rs/zerolog"
8+)
9+
10+type SessionLogConfig struct {
11+ Level string
12+ Format string
13+ TimeFormat string
14+ LogDir string
15+ WriteToStderr bool
16+ EnableFileLog bool
17+}
18+
19+// SessionLogger creates a logger tied to a session.
20+type SessionLogger interface {
21+ CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg SessionLogConfig) (zerolog.Logger, func(), error)
22+}
@@ -0,0 +1,22 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/rs/zerolog"
8+)
9+
10+type SessionLogConfig struct {
11+ Level string
12+ Format string
13+ TimeFormat string
14+ LogDir string
15+ WriteToStderr bool
16+ EnableFileLog bool
17+}
18+
19+// SessionLogger creates a logger tied to a session.
20+type SessionLogger interface {
21+ CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg SessionLogConfig) (zerolog.Logger, func(), error)
22+}
A internal/application/usecase/manage_session.go +103 -0
@@ -0,0 +1,103 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/rs/zerolog"
13+)
14+
15+type ManageSessionUseCase struct {
16+ sessionRepo repository.SessionRepository
17+ loggerPort port.SessionLogger
18+}
19+
20+func NewManageSessionUseCase(sessionRepo repository.SessionRepository, loggerPort port.SessionLogger) *ManageSessionUseCase {
21+ return &ManageSessionUseCase{sessionRepo: sessionRepo, loggerPort: loggerPort}
22+}
23+
24+type StartSessionInput struct {
25+ Type entity.SessionType
26+ SessionID entity.SessionID
27+ Now time.Time
28+ LogConfig port.SessionLogConfig
29+}
30+
31+type StartSessionOutput struct {
32+ Session *entity.Session
33+ Logger zerolog.Logger
34+ LogCleanup func()
35+}
36+
37+func (uc *ManageSessionUseCase) StartSession(ctx context.Context, input StartSessionInput) (*StartSessionOutput, error) {
38+ log := logging.FromContext(ctx)
39+
40+ now := input.Now
41+ if now.IsZero() {
42+ now = time.Now()
43+ }
44+
45+ sessionID := input.SessionID
46+ if sessionID == "" {
47+ sessionID = entity.SessionID(logging.GenerateSessionID())
48+ }
49+
50+ sType := input.Type
51+ if sType == "" {
52+ sType = entity.SessionTypeBrowser
53+ }
54+
55+ session := &entity.Session{
56+ ID: sessionID,
57+ Type: sType,
58+ StartedAt: now.UTC(),
59+ }
60+ if err := session.Validate(); err != nil {
61+ return nil, err
62+ }
63+
64+ logger, cleanup, err := uc.loggerPort.CreateLogger(ctx, session.ID, input.LogConfig)
65+ if err != nil {
66+ log.Warn().Err(err).Msg("failed to create session logger")
67+ // proceed with fallback logger returned by adapter
68+ }
69+
70+ sessionCtx := logging.WithContext(ctx, logger)
71+ if saveErr := uc.sessionRepo.Save(sessionCtx, session); saveErr != nil {
72+ if cleanup != nil {
73+ cleanup()
74+ }
75+ return nil, fmt.Errorf("save session: %w", saveErr)
76+ }
77+
78+ logger.Info().Str("session_id", string(session.ID)).Str("type", string(session.Type)).Msg("session started")
79+ return &StartSessionOutput{Session: session, Logger: logger, LogCleanup: cleanup}, nil
80+}
81+
82+func (uc *ManageSessionUseCase) EndSession(ctx context.Context, sessionID entity.SessionID, endedAt time.Time) error {
83+ log := logging.FromContext(ctx)
84+ if sessionID == "" {
85+ return fmt.Errorf("session id required")
86+ }
87+ if endedAt.IsZero() {
88+ endedAt = time.Now()
89+ }
90+ if err := uc.sessionRepo.MarkEnded(ctx, sessionID, endedAt); err != nil {
91+ return fmt.Errorf("mark session ended: %w", err)
92+ }
93+ log.Info().Str("session_id", string(sessionID)).Msg("session ended")
94+ return nil
95+}
96+
97+func (uc *ManageSessionUseCase) GetActiveSession(ctx context.Context) (*entity.Session, error) {
98+ return uc.sessionRepo.GetActive(ctx)
99+}
100+
101+func (uc *ManageSessionUseCase) GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error) {
102+ return uc.sessionRepo.GetRecent(ctx, limit)
103+}
@@ -0,0 +1,103 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/rs/zerolog"
13+)
14+
15+type ManageSessionUseCase struct {
16+ sessionRepo repository.SessionRepository
17+ loggerPort port.SessionLogger
18+}
19+
20+func NewManageSessionUseCase(sessionRepo repository.SessionRepository, loggerPort port.SessionLogger) *ManageSessionUseCase {
21+ return &ManageSessionUseCase{sessionRepo: sessionRepo, loggerPort: loggerPort}
22+}
23+
24+type StartSessionInput struct {
25+ Type entity.SessionType
26+ SessionID entity.SessionID
27+ Now time.Time
28+ LogConfig port.SessionLogConfig
29+}
30+
31+type StartSessionOutput struct {
32+ Session *entity.Session
33+ Logger zerolog.Logger
34+ LogCleanup func()
35+}
36+
37+func (uc *ManageSessionUseCase) StartSession(ctx context.Context, input StartSessionInput) (*StartSessionOutput, error) {
38+ log := logging.FromContext(ctx)
39+
40+ now := input.Now
41+ if now.IsZero() {
42+ now = time.Now()
43+ }
44+
45+ sessionID := input.SessionID
46+ if sessionID == "" {
47+ sessionID = entity.SessionID(logging.GenerateSessionID())
48+ }
49+
50+ sType := input.Type
51+ if sType == "" {
52+ sType = entity.SessionTypeBrowser
53+ }
54+
55+ session := &entity.Session{
56+ ID: sessionID,
57+ Type: sType,
58+ StartedAt: now.UTC(),
59+ }
60+ if err := session.Validate(); err != nil {
61+ return nil, err
62+ }
63+
64+ logger, cleanup, err := uc.loggerPort.CreateLogger(ctx, session.ID, input.LogConfig)
65+ if err != nil {
66+ log.Warn().Err(err).Msg("failed to create session logger")
67+ // proceed with fallback logger returned by adapter
68+ }
69+
70+ sessionCtx := logging.WithContext(ctx, logger)
71+ if saveErr := uc.sessionRepo.Save(sessionCtx, session); saveErr != nil {
72+ if cleanup != nil {
73+ cleanup()
74+ }
75+ return nil, fmt.Errorf("save session: %w", saveErr)
76+ }
77+
78+ logger.Info().Str("session_id", string(session.ID)).Str("type", string(session.Type)).Msg("session started")
79+ return &StartSessionOutput{Session: session, Logger: logger, LogCleanup: cleanup}, nil
80+}
81+
82+func (uc *ManageSessionUseCase) EndSession(ctx context.Context, sessionID entity.SessionID, endedAt time.Time) error {
83+ log := logging.FromContext(ctx)
84+ if sessionID == "" {
85+ return fmt.Errorf("session id required")
86+ }
87+ if endedAt.IsZero() {
88+ endedAt = time.Now()
89+ }
90+ if err := uc.sessionRepo.MarkEnded(ctx, sessionID, endedAt); err != nil {
91+ return fmt.Errorf("mark session ended: %w", err)
92+ }
93+ log.Info().Str("session_id", string(sessionID)).Msg("session ended")
94+ return nil
95+}
96+
97+func (uc *ManageSessionUseCase) GetActiveSession(ctx context.Context) (*entity.Session, error) {
98+ return uc.sessionRepo.GetActive(ctx)
99+}
100+
101+func (uc *ManageSessionUseCase) GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error) {
102+ return uc.sessionRepo.GetRecent(ctx, limit)
103+}
A internal/application/usecase/manage_session_test.go +78 -0
@@ -0,0 +1,78 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ portmocks "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/bnema/dumber/internal/logging"
15+ "github.com/rs/zerolog"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+func testContext() context.Context {
22+ logger := logging.NewFromConfigValues("debug", "console")
23+ return logging.WithContext(context.Background(), logger)
24+}
25+
26+func TestManageSessionUseCase_StartSession_SavesAndReturnsLogger(t *testing.T) {
27+ ctx := testContext()
28+
29+ sessionRepo := repomocks.NewMockSessionRepository(t)
30+ loggerPort := portmocks.NewMockSessionLogger(t)
31+
32+ cfg := port.SessionLogConfig{Level: "info", Format: "json", TimeFormat: "15:04:05", EnableFileLog: false}
33+ returnedLogger := zerolog.New(zerolog.NewConsoleWriter())
34+
35+ loggerPort.EXPECT().CreateLogger(mock.Anything, mock.Anything, cfg).
36+ Return(returnedLogger, func() {}, nil)
37+
38+ sessionRepo.EXPECT().Save(mock.Anything, mock.AnythingOfType("*entity.Session")).
39+ Run(func(_ context.Context, s *entity.Session) {
40+ require.Equal(t, entity.SessionTypeBrowser, s.Type)
41+ require.NotEmpty(t, s.ID)
42+ require.False(t, s.StartedAt.IsZero())
43+ }).
44+ Return(nil)
45+
46+ uc := usecase.NewManageSessionUseCase(sessionRepo, loggerPort)
47+
48+ out, err := uc.StartSession(ctx, usecase.StartSessionInput{Type: entity.SessionTypeBrowser, Now: time.Now(), LogConfig: cfg})
49+ require.NoError(t, err)
50+ require.NotNil(t, out)
51+ assert.NotNil(t, out.Session)
52+ assert.Equal(t, returnedLogger, out.Logger)
53+}
54+
55+func TestManageSessionUseCase_StartSession_WhenSaveFails_CleansUp(t *testing.T) {
56+ ctx := testContext()
57+
58+ sessionRepo := repomocks.NewMockSessionRepository(t)
59+ loggerPort := portmocks.NewMockSessionLogger(t)
60+
61+ cfg := port.SessionLogConfig{Level: "info", Format: "json", EnableFileLog: false}
62+ returnedLogger := zerolog.New(zerolog.NewConsoleWriter())
63+
64+ cleanupCalled := false
65+ cleanupFn := func() { cleanupCalled = true }
66+
67+ loggerPort.EXPECT().CreateLogger(mock.Anything, mock.Anything, cfg).
68+ Return(returnedLogger, cleanupFn, nil)
69+
70+ sessionRepo.EXPECT().Save(mock.Anything, mock.AnythingOfType("*entity.Session")).
71+ Return(errors.New("db down"))
72+
73+ uc := usecase.NewManageSessionUseCase(sessionRepo, loggerPort)
74+
75+ _, err := uc.StartSession(ctx, usecase.StartSessionInput{Type: entity.SessionTypeBrowser, Now: time.Now(), LogConfig: cfg})
76+ require.Error(t, err)
77+ assert.True(t, cleanupCalled)
78+}
@@ -0,0 +1,78 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ portmocks "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/bnema/dumber/internal/logging"
15+ "github.com/rs/zerolog"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+func testContext() context.Context {
22+ logger := logging.NewFromConfigValues("debug", "console")
23+ return logging.WithContext(context.Background(), logger)
24+}
25+
26+func TestManageSessionUseCase_StartSession_SavesAndReturnsLogger(t *testing.T) {
27+ ctx := testContext()
28+
29+ sessionRepo := repomocks.NewMockSessionRepository(t)
30+ loggerPort := portmocks.NewMockSessionLogger(t)
31+
32+ cfg := port.SessionLogConfig{Level: "info", Format: "json", TimeFormat: "15:04:05", EnableFileLog: false}
33+ returnedLogger := zerolog.New(zerolog.NewConsoleWriter())
34+
35+ loggerPort.EXPECT().CreateLogger(mock.Anything, mock.Anything, cfg).
36+ Return(returnedLogger, func() {}, nil)
37+
38+ sessionRepo.EXPECT().Save(mock.Anything, mock.AnythingOfType("*entity.Session")).
39+ Run(func(_ context.Context, s *entity.Session) {
40+ require.Equal(t, entity.SessionTypeBrowser, s.Type)
41+ require.NotEmpty(t, s.ID)
42+ require.False(t, s.StartedAt.IsZero())
43+ }).
44+ Return(nil)
45+
46+ uc := usecase.NewManageSessionUseCase(sessionRepo, loggerPort)
47+
48+ out, err := uc.StartSession(ctx, usecase.StartSessionInput{Type: entity.SessionTypeBrowser, Now: time.Now(), LogConfig: cfg})
49+ require.NoError(t, err)
50+ require.NotNil(t, out)
51+ assert.NotNil(t, out.Session)
52+ assert.Equal(t, returnedLogger, out.Logger)
53+}
54+
55+func TestManageSessionUseCase_StartSession_WhenSaveFails_CleansUp(t *testing.T) {
56+ ctx := testContext()
57+
58+ sessionRepo := repomocks.NewMockSessionRepository(t)
59+ loggerPort := portmocks.NewMockSessionLogger(t)
60+
61+ cfg := port.SessionLogConfig{Level: "info", Format: "json", EnableFileLog: false}
62+ returnedLogger := zerolog.New(zerolog.NewConsoleWriter())
63+
64+ cleanupCalled := false
65+ cleanupFn := func() { cleanupCalled = true }
66+
67+ loggerPort.EXPECT().CreateLogger(mock.Anything, mock.Anything, cfg).
68+ Return(returnedLogger, cleanupFn, nil)
69+
70+ sessionRepo.EXPECT().Save(mock.Anything, mock.AnythingOfType("*entity.Session")).
71+ Return(errors.New("db down"))
72+
73+ uc := usecase.NewManageSessionUseCase(sessionRepo, loggerPort)
74+
75+ _, err := uc.StartSession(ctx, usecase.StartSessionInput{Type: entity.SessionTypeBrowser, Now: time.Now(), LogConfig: cfg})
76+ require.Error(t, err)
77+ assert.True(t, cleanupCalled)
78+}
A internal/bootstrap/session.go +193 -0
@@ -0,0 +1,193 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "syscall"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+ infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19+ corelogging "github.com/bnema/dumber/internal/logging"
20+ "github.com/rs/zerolog"
21+)
22+
23+type BrowserSession struct {
24+ Session *entity.Session
25+ Logger zerolog.Logger
26+ LogCleanup func()
27+
28+ End func(context.Context) error
29+}
30+
31+func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
32+ log := corelogging.FromContext(ctx)
33+
34+ lockDir := cfg.Logging.LogDir
35+ if lockDir == "" {
36+ if dir, err := config.GetLogDir(); err == nil {
37+ lockDir = dir
38+ }
39+ }
40+
41+ sessionRepo := sqlite.NewSessionRepository(db)
42+ sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
43+ sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
44+
45+ // If a previous browser process died without cleanup, it may have left sessions "active".
46+ // Use per-session lock files to distinguish truly running sessions from stale ones.
47+ if lockDir != "" {
48+ if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
49+ log.Warn().Err(err).Msg("failed to mark stale sessions ended")
50+ }
51+ }
52+
53+ now := time.Now()
54+ out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
55+ Type: entity.SessionTypeBrowser,
56+ Now: now,
57+ LogConfig: port.SessionLogConfig{
58+ Level: cfg.Logging.Level,
59+ Format: cfg.Logging.Format,
60+ TimeFormat: "15:04:05",
61+ LogDir: cfg.Logging.LogDir,
62+ WriteToStderr: true,
63+ EnableFileLog: cfg.Logging.EnableFileLog,
64+ },
65+ })
66+ if err != nil {
67+ return nil, ctx, err
68+ }
69+
70+ lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
71+ if lockErr != nil {
72+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
73+ out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
74+ }
75+
76+ sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
77+
78+ endFn := func(endCtx context.Context) error {
79+ endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
80+ if lockFile != nil {
81+ _ = unlockAndClose(lockFile)
82+ _ = os.Remove(lockPath)
83+ }
84+ return endErr
85+ }
86+
87+ return &BrowserSession{
88+ Session: out.Session,
89+ Logger: out.Logger,
90+ LogCleanup: out.LogCleanup,
91+ End: endFn,
92+ }, sessionCtx, nil
93+}
94+
95+func endStaleActiveBrowserSessions(ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger) error {
96+ // We only have GetRecentSessions; fetch enough to cover likely actives.
97+ recent, err := sessionUC.GetRecentSessions(ctx, 200)
98+ if err != nil {
99+ return err
100+ }
101+
102+ now := time.Now()
103+ for _, s := range recent {
104+ if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
105+ continue
106+ }
107+
108+ lockPath := sessionLockPath(lockDir, s.ID)
109+ if _, statErr := os.Stat(lockPath); statErr != nil {
110+ // No lock file: conservative behavior, don’t auto-end.
111+ continue
112+ }
113+
114+ f, err := os.OpenFile(lockPath, os.O_RDWR, 0o600)
115+ if err != nil {
116+ continue
117+ }
118+
119+ locked, lockErr := tryLockExclusiveNonBlocking(f)
120+ if lockErr != nil {
121+ _ = f.Close()
122+ continue
123+ }
124+ if !locked {
125+ // Another process is holding the lock => session still alive.
126+ _ = f.Close()
127+ continue
128+ }
129+
130+ // We acquired the lock => no running process holds it => stale session.
131+ if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
132+ if log != nil {
133+ log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
134+ }
135+ }
136+ _ = unlockAndClose(f)
137+ _ = os.Remove(lockPath)
138+ }
139+
140+ return nil
141+}
142+
143+func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
144+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
145+}
146+
147+func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
148+ if lockDir == "" {
149+ return nil, "", errors.New("lock dir is empty")
150+ }
151+ if err := os.MkdirAll(lockDir, 0o755); err != nil {
152+ return nil, "", err
153+ }
154+
155+ lockPath := sessionLockPath(lockDir, sessionID)
156+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
157+ if err != nil {
158+ return nil, "", err
159+ }
160+
161+ locked, lockErr := tryLockExclusiveNonBlocking(f)
162+ if lockErr != nil {
163+ _ = f.Close()
164+ return nil, "", lockErr
165+ }
166+ if !locked {
167+ _ = f.Close()
168+ return nil, "", errors.New("session lock already held")
169+ }
170+ return f, lockPath, nil
171+}
172+
173+func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
174+ if f == nil {
175+ return false, errors.New("nil file")
176+ }
177+ err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
178+ if err == nil {
179+ return true, nil
180+ }
181+ if errors.Is(err, syscall.EWOULDBLOCK) {
182+ return false, nil
183+ }
184+ return false, err
185+}
186+
187+func unlockAndClose(f *os.File) error {
188+ if f == nil {
189+ return nil
190+ }
191+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
192+ return f.Close()
193+}
@@ -0,0 +1,193 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "syscall"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+ infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19+ corelogging "github.com/bnema/dumber/internal/logging"
20+ "github.com/rs/zerolog"
21+)
22+
23+type BrowserSession struct {
24+ Session *entity.Session
25+ Logger zerolog.Logger
26+ LogCleanup func()
27+
28+ End func(context.Context) error
29+}
30+
31+func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
32+ log := corelogging.FromContext(ctx)
33+
34+ lockDir := cfg.Logging.LogDir
35+ if lockDir == "" {
36+ if dir, err := config.GetLogDir(); err == nil {
37+ lockDir = dir
38+ }
39+ }
40+
41+ sessionRepo := sqlite.NewSessionRepository(db)
42+ sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
43+ sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
44+
45+ // If a previous browser process died without cleanup, it may have left sessions "active".
46+ // Use per-session lock files to distinguish truly running sessions from stale ones.
47+ if lockDir != "" {
48+ if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
49+ log.Warn().Err(err).Msg("failed to mark stale sessions ended")
50+ }
51+ }
52+
53+ now := time.Now()
54+ out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
55+ Type: entity.SessionTypeBrowser,
56+ Now: now,
57+ LogConfig: port.SessionLogConfig{
58+ Level: cfg.Logging.Level,
59+ Format: cfg.Logging.Format,
60+ TimeFormat: "15:04:05",
61+ LogDir: cfg.Logging.LogDir,
62+ WriteToStderr: true,
63+ EnableFileLog: cfg.Logging.EnableFileLog,
64+ },
65+ })
66+ if err != nil {
67+ return nil, ctx, err
68+ }
69+
70+ lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
71+ if lockErr != nil {
72+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
73+ out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
74+ }
75+
76+ sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
77+
78+ endFn := func(endCtx context.Context) error {
79+ endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
80+ if lockFile != nil {
81+ _ = unlockAndClose(lockFile)
82+ _ = os.Remove(lockPath)
83+ }
84+ return endErr
85+ }
86+
87+ return &BrowserSession{
88+ Session: out.Session,
89+ Logger: out.Logger,
90+ LogCleanup: out.LogCleanup,
91+ End: endFn,
92+ }, sessionCtx, nil
93+}
94+
95+func endStaleActiveBrowserSessions(ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger) error {
96+ // We only have GetRecentSessions; fetch enough to cover likely actives.
97+ recent, err := sessionUC.GetRecentSessions(ctx, 200)
98+ if err != nil {
99+ return err
100+ }
101+
102+ now := time.Now()
103+ for _, s := range recent {
104+ if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
105+ continue
106+ }
107+
108+ lockPath := sessionLockPath(lockDir, s.ID)
109+ if _, statErr := os.Stat(lockPath); statErr != nil {
110+ // No lock file: conservative behavior, don’t auto-end.
111+ continue
112+ }
113+
114+ f, err := os.OpenFile(lockPath, os.O_RDWR, 0o600)
115+ if err != nil {
116+ continue
117+ }
118+
119+ locked, lockErr := tryLockExclusiveNonBlocking(f)
120+ if lockErr != nil {
121+ _ = f.Close()
122+ continue
123+ }
124+ if !locked {
125+ // Another process is holding the lock => session still alive.
126+ _ = f.Close()
127+ continue
128+ }
129+
130+ // We acquired the lock => no running process holds it => stale session.
131+ if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
132+ if log != nil {
133+ log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
134+ }
135+ }
136+ _ = unlockAndClose(f)
137+ _ = os.Remove(lockPath)
138+ }
139+
140+ return nil
141+}
142+
143+func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
144+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
145+}
146+
147+func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
148+ if lockDir == "" {
149+ return nil, "", errors.New("lock dir is empty")
150+ }
151+ if err := os.MkdirAll(lockDir, 0o755); err != nil {
152+ return nil, "", err
153+ }
154+
155+ lockPath := sessionLockPath(lockDir, sessionID)
156+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
157+ if err != nil {
158+ return nil, "", err
159+ }
160+
161+ locked, lockErr := tryLockExclusiveNonBlocking(f)
162+ if lockErr != nil {
163+ _ = f.Close()
164+ return nil, "", lockErr
165+ }
166+ if !locked {
167+ _ = f.Close()
168+ return nil, "", errors.New("session lock already held")
169+ }
170+ return f, lockPath, nil
171+}
172+
173+func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
174+ if f == nil {
175+ return false, errors.New("nil file")
176+ }
177+ err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
178+ if err == nil {
179+ return true, nil
180+ }
181+ if errors.Is(err, syscall.EWOULDBLOCK) {
182+ return false, nil
183+ }
184+ return false, err
185+}
186+
187+func unlockAndClose(f *os.File) error {
188+ if f == nil {
189+ return nil
190+ }
191+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
192+ return f.Close()
193+}
A internal/domain/entity/session.go +62 -0
@@ -0,0 +1,62 @@
1+package entity
2+
3+import (
4+ "errors"
5+ "time"
6+)
7+
8+// SessionID uniquely identifies an application session.
9+// For now it matches the log session ID format (YYYYMMDD_HHMMSS_xxxx).
10+type SessionID string
11+
12+// SessionType distinguishes long-running browser sessions from ephemeral CLI invocations.
13+type SessionType string
14+
15+const (
16+ SessionTypeBrowser SessionType = "browser"
17+ SessionTypeCLI SessionType = "cli"
18+)
19+
20+// Session captures metadata about a dumber run.
21+// A browser session is expected to have a corresponding log file.
22+type Session struct {
23+ ID SessionID
24+ Type SessionType
25+ StartedAt time.Time
26+ EndedAt *time.Time
27+}
28+
29+func (s *Session) ShortID() string {
30+ id := string(s.ID)
31+ if len(id) < 4 {
32+ return id
33+ }
34+ return id[len(id)-4:]
35+}
36+
37+func (s *Session) IsActive() bool {
38+ return s != nil && s.EndedAt == nil
39+}
40+
41+func (s *Session) End(endedAt time.Time) {
42+ endedAt = endedAt.UTC()
43+ s.EndedAt = &endedAt
44+}
45+
46+func (s *Session) Validate() error {
47+ if s == nil {
48+ return ErrInvalidSession
49+ }
50+ if s.ID == "" {
51+ return ErrInvalidSession
52+ }
53+ if s.Type != SessionTypeBrowser && s.Type != SessionTypeCLI {
54+ return ErrInvalidSession
55+ }
56+ if s.StartedAt.IsZero() {
57+ return ErrInvalidSession
58+ }
59+ return nil
60+}
61+
62+var ErrInvalidSession = errors.New("invalid session")
@@ -0,0 +1,62 @@
1+package entity
2+
3+import (
4+ "errors"
5+ "time"
6+)
7+
8+// SessionID uniquely identifies an application session.
9+// For now it matches the log session ID format (YYYYMMDD_HHMMSS_xxxx).
10+type SessionID string
11+
12+// SessionType distinguishes long-running browser sessions from ephemeral CLI invocations.
13+type SessionType string
14+
15+const (
16+ SessionTypeBrowser SessionType = "browser"
17+ SessionTypeCLI SessionType = "cli"
18+)
19+
20+// Session captures metadata about a dumber run.
21+// A browser session is expected to have a corresponding log file.
22+type Session struct {
23+ ID SessionID
24+ Type SessionType
25+ StartedAt time.Time
26+ EndedAt *time.Time
27+}
28+
29+func (s *Session) ShortID() string {
30+ id := string(s.ID)
31+ if len(id) < 4 {
32+ return id
33+ }
34+ return id[len(id)-4:]
35+}
36+
37+func (s *Session) IsActive() bool {
38+ return s != nil && s.EndedAt == nil
39+}
40+
41+func (s *Session) End(endedAt time.Time) {
42+ endedAt = endedAt.UTC()
43+ s.EndedAt = &endedAt
44+}
45+
46+func (s *Session) Validate() error {
47+ if s == nil {
48+ return ErrInvalidSession
49+ }
50+ if s.ID == "" {
51+ return ErrInvalidSession
52+ }
53+ if s.Type != SessionTypeBrowser && s.Type != SessionTypeCLI {
54+ return ErrInvalidSession
55+ }
56+ if s.StartedAt.IsZero() {
57+ return ErrInvalidSession
58+ }
59+ return nil
60+}
61+
62+var ErrInvalidSession = errors.New("invalid session")
A internal/domain/entity/session_test.go +46 -0
@@ -0,0 +1,46 @@
1+package entity_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSession_ShortID(t *testing.T) {
13+ s := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3")}
14+ assert.Equal(t, "a7b3", s.ShortID())
15+
16+ s2 := &entity.Session{ID: entity.SessionID("abc")}
17+ assert.Equal(t, "abc", s2.ShortID())
18+}
19+
20+func TestSession_Validate(t *testing.T) {
21+ now := time.Now()
22+
23+ valid := &entity.Session{ID: "20251217_205106_a7b3", Type: entity.SessionTypeBrowser, StartedAt: now}
24+ require.NoError(t, valid.Validate())
25+
26+ missingID := &entity.Session{Type: entity.SessionTypeBrowser, StartedAt: now}
27+ require.ErrorIs(t, missingID.Validate(), entity.ErrInvalidSession)
28+
29+ badType := &entity.Session{ID: "20251217_205106_a7b3", Type: "nope", StartedAt: now}
30+ require.ErrorIs(t, badType.Validate(), entity.ErrInvalidSession)
31+
32+ missingStarted := &entity.Session{ID: "20251217_205106_a7b3", Type: entity.SessionTypeBrowser}
33+ require.ErrorIs(t, missingStarted.Validate(), entity.ErrInvalidSession)
34+}
35+
36+func TestSession_End(t *testing.T) {
37+ s := &entity.Session{ID: "x", Type: entity.SessionTypeBrowser, StartedAt: time.Now()}
38+ assert.True(t, s.IsActive())
39+
40+ endedAt := time.Date(2025, 12, 22, 0, 0, 0, 0, time.FixedZone("X", 3600))
41+ s.End(endedAt)
42+ assert.False(t, s.IsActive())
43+ if assert.NotNil(t, s.EndedAt) {
44+ assert.True(t, s.EndedAt.Equal(endedAt.UTC()))
45+ }
46+}
@@ -0,0 +1,46 @@
1+package entity_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSession_ShortID(t *testing.T) {
13+ s := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3")}
14+ assert.Equal(t, "a7b3", s.ShortID())
15+
16+ s2 := &entity.Session{ID: entity.SessionID("abc")}
17+ assert.Equal(t, "abc", s2.ShortID())
18+}
19+
20+func TestSession_Validate(t *testing.T) {
21+ now := time.Now()
22+
23+ valid := &entity.Session{ID: "20251217_205106_a7b3", Type: entity.SessionTypeBrowser, StartedAt: now}
24+ require.NoError(t, valid.Validate())
25+
26+ missingID := &entity.Session{Type: entity.SessionTypeBrowser, StartedAt: now}
27+ require.ErrorIs(t, missingID.Validate(), entity.ErrInvalidSession)
28+
29+ badType := &entity.Session{ID: "20251217_205106_a7b3", Type: "nope", StartedAt: now}
30+ require.ErrorIs(t, badType.Validate(), entity.ErrInvalidSession)
31+
32+ missingStarted := &entity.Session{ID: "20251217_205106_a7b3", Type: entity.SessionTypeBrowser}
33+ require.ErrorIs(t, missingStarted.Validate(), entity.ErrInvalidSession)
34+}
35+
36+func TestSession_End(t *testing.T) {
37+ s := &entity.Session{ID: "x", Type: entity.SessionTypeBrowser, StartedAt: time.Now()}
38+ assert.True(t, s.IsActive())
39+
40+ endedAt := time.Date(2025, 12, 22, 0, 0, 0, 0, time.FixedZone("X", 3600))
41+ s.End(endedAt)
42+ assert.False(t, s.IsActive())
43+ if assert.NotNil(t, s.EndedAt) {
44+ assert.True(t, s.EndedAt.Equal(endedAt.UTC()))
45+ }
46+}
M internal/domain/repository/mocks/mocks.go +345 -0
@@ -2719,6 +2719,351 @@ func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.C
27192719 return _c
27202720 }
27212721
2722+// NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2723+// The first argument is typically a *testing.T value.
2724+func NewMockSessionRepository(t interface {
2725+ mock.TestingT
2726+ Cleanup(func())
2727+}) *MockSessionRepository {
2728+ mock := &MockSessionRepository{}
2729+ mock.Mock.Test(t)
2730+
2731+ t.Cleanup(func() { mock.AssertExpectations(t) })
2732+
2733+ return mock
2734+}
2735+
2736+// MockSessionRepository is an autogenerated mock type for the SessionRepository type
2737+type MockSessionRepository struct {
2738+ mock.Mock
2739+}
2740+
2741+type MockSessionRepository_Expecter struct {
2742+ mock *mock.Mock
2743+}
2744+
2745+func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter {
2746+ return &MockSessionRepository_Expecter{mock: &_m.Mock}
2747+}
2748+
2749+// FindByID provides a mock function for the type MockSessionRepository
2750+func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
2751+ ret := _mock.Called(ctx, id)
2752+
2753+ if len(ret) == 0 {
2754+ panic("no return value specified for FindByID")
2755+ }
2756+
2757+ var r0 *entity.Session
2758+ var r1 error
2759+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.Session, error)); ok {
2760+ return returnFunc(ctx, id)
2761+ }
2762+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.Session); ok {
2763+ r0 = returnFunc(ctx, id)
2764+ } else {
2765+ if ret.Get(0) != nil {
2766+ r0 = ret.Get(0).(*entity.Session)
2767+ }
2768+ }
2769+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok {
2770+ r1 = returnFunc(ctx, id)
2771+ } else {
2772+ r1 = ret.Error(1)
2773+ }
2774+ return r0, r1
2775+}
2776+
2777+// MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID'
2778+type MockSessionRepository_FindByID_Call struct {
2779+ *mock.Call
2780+}
2781+
2782+// FindByID is a helper method to define mock.On call
2783+// - ctx context.Context
2784+// - id entity.SessionID
2785+func (_e *MockSessionRepository_Expecter) FindByID(ctx interface{}, id interface{}) *MockSessionRepository_FindByID_Call {
2786+ return &MockSessionRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)}
2787+}
2788+
2789+func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_FindByID_Call {
2790+ _c.Call.Run(func(args mock.Arguments) {
2791+ var arg0 context.Context
2792+ if args[0] != nil {
2793+ arg0 = args[0].(context.Context)
2794+ }
2795+ var arg1 entity.SessionID
2796+ if args[1] != nil {
2797+ arg1 = args[1].(entity.SessionID)
2798+ }
2799+ run(
2800+ arg0,
2801+ arg1,
2802+ )
2803+ })
2804+ return _c
2805+}
2806+
2807+func (_c *MockSessionRepository_FindByID_Call) Return(session *entity.Session, err error) *MockSessionRepository_FindByID_Call {
2808+ _c.Call.Return(session, err)
2809+ return _c
2810+}
2811+
2812+func (_c *MockSessionRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) (*entity.Session, error)) *MockSessionRepository_FindByID_Call {
2813+ _c.Call.Return(run)
2814+ return _c
2815+}
2816+
2817+// GetActive provides a mock function for the type MockSessionRepository
2818+func (_mock *MockSessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
2819+ ret := _mock.Called(ctx)
2820+
2821+ if len(ret) == 0 {
2822+ panic("no return value specified for GetActive")
2823+ }
2824+
2825+ var r0 *entity.Session
2826+ var r1 error
2827+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.Session, error)); ok {
2828+ return returnFunc(ctx)
2829+ }
2830+ if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.Session); ok {
2831+ r0 = returnFunc(ctx)
2832+ } else {
2833+ if ret.Get(0) != nil {
2834+ r0 = ret.Get(0).(*entity.Session)
2835+ }
2836+ }
2837+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2838+ r1 = returnFunc(ctx)
2839+ } else {
2840+ r1 = ret.Error(1)
2841+ }
2842+ return r0, r1
2843+}
2844+
2845+// MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive'
2846+type MockSessionRepository_GetActive_Call struct {
2847+ *mock.Call
2848+}
2849+
2850+// GetActive is a helper method to define mock.On call
2851+// - ctx context.Context
2852+func (_e *MockSessionRepository_Expecter) GetActive(ctx interface{}) *MockSessionRepository_GetActive_Call {
2853+ return &MockSessionRepository_GetActive_Call{Call: _e.mock.On("GetActive", ctx)}
2854+}
2855+
2856+func (_c *MockSessionRepository_GetActive_Call) Run(run func(ctx context.Context)) *MockSessionRepository_GetActive_Call {
2857+ _c.Call.Run(func(args mock.Arguments) {
2858+ var arg0 context.Context
2859+ if args[0] != nil {
2860+ arg0 = args[0].(context.Context)
2861+ }
2862+ run(
2863+ arg0,
2864+ )
2865+ })
2866+ return _c
2867+}
2868+
2869+func (_c *MockSessionRepository_GetActive_Call) Return(session *entity.Session, err error) *MockSessionRepository_GetActive_Call {
2870+ _c.Call.Return(session, err)
2871+ return _c
2872+}
2873+
2874+func (_c *MockSessionRepository_GetActive_Call) RunAndReturn(run func(ctx context.Context) (*entity.Session, error)) *MockSessionRepository_GetActive_Call {
2875+ _c.Call.Return(run)
2876+ return _c
2877+}
2878+
2879+// GetRecent provides a mock function for the type MockSessionRepository
2880+func (_mock *MockSessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
2881+ ret := _mock.Called(ctx, limit)
2882+
2883+ if len(ret) == 0 {
2884+ panic("no return value specified for GetRecent")
2885+ }
2886+
2887+ var r0 []*entity.Session
2888+ var r1 error
2889+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.Session, error)); ok {
2890+ return returnFunc(ctx, limit)
2891+ }
2892+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.Session); ok {
2893+ r0 = returnFunc(ctx, limit)
2894+ } else {
2895+ if ret.Get(0) != nil {
2896+ r0 = ret.Get(0).([]*entity.Session)
2897+ }
2898+ }
2899+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2900+ r1 = returnFunc(ctx, limit)
2901+ } else {
2902+ r1 = ret.Error(1)
2903+ }
2904+ return r0, r1
2905+}
2906+
2907+// MockSessionRepository_GetRecent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecent'
2908+type MockSessionRepository_GetRecent_Call struct {
2909+ *mock.Call
2910+}
2911+
2912+// GetRecent is a helper method to define mock.On call
2913+// - ctx context.Context
2914+// - limit int
2915+func (_e *MockSessionRepository_Expecter) GetRecent(ctx interface{}, limit interface{}) *MockSessionRepository_GetRecent_Call {
2916+ return &MockSessionRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit)}
2917+}
2918+
2919+func (_c *MockSessionRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int)) *MockSessionRepository_GetRecent_Call {
2920+ _c.Call.Run(func(args mock.Arguments) {
2921+ var arg0 context.Context
2922+ if args[0] != nil {
2923+ arg0 = args[0].(context.Context)
2924+ }
2925+ var arg1 int
2926+ if args[1] != nil {
2927+ arg1 = args[1].(int)
2928+ }
2929+ run(
2930+ arg0,
2931+ arg1,
2932+ )
2933+ })
2934+ return _c
2935+}
2936+
2937+func (_c *MockSessionRepository_GetRecent_Call) Return(sessions []*entity.Session, err error) *MockSessionRepository_GetRecent_Call {
2938+ _c.Call.Return(sessions, err)
2939+ return _c
2940+}
2941+
2942+func (_c *MockSessionRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int) ([]*entity.Session, error)) *MockSessionRepository_GetRecent_Call {
2943+ _c.Call.Return(run)
2944+ return _c
2945+}
2946+
2947+// MarkEnded provides a mock function for the type MockSessionRepository
2948+func (_mock *MockSessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
2949+ ret := _mock.Called(ctx, id, endedAt)
2950+
2951+ if len(ret) == 0 {
2952+ panic("no return value specified for MarkEnded")
2953+ }
2954+
2955+ var r0 error
2956+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, time.Time) error); ok {
2957+ r0 = returnFunc(ctx, id, endedAt)
2958+ } else {
2959+ r0 = ret.Error(0)
2960+ }
2961+ return r0
2962+}
2963+
2964+// MockSessionRepository_MarkEnded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkEnded'
2965+type MockSessionRepository_MarkEnded_Call struct {
2966+ *mock.Call
2967+}
2968+
2969+// MarkEnded is a helper method to define mock.On call
2970+// - ctx context.Context
2971+// - id entity.SessionID
2972+// - endedAt time.Time
2973+func (_e *MockSessionRepository_Expecter) MarkEnded(ctx interface{}, id interface{}, endedAt interface{}) *MockSessionRepository_MarkEnded_Call {
2974+ return &MockSessionRepository_MarkEnded_Call{Call: _e.mock.On("MarkEnded", ctx, id, endedAt)}
2975+}
2976+
2977+func (_c *MockSessionRepository_MarkEnded_Call) Run(run func(ctx context.Context, id entity.SessionID, endedAt time.Time)) *MockSessionRepository_MarkEnded_Call {
2978+ _c.Call.Run(func(args mock.Arguments) {
2979+ var arg0 context.Context
2980+ if args[0] != nil {
2981+ arg0 = args[0].(context.Context)
2982+ }
2983+ var arg1 entity.SessionID
2984+ if args[1] != nil {
2985+ arg1 = args[1].(entity.SessionID)
2986+ }
2987+ var arg2 time.Time
2988+ if args[2] != nil {
2989+ arg2 = args[2].(time.Time)
2990+ }
2991+ run(
2992+ arg0,
2993+ arg1,
2994+ arg2,
2995+ )
2996+ })
2997+ return _c
2998+}
2999+
3000+func (_c *MockSessionRepository_MarkEnded_Call) Return(err error) *MockSessionRepository_MarkEnded_Call {
3001+ _c.Call.Return(err)
3002+ return _c
3003+}
3004+
3005+func (_c *MockSessionRepository_MarkEnded_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID, endedAt time.Time) error) *MockSessionRepository_MarkEnded_Call {
3006+ _c.Call.Return(run)
3007+ return _c
3008+}
3009+
3010+// Save provides a mock function for the type MockSessionRepository
3011+func (_mock *MockSessionRepository) Save(ctx context.Context, session *entity.Session) error {
3012+ ret := _mock.Called(ctx, session)
3013+
3014+ if len(ret) == 0 {
3015+ panic("no return value specified for Save")
3016+ }
3017+
3018+ var r0 error
3019+ if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.Session) error); ok {
3020+ r0 = returnFunc(ctx, session)
3021+ } else {
3022+ r0 = ret.Error(0)
3023+ }
3024+ return r0
3025+}
3026+
3027+// MockSessionRepository_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
3028+type MockSessionRepository_Save_Call struct {
3029+ *mock.Call
3030+}
3031+
3032+// Save is a helper method to define mock.On call
3033+// - ctx context.Context
3034+// - session *entity.Session
3035+func (_e *MockSessionRepository_Expecter) Save(ctx interface{}, session interface{}) *MockSessionRepository_Save_Call {
3036+ return &MockSessionRepository_Save_Call{Call: _e.mock.On("Save", ctx, session)}
3037+}
3038+
3039+func (_c *MockSessionRepository_Save_Call) Run(run func(ctx context.Context, session *entity.Session)) *MockSessionRepository_Save_Call {
3040+ _c.Call.Run(func(args mock.Arguments) {
3041+ var arg0 context.Context
3042+ if args[0] != nil {
3043+ arg0 = args[0].(context.Context)
3044+ }
3045+ var arg1 *entity.Session
3046+ if args[1] != nil {
3047+ arg1 = args[1].(*entity.Session)
3048+ }
3049+ run(
3050+ arg0,
3051+ arg1,
3052+ )
3053+ })
3054+ return _c
3055+}
3056+
3057+func (_c *MockSessionRepository_Save_Call) Return(err error) *MockSessionRepository_Save_Call {
3058+ _c.Call.Return(err)
3059+ return _c
3060+}
3061+
3062+func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Context, session *entity.Session) error) *MockSessionRepository_Save_Call {
3063+ _c.Call.Return(run)
3064+ return _c
3065+}
3066+
27223067 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
27233068 // The first argument is typically a *testing.T value.
27243069 func NewMockZoomRepository(t interface {
@@ -2719,6 +2719,351 @@ func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.C
2719 return _c2719 return _c
2720 }2720 }
2721 2721
2722+// NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2723+// The first argument is typically a *testing.T value.
2724+func NewMockSessionRepository(t interface {
2725+ mock.TestingT
2726+ Cleanup(func())
2727+}) *MockSessionRepository {
2728+ mock := &MockSessionRepository{}
2729+ mock.Mock.Test(t)
2730+
2731+ t.Cleanup(func() { mock.AssertExpectations(t) })
2732+
2733+ return mock
2734+}
2735+
2736+// MockSessionRepository is an autogenerated mock type for the SessionRepository type
2737+type MockSessionRepository struct {
2738+ mock.Mock
2739+}
2740+
2741+type MockSessionRepository_Expecter struct {
2742+ mock *mock.Mock
2743+}
2744+
2745+func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter {
2746+ return &MockSessionRepository_Expecter{mock: &_m.Mock}
2747+}
2748+
2749+// FindByID provides a mock function for the type MockSessionRepository
2750+func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
2751+ ret := _mock.Called(ctx, id)
2752+
2753+ if len(ret) == 0 {
2754+ panic("no return value specified for FindByID")
2755+ }
2756+
2757+ var r0 *entity.Session
2758+ var r1 error
2759+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.Session, error)); ok {
2760+ return returnFunc(ctx, id)
2761+ }
2762+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.Session); ok {
2763+ r0 = returnFunc(ctx, id)
2764+ } else {
2765+ if ret.Get(0) != nil {
2766+ r0 = ret.Get(0).(*entity.Session)
2767+ }
2768+ }
2769+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok {
2770+ r1 = returnFunc(ctx, id)
2771+ } else {
2772+ r1 = ret.Error(1)
2773+ }
2774+ return r0, r1
2775+}
2776+
2777+// MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID'
2778+type MockSessionRepository_FindByID_Call struct {
2779+ *mock.Call
2780+}
2781+
2782+// FindByID is a helper method to define mock.On call
2783+// - ctx context.Context
2784+// - id entity.SessionID
2785+func (_e *MockSessionRepository_Expecter) FindByID(ctx interface{}, id interface{}) *MockSessionRepository_FindByID_Call {
2786+ return &MockSessionRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)}
2787+}
2788+
2789+func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_FindByID_Call {
2790+ _c.Call.Run(func(args mock.Arguments) {
2791+ var arg0 context.Context
2792+ if args[0] != nil {
2793+ arg0 = args[0].(context.Context)
2794+ }
2795+ var arg1 entity.SessionID
2796+ if args[1] != nil {
2797+ arg1 = args[1].(entity.SessionID)
2798+ }
2799+ run(
2800+ arg0,
2801+ arg1,
2802+ )
2803+ })
2804+ return _c
2805+}
2806+
2807+func (_c *MockSessionRepository_FindByID_Call) Return(session *entity.Session, err error) *MockSessionRepository_FindByID_Call {
2808+ _c.Call.Return(session, err)
2809+ return _c
2810+}
2811+
2812+func (_c *MockSessionRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) (*entity.Session, error)) *MockSessionRepository_FindByID_Call {
2813+ _c.Call.Return(run)
2814+ return _c
2815+}
2816+
2817+// GetActive provides a mock function for the type MockSessionRepository
2818+func (_mock *MockSessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
2819+ ret := _mock.Called(ctx)
2820+
2821+ if len(ret) == 0 {
2822+ panic("no return value specified for GetActive")
2823+ }
2824+
2825+ var r0 *entity.Session
2826+ var r1 error
2827+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.Session, error)); ok {
2828+ return returnFunc(ctx)
2829+ }
2830+ if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.Session); ok {
2831+ r0 = returnFunc(ctx)
2832+ } else {
2833+ if ret.Get(0) != nil {
2834+ r0 = ret.Get(0).(*entity.Session)
2835+ }
2836+ }
2837+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2838+ r1 = returnFunc(ctx)
2839+ } else {
2840+ r1 = ret.Error(1)
2841+ }
2842+ return r0, r1
2843+}
2844+
2845+// MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive'
2846+type MockSessionRepository_GetActive_Call struct {
2847+ *mock.Call
2848+}
2849+
2850+// GetActive is a helper method to define mock.On call
2851+// - ctx context.Context
2852+func (_e *MockSessionRepository_Expecter) GetActive(ctx interface{}) *MockSessionRepository_GetActive_Call {
2853+ return &MockSessionRepository_GetActive_Call{Call: _e.mock.On("GetActive", ctx)}
2854+}
2855+
2856+func (_c *MockSessionRepository_GetActive_Call) Run(run func(ctx context.Context)) *MockSessionRepository_GetActive_Call {
2857+ _c.Call.Run(func(args mock.Arguments) {
2858+ var arg0 context.Context
2859+ if args[0] != nil {
2860+ arg0 = args[0].(context.Context)
2861+ }
2862+ run(
2863+ arg0,
2864+ )
2865+ })
2866+ return _c
2867+}
2868+
2869+func (_c *MockSessionRepository_GetActive_Call) Return(session *entity.Session, err error) *MockSessionRepository_GetActive_Call {
2870+ _c.Call.Return(session, err)
2871+ return _c
2872+}
2873+
2874+func (_c *MockSessionRepository_GetActive_Call) RunAndReturn(run func(ctx context.Context) (*entity.Session, error)) *MockSessionRepository_GetActive_Call {
2875+ _c.Call.Return(run)
2876+ return _c
2877+}
2878+
2879+// GetRecent provides a mock function for the type MockSessionRepository
2880+func (_mock *MockSessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
2881+ ret := _mock.Called(ctx, limit)
2882+
2883+ if len(ret) == 0 {
2884+ panic("no return value specified for GetRecent")
2885+ }
2886+
2887+ var r0 []*entity.Session
2888+ var r1 error
2889+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.Session, error)); ok {
2890+ return returnFunc(ctx, limit)
2891+ }
2892+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.Session); ok {
2893+ r0 = returnFunc(ctx, limit)
2894+ } else {
2895+ if ret.Get(0) != nil {
2896+ r0 = ret.Get(0).([]*entity.Session)
2897+ }
2898+ }
2899+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2900+ r1 = returnFunc(ctx, limit)
2901+ } else {
2902+ r1 = ret.Error(1)
2903+ }
2904+ return r0, r1
2905+}
2906+
2907+// MockSessionRepository_GetRecent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecent'
2908+type MockSessionRepository_GetRecent_Call struct {
2909+ *mock.Call
2910+}
2911+
2912+// GetRecent is a helper method to define mock.On call
2913+// - ctx context.Context
2914+// - limit int
2915+func (_e *MockSessionRepository_Expecter) GetRecent(ctx interface{}, limit interface{}) *MockSessionRepository_GetRecent_Call {
2916+ return &MockSessionRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit)}
2917+}
2918+
2919+func (_c *MockSessionRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int)) *MockSessionRepository_GetRecent_Call {
2920+ _c.Call.Run(func(args mock.Arguments) {
2921+ var arg0 context.Context
2922+ if args[0] != nil {
2923+ arg0 = args[0].(context.Context)
2924+ }
2925+ var arg1 int
2926+ if args[1] != nil {
2927+ arg1 = args[1].(int)
2928+ }
2929+ run(
2930+ arg0,
2931+ arg1,
2932+ )
2933+ })
2934+ return _c
2935+}
2936+
2937+func (_c *MockSessionRepository_GetRecent_Call) Return(sessions []*entity.Session, err error) *MockSessionRepository_GetRecent_Call {
2938+ _c.Call.Return(sessions, err)
2939+ return _c
2940+}
2941+
2942+func (_c *MockSessionRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int) ([]*entity.Session, error)) *MockSessionRepository_GetRecent_Call {
2943+ _c.Call.Return(run)
2944+ return _c
2945+}
2946+
2947+// MarkEnded provides a mock function for the type MockSessionRepository
2948+func (_mock *MockSessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
2949+ ret := _mock.Called(ctx, id, endedAt)
2950+
2951+ if len(ret) == 0 {
2952+ panic("no return value specified for MarkEnded")
2953+ }
2954+
2955+ var r0 error
2956+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, time.Time) error); ok {
2957+ r0 = returnFunc(ctx, id, endedAt)
2958+ } else {
2959+ r0 = ret.Error(0)
2960+ }
2961+ return r0
2962+}
2963+
2964+// MockSessionRepository_MarkEnded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkEnded'
2965+type MockSessionRepository_MarkEnded_Call struct {
2966+ *mock.Call
2967+}
2968+
2969+// MarkEnded is a helper method to define mock.On call
2970+// - ctx context.Context
2971+// - id entity.SessionID
2972+// - endedAt time.Time
2973+func (_e *MockSessionRepository_Expecter) MarkEnded(ctx interface{}, id interface{}, endedAt interface{}) *MockSessionRepository_MarkEnded_Call {
2974+ return &MockSessionRepository_MarkEnded_Call{Call: _e.mock.On("MarkEnded", ctx, id, endedAt)}
2975+}
2976+
2977+func (_c *MockSessionRepository_MarkEnded_Call) Run(run func(ctx context.Context, id entity.SessionID, endedAt time.Time)) *MockSessionRepository_MarkEnded_Call {
2978+ _c.Call.Run(func(args mock.Arguments) {
2979+ var arg0 context.Context
2980+ if args[0] != nil {
2981+ arg0 = args[0].(context.Context)
2982+ }
2983+ var arg1 entity.SessionID
2984+ if args[1] != nil {
2985+ arg1 = args[1].(entity.SessionID)
2986+ }
2987+ var arg2 time.Time
2988+ if args[2] != nil {
2989+ arg2 = args[2].(time.Time)
2990+ }
2991+ run(
2992+ arg0,
2993+ arg1,
2994+ arg2,
2995+ )
2996+ })
2997+ return _c
2998+}
2999+
3000+func (_c *MockSessionRepository_MarkEnded_Call) Return(err error) *MockSessionRepository_MarkEnded_Call {
3001+ _c.Call.Return(err)
3002+ return _c
3003+}
3004+
3005+func (_c *MockSessionRepository_MarkEnded_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID, endedAt time.Time) error) *MockSessionRepository_MarkEnded_Call {
3006+ _c.Call.Return(run)
3007+ return _c
3008+}
3009+
3010+// Save provides a mock function for the type MockSessionRepository
3011+func (_mock *MockSessionRepository) Save(ctx context.Context, session *entity.Session) error {
3012+ ret := _mock.Called(ctx, session)
3013+
3014+ if len(ret) == 0 {
3015+ panic("no return value specified for Save")
3016+ }
3017+
3018+ var r0 error
3019+ if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.Session) error); ok {
3020+ r0 = returnFunc(ctx, session)
3021+ } else {
3022+ r0 = ret.Error(0)
3023+ }
3024+ return r0
3025+}
3026+
3027+// MockSessionRepository_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
3028+type MockSessionRepository_Save_Call struct {
3029+ *mock.Call
3030+}
3031+
3032+// Save is a helper method to define mock.On call
3033+// - ctx context.Context
3034+// - session *entity.Session
3035+func (_e *MockSessionRepository_Expecter) Save(ctx interface{}, session interface{}) *MockSessionRepository_Save_Call {
3036+ return &MockSessionRepository_Save_Call{Call: _e.mock.On("Save", ctx, session)}
3037+}
3038+
3039+func (_c *MockSessionRepository_Save_Call) Run(run func(ctx context.Context, session *entity.Session)) *MockSessionRepository_Save_Call {
3040+ _c.Call.Run(func(args mock.Arguments) {
3041+ var arg0 context.Context
3042+ if args[0] != nil {
3043+ arg0 = args[0].(context.Context)
3044+ }
3045+ var arg1 *entity.Session
3046+ if args[1] != nil {
3047+ arg1 = args[1].(*entity.Session)
3048+ }
3049+ run(
3050+ arg0,
3051+ arg1,
3052+ )
3053+ })
3054+ return _c
3055+}
3056+
3057+func (_c *MockSessionRepository_Save_Call) Return(err error) *MockSessionRepository_Save_Call {
3058+ _c.Call.Return(err)
3059+ return _c
3060+}
3061+
3062+func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Context, session *entity.Session) error) *MockSessionRepository_Save_Call {
3063+ _c.Call.Return(run)
3064+ return _c
3065+}
3066+
2722 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.3067 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2723 // The first argument is typically a *testing.T value.3068 // The first argument is typically a *testing.T value.
2724 func NewMockZoomRepository(t interface {3069 func NewMockZoomRepository(t interface {
A internal/domain/repository/session.go +19 -0
@@ -0,0 +1,19 @@
1+package repository
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// SessionRepository persists session metadata.
11+type SessionRepository interface {
12+ Save(ctx context.Context, session *entity.Session) error
13+ FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error)
14+
15+ // GetActive returns the most recent active browser session (if any).
16+ GetActive(ctx context.Context) (*entity.Session, error)
17+ GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
18+ MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error
19+}
@@ -0,0 +1,19 @@
1+package repository
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// SessionRepository persists session metadata.
11+type SessionRepository interface {
12+ Save(ctx context.Context, session *entity.Session) error
13+ FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error)
14+
15+ // GetActive returns the most recent active browser session (if any).
16+ GetActive(ctx context.Context) (*entity.Session, error)
17+ GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
18+ MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error
19+}
A internal/infrastructure/logging/session_logger.go +43 -0
@@ -0,0 +1,43 @@
1+package logging
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ corelogging "github.com/bnema/dumber/internal/logging"
9+ "github.com/rs/zerolog"
10+)
11+
12+type SessionLoggerAdapter struct{}
13+
14+var _ port.SessionLogger = (*SessionLoggerAdapter)(nil)
15+
16+func NewSessionLoggerAdapter() *SessionLoggerAdapter {
17+ return &SessionLoggerAdapter{}
18+}
19+
20+func (*SessionLoggerAdapter) CreateLogger(
21+ _ context.Context,
22+ sessionID entity.SessionID,
23+ cfg port.SessionLogConfig,
24+) (zerolog.Logger, func(), error) {
25+ logger, cleanup, err := corelogging.NewWithFile(
26+ corelogging.Config{
27+ Level: corelogging.ParseLevel(cfg.Level),
28+ Format: cfg.Format,
29+ TimeFormat: cfg.TimeFormat,
30+ },
31+ corelogging.FileConfig{
32+ Enabled: cfg.EnableFileLog,
33+ LogDir: cfg.LogDir,
34+ SessionID: string(sessionID),
35+ WriteToStderr: cfg.WriteToStderr,
36+ },
37+ )
38+ if err != nil {
39+ fallback := corelogging.NewFromConfigValues(cfg.Level, cfg.Format)
40+ return fallback, func() {}, err
41+ }
42+ return logger, cleanup, nil
43+}
@@ -0,0 +1,43 @@
1+package logging
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ corelogging "github.com/bnema/dumber/internal/logging"
9+ "github.com/rs/zerolog"
10+)
11+
12+type SessionLoggerAdapter struct{}
13+
14+var _ port.SessionLogger = (*SessionLoggerAdapter)(nil)
15+
16+func NewSessionLoggerAdapter() *SessionLoggerAdapter {
17+ return &SessionLoggerAdapter{}
18+}
19+
20+func (*SessionLoggerAdapter) CreateLogger(
21+ _ context.Context,
22+ sessionID entity.SessionID,
23+ cfg port.SessionLogConfig,
24+) (zerolog.Logger, func(), error) {
25+ logger, cleanup, err := corelogging.NewWithFile(
26+ corelogging.Config{
27+ Level: corelogging.ParseLevel(cfg.Level),
28+ Format: cfg.Format,
29+ TimeFormat: cfg.TimeFormat,
30+ },
31+ corelogging.FileConfig{
32+ Enabled: cfg.EnableFileLog,
33+ LogDir: cfg.LogDir,
34+ SessionID: string(sessionID),
35+ WriteToStderr: cfg.WriteToStderr,
36+ },
37+ )
38+ if err != nil {
39+ fallback := corelogging.NewFromConfigValues(cfg.Level, cfg.Format)
40+ return fallback, func() {}, err
41+ }
42+ return logger, cleanup, nil
43+}
A internal/infrastructure/persistence/sqlite/migrations/005_sessions.sql +13 -0
@@ -0,0 +1,13 @@
1+-- +goose Up
2+CREATE TABLE sessions (
3+ id TEXT PRIMARY KEY,
4+ type TEXT NOT NULL CHECK (type IN ('browser', 'cli')),
5+ started_at TIMESTAMP NOT NULL,
6+ ended_at TIMESTAMP
7+);
8+
9+CREATE INDEX idx_sessions_started_at ON sessions(started_at DESC);
10+CREATE INDEX idx_sessions_active ON sessions(started_at DESC) WHERE ended_at IS NULL;
11+
12+-- +goose Down
13+DROP TABLE sessions;
@@ -0,0 +1,13 @@
1+-- +goose Up
2+CREATE TABLE sessions (
3+ id TEXT PRIMARY KEY,
4+ type TEXT NOT NULL CHECK (type IN ('browser', 'cli')),
5+ started_at TIMESTAMP NOT NULL,
6+ ended_at TIMESTAMP
7+);
8+
9+CREATE INDEX idx_sessions_started_at ON sessions(started_at DESC);
10+CREATE INDEX idx_sessions_active ON sessions(started_at DESC) WHERE ended_at IS NULL;
11+
12+-- +goose Down
13+DROP TABLE sessions;
A internal/infrastructure/persistence/sqlite/queries/sessions.sql +22 -0
@@ -0,0 +1,22 @@
1+-- name: InsertSession :exec
2+INSERT INTO sessions (id, type, started_at, ended_at)
3+VALUES (?, ?, ?, ?);
4+
5+-- name: GetSessionByID :one
6+SELECT * FROM sessions WHERE id = ? LIMIT 1;
7+
8+-- name: GetActiveBrowserSession :one
9+SELECT * FROM sessions
10+WHERE type = 'browser' AND ended_at IS NULL
11+ORDER BY started_at DESC
12+LIMIT 1;
13+
14+-- name: GetRecentSessions :many
15+SELECT * FROM sessions
16+ORDER BY started_at DESC
17+LIMIT ?;
18+
19+-- name: MarkSessionEnded :exec
20+UPDATE sessions
21+SET ended_at = ?
22+WHERE id = ?;
@@ -0,0 +1,22 @@
1+-- name: InsertSession :exec
2+INSERT INTO sessions (id, type, started_at, ended_at)
3+VALUES (?, ?, ?, ?);
4+
5+-- name: GetSessionByID :one
6+SELECT * FROM sessions WHERE id = ? LIMIT 1;
7+
8+-- name: GetActiveBrowserSession :one
9+SELECT * FROM sessions
10+WHERE type = 'browser' AND ended_at IS NULL
11+ORDER BY started_at DESC
12+LIMIT 1;
13+
14+-- name: GetRecentSessions :many
15+SELECT * FROM sessions
16+ORDER BY started_at DESC
17+LIMIT ?;
18+
19+-- name: MarkSessionEnded :exec
20+UPDATE sessions
21+SET ended_at = ?
22+WHERE id = ?;
A internal/infrastructure/persistence/sqlite/session_repo.go +103 -0
@@ -0,0 +1,103 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type sessionRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+func NewSessionRepository(db *sql.DB) repository.SessionRepository {
20+ return &sessionRepo{queries: sqlc.New(db)}
21+}
22+
23+func (r *sessionRepo) Save(ctx context.Context, session *entity.Session) error {
24+ log := logging.FromContext(ctx)
25+ if err := session.Validate(); err != nil {
26+ return err
27+ }
28+
29+ log.Debug().Str("session", string(session.ID)).Str("type", string(session.Type)).Msg("saving session")
30+
31+ var endedAt sql.NullTime
32+ if session.EndedAt != nil {
33+ endedAt = sql.NullTime{Time: session.EndedAt.UTC(), Valid: true}
34+ }
35+
36+ return r.queries.InsertSession(ctx, sqlc.InsertSessionParams{
37+ ID: string(session.ID),
38+ Type: string(session.Type),
39+ StartedAt: session.StartedAt.UTC(),
40+ EndedAt: endedAt,
41+ })
42+}
43+
44+func (r *sessionRepo) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
45+ row, err := r.queries.GetSessionByID(ctx, string(id))
46+ if err != nil {
47+ if errors.Is(err, sql.ErrNoRows) {
48+ return nil, nil
49+ }
50+ return nil, err
51+ }
52+ return sessionFromRow(row), nil
53+}
54+
55+func (r *sessionRepo) GetActive(ctx context.Context) (*entity.Session, error) {
56+ row, err := r.queries.GetActiveBrowserSession(ctx)
57+ if err != nil {
58+ if errors.Is(err, sql.ErrNoRows) {
59+ return nil, nil
60+ }
61+ return nil, err
62+ }
63+ return sessionFromRow(row), nil
64+}
65+
66+func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
67+ if limit <= 0 {
68+ limit = 20
69+ }
70+ rows, err := r.queries.GetRecentSessions(ctx, int64(limit))
71+ if err != nil {
72+ return nil, err
73+ }
74+
75+ sessions := make([]*entity.Session, len(rows))
76+ for i := range rows {
77+ sessions[i] = sessionFromRow(rows[i])
78+ }
79+ return sessions, nil
80+}
81+
82+func (r *sessionRepo) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
83+ endedAt = endedAt.UTC()
84+ return r.queries.MarkSessionEnded(ctx, sqlc.MarkSessionEndedParams{
85+ EndedAt: sql.NullTime{Time: endedAt, Valid: true},
86+ ID: string(id),
87+ })
88+}
89+
90+func sessionFromRow(row sqlc.Session) *entity.Session {
91+ var endedAt *time.Time
92+ if row.EndedAt.Valid {
93+ t := row.EndedAt.Time.UTC()
94+ endedAt = &t
95+ }
96+
97+ return &entity.Session{
98+ ID: entity.SessionID(row.ID),
99+ Type: entity.SessionType(row.Type),
100+ StartedAt: row.StartedAt.UTC(),
101+ EndedAt: endedAt,
102+ }
103+}
@@ -0,0 +1,103 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type sessionRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+func NewSessionRepository(db *sql.DB) repository.SessionRepository {
20+ return &sessionRepo{queries: sqlc.New(db)}
21+}
22+
23+func (r *sessionRepo) Save(ctx context.Context, session *entity.Session) error {
24+ log := logging.FromContext(ctx)
25+ if err := session.Validate(); err != nil {
26+ return err
27+ }
28+
29+ log.Debug().Str("session", string(session.ID)).Str("type", string(session.Type)).Msg("saving session")
30+
31+ var endedAt sql.NullTime
32+ if session.EndedAt != nil {
33+ endedAt = sql.NullTime{Time: session.EndedAt.UTC(), Valid: true}
34+ }
35+
36+ return r.queries.InsertSession(ctx, sqlc.InsertSessionParams{
37+ ID: string(session.ID),
38+ Type: string(session.Type),
39+ StartedAt: session.StartedAt.UTC(),
40+ EndedAt: endedAt,
41+ })
42+}
43+
44+func (r *sessionRepo) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
45+ row, err := r.queries.GetSessionByID(ctx, string(id))
46+ if err != nil {
47+ if errors.Is(err, sql.ErrNoRows) {
48+ return nil, nil
49+ }
50+ return nil, err
51+ }
52+ return sessionFromRow(row), nil
53+}
54+
55+func (r *sessionRepo) GetActive(ctx context.Context) (*entity.Session, error) {
56+ row, err := r.queries.GetActiveBrowserSession(ctx)
57+ if err != nil {
58+ if errors.Is(err, sql.ErrNoRows) {
59+ return nil, nil
60+ }
61+ return nil, err
62+ }
63+ return sessionFromRow(row), nil
64+}
65+
66+func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
67+ if limit <= 0 {
68+ limit = 20
69+ }
70+ rows, err := r.queries.GetRecentSessions(ctx, int64(limit))
71+ if err != nil {
72+ return nil, err
73+ }
74+
75+ sessions := make([]*entity.Session, len(rows))
76+ for i := range rows {
77+ sessions[i] = sessionFromRow(rows[i])
78+ }
79+ return sessions, nil
80+}
81+
82+func (r *sessionRepo) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
83+ endedAt = endedAt.UTC()
84+ return r.queries.MarkSessionEnded(ctx, sqlc.MarkSessionEndedParams{
85+ EndedAt: sql.NullTime{Time: endedAt, Valid: true},
86+ ID: string(id),
87+ })
88+}
89+
90+func sessionFromRow(row sqlc.Session) *entity.Session {
91+ var endedAt *time.Time
92+ if row.EndedAt.Valid {
93+ t := row.EndedAt.Time.UTC()
94+ endedAt = &t
95+ }
96+
97+ return &entity.Session{
98+ ID: entity.SessionID(row.ID),
99+ Type: entity.SessionType(row.Type),
100+ StartedAt: row.StartedAt.UTC(),
101+ EndedAt: endedAt,
102+ }
103+}
A internal/infrastructure/persistence/sqlite/session_repo_test.go +64 -0
@@ -0,0 +1,64 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func testCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestSessionRepository_CRUD(t *testing.T) {
22+ ctx := testCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewSessionRepository(db)
30+
31+ startedAt := time.Date(2025, 12, 22, 8, 0, 0, 0, time.UTC)
32+ s := &entity.Session{ID: "20251222_080000_abcd", Type: entity.SessionTypeBrowser, StartedAt: startedAt}
33+ require.NoError(t, repo.Save(ctx, s))
34+
35+ got, err := repo.FindByID(ctx, s.ID)
36+ require.NoError(t, err)
37+ require.NotNil(t, got)
38+ assert.Equal(t, s.ID, got.ID)
39+ assert.Equal(t, s.Type, got.Type)
40+ assert.True(t, got.StartedAt.Equal(startedAt))
41+ assert.Nil(t, got.EndedAt)
42+
43+ active, err := repo.GetActive(ctx)
44+ require.NoError(t, err)
45+ require.NotNil(t, active)
46+ assert.Equal(t, s.ID, active.ID)
47+
48+ endedAt := time.Date(2025, 12, 22, 9, 0, 0, 0, time.UTC)
49+ require.NoError(t, repo.MarkEnded(ctx, s.ID, endedAt))
50+
51+ active2, err := repo.GetActive(ctx)
52+ require.NoError(t, err)
53+ assert.Nil(t, active2)
54+
55+ got2, err := repo.FindByID(ctx, s.ID)
56+ require.NoError(t, err)
57+ require.NotNil(t, got2.EndedAt)
58+ assert.True(t, got2.EndedAt.Equal(endedAt))
59+
60+ recent, err := repo.GetRecent(ctx, 10)
61+ require.NoError(t, err)
62+ require.Len(t, recent, 1)
63+ assert.Equal(t, s.ID, recent[0].ID)
64+}
@@ -0,0 +1,64 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func testCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestSessionRepository_CRUD(t *testing.T) {
22+ ctx := testCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewSessionRepository(db)
30+
31+ startedAt := time.Date(2025, 12, 22, 8, 0, 0, 0, time.UTC)
32+ s := &entity.Session{ID: "20251222_080000_abcd", Type: entity.SessionTypeBrowser, StartedAt: startedAt}
33+ require.NoError(t, repo.Save(ctx, s))
34+
35+ got, err := repo.FindByID(ctx, s.ID)
36+ require.NoError(t, err)
37+ require.NotNil(t, got)
38+ assert.Equal(t, s.ID, got.ID)
39+ assert.Equal(t, s.Type, got.Type)
40+ assert.True(t, got.StartedAt.Equal(startedAt))
41+ assert.Nil(t, got.EndedAt)
42+
43+ active, err := repo.GetActive(ctx)
44+ require.NoError(t, err)
45+ require.NotNil(t, active)
46+ assert.Equal(t, s.ID, active.ID)
47+
48+ endedAt := time.Date(2025, 12, 22, 9, 0, 0, 0, time.UTC)
49+ require.NoError(t, repo.MarkEnded(ctx, s.ID, endedAt))
50+
51+ active2, err := repo.GetActive(ctx)
52+ require.NoError(t, err)
53+ assert.Nil(t, active2)
54+
55+ got2, err := repo.FindByID(ctx, s.ID)
56+ require.NoError(t, err)
57+ require.NotNil(t, got2.EndedAt)
58+ assert.True(t, got2.EndedAt.Equal(endedAt))
59+
60+ recent, err := repo.GetRecent(ctx, 10)
61+ require.NoError(t, err)
62+ require.Len(t, recent, 1)
63+ assert.Equal(t, s.ID, recent[0].ID)
64+}
M internal/infrastructure/persistence/sqlite/sqlc/models.go +8 -0
@@ -6,6 +6,7 @@ package sqlc
66
77 import (
88 "database/sql"
9+ "time"
910 )
1011
1112 type ContentWhitelist struct {
@@ -62,6 +63,13 @@ type HistoryFt struct {
6263 Title string `json:"title"`
6364 }
6465
66+type Session struct {
67+ ID string `json:"id"`
68+ Type string `json:"type"`
69+ StartedAt time.Time `json:"started_at"`
70+ EndedAt sql.NullTime `json:"ended_at"`
71+}
72+
6573 type ZoomLevel struct {
6674 Domain string `json:"domain"`
6775 ZoomFactor float64 `json:"zoom_factor"`
@@ -6,6 +6,7 @@ package sqlc
6 6
7 import (7 import (
8 "database/sql"8 "database/sql"
9+ "time"
9 )10 )
10 11
11 type ContentWhitelist struct {12 type ContentWhitelist struct {
@@ -62,6 +63,13 @@ type HistoryFt struct {
62 Title string `json:"title"`63 Title string `json:"title"`
63 }64 }
64 65
66+type Session struct {
67+ ID string `json:"id"`
68+ Type string `json:"type"`
69+ StartedAt time.Time `json:"started_at"`
70+ EndedAt sql.NullTime `json:"ended_at"`
71+}
72+
65 type ZoomLevel struct {73 type ZoomLevel struct {
66 Domain string `json:"domain"`74 Domain string `json:"domain"`
67 ZoomFactor float64 `json:"zoom_factor"`75 ZoomFactor float64 `json:"zoom_factor"`
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
@@ -23,6 +23,7 @@ type Querier interface {
2323 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
2424 DeleteTag(ctx context.Context, id int64) error
2525 DeleteZoomLevel(ctx context.Context, domain string) error
26+ GetActiveBrowserSession(ctx context.Context) (Session, error)
2627 GetAllFavorites(ctx context.Context) ([]Favorite, error)
2728 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
2829 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
@@ -40,14 +41,18 @@ type Querier interface {
4041 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
4142 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
4243 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
44+ GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
4345 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
46+ GetSessionByID(ctx context.Context, id string) (Session, error)
4447 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
4548 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
4649 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
4750 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
4851 IncrementVisitCount(ctx context.Context, url string) error
52+ InsertSession(ctx context.Context, arg InsertSessionParams) error
4953 IsWhitelisted(ctx context.Context, domain string) (int64, error)
5054 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
55+ MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error
5156 RemoveFromWhitelist(ctx context.Context, domain string) error
5257 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
5358 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
@@ -23,6 +23,7 @@ type Querier interface {
23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
24 DeleteTag(ctx context.Context, id int64) error24 DeleteTag(ctx context.Context, id int64) error
25 DeleteZoomLevel(ctx context.Context, domain string) error25 DeleteZoomLevel(ctx context.Context, domain string) error
26+ GetActiveBrowserSession(ctx context.Context) (Session, error)
26 GetAllFavorites(ctx context.Context) ([]Favorite, error)27 GetAllFavorites(ctx context.Context) ([]Favorite, error)
27 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)28 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
28 GetAllTags(ctx context.Context) ([]FavoriteTag, error)29 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
@@ -40,14 +41,18 @@ type Querier interface {
40 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)41 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
41 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)42 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
42 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)43 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
44+ GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
43 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)45 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
46+ GetSessionByID(ctx context.Context, id string) (Session, error)
44 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)47 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
45 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)48 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
46 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)49 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
47 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)50 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
48 IncrementVisitCount(ctx context.Context, url string) error51 IncrementVisitCount(ctx context.Context, url string) error
52+ InsertSession(ctx context.Context, arg InsertSessionParams) error
49 IsWhitelisted(ctx context.Context, domain string) (int64, error)53 IsWhitelisted(ctx context.Context, domain string) (int64, error)
50 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)54 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
55+ MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error
51 RemoveFromWhitelist(ctx context.Context, domain string) error56 RemoveFromWhitelist(ctx context.Context, domain string) error
52 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error57 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
53 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)58 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
A internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +119 -0
@@ -0,0 +1,119 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: sessions.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+ "time"
12+)
13+
14+const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
15+SELECT id, type, started_at, ended_at FROM sessions
16+WHERE type = 'browser' AND ended_at IS NULL
17+ORDER BY started_at DESC
18+LIMIT 1
19+`
20+
21+func (q *Queries) GetActiveBrowserSession(ctx context.Context) (Session, error) {
22+ row := q.db.QueryRowContext(ctx, GetActiveBrowserSession)
23+ var i Session
24+ err := row.Scan(
25+ &i.ID,
26+ &i.Type,
27+ &i.StartedAt,
28+ &i.EndedAt,
29+ )
30+ return i, err
31+}
32+
33+const GetRecentSessions = `-- name: GetRecentSessions :many
34+SELECT id, type, started_at, ended_at FROM sessions
35+ORDER BY started_at DESC
36+LIMIT ?
37+`
38+
39+func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) {
40+ rows, err := q.db.QueryContext(ctx, GetRecentSessions, limit)
41+ if err != nil {
42+ return nil, err
43+ }
44+ defer rows.Close()
45+ items := []Session{}
46+ for rows.Next() {
47+ var i Session
48+ if err := rows.Scan(
49+ &i.ID,
50+ &i.Type,
51+ &i.StartedAt,
52+ &i.EndedAt,
53+ ); err != nil {
54+ return nil, err
55+ }
56+ items = append(items, i)
57+ }
58+ if err := rows.Close(); err != nil {
59+ return nil, err
60+ }
61+ if err := rows.Err(); err != nil {
62+ return nil, err
63+ }
64+ return items, nil
65+}
66+
67+const GetSessionByID = `-- name: GetSessionByID :one
68+SELECT id, type, started_at, ended_at FROM sessions WHERE id = ? LIMIT 1
69+`
70+
71+func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
72+ row := q.db.QueryRowContext(ctx, GetSessionByID, id)
73+ var i Session
74+ err := row.Scan(
75+ &i.ID,
76+ &i.Type,
77+ &i.StartedAt,
78+ &i.EndedAt,
79+ )
80+ return i, err
81+}
82+
83+const InsertSession = `-- name: InsertSession :exec
84+INSERT INTO sessions (id, type, started_at, ended_at)
85+VALUES (?, ?, ?, ?)
86+`
87+
88+type InsertSessionParams struct {
89+ ID string `json:"id"`
90+ Type string `json:"type"`
91+ StartedAt time.Time `json:"started_at"`
92+ EndedAt sql.NullTime `json:"ended_at"`
93+}
94+
95+func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error {
96+ _, err := q.db.ExecContext(ctx, InsertSession,
97+ arg.ID,
98+ arg.Type,
99+ arg.StartedAt,
100+ arg.EndedAt,
101+ )
102+ return err
103+}
104+
105+const MarkSessionEnded = `-- name: MarkSessionEnded :exec
106+UPDATE sessions
107+SET ended_at = ?
108+WHERE id = ?
109+`
110+
111+type MarkSessionEndedParams struct {
112+ EndedAt sql.NullTime `json:"ended_at"`
113+ ID string `json:"id"`
114+}
115+
116+func (q *Queries) MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error {
117+ _, err := q.db.ExecContext(ctx, MarkSessionEnded, arg.EndedAt, arg.ID)
118+ return err
119+}
@@ -0,0 +1,119 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: sessions.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+ "time"
12+)
13+
14+const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
15+SELECT id, type, started_at, ended_at FROM sessions
16+WHERE type = 'browser' AND ended_at IS NULL
17+ORDER BY started_at DESC
18+LIMIT 1
19+`
20+
21+func (q *Queries) GetActiveBrowserSession(ctx context.Context) (Session, error) {
22+ row := q.db.QueryRowContext(ctx, GetActiveBrowserSession)
23+ var i Session
24+ err := row.Scan(
25+ &i.ID,
26+ &i.Type,
27+ &i.StartedAt,
28+ &i.EndedAt,
29+ )
30+ return i, err
31+}
32+
33+const GetRecentSessions = `-- name: GetRecentSessions :many
34+SELECT id, type, started_at, ended_at FROM sessions
35+ORDER BY started_at DESC
36+LIMIT ?
37+`
38+
39+func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) {
40+ rows, err := q.db.QueryContext(ctx, GetRecentSessions, limit)
41+ if err != nil {
42+ return nil, err
43+ }
44+ defer rows.Close()
45+ items := []Session{}
46+ for rows.Next() {
47+ var i Session
48+ if err := rows.Scan(
49+ &i.ID,
50+ &i.Type,
51+ &i.StartedAt,
52+ &i.EndedAt,
53+ ); err != nil {
54+ return nil, err
55+ }
56+ items = append(items, i)
57+ }
58+ if err := rows.Close(); err != nil {
59+ return nil, err
60+ }
61+ if err := rows.Err(); err != nil {
62+ return nil, err
63+ }
64+ return items, nil
65+}
66+
67+const GetSessionByID = `-- name: GetSessionByID :one
68+SELECT id, type, started_at, ended_at FROM sessions WHERE id = ? LIMIT 1
69+`
70+
71+func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
72+ row := q.db.QueryRowContext(ctx, GetSessionByID, id)
73+ var i Session
74+ err := row.Scan(
75+ &i.ID,
76+ &i.Type,
77+ &i.StartedAt,
78+ &i.EndedAt,
79+ )
80+ return i, err
81+}
82+
83+const InsertSession = `-- name: InsertSession :exec
84+INSERT INTO sessions (id, type, started_at, ended_at)
85+VALUES (?, ?, ?, ?)
86+`
87+
88+type InsertSessionParams struct {
89+ ID string `json:"id"`
90+ Type string `json:"type"`
91+ StartedAt time.Time `json:"started_at"`
92+ EndedAt sql.NullTime `json:"ended_at"`
93+}
94+
95+func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error {
96+ _, err := q.db.ExecContext(ctx, InsertSession,
97+ arg.ID,
98+ arg.Type,
99+ arg.StartedAt,
100+ arg.EndedAt,
101+ )
102+ return err
103+}
104+
105+const MarkSessionEnded = `-- name: MarkSessionEnded :exec
106+UPDATE sessions
107+SET ended_at = ?
108+WHERE id = ?
109+`
110+
111+type MarkSessionEndedParams struct {
112+ EndedAt sql.NullTime `json:"ended_at"`
113+ ID string `json:"id"`
114+}
115+
116+func (q *Queries) MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error {
117+ _, err := q.db.ExecContext(ctx, MarkSessionEnded, arg.EndedAt, arg.ID)
118+ return err
119+}

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