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: | |||
| 8 | 8 | filename: mocks.go | |
| 9 | 9 | pkgname: mocks | |
| 10 | 10 | interfaces: | |
| 11 | + | SessionLogger: {} | |
| 11 | 12 | WebView: {} | |
| 12 | 13 | WebViewFactory: {} | |
| 13 | 14 | WebViewPool: {} | |
| @@ -21,6 +22,7 @@ packages: | |||
| 21 | 22 | FavoriteRepository: {} | |
| 22 | 23 | FolderRepository: {} | |
| 23 | 24 | HistoryRepository: {} | |
| 25 | + | SessionRepository: {} | |
| 24 | 26 | TagRepository: {} | |
| 25 | 27 | ZoomRepository: {} | |
| 26 | 28 | github.com/bnema/dumber/internal/infrastructure/filtering: | |
| @@ -8,6 +8,7 @@ packages: | |||||
| 8 | filename: mocks.go | 8 | filename: mocks.go | ||
| 9 | pkgname: mocks | 9 | 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 ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/rs/zerolog" | |
| 11 | 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 | 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 | 124 | // The first argument is typically a *testing.T value. | |
| 16 | 125 | 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 | |||
| 2719 | 2719 | 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 | 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 | 3068 | // The first argument is typically a *testing.T value. | |
| 2724 | 3069 | func NewMockZoomRepository(t interface { | |
| @@ -2719,6 +2719,351 @@ func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.C | |||||
| 2719 | return _c | 2719 | 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 | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "database/sql" | |
| 9 | + | "time" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | type ContentWhitelist struct { | |
| @@ -62,6 +63,13 @@ type HistoryFt struct { | |||
| 62 | 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 | 73 | type ZoomLevel struct { | |
| 66 | 74 | Domain string `json:"domain"` | |
| 67 | 75 | 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 { | |||
| 23 | 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 24 | 24 | DeleteTag(ctx context.Context, id int64) error | |
| 25 | 25 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 26 | + | GetActiveBrowserSession(ctx context.Context) (Session, error) | |
| 26 | 27 | GetAllFavorites(ctx context.Context) ([]Favorite, error) | |
| 27 | 28 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 28 | 29 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | |
| @@ -40,14 +41,18 @@ type Querier interface { | |||
| 40 | 41 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | |
| 41 | 42 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | |
| 42 | 43 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | |
| 44 | + | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| 43 | 45 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 46 | + | GetSessionByID(ctx context.Context, id string) (Session, error) | |
| 44 | 47 | GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) | |
| 45 | 48 | GetTagByName(ctx context.Context, name string) (FavoriteTag, error) | |
| 46 | 49 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | |
| 47 | 50 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | |
| 48 | 51 | IncrementVisitCount(ctx context.Context, url string) error | |
| 52 | + | InsertSession(ctx context.Context, arg InsertSessionParams) error | |
| 49 | 53 | IsWhitelisted(ctx context.Context, domain string) (int64, error) | |
| 50 | 54 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |
| 55 | + | MarkSessionEnded(ctx context.Context, arg MarkSessionEndedParams) error | |
| 51 | 56 | RemoveFromWhitelist(ctx context.Context, domain string) error | |
| 52 | 57 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | |
| 53 | 58 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | |
| @@ -23,6 +23,7 @@ type Querier interface { | |||||
| 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| 24 | DeleteTag(ctx context.Context, id int64) error | 24 | DeleteTag(ctx context.Context, id int64) error | ||
| 25 | DeleteZoomLevel(ctx context.Context, domain string) error | 25 | 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) error | 51 | 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) error | 56 | RemoveFromWhitelist(ctx context.Context, domain string) error | ||
| 52 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | 57 | 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 | + | } | |||