feat(usecase): add session snapshot, restore, and list use cases
- commit
- 0ded3572def7ec32e686962cf3697caef9df4738
- author
- bnema · · 2025-12-24 21:27 UTC
- committer
- brice · · 2025-12-25 05:18 UTC
- parent
- f1ba35b
- signature
- unsigned
5 files · +670 -0
A internal/application/usecase/list_sessions.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 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/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ListSessionsUseCase handles listing sessions with their state information. | |
| 15 | + | type ListSessionsUseCase struct { | |
| 16 | + | sessionRepo repository.SessionRepository | |
| 17 | + | stateRepo repository.SessionStateRepository | |
| 18 | + | lockDir string // Directory where lock files are stored | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewListSessionsUseCase creates a new ListSessionsUseCase. | |
| 22 | + | func NewListSessionsUseCase( | |
| 23 | + | sessionRepo repository.SessionRepository, | |
| 24 | + | stateRepo repository.SessionStateRepository, | |
| 25 | + | lockDir string, | |
| 26 | + | ) *ListSessionsUseCase { | |
| 27 | + | return &ListSessionsUseCase{ | |
| 28 | + | sessionRepo: sessionRepo, | |
| 29 | + | stateRepo: stateRepo, | |
| 30 | + | lockDir: lockDir, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // ListSessionsOutput contains the list of sessions with their info. | |
| 35 | + | type ListSessionsOutput struct { | |
| 36 | + | Sessions []entity.SessionInfo | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Execute returns a list of sessions with their state information. | |
| 40 | + | // Sessions are sorted with active first, then by date (most recent first). | |
| 41 | + | func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) { | |
| 42 | + | log := logging.FromContext(ctx) | |
| 43 | + | ||
| 44 | + | if limit <= 0 { | |
| 45 | + | limit = 50 | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | sessions, err := uc.sessionRepo.GetRecent(ctx, limit) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Get all snapshots | |
| 54 | + | snapshots, err := uc.stateRepo.GetAllSnapshots(ctx) | |
| 55 | + | if err != nil { | |
| 56 | + | log.Warn().Err(err).Msg("failed to get session snapshots") | |
| 57 | + | // Continue without snapshots | |
| 58 | + | snapshots = nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Build snapshot lookup map | |
| 62 | + | snapshotMap := make(map[entity.SessionID]*entity.SessionState) | |
| 63 | + | for _, s := range snapshots { | |
| 64 | + | snapshotMap[s.SessionID] = s | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Build session info list | |
| 68 | + | result := make([]entity.SessionInfo, 0, len(sessions)) | |
| 69 | + | for _, session := range sessions { | |
| 70 | + | // Only include browser sessions | |
| 71 | + | if session.Type != entity.SessionTypeBrowser { | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | info := entity.SessionInfo{ | |
| 76 | + | Session: session, | |
| 77 | + | IsCurrent: session.ID == currentSessionID, | |
| 78 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | if state, ok := snapshotMap[session.ID]; ok { | |
| 82 | + | info.State = state | |
| 83 | + | info.TabCount = len(state.Tabs) | |
| 84 | + | info.PaneCount = state.CountPanes() | |
| 85 | + | info.UpdatedAt = state.SavedAt | |
| 86 | + | } else { | |
| 87 | + | info.UpdatedAt = session.StartedAt | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | result = append(result, info) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Sort: current first, then active, then by date | |
| 94 | + | sortSessionInfos(result, currentSessionID) | |
| 95 | + | ||
| 96 | + | return &ListSessionsOutput{Sessions: result}, nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // hasActiveLock checks if a session has an active lock file. | |
| 100 | + | func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool { | |
| 101 | + | if uc.lockDir == "" { | |
| 102 | + | return false | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | lockPath := filepath.Join(uc.lockDir, string(sessionID)+".lock") | |
| 106 | + | _, err := os.Stat(lockPath) | |
| 107 | + | return err == nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending. | |
| 111 | + | func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) { | |
| 112 | + | // Simple bubble sort for small lists | |
| 113 | + | n := len(infos) | |
| 114 | + | for i := 0; i < n-1; i++ { | |
| 115 | + | for j := 0; j < n-i-1; j++ { | |
| 116 | + | if shouldSwap(infos[j], infos[j+1], currentID) { | |
| 117 | + | infos[j], infos[j+1] = infos[j+1], infos[j] | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool { | |
| 124 | + | // Current session always first | |
| 125 | + | if a.Session.ID == currentID { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | if b.Session.ID == currentID { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Active sessions before inactive | |
| 133 | + | if a.IsActive && !b.IsActive { | |
| 134 | + | return false | |
| 135 | + | } | |
| 136 | + | if !a.IsActive && b.IsActive { | |
| 137 | + | return true | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Most recent first | |
| 141 | + | return a.UpdatedAt.Before(b.UpdatedAt) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // GetSessionInfo returns info for a specific session. | |
| 145 | + | func (uc *ListSessionsUseCase) GetSessionInfo( | |
| 146 | + | ctx context.Context, | |
| 147 | + | sessionID, currentSessionID entity.SessionID, | |
| 148 | + | ) (*entity.SessionInfo, error) { | |
| 149 | + | session, err := uc.sessionRepo.FindByID(ctx, sessionID) | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, err | |
| 152 | + | } | |
| 153 | + | if session == nil { | |
| 154 | + | return nil, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | info := &entity.SessionInfo{ | |
| 158 | + | Session: session, | |
| 159 | + | IsCurrent: session.ID == currentSessionID, | |
| 160 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 161 | + | UpdatedAt: session.StartedAt, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | state, err := uc.stateRepo.GetSnapshot(ctx, sessionID) | |
| 165 | + | if err != nil { | |
| 166 | + | logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state") | |
| 167 | + | } | |
| 168 | + | if state != nil { | |
| 169 | + | info.State = state | |
| 170 | + | info.TabCount = len(state.Tabs) | |
| 171 | + | info.PaneCount = state.CountPanes() | |
| 172 | + | info.UpdatedAt = state.SavedAt | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | return info, nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | const ( | |
| 179 | + | hoursPerDay = 24 | |
| 180 | + | daysPerWeek = 7 | |
| 181 | + | maxIDDisplay = 20 | |
| 182 | + | ) | |
| 183 | + | ||
| 184 | + | // GetRelativeTime returns a human-readable relative time string. | |
| 185 | + | func GetRelativeTime(t time.Time) string { | |
| 186 | + | now := time.Now() | |
| 187 | + | diff := now.Sub(t) | |
| 188 | + | ||
| 189 | + | switch { | |
| 190 | + | case diff < time.Minute: | |
| 191 | + | return "just now" | |
| 192 | + | case diff < time.Hour: | |
| 193 | + | mins := int(diff.Minutes()) | |
| 194 | + | if mins == 1 { | |
| 195 | + | return "1m ago" | |
| 196 | + | } | |
| 197 | + | return formatDuration(mins, "m") | |
| 198 | + | case diff < hoursPerDay*time.Hour: | |
| 199 | + | hours := int(diff.Hours()) | |
| 200 | + | if hours == 1 { | |
| 201 | + | return "1h ago" | |
| 202 | + | } | |
| 203 | + | return formatDuration(hours, "h") | |
| 204 | + | case diff < daysPerWeek*hoursPerDay*time.Hour: | |
| 205 | + | days := int(diff.Hours() / hoursPerDay) | |
| 206 | + | if days == 1 { | |
| 207 | + | return "1d ago" | |
| 208 | + | } | |
| 209 | + | return formatDuration(days, "d") | |
| 210 | + | default: | |
| 211 | + | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |
| 212 | + | if weeks == 1 { | |
| 213 | + | return "1w ago" | |
| 214 | + | } | |
| 215 | + | return formatDuration(weeks, "w") | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func formatDuration(n int, unit string) string { | |
| 220 | + | return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago" | |
| 221 | + | } | |
| @@ -0,0 +1,221 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 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/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ListSessionsUseCase handles listing sessions with their state information. | |||
| 15 | + | type ListSessionsUseCase struct { | |||
| 16 | + | sessionRepo repository.SessionRepository | |||
| 17 | + | stateRepo repository.SessionStateRepository | |||
| 18 | + | lockDir string // Directory where lock files are stored | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // NewListSessionsUseCase creates a new ListSessionsUseCase. | |||
| 22 | + | func NewListSessionsUseCase( | |||
| 23 | + | sessionRepo repository.SessionRepository, | |||
| 24 | + | stateRepo repository.SessionStateRepository, | |||
| 25 | + | lockDir string, | |||
| 26 | + | ) *ListSessionsUseCase { | |||
| 27 | + | return &ListSessionsUseCase{ | |||
| 28 | + | sessionRepo: sessionRepo, | |||
| 29 | + | stateRepo: stateRepo, | |||
| 30 | + | lockDir: lockDir, | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ListSessionsOutput contains the list of sessions with their info. | |||
| 35 | + | type ListSessionsOutput struct { | |||
| 36 | + | Sessions []entity.SessionInfo | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Execute returns a list of sessions with their state information. | |||
| 40 | + | // Sessions are sorted with active first, then by date (most recent first). | |||
| 41 | + | func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) { | |||
| 42 | + | log := logging.FromContext(ctx) | |||
| 43 | + | ||||
| 44 | + | if limit <= 0 { | |||
| 45 | + | limit = 50 | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | sessions, err := uc.sessionRepo.GetRecent(ctx, limit) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Get all snapshots | |||
| 54 | + | snapshots, err := uc.stateRepo.GetAllSnapshots(ctx) | |||
| 55 | + | if err != nil { | |||
| 56 | + | log.Warn().Err(err).Msg("failed to get session snapshots") | |||
| 57 | + | // Continue without snapshots | |||
| 58 | + | snapshots = nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Build snapshot lookup map | |||
| 62 | + | snapshotMap := make(map[entity.SessionID]*entity.SessionState) | |||
| 63 | + | for _, s := range snapshots { | |||
| 64 | + | snapshotMap[s.SessionID] = s | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Build session info list | |||
| 68 | + | result := make([]entity.SessionInfo, 0, len(sessions)) | |||
| 69 | + | for _, session := range sessions { | |||
| 70 | + | // Only include browser sessions | |||
| 71 | + | if session.Type != entity.SessionTypeBrowser { | |||
| 72 | + | continue | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | info := entity.SessionInfo{ | |||
| 76 | + | Session: session, | |||
| 77 | + | IsCurrent: session.ID == currentSessionID, | |||
| 78 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | if state, ok := snapshotMap[session.ID]; ok { | |||
| 82 | + | info.State = state | |||
| 83 | + | info.TabCount = len(state.Tabs) | |||
| 84 | + | info.PaneCount = state.CountPanes() | |||
| 85 | + | info.UpdatedAt = state.SavedAt | |||
| 86 | + | } else { | |||
| 87 | + | info.UpdatedAt = session.StartedAt | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | result = append(result, info) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Sort: current first, then active, then by date | |||
| 94 | + | sortSessionInfos(result, currentSessionID) | |||
| 95 | + | ||||
| 96 | + | return &ListSessionsOutput{Sessions: result}, nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // hasActiveLock checks if a session has an active lock file. | |||
| 100 | + | func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool { | |||
| 101 | + | if uc.lockDir == "" { | |||
| 102 | + | return false | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | lockPath := filepath.Join(uc.lockDir, string(sessionID)+".lock") | |||
| 106 | + | _, err := os.Stat(lockPath) | |||
| 107 | + | return err == nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending. | |||
| 111 | + | func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) { | |||
| 112 | + | // Simple bubble sort for small lists | |||
| 113 | + | n := len(infos) | |||
| 114 | + | for i := 0; i < n-1; i++ { | |||
| 115 | + | for j := 0; j < n-i-1; j++ { | |||
| 116 | + | if shouldSwap(infos[j], infos[j+1], currentID) { | |||
| 117 | + | infos[j], infos[j+1] = infos[j+1], infos[j] | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool { | |||
| 124 | + | // Current session always first | |||
| 125 | + | if a.Session.ID == currentID { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | if b.Session.ID == currentID { | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Active sessions before inactive | |||
| 133 | + | if a.IsActive && !b.IsActive { | |||
| 134 | + | return false | |||
| 135 | + | } | |||
| 136 | + | if !a.IsActive && b.IsActive { | |||
| 137 | + | return true | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Most recent first | |||
| 141 | + | return a.UpdatedAt.Before(b.UpdatedAt) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // GetSessionInfo returns info for a specific session. | |||
| 145 | + | func (uc *ListSessionsUseCase) GetSessionInfo( | |||
| 146 | + | ctx context.Context, | |||
| 147 | + | sessionID, currentSessionID entity.SessionID, | |||
| 148 | + | ) (*entity.SessionInfo, error) { | |||
| 149 | + | session, err := uc.sessionRepo.FindByID(ctx, sessionID) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return nil, err | |||
| 152 | + | } | |||
| 153 | + | if session == nil { | |||
| 154 | + | return nil, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | info := &entity.SessionInfo{ | |||
| 158 | + | Session: session, | |||
| 159 | + | IsCurrent: session.ID == currentSessionID, | |||
| 160 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |||
| 161 | + | UpdatedAt: session.StartedAt, | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | state, err := uc.stateRepo.GetSnapshot(ctx, sessionID) | |||
| 165 | + | if err != nil { | |||
| 166 | + | logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state") | |||
| 167 | + | } | |||
| 168 | + | if state != nil { | |||
| 169 | + | info.State = state | |||
| 170 | + | info.TabCount = len(state.Tabs) | |||
| 171 | + | info.PaneCount = state.CountPanes() | |||
| 172 | + | info.UpdatedAt = state.SavedAt | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | return info, nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | const ( | |||
| 179 | + | hoursPerDay = 24 | |||
| 180 | + | daysPerWeek = 7 | |||
| 181 | + | maxIDDisplay = 20 | |||
| 182 | + | ) | |||
| 183 | + | ||||
| 184 | + | // GetRelativeTime returns a human-readable relative time string. | |||
| 185 | + | func GetRelativeTime(t time.Time) string { | |||
| 186 | + | now := time.Now() | |||
| 187 | + | diff := now.Sub(t) | |||
| 188 | + | ||||
| 189 | + | switch { | |||
| 190 | + | case diff < time.Minute: | |||
| 191 | + | return "just now" | |||
| 192 | + | case diff < time.Hour: | |||
| 193 | + | mins := int(diff.Minutes()) | |||
| 194 | + | if mins == 1 { | |||
| 195 | + | return "1m ago" | |||
| 196 | + | } | |||
| 197 | + | return formatDuration(mins, "m") | |||
| 198 | + | case diff < hoursPerDay*time.Hour: | |||
| 199 | + | hours := int(diff.Hours()) | |||
| 200 | + | if hours == 1 { | |||
| 201 | + | return "1h ago" | |||
| 202 | + | } | |||
| 203 | + | return formatDuration(hours, "h") | |||
| 204 | + | case diff < daysPerWeek*hoursPerDay*time.Hour: | |||
| 205 | + | days := int(diff.Hours() / hoursPerDay) | |||
| 206 | + | if days == 1 { | |||
| 207 | + | return "1d ago" | |||
| 208 | + | } | |||
| 209 | + | return formatDuration(days, "d") | |||
| 210 | + | default: | |||
| 211 | + | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |||
| 212 | + | if weeks == 1 { | |||
| 213 | + | return "1w ago" | |||
| 214 | + | } | |||
| 215 | + | return formatDuration(weeks, "w") | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func formatDuration(n int, unit string) string { | |||
| 220 | + | return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago" | |||
| 221 | + | } | |||
A internal/application/usecase/restore_session.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ErrSessionNotFound is returned when a session state cannot be found. | |
| 14 | + | var ErrSessionNotFound = errors.New("session not found") | |
| 15 | + | ||
| 16 | + | // ErrVersionMismatch is returned when the session state version is incompatible. | |
| 17 | + | var ErrVersionMismatch = errors.New("session state version mismatch") | |
| 18 | + | ||
| 19 | + | // RestoreSessionUseCase handles restoring session state from a snapshot. | |
| 20 | + | type RestoreSessionUseCase struct { | |
| 21 | + | stateRepo repository.SessionStateRepository | |
| 22 | + | sessionRepo repository.SessionRepository | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewRestoreSessionUseCase creates a new RestoreSessionUseCase. | |
| 26 | + | func NewRestoreSessionUseCase( | |
| 27 | + | stateRepo repository.SessionStateRepository, | |
| 28 | + | sessionRepo repository.SessionRepository, | |
| 29 | + | ) *RestoreSessionUseCase { | |
| 30 | + | return &RestoreSessionUseCase{ | |
| 31 | + | stateRepo: stateRepo, | |
| 32 | + | sessionRepo: sessionRepo, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RestoreInput contains the parameters for restoring a session. | |
| 37 | + | type RestoreInput struct { | |
| 38 | + | SessionID entity.SessionID | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // RestoreOutput contains the restored session state. | |
| 42 | + | type RestoreOutput struct { | |
| 43 | + | State *entity.SessionState | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Execute loads and validates a session state for restoration. | |
| 47 | + | func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) { | |
| 48 | + | log := logging.FromContext(ctx) | |
| 49 | + | ||
| 50 | + | if input.SessionID == "" { | |
| 51 | + | return nil, fmt.Errorf("session id required") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | log.Info(). | |
| 55 | + | Str("session_id", string(input.SessionID)). | |
| 56 | + | Msg("restoring session state") | |
| 57 | + | ||
| 58 | + | state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, fmt.Errorf("get session snapshot: %w", err) | |
| 61 | + | } | |
| 62 | + | if state == nil { | |
| 63 | + | return nil, ErrSessionNotFound | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Validate version compatibility | |
| 67 | + | if state.Version > entity.SessionStateVersion { | |
| 68 | + | log.Warn(). | |
| 69 | + | Int("state_version", state.Version). | |
| 70 | + | Int("current_version", entity.SessionStateVersion). | |
| 71 | + | Msg("session state version is newer than current version") | |
| 72 | + | return nil, ErrVersionMismatch | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | log.Info(). | |
| 76 | + | Str("session_id", string(input.SessionID)). | |
| 77 | + | Int("tab_count", len(state.Tabs)). | |
| 78 | + | Int("pane_count", state.CountPanes()). | |
| 79 | + | Msg("session state loaded for restoration") | |
| 80 | + | ||
| 81 | + | return &RestoreOutput{State: state}, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion). | |
| 85 | + | func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |
| 86 | + | return uc.stateRepo.DeleteSnapshot(ctx, sessionID) | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ErrSessionNotFound is returned when a session state cannot be found. | |||
| 14 | + | var ErrSessionNotFound = errors.New("session not found") | |||
| 15 | + | ||||
| 16 | + | // ErrVersionMismatch is returned when the session state version is incompatible. | |||
| 17 | + | var ErrVersionMismatch = errors.New("session state version mismatch") | |||
| 18 | + | ||||
| 19 | + | // RestoreSessionUseCase handles restoring session state from a snapshot. | |||
| 20 | + | type RestoreSessionUseCase struct { | |||
| 21 | + | stateRepo repository.SessionStateRepository | |||
| 22 | + | sessionRepo repository.SessionRepository | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewRestoreSessionUseCase creates a new RestoreSessionUseCase. | |||
| 26 | + | func NewRestoreSessionUseCase( | |||
| 27 | + | stateRepo repository.SessionStateRepository, | |||
| 28 | + | sessionRepo repository.SessionRepository, | |||
| 29 | + | ) *RestoreSessionUseCase { | |||
| 30 | + | return &RestoreSessionUseCase{ | |||
| 31 | + | stateRepo: stateRepo, | |||
| 32 | + | sessionRepo: sessionRepo, | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // RestoreInput contains the parameters for restoring a session. | |||
| 37 | + | type RestoreInput struct { | |||
| 38 | + | SessionID entity.SessionID | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // RestoreOutput contains the restored session state. | |||
| 42 | + | type RestoreOutput struct { | |||
| 43 | + | State *entity.SessionState | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Execute loads and validates a session state for restoration. | |||
| 47 | + | func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) { | |||
| 48 | + | log := logging.FromContext(ctx) | |||
| 49 | + | ||||
| 50 | + | if input.SessionID == "" { | |||
| 51 | + | return nil, fmt.Errorf("session id required") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | log.Info(). | |||
| 55 | + | Str("session_id", string(input.SessionID)). | |||
| 56 | + | Msg("restoring session state") | |||
| 57 | + | ||||
| 58 | + | state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return nil, fmt.Errorf("get session snapshot: %w", err) | |||
| 61 | + | } | |||
| 62 | + | if state == nil { | |||
| 63 | + | return nil, ErrSessionNotFound | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Validate version compatibility | |||
| 67 | + | if state.Version > entity.SessionStateVersion { | |||
| 68 | + | log.Warn(). | |||
| 69 | + | Int("state_version", state.Version). | |||
| 70 | + | Int("current_version", entity.SessionStateVersion). | |||
| 71 | + | Msg("session state version is newer than current version") | |||
| 72 | + | return nil, ErrVersionMismatch | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | log.Info(). | |||
| 76 | + | Str("session_id", string(input.SessionID)). | |||
| 77 | + | Int("tab_count", len(state.Tabs)). | |||
| 78 | + | Int("pane_count", state.CountPanes()). | |||
| 79 | + | Msg("session state loaded for restoration") | |||
| 80 | + | ||||
| 81 | + | return &RestoreOutput{State: state}, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion). | |||
| 85 | + | func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |||
| 86 | + | return uc.stateRepo.DeleteSnapshot(ctx, sessionID) | |||
| 87 | + | } | |||
A internal/application/usecase/restore_session_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) { | |
| 16 | + | ctx := testContext() | |
| 17 | + | ||
| 18 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 19 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 20 | + | ||
| 21 | + | sessionID := entity.SessionID("20251224_120000_restore") | |
| 22 | + | ||
| 23 | + | expectedState := &entity.SessionState{ | |
| 24 | + | Version: entity.SessionStateVersion, | |
| 25 | + | SessionID: sessionID, | |
| 26 | + | Tabs: []entity.TabSnapshot{ | |
| 27 | + | { | |
| 28 | + | ID: "tab-1", | |
| 29 | + | Name: "Test Tab", | |
| 30 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 31 | + | Root: &entity.PaneNodeSnapshot{ | |
| 32 | + | Pane: &entity.PaneSnapshot{ | |
| 33 | + | ID: "pane-1", | |
| 34 | + | URI: "https://example.com", | |
| 35 | + | Title: "Example", | |
| 36 | + | }, | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | SavedAt: time.Now(), | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil) | |
| 45 | + | ||
| 46 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 47 | + | ||
| 48 | + | output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.NotNil(t, output) | |
| 51 | + | assert.Equal(t, expectedState, output.State) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |
| 55 | + | ctx := testContext() | |
| 56 | + | ||
| 57 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 58 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 59 | + | ||
| 60 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 61 | + | ||
| 62 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""}) | |
| 63 | + | require.Error(t, err) | |
| 64 | + | assert.Contains(t, err.Error(), "session id required") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |
| 68 | + | ctx := testContext() | |
| 69 | + | ||
| 70 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 71 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 72 | + | ||
| 73 | + | sessionID := entity.SessionID("20251224_120000_notfound") | |
| 74 | + | ||
| 75 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) | |
| 76 | + | ||
| 77 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 78 | + | ||
| 79 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 80 | + | require.Error(t, err) | |
| 81 | + | assert.ErrorIs(t, err, usecase.ErrSessionNotFound) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) { | |
| 85 | + | ctx := testContext() | |
| 86 | + | ||
| 87 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 88 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 89 | + | ||
| 90 | + | sessionID := entity.SessionID("20251224_120000_version") | |
| 91 | + | ||
| 92 | + | // Return a state with a future version | |
| 93 | + | futureState := &entity.SessionState{ | |
| 94 | + | Version: entity.SessionStateVersion + 1, | |
| 95 | + | SessionID: sessionID, | |
| 96 | + | Tabs: []entity.TabSnapshot{}, | |
| 97 | + | SavedAt: time.Now(), | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil) | |
| 101 | + | ||
| 102 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 103 | + | ||
| 104 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 105 | + | require.Error(t, err) | |
| 106 | + | assert.ErrorIs(t, err, usecase.ErrVersionMismatch) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) { | |
| 110 | + | ctx := testContext() | |
| 111 | + | ||
| 112 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 113 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 114 | + | ||
| 115 | + | sessionID := entity.SessionID("20251224_120000_error") | |
| 116 | + | ||
| 117 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error")) | |
| 118 | + | ||
| 119 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 120 | + | ||
| 121 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 122 | + | require.Error(t, err) | |
| 123 | + | assert.Contains(t, err.Error(), "get session snapshot") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) { | |
| 127 | + | ctx := testContext() | |
| 128 | + | ||
| 129 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 130 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 131 | + | ||
| 132 | + | sessionID := entity.SessionID("20251224_120000_delete") | |
| 133 | + | ||
| 134 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 135 | + | ||
| 136 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 137 | + | ||
| 138 | + | err := uc.DeleteSnapshot(ctx, sessionID) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) { | |||
| 16 | + | ctx := testContext() | |||
| 17 | + | ||||
| 18 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 19 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |||
| 20 | + | ||||
| 21 | + | sessionID := entity.SessionID("20251224_120000_restore") | |||
| 22 | + | ||||
| 23 | + | expectedState := &entity.SessionState{ | |||
| 24 | + | Version: entity.SessionStateVersion, | |||
| 25 | + | SessionID: sessionID, | |||
| 26 | + | Tabs: []entity.TabSnapshot{ | |||
| 27 | + | { | |||
| 28 | + | ID: "tab-1", | |||
| 29 | + | Name: "Test Tab", | |||
| 30 | + | Workspace: entity.WorkspaceSnapshot{ | |||
| 31 | + | Root: &entity.PaneNodeSnapshot{ | |||
| 32 | + | Pane: &entity.PaneSnapshot{ | |||
| 33 | + | ID: "pane-1", | |||
| 34 | + | URI: "https://example.com", | |||
| 35 | + | Title: "Example", | |||
| 36 | + | }, | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | }, | |||
| 40 | + | }, | |||
| 41 | + | SavedAt: time.Now(), | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil) | |||
| 45 | + | ||||
| 46 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |||
| 47 | + | ||||
| 48 | + | output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | require.NotNil(t, output) | |||
| 51 | + | assert.Equal(t, expectedState, output.State) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |||
| 55 | + | ctx := testContext() | |||
| 56 | + | ||||
| 57 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 58 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |||
| 59 | + | ||||
| 60 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |||
| 61 | + | ||||
| 62 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""}) | |||
| 63 | + | require.Error(t, err) | |||
| 64 | + | assert.Contains(t, err.Error(), "session id required") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |||
| 68 | + | ctx := testContext() | |||
| 69 | + | ||||
| 70 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 71 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |||
| 72 | + | ||||
| 73 | + | sessionID := entity.SessionID("20251224_120000_notfound") | |||
| 74 | + | ||||
| 75 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) | |||
| 76 | + | ||||
| 77 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |||
| 78 | + | ||||
| 79 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |||
| 80 | + | require.Error(t, err) | |||
| 81 | + | assert.ErrorIs(t, err, usecase.ErrSessionNotFound) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) { | |||
| 85 | + | ctx := testContext() | |||
| 86 | + | ||||
| 87 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 88 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |||
| 89 | + | ||||
| 90 | + | sessionID := entity.SessionID("20251224_120000_version") | |||
| 91 | + | ||||
| 92 | + | // Return a state with a future version | |||
| 93 | + | futureState := &entity.SessionState{ | |||
| 94 | + | Version: entity.SessionStateVersion + 1, | |||
| 95 | + | SessionID: sessionID, | |||
| 96 | + | Tabs: []entity.TabSnapshot{}, | |||
| 97 | + | SavedAt: time.Now(), | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil) | |||
| 101 | + | ||||
| 102 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |||
| 103 | + | ||||
| 104 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |||
| 105 | + | require.Error(t, err) | |||
| 106 | + | assert.ErrorIs(t, err, usecase.ErrVersionMismatch) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) { | |||
| 110 | + | ctx := testContext() | |||
| 111 | + | ||||
| 112 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 113 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |||
| 114 | + | ||||
| 115 | + | sessionID := entity.SessionID("20251224_120000_error") | |||
| 116 | + | ||||
| 117 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error")) | |||
| 118 | + | ||||
| 119 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |||
| 120 | + | ||||
| 121 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |||
| 122 | + | require.Error(t, err) | |||
| 123 | + | assert.Contains(t, err.Error(), "get session snapshot") | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) { | |||
| 127 | + | ctx := testContext() | |||
| 128 | + | ||||
| 129 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 130 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |||
| 131 | + | ||||
| 132 | + | sessionID := entity.SessionID("20251224_120000_delete") | |||
| 133 | + | ||||
| 134 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |||
| 135 | + | ||||
| 136 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |||
| 137 | + | ||||
| 138 | + | err := uc.DeleteSnapshot(ctx, sessionID) | |||
| 139 | + | require.NoError(t, err) | |||
| 140 | + | } | |||
A internal/application/usecase/snapshot_session.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SnapshotSessionUseCase handles saving session state snapshots. | |
| 13 | + | type SnapshotSessionUseCase struct { | |
| 14 | + | stateRepo repository.SessionStateRepository | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase. | |
| 18 | + | func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase { | |
| 19 | + | return &SnapshotSessionUseCase{stateRepo: stateRepo} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // SnapshotInput contains the parameters for creating a session snapshot. | |
| 23 | + | type SnapshotInput struct { | |
| 24 | + | SessionID entity.SessionID | |
| 25 | + | TabList *entity.TabList | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Execute creates a snapshot of the current session state and saves it. | |
| 29 | + | func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error { | |
| 30 | + | log := logging.FromContext(ctx) | |
| 31 | + | ||
| 32 | + | if input.SessionID == "" { | |
| 33 | + | return fmt.Errorf("session id required") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | state := entity.SnapshotFromTabList(input.SessionID, input.TabList) | |
| 37 | + | ||
| 38 | + | log.Debug(). | |
| 39 | + | Str("session_id", string(input.SessionID)). | |
| 40 | + | Int("tab_count", len(state.Tabs)). | |
| 41 | + | Int("pane_count", state.CountPanes()). | |
| 42 | + | Msg("creating session snapshot") | |
| 43 | + | ||
| 44 | + | if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil { | |
| 45 | + | return fmt.Errorf("save session snapshot: %w", err) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return nil | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SnapshotSessionUseCase handles saving session state snapshots. | |||
| 13 | + | type SnapshotSessionUseCase struct { | |||
| 14 | + | stateRepo repository.SessionStateRepository | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase. | |||
| 18 | + | func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase { | |||
| 19 | + | return &SnapshotSessionUseCase{stateRepo: stateRepo} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // SnapshotInput contains the parameters for creating a session snapshot. | |||
| 23 | + | type SnapshotInput struct { | |||
| 24 | + | SessionID entity.SessionID | |||
| 25 | + | TabList *entity.TabList | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Execute creates a snapshot of the current session state and saves it. | |||
| 29 | + | func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error { | |||
| 30 | + | log := logging.FromContext(ctx) | |||
| 31 | + | ||||
| 32 | + | if input.SessionID == "" { | |||
| 33 | + | return fmt.Errorf("session id required") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | state := entity.SnapshotFromTabList(input.SessionID, input.TabList) | |||
| 37 | + | ||||
| 38 | + | log.Debug(). | |||
| 39 | + | Str("session_id", string(input.SessionID)). | |||
| 40 | + | Int("tab_count", len(state.Tabs)). | |||
| 41 | + | Int("pane_count", state.CountPanes()). | |||
| 42 | + | Msg("creating session snapshot") | |||
| 43 | + | ||||
| 44 | + | if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil { | |||
| 45 | + | return fmt.Errorf("save session snapshot: %w", err) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | return nil | |||
| 49 | + | } | |||
A internal/application/usecase/snapshot_session_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 20 | + | ||
| 21 | + | // Create a tab list with some tabs | |
| 22 | + | tabList := entity.NewTabList() | |
| 23 | + | pane := entity.NewPane("pane-1") | |
| 24 | + | pane.URI = "https://example.com" | |
| 25 | + | pane.Title = "Example" | |
| 26 | + | tab := entity.NewTab("tab-1", "ws-1", pane) | |
| 27 | + | tab.Name = "Test Tab" | |
| 28 | + | tabList.Add(tab) | |
| 29 | + | ||
| 30 | + | sessionID := entity.SessionID("20251224_120000_test") | |
| 31 | + | ||
| 32 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 33 | + | Run(func(_ context.Context, state *entity.SessionState) { | |
| 34 | + | require.Equal(t, sessionID, state.SessionID) | |
| 35 | + | require.Len(t, state.Tabs, 1) | |
| 36 | + | require.Equal(t, "Test Tab", state.Tabs[0].Name) | |
| 37 | + | require.Equal(t, entity.SessionStateVersion, state.Version) | |
| 38 | + | }). | |
| 39 | + | Return(nil) | |
| 40 | + | ||
| 41 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 42 | + | ||
| 43 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 44 | + | SessionID: sessionID, | |
| 45 | + | TabList: tabList, | |
| 46 | + | }) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) { | |
| 51 | + | ctx := testContext() | |
| 52 | + | ||
| 53 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 54 | + | ||
| 55 | + | sessionID := entity.SessionID("20251224_120000_empty") | |
| 56 | + | ||
| 57 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 58 | + | Run(func(_ context.Context, state *entity.SessionState) { | |
| 59 | + | require.Equal(t, sessionID, state.SessionID) | |
| 60 | + | require.Empty(t, state.Tabs) | |
| 61 | + | }). | |
| 62 | + | Return(nil) | |
| 63 | + | ||
| 64 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 65 | + | ||
| 66 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 67 | + | SessionID: sessionID, | |
| 68 | + | TabList: nil, | |
| 69 | + | }) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |
| 74 | + | ctx := testContext() | |
| 75 | + | ||
| 76 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 77 | + | ||
| 78 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 79 | + | ||
| 80 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 81 | + | SessionID: "", | |
| 82 | + | TabList: entity.NewTabList(), | |
| 83 | + | }) | |
| 84 | + | require.Error(t, err) | |
| 85 | + | assert.Contains(t, err.Error(), "session id required") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |
| 89 | + | sessionID := entity.SessionID("20251224_120000_snap") | |
| 90 | + | ||
| 91 | + | // Create a workspace with multiple panes | |
| 92 | + | pane1 := entity.NewPane("pane-1") | |
| 93 | + | pane1.URI = "https://github.com" | |
| 94 | + | pane1.Title = "GitHub" | |
| 95 | + | pane1.ZoomFactor = 1.2 | |
| 96 | + | ||
| 97 | + | pane2 := entity.NewPane("pane-2") | |
| 98 | + | pane2.URI = "https://google.com" | |
| 99 | + | pane2.Title = "Google" | |
| 100 | + | ||
| 101 | + | tab := entity.NewTab("tab-1", "ws-1", pane1) | |
| 102 | + | tab.Name = "Dev Tab" | |
| 103 | + | tab.IsPinned = true | |
| 104 | + | ||
| 105 | + | tabList := entity.NewTabList() | |
| 106 | + | tabList.Add(tab) | |
| 107 | + | ||
| 108 | + | state := entity.SnapshotFromTabList(sessionID, tabList) | |
| 109 | + | ||
| 110 | + | require.NotNil(t, state) | |
| 111 | + | assert.Equal(t, entity.SessionStateVersion, state.Version) | |
| 112 | + | assert.Equal(t, sessionID, state.SessionID) | |
| 113 | + | assert.Len(t, state.Tabs, 1) | |
| 114 | + | assert.Equal(t, "Dev Tab", state.Tabs[0].Name) | |
| 115 | + | assert.True(t, state.Tabs[0].IsPinned) | |
| 116 | + | assert.NotNil(t, state.Tabs[0].Workspace.Root) | |
| 117 | + | assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI) | |
| 118 | + | assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001) | |
| 119 | + | assert.False(t, state.SavedAt.IsZero()) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestSessionState_CountPanes(t *testing.T) { | |
| 123 | + | state := &entity.SessionState{ | |
| 124 | + | Tabs: []entity.TabSnapshot{ | |
| 125 | + | { | |
| 126 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 127 | + | Root: &entity.PaneNodeSnapshot{ | |
| 128 | + | Pane: &entity.PaneSnapshot{ID: "pane-1"}, | |
| 129 | + | }, | |
| 130 | + | }, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 134 | + | Root: &entity.PaneNodeSnapshot{ | |
| 135 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 136 | + | {Pane: &entity.PaneSnapshot{ID: "pane-2"}}, | |
| 137 | + | {Pane: &entity.PaneSnapshot{ID: "pane-3"}}, | |
| 138 | + | }, | |
| 139 | + | }, | |
| 140 | + | }, | |
| 141 | + | }, | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | assert.Equal(t, 3, state.CountPanes()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestGetRelativeTime(t *testing.T) { | |
| 149 | + | now := time.Now() | |
| 150 | + | ||
| 151 | + | tests := []struct { | |
| 152 | + | name string | |
| 153 | + | t time.Time | |
| 154 | + | expected string | |
| 155 | + | }{ | |
| 156 | + | {"just now", now.Add(-30 * time.Second), "just now"}, | |
| 157 | + | {"1 minute", now.Add(-1 * time.Minute), "1m ago"}, | |
| 158 | + | {"5 minutes", now.Add(-5 * time.Minute), "05m ago"}, | |
| 159 | + | {"1 hour", now.Add(-1 * time.Hour), "1h ago"}, | |
| 160 | + | {"3 hours", now.Add(-3 * time.Hour), "03h ago"}, | |
| 161 | + | {"1 day", now.Add(-24 * time.Hour), "1d ago"}, | |
| 162 | + | {"2 days", now.Add(-48 * time.Hour), "02d ago"}, | |
| 163 | + | {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"}, | |
| 164 | + | {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"}, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | for _, tt := range tests { | |
| 168 | + | t.Run(tt.name, func(t *testing.T) { | |
| 169 | + | result := usecase.GetRelativeTime(tt.t) | |
| 170 | + | assert.Equal(t, tt.expected, result) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | } | |
| @@ -0,0 +1,173 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) { | |||
| 17 | + | ctx := testContext() | |||
| 18 | + | ||||
| 19 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 20 | + | ||||
| 21 | + | // Create a tab list with some tabs | |||
| 22 | + | tabList := entity.NewTabList() | |||
| 23 | + | pane := entity.NewPane("pane-1") | |||
| 24 | + | pane.URI = "https://example.com" | |||
| 25 | + | pane.Title = "Example" | |||
| 26 | + | tab := entity.NewTab("tab-1", "ws-1", pane) | |||
| 27 | + | tab.Name = "Test Tab" | |||
| 28 | + | tabList.Add(tab) | |||
| 29 | + | ||||
| 30 | + | sessionID := entity.SessionID("20251224_120000_test") | |||
| 31 | + | ||||
| 32 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 33 | + | Run(func(_ context.Context, state *entity.SessionState) { | |||
| 34 | + | require.Equal(t, sessionID, state.SessionID) | |||
| 35 | + | require.Len(t, state.Tabs, 1) | |||
| 36 | + | require.Equal(t, "Test Tab", state.Tabs[0].Name) | |||
| 37 | + | require.Equal(t, entity.SessionStateVersion, state.Version) | |||
| 38 | + | }). | |||
| 39 | + | Return(nil) | |||
| 40 | + | ||||
| 41 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |||
| 42 | + | ||||
| 43 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |||
| 44 | + | SessionID: sessionID, | |||
| 45 | + | TabList: tabList, | |||
| 46 | + | }) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) { | |||
| 51 | + | ctx := testContext() | |||
| 52 | + | ||||
| 53 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 54 | + | ||||
| 55 | + | sessionID := entity.SessionID("20251224_120000_empty") | |||
| 56 | + | ||||
| 57 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 58 | + | Run(func(_ context.Context, state *entity.SessionState) { | |||
| 59 | + | require.Equal(t, sessionID, state.SessionID) | |||
| 60 | + | require.Empty(t, state.Tabs) | |||
| 61 | + | }). | |||
| 62 | + | Return(nil) | |||
| 63 | + | ||||
| 64 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |||
| 65 | + | ||||
| 66 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |||
| 67 | + | SessionID: sessionID, | |||
| 68 | + | TabList: nil, | |||
| 69 | + | }) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |||
| 74 | + | ctx := testContext() | |||
| 75 | + | ||||
| 76 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |||
| 77 | + | ||||
| 78 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |||
| 79 | + | ||||
| 80 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |||
| 81 | + | SessionID: "", | |||
| 82 | + | TabList: entity.NewTabList(), | |||
| 83 | + | }) | |||
| 84 | + | require.Error(t, err) | |||
| 85 | + | assert.Contains(t, err.Error(), "session id required") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |||
| 89 | + | sessionID := entity.SessionID("20251224_120000_snap") | |||
| 90 | + | ||||
| 91 | + | // Create a workspace with multiple panes | |||
| 92 | + | pane1 := entity.NewPane("pane-1") | |||
| 93 | + | pane1.URI = "https://github.com" | |||
| 94 | + | pane1.Title = "GitHub" | |||
| 95 | + | pane1.ZoomFactor = 1.2 | |||
| 96 | + | ||||
| 97 | + | pane2 := entity.NewPane("pane-2") | |||
| 98 | + | pane2.URI = "https://google.com" | |||
| 99 | + | pane2.Title = "Google" | |||
| 100 | + | ||||
| 101 | + | tab := entity.NewTab("tab-1", "ws-1", pane1) | |||
| 102 | + | tab.Name = "Dev Tab" | |||
| 103 | + | tab.IsPinned = true | |||
| 104 | + | ||||
| 105 | + | tabList := entity.NewTabList() | |||
| 106 | + | tabList.Add(tab) | |||
| 107 | + | ||||
| 108 | + | state := entity.SnapshotFromTabList(sessionID, tabList) | |||
| 109 | + | ||||
| 110 | + | require.NotNil(t, state) | |||
| 111 | + | assert.Equal(t, entity.SessionStateVersion, state.Version) | |||
| 112 | + | assert.Equal(t, sessionID, state.SessionID) | |||
| 113 | + | assert.Len(t, state.Tabs, 1) | |||
| 114 | + | assert.Equal(t, "Dev Tab", state.Tabs[0].Name) | |||
| 115 | + | assert.True(t, state.Tabs[0].IsPinned) | |||
| 116 | + | assert.NotNil(t, state.Tabs[0].Workspace.Root) | |||
| 117 | + | assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI) | |||
| 118 | + | assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001) | |||
| 119 | + | assert.False(t, state.SavedAt.IsZero()) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestSessionState_CountPanes(t *testing.T) { | |||
| 123 | + | state := &entity.SessionState{ | |||
| 124 | + | Tabs: []entity.TabSnapshot{ | |||
| 125 | + | { | |||
| 126 | + | Workspace: entity.WorkspaceSnapshot{ | |||
| 127 | + | Root: &entity.PaneNodeSnapshot{ | |||
| 128 | + | Pane: &entity.PaneSnapshot{ID: "pane-1"}, | |||
| 129 | + | }, | |||
| 130 | + | }, | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | Workspace: entity.WorkspaceSnapshot{ | |||
| 134 | + | Root: &entity.PaneNodeSnapshot{ | |||
| 135 | + | Children: []*entity.PaneNodeSnapshot{ | |||
| 136 | + | {Pane: &entity.PaneSnapshot{ID: "pane-2"}}, | |||
| 137 | + | {Pane: &entity.PaneSnapshot{ID: "pane-3"}}, | |||
| 138 | + | }, | |||
| 139 | + | }, | |||
| 140 | + | }, | |||
| 141 | + | }, | |||
| 142 | + | }, | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | assert.Equal(t, 3, state.CountPanes()) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestGetRelativeTime(t *testing.T) { | |||
| 149 | + | now := time.Now() | |||
| 150 | + | ||||
| 151 | + | tests := []struct { | |||
| 152 | + | name string | |||
| 153 | + | t time.Time | |||
| 154 | + | expected string | |||
| 155 | + | }{ | |||
| 156 | + | {"just now", now.Add(-30 * time.Second), "just now"}, | |||
| 157 | + | {"1 minute", now.Add(-1 * time.Minute), "1m ago"}, | |||
| 158 | + | {"5 minutes", now.Add(-5 * time.Minute), "05m ago"}, | |||
| 159 | + | {"1 hour", now.Add(-1 * time.Hour), "1h ago"}, | |||
| 160 | + | {"3 hours", now.Add(-3 * time.Hour), "03h ago"}, | |||
| 161 | + | {"1 day", now.Add(-24 * time.Hour), "1d ago"}, | |||
| 162 | + | {"2 days", now.Add(-48 * time.Hour), "02d ago"}, | |||
| 163 | + | {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"}, | |||
| 164 | + | {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"}, | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | for _, tt := range tests { | |||
| 168 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 169 | + | result := usecase.GetRelativeTime(tt.t) | |||
| 170 | + | assert.Equal(t, tt.expected, result) | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | } | |||