refactor(omnibox): scope history suggestions by age (#436)
* refactor(omnibox): scope history suggestions by age * perf(omnibox): preserve bounded history query plans * test(history): harden bounded query benchmarks
- commit
- cbc3a47f42cf3a3af3cedb7b59e3afc239523c9a
- author
- bnema · · 2026-09-14 20:11 UTC
- committer
- GitHub · · 2026-09-14 20:11 UTC
- parent
- acc7e1e
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
29 files · +1599 -228
M CHANGELOG.md +1 -0
| @@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file. | |||
| 17 | 17 | - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration. | |
| 18 | 18 | - **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader. | |
| 19 | 19 | - **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window. | |
| 20 | + | - **Omnibox history window**: Replaced `omnibox.most_visited_days` with `omnibox.max_history_days`. The window now applies to typed omnibox searches and to both empty-input lists (`recent` and `most_visited`), filtered in SQL before result limits. `dumber config migrate` renames the legacy key while preserving its value (including `0`). | |
| 20 | 21 | - **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization. | |
| 21 | 22 | - **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata. | |
| 22 | 23 | ||
| @@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file. | |||||
| 17 | - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration. | 17 | - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration. | ||
| 18 | - **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader. | 18 | - **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader. | ||
| 19 | - **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window. | 19 | - **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window. | ||
| 20 | + | - **Omnibox history window**: Replaced `omnibox.most_visited_days` with `omnibox.max_history_days`. The window now applies to typed omnibox searches and to both empty-input lists (`recent` and `most_visited`), filtered in SQL before result limits. `dumber config migrate` renames the legacy key while preserving its value (including `0`). | |||
| 20 | - **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization. | 21 | - **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization. | ||
| 21 | - **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata. | 22 | - **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata. | ||
| 22 | 23 | ||||
M docs/config/index.md +6 -2
| @@ -105,7 +105,7 @@ dumber dmenu --days 14 --most-visited | |||
| 105 | 105 | |-----|------|---------|--------------|-------------| | |
| 106 | 106 | | `omnibox.style` | string | `"command"` | `multiplexer`, `command`, `minimal` | Omnibox visual treatment | | |
| 107 | 107 | | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior; press `Ctrl+R` in the omnibox to switch | | |
| 108 | - | | `omnibox.most_visited_days` | int | `30` | `>= 0` | Days of history to consider when `initial_behavior = "most_visited"` (`0` = all history) | | |
| 108 | + | | `omnibox.max_history_days` | int | `30` | `>= 0` | Days of history used by omnibox suggestions, for both typed searches and the empty-input list (`0` = all history) | | |
| 109 | 109 | | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane | | |
| 110 | 110 | ||
| 111 | 111 | Changing `omnibox.style` takes effect after restarting Dumber. | |
| @@ -115,7 +115,7 @@ Changing `omnibox.style` takes effect after restarting Dumber. | |||
| 115 | 115 | [omnibox] | |
| 116 | 116 | style = "command" # Soft terminal-inspired command palette | |
| 117 | 117 | initial_behavior = "recent" # Show recent history when omnibox opens | |
| 118 | - | most_visited_days = 30 # Days of history used for most_visited | |
| 118 | + | max_history_days = 30 # History window for omnibox searches and initial list | |
| 119 | 119 | auto_open_on_new_pane = false | |
| 120 | 120 | ||
| 121 | 121 | # Alternative visual treatments: | |
| @@ -127,6 +127,10 @@ auto_open_on_new_pane = false | |||
| 127 | 127 | # initial_behavior = "none" # Show no initial suggestions | |
| 128 | 128 | ``` | |
| 129 | 129 | ||
| 130 | + | > **Note:** `omnibox.max_history_days` is non-destructive: it never deletes history, it only limits which entries omnibox suggestions consider. `most_visited` ranks by lifetime visit counts among URLs whose `last_visited` falls inside the window. | |
| 131 | + | > | |
| 132 | + | > This key replaced `omnibox.most_visited_days`. Run `dumber config migrate` to rename the legacy key while preserving its value. The derived environment variable is `DUMBER_OMNIBOX_MAX_HISTORY_DAYS`; the old `DUMBER_OMNIBOX_MOST_VISITED_DAYS` is no longer read. | |
| 133 | + | ||
| 130 | 134 | ## Logging | |
| 131 | 135 | ||
| 132 | 136 | | Key | Type | Default | Valid Values | Description | | |
| @@ -105,7 +105,7 @@ dumber dmenu --days 14 --most-visited | |||||
| 105 | |-----|------|---------|--------------|-------------| | 105 | |-----|------|---------|--------------|-------------| | ||
| 106 | | `omnibox.style` | string | `"command"` | `multiplexer`, `command`, `minimal` | Omnibox visual treatment | | 106 | | `omnibox.style` | string | `"command"` | `multiplexer`, `command`, `minimal` | Omnibox visual treatment | | ||
| 107 | | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior; press `Ctrl+R` in the omnibox to switch | | 107 | | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior; press `Ctrl+R` in the omnibox to switch | | ||
| 108 | - | | `omnibox.most_visited_days` | int | `30` | `>= 0` | Days of history to consider when `initial_behavior = "most_visited"` (`0` = all history) | | 108 | + | | `omnibox.max_history_days` | int | `30` | `>= 0` | Days of history used by omnibox suggestions, for both typed searches and the empty-input list (`0` = all history) | |
| 109 | | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane | | 109 | | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane | | ||
| 110 | 110 | ||||
| 111 | Changing `omnibox.style` takes effect after restarting Dumber. | 111 | Changing `omnibox.style` takes effect after restarting Dumber. | ||
| @@ -115,7 +115,7 @@ Changing `omnibox.style` takes effect after restarting Dumber. | |||||
| 115 | [omnibox] | 115 | [omnibox] | ||
| 116 | style = "command" # Soft terminal-inspired command palette | 116 | style = "command" # Soft terminal-inspired command palette | ||
| 117 | initial_behavior = "recent" # Show recent history when omnibox opens | 117 | initial_behavior = "recent" # Show recent history when omnibox opens | ||
| 118 | - | most_visited_days = 30 # Days of history used for most_visited | 118 | + | max_history_days = 30 # History window for omnibox searches and initial list |
| 119 | auto_open_on_new_pane = false | 119 | auto_open_on_new_pane = false | ||
| 120 | 120 | ||||
| 121 | # Alternative visual treatments: | 121 | # Alternative visual treatments: | ||
| @@ -127,6 +127,10 @@ auto_open_on_new_pane = false | |||||
| 127 | # initial_behavior = "none" # Show no initial suggestions | 127 | # initial_behavior = "none" # Show no initial suggestions | ||
| 128 | ``` | 128 | ``` | ||
| 129 | 129 | ||||
| 130 | + | > **Note:** `omnibox.max_history_days` is non-destructive: it never deletes history, it only limits which entries omnibox suggestions consider. `most_visited` ranks by lifetime visit counts among URLs whose `last_visited` falls inside the window. | |||
| 131 | + | > | |||
| 132 | + | > This key replaced `omnibox.most_visited_days`. Run `dumber config migrate` to rename the legacy key while preserving its value. The derived environment variable is `DUMBER_OMNIBOX_MAX_HISTORY_DAYS`; the old `DUMBER_OMNIBOX_MOST_VISITED_DAYS` is no longer read. | |||
| 133 | + | ||||
| 130 | ## Logging | 134 | ## Logging | ||
| 131 | 135 | ||||
| 132 | | Key | Type | Default | Valid Values | Description | | 136 | | Key | Type | Default | Valid Values | Description | | ||
M docs/reference/configuration.md +1 -1
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | | `dmenu.sort_by_visit_count` | bool | `true` | | | |
| 22 | 22 | | `omnibox.style` | string | `command` | `multiplexer`, `command`, `minimal` | | |
| 23 | 23 | | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` | | |
| 24 | - | | `omnibox.most_visited_days` | int | `30` | `>= 0` | | |
| 24 | + | | `omnibox.max_history_days` | int | `30` | `>= 0` | | |
| 25 | 25 | | `omnibox.auto_open_on_new_pane` | bool | `false` | | | |
| 26 | 26 | | `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` | | |
| 27 | 27 | | `logging.format` | string | `text` | `text`, `json`, `console` | | |
| @@ -21,7 +21,7 @@ | |||||
| 21 | | `dmenu.sort_by_visit_count` | bool | `true` | | | 21 | | `dmenu.sort_by_visit_count` | bool | `true` | | | ||
| 22 | | `omnibox.style` | string | `command` | `multiplexer`, `command`, `minimal` | | 22 | | `omnibox.style` | string | `command` | `multiplexer`, `command`, `minimal` | | ||
| 23 | | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` | | 23 | | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` | | ||
| 24 | - | | `omnibox.most_visited_days` | int | `30` | `>= 0` | | 24 | + | | `omnibox.max_history_days` | int | `30` | `>= 0` | |
| 25 | | `omnibox.auto_open_on_new_pane` | bool | `false` | | | 25 | | `omnibox.auto_open_on_new_pane` | bool | `false` | | | ||
| 26 | | `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` | | 26 | | `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` | | ||
| 27 | | `logging.format` | string | `text` | `text`, `json`, `console` | | 27 | | `logging.format` | string | `text` | `text`, `json`, `console` | | ||
M internal/application/dto/history.go +3 -0
| @@ -6,6 +6,9 @@ import "github.com/bnema/dumber/internal/domain/entity" | |||
| 6 | 6 | type HistorySearchInput struct { | |
| 7 | 7 | Query string | |
| 8 | 8 | Limit int | |
| 9 | + | // MaxAgeDays restricts results to entries last visited within the last N | |
| 10 | + | // days. Zero or negative means no age restriction (all stored history). | |
| 11 | + | MaxAgeDays int | |
| 9 | 12 | } | |
| 10 | 13 | ||
| 11 | 14 | // HistorySearchOutput holds search results for history search use cases. | |
| @@ -6,6 +6,9 @@ import "github.com/bnema/dumber/internal/domain/entity" | |||||
| 6 | type HistorySearchInput struct { | 6 | type HistorySearchInput struct { | ||
| 7 | Query string | 7 | Query string | ||
| 8 | Limit int | 8 | Limit int | ||
| 9 | + | // MaxAgeDays restricts results to entries last visited within the last N | |||
| 10 | + | // days. Zero or negative means no age restriction (all stored history). | |||
| 11 | + | MaxAgeDays int | |||
| 9 | } | 12 | } | ||
| 10 | 13 | ||||
| 11 | // HistorySearchOutput holds search results for history search use cases. | 14 | // HistorySearchOutput holds search results for history search use cases. | ||
M internal/application/usecase/search_history.go +50 -3
| @@ -96,8 +96,8 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||
| 96 | 96 | ||
| 97 | 97 | limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit) | |
| 98 | 98 | ||
| 99 | - | // Use repository's FTS5 search | |
| 100 | - | matches, err := uc.historyRepo.Search(ctx, input.Query, limit) | |
| 99 | + | // Use repository's FTS5 search with the optional age scope applied in SQL. | |
| 100 | + | matches, err := uc.historyRepo.Search(ctx, input.Query, limit, historyScopeFromDays(input.MaxAgeDays, time.Now())) | |
| 101 | 101 | if err != nil { | |
| 102 | 102 | return nil, fmt.Errorf("failed to search history: %w", err) | |
| 103 | 103 | } | |
| @@ -116,7 +116,7 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||
| 116 | 116 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 117 | 117 | limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | |
| 118 | 118 | ||
| 119 | - | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | |
| 119 | + | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset, repository.HistoryScope{}) | |
| 120 | 120 | if err != nil { | |
| 121 | 121 | return nil, fmt.Errorf("failed to get recent history: %w", err) | |
| 122 | 122 | } | |
| @@ -124,6 +124,53 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||
| 124 | 124 | return entries, nil | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | // GetRecentWithin retrieves up to limit recent history entries visited within | |
| 128 | + | // the last maxAgeDays days. Zero or negative maxAgeDays means all stored | |
| 129 | + | // history; a non-positive limit returns no entries. The age filter and limit | |
| 130 | + | // are applied in SQL. | |
| 131 | + | func (uc *SearchHistoryUseCase) GetRecentWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) { | |
| 132 | + | if limit <= 0 { | |
| 133 | + | return []*entity.HistoryEntry{}, nil | |
| 134 | + | } | |
| 135 | + | limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | |
| 136 | + | ||
| 137 | + | entries, err := uc.historyRepo.GetRecent(ctx, limit, 0, historyScopeFromDays(maxAgeDays, time.Now())) | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, fmt.Errorf("failed to get recent history: %w", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | return entries, nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // GetMostVisitedWithin retrieves up to limit most-visited history entries | |
| 146 | + | // visited within the last maxAgeDays days. Zero or negative maxAgeDays means | |
| 147 | + | // all stored history; a non-positive limit returns no entries. The age filter | |
| 148 | + | // and limit are applied in SQL. | |
| 149 | + | func (uc *SearchHistoryUseCase) GetMostVisitedWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) { | |
| 150 | + | if limit <= 0 { | |
| 151 | + | return []*entity.HistoryEntry{}, nil | |
| 152 | + | } | |
| 153 | + | limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | |
| 154 | + | ||
| 155 | + | entries, err := uc.historyRepo.GetMostVisitedWithin(ctx, limit, historyScopeFromDays(maxAgeDays, time.Now())) | |
| 156 | + | if err != nil { | |
| 157 | + | return nil, fmt.Errorf("failed to get most visited history: %w", err) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | return entries, nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // historyScopeFromDays converts an optional max age in days to an explicit | |
| 164 | + | // history scope. The application boundary normalizes zero and negative values | |
| 165 | + | // to "no age restriction"; config validation rejects negative | |
| 166 | + | // omnibox.max_history_days before it reaches this layer. | |
| 167 | + | func historyScopeFromDays(maxAgeDays int, now time.Time) repository.HistoryScope { | |
| 168 | + | if maxAgeDays <= 0 { | |
| 169 | + | return repository.HistoryScope{} | |
| 170 | + | } | |
| 171 | + | return repository.HistoryScope{Cutoff: now.UTC().AddDate(0, 0, -maxAgeDays)} | |
| 172 | + | } | |
| 173 | + | ||
| 127 | 174 | // GetRecentByDomain retrieves recent history entries for a canonical domain. A | |
| 128 | 175 | // zero limit means all matching entries; negative limits use the default page | |
| 129 | 176 | // size. Positive limits are capped to keep WebUI-originated queries bounded. | |
| @@ -96,8 +96,8 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||||
| 96 | 96 | ||||
| 97 | limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit) | 97 | limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit) | ||
| 98 | 98 | ||||
| 99 | - | // Use repository's FTS5 search | 99 | + | // Use repository's FTS5 search with the optional age scope applied in SQL. |
| 100 | - | matches, err := uc.historyRepo.Search(ctx, input.Query, limit) | 100 | + | matches, err := uc.historyRepo.Search(ctx, input.Query, limit, historyScopeFromDays(input.MaxAgeDays, time.Now())) |
| 101 | if err != nil { | 101 | if err != nil { | ||
| 102 | return nil, fmt.Errorf("failed to search history: %w", err) | 102 | return nil, fmt.Errorf("failed to search history: %w", err) | ||
| 103 | } | 103 | } | ||
| @@ -116,7 +116,7 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||||
| 116 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 116 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 117 | limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | 117 | limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | ||
| 118 | 118 | ||||
| 119 | - | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | 119 | + | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset, repository.HistoryScope{}) |
| 120 | if err != nil { | 120 | if err != nil { | ||
| 121 | return nil, fmt.Errorf("failed to get recent history: %w", err) | 121 | return nil, fmt.Errorf("failed to get recent history: %w", err) | ||
| 122 | } | 122 | } | ||
| @@ -124,6 +124,53 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||||
| 124 | return entries, nil | 124 | return entries, nil | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | + | // GetRecentWithin retrieves up to limit recent history entries visited within | |||
| 128 | + | // the last maxAgeDays days. Zero or negative maxAgeDays means all stored | |||
| 129 | + | // history; a non-positive limit returns no entries. The age filter and limit | |||
| 130 | + | // are applied in SQL. | |||
| 131 | + | func (uc *SearchHistoryUseCase) GetRecentWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) { | |||
| 132 | + | if limit <= 0 { | |||
| 133 | + | return []*entity.HistoryEntry{}, nil | |||
| 134 | + | } | |||
| 135 | + | limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | |||
| 136 | + | ||||
| 137 | + | entries, err := uc.historyRepo.GetRecent(ctx, limit, 0, historyScopeFromDays(maxAgeDays, time.Now())) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return nil, fmt.Errorf("failed to get recent history: %w", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | return entries, nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // GetMostVisitedWithin retrieves up to limit most-visited history entries | |||
| 146 | + | // visited within the last maxAgeDays days. Zero or negative maxAgeDays means | |||
| 147 | + | // all stored history; a non-positive limit returns no entries. The age filter | |||
| 148 | + | // and limit are applied in SQL. | |||
| 149 | + | func (uc *SearchHistoryUseCase) GetMostVisitedWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) { | |||
| 150 | + | if limit <= 0 { | |||
| 151 | + | return []*entity.HistoryEntry{}, nil | |||
| 152 | + | } | |||
| 153 | + | limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) | |||
| 154 | + | ||||
| 155 | + | entries, err := uc.historyRepo.GetMostVisitedWithin(ctx, limit, historyScopeFromDays(maxAgeDays, time.Now())) | |||
| 156 | + | if err != nil { | |||
| 157 | + | return nil, fmt.Errorf("failed to get most visited history: %w", err) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | return entries, nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // historyScopeFromDays converts an optional max age in days to an explicit | |||
| 164 | + | // history scope. The application boundary normalizes zero and negative values | |||
| 165 | + | // to "no age restriction"; config validation rejects negative | |||
| 166 | + | // omnibox.max_history_days before it reaches this layer. | |||
| 167 | + | func historyScopeFromDays(maxAgeDays int, now time.Time) repository.HistoryScope { | |||
| 168 | + | if maxAgeDays <= 0 { | |||
| 169 | + | return repository.HistoryScope{} | |||
| 170 | + | } | |||
| 171 | + | return repository.HistoryScope{Cutoff: now.UTC().AddDate(0, 0, -maxAgeDays)} | |||
| 172 | + | } | |||
| 173 | + | ||||
| 127 | // GetRecentByDomain retrieves recent history entries for a canonical domain. A | 174 | // GetRecentByDomain retrieves recent history entries for a canonical domain. A | ||
| 128 | // zero limit means all matching entries; negative limits use the default page | 175 | // zero limit means all matching entries; negative limits use the default page | ||
| 129 | // size. Positive limits are capped to keep WebUI-originated queries bounded. | 176 | // size. Positive limits are capped to keep WebUI-originated queries bounded. | ||
M internal/application/usecase/search_history_test.go +176 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | repository "github.com/bnema/dumber/internal/domain/repository" | |
| 12 | 13 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 13 | 14 | "github.com/stretchr/testify/assert" | |
| 14 | 15 | "github.com/stretchr/testify/mock" | |
| @@ -63,7 +64,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |||
| 63 | 64 | ctx := testContext() | |
| 64 | 65 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 65 | 66 | entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}} | |
| 66 | - | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once() | |
| 67 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0, repository.HistoryScope{}).Return(entries, nil).Once() | |
| 67 | 68 | ||
| 68 | 69 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 69 | 70 | result, err := uc.GetRecent(ctx, 0, 0) | |
| @@ -75,7 +76,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |||
| 75 | 76 | func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) { | |
| 76 | 77 | ctx := testContext() | |
| 77 | 78 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 78 | - | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once() | |
| 79 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once() | |
| 79 | 80 | ||
| 80 | 81 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 81 | 82 | result, err := uc.GetRecent(ctx, -1, 0) | |
| @@ -87,7 +88,7 @@ func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testi | |||
| 87 | 88 | func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) { | |
| 88 | 89 | ctx := testContext() | |
| 89 | 90 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 90 | - | historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10).Return([]*entity.HistoryEntry{}, nil).Once() | |
| 91 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once() | |
| 91 | 92 | ||
| 92 | 93 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 93 | 94 | result, err := uc.GetRecent(ctx, 10_000, 10) | |
| @@ -99,7 +100,7 @@ func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) { | |||
| 99 | 100 | func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) { | |
| 100 | 101 | ctx := testContext() | |
| 101 | 102 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 102 | - | historyRepo.EXPECT().Search(mock.Anything, "dumber", 100).Return([]entity.HistoryMatch{}, nil).Once() | |
| 103 | + | historyRepo.EXPECT().Search(mock.Anything, "dumber", 100, repository.HistoryScope{}).Return([]entity.HistoryMatch{}, nil).Once() | |
| 103 | 104 | ||
| 104 | 105 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 105 | 106 | result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000}) | |
| @@ -109,6 +110,99 @@ func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) { | |||
| 109 | 110 | assert.Empty(t, result.Matches) | |
| 110 | 111 | } | |
| 111 | 112 | ||
| 113 | + | func TestSearchHistoryUseCase_Search_AppliesOptionalAgeScope(t *testing.T) { | |
| 114 | + | ctx := testContext() | |
| 115 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 116 | + | var gotScope repository.HistoryScope | |
| 117 | + | historyRepo.EXPECT(). | |
| 118 | + | Search(mock.Anything, "dumber", 20, mock.Anything). | |
| 119 | + | Run(func(_ context.Context, _ string, _ int, scope repository.HistoryScope) { gotScope = scope }). | |
| 120 | + | Return([]entity.HistoryMatch{}, nil). | |
| 121 | + | Once() | |
| 122 | + | ||
| 123 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 124 | + | _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 30}) | |
| 125 | + | ||
| 126 | + | require.NoError(t, err) | |
| 127 | + | assert.WithinDuration(t, time.Now().AddDate(0, 0, -30), gotScope.Cutoff, time.Minute) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestSearchHistoryUseCase_Search_ZeroMaxAgeMeansAllHistory(t *testing.T) { | |
| 131 | + | ctx := testContext() | |
| 132 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 133 | + | historyRepo.EXPECT(). | |
| 134 | + | Search(mock.Anything, "dumber", 20, repository.HistoryScope{}). | |
| 135 | + | Return([]entity.HistoryMatch{}, nil). | |
| 136 | + | Once() | |
| 137 | + | ||
| 138 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 139 | + | _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 0}) | |
| 140 | + | ||
| 141 | + | require.NoError(t, err) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestSearchHistoryUseCase_GetRecentWithin_AppliesScopeAndLimit(t *testing.T) { | |
| 145 | + | ctx := testContext() | |
| 146 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 147 | + | var gotScope repository.HistoryScope | |
| 148 | + | historyRepo.EXPECT(). | |
| 149 | + | GetRecent(mock.Anything, 8, 0, mock.Anything). | |
| 150 | + | Run(func(_ context.Context, _ int, _ int, scope repository.HistoryScope) { gotScope = scope }). | |
| 151 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 152 | + | Once() | |
| 153 | + | ||
| 154 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 155 | + | _, err := uc.GetRecentWithin(ctx, 8, 14) | |
| 156 | + | ||
| 157 | + | require.NoError(t, err) | |
| 158 | + | assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestSearchHistoryUseCase_GetRecentWithin_ZeroMeansAllHistory(t *testing.T) { | |
| 162 | + | ctx := testContext() | |
| 163 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 164 | + | historyRepo.EXPECT(). | |
| 165 | + | GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}). | |
| 166 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 167 | + | Once() | |
| 168 | + | ||
| 169 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 170 | + | _, err := uc.GetRecentWithin(ctx, 8, 0) | |
| 171 | + | ||
| 172 | + | require.NoError(t, err) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_AppliesScopeAndLimit(t *testing.T) { | |
| 176 | + | ctx := testContext() | |
| 177 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 178 | + | var gotScope repository.HistoryScope | |
| 179 | + | historyRepo.EXPECT(). | |
| 180 | + | GetMostVisitedWithin(mock.Anything, 8, mock.Anything). | |
| 181 | + | Run(func(_ context.Context, _ int, scope repository.HistoryScope) { gotScope = scope }). | |
| 182 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 183 | + | Once() | |
| 184 | + | ||
| 185 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 186 | + | _, err := uc.GetMostVisitedWithin(ctx, 8, 14) | |
| 187 | + | ||
| 188 | + | require.NoError(t, err) | |
| 189 | + | assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_ZeroMeansAllHistory(t *testing.T) { | |
| 193 | + | ctx := testContext() | |
| 194 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 195 | + | historyRepo.EXPECT(). | |
| 196 | + | GetMostVisitedWithin(mock.Anything, 8, repository.HistoryScope{}). | |
| 197 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 198 | + | Once() | |
| 199 | + | ||
| 200 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 201 | + | _, err := uc.GetMostVisitedWithin(ctx, 8, 0) | |
| 202 | + | ||
| 203 | + | require.NoError(t, err) | |
| 204 | + | } | |
| 205 | + | ||
| 112 | 206 | func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) { | |
| 113 | 207 | ctx := testContext() | |
| 114 | 208 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| @@ -817,3 +911,81 @@ func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T | |||
| 817 | 911 | ||
| 818 | 912 | require.NoError(t, err) | |
| 819 | 913 | } | |
| 914 | + | ||
| 915 | + | func TestSearchHistoryUseCase_Search_NegativeMaxAgeMeansAllHistory(t *testing.T) { | |
| 916 | + | ctx := testContext() | |
| 917 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 918 | + | historyRepo.EXPECT(). | |
| 919 | + | Search(mock.Anything, "dumber", 20, repository.HistoryScope{}). | |
| 920 | + | Return([]entity.HistoryMatch{}, nil). | |
| 921 | + | Once() | |
| 922 | + | ||
| 923 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 924 | + | _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: -5}) | |
| 925 | + | ||
| 926 | + | require.NoError(t, err) | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | func TestSearchHistoryUseCase_GetRecentWithin_NegativeMaxAgeMeansAllHistory(t *testing.T) { | |
| 930 | + | ctx := testContext() | |
| 931 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 932 | + | historyRepo.EXPECT(). | |
| 933 | + | GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}). | |
| 934 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 935 | + | Once() | |
| 936 | + | ||
| 937 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 938 | + | _, err := uc.GetRecentWithin(ctx, 8, -5) | |
| 939 | + | ||
| 940 | + | require.NoError(t, err) | |
| 941 | + | } | |
| 942 | + | ||
| 943 | + | func TestSearchHistoryUseCase_GetRecentWithin_NonPositiveLimitReturnsEmpty(t *testing.T) { | |
| 944 | + | ctx := testContext() | |
| 945 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 946 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 947 | + | ||
| 948 | + | results, err := uc.GetRecentWithin(ctx, 0, 30) | |
| 949 | + | ||
| 950 | + | require.NoError(t, err) | |
| 951 | + | assert.Empty(t, results) | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_NonPositiveLimitReturnsEmpty(t *testing.T) { | |
| 955 | + | ctx := testContext() | |
| 956 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 957 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 958 | + | ||
| 959 | + | results, err := uc.GetMostVisitedWithin(ctx, -3, 30) | |
| 960 | + | ||
| 961 | + | require.NoError(t, err) | |
| 962 | + | assert.Empty(t, results) | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | func TestSearchHistoryUseCase_GetRecentWithin_ClampsOversizedLimit(t *testing.T) { | |
| 966 | + | ctx := testContext() | |
| 967 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 968 | + | historyRepo.EXPECT(). | |
| 969 | + | GetRecent(mock.Anything, 500, 0, mock.Anything). | |
| 970 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 971 | + | Once() | |
| 972 | + | ||
| 973 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 974 | + | _, err := uc.GetRecentWithin(ctx, 10_000, 30) | |
| 975 | + | ||
| 976 | + | require.NoError(t, err) | |
| 977 | + | } | |
| 978 | + | ||
| 979 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_ClampsOversizedLimit(t *testing.T) { | |
| 980 | + | ctx := testContext() | |
| 981 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 982 | + | historyRepo.EXPECT(). | |
| 983 | + | GetMostVisitedWithin(mock.Anything, 500, mock.Anything). | |
| 984 | + | Return([]*entity.HistoryEntry{}, nil). | |
| 985 | + | Once() | |
| 986 | + | ||
| 987 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 988 | + | _, err := uc.GetMostVisitedWithin(ctx, 10_000, 30) | |
| 989 | + | ||
| 990 | + | require.NoError(t, err) | |
| 991 | + | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/application/dto" | 9 | "github.com/bnema/dumber/internal/application/dto" | ||
| 10 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | + | repository "github.com/bnema/dumber/internal/domain/repository" | |||
| 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | 13 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | ||
| 13 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/mock" | 15 | "github.com/stretchr/testify/mock" | ||
| @@ -63,7 +64,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |||||
| 63 | ctx := testContext() | 64 | ctx := testContext() | ||
| 64 | historyRepo := repomocks.NewMockHistoryRepository(t) | 65 | historyRepo := repomocks.NewMockHistoryRepository(t) | ||
| 65 | entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}} | 66 | entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}} | ||
| 66 | - | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once() | 67 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0, repository.HistoryScope{}).Return(entries, nil).Once() |
| 67 | 68 | ||||
| 68 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | 69 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | ||
| 69 | result, err := uc.GetRecent(ctx, 0, 0) | 70 | result, err := uc.GetRecent(ctx, 0, 0) | ||
| @@ -75,7 +76,7 @@ func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |||||
| 75 | func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) { | 76 | func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) { | ||
| 76 | ctx := testContext() | 77 | ctx := testContext() | ||
| 77 | historyRepo := repomocks.NewMockHistoryRepository(t) | 78 | historyRepo := repomocks.NewMockHistoryRepository(t) | ||
| 78 | - | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once() | 79 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once() |
| 79 | 80 | ||||
| 80 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | 81 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | ||
| 81 | result, err := uc.GetRecent(ctx, -1, 0) | 82 | result, err := uc.GetRecent(ctx, -1, 0) | ||
| @@ -87,7 +88,7 @@ func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testi | |||||
| 87 | func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) { | 88 | func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) { | ||
| 88 | ctx := testContext() | 89 | ctx := testContext() | ||
| 89 | historyRepo := repomocks.NewMockHistoryRepository(t) | 90 | historyRepo := repomocks.NewMockHistoryRepository(t) | ||
| 90 | - | historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10).Return([]*entity.HistoryEntry{}, nil).Once() | 91 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once() |
| 91 | 92 | ||||
| 92 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | 93 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | ||
| 93 | result, err := uc.GetRecent(ctx, 10_000, 10) | 94 | result, err := uc.GetRecent(ctx, 10_000, 10) | ||
| @@ -99,7 +100,7 @@ func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) { | |||||
| 99 | func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) { | 100 | func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) { | ||
| 100 | ctx := testContext() | 101 | ctx := testContext() | ||
| 101 | historyRepo := repomocks.NewMockHistoryRepository(t) | 102 | historyRepo := repomocks.NewMockHistoryRepository(t) | ||
| 102 | - | historyRepo.EXPECT().Search(mock.Anything, "dumber", 100).Return([]entity.HistoryMatch{}, nil).Once() | 103 | + | historyRepo.EXPECT().Search(mock.Anything, "dumber", 100, repository.HistoryScope{}).Return([]entity.HistoryMatch{}, nil).Once() |
| 103 | 104 | ||||
| 104 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | 105 | uc := usecase.NewSearchHistoryUseCase(historyRepo) | ||
| 105 | result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000}) | 106 | result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000}) | ||
| @@ -109,6 +110,99 @@ func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) { | |||||
| 109 | assert.Empty(t, result.Matches) | 110 | assert.Empty(t, result.Matches) | ||
| 110 | } | 111 | } | ||
| 111 | 112 | ||||
| 113 | + | func TestSearchHistoryUseCase_Search_AppliesOptionalAgeScope(t *testing.T) { | |||
| 114 | + | ctx := testContext() | |||
| 115 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 116 | + | var gotScope repository.HistoryScope | |||
| 117 | + | historyRepo.EXPECT(). | |||
| 118 | + | Search(mock.Anything, "dumber", 20, mock.Anything). | |||
| 119 | + | Run(func(_ context.Context, _ string, _ int, scope repository.HistoryScope) { gotScope = scope }). | |||
| 120 | + | Return([]entity.HistoryMatch{}, nil). | |||
| 121 | + | Once() | |||
| 122 | + | ||||
| 123 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 124 | + | _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 30}) | |||
| 125 | + | ||||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | assert.WithinDuration(t, time.Now().AddDate(0, 0, -30), gotScope.Cutoff, time.Minute) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestSearchHistoryUseCase_Search_ZeroMaxAgeMeansAllHistory(t *testing.T) { | |||
| 131 | + | ctx := testContext() | |||
| 132 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 133 | + | historyRepo.EXPECT(). | |||
| 134 | + | Search(mock.Anything, "dumber", 20, repository.HistoryScope{}). | |||
| 135 | + | Return([]entity.HistoryMatch{}, nil). | |||
| 136 | + | Once() | |||
| 137 | + | ||||
| 138 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 139 | + | _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 0}) | |||
| 140 | + | ||||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestSearchHistoryUseCase_GetRecentWithin_AppliesScopeAndLimit(t *testing.T) { | |||
| 145 | + | ctx := testContext() | |||
| 146 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 147 | + | var gotScope repository.HistoryScope | |||
| 148 | + | historyRepo.EXPECT(). | |||
| 149 | + | GetRecent(mock.Anything, 8, 0, mock.Anything). | |||
| 150 | + | Run(func(_ context.Context, _ int, _ int, scope repository.HistoryScope) { gotScope = scope }). | |||
| 151 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 152 | + | Once() | |||
| 153 | + | ||||
| 154 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 155 | + | _, err := uc.GetRecentWithin(ctx, 8, 14) | |||
| 156 | + | ||||
| 157 | + | require.NoError(t, err) | |||
| 158 | + | assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestSearchHistoryUseCase_GetRecentWithin_ZeroMeansAllHistory(t *testing.T) { | |||
| 162 | + | ctx := testContext() | |||
| 163 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 164 | + | historyRepo.EXPECT(). | |||
| 165 | + | GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}). | |||
| 166 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 167 | + | Once() | |||
| 168 | + | ||||
| 169 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 170 | + | _, err := uc.GetRecentWithin(ctx, 8, 0) | |||
| 171 | + | ||||
| 172 | + | require.NoError(t, err) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_AppliesScopeAndLimit(t *testing.T) { | |||
| 176 | + | ctx := testContext() | |||
| 177 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 178 | + | var gotScope repository.HistoryScope | |||
| 179 | + | historyRepo.EXPECT(). | |||
| 180 | + | GetMostVisitedWithin(mock.Anything, 8, mock.Anything). | |||
| 181 | + | Run(func(_ context.Context, _ int, scope repository.HistoryScope) { gotScope = scope }). | |||
| 182 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 183 | + | Once() | |||
| 184 | + | ||||
| 185 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 186 | + | _, err := uc.GetMostVisitedWithin(ctx, 8, 14) | |||
| 187 | + | ||||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_ZeroMeansAllHistory(t *testing.T) { | |||
| 193 | + | ctx := testContext() | |||
| 194 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 195 | + | historyRepo.EXPECT(). | |||
| 196 | + | GetMostVisitedWithin(mock.Anything, 8, repository.HistoryScope{}). | |||
| 197 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 198 | + | Once() | |||
| 199 | + | ||||
| 200 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 201 | + | _, err := uc.GetMostVisitedWithin(ctx, 8, 0) | |||
| 202 | + | ||||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 112 | func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) { | 206 | func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) { | ||
| 113 | ctx := testContext() | 207 | ctx := testContext() | ||
| 114 | historyRepo := repomocks.NewMockHistoryRepository(t) | 208 | historyRepo := repomocks.NewMockHistoryRepository(t) | ||
| @@ -817,3 +911,81 @@ func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T | |||||
| 817 | 911 | ||||
| 818 | require.NoError(t, err) | 912 | require.NoError(t, err) | ||
| 819 | } | 913 | } | ||
| 914 | + | ||||
| 915 | + | func TestSearchHistoryUseCase_Search_NegativeMaxAgeMeansAllHistory(t *testing.T) { | |||
| 916 | + | ctx := testContext() | |||
| 917 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 918 | + | historyRepo.EXPECT(). | |||
| 919 | + | Search(mock.Anything, "dumber", 20, repository.HistoryScope{}). | |||
| 920 | + | Return([]entity.HistoryMatch{}, nil). | |||
| 921 | + | Once() | |||
| 922 | + | ||||
| 923 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 924 | + | _, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: -5}) | |||
| 925 | + | ||||
| 926 | + | require.NoError(t, err) | |||
| 927 | + | } | |||
| 928 | + | ||||
| 929 | + | func TestSearchHistoryUseCase_GetRecentWithin_NegativeMaxAgeMeansAllHistory(t *testing.T) { | |||
| 930 | + | ctx := testContext() | |||
| 931 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 932 | + | historyRepo.EXPECT(). | |||
| 933 | + | GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}). | |||
| 934 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 935 | + | Once() | |||
| 936 | + | ||||
| 937 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 938 | + | _, err := uc.GetRecentWithin(ctx, 8, -5) | |||
| 939 | + | ||||
| 940 | + | require.NoError(t, err) | |||
| 941 | + | } | |||
| 942 | + | ||||
| 943 | + | func TestSearchHistoryUseCase_GetRecentWithin_NonPositiveLimitReturnsEmpty(t *testing.T) { | |||
| 944 | + | ctx := testContext() | |||
| 945 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 946 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 947 | + | ||||
| 948 | + | results, err := uc.GetRecentWithin(ctx, 0, 30) | |||
| 949 | + | ||||
| 950 | + | require.NoError(t, err) | |||
| 951 | + | assert.Empty(t, results) | |||
| 952 | + | } | |||
| 953 | + | ||||
| 954 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_NonPositiveLimitReturnsEmpty(t *testing.T) { | |||
| 955 | + | ctx := testContext() | |||
| 956 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 957 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 958 | + | ||||
| 959 | + | results, err := uc.GetMostVisitedWithin(ctx, -3, 30) | |||
| 960 | + | ||||
| 961 | + | require.NoError(t, err) | |||
| 962 | + | assert.Empty(t, results) | |||
| 963 | + | } | |||
| 964 | + | ||||
| 965 | + | func TestSearchHistoryUseCase_GetRecentWithin_ClampsOversizedLimit(t *testing.T) { | |||
| 966 | + | ctx := testContext() | |||
| 967 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 968 | + | historyRepo.EXPECT(). | |||
| 969 | + | GetRecent(mock.Anything, 500, 0, mock.Anything). | |||
| 970 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 971 | + | Once() | |||
| 972 | + | ||||
| 973 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 974 | + | _, err := uc.GetRecentWithin(ctx, 10_000, 30) | |||
| 975 | + | ||||
| 976 | + | require.NoError(t, err) | |||
| 977 | + | } | |||
| 978 | + | ||||
| 979 | + | func TestSearchHistoryUseCase_GetMostVisitedWithin_ClampsOversizedLimit(t *testing.T) { | |||
| 980 | + | ctx := testContext() | |||
| 981 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 982 | + | historyRepo.EXPECT(). | |||
| 983 | + | GetMostVisitedWithin(mock.Anything, 500, mock.Anything). | |||
| 984 | + | Return([]*entity.HistoryEntry{}, nil). | |||
| 985 | + | Once() | |||
| 986 | + | ||||
| 987 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 988 | + | _, err := uc.GetMostVisitedWithin(ctx, 10_000, 30) | |||
| 989 | + | ||||
| 990 | + | require.NoError(t, err) | |||
| 991 | + | } | |||
M internal/bootstrap/runtime_config.go +1 -1
| @@ -98,7 +98,7 @@ func RuntimeConfigSnapshotFromConfig(cfg *config.Config) entity.RuntimeConfigSna | |||
| 98 | 98 | Omnibox: entity.RuntimeOmniboxConfig{ | |
| 99 | 99 | Style: cfg.Omnibox.Style, | |
| 100 | 100 | InitialBehavior: cfg.Omnibox.InitialBehavior, | |
| 101 | - | MostVisitedDays: cfg.Omnibox.MostVisitedDays, | |
| 101 | + | MaxHistoryDays: cfg.Omnibox.MaxHistoryDays, | |
| 102 | 102 | AutoOpenOnNewPane: cfg.Omnibox.AutoOpenOnNewPane, | |
| 103 | 103 | }, | |
| 104 | 104 | Update: entity.RuntimeUpdateConfig{ | |
| @@ -98,7 +98,7 @@ func RuntimeConfigSnapshotFromConfig(cfg *config.Config) entity.RuntimeConfigSna | |||||
| 98 | Omnibox: entity.RuntimeOmniboxConfig{ | 98 | Omnibox: entity.RuntimeOmniboxConfig{ | ||
| 99 | Style: cfg.Omnibox.Style, | 99 | Style: cfg.Omnibox.Style, | ||
| 100 | InitialBehavior: cfg.Omnibox.InitialBehavior, | 100 | InitialBehavior: cfg.Omnibox.InitialBehavior, | ||
| 101 | - | MostVisitedDays: cfg.Omnibox.MostVisitedDays, | 101 | + | MaxHistoryDays: cfg.Omnibox.MaxHistoryDays, |
| 102 | AutoOpenOnNewPane: cfg.Omnibox.AutoOpenOnNewPane, | 102 | AutoOpenOnNewPane: cfg.Omnibox.AutoOpenOnNewPane, | ||
| 103 | }, | 103 | }, | ||
| 104 | Update: entity.RuntimeUpdateConfig{ | 104 | Update: entity.RuntimeUpdateConfig{ | ||
M internal/domain/entity/runtime_config.go +1 -1
| @@ -66,7 +66,7 @@ type RuntimeSearchShortcut struct { | |||
| 66 | 66 | type RuntimeOmniboxConfig struct { | |
| 67 | 67 | Style string | |
| 68 | 68 | InitialBehavior OmniboxInitialBehavior | |
| 69 | - | MostVisitedDays int | |
| 69 | + | MaxHistoryDays int | |
| 70 | 70 | AutoOpenOnNewPane bool | |
| 71 | 71 | } | |
| 72 | 72 | ||
| @@ -66,7 +66,7 @@ type RuntimeSearchShortcut struct { | |||||
| 66 | type RuntimeOmniboxConfig struct { | 66 | type RuntimeOmniboxConfig struct { | ||
| 67 | Style string | 67 | Style string | ||
| 68 | InitialBehavior OmniboxInitialBehavior | 68 | InitialBehavior OmniboxInitialBehavior | ||
| 69 | - | MostVisitedDays int | 69 | + | MaxHistoryDays int |
| 70 | AutoOpenOnNewPane bool | 70 | AutoOpenOnNewPane bool | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
M internal/domain/repository/history.go +20 -4
| @@ -7,6 +7,13 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | // HistoryScope constrains a history query to an optional age window. Cutoff is | |
| 11 | + | // the inclusive lower bound for an entry's last_visited timestamp. The zero | |
| 12 | + | // time disables age filtering, so all stored history is eligible. | |
| 13 | + | type HistoryScope struct { | |
| 14 | + | Cutoff time.Time | |
| 15 | + | } | |
| 16 | + | ||
| 10 | 17 | // HistoryRepository defines operations for browsing history persistence. | |
| 11 | 18 | type HistoryRepository interface { | |
| 12 | 19 | // Save creates or updates a history entry (upsert). | |
| @@ -15,11 +22,15 @@ type HistoryRepository interface { | |||
| 15 | 22 | // FindByURL retrieves a history entry by its URL. | |
| 16 | 23 | FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) | |
| 17 | 24 | ||
| 18 | - | // Search performs a fuzzy search on history entries. | |
| 19 | - | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | |
| 25 | + | // Search performs a fuzzy search on history entries. Results older than | |
| 26 | + | // scope.Cutoff are excluded in SQL before the result limit is applied. A | |
| 27 | + | // non-positive limit returns no matches. | |
| 28 | + | Search(ctx context.Context, query string, limit int, scope HistoryScope) ([]entity.HistoryMatch, error) | |
| 20 | 29 | ||
| 21 | - | // GetRecent retrieves recent history entries with pagination. A zero limit means all entries. | |
| 22 | - | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 30 | + | // GetRecent retrieves recent history entries with pagination. A non-positive | |
| 31 | + | // limit means all entries. Results older than scope.Cutoff are excluded in | |
| 32 | + | // SQL before the limit is applied. | |
| 33 | + | GetRecent(ctx context.Context, limit, offset int, scope HistoryScope) ([]*entity.HistoryEntry, error) | |
| 23 | 34 | ||
| 24 | 35 | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | |
| 25 | 36 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |
| @@ -38,6 +49,11 @@ type HistoryRepository interface { | |||
| 38 | 49 | // days must be > 0. | |
| 39 | 50 | GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |
| 40 | 51 | ||
| 52 | + | // GetMostVisitedWithin retrieves history entries ordered by visit count, | |
| 53 | + | // optionally restricted to scope.Cutoff and capped by limit in SQL. A | |
| 54 | + | // non-positive limit returns an empty slice. | |
| 55 | + | GetMostVisitedWithin(ctx context.Context, limit int, scope HistoryScope) ([]*entity.HistoryEntry, error) | |
| 56 | + | ||
| 41 | 57 | // GetAllRecentHistory retrieves all history entries sorted by recency. | |
| 42 | 58 | GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) | |
| 43 | 59 | ||
| @@ -7,6 +7,13 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | // HistoryScope constrains a history query to an optional age window. Cutoff is | |||
| 11 | + | // the inclusive lower bound for an entry's last_visited timestamp. The zero | |||
| 12 | + | // time disables age filtering, so all stored history is eligible. | |||
| 13 | + | type HistoryScope struct { | |||
| 14 | + | Cutoff time.Time | |||
| 15 | + | } | |||
| 16 | + | ||||
| 10 | // HistoryRepository defines operations for browsing history persistence. | 17 | // HistoryRepository defines operations for browsing history persistence. | ||
| 11 | type HistoryRepository interface { | 18 | type HistoryRepository interface { | ||
| 12 | // Save creates or updates a history entry (upsert). | 19 | // Save creates or updates a history entry (upsert). | ||
| @@ -15,11 +22,15 @@ type HistoryRepository interface { | |||||
| 15 | // FindByURL retrieves a history entry by its URL. | 22 | // FindByURL retrieves a history entry by its URL. | ||
| 16 | FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) | 23 | FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) | ||
| 17 | 24 | ||||
| 18 | - | // Search performs a fuzzy search on history entries. | 25 | + | // Search performs a fuzzy search on history entries. Results older than |
| 19 | - | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | 26 | + | // scope.Cutoff are excluded in SQL before the result limit is applied. A |
| 27 | + | // non-positive limit returns no matches. | |||
| 28 | + | Search(ctx context.Context, query string, limit int, scope HistoryScope) ([]entity.HistoryMatch, error) | |||
| 20 | 29 | ||||
| 21 | - | // GetRecent retrieves recent history entries with pagination. A zero limit means all entries. | 30 | + | // GetRecent retrieves recent history entries with pagination. A non-positive |
| 22 | - | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 31 | + | // limit means all entries. Results older than scope.Cutoff are excluded in |
| 32 | + | // SQL before the limit is applied. | |||
| 33 | + | GetRecent(ctx context.Context, limit, offset int, scope HistoryScope) ([]*entity.HistoryEntry, error) | |||
| 23 | 34 | ||||
| 24 | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | 35 | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | ||
| 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 36 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| @@ -38,6 +49,11 @@ type HistoryRepository interface { | |||||
| 38 | // days must be > 0. | 49 | // days must be > 0. | ||
| 39 | GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | 50 | GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | ||
| 40 | 51 | ||||
| 52 | + | // GetMostVisitedWithin retrieves history entries ordered by visit count, | |||
| 53 | + | // optionally restricted to scope.Cutoff and capped by limit in SQL. A | |||
| 54 | + | // non-positive limit returns an empty slice. | |||
| 55 | + | GetMostVisitedWithin(ctx context.Context, limit int, scope HistoryScope) ([]*entity.HistoryEntry, error) | |||
| 56 | + | ||||
| 41 | // GetAllRecentHistory retrieves all history entries sorted by recency. | 57 | // GetAllRecentHistory retrieves all history entries sorted by recency. | ||
| 42 | GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) | 58 | GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) | ||
| 43 | 59 | ||||
M internal/domain/repository/mocks/history.go +111 -24
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 12 | 13 | mock "github.com/stretchr/testify/mock" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -776,9 +777,83 @@ func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx c | |||
| 776 | 777 | return _c | |
| 777 | 778 | } | |
| 778 | 779 | ||
| 780 | + | // GetMostVisitedWithin provides a mock function for the type MockHistoryRepository | |
| 781 | + | func (_mock *MockHistoryRepository) GetMostVisitedWithin(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) { | |
| 782 | + | ret := _mock.Called(ctx, limit, scope) | |
| 783 | + | ||
| 784 | + | if len(ret) == 0 { | |
| 785 | + | panic("no return value specified for GetMostVisitedWithin") | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | var r0 []*entity.HistoryEntry | |
| 789 | + | var r1 error | |
| 790 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok { | |
| 791 | + | return returnFunc(ctx, limit, scope) | |
| 792 | + | } | |
| 793 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) []*entity.HistoryEntry); ok { | |
| 794 | + | r0 = returnFunc(ctx, limit, scope) | |
| 795 | + | } else { | |
| 796 | + | if ret.Get(0) != nil { | |
| 797 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 798 | + | } | |
| 799 | + | } | |
| 800 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int, repository.HistoryScope) error); ok { | |
| 801 | + | r1 = returnFunc(ctx, limit, scope) | |
| 802 | + | } else { | |
| 803 | + | r1 = ret.Error(1) | |
| 804 | + | } | |
| 805 | + | return r0, r1 | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | // MockHistoryRepository_GetMostVisitedWithin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisitedWithin' | |
| 809 | + | type MockHistoryRepository_GetMostVisitedWithin_Call struct { | |
| 810 | + | *mock.Call | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | // GetMostVisitedWithin is a helper method to define mock.On call | |
| 814 | + | // - ctx context.Context | |
| 815 | + | // - limit int | |
| 816 | + | // - scope repository.HistoryScope | |
| 817 | + | func (_e *MockHistoryRepository_Expecter) GetMostVisitedWithin(ctx any, limit any, scope any) *MockHistoryRepository_GetMostVisitedWithin_Call { | |
| 818 | + | return &MockHistoryRepository_GetMostVisitedWithin_Call{Call: _e.mock.On("GetMostVisitedWithin", ctx, limit, scope)} | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Run(run func(ctx context.Context, limit int, scope repository.HistoryScope)) *MockHistoryRepository_GetMostVisitedWithin_Call { | |
| 822 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 823 | + | var arg0 context.Context | |
| 824 | + | if args[0] != nil { | |
| 825 | + | arg0 = args[0].(context.Context) | |
| 826 | + | } | |
| 827 | + | var arg1 int | |
| 828 | + | if args[1] != nil { | |
| 829 | + | arg1 = args[1].(int) | |
| 830 | + | } | |
| 831 | + | var arg2 repository.HistoryScope | |
| 832 | + | if args[2] != nil { | |
| 833 | + | arg2 = args[2].(repository.HistoryScope) | |
| 834 | + | } | |
| 835 | + | run( | |
| 836 | + | arg0, | |
| 837 | + | arg1, | |
| 838 | + | arg2, | |
| 839 | + | ) | |
| 840 | + | }) | |
| 841 | + | return _c | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisitedWithin_Call { | |
| 845 | + | _c.Call.Return(historyEntrys, err) | |
| 846 | + | return _c | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) RunAndReturn(run func(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisitedWithin_Call { | |
| 850 | + | _c.Call.Return(run) | |
| 851 | + | return _c | |
| 852 | + | } | |
| 853 | + | ||
| 779 | 854 | // GetRecent provides a mock function for the type MockHistoryRepository | |
| 780 | - | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) { | |
| 781 | - | ret := _mock.Called(ctx, limit, offset) | |
| 855 | + | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) { | |
| 856 | + | ret := _mock.Called(ctx, limit, offset, scope) | |
| 782 | 857 | ||
| 783 | 858 | if len(ret) == 0 { | |
| 784 | 859 | panic("no return value specified for GetRecent") | |
| @@ -786,18 +861,18 @@ func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, of | |||
| 786 | 861 | ||
| 787 | 862 | var r0 []*entity.HistoryEntry | |
| 788 | 863 | var r1 error | |
| 789 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*entity.HistoryEntry, error)); ok { | |
| 790 | - | return returnFunc(ctx, limit, offset) | |
| 864 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok { | |
| 865 | + | return returnFunc(ctx, limit, offset, scope) | |
| 791 | 866 | } | |
| 792 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*entity.HistoryEntry); ok { | |
| 793 | - | r0 = returnFunc(ctx, limit, offset) | |
| 867 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) []*entity.HistoryEntry); ok { | |
| 868 | + | r0 = returnFunc(ctx, limit, offset, scope) | |
| 794 | 869 | } else { | |
| 795 | 870 | if ret.Get(0) != nil { | |
| 796 | 871 | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 797 | 872 | } | |
| 798 | 873 | } | |
| 799 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { | |
| 800 | - | r1 = returnFunc(ctx, limit, offset) | |
| 874 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int, int, repository.HistoryScope) error); ok { | |
| 875 | + | r1 = returnFunc(ctx, limit, offset, scope) | |
| 801 | 876 | } else { | |
| 802 | 877 | r1 = ret.Error(1) | |
| 803 | 878 | } | |
| @@ -813,11 +888,12 @@ type MockHistoryRepository_GetRecent_Call struct { | |||
| 813 | 888 | // - ctx context.Context | |
| 814 | 889 | // - limit int | |
| 815 | 890 | // - offset int | |
| 816 | - | func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any) *MockHistoryRepository_GetRecent_Call { | |
| 817 | - | return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset)} | |
| 891 | + | // - scope repository.HistoryScope | |
| 892 | + | func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any, scope any) *MockHistoryRepository_GetRecent_Call { | |
| 893 | + | return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset, scope)} | |
| 818 | 894 | } | |
| 819 | 895 | ||
| 820 | - | func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int)) *MockHistoryRepository_GetRecent_Call { | |
| 896 | + | func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope)) *MockHistoryRepository_GetRecent_Call { | |
| 821 | 897 | _c.Call.Run(func(args mock.Arguments) { | |
| 822 | 898 | var arg0 context.Context | |
| 823 | 899 | if args[0] != nil { | |
| @@ -831,10 +907,15 @@ func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context | |||
| 831 | 907 | if args[2] != nil { | |
| 832 | 908 | arg2 = args[2].(int) | |
| 833 | 909 | } | |
| 910 | + | var arg3 repository.HistoryScope | |
| 911 | + | if args[3] != nil { | |
| 912 | + | arg3 = args[3].(repository.HistoryScope) | |
| 913 | + | } | |
| 834 | 914 | run( | |
| 835 | 915 | arg0, | |
| 836 | 916 | arg1, | |
| 837 | 917 | arg2, | |
| 918 | + | arg3, | |
| 838 | 919 | ) | |
| 839 | 920 | }) | |
| 840 | 921 | return _c | |
| @@ -845,7 +926,7 @@ func (_c *MockHistoryRepository_GetRecent_Call) Return(historyEntrys []*entity.H | |||
| 845 | 926 | return _c | |
| 846 | 927 | } | |
| 847 | 928 | ||
| 848 | - | func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call { | |
| 929 | + | func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call { | |
| 849 | 930 | _c.Call.Return(run) | |
| 850 | 931 | return _c | |
| 851 | 932 | } | |
| @@ -1341,8 +1422,8 @@ func (_c *MockHistoryRepository_Save_Call) RunAndReturn(run func(ctx context.Con | |||
| 1341 | 1422 | } | |
| 1342 | 1423 | ||
| 1343 | 1424 | // Search provides a mock function for the type MockHistoryRepository | |
| 1344 | - | func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 1345 | - | ret := _mock.Called(ctx, query, limit) | |
| 1425 | + | func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) { | |
| 1426 | + | ret := _mock.Called(ctx, query, limit, scope) | |
| 1346 | 1427 | ||
| 1347 | 1428 | if len(ret) == 0 { | |
| 1348 | 1429 | panic("no return value specified for Search") | |
| @@ -1350,18 +1431,18 @@ func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, li | |||
| 1350 | 1431 | ||
| 1351 | 1432 | var r0 []entity.HistoryMatch | |
| 1352 | 1433 | var r1 error | |
| 1353 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]entity.HistoryMatch, error)); ok { | |
| 1354 | - | return returnFunc(ctx, query, limit) | |
| 1434 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) ([]entity.HistoryMatch, error)); ok { | |
| 1435 | + | return returnFunc(ctx, query, limit, scope) | |
| 1355 | 1436 | } | |
| 1356 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []entity.HistoryMatch); ok { | |
| 1357 | - | r0 = returnFunc(ctx, query, limit) | |
| 1437 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) []entity.HistoryMatch); ok { | |
| 1438 | + | r0 = returnFunc(ctx, query, limit, scope) | |
| 1358 | 1439 | } else { | |
| 1359 | 1440 | if ret.Get(0) != nil { | |
| 1360 | 1441 | r0 = ret.Get(0).([]entity.HistoryMatch) | |
| 1361 | 1442 | } | |
| 1362 | 1443 | } | |
| 1363 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok { | |
| 1364 | - | r1 = returnFunc(ctx, query, limit) | |
| 1444 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, repository.HistoryScope) error); ok { | |
| 1445 | + | r1 = returnFunc(ctx, query, limit, scope) | |
| 1365 | 1446 | } else { | |
| 1366 | 1447 | r1 = ret.Error(1) | |
| 1367 | 1448 | } | |
| @@ -1377,11 +1458,12 @@ type MockHistoryRepository_Search_Call struct { | |||
| 1377 | 1458 | // - ctx context.Context | |
| 1378 | 1459 | // - query string | |
| 1379 | 1460 | // - limit int | |
| 1380 | - | func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any) *MockHistoryRepository_Search_Call { | |
| 1381 | - | return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit)} | |
| 1461 | + | // - scope repository.HistoryScope | |
| 1462 | + | func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any, scope any) *MockHistoryRepository_Search_Call { | |
| 1463 | + | return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit, scope)} | |
| 1382 | 1464 | } | |
| 1383 | 1465 | ||
| 1384 | - | func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int)) *MockHistoryRepository_Search_Call { | |
| 1466 | + | func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope)) *MockHistoryRepository_Search_Call { | |
| 1385 | 1467 | _c.Call.Run(func(args mock.Arguments) { | |
| 1386 | 1468 | var arg0 context.Context | |
| 1387 | 1469 | if args[0] != nil { | |
| @@ -1395,10 +1477,15 @@ func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, q | |||
| 1395 | 1477 | if args[2] != nil { | |
| 1396 | 1478 | arg2 = args[2].(int) | |
| 1397 | 1479 | } | |
| 1480 | + | var arg3 repository.HistoryScope | |
| 1481 | + | if args[3] != nil { | |
| 1482 | + | arg3 = args[3].(repository.HistoryScope) | |
| 1483 | + | } | |
| 1398 | 1484 | run( | |
| 1399 | 1485 | arg0, | |
| 1400 | 1486 | arg1, | |
| 1401 | 1487 | arg2, | |
| 1488 | + | arg3, | |
| 1402 | 1489 | ) | |
| 1403 | 1490 | }) | |
| 1404 | 1491 | return _c | |
| @@ -1409,7 +1496,7 @@ func (_c *MockHistoryRepository_Search_Call) Return(historyMatchs []entity.Histo | |||
| 1409 | 1496 | return _c | |
| 1410 | 1497 | } | |
| 1411 | 1498 | ||
| 1412 | - | func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call { | |
| 1499 | + | func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call { | |
| 1413 | 1500 | _c.Call.Return(run) | |
| 1414 | 1501 | return _c | |
| 1415 | 1502 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 12 | mock "github.com/stretchr/testify/mock" | 13 | mock "github.com/stretchr/testify/mock" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -776,9 +777,83 @@ func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx c | |||||
| 776 | return _c | 777 | return _c | ||
| 777 | } | 778 | } | ||
| 778 | 779 | ||||
| 780 | + | // GetMostVisitedWithin provides a mock function for the type MockHistoryRepository | |||
| 781 | + | func (_mock *MockHistoryRepository) GetMostVisitedWithin(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) { | |||
| 782 | + | ret := _mock.Called(ctx, limit, scope) | |||
| 783 | + | ||||
| 784 | + | if len(ret) == 0 { | |||
| 785 | + | panic("no return value specified for GetMostVisitedWithin") | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | var r0 []*entity.HistoryEntry | |||
| 789 | + | var r1 error | |||
| 790 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok { | |||
| 791 | + | return returnFunc(ctx, limit, scope) | |||
| 792 | + | } | |||
| 793 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, repository.HistoryScope) []*entity.HistoryEntry); ok { | |||
| 794 | + | r0 = returnFunc(ctx, limit, scope) | |||
| 795 | + | } else { | |||
| 796 | + | if ret.Get(0) != nil { | |||
| 797 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 798 | + | } | |||
| 799 | + | } | |||
| 800 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int, repository.HistoryScope) error); ok { | |||
| 801 | + | r1 = returnFunc(ctx, limit, scope) | |||
| 802 | + | } else { | |||
| 803 | + | r1 = ret.Error(1) | |||
| 804 | + | } | |||
| 805 | + | return r0, r1 | |||
| 806 | + | } | |||
| 807 | + | ||||
| 808 | + | // MockHistoryRepository_GetMostVisitedWithin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisitedWithin' | |||
| 809 | + | type MockHistoryRepository_GetMostVisitedWithin_Call struct { | |||
| 810 | + | *mock.Call | |||
| 811 | + | } | |||
| 812 | + | ||||
| 813 | + | // GetMostVisitedWithin is a helper method to define mock.On call | |||
| 814 | + | // - ctx context.Context | |||
| 815 | + | // - limit int | |||
| 816 | + | // - scope repository.HistoryScope | |||
| 817 | + | func (_e *MockHistoryRepository_Expecter) GetMostVisitedWithin(ctx any, limit any, scope any) *MockHistoryRepository_GetMostVisitedWithin_Call { | |||
| 818 | + | return &MockHistoryRepository_GetMostVisitedWithin_Call{Call: _e.mock.On("GetMostVisitedWithin", ctx, limit, scope)} | |||
| 819 | + | } | |||
| 820 | + | ||||
| 821 | + | func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Run(run func(ctx context.Context, limit int, scope repository.HistoryScope)) *MockHistoryRepository_GetMostVisitedWithin_Call { | |||
| 822 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 823 | + | var arg0 context.Context | |||
| 824 | + | if args[0] != nil { | |||
| 825 | + | arg0 = args[0].(context.Context) | |||
| 826 | + | } | |||
| 827 | + | var arg1 int | |||
| 828 | + | if args[1] != nil { | |||
| 829 | + | arg1 = args[1].(int) | |||
| 830 | + | } | |||
| 831 | + | var arg2 repository.HistoryScope | |||
| 832 | + | if args[2] != nil { | |||
| 833 | + | arg2 = args[2].(repository.HistoryScope) | |||
| 834 | + | } | |||
| 835 | + | run( | |||
| 836 | + | arg0, | |||
| 837 | + | arg1, | |||
| 838 | + | arg2, | |||
| 839 | + | ) | |||
| 840 | + | }) | |||
| 841 | + | return _c | |||
| 842 | + | } | |||
| 843 | + | ||||
| 844 | + | func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisitedWithin_Call { | |||
| 845 | + | _c.Call.Return(historyEntrys, err) | |||
| 846 | + | return _c | |||
| 847 | + | } | |||
| 848 | + | ||||
| 849 | + | func (_c *MockHistoryRepository_GetMostVisitedWithin_Call) RunAndReturn(run func(ctx context.Context, limit int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisitedWithin_Call { | |||
| 850 | + | _c.Call.Return(run) | |||
| 851 | + | return _c | |||
| 852 | + | } | |||
| 853 | + | ||||
| 779 | // GetRecent provides a mock function for the type MockHistoryRepository | 854 | // GetRecent provides a mock function for the type MockHistoryRepository | ||
| 780 | - | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) { | 855 | + | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) { |
| 781 | - | ret := _mock.Called(ctx, limit, offset) | 856 | + | ret := _mock.Called(ctx, limit, offset, scope) |
| 782 | 857 | ||||
| 783 | if len(ret) == 0 { | 858 | if len(ret) == 0 { | ||
| 784 | panic("no return value specified for GetRecent") | 859 | panic("no return value specified for GetRecent") | ||
| @@ -786,18 +861,18 @@ func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, of | |||||
| 786 | 861 | ||||
| 787 | var r0 []*entity.HistoryEntry | 862 | var r0 []*entity.HistoryEntry | ||
| 788 | var r1 error | 863 | var r1 error | ||
| 789 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*entity.HistoryEntry, error)); ok { | 864 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) ([]*entity.HistoryEntry, error)); ok { |
| 790 | - | return returnFunc(ctx, limit, offset) | 865 | + | return returnFunc(ctx, limit, offset, scope) |
| 791 | } | 866 | } | ||
| 792 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*entity.HistoryEntry); ok { | 867 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, repository.HistoryScope) []*entity.HistoryEntry); ok { |
| 793 | - | r0 = returnFunc(ctx, limit, offset) | 868 | + | r0 = returnFunc(ctx, limit, offset, scope) |
| 794 | } else { | 869 | } else { | ||
| 795 | if ret.Get(0) != nil { | 870 | if ret.Get(0) != nil { | ||
| 796 | r0 = ret.Get(0).([]*entity.HistoryEntry) | 871 | r0 = ret.Get(0).([]*entity.HistoryEntry) | ||
| 797 | } | 872 | } | ||
| 798 | } | 873 | } | ||
| 799 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok { | 874 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int, int, repository.HistoryScope) error); ok { |
| 800 | - | r1 = returnFunc(ctx, limit, offset) | 875 | + | r1 = returnFunc(ctx, limit, offset, scope) |
| 801 | } else { | 876 | } else { | ||
| 802 | r1 = ret.Error(1) | 877 | r1 = ret.Error(1) | ||
| 803 | } | 878 | } | ||
| @@ -813,11 +888,12 @@ type MockHistoryRepository_GetRecent_Call struct { | |||||
| 813 | // - ctx context.Context | 888 | // - ctx context.Context | ||
| 814 | // - limit int | 889 | // - limit int | ||
| 815 | // - offset int | 890 | // - offset int | ||
| 816 | - | func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any) *MockHistoryRepository_GetRecent_Call { | 891 | + | // - scope repository.HistoryScope |
| 817 | - | return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset)} | 892 | + | func (_e *MockHistoryRepository_Expecter) GetRecent(ctx any, limit any, offset any, scope any) *MockHistoryRepository_GetRecent_Call { |
| 893 | + | return &MockHistoryRepository_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset, scope)} | |||
| 818 | } | 894 | } | ||
| 819 | 895 | ||||
| 820 | - | func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int)) *MockHistoryRepository_GetRecent_Call { | 896 | + | func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope)) *MockHistoryRepository_GetRecent_Call { |
| 821 | _c.Call.Run(func(args mock.Arguments) { | 897 | _c.Call.Run(func(args mock.Arguments) { | ||
| 822 | var arg0 context.Context | 898 | var arg0 context.Context | ||
| 823 | if args[0] != nil { | 899 | if args[0] != nil { | ||
| @@ -831,10 +907,15 @@ func (_c *MockHistoryRepository_GetRecent_Call) Run(run func(ctx context.Context | |||||
| 831 | if args[2] != nil { | 907 | if args[2] != nil { | ||
| 832 | arg2 = args[2].(int) | 908 | arg2 = args[2].(int) | ||
| 833 | } | 909 | } | ||
| 910 | + | var arg3 repository.HistoryScope | |||
| 911 | + | if args[3] != nil { | |||
| 912 | + | arg3 = args[3].(repository.HistoryScope) | |||
| 913 | + | } | |||
| 834 | run( | 914 | run( | ||
| 835 | arg0, | 915 | arg0, | ||
| 836 | arg1, | 916 | arg1, | ||
| 837 | arg2, | 917 | arg2, | ||
| 918 | + | arg3, | |||
| 838 | ) | 919 | ) | ||
| 839 | }) | 920 | }) | ||
| 840 | return _c | 921 | return _c | ||
| @@ -845,7 +926,7 @@ func (_c *MockHistoryRepository_GetRecent_Call) Return(historyEntrys []*entity.H | |||||
| 845 | return _c | 926 | return _c | ||
| 846 | } | 927 | } | ||
| 847 | 928 | ||||
| 848 | - | func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call { | 929 | + | func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecent_Call { |
| 849 | _c.Call.Return(run) | 930 | _c.Call.Return(run) | ||
| 850 | return _c | 931 | return _c | ||
| 851 | } | 932 | } | ||
| @@ -1341,8 +1422,8 @@ func (_c *MockHistoryRepository_Save_Call) RunAndReturn(run func(ctx context.Con | |||||
| 1341 | } | 1422 | } | ||
| 1342 | 1423 | ||||
| 1343 | // Search provides a mock function for the type MockHistoryRepository | 1424 | // Search provides a mock function for the type MockHistoryRepository | ||
| 1344 | - | func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 1425 | + | func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) { |
| 1345 | - | ret := _mock.Called(ctx, query, limit) | 1426 | + | ret := _mock.Called(ctx, query, limit, scope) |
| 1346 | 1427 | ||||
| 1347 | if len(ret) == 0 { | 1428 | if len(ret) == 0 { | ||
| 1348 | panic("no return value specified for Search") | 1429 | panic("no return value specified for Search") | ||
| @@ -1350,18 +1431,18 @@ func (_mock *MockHistoryRepository) Search(ctx context.Context, query string, li | |||||
| 1350 | 1431 | ||||
| 1351 | var r0 []entity.HistoryMatch | 1432 | var r0 []entity.HistoryMatch | ||
| 1352 | var r1 error | 1433 | var r1 error | ||
| 1353 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]entity.HistoryMatch, error)); ok { | 1434 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) ([]entity.HistoryMatch, error)); ok { |
| 1354 | - | return returnFunc(ctx, query, limit) | 1435 | + | return returnFunc(ctx, query, limit, scope) |
| 1355 | } | 1436 | } | ||
| 1356 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []entity.HistoryMatch); ok { | 1437 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, repository.HistoryScope) []entity.HistoryMatch); ok { |
| 1357 | - | r0 = returnFunc(ctx, query, limit) | 1438 | + | r0 = returnFunc(ctx, query, limit, scope) |
| 1358 | } else { | 1439 | } else { | ||
| 1359 | if ret.Get(0) != nil { | 1440 | if ret.Get(0) != nil { | ||
| 1360 | r0 = ret.Get(0).([]entity.HistoryMatch) | 1441 | r0 = ret.Get(0).([]entity.HistoryMatch) | ||
| 1361 | } | 1442 | } | ||
| 1362 | } | 1443 | } | ||
| 1363 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok { | 1444 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, repository.HistoryScope) error); ok { |
| 1364 | - | r1 = returnFunc(ctx, query, limit) | 1445 | + | r1 = returnFunc(ctx, query, limit, scope) |
| 1365 | } else { | 1446 | } else { | ||
| 1366 | r1 = ret.Error(1) | 1447 | r1 = ret.Error(1) | ||
| 1367 | } | 1448 | } | ||
| @@ -1377,11 +1458,12 @@ type MockHistoryRepository_Search_Call struct { | |||||
| 1377 | // - ctx context.Context | 1458 | // - ctx context.Context | ||
| 1378 | // - query string | 1459 | // - query string | ||
| 1379 | // - limit int | 1460 | // - limit int | ||
| 1380 | - | func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any) *MockHistoryRepository_Search_Call { | 1461 | + | // - scope repository.HistoryScope |
| 1381 | - | return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit)} | 1462 | + | func (_e *MockHistoryRepository_Expecter) Search(ctx any, query any, limit any, scope any) *MockHistoryRepository_Search_Call { |
| 1463 | + | return &MockHistoryRepository_Search_Call{Call: _e.mock.On("Search", ctx, query, limit, scope)} | |||
| 1382 | } | 1464 | } | ||
| 1383 | 1465 | ||||
| 1384 | - | func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int)) *MockHistoryRepository_Search_Call { | 1466 | + | func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope)) *MockHistoryRepository_Search_Call { |
| 1385 | _c.Call.Run(func(args mock.Arguments) { | 1467 | _c.Call.Run(func(args mock.Arguments) { | ||
| 1386 | var arg0 context.Context | 1468 | var arg0 context.Context | ||
| 1387 | if args[0] != nil { | 1469 | if args[0] != nil { | ||
| @@ -1395,10 +1477,15 @@ func (_c *MockHistoryRepository_Search_Call) Run(run func(ctx context.Context, q | |||||
| 1395 | if args[2] != nil { | 1477 | if args[2] != nil { | ||
| 1396 | arg2 = args[2].(int) | 1478 | arg2 = args[2].(int) | ||
| 1397 | } | 1479 | } | ||
| 1480 | + | var arg3 repository.HistoryScope | |||
| 1481 | + | if args[3] != nil { | |||
| 1482 | + | arg3 = args[3].(repository.HistoryScope) | |||
| 1483 | + | } | |||
| 1398 | run( | 1484 | run( | ||
| 1399 | arg0, | 1485 | arg0, | ||
| 1400 | arg1, | 1486 | arg1, | ||
| 1401 | arg2, | 1487 | arg2, | ||
| 1488 | + | arg3, | |||
| 1402 | ) | 1489 | ) | ||
| 1403 | }) | 1490 | }) | ||
| 1404 | return _c | 1491 | return _c | ||
| @@ -1409,7 +1496,7 @@ func (_c *MockHistoryRepository_Search_Call) Return(historyMatchs []entity.Histo | |||||
| 1409 | return _c | 1496 | return _c | ||
| 1410 | } | 1497 | } | ||
| 1411 | 1498 | ||||
| 1412 | - | func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call { | 1499 | + | func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error)) *MockHistoryRepository_Search_Call { |
| 1413 | _c.Call.Return(run) | 1500 | _c.Call.Return(run) | ||
| 1414 | return _c | 1501 | return _c | ||
| 1415 | } | 1502 | } | ||
M internal/infrastructure/config/defaults.go +2 -2
| @@ -35,7 +35,7 @@ const ( | |||
| 35 | 35 | // Omnibox defaults | |
| 36 | 36 | defaultOmniboxStyle = "command" | |
| 37 | 37 | defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent | |
| 38 | - | defaultOmniboxMostVisitedDays = 30 | |
| 38 | + | defaultOmniboxMaxHistoryDays = 30 | |
| 39 | 39 | defaultOmniboxAutoOpenOnNewPane = false | |
| 40 | 40 | ||
| 41 | 41 | // Workspace defaults | |
| @@ -408,7 +408,7 @@ func DefaultConfig() *Config { | |||
| 408 | 408 | Omnibox: OmniboxConfig{ | |
| 409 | 409 | Style: defaultOmniboxStyle, | |
| 410 | 410 | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 411 | - | MostVisitedDays: defaultOmniboxMostVisitedDays, | |
| 411 | + | MaxHistoryDays: defaultOmniboxMaxHistoryDays, | |
| 412 | 412 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | |
| 413 | 413 | }, | |
| 414 | 414 | Session: SessionConfig{ | |
| @@ -35,7 +35,7 @@ const ( | |||||
| 35 | // Omnibox defaults | 35 | // Omnibox defaults | ||
| 36 | defaultOmniboxStyle = "command" | 36 | defaultOmniboxStyle = "command" | ||
| 37 | defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent | 37 | defaultOmniboxInitialBehavior = OmniboxInitialBehaviorRecent | ||
| 38 | - | defaultOmniboxMostVisitedDays = 30 | 38 | + | defaultOmniboxMaxHistoryDays = 30 |
| 39 | defaultOmniboxAutoOpenOnNewPane = false | 39 | defaultOmniboxAutoOpenOnNewPane = false | ||
| 40 | 40 | ||||
| 41 | // Workspace defaults | 41 | // Workspace defaults | ||
| @@ -408,7 +408,7 @@ func DefaultConfig() *Config { | |||||
| 408 | Omnibox: OmniboxConfig{ | 408 | Omnibox: OmniboxConfig{ | ||
| 409 | Style: defaultOmniboxStyle, | 409 | Style: defaultOmniboxStyle, | ||
| 410 | InitialBehavior: defaultOmniboxInitialBehavior, | 410 | InitialBehavior: defaultOmniboxInitialBehavior, | ||
| 411 | - | MostVisitedDays: defaultOmniboxMostVisitedDays, | 411 | + | MaxHistoryDays: defaultOmniboxMaxHistoryDays, |
| 412 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | 412 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | ||
| 413 | }, | 413 | }, | ||
| 414 | Session: SessionConfig{ | 414 | Session: SessionConfig{ | ||
M internal/infrastructure/config/loader.go +1 -1
| @@ -755,7 +755,7 @@ func (m *Manager) setClipboardDefaults(defaults *Config) { | |||
| 755 | 755 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | |
| 756 | 756 | m.viper.SetDefault("omnibox.style", defaults.Omnibox.Style) | |
| 757 | 757 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | |
| 758 | - | m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays) | |
| 758 | + | m.viper.SetDefault("omnibox.max_history_days", defaults.Omnibox.MaxHistoryDays) | |
| 759 | 759 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | |
| 760 | 760 | } | |
| 761 | 761 | ||
| @@ -755,7 +755,7 @@ func (m *Manager) setClipboardDefaults(defaults *Config) { | |||||
| 755 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | 755 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | ||
| 756 | m.viper.SetDefault("omnibox.style", defaults.Omnibox.Style) | 756 | m.viper.SetDefault("omnibox.style", defaults.Omnibox.Style) | ||
| 757 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | 757 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | ||
| 758 | - | m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays) | 758 | + | m.viper.SetDefault("omnibox.max_history_days", defaults.Omnibox.MaxHistoryDays) |
| 759 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | 759 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | ||
| 760 | } | 760 | } | ||
| 761 | 761 | ||||
M internal/infrastructure/config/migrate.go +24 -0
| @@ -124,6 +124,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 124 | 124 | m.detectLegacyFavoritesSidebarRename(userKeysWithValues)..., | |
| 125 | 125 | ) | |
| 126 | 126 | legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...) | |
| 127 | + | legacyRenames = append(legacyRenames, m.detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeysWithValues)...) | |
| 127 | 128 | var changes []port.KeyChange | |
| 128 | 129 | changes = append(changes, legacyRenames...) | |
| 129 | 130 | ||
| @@ -790,6 +791,29 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any | |||
| 790 | 791 | return changes | |
| 791 | 792 | } | |
| 792 | 793 | ||
| 794 | + | func (m *Migrator) detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeys map[string]any) []port.KeyChange { | |
| 795 | + | const ( | |
| 796 | + | oldKey = "omnibox.most_visited_days" | |
| 797 | + | newKey = "omnibox.max_history_days" | |
| 798 | + | ) | |
| 799 | + | ||
| 800 | + | oldValue, hasOld := rawUserKeys[oldKey] | |
| 801 | + | if !hasOld { | |
| 802 | + | return nil | |
| 803 | + | } | |
| 804 | + | if _, hasNew := rawUserKeys[newKey]; hasNew { | |
| 805 | + | return nil | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | return []port.KeyChange{{ | |
| 809 | + | Type: port.KeyChangeRenamed, | |
| 810 | + | OldKey: oldKey, | |
| 811 | + | NewKey: newKey, | |
| 812 | + | OldValue: m.formatValue(oldValue), | |
| 813 | + | NewValue: m.formatValue(m.defaultValueForKey(newKey)), | |
| 814 | + | }} | |
| 815 | + | } | |
| 816 | + | ||
| 793 | 817 | func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange { | |
| 794 | 818 | changes := make([]port.KeyChange, 0, 3) | |
| 795 | 819 | for oldKey, oldValue := range rawUserKeys { | |
| @@ -124,6 +124,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||||
| 124 | m.detectLegacyFavoritesSidebarRename(userKeysWithValues)..., | 124 | m.detectLegacyFavoritesSidebarRename(userKeysWithValues)..., | ||
| 125 | ) | 125 | ) | ||
| 126 | legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...) | 126 | legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...) | ||
| 127 | + | legacyRenames = append(legacyRenames, m.detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeysWithValues)...) | |||
| 127 | var changes []port.KeyChange | 128 | var changes []port.KeyChange | ||
| 128 | changes = append(changes, legacyRenames...) | 129 | changes = append(changes, legacyRenames...) | ||
| 129 | 130 | ||||
| @@ -790,6 +791,29 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any | |||||
| 790 | return changes | 791 | return changes | ||
| 791 | } | 792 | } | ||
| 792 | 793 | ||||
| 794 | + | func (m *Migrator) detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeys map[string]any) []port.KeyChange { | |||
| 795 | + | const ( | |||
| 796 | + | oldKey = "omnibox.most_visited_days" | |||
| 797 | + | newKey = "omnibox.max_history_days" | |||
| 798 | + | ) | |||
| 799 | + | ||||
| 800 | + | oldValue, hasOld := rawUserKeys[oldKey] | |||
| 801 | + | if !hasOld { | |||
| 802 | + | return nil | |||
| 803 | + | } | |||
| 804 | + | if _, hasNew := rawUserKeys[newKey]; hasNew { | |||
| 805 | + | return nil | |||
| 806 | + | } | |||
| 807 | + | ||||
| 808 | + | return []port.KeyChange{{ | |||
| 809 | + | Type: port.KeyChangeRenamed, | |||
| 810 | + | OldKey: oldKey, | |||
| 811 | + | NewKey: newKey, | |||
| 812 | + | OldValue: m.formatValue(oldValue), | |||
| 813 | + | NewValue: m.formatValue(m.defaultValueForKey(newKey)), | |||
| 814 | + | }} | |||
| 815 | + | } | |||
| 816 | + | ||||
| 793 | func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange { | 817 | func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange { | ||
| 794 | changes := make([]port.KeyChange, 0, 3) | 818 | changes := make([]port.KeyChange, 0, 3) | ||
| 795 | for oldKey, oldValue := range rawUserKeys { | 819 | for oldKey, oldValue := range rawUserKeys { | ||
M internal/infrastructure/config/migrate_test.go +76 -0
| @@ -1541,3 +1541,79 @@ desc = "Toggle Favorites in right split" | |||
| 1541 | 1541 | assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys) | |
| 1542 | 1542 | assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys) | |
| 1543 | 1543 | } | |
| 1544 | + | ||
| 1545 | + | func writeMigratorUserConfig(t *testing.T, content string) string { | |
| 1546 | + | t.Helper() | |
| 1547 | + | home := t.TempDir() | |
| 1548 | + | t.Setenv("HOME", home) | |
| 1549 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 1550 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 1551 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 1552 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 1553 | + | ||
| 1554 | + | configFile, err := GetConfigFile() | |
| 1555 | + | require.NoError(t, err) | |
| 1556 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |
| 1557 | + | require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644)) | |
| 1558 | + | return configFile | |
| 1559 | + | } | |
| 1560 | + | ||
| 1561 | + | func TestMigrator_DetectChanges_ReportsLegacyOmniboxMaxHistoryDaysRename(t *testing.T) { | |
| 1562 | + | writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = 0\n") | |
| 1563 | + | ||
| 1564 | + | changes, err := NewMigrator().DetectChanges() | |
| 1565 | + | require.NoError(t, err) | |
| 1566 | + | ||
| 1567 | + | found := false | |
| 1568 | + | for _, change := range changes { | |
| 1569 | + | if change.Type == port.KeyChangeRenamed && | |
| 1570 | + | change.OldKey == "omnibox.most_visited_days" && | |
| 1571 | + | change.NewKey == "omnibox.max_history_days" { | |
| 1572 | + | found = true | |
| 1573 | + | } | |
| 1574 | + | if change.OldKey == "omnibox.most_visited_days" && change.Type == port.KeyChangeRemoved { | |
| 1575 | + | t.Fatal("legacy key must be reported as a rename, not a removal") | |
| 1576 | + | } | |
| 1577 | + | if change.NewKey == "omnibox.max_history_days" && change.Type == port.KeyChangeAdded { | |
| 1578 | + | t.Fatal("migrated key must be reported as a rename, not an addition") | |
| 1579 | + | } | |
| 1580 | + | } | |
| 1581 | + | assert.True(t, found, "expected legacy omnibox.most_visited_days rename to be reported") | |
| 1582 | + | } | |
| 1583 | + | ||
| 1584 | + | func TestMigrator_Migrate_RenamesLegacyOmniboxMaxHistoryDaysPreservingValue(t *testing.T) { | |
| 1585 | + | tests := []struct { | |
| 1586 | + | name string | |
| 1587 | + | value string | |
| 1588 | + | }{ | |
| 1589 | + | {name: "zero is preserved", value: "0"}, | |
| 1590 | + | {name: "custom value is preserved", value: "7"}, | |
| 1591 | + | } | |
| 1592 | + | ||
| 1593 | + | for _, tt := range tests { | |
| 1594 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1595 | + | configFile := writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = "+tt.value+"\n") | |
| 1596 | + | ||
| 1597 | + | applied, err := NewMigrator().Migrate() | |
| 1598 | + | require.NoError(t, err) | |
| 1599 | + | assert.Contains(t, applied, "omnibox.most_visited_days -> omnibox.max_history_days") | |
| 1600 | + | ||
| 1601 | + | migrated, err := os.ReadFile(configFile) | |
| 1602 | + | require.NoError(t, err) | |
| 1603 | + | assert.Contains(t, string(migrated), "max_history_days = "+tt.value) | |
| 1604 | + | assert.NotContains(t, string(migrated), "most_visited_days") | |
| 1605 | + | }) | |
| 1606 | + | } | |
| 1607 | + | } | |
| 1608 | + | ||
| 1609 | + | func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.T) { | |
| 1610 | + | configFile := writeMigratorUserConfig(t, "[omnibox]\nmax_history_days = 7\nmost_visited_days = 99\n") | |
| 1611 | + | ||
| 1612 | + | _, err := NewMigrator().Migrate() | |
| 1613 | + | require.NoError(t, err) | |
| 1614 | + | ||
| 1615 | + | migrated, err := os.ReadFile(configFile) | |
| 1616 | + | require.NoError(t, err) | |
| 1617 | + | assert.Contains(t, string(migrated), "max_history_days = 7") | |
| 1618 | + | assert.NotContains(t, string(migrated), "most_visited_days") | |
| 1619 | + | } | |
| @@ -1541,3 +1541,79 @@ desc = "Toggle Favorites in right split" | |||||
| 1541 | assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys) | 1541 | assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys) | ||
| 1542 | assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys) | 1542 | assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys) | ||
| 1543 | } | 1543 | } | ||
| 1544 | + | ||||
| 1545 | + | func writeMigratorUserConfig(t *testing.T, content string) string { | |||
| 1546 | + | t.Helper() | |||
| 1547 | + | home := t.TempDir() | |||
| 1548 | + | t.Setenv("HOME", home) | |||
| 1549 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 1550 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 1551 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 1552 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 1553 | + | ||||
| 1554 | + | configFile, err := GetConfigFile() | |||
| 1555 | + | require.NoError(t, err) | |||
| 1556 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |||
| 1557 | + | require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644)) | |||
| 1558 | + | return configFile | |||
| 1559 | + | } | |||
| 1560 | + | ||||
| 1561 | + | func TestMigrator_DetectChanges_ReportsLegacyOmniboxMaxHistoryDaysRename(t *testing.T) { | |||
| 1562 | + | writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = 0\n") | |||
| 1563 | + | ||||
| 1564 | + | changes, err := NewMigrator().DetectChanges() | |||
| 1565 | + | require.NoError(t, err) | |||
| 1566 | + | ||||
| 1567 | + | found := false | |||
| 1568 | + | for _, change := range changes { | |||
| 1569 | + | if change.Type == port.KeyChangeRenamed && | |||
| 1570 | + | change.OldKey == "omnibox.most_visited_days" && | |||
| 1571 | + | change.NewKey == "omnibox.max_history_days" { | |||
| 1572 | + | found = true | |||
| 1573 | + | } | |||
| 1574 | + | if change.OldKey == "omnibox.most_visited_days" && change.Type == port.KeyChangeRemoved { | |||
| 1575 | + | t.Fatal("legacy key must be reported as a rename, not a removal") | |||
| 1576 | + | } | |||
| 1577 | + | if change.NewKey == "omnibox.max_history_days" && change.Type == port.KeyChangeAdded { | |||
| 1578 | + | t.Fatal("migrated key must be reported as a rename, not an addition") | |||
| 1579 | + | } | |||
| 1580 | + | } | |||
| 1581 | + | assert.True(t, found, "expected legacy omnibox.most_visited_days rename to be reported") | |||
| 1582 | + | } | |||
| 1583 | + | ||||
| 1584 | + | func TestMigrator_Migrate_RenamesLegacyOmniboxMaxHistoryDaysPreservingValue(t *testing.T) { | |||
| 1585 | + | tests := []struct { | |||
| 1586 | + | name string | |||
| 1587 | + | value string | |||
| 1588 | + | }{ | |||
| 1589 | + | {name: "zero is preserved", value: "0"}, | |||
| 1590 | + | {name: "custom value is preserved", value: "7"}, | |||
| 1591 | + | } | |||
| 1592 | + | ||||
| 1593 | + | for _, tt := range tests { | |||
| 1594 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1595 | + | configFile := writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = "+tt.value+"\n") | |||
| 1596 | + | ||||
| 1597 | + | applied, err := NewMigrator().Migrate() | |||
| 1598 | + | require.NoError(t, err) | |||
| 1599 | + | assert.Contains(t, applied, "omnibox.most_visited_days -> omnibox.max_history_days") | |||
| 1600 | + | ||||
| 1601 | + | migrated, err := os.ReadFile(configFile) | |||
| 1602 | + | require.NoError(t, err) | |||
| 1603 | + | assert.Contains(t, string(migrated), "max_history_days = "+tt.value) | |||
| 1604 | + | assert.NotContains(t, string(migrated), "most_visited_days") | |||
| 1605 | + | }) | |||
| 1606 | + | } | |||
| 1607 | + | } | |||
| 1608 | + | ||||
| 1609 | + | func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.T) { | |||
| 1610 | + | configFile := writeMigratorUserConfig(t, "[omnibox]\nmax_history_days = 7\nmost_visited_days = 99\n") | |||
| 1611 | + | ||||
| 1612 | + | _, err := NewMigrator().Migrate() | |||
| 1613 | + | require.NoError(t, err) | |||
| 1614 | + | ||||
| 1615 | + | migrated, err := os.ReadFile(configFile) | |||
| 1616 | + | require.NoError(t, err) | |||
| 1617 | + | assert.Contains(t, string(migrated), "max_history_days = 7") | |||
| 1618 | + | assert.NotContains(t, string(migrated), "most_visited_days") | |||
| 1619 | + | } | |||
M internal/infrastructure/config/schema.go +4 -3
| @@ -394,10 +394,11 @@ type OmniboxConfig struct { | |||
| 394 | 394 | // InitialBehavior controls what to show when omnibox opens with empty input | |
| 395 | 395 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | |
| 396 | 396 | InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | |
| 397 | - | // MostVisitedDays controls how many days of history are considered when initial behavior is most_visited. | |
| 398 | - | // 0 means all history. | |
| 397 | + | // MaxHistoryDays controls how many days of history the omnibox considers for | |
| 398 | + | // typed searches and for the empty-input initial list (recent or most_visited). | |
| 399 | + | // 0 means all stored history. | |
| 399 | 400 | // Default: 30. | |
| 400 | - | MostVisitedDays int `mapstructure:"most_visited_days" yaml:"most_visited_days" toml:"most_visited_days"` | |
| 401 | + | MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"` | |
| 401 | 402 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | |
| 402 | 403 | // Default: false | |
| 403 | 404 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | |
| @@ -394,10 +394,11 @@ type OmniboxConfig struct { | |||||
| 394 | // InitialBehavior controls what to show when omnibox opens with empty input | 394 | // InitialBehavior controls what to show when omnibox opens with empty input | ||
| 395 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | 395 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | ||
| 396 | InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | 396 | InitialBehavior OmniboxInitialBehavior `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | ||
| 397 | - | // MostVisitedDays controls how many days of history are considered when initial behavior is most_visited. | 397 | + | // MaxHistoryDays controls how many days of history the omnibox considers for |
| 398 | - | // 0 means all history. | 398 | + | // typed searches and for the empty-input initial list (recent or most_visited). |
| 399 | + | // 0 means all stored history. | |||
| 399 | // Default: 30. | 400 | // Default: 30. | ||
| 400 | - | MostVisitedDays int `mapstructure:"most_visited_days" yaml:"most_visited_days" toml:"most_visited_days"` | 401 | + | MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"` |
| 401 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | 402 | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | ||
| 402 | // Default: false | 403 | // Default: false | ||
| 403 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | 404 | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | ||
M internal/infrastructure/config/schema_provider.go +3 -3
| @@ -800,10 +800,10 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 800 | 800 | Section: SectionOmnibox, | |
| 801 | 801 | }, | |
| 802 | 802 | { | |
| 803 | - | Key: "omnibox.most_visited_days", | |
| 803 | + | Key: "omnibox.max_history_days", | |
| 804 | 804 | Type: "int", | |
| 805 | - | Default: fmt.Sprintf("%d", defaults.Omnibox.MostVisitedDays), | |
| 806 | - | Description: "Days of history to consider for most_visited (0 = all history)", | |
| 805 | + | Default: fmt.Sprintf("%d", defaults.Omnibox.MaxHistoryDays), | |
| 806 | + | Description: "Days of omnibox suggestion history (typed and initial); 0 = all history, never deletes", | |
| 807 | 807 | Range: ">=0", | |
| 808 | 808 | Section: SectionOmnibox, | |
| 809 | 809 | }, | |
| @@ -800,10 +800,10 @@ func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo { | |||||
| 800 | Section: SectionOmnibox, | 800 | Section: SectionOmnibox, | ||
| 801 | }, | 801 | }, | ||
| 802 | { | 802 | { | ||
| 803 | - | Key: "omnibox.most_visited_days", | 803 | + | Key: "omnibox.max_history_days", |
| 804 | Type: "int", | 804 | Type: "int", | ||
| 805 | - | Default: fmt.Sprintf("%d", defaults.Omnibox.MostVisitedDays), | 805 | + | Default: fmt.Sprintf("%d", defaults.Omnibox.MaxHistoryDays), |
| 806 | - | Description: "Days of history to consider for most_visited (0 = all history)", | 806 | + | Description: "Days of omnibox suggestion history (typed and initial); 0 = all history, never deletes", |
| 807 | Range: ">=0", | 807 | Range: ">=0", | ||
| 808 | Section: SectionOmnibox, | 808 | Section: SectionOmnibox, | ||
| 809 | }, | 809 | }, | ||
M internal/infrastructure/config/validation.go +2 -2
| @@ -654,8 +654,8 @@ func validateOmnibox(config *Config) []string { | |||
| 654 | 654 | config.Omnibox.Style, | |
| 655 | 655 | )) | |
| 656 | 656 | } | |
| 657 | - | if config.Omnibox.MostVisitedDays < 0 { | |
| 658 | - | validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative") | |
| 657 | + | if config.Omnibox.MaxHistoryDays < 0 { | |
| 658 | + | validationErrors = append(validationErrors, "omnibox.max_history_days must be non-negative") | |
| 659 | 659 | } | |
| 660 | 660 | switch config.Omnibox.InitialBehavior { | |
| 661 | 661 | case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: | |
| @@ -654,8 +654,8 @@ func validateOmnibox(config *Config) []string { | |||||
| 654 | config.Omnibox.Style, | 654 | config.Omnibox.Style, | ||
| 655 | )) | 655 | )) | ||
| 656 | } | 656 | } | ||
| 657 | - | if config.Omnibox.MostVisitedDays < 0 { | 657 | + | if config.Omnibox.MaxHistoryDays < 0 { |
| 658 | - | validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative") | 658 | + | validationErrors = append(validationErrors, "omnibox.max_history_days must be non-negative") |
| 659 | } | 659 | } | ||
| 660 | switch config.Omnibox.InitialBehavior { | 660 | switch config.Omnibox.InitialBehavior { | ||
| 661 | case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: | 661 | case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: | ||
M internal/infrastructure/config/validation_test.go +23 -0
| @@ -482,3 +482,26 @@ func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) | |||
| 482 | 482 | err := validateConfig(cfg) | |
| 483 | 483 | require.NoError(t, err) | |
| 484 | 484 | } | |
| 485 | + | ||
| 486 | + | func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) { | |
| 487 | + | t.Run("default is valid", func(t *testing.T) { | |
| 488 | + | cfg := DefaultConfig() | |
| 489 | + | require.NoError(t, validateConfig(cfg)) | |
| 490 | + | }) | |
| 491 | + | ||
| 492 | + | t.Run("zero means all history and is valid", func(t *testing.T) { | |
| 493 | + | cfg := DefaultConfig() | |
| 494 | + | cfg.Omnibox.MaxHistoryDays = 0 | |
| 495 | + | require.NoError(t, validateConfig(cfg)) | |
| 496 | + | }) | |
| 497 | + | ||
| 498 | + | t.Run("negative is rejected", func(t *testing.T) { | |
| 499 | + | cfg := DefaultConfig() | |
| 500 | + | cfg.Omnibox.MaxHistoryDays = -1 | |
| 501 | + | ||
| 502 | + | err := validateConfig(cfg) | |
| 503 | + | ||
| 504 | + | require.Error(t, err) | |
| 505 | + | assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative") | |
| 506 | + | }) | |
| 507 | + | } | |
| @@ -482,3 +482,26 @@ func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) | |||||
| 482 | err := validateConfig(cfg) | 482 | err := validateConfig(cfg) | ||
| 483 | require.NoError(t, err) | 483 | require.NoError(t, err) | ||
| 484 | } | 484 | } | ||
| 485 | + | ||||
| 486 | + | func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) { | |||
| 487 | + | t.Run("default is valid", func(t *testing.T) { | |||
| 488 | + | cfg := DefaultConfig() | |||
| 489 | + | require.NoError(t, validateConfig(cfg)) | |||
| 490 | + | }) | |||
| 491 | + | ||||
| 492 | + | t.Run("zero means all history and is valid", func(t *testing.T) { | |||
| 493 | + | cfg := DefaultConfig() | |||
| 494 | + | cfg.Omnibox.MaxHistoryDays = 0 | |||
| 495 | + | require.NoError(t, validateConfig(cfg)) | |||
| 496 | + | }) | |||
| 497 | + | ||||
| 498 | + | t.Run("negative is rejected", func(t *testing.T) { | |||
| 499 | + | cfg := DefaultConfig() | |||
| 500 | + | cfg.Omnibox.MaxHistoryDays = -1 | |||
| 501 | + | ||||
| 502 | + | err := validateConfig(cfg) | |||
| 503 | + | ||||
| 504 | + | require.Error(t, err) | |||
| 505 | + | assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative") | |||
| 506 | + | }) | |||
| 507 | + | } | |||
M internal/infrastructure/persistence/sqlite/history_repo.go +86 -16
| @@ -44,6 +44,30 @@ const ( | |||
| 44 | 44 | ||
| 45 | 45 | var expectedFTSFailureCount atomic.Uint64 | |
| 46 | 46 | ||
| 47 | + | // boundedHistoryCutoff normalizes a real cutoff to UTC at the second precision | |
| 48 | + | // SQLite stores in last_visited, so the inclusive `>=` comparison matches an | |
| 49 | + | // entry exactly on the cutoff. | |
| 50 | + | // | |
| 51 | + | // sqlc models the nullable last_visited column comparison as sql.NullTime, so | |
| 52 | + | // Valid is always true here: only bounded callers use this helper. Unbounded | |
| 53 | + | // recent/most-visited lists use separate queries with no cutoff predicate, and | |
| 54 | + | // the FTS path uses historyCutoffParam. | |
| 55 | + | func boundedHistoryCutoff(cutoff time.Time) sql.NullTime { | |
| 56 | + | return sql.NullTime{Time: cutoff.UTC().Truncate(time.Second), Valid: true} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // historyCutoffParam returns nil for an unbounded scope and a concrete cutoff | |
| 60 | + | // otherwise. It feeds the FTS queries, where MATCH already narrows the | |
| 61 | + | // candidate set and a single nullable predicate avoids duplicating the URL and | |
| 62 | + | // title variants across bounded and unbounded windows. sqlc types that | |
| 63 | + | // predicate as any because the `IS NULL` branch prevents a concrete inference. | |
| 64 | + | func historyCutoffParam(scope repository.HistoryScope) any { | |
| 65 | + | if scope.Cutoff.IsZero() { | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | return boundedHistoryCutoff(scope.Cutoff) | |
| 69 | + | } | |
| 70 | + | ||
| 47 | 71 | type historyRepo struct { | |
| 48 | 72 | db *sql.DB | |
| 49 | 73 | queries *sqlc.Queries | |
| @@ -115,7 +139,7 @@ func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryE | |||
| 115 | 139 | return err | |
| 116 | 140 | } | |
| 117 | 141 | ||
| 118 | - | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 142 | + | func (r *historyRepo) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) { | |
| 119 | 143 | if limit <= 0 { | |
| 120 | 144 | return []entity.HistoryMatch{}, nil | |
| 121 | 145 | } | |
| @@ -136,9 +160,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 136 | 160 | // Use domain-boosted query for URL search to prioritize domain matches | |
| 137 | 161 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | |
| 138 | 162 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | |
| 139 | - | Term: sql.NullString{String: words[0], Valid: true}, | |
| 140 | - | Query: ftsQuery, | |
| 141 | - | Limit: int64(limit), | |
| 163 | + | Term: sql.NullString{String: words[0], Valid: true}, | |
| 164 | + | Query: ftsQuery, | |
| 165 | + | Cutoff: historyCutoffParam(scope), | |
| 166 | + | Limit: int64(limit), | |
| 142 | 167 | }) | |
| 143 | 168 | if urlErr != nil { | |
| 144 | 169 | logFTSSearchError(ctx, "url", query, urlErr) | |
| @@ -146,8 +171,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 146 | 171 | } | |
| 147 | 172 | ||
| 148 | 173 | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ | |
| 149 | - | Query: ftsQuery, | |
| 150 | - | Limit: int64(limit), | |
| 174 | + | Query: ftsQuery, | |
| 175 | + | Cutoff: historyCutoffParam(scope), | |
| 176 | + | Limit: int64(limit), | |
| 151 | 177 | }) | |
| 152 | 178 | if titleErr != nil { | |
| 153 | 179 | logFTSSearchError(ctx, "title", query, titleErr) | |
| @@ -482,18 +508,32 @@ func isExpectedFTSError(err error) bool { | |||
| 482 | 508 | strings.Contains(msg, "unterminated string") | |
| 483 | 509 | } | |
| 484 | 510 | ||
| 485 | - | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 511 | + | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) { | |
| 512 | + | // SQLite treats a negative LIMIT as "no limit", which preserves the | |
| 513 | + | // historical zero-limit-means-all behavior while applying the age scope. | |
| 514 | + | sqlLimit := int64(limit) | |
| 486 | 515 | if limit <= 0 { | |
| 487 | - | rows, err := r.queries.GetAllRecentHistory(ctx) | |
| 488 | - | if err != nil { | |
| 489 | - | return nil, err | |
| 490 | - | } | |
| 491 | - | return historyEntriesFromRows(rows), nil | |
| 516 | + | sqlLimit = -1 | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // Bounded and unbounded windows use separate queries so the bounded path | |
| 520 | + | // compares last_visited directly and keeps the index usable. | |
| 521 | + | var ( | |
| 522 | + | rows []sqlc.History | |
| 523 | + | err error | |
| 524 | + | ) | |
| 525 | + | if scope.Cutoff.IsZero() { | |
| 526 | + | rows, err = r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 527 | + | Limit: sqlLimit, | |
| 528 | + | Offset: int64(offset), | |
| 529 | + | }) | |
| 530 | + | } else { | |
| 531 | + | rows, err = r.queries.GetRecentHistorySinceCutoff(ctx, sqlc.GetRecentHistorySinceCutoffParams{ | |
| 532 | + | Cutoff: boundedHistoryCutoff(scope.Cutoff), | |
| 533 | + | Limit: sqlLimit, | |
| 534 | + | Offset: int64(offset), | |
| 535 | + | }) | |
| 492 | 536 | } | |
| 493 | - | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 494 | - | Limit: int64(limit), | |
| 495 | - | Offset: int64(offset), | |
| 496 | - | }) | |
| 497 | 537 | if err != nil { | |
| 498 | 538 | return nil, err | |
| 499 | 539 | } | |
| @@ -612,6 +652,36 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||
| 612 | 652 | return historyEntriesFromRows(rows), nil | |
| 613 | 653 | } | |
| 614 | 654 | ||
| 655 | + | func (r *historyRepo) GetMostVisitedWithin( | |
| 656 | + | ctx context.Context, | |
| 657 | + | limit int, | |
| 658 | + | scope repository.HistoryScope, | |
| 659 | + | ) ([]*entity.HistoryEntry, error) { | |
| 660 | + | if limit <= 0 { | |
| 661 | + | return []*entity.HistoryEntry{}, nil | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | // Bounded and unbounded windows use separate queries so the bounded path | |
| 665 | + | // compares last_visited directly and keeps the index usable. | |
| 666 | + | var ( | |
| 667 | + | rows []sqlc.History | |
| 668 | + | err error | |
| 669 | + | ) | |
| 670 | + | if scope.Cutoff.IsZero() { | |
| 671 | + | rows, err = r.queries.GetMostVisitedHistory(ctx, int64(limit)) | |
| 672 | + | } else { | |
| 673 | + | rows, err = r.queries.GetMostVisitedHistorySinceCutoff(ctx, sqlc.GetMostVisitedHistorySinceCutoffParams{ | |
| 674 | + | Cutoff: boundedHistoryCutoff(scope.Cutoff), | |
| 675 | + | Limit: int64(limit), | |
| 676 | + | }) | |
| 677 | + | } | |
| 678 | + | if err != nil { | |
| 679 | + | return nil, err | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | return historyEntriesFromRows(rows), nil | |
| 683 | + | } | |
| 684 | + | ||
| 615 | 685 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | |
| 616 | 686 | // Skip incrementing about:blank - it should always have visit_count = 1 | |
| 617 | 687 | if rawURL == aboutBlankURL { | |
| @@ -44,6 +44,30 @@ const ( | |||||
| 44 | 44 | ||||
| 45 | var expectedFTSFailureCount atomic.Uint64 | 45 | var expectedFTSFailureCount atomic.Uint64 | ||
| 46 | 46 | ||||
| 47 | + | // boundedHistoryCutoff normalizes a real cutoff to UTC at the second precision | |||
| 48 | + | // SQLite stores in last_visited, so the inclusive `>=` comparison matches an | |||
| 49 | + | // entry exactly on the cutoff. | |||
| 50 | + | // | |||
| 51 | + | // sqlc models the nullable last_visited column comparison as sql.NullTime, so | |||
| 52 | + | // Valid is always true here: only bounded callers use this helper. Unbounded | |||
| 53 | + | // recent/most-visited lists use separate queries with no cutoff predicate, and | |||
| 54 | + | // the FTS path uses historyCutoffParam. | |||
| 55 | + | func boundedHistoryCutoff(cutoff time.Time) sql.NullTime { | |||
| 56 | + | return sql.NullTime{Time: cutoff.UTC().Truncate(time.Second), Valid: true} | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // historyCutoffParam returns nil for an unbounded scope and a concrete cutoff | |||
| 60 | + | // otherwise. It feeds the FTS queries, where MATCH already narrows the | |||
| 61 | + | // candidate set and a single nullable predicate avoids duplicating the URL and | |||
| 62 | + | // title variants across bounded and unbounded windows. sqlc types that | |||
| 63 | + | // predicate as any because the `IS NULL` branch prevents a concrete inference. | |||
| 64 | + | func historyCutoffParam(scope repository.HistoryScope) any { | |||
| 65 | + | if scope.Cutoff.IsZero() { | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | return boundedHistoryCutoff(scope.Cutoff) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 47 | type historyRepo struct { | 71 | type historyRepo struct { | ||
| 48 | db *sql.DB | 72 | db *sql.DB | ||
| 49 | queries *sqlc.Queries | 73 | queries *sqlc.Queries | ||
| @@ -115,7 +139,7 @@ func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryE | |||||
| 115 | return err | 139 | return err | ||
| 116 | } | 140 | } | ||
| 117 | 141 | ||||
| 118 | - | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 142 | + | func (r *historyRepo) Search(ctx context.Context, query string, limit int, scope repository.HistoryScope) ([]entity.HistoryMatch, error) { |
| 119 | if limit <= 0 { | 143 | if limit <= 0 { | ||
| 120 | return []entity.HistoryMatch{}, nil | 144 | return []entity.HistoryMatch{}, nil | ||
| 121 | } | 145 | } | ||
| @@ -136,9 +160,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 136 | // Use domain-boosted query for URL search to prioritize domain matches | 160 | // Use domain-boosted query for URL search to prioritize domain matches | ||
| 137 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | 161 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | ||
| 138 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | 162 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | ||
| 139 | - | Term: sql.NullString{String: words[0], Valid: true}, | 163 | + | Term: sql.NullString{String: words[0], Valid: true}, |
| 140 | - | Query: ftsQuery, | 164 | + | Query: ftsQuery, |
| 141 | - | Limit: int64(limit), | 165 | + | Cutoff: historyCutoffParam(scope), |
| 166 | + | Limit: int64(limit), | |||
| 142 | }) | 167 | }) | ||
| 143 | if urlErr != nil { | 168 | if urlErr != nil { | ||
| 144 | logFTSSearchError(ctx, "url", query, urlErr) | 169 | logFTSSearchError(ctx, "url", query, urlErr) | ||
| @@ -146,8 +171,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 146 | } | 171 | } | ||
| 147 | 172 | ||||
| 148 | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ | 173 | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ | ||
| 149 | - | Query: ftsQuery, | 174 | + | Query: ftsQuery, |
| 150 | - | Limit: int64(limit), | 175 | + | Cutoff: historyCutoffParam(scope), |
| 176 | + | Limit: int64(limit), | |||
| 151 | }) | 177 | }) | ||
| 152 | if titleErr != nil { | 178 | if titleErr != nil { | ||
| 153 | logFTSSearchError(ctx, "title", query, titleErr) | 179 | logFTSSearchError(ctx, "title", query, titleErr) | ||
| @@ -482,18 +508,32 @@ func isExpectedFTSError(err error) bool { | |||||
| 482 | strings.Contains(msg, "unterminated string") | 508 | strings.Contains(msg, "unterminated string") | ||
| 483 | } | 509 | } | ||
| 484 | 510 | ||||
| 485 | - | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 511 | + | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int, scope repository.HistoryScope) ([]*entity.HistoryEntry, error) { |
| 512 | + | // SQLite treats a negative LIMIT as "no limit", which preserves the | |||
| 513 | + | // historical zero-limit-means-all behavior while applying the age scope. | |||
| 514 | + | sqlLimit := int64(limit) | |||
| 486 | if limit <= 0 { | 515 | if limit <= 0 { | ||
| 487 | - | rows, err := r.queries.GetAllRecentHistory(ctx) | 516 | + | sqlLimit = -1 |
| 488 | - | if err != nil { | 517 | + | } |
| 489 | - | return nil, err | 518 | + | |
| 490 | - | } | 519 | + | // Bounded and unbounded windows use separate queries so the bounded path |
| 491 | - | return historyEntriesFromRows(rows), nil | 520 | + | // compares last_visited directly and keeps the index usable. |
| 521 | + | var ( | |||
| 522 | + | rows []sqlc.History | |||
| 523 | + | err error | |||
| 524 | + | ) | |||
| 525 | + | if scope.Cutoff.IsZero() { | |||
| 526 | + | rows, err = r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |||
| 527 | + | Limit: sqlLimit, | |||
| 528 | + | Offset: int64(offset), | |||
| 529 | + | }) | |||
| 530 | + | } else { | |||
| 531 | + | rows, err = r.queries.GetRecentHistorySinceCutoff(ctx, sqlc.GetRecentHistorySinceCutoffParams{ | |||
| 532 | + | Cutoff: boundedHistoryCutoff(scope.Cutoff), | |||
| 533 | + | Limit: sqlLimit, | |||
| 534 | + | Offset: int64(offset), | |||
| 535 | + | }) | |||
| 492 | } | 536 | } | ||
| 493 | - | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |||
| 494 | - | Limit: int64(limit), | |||
| 495 | - | Offset: int64(offset), | |||
| 496 | - | }) | |||
| 497 | if err != nil { | 537 | if err != nil { | ||
| 498 | return nil, err | 538 | return nil, err | ||
| 499 | } | 539 | } | ||
| @@ -612,6 +652,36 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||||
| 612 | return historyEntriesFromRows(rows), nil | 652 | return historyEntriesFromRows(rows), nil | ||
| 613 | } | 653 | } | ||
| 614 | 654 | ||||
| 655 | + | func (r *historyRepo) GetMostVisitedWithin( | |||
| 656 | + | ctx context.Context, | |||
| 657 | + | limit int, | |||
| 658 | + | scope repository.HistoryScope, | |||
| 659 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 660 | + | if limit <= 0 { | |||
| 661 | + | return []*entity.HistoryEntry{}, nil | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | // Bounded and unbounded windows use separate queries so the bounded path | |||
| 665 | + | // compares last_visited directly and keeps the index usable. | |||
| 666 | + | var ( | |||
| 667 | + | rows []sqlc.History | |||
| 668 | + | err error | |||
| 669 | + | ) | |||
| 670 | + | if scope.Cutoff.IsZero() { | |||
| 671 | + | rows, err = r.queries.GetMostVisitedHistory(ctx, int64(limit)) | |||
| 672 | + | } else { | |||
| 673 | + | rows, err = r.queries.GetMostVisitedHistorySinceCutoff(ctx, sqlc.GetMostVisitedHistorySinceCutoffParams{ | |||
| 674 | + | Cutoff: boundedHistoryCutoff(scope.Cutoff), | |||
| 675 | + | Limit: int64(limit), | |||
| 676 | + | }) | |||
| 677 | + | } | |||
| 678 | + | if err != nil { | |||
| 679 | + | return nil, err | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | return historyEntriesFromRows(rows), nil | |||
| 683 | + | } | |||
| 684 | + | ||||
| 615 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | 685 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | ||
| 616 | // Skip incrementing about:blank - it should always have visit_count = 1 | 686 | // Skip incrementing about:blank - it should always have visit_count = 1 | ||
| 617 | if rawURL == aboutBlankURL { | 687 | if rawURL == aboutBlankURL { | ||
M internal/infrastructure/persistence/sqlite/history_repo_benchmark_test.go +301 -24
| @@ -1,70 +1,347 @@ | |||
| 1 | 1 | package sqlite_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 4 | 6 | "fmt" | |
| 5 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 6 | 9 | "testing" | |
| 10 | + | "time" | |
| 7 | 11 | ||
| 8 | - | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 13 | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | 14 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |
| 11 | 16 | ) | |
| 12 | 17 | ||
| 13 | - | // newHistoryBenchmarkRepository creates a representative local history corpus | |
| 14 | - | // outside the measured operation. Sidebar reads use GetRecent and omnibox | |
| 15 | - | // history suggestions use Search. | |
| 16 | - | func newHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, func()) { | |
| 18 | + | const ( | |
| 19 | + | // historyBenchmarkCorpusSize is the number of rows seeded for the | |
| 20 | + | // recent/search persistence benchmarks. | |
| 21 | + | historyBenchmarkCorpusSize = 200 | |
| 22 | + | // historyBenchmarkEligibleRows is the number of rows left inside the bounded | |
| 23 | + | // window; the remainder are stale so bounded benchmarks still exercise a | |
| 24 | + | // large history with few eligible rows. | |
| 25 | + | historyBenchmarkEligibleRows = 40 | |
| 26 | + | // historyBenchmarkStaleRows is the number of stale, high-visit-count rows | |
| 27 | + | // seeded for bounded most-visited benchmarks. They outrank every eligible | |
| 28 | + | // row, so an unfiltered most-visited scan would never reach the eligible rows | |
| 29 | + | // within the result limit. | |
| 30 | + | historyBenchmarkStaleRows = 2000 | |
| 31 | + | ||
| 32 | + | historyBenchmarkRecentLimit = 50 | |
| 33 | + | historyBenchmarkSearchLimit = 10 | |
| 34 | + | historyBenchmarkMostVisitedLimit = 10 | |
| 35 | + | historyBenchmarkMaxAgeDays = 30 | |
| 36 | + | // historyBenchmarkStaleAgeDays places corpus rows well outside the window. | |
| 37 | + | historyBenchmarkStaleAgeDays = 400 | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // historyBenchmarkRow is one explicitly categorized corpus row. Category is | |
| 41 | + | // encoded in the URL, title, and timestamp rather than an AUTOINCREMENT id so | |
| 42 | + | // benchmarks do not depend on insertion order. | |
| 43 | + | type historyBenchmarkRow struct { | |
| 44 | + | url string | |
| 45 | + | title string | |
| 46 | + | lastVisited time.Time | |
| 47 | + | visitCount int | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // historyBenchmarkURL returns the stable URL for corpus row i. | |
| 51 | + | func historyBenchmarkURL(i int) string { | |
| 52 | + | return fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // newHistoryBenchmarkDB opens an isolated benchmark database and registers | |
| 56 | + | // cleanup. | |
| 57 | + | func newHistoryBenchmarkDB(b *testing.B) *sql.DB { | |
| 17 | 58 | b.Helper() | |
| 18 | 59 | ||
| 19 | - | ctx := historyTestCtx() | |
| 20 | - | db, err := sqlite.NewConnection(ctx, filepath.Join(b.TempDir(), "dumber.db")) | |
| 60 | + | db, err := sqlite.NewConnection(historyTestCtx(), filepath.Join(b.TempDir(), "dumber.db")) | |
| 21 | 61 | if err != nil { | |
| 22 | 62 | b.Fatal(err) | |
| 23 | 63 | } | |
| 24 | - | ||
| 25 | 64 | b.Cleanup(func() { | |
| 26 | - | if err := db.Close(); err != nil { | |
| 27 | - | b.Errorf("close benchmark database: %v", err) | |
| 65 | + | if closeErr := db.Close(); closeErr != nil { | |
| 66 | + | b.Errorf("close benchmark database: %v", closeErr) | |
| 28 | 67 | } | |
| 29 | 68 | }) | |
| 30 | 69 | ||
| 31 | - | repo := sqlite.NewHistoryRepository(db) | |
| 32 | - | for i := range 200 { | |
| 33 | - | entry := &entity.HistoryEntry{ | |
| 34 | - | URL: fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i), | |
| 35 | - | Title: fmt.Sprintf("Dumber history entry %d", i), | |
| 36 | - | } | |
| 37 | - | if err := repo.Save(ctx, entry); err != nil { | |
| 70 | + | return db | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // seedHistoryBenchmarkRows inserts corpus rows in a single transaction without | |
| 74 | + | // going through the logging repository path. | |
| 75 | + | func seedHistoryBenchmarkRows(b *testing.B, ctx context.Context, db *sql.DB, rows []historyBenchmarkRow) { | |
| 76 | + | b.Helper() | |
| 77 | + | ||
| 78 | + | tx, err := db.BeginTx(ctx, nil) | |
| 79 | + | if err != nil { | |
| 80 | + | b.Fatal(err) | |
| 81 | + | } | |
| 82 | + | stmt, err := tx.PrepareContext(ctx, `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, ?, ?)`) | |
| 83 | + | if err != nil { | |
| 84 | + | _ = tx.Rollback() | |
| 85 | + | b.Fatal(err) | |
| 86 | + | } | |
| 87 | + | defer func() { _ = stmt.Close() }() | |
| 88 | + | ||
| 89 | + | for _, row := range rows { | |
| 90 | + | if _, err := stmt.ExecContext( | |
| 91 | + | ctx, | |
| 92 | + | row.url, | |
| 93 | + | row.title, | |
| 94 | + | sqliteTimestamp(row.lastVisited), | |
| 95 | + | row.visitCount, | |
| 96 | + | ); err != nil { | |
| 97 | + | _ = tx.Rollback() | |
| 38 | 98 | b.Fatal(err) | |
| 39 | 99 | } | |
| 40 | 100 | } | |
| 101 | + | if err := tx.Commit(); err != nil { | |
| 102 | + | b.Fatal(err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // historyBenchmarkCorpusRows builds historyBenchmarkCorpusSize rows with the | |
| 107 | + | // first eligibleRows rows recent and the rest stale. | |
| 108 | + | func historyBenchmarkCorpusRows(now time.Time, eligibleRows int) []historyBenchmarkRow { | |
| 109 | + | recent := now.Add(-time.Hour) | |
| 110 | + | stale := now.AddDate(0, 0, -historyBenchmarkStaleAgeDays) | |
| 111 | + | ||
| 112 | + | rows := make([]historyBenchmarkRow, 0, historyBenchmarkCorpusSize) | |
| 113 | + | for i := range historyBenchmarkCorpusSize { | |
| 114 | + | lastVisited := stale | |
| 115 | + | if i < eligibleRows { | |
| 116 | + | lastVisited = recent | |
| 117 | + | } | |
| 118 | + | rows = append(rows, historyBenchmarkRow{ | |
| 119 | + | url: historyBenchmarkURL(i), | |
| 120 | + | title: fmt.Sprintf("Dumber history entry %d", i), | |
| 121 | + | lastVisited: lastVisited, | |
| 122 | + | visitCount: 1, | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | return rows | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // newHistoryBenchmarkRepository seeds an unbounded corpus where every row is | |
| 129 | + | // inside the window. | |
| 130 | + | func newHistoryBenchmarkRepository(b *testing.B) repository.HistoryRepository { | |
| 131 | + | b.Helper() | |
| 132 | + | ||
| 133 | + | db := newHistoryBenchmarkDB(b) | |
| 134 | + | seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(time.Now().UTC(), historyBenchmarkCorpusSize)) | |
| 41 | 135 | ||
| 42 | - | return repo, func() {} | |
| 136 | + | return sqlite.NewHistoryRepository(db) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // newBoundedHistoryBenchmarkRepository seeds a corpus where only | |
| 140 | + | // historyBenchmarkEligibleRows rows fall inside the window, and returns the | |
| 141 | + | // cutoff for the bounded repository paths. | |
| 142 | + | func newBoundedHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, time.Time) { | |
| 143 | + | b.Helper() | |
| 144 | + | ||
| 145 | + | now := time.Now().UTC() | |
| 146 | + | db := newHistoryBenchmarkDB(b) | |
| 147 | + | seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(now, historyBenchmarkEligibleRows)) | |
| 148 | + | ||
| 149 | + | return sqlite.NewHistoryRepository(db), now.AddDate(0, 0, -historyBenchmarkMaxAgeDays) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // newMostVisitedBenchmarkUseCase seeds thousands of stale high-count rows plus | |
| 153 | + | // eligibleRows recent low-count rows, then returns the application use case so | |
| 154 | + | // benchmarks exercise the real omnibox most-visited path. | |
| 155 | + | func newMostVisitedBenchmarkUseCase(b *testing.B, eligibleRows int) *usecase.SearchHistoryUseCase { | |
| 156 | + | b.Helper() | |
| 157 | + | ||
| 158 | + | now := time.Now().UTC() | |
| 159 | + | db := newHistoryBenchmarkDB(b) | |
| 160 | + | ||
| 161 | + | rows := make([]historyBenchmarkRow, 0, historyBenchmarkStaleRows+eligibleRows) | |
| 162 | + | for i := range historyBenchmarkStaleRows { | |
| 163 | + | rows = append(rows, historyBenchmarkRow{ | |
| 164 | + | url: fmt.Sprintf("https://stale%d.test/page", i), | |
| 165 | + | title: fmt.Sprintf("Stale %d", i), | |
| 166 | + | lastVisited: now.AddDate(0, 0, -historyBenchmarkStaleAgeDays), | |
| 167 | + | visitCount: 10_000 + i, | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | for i := range eligibleRows { | |
| 171 | + | rows = append(rows, historyBenchmarkRow{ | |
| 172 | + | url: fmt.Sprintf("https://eligible%d.test/page", i), | |
| 173 | + | title: fmt.Sprintf("Eligible %d", i), | |
| 174 | + | lastVisited: now.Add(-time.Hour), | |
| 175 | + | visitCount: 1 + i, | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | seedHistoryBenchmarkRows(b, historyTestCtx(), db, rows) | |
| 179 | + | ||
| 180 | + | logBoundedMostVisitedPlan(b, historyTestCtx(), db, now.AddDate(0, 0, -historyBenchmarkMaxAgeDays)) | |
| 181 | + | ||
| 182 | + | return usecase.NewSearchHistoryUseCase(sqlite.NewHistoryRepository(db)) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // requireBenchmarkLen fails the benchmark if a fixture or query regression | |
| 186 | + | // changes the expected result count, so benchmarks never silently measure empty | |
| 187 | + | // work. | |
| 188 | + | func requireBenchmarkLen(b *testing.B, got, want int, label string) { | |
| 189 | + | b.Helper() | |
| 190 | + | ||
| 191 | + | if got != want { | |
| 192 | + | b.Fatalf("%s: got %d results, want %d", label, got, want) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // logBoundedMostVisitedPlan reports (without asserting) the planner choice for | |
| 197 | + | // the bounded most-visited query, which is informational and not part of the | |
| 198 | + | // contract. | |
| 199 | + | func logBoundedMostVisitedPlan(b *testing.B, ctx context.Context, db *sql.DB, cutoff time.Time) { | |
| 200 | + | b.Helper() | |
| 201 | + | ||
| 202 | + | rows, err := db.QueryContext( | |
| 203 | + | ctx, | |
| 204 | + | "EXPLAIN QUERY PLAN "+sqlc.GetMostVisitedHistorySinceCutoff, | |
| 205 | + | sqliteTimestamp(cutoff), | |
| 206 | + | historyBenchmarkMostVisitedLimit, | |
| 207 | + | ) | |
| 208 | + | if err != nil { | |
| 209 | + | b.Logf("bounded most-visited explain failed: %v", err) | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | defer func() { _ = rows.Close() }() | |
| 213 | + | ||
| 214 | + | var details []string | |
| 215 | + | for rows.Next() { | |
| 216 | + | var id, parent, notUsed int | |
| 217 | + | var detail string | |
| 218 | + | if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { | |
| 219 | + | b.Logf("bounded most-visited explain scan failed: %v", err) | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | details = append(details, detail) | |
| 223 | + | } | |
| 224 | + | b.Logf("bounded most-visited plan: %s", strings.Join(details, "; ")) | |
| 43 | 225 | } | |
| 44 | 226 | ||
| 45 | 227 | func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) { | |
| 46 | - | repo, closeDB := newHistoryBenchmarkRepository(b) | |
| 47 | - | defer closeDB() | |
| 228 | + | repo := newHistoryBenchmarkRepository(b) | |
| 48 | 229 | ctx := historyTestCtx() | |
| 49 | 230 | ||
| 231 | + | results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}) | |
| 232 | + | if err != nil { | |
| 233 | + | b.Fatal(err) | |
| 234 | + | } | |
| 235 | + | requireBenchmarkLen(b, len(results), historyBenchmarkRecentLimit, "unbounded recent") | |
| 236 | + | ||
| 237 | + | b.ReportAllocs() | |
| 238 | + | b.ResetTimer() | |
| 239 | + | for range b.N { | |
| 240 | + | if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}); err != nil { | |
| 241 | + | b.Fatal(err) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func BenchmarkHistorySQLiteSidebarRecentBounded(b *testing.B) { | |
| 247 | + | repo, cutoff := newBoundedHistoryBenchmarkRepository(b) | |
| 248 | + | ctx := historyTestCtx() | |
| 249 | + | scope := repository.HistoryScope{Cutoff: cutoff} | |
| 250 | + | ||
| 251 | + | results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope) | |
| 252 | + | if err != nil { | |
| 253 | + | b.Fatal(err) | |
| 254 | + | } | |
| 255 | + | requireBenchmarkLen(b, len(results), historyBenchmarkEligibleRows, "bounded recent") | |
| 256 | + | ||
| 257 | + | b.ReportAllocs() | |
| 258 | + | b.ResetTimer() | |
| 259 | + | for range b.N { | |
| 260 | + | if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope); err != nil { | |
| 261 | + | b.Fatal(err) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible measures the | |
| 267 | + | // bounded most-visited path when only a handful of rows fall inside the window | |
| 268 | + | // despite thousands of higher-count stale rows. | |
| 269 | + | func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible(b *testing.B) { | |
| 270 | + | const eligibleRows = 5 | |
| 271 | + | ||
| 272 | + | uc := newMostVisitedBenchmarkUseCase(b, eligibleRows) | |
| 273 | + | ctx := historyTestCtx() | |
| 274 | + | ||
| 275 | + | results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays) | |
| 276 | + | if err != nil { | |
| 277 | + | b.Fatal(err) | |
| 278 | + | } | |
| 279 | + | requireBenchmarkLen(b, len(results), eligibleRows, "bounded most-visited few eligible") | |
| 280 | + | ||
| 281 | + | b.ReportAllocs() | |
| 282 | + | b.ResetTimer() | |
| 283 | + | for range b.N { | |
| 284 | + | if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil { | |
| 285 | + | b.Fatal(err) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible measures the | |
| 291 | + | // bounded most-visited path when no row falls inside the window. | |
| 292 | + | func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible(b *testing.B) { | |
| 293 | + | uc := newMostVisitedBenchmarkUseCase(b, 0) | |
| 294 | + | ctx := historyTestCtx() | |
| 295 | + | ||
| 296 | + | results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays) | |
| 297 | + | if err != nil { | |
| 298 | + | b.Fatal(err) | |
| 299 | + | } | |
| 300 | + | requireBenchmarkLen(b, len(results), 0, "bounded most-visited no eligible") | |
| 301 | + | ||
| 50 | 302 | b.ReportAllocs() | |
| 51 | 303 | b.ResetTimer() | |
| 52 | 304 | for range b.N { | |
| 53 | - | if _, err := repo.GetRecent(ctx, 50, 0); err != nil { | |
| 305 | + | if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil { | |
| 54 | 306 | b.Fatal(err) | |
| 55 | 307 | } | |
| 56 | 308 | } | |
| 57 | 309 | } | |
| 58 | 310 | ||
| 59 | 311 | func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) { | |
| 60 | - | repo, closeDB := newHistoryBenchmarkRepository(b) | |
| 61 | - | defer closeDB() | |
| 312 | + | repo := newHistoryBenchmarkRepository(b) | |
| 62 | 313 | ctx := historyTestCtx() | |
| 63 | 314 | ||
| 315 | + | results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}) | |
| 316 | + | if err != nil { | |
| 317 | + | b.Fatal(err) | |
| 318 | + | } | |
| 319 | + | requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "unbounded search") | |
| 320 | + | ||
| 321 | + | b.ReportAllocs() | |
| 322 | + | b.ResetTimer() | |
| 323 | + | for range b.N { | |
| 324 | + | if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}); err != nil { | |
| 325 | + | b.Fatal(err) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func BenchmarkHistorySQLiteOmniboxSearchBounded(b *testing.B) { | |
| 331 | + | repo, cutoff := newBoundedHistoryBenchmarkRepository(b) | |
| 332 | + | ctx := historyTestCtx() | |
| 333 | + | scope := repository.HistoryScope{Cutoff: cutoff} | |
| 334 | + | ||
| 335 | + | results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope) | |
| 336 | + | if err != nil { | |
| 337 | + | b.Fatal(err) | |
| 338 | + | } | |
| 339 | + | requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "bounded search") | |
| 340 | + | ||
| 64 | 341 | b.ReportAllocs() | |
| 65 | 342 | b.ResetTimer() | |
| 66 | 343 | for range b.N { | |
| 67 | - | if _, err := repo.Search(ctx, "dumber history", 10); err != nil { | |
| 344 | + | if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope); err != nil { | |
| 68 | 345 | b.Fatal(err) | |
| 69 | 346 | } | |
| 70 | 347 | } | |
| @@ -1,70 +1,347 @@ | |||||
| 1 | package sqlite_test | 1 | package sqlite_test | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 4 | "fmt" | 6 | "fmt" | ||
| 5 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strings" | |||
| 6 | "testing" | 9 | "testing" | ||
| 10 | + | "time" | |||
| 7 | 11 | ||||
| 8 | - | "github.com/bnema/dumber/internal/domain/entity" | 12 | + | "github.com/bnema/dumber/internal/application/usecase" |
| 9 | "github.com/bnema/dumber/internal/domain/repository" | 13 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 10 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 14 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |||
| 11 | ) | 16 | ) | ||
| 12 | 17 | ||||
| 13 | - | // newHistoryBenchmarkRepository creates a representative local history corpus | 18 | + | const ( |
| 14 | - | // outside the measured operation. Sidebar reads use GetRecent and omnibox | 19 | + | // historyBenchmarkCorpusSize is the number of rows seeded for the |
| 15 | - | // history suggestions use Search. | 20 | + | // recent/search persistence benchmarks. |
| 16 | - | func newHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, func()) { | 21 | + | historyBenchmarkCorpusSize = 200 |
| 22 | + | // historyBenchmarkEligibleRows is the number of rows left inside the bounded | |||
| 23 | + | // window; the remainder are stale so bounded benchmarks still exercise a | |||
| 24 | + | // large history with few eligible rows. | |||
| 25 | + | historyBenchmarkEligibleRows = 40 | |||
| 26 | + | // historyBenchmarkStaleRows is the number of stale, high-visit-count rows | |||
| 27 | + | // seeded for bounded most-visited benchmarks. They outrank every eligible | |||
| 28 | + | // row, so an unfiltered most-visited scan would never reach the eligible rows | |||
| 29 | + | // within the result limit. | |||
| 30 | + | historyBenchmarkStaleRows = 2000 | |||
| 31 | + | ||||
| 32 | + | historyBenchmarkRecentLimit = 50 | |||
| 33 | + | historyBenchmarkSearchLimit = 10 | |||
| 34 | + | historyBenchmarkMostVisitedLimit = 10 | |||
| 35 | + | historyBenchmarkMaxAgeDays = 30 | |||
| 36 | + | // historyBenchmarkStaleAgeDays places corpus rows well outside the window. | |||
| 37 | + | historyBenchmarkStaleAgeDays = 400 | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // historyBenchmarkRow is one explicitly categorized corpus row. Category is | |||
| 41 | + | // encoded in the URL, title, and timestamp rather than an AUTOINCREMENT id so | |||
| 42 | + | // benchmarks do not depend on insertion order. | |||
| 43 | + | type historyBenchmarkRow struct { | |||
| 44 | + | url string | |||
| 45 | + | title string | |||
| 46 | + | lastVisited time.Time | |||
| 47 | + | visitCount int | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // historyBenchmarkURL returns the stable URL for corpus row i. | |||
| 51 | + | func historyBenchmarkURL(i int) string { | |||
| 52 | + | return fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // newHistoryBenchmarkDB opens an isolated benchmark database and registers | |||
| 56 | + | // cleanup. | |||
| 57 | + | func newHistoryBenchmarkDB(b *testing.B) *sql.DB { | |||
| 17 | b.Helper() | 58 | b.Helper() | ||
| 18 | 59 | ||||
| 19 | - | ctx := historyTestCtx() | 60 | + | db, err := sqlite.NewConnection(historyTestCtx(), filepath.Join(b.TempDir(), "dumber.db")) |
| 20 | - | db, err := sqlite.NewConnection(ctx, filepath.Join(b.TempDir(), "dumber.db")) | |||
| 21 | if err != nil { | 61 | if err != nil { | ||
| 22 | b.Fatal(err) | 62 | b.Fatal(err) | ||
| 23 | } | 63 | } | ||
| 24 | - | ||||
| 25 | b.Cleanup(func() { | 64 | b.Cleanup(func() { | ||
| 26 | - | if err := db.Close(); err != nil { | 65 | + | if closeErr := db.Close(); closeErr != nil { |
| 27 | - | b.Errorf("close benchmark database: %v", err) | 66 | + | b.Errorf("close benchmark database: %v", closeErr) |
| 28 | } | 67 | } | ||
| 29 | }) | 68 | }) | ||
| 30 | 69 | ||||
| 31 | - | repo := sqlite.NewHistoryRepository(db) | 70 | + | return db |
| 32 | - | for i := range 200 { | 71 | + | } |
| 33 | - | entry := &entity.HistoryEntry{ | 72 | + | |
| 34 | - | URL: fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i), | 73 | + | // seedHistoryBenchmarkRows inserts corpus rows in a single transaction without |
| 35 | - | Title: fmt.Sprintf("Dumber history entry %d", i), | 74 | + | // going through the logging repository path. |
| 36 | - | } | 75 | + | func seedHistoryBenchmarkRows(b *testing.B, ctx context.Context, db *sql.DB, rows []historyBenchmarkRow) { |
| 37 | - | if err := repo.Save(ctx, entry); err != nil { | 76 | + | b.Helper() |
| 77 | + | ||||
| 78 | + | tx, err := db.BeginTx(ctx, nil) | |||
| 79 | + | if err != nil { | |||
| 80 | + | b.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | stmt, err := tx.PrepareContext(ctx, `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, ?, ?)`) | |||
| 83 | + | if err != nil { | |||
| 84 | + | _ = tx.Rollback() | |||
| 85 | + | b.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | defer func() { _ = stmt.Close() }() | |||
| 88 | + | ||||
| 89 | + | for _, row := range rows { | |||
| 90 | + | if _, err := stmt.ExecContext( | |||
| 91 | + | ctx, | |||
| 92 | + | row.url, | |||
| 93 | + | row.title, | |||
| 94 | + | sqliteTimestamp(row.lastVisited), | |||
| 95 | + | row.visitCount, | |||
| 96 | + | ); err != nil { | |||
| 97 | + | _ = tx.Rollback() | |||
| 38 | b.Fatal(err) | 98 | b.Fatal(err) | ||
| 39 | } | 99 | } | ||
| 40 | } | 100 | } | ||
| 101 | + | if err := tx.Commit(); err != nil { | |||
| 102 | + | b.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // historyBenchmarkCorpusRows builds historyBenchmarkCorpusSize rows with the | |||
| 107 | + | // first eligibleRows rows recent and the rest stale. | |||
| 108 | + | func historyBenchmarkCorpusRows(now time.Time, eligibleRows int) []historyBenchmarkRow { | |||
| 109 | + | recent := now.Add(-time.Hour) | |||
| 110 | + | stale := now.AddDate(0, 0, -historyBenchmarkStaleAgeDays) | |||
| 111 | + | ||||
| 112 | + | rows := make([]historyBenchmarkRow, 0, historyBenchmarkCorpusSize) | |||
| 113 | + | for i := range historyBenchmarkCorpusSize { | |||
| 114 | + | lastVisited := stale | |||
| 115 | + | if i < eligibleRows { | |||
| 116 | + | lastVisited = recent | |||
| 117 | + | } | |||
| 118 | + | rows = append(rows, historyBenchmarkRow{ | |||
| 119 | + | url: historyBenchmarkURL(i), | |||
| 120 | + | title: fmt.Sprintf("Dumber history entry %d", i), | |||
| 121 | + | lastVisited: lastVisited, | |||
| 122 | + | visitCount: 1, | |||
| 123 | + | }) | |||
| 124 | + | } | |||
| 125 | + | return rows | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // newHistoryBenchmarkRepository seeds an unbounded corpus where every row is | |||
| 129 | + | // inside the window. | |||
| 130 | + | func newHistoryBenchmarkRepository(b *testing.B) repository.HistoryRepository { | |||
| 131 | + | b.Helper() | |||
| 132 | + | ||||
| 133 | + | db := newHistoryBenchmarkDB(b) | |||
| 134 | + | seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(time.Now().UTC(), historyBenchmarkCorpusSize)) | |||
| 41 | 135 | ||||
| 42 | - | return repo, func() {} | 136 | + | return sqlite.NewHistoryRepository(db) |
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // newBoundedHistoryBenchmarkRepository seeds a corpus where only | |||
| 140 | + | // historyBenchmarkEligibleRows rows fall inside the window, and returns the | |||
| 141 | + | // cutoff for the bounded repository paths. | |||
| 142 | + | func newBoundedHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, time.Time) { | |||
| 143 | + | b.Helper() | |||
| 144 | + | ||||
| 145 | + | now := time.Now().UTC() | |||
| 146 | + | db := newHistoryBenchmarkDB(b) | |||
| 147 | + | seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(now, historyBenchmarkEligibleRows)) | |||
| 148 | + | ||||
| 149 | + | return sqlite.NewHistoryRepository(db), now.AddDate(0, 0, -historyBenchmarkMaxAgeDays) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // newMostVisitedBenchmarkUseCase seeds thousands of stale high-count rows plus | |||
| 153 | + | // eligibleRows recent low-count rows, then returns the application use case so | |||
| 154 | + | // benchmarks exercise the real omnibox most-visited path. | |||
| 155 | + | func newMostVisitedBenchmarkUseCase(b *testing.B, eligibleRows int) *usecase.SearchHistoryUseCase { | |||
| 156 | + | b.Helper() | |||
| 157 | + | ||||
| 158 | + | now := time.Now().UTC() | |||
| 159 | + | db := newHistoryBenchmarkDB(b) | |||
| 160 | + | ||||
| 161 | + | rows := make([]historyBenchmarkRow, 0, historyBenchmarkStaleRows+eligibleRows) | |||
| 162 | + | for i := range historyBenchmarkStaleRows { | |||
| 163 | + | rows = append(rows, historyBenchmarkRow{ | |||
| 164 | + | url: fmt.Sprintf("https://stale%d.test/page", i), | |||
| 165 | + | title: fmt.Sprintf("Stale %d", i), | |||
| 166 | + | lastVisited: now.AddDate(0, 0, -historyBenchmarkStaleAgeDays), | |||
| 167 | + | visitCount: 10_000 + i, | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | for i := range eligibleRows { | |||
| 171 | + | rows = append(rows, historyBenchmarkRow{ | |||
| 172 | + | url: fmt.Sprintf("https://eligible%d.test/page", i), | |||
| 173 | + | title: fmt.Sprintf("Eligible %d", i), | |||
| 174 | + | lastVisited: now.Add(-time.Hour), | |||
| 175 | + | visitCount: 1 + i, | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | seedHistoryBenchmarkRows(b, historyTestCtx(), db, rows) | |||
| 179 | + | ||||
| 180 | + | logBoundedMostVisitedPlan(b, historyTestCtx(), db, now.AddDate(0, 0, -historyBenchmarkMaxAgeDays)) | |||
| 181 | + | ||||
| 182 | + | return usecase.NewSearchHistoryUseCase(sqlite.NewHistoryRepository(db)) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // requireBenchmarkLen fails the benchmark if a fixture or query regression | |||
| 186 | + | // changes the expected result count, so benchmarks never silently measure empty | |||
| 187 | + | // work. | |||
| 188 | + | func requireBenchmarkLen(b *testing.B, got, want int, label string) { | |||
| 189 | + | b.Helper() | |||
| 190 | + | ||||
| 191 | + | if got != want { | |||
| 192 | + | b.Fatalf("%s: got %d results, want %d", label, got, want) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // logBoundedMostVisitedPlan reports (without asserting) the planner choice for | |||
| 197 | + | // the bounded most-visited query, which is informational and not part of the | |||
| 198 | + | // contract. | |||
| 199 | + | func logBoundedMostVisitedPlan(b *testing.B, ctx context.Context, db *sql.DB, cutoff time.Time) { | |||
| 200 | + | b.Helper() | |||
| 201 | + | ||||
| 202 | + | rows, err := db.QueryContext( | |||
| 203 | + | ctx, | |||
| 204 | + | "EXPLAIN QUERY PLAN "+sqlc.GetMostVisitedHistorySinceCutoff, | |||
| 205 | + | sqliteTimestamp(cutoff), | |||
| 206 | + | historyBenchmarkMostVisitedLimit, | |||
| 207 | + | ) | |||
| 208 | + | if err != nil { | |||
| 209 | + | b.Logf("bounded most-visited explain failed: %v", err) | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | defer func() { _ = rows.Close() }() | |||
| 213 | + | ||||
| 214 | + | var details []string | |||
| 215 | + | for rows.Next() { | |||
| 216 | + | var id, parent, notUsed int | |||
| 217 | + | var detail string | |||
| 218 | + | if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { | |||
| 219 | + | b.Logf("bounded most-visited explain scan failed: %v", err) | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | details = append(details, detail) | |||
| 223 | + | } | |||
| 224 | + | b.Logf("bounded most-visited plan: %s", strings.Join(details, "; ")) | |||
| 43 | } | 225 | } | ||
| 44 | 226 | ||||
| 45 | func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) { | 227 | func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) { | ||
| 46 | - | repo, closeDB := newHistoryBenchmarkRepository(b) | 228 | + | repo := newHistoryBenchmarkRepository(b) |
| 47 | - | defer closeDB() | |||
| 48 | ctx := historyTestCtx() | 229 | ctx := historyTestCtx() | ||
| 49 | 230 | ||||
| 231 | + | results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}) | |||
| 232 | + | if err != nil { | |||
| 233 | + | b.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | requireBenchmarkLen(b, len(results), historyBenchmarkRecentLimit, "unbounded recent") | |||
| 236 | + | ||||
| 237 | + | b.ReportAllocs() | |||
| 238 | + | b.ResetTimer() | |||
| 239 | + | for range b.N { | |||
| 240 | + | if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}); err != nil { | |||
| 241 | + | b.Fatal(err) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func BenchmarkHistorySQLiteSidebarRecentBounded(b *testing.B) { | |||
| 247 | + | repo, cutoff := newBoundedHistoryBenchmarkRepository(b) | |||
| 248 | + | ctx := historyTestCtx() | |||
| 249 | + | scope := repository.HistoryScope{Cutoff: cutoff} | |||
| 250 | + | ||||
| 251 | + | results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope) | |||
| 252 | + | if err != nil { | |||
| 253 | + | b.Fatal(err) | |||
| 254 | + | } | |||
| 255 | + | requireBenchmarkLen(b, len(results), historyBenchmarkEligibleRows, "bounded recent") | |||
| 256 | + | ||||
| 257 | + | b.ReportAllocs() | |||
| 258 | + | b.ResetTimer() | |||
| 259 | + | for range b.N { | |||
| 260 | + | if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope); err != nil { | |||
| 261 | + | b.Fatal(err) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible measures the | |||
| 267 | + | // bounded most-visited path when only a handful of rows fall inside the window | |||
| 268 | + | // despite thousands of higher-count stale rows. | |||
| 269 | + | func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible(b *testing.B) { | |||
| 270 | + | const eligibleRows = 5 | |||
| 271 | + | ||||
| 272 | + | uc := newMostVisitedBenchmarkUseCase(b, eligibleRows) | |||
| 273 | + | ctx := historyTestCtx() | |||
| 274 | + | ||||
| 275 | + | results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays) | |||
| 276 | + | if err != nil { | |||
| 277 | + | b.Fatal(err) | |||
| 278 | + | } | |||
| 279 | + | requireBenchmarkLen(b, len(results), eligibleRows, "bounded most-visited few eligible") | |||
| 280 | + | ||||
| 281 | + | b.ReportAllocs() | |||
| 282 | + | b.ResetTimer() | |||
| 283 | + | for range b.N { | |||
| 284 | + | if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil { | |||
| 285 | + | b.Fatal(err) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible measures the | |||
| 291 | + | // bounded most-visited path when no row falls inside the window. | |||
| 292 | + | func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible(b *testing.B) { | |||
| 293 | + | uc := newMostVisitedBenchmarkUseCase(b, 0) | |||
| 294 | + | ctx := historyTestCtx() | |||
| 295 | + | ||||
| 296 | + | results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays) | |||
| 297 | + | if err != nil { | |||
| 298 | + | b.Fatal(err) | |||
| 299 | + | } | |||
| 300 | + | requireBenchmarkLen(b, len(results), 0, "bounded most-visited no eligible") | |||
| 301 | + | ||||
| 50 | b.ReportAllocs() | 302 | b.ReportAllocs() | ||
| 51 | b.ResetTimer() | 303 | b.ResetTimer() | ||
| 52 | for range b.N { | 304 | for range b.N { | ||
| 53 | - | if _, err := repo.GetRecent(ctx, 50, 0); err != nil { | 305 | + | if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil { |
| 54 | b.Fatal(err) | 306 | b.Fatal(err) | ||
| 55 | } | 307 | } | ||
| 56 | } | 308 | } | ||
| 57 | } | 309 | } | ||
| 58 | 310 | ||||
| 59 | func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) { | 311 | func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) { | ||
| 60 | - | repo, closeDB := newHistoryBenchmarkRepository(b) | 312 | + | repo := newHistoryBenchmarkRepository(b) |
| 61 | - | defer closeDB() | |||
| 62 | ctx := historyTestCtx() | 313 | ctx := historyTestCtx() | ||
| 63 | 314 | ||||
| 315 | + | results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}) | |||
| 316 | + | if err != nil { | |||
| 317 | + | b.Fatal(err) | |||
| 318 | + | } | |||
| 319 | + | requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "unbounded search") | |||
| 320 | + | ||||
| 321 | + | b.ReportAllocs() | |||
| 322 | + | b.ResetTimer() | |||
| 323 | + | for range b.N { | |||
| 324 | + | if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}); err != nil { | |||
| 325 | + | b.Fatal(err) | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func BenchmarkHistorySQLiteOmniboxSearchBounded(b *testing.B) { | |||
| 331 | + | repo, cutoff := newBoundedHistoryBenchmarkRepository(b) | |||
| 332 | + | ctx := historyTestCtx() | |||
| 333 | + | scope := repository.HistoryScope{Cutoff: cutoff} | |||
| 334 | + | ||||
| 335 | + | results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope) | |||
| 336 | + | if err != nil { | |||
| 337 | + | b.Fatal(err) | |||
| 338 | + | } | |||
| 339 | + | requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "bounded search") | |||
| 340 | + | ||||
| 64 | b.ReportAllocs() | 341 | b.ReportAllocs() | ||
| 65 | b.ResetTimer() | 342 | b.ResetTimer() | ||
| 66 | for range b.N { | 343 | for range b.N { | ||
| 67 | - | if _, err := repo.Search(ctx, "dumber history", 10); err != nil { | 344 | + | if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope); err != nil { |
| 68 | b.Fatal(err) | 345 | b.Fatal(err) | ||
| 69 | } | 346 | } | ||
| 70 | } | 347 | } | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +390 -19
| @@ -2,13 +2,17 @@ package sqlite_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "database/sql" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | repository "github.com/bnema/dumber/internal/domain/repository" | |
| 11 | 14 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |
| 12 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 17 | "github.com/stretchr/testify/assert" | |
| 14 | 18 | "github.com/stretchr/testify/require" | |
| @@ -188,11 +192,11 @@ func TestHistoryRepository_CRUD(t *testing.T) { | |||
| 188 | 192 | assert.Equal(t, int64(2), found2.VisitCount) | |
| 189 | 193 | ||
| 190 | 194 | // Get recent | |
| 191 | - | recent, err := repo.GetRecent(ctx, 10, 0) | |
| 195 | + | recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{}) | |
| 192 | 196 | require.NoError(t, err) | |
| 193 | 197 | require.Len(t, recent, 1) | |
| 194 | 198 | ||
| 195 | - | allRecent, err := repo.GetRecent(ctx, 0, 0) | |
| 199 | + | allRecent, err := repo.GetRecent(ctx, 0, 0, repository.HistoryScope{}) | |
| 196 | 200 | require.NoError(t, err) | |
| 197 | 201 | require.Len(t, allRecent, 1) | |
| 198 | 202 | ||
| @@ -350,7 +354,7 @@ func TestHistoryRepository_Search_SingleWord(t *testing.T) { | |||
| 350 | 354 | } | |
| 351 | 355 | ||
| 352 | 356 | // Search for "github" - should find the github entry | |
| 353 | - | results, err := repo.Search(ctx, "github", 10) | |
| 357 | + | results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{}) | |
| 354 | 358 | require.NoError(t, err) | |
| 355 | 359 | require.Len(t, results, 1) | |
| 356 | 360 | assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL) | |
| @@ -377,7 +381,7 @@ func TestHistoryRepository_Search_MultiWord(t *testing.T) { | |||
| 377 | 381 | } | |
| 378 | 382 | ||
| 379 | 383 | // Search for "github issues" - should find only the github issues entry | |
| 380 | - | results, err := repo.Search(ctx, "github issues", 10) | |
| 384 | + | results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{}) | |
| 381 | 385 | require.NoError(t, err) | |
| 382 | 386 | require.Len(t, results, 1) | |
| 383 | 387 | assert.Equal(t, "https://github.com/issues", results[0].Entry.URL) | |
| @@ -401,7 +405,7 @@ func TestHistoryRepository_Search_MatchesTitle(t *testing.T) { | |||
| 401 | 405 | require.NoError(t, repo.Save(ctx, entry)) | |
| 402 | 406 | ||
| 403 | 407 | // Search for "Documentation" - should find by title | |
| 404 | - | results, err := repo.Search(ctx, "Documentation", 10) | |
| 408 | + | results, err := repo.Search(ctx, "Documentation", 10, repository.HistoryScope{}) | |
| 405 | 409 | require.NoError(t, err) | |
| 406 | 410 | require.Len(t, results, 1) | |
| 407 | 411 | assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL) | |
| @@ -424,12 +428,12 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) { | |||
| 424 | 428 | })) | |
| 425 | 429 | ||
| 426 | 430 | // Empty query should return empty results, no error | |
| 427 | - | results, err := repo.Search(ctx, "", 10) | |
| 431 | + | results, err := repo.Search(ctx, "", 10, repository.HistoryScope{}) | |
| 428 | 432 | require.NoError(t, err) | |
| 429 | 433 | assert.Empty(t, results) | |
| 430 | 434 | ||
| 431 | 435 | // Whitespace-only query should also return empty results | |
| 432 | - | results, err = repo.Search(ctx, " ", 10) | |
| 436 | + | results, err = repo.Search(ctx, " ", 10, repository.HistoryScope{}) | |
| 433 | 437 | require.NoError(t, err) | |
| 434 | 438 | assert.Empty(t, results) | |
| 435 | 439 | } | |
| @@ -457,7 +461,7 @@ func TestHistoryRepository_Search_NoValidTokens(t *testing.T) { | |||
| 457 | 461 | } | |
| 458 | 462 | ||
| 459 | 463 | for _, q := range invalidQueries { | |
| 460 | - | results, err := repo.Search(ctx, q, 10) | |
| 464 | + | results, err := repo.Search(ctx, q, 10, repository.HistoryScope{}) | |
| 461 | 465 | require.NoError(t, err, "query %q should return early without SQL errors", q) | |
| 462 | 466 | assert.Empty(t, results, "query %q should have no valid tokens", q) | |
| 463 | 467 | } | |
| @@ -480,7 +484,7 @@ func TestHistoryRepository_Search_NoResults(t *testing.T) { | |||
| 480 | 484 | })) | |
| 481 | 485 | ||
| 482 | 486 | // Search for something that doesn't exist | |
| 483 | - | results, err := repo.Search(ctx, "nonexistent", 10) | |
| 487 | + | results, err := repo.Search(ctx, "nonexistent", 10, repository.HistoryScope{}) | |
| 484 | 488 | require.NoError(t, err) | |
| 485 | 489 | assert.Empty(t, results) | |
| 486 | 490 | } | |
| @@ -506,7 +510,7 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |||
| 506 | 510 | } | |
| 507 | 511 | ||
| 508 | 512 | // Search for "git" - should find both github and gitlab (prefix match) | |
| 509 | - | results, err := repo.Search(ctx, "git", 10) | |
| 513 | + | results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{}) | |
| 510 | 514 | require.NoError(t, err) | |
| 511 | 515 | require.Len(t, results, 2) | |
| 512 | 516 | ||
| @@ -541,7 +545,7 @@ func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) { | |||
| 541 | 545 | ||
| 542 | 546 | // Search with period in query (domain-like) - periods should be treated as separators | |
| 543 | 547 | // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema) | |
| 544 | - | results, err := repo.Search(ctx, "gordon.bnem", 10) | |
| 548 | + | results, err := repo.Search(ctx, "gordon.bnem", 10, repository.HistoryScope{}) | |
| 545 | 549 | require.NoError(t, err) | |
| 546 | 550 | require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev") | |
| 547 | 551 | ||
| @@ -584,7 +588,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||
| 584 | 588 | } | |
| 585 | 589 | ||
| 586 | 590 | for _, q := range specialQueries { | |
| 587 | - | results, err := repo.Search(ctx, q, 10) | |
| 591 | + | results, err := repo.Search(ctx, q, 10, repository.HistoryScope{}) | |
| 588 | 592 | require.NoError(t, err, "query %q should not cause error", q) | |
| 589 | 593 | // May or may not find results, but should not error | |
| 590 | 594 | _ = results | |
| @@ -606,7 +610,7 @@ func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) { | |||
| 606 | 610 | Title: "Dumber", | |
| 607 | 611 | })) | |
| 608 | 612 | ||
| 609 | - | results, err := repo.Search(ctx, "AND github OR", 10) | |
| 613 | + | results, err := repo.Search(ctx, "AND github OR", 10, repository.HistoryScope{}) | |
| 610 | 614 | require.NoError(t, err) | |
| 611 | 615 | require.Len(t, results, 1) | |
| 612 | 616 | assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL) | |
| @@ -627,7 +631,7 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |||
| 627 | 631 | Title: "Dumber", | |
| 628 | 632 | })) | |
| 629 | 633 | ||
| 630 | - | results, err := repo.Search(ctx, "github.com/bnema", 10) | |
| 634 | + | results, err := repo.Search(ctx, "github.com/bnema", 10, repository.HistoryScope{}) | |
| 631 | 635 | require.NoError(t, err) | |
| 632 | 636 | require.NotEmpty(t, results, "slash-separated query should match history") | |
| 633 | 637 | } | |
| @@ -651,7 +655,7 @@ func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T | |||
| 651 | 655 | Title: "GitHub Issues Mirror", | |
| 652 | 656 | })) | |
| 653 | 657 | ||
| 654 | - | results, err := repo.Search(ctx, "github issues", 10) | |
| 658 | + | results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{}) | |
| 655 | 659 | require.NoError(t, err) | |
| 656 | 660 | require.Len(t, results, 2) | |
| 657 | 661 | assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL) | |
| @@ -676,7 +680,7 @@ func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing. | |||
| 676 | 680 | Title: "GitHub documentation mirror", | |
| 677 | 681 | })) | |
| 678 | 682 | ||
| 679 | - | results, err := repo.Search(ctx, "git", 10) | |
| 683 | + | results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{}) | |
| 680 | 684 | require.NoError(t, err) | |
| 681 | 685 | require.Len(t, results, 2) | |
| 682 | 686 | assert.Equal(t, "https://github.com", results[0].Entry.URL) | |
| @@ -701,7 +705,7 @@ func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T | |||
| 701 | 705 | Title: "Issue 123", | |
| 702 | 706 | })) | |
| 703 | 707 | ||
| 704 | - | results, err := repo.Search(ctx, "github", 10) | |
| 708 | + | results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{}) | |
| 705 | 709 | require.NoError(t, err) | |
| 706 | 710 | require.Len(t, results, 2) | |
| 707 | 711 | assert.Equal(t, "https://github.com", results[0].Entry.URL) | |
| @@ -829,11 +833,11 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) | |||
| 829 | 833 | Title: "GitHub", | |
| 830 | 834 | })) | |
| 831 | 835 | ||
| 832 | - | results, err := repo.Search(ctx, "git", 0) | |
| 836 | + | results, err := repo.Search(ctx, "git", 0, repository.HistoryScope{}) | |
| 833 | 837 | require.NoError(t, err) | |
| 834 | 838 | assert.Empty(t, results) | |
| 835 | 839 | ||
| 836 | - | results, err = repo.Search(ctx, "git", -1) | |
| 840 | + | results, err = repo.Search(ctx, "git", -1, repository.HistoryScope{}) | |
| 837 | 841 | require.NoError(t, err) | |
| 838 | 842 | assert.Empty(t, results) | |
| 839 | 843 | } | |
| @@ -937,3 +941,370 @@ func historyIDs(entries []*entity.HistoryEntry) []int64 { | |||
| 937 | 941 | } | |
| 938 | 942 | return ids | |
| 939 | 943 | } | |
| 944 | + | ||
| 945 | + | // insertHistoryRow seeds a history row with an explicit visit count and | |
| 946 | + | // last_visited so age-scope tests can use stable, non-now timestamps. The | |
| 947 | + | // timestamp is written in the same UTC second-precision format that SQLite's | |
| 948 | + | // CURRENT_TIMESTAMP uses in production. | |
| 949 | + | func insertHistoryRow( | |
| 950 | + | t *testing.T, | |
| 951 | + | ctx context.Context, | |
| 952 | + | db *sql.DB, | |
| 953 | + | url, title string, | |
| 954 | + | visitCount int, | |
| 955 | + | lastVisited time.Time, | |
| 956 | + | ) { | |
| 957 | + | t.Helper() | |
| 958 | + | timestamp := sqliteTimestamp(lastVisited) | |
| 959 | + | _, err := db.ExecContext(ctx, | |
| 960 | + | `INSERT INTO history (url, title, last_visited, created_at, visit_count) VALUES (?, ?, ?, ?, ?)`, | |
| 961 | + | url, title, timestamp, timestamp, visitCount) | |
| 962 | + | require.NoError(t, err) | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | // sqliteTimestamp formats a time the way SQLite's CURRENT_TIMESTAMP stores it: | |
| 966 | + | // UTC at second precision without a timezone suffix. | |
| 967 | + | func sqliteTimestamp(t time.Time) string { | |
| 968 | + | return t.UTC().Format("2006-01-02 15:04:05") | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | func TestHistoryRepository_GetMostVisitedWithin_ExcludesOldHighCountURL(t *testing.T) { | |
| 972 | + | ctx := historyTestCtx() | |
| 973 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 974 | + | require.NoError(t, err) | |
| 975 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 976 | + | repo := sqlite.NewHistoryRepository(db) | |
| 977 | + | ||
| 978 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 979 | + | cutoff := now.AddDate(0, 0, -30) | |
| 980 | + | insertHistoryRow(t, ctx, db, "https://old.example.com", "Old Popular", 50, now.AddDate(0, 0, -40)) | |
| 981 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 2, now.AddDate(0, 0, -1)) | |
| 982 | + | ||
| 983 | + | results, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{Cutoff: cutoff}) | |
| 984 | + | require.NoError(t, err) | |
| 985 | + | require.Len(t, results, 1) | |
| 986 | + | assert.Equal(t, "https://recent.example.com", results[0].URL) | |
| 987 | + | ||
| 988 | + | // A zero cutoff means all stored history, so the old popular URL returns. | |
| 989 | + | all, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{}) | |
| 990 | + | require.NoError(t, err) | |
| 991 | + | require.Len(t, all, 2) | |
| 992 | + | assert.Equal(t, "https://old.example.com", all[0].URL) | |
| 993 | + | assert.Equal(t, int64(50), all[0].VisitCount) | |
| 994 | + | } | |
| 995 | + | ||
| 996 | + | func TestHistoryRepository_GetMostVisitedWithin_BoundsResults(t *testing.T) { | |
| 997 | + | ctx := historyTestCtx() | |
| 998 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 999 | + | require.NoError(t, err) | |
| 1000 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1001 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1002 | + | ||
| 1003 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1004 | + | insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 5, now.Add(-time.Hour)) | |
| 1005 | + | insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 3, now.Add(-2*time.Hour)) | |
| 1006 | + | insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour)) | |
| 1007 | + | ||
| 1008 | + | results, err := repo.GetMostVisitedWithin(ctx, 2, repository.HistoryScope{}) | |
| 1009 | + | require.NoError(t, err) | |
| 1010 | + | require.Len(t, results, 2) | |
| 1011 | + | assert.Equal(t, []string{"https://one.example.com", "https://two.example.com"}, historyURLs(results)) | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | func TestHistoryRepository_GetRecent_ScopeExcludesOldEntries(t *testing.T) { | |
| 1015 | + | ctx := historyTestCtx() | |
| 1016 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1017 | + | require.NoError(t, err) | |
| 1018 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1019 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1020 | + | ||
| 1021 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1022 | + | cutoff := now.AddDate(0, 0, -30) | |
| 1023 | + | insertHistoryRow(t, ctx, db, "https://old.example.com", "Old", 1, now.AddDate(0, 0, -40)) | |
| 1024 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 1, now.AddDate(0, 0, -1)) | |
| 1025 | + | ||
| 1026 | + | scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff}) | |
| 1027 | + | require.NoError(t, err) | |
| 1028 | + | require.Len(t, scoped, 1) | |
| 1029 | + | assert.Equal(t, "https://recent.example.com", scoped[0].URL) | |
| 1030 | + | ||
| 1031 | + | all, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{}) | |
| 1032 | + | require.NoError(t, err) | |
| 1033 | + | require.Len(t, all, 2) | |
| 1034 | + | ||
| 1035 | + | bounded, err := repo.GetRecent(ctx, 1, 0, repository.HistoryScope{}) | |
| 1036 | + | require.NoError(t, err) | |
| 1037 | + | require.Len(t, bounded, 1) | |
| 1038 | + | } | |
| 1039 | + | ||
| 1040 | + | func TestHistoryRepository_Search_ScopeExcludesOldTitleOnlyMatch(t *testing.T) { | |
| 1041 | + | ctx := historyTestCtx() | |
| 1042 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1043 | + | require.NoError(t, err) | |
| 1044 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1045 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1046 | + | ||
| 1047 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1048 | + | cutoff := now.AddDate(0, 0, -30) | |
| 1049 | + | // Matches only in the title, but is older than the window. | |
| 1050 | + | insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Widget Old Guide", 1, now.AddDate(0, 0, -40)) | |
| 1051 | + | // Matches in the title and is inside the window. | |
| 1052 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com/guide", "Widget Recent Guide", 1, now.AddDate(0, 0, -1)) | |
| 1053 | + | // Matches in the URL, but is older than the window. | |
| 1054 | + | insertHistoryRow(t, ctx, db, "https://widget.example.com/page", "No match here", 1, now.AddDate(0, 0, -40)) | |
| 1055 | + | ||
| 1056 | + | scoped, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{Cutoff: cutoff}) | |
| 1057 | + | require.NoError(t, err) | |
| 1058 | + | require.Len(t, scoped, 1) | |
| 1059 | + | assert.Equal(t, "https://recent.example.com/guide", scoped[0].Entry.URL) | |
| 1060 | + | ||
| 1061 | + | // A zero cutoff keeps the general, unrestricted search behavior. | |
| 1062 | + | all, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{}) | |
| 1063 | + | require.NoError(t, err) | |
| 1064 | + | require.Len(t, all, 3) | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | func historyURLs(entries []*entity.HistoryEntry) []string { | |
| 1068 | + | urls := make([]string, len(entries)) | |
| 1069 | + | for i, entry := range entries { | |
| 1070 | + | urls[i] = entry.URL | |
| 1071 | + | } | |
| 1072 | + | return urls | |
| 1073 | + | } | |
| 1074 | + | ||
| 1075 | + | func TestHistoryRepository_ScopeCutoffIsInclusiveAtSecondPrecision(t *testing.T) { | |
| 1076 | + | ctx := historyTestCtx() | |
| 1077 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1078 | + | require.NoError(t, err) | |
| 1079 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1080 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1081 | + | ||
| 1082 | + | cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1083 | + | insertHistoryRow(t, ctx, db, "https://exact.example.com", "Exact", 1, cutoff) | |
| 1084 | + | insertHistoryRow(t, ctx, db, "https://before.example.com", "Before", 1, cutoff.Add(-time.Second)) | |
| 1085 | + | ||
| 1086 | + | results, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff}) | |
| 1087 | + | require.NoError(t, err) | |
| 1088 | + | require.Len(t, results, 1, "the entry exactly on the cutoff must be included") | |
| 1089 | + | assert.Equal(t, "https://exact.example.com", results[0].URL) | |
| 1090 | + | } | |
| 1091 | + | ||
| 1092 | + | func TestHistoryRepository_UnboundedScopePreservesNullLastVisited(t *testing.T) { | |
| 1093 | + | ctx := historyTestCtx() | |
| 1094 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1095 | + | require.NoError(t, err) | |
| 1096 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1097 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1098 | + | ||
| 1099 | + | _, err = db.ExecContext( | |
| 1100 | + | ctx, | |
| 1101 | + | `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, NULL, ?)`, | |
| 1102 | + | "https://null-last-visited.example.com", | |
| 1103 | + | "Legacy Null Row", | |
| 1104 | + | 1, | |
| 1105 | + | ) | |
| 1106 | + | require.NoError(t, err) | |
| 1107 | + | ||
| 1108 | + | cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1109 | + | ||
| 1110 | + | recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{}) | |
| 1111 | + | require.NoError(t, err) | |
| 1112 | + | require.Len(t, recent, 1, "unbounded recent history must keep NULL last_visited rows") | |
| 1113 | + | ||
| 1114 | + | mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{}) | |
| 1115 | + | require.NoError(t, err) | |
| 1116 | + | require.Len(t, mostVisited, 1, "unbounded most-visited must keep NULL last_visited rows") | |
| 1117 | + | ||
| 1118 | + | matches, err := repo.Search(ctx, "legacy", 10, repository.HistoryScope{}) | |
| 1119 | + | require.NoError(t, err) | |
| 1120 | + | require.Len(t, matches, 1, "unbounded search must keep NULL last_visited rows") | |
| 1121 | + | ||
| 1122 | + | scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff}) | |
| 1123 | + | require.NoError(t, err) | |
| 1124 | + | assert.Empty(t, scoped, "a bounded window must not include rows without a last_visited timestamp") | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | func TestHistoryRepository_Search_FiltersBeforeLimitOnURLMatch(t *testing.T) { | |
| 1128 | + | ctx := historyTestCtx() | |
| 1129 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1130 | + | require.NoError(t, err) | |
| 1131 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1132 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1133 | + | ||
| 1134 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1135 | + | cutoff := now.AddDate(0, 0, -30) | |
| 1136 | + | // The old row outranks the recent row (higher visit count and domain boost), | |
| 1137 | + | // so it would consume the single LIMIT slot if filtering happened after LIMIT. | |
| 1138 | + | insertHistoryRow(t, ctx, db, "https://urlsecret.example.com/", "Old", 50, now.AddDate(0, 0, -40)) | |
| 1139 | + | insertHistoryRow(t, ctx, db, "https://urlsecret-recent.example.com/", "Recent", 1, now.AddDate(0, 0, -1)) | |
| 1140 | + | ||
| 1141 | + | results, err := repo.Search(ctx, "urlsecret", 1, repository.HistoryScope{Cutoff: cutoff}) | |
| 1142 | + | require.NoError(t, err) | |
| 1143 | + | require.Len(t, results, 1, "URL FTS must filter by age before applying LIMIT") | |
| 1144 | + | assert.Equal(t, "https://urlsecret-recent.example.com/", results[0].Entry.URL) | |
| 1145 | + | ||
| 1146 | + | // Without a scope the old high-priority row wins the same query. | |
| 1147 | + | all, err := repo.Search(ctx, "urlsecret", 10, repository.HistoryScope{}) | |
| 1148 | + | require.NoError(t, err) | |
| 1149 | + | require.Len(t, all, 2) | |
| 1150 | + | assert.Equal(t, "https://urlsecret.example.com/", all[0].Entry.URL) | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | func TestHistoryRepository_Search_FiltersBeforeLimitOnTitleMatch(t *testing.T) { | |
| 1154 | + | ctx := historyTestCtx() | |
| 1155 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1156 | + | require.NoError(t, err) | |
| 1157 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1158 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1159 | + | ||
| 1160 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1161 | + | cutoff := now.AddDate(0, 0, -30) | |
| 1162 | + | // The query only matches titles, so only the title FTS path is exercised. | |
| 1163 | + | // The old row has the higher visit count and would win an unfiltered LIMIT. | |
| 1164 | + | insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Titlesecret Old Guide", 50, now.AddDate(0, 0, -40)) | |
| 1165 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com/entry", "Titlesecret Recent Guide", 1, now.AddDate(0, 0, -1)) | |
| 1166 | + | ||
| 1167 | + | results, err := repo.Search(ctx, "titlesecret", 1, repository.HistoryScope{Cutoff: cutoff}) | |
| 1168 | + | require.NoError(t, err) | |
| 1169 | + | require.Len(t, results, 1, "title FTS must filter by age before applying LIMIT") | |
| 1170 | + | assert.Equal(t, "https://recent.example.com/entry", results[0].Entry.URL) | |
| 1171 | + | ||
| 1172 | + | all, err := repo.Search(ctx, "titlesecret", 10, repository.HistoryScope{}) | |
| 1173 | + | require.NoError(t, err) | |
| 1174 | + | require.Len(t, all, 2) | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | func TestHistoryRepository_GetRecent_BoundedLimitOffsetAndCutoff(t *testing.T) { | |
| 1178 | + | ctx := historyTestCtx() | |
| 1179 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1180 | + | require.NoError(t, err) | |
| 1181 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1182 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1183 | + | ||
| 1184 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1185 | + | cutoff := now.AddDate(0, 0, -30) | |
| 1186 | + | insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 1, now.Add(-time.Hour)) | |
| 1187 | + | insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 1, now.Add(-2*time.Hour)) | |
| 1188 | + | insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour)) | |
| 1189 | + | insertHistoryRow(t, ctx, db, "https://stale.example.com", "Stale", 1, now.AddDate(0, 0, -40)) | |
| 1190 | + | ||
| 1191 | + | scope := repository.HistoryScope{Cutoff: cutoff} | |
| 1192 | + | ||
| 1193 | + | page, err := repo.GetRecent(ctx, 1, 1, scope) | |
| 1194 | + | require.NoError(t, err) | |
| 1195 | + | require.Len(t, page, 1) | |
| 1196 | + | assert.Equal(t, "https://two.example.com", page[0].URL) | |
| 1197 | + | ||
| 1198 | + | // A non-positive limit means all eligible rows, with the offset applied and | |
| 1199 | + | // the stale row excluded by the cutoff. | |
| 1200 | + | rest, err := repo.GetRecent(ctx, 0, 1, scope) | |
| 1201 | + | require.NoError(t, err) | |
| 1202 | + | require.Len(t, rest, 2) | |
| 1203 | + | assert.Equal(t, []string{"https://two.example.com", "https://three.example.com"}, historyURLs(rest)) | |
| 1204 | + | } | |
| 1205 | + | ||
| 1206 | + | func TestHistoryRepository_Search_BoundedScopeEmptyResult(t *testing.T) { | |
| 1207 | + | ctx := historyTestCtx() | |
| 1208 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1209 | + | require.NoError(t, err) | |
| 1210 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1211 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1212 | + | ||
| 1213 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1214 | + | cutoff := now.AddDate(0, 0, -30) | |
| 1215 | + | insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Ephemeral Secret", 1, now.AddDate(0, 0, -40)) | |
| 1216 | + | ||
| 1217 | + | scoped, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{Cutoff: cutoff}) | |
| 1218 | + | require.NoError(t, err) | |
| 1219 | + | assert.Empty(t, scoped, "a bounded search must not return age-excluded matches") | |
| 1220 | + | ||
| 1221 | + | all, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{}) | |
| 1222 | + | require.NoError(t, err) | |
| 1223 | + | require.Len(t, all, 1) | |
| 1224 | + | } | |
| 1225 | + | ||
| 1226 | + | func TestHistoryRepository_GetMostVisitedWithin_NonPositiveLimitIsEmpty(t *testing.T) { | |
| 1227 | + | ctx := historyTestCtx() | |
| 1228 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1229 | + | require.NoError(t, err) | |
| 1230 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1231 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1232 | + | ||
| 1233 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1234 | + | insertHistoryRow(t, ctx, db, "https://example.com", "Example", 5, now.Add(-time.Hour)) | |
| 1235 | + | ||
| 1236 | + | unbounded, err := repo.GetMostVisitedWithin(ctx, 0, repository.HistoryScope{}) | |
| 1237 | + | require.NoError(t, err) | |
| 1238 | + | assert.Empty(t, unbounded) | |
| 1239 | + | ||
| 1240 | + | bounded, err := repo.GetMostVisitedWithin(ctx, -5, repository.HistoryScope{Cutoff: now.AddDate(0, 0, -30)}) | |
| 1241 | + | require.NoError(t, err) | |
| 1242 | + | assert.Empty(t, bounded) | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | // queryPlan records the actual driver's EXPLAIN QUERY PLAN output for a query. | |
| 1246 | + | func queryPlan(t *testing.T, ctx context.Context, db *sql.DB, query string, args ...any) []string { | |
| 1247 | + | t.Helper() | |
| 1248 | + | ||
| 1249 | + | rows, err := db.QueryContext(ctx, "EXPLAIN QUERY PLAN "+query, args...) | |
| 1250 | + | require.NoError(t, err) | |
| 1251 | + | defer func() { _ = rows.Close() }() | |
| 1252 | + | ||
| 1253 | + | var details []string | |
| 1254 | + | for rows.Next() { | |
| 1255 | + | var id, parent, notUsed int | |
| 1256 | + | var detail string | |
| 1257 | + | require.NoError(t, rows.Scan(&id, &parent, ¬Used, &detail)) | |
| 1258 | + | details = append(details, detail) | |
| 1259 | + | } | |
| 1260 | + | require.NoError(t, rows.Err()) | |
| 1261 | + | return details | |
| 1262 | + | } | |
| 1263 | + | ||
| 1264 | + | func TestHistoryRepository_BoundedRecentPlanUsesCutoffIndex(t *testing.T) { | |
| 1265 | + | ctx := historyTestCtx() | |
| 1266 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1267 | + | require.NoError(t, err) | |
| 1268 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1269 | + | ||
| 1270 | + | // Exercise the exact generated statement that historyRepo.GetRecent runs for | |
| 1271 | + | // a bounded scope (see GetRecentHistorySinceCutoff), passed verbatim including | |
| 1272 | + | // sqlc's leading `-- name:` comment. This fails to compile if the bounded | |
| 1273 | + | // variant disappears, and the plan assertions fail if it regresses to a | |
| 1274 | + | // nullable `(? IS NULL OR last_visited >= ?)` predicate, which plans as a | |
| 1275 | + | // SCAN using idx_history_last_visited rather than a SEARCH. | |
| 1276 | + | cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |
| 1277 | + | plan := queryPlan(t, ctx, db, sqlc.GetRecentHistorySinceCutoff, sql.NullTime{Time: cutoff.UTC(), Valid: true}, 0, 50) | |
| 1278 | + | ||
| 1279 | + | joined := strings.Join(plan, "\n") | |
| 1280 | + | require.Contains(t, joined, "SEARCH", "bounded recent query must use an index search") | |
| 1281 | + | require.Contains(t, joined, "idx_history_last_visited", "bounded recent query must use the last_visited index") | |
| 1282 | + | } | |
| 1283 | + | ||
| 1284 | + | func TestHistoryRepository_ScopeCutoffSubSecondNormalization(t *testing.T) { | |
| 1285 | + | ctx := historyTestCtx() | |
| 1286 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 1287 | + | require.NoError(t, err) | |
| 1288 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 1289 | + | repo := sqlite.NewHistoryRepository(db) | |
| 1290 | + | ||
| 1291 | + | // Stored at exactly 12:00:05 with no fractional part, matching | |
| 1292 | + | // CURRENT_TIMESTAMP. | |
| 1293 | + | stored := time.Date(2026, 6, 24, 12, 0, 5, 0, time.UTC) | |
| 1294 | + | insertHistoryRow(t, ctx, db, "https://subsecond.example.com/marker", "Subsecond Marker", 1, stored) | |
| 1295 | + | scope := repository.HistoryScope{Cutoff: stored.Add(500 * time.Millisecond)} | |
| 1296 | + | ||
| 1297 | + | recent, err := repo.GetRecent(ctx, 10, 0, scope) | |
| 1298 | + | require.NoError(t, err) | |
| 1299 | + | require.Len(t, recent, 1, "a sub-second cutoff must normalize to the stored second and include the row") | |
| 1300 | + | assert.Equal(t, "https://subsecond.example.com/marker", recent[0].URL) | |
| 1301 | + | ||
| 1302 | + | mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, scope) | |
| 1303 | + | require.NoError(t, err) | |
| 1304 | + | require.Len(t, mostVisited, 1) | |
| 1305 | + | ||
| 1306 | + | matches, err := repo.Search(ctx, "subsecond", 10, scope) | |
| 1307 | + | require.NoError(t, err) | |
| 1308 | + | require.Len(t, matches, 1, "the shared FTS cutoff helper must apply the same normalization") | |
| 1309 | + | assert.Equal(t, "https://subsecond.example.com/marker", matches[0].Entry.URL) | |
| 1310 | + | } | |
| @@ -2,13 +2,17 @@ package sqlite_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "database/sql" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strings" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 13 | + | repository "github.com/bnema/dumber/internal/domain/repository" | |||
| 11 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 14 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |||
| 12 | "github.com/bnema/dumber/internal/logging" | 16 | "github.com/bnema/dumber/internal/logging" | ||
| 13 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| @@ -188,11 +192,11 @@ func TestHistoryRepository_CRUD(t *testing.T) { | |||||
| 188 | assert.Equal(t, int64(2), found2.VisitCount) | 192 | assert.Equal(t, int64(2), found2.VisitCount) | ||
| 189 | 193 | ||||
| 190 | // Get recent | 194 | // Get recent | ||
| 191 | - | recent, err := repo.GetRecent(ctx, 10, 0) | 195 | + | recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{}) |
| 192 | require.NoError(t, err) | 196 | require.NoError(t, err) | ||
| 193 | require.Len(t, recent, 1) | 197 | require.Len(t, recent, 1) | ||
| 194 | 198 | ||||
| 195 | - | allRecent, err := repo.GetRecent(ctx, 0, 0) | 199 | + | allRecent, err := repo.GetRecent(ctx, 0, 0, repository.HistoryScope{}) |
| 196 | require.NoError(t, err) | 200 | require.NoError(t, err) | ||
| 197 | require.Len(t, allRecent, 1) | 201 | require.Len(t, allRecent, 1) | ||
| 198 | 202 | ||||
| @@ -350,7 +354,7 @@ func TestHistoryRepository_Search_SingleWord(t *testing.T) { | |||||
| 350 | } | 354 | } | ||
| 351 | 355 | ||||
| 352 | // Search for "github" - should find the github entry | 356 | // Search for "github" - should find the github entry | ||
| 353 | - | results, err := repo.Search(ctx, "github", 10) | 357 | + | results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{}) |
| 354 | require.NoError(t, err) | 358 | require.NoError(t, err) | ||
| 355 | require.Len(t, results, 1) | 359 | require.Len(t, results, 1) | ||
| 356 | assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL) | 360 | assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL) | ||
| @@ -377,7 +381,7 @@ func TestHistoryRepository_Search_MultiWord(t *testing.T) { | |||||
| 377 | } | 381 | } | ||
| 378 | 382 | ||||
| 379 | // Search for "github issues" - should find only the github issues entry | 383 | // Search for "github issues" - should find only the github issues entry | ||
| 380 | - | results, err := repo.Search(ctx, "github issues", 10) | 384 | + | results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{}) |
| 381 | require.NoError(t, err) | 385 | require.NoError(t, err) | ||
| 382 | require.Len(t, results, 1) | 386 | require.Len(t, results, 1) | ||
| 383 | assert.Equal(t, "https://github.com/issues", results[0].Entry.URL) | 387 | assert.Equal(t, "https://github.com/issues", results[0].Entry.URL) | ||
| @@ -401,7 +405,7 @@ func TestHistoryRepository_Search_MatchesTitle(t *testing.T) { | |||||
| 401 | require.NoError(t, repo.Save(ctx, entry)) | 405 | require.NoError(t, repo.Save(ctx, entry)) | ||
| 402 | 406 | ||||
| 403 | // Search for "Documentation" - should find by title | 407 | // Search for "Documentation" - should find by title | ||
| 404 | - | results, err := repo.Search(ctx, "Documentation", 10) | 408 | + | results, err := repo.Search(ctx, "Documentation", 10, repository.HistoryScope{}) |
| 405 | require.NoError(t, err) | 409 | require.NoError(t, err) | ||
| 406 | require.Len(t, results, 1) | 410 | require.Len(t, results, 1) | ||
| 407 | assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL) | 411 | assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL) | ||
| @@ -424,12 +428,12 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) { | |||||
| 424 | })) | 428 | })) | ||
| 425 | 429 | ||||
| 426 | // Empty query should return empty results, no error | 430 | // Empty query should return empty results, no error | ||
| 427 | - | results, err := repo.Search(ctx, "", 10) | 431 | + | results, err := repo.Search(ctx, "", 10, repository.HistoryScope{}) |
| 428 | require.NoError(t, err) | 432 | require.NoError(t, err) | ||
| 429 | assert.Empty(t, results) | 433 | assert.Empty(t, results) | ||
| 430 | 434 | ||||
| 431 | // Whitespace-only query should also return empty results | 435 | // Whitespace-only query should also return empty results | ||
| 432 | - | results, err = repo.Search(ctx, " ", 10) | 436 | + | results, err = repo.Search(ctx, " ", 10, repository.HistoryScope{}) |
| 433 | require.NoError(t, err) | 437 | require.NoError(t, err) | ||
| 434 | assert.Empty(t, results) | 438 | assert.Empty(t, results) | ||
| 435 | } | 439 | } | ||
| @@ -457,7 +461,7 @@ func TestHistoryRepository_Search_NoValidTokens(t *testing.T) { | |||||
| 457 | } | 461 | } | ||
| 458 | 462 | ||||
| 459 | for _, q := range invalidQueries { | 463 | for _, q := range invalidQueries { | ||
| 460 | - | results, err := repo.Search(ctx, q, 10) | 464 | + | results, err := repo.Search(ctx, q, 10, repository.HistoryScope{}) |
| 461 | require.NoError(t, err, "query %q should return early without SQL errors", q) | 465 | require.NoError(t, err, "query %q should return early without SQL errors", q) | ||
| 462 | assert.Empty(t, results, "query %q should have no valid tokens", q) | 466 | assert.Empty(t, results, "query %q should have no valid tokens", q) | ||
| 463 | } | 467 | } | ||
| @@ -480,7 +484,7 @@ func TestHistoryRepository_Search_NoResults(t *testing.T) { | |||||
| 480 | })) | 484 | })) | ||
| 481 | 485 | ||||
| 482 | // Search for something that doesn't exist | 486 | // Search for something that doesn't exist | ||
| 483 | - | results, err := repo.Search(ctx, "nonexistent", 10) | 487 | + | results, err := repo.Search(ctx, "nonexistent", 10, repository.HistoryScope{}) |
| 484 | require.NoError(t, err) | 488 | require.NoError(t, err) | ||
| 485 | assert.Empty(t, results) | 489 | assert.Empty(t, results) | ||
| 486 | } | 490 | } | ||
| @@ -506,7 +510,7 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |||||
| 506 | } | 510 | } | ||
| 507 | 511 | ||||
| 508 | // Search for "git" - should find both github and gitlab (prefix match) | 512 | // Search for "git" - should find both github and gitlab (prefix match) | ||
| 509 | - | results, err := repo.Search(ctx, "git", 10) | 513 | + | results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{}) |
| 510 | require.NoError(t, err) | 514 | require.NoError(t, err) | ||
| 511 | require.Len(t, results, 2) | 515 | require.Len(t, results, 2) | ||
| 512 | 516 | ||||
| @@ -541,7 +545,7 @@ func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) { | |||||
| 541 | 545 | ||||
| 542 | // Search with period in query (domain-like) - periods should be treated as separators | 546 | // Search with period in query (domain-like) - periods should be treated as separators | ||
| 543 | // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema) | 547 | // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema) | ||
| 544 | - | results, err := repo.Search(ctx, "gordon.bnem", 10) | 548 | + | results, err := repo.Search(ctx, "gordon.bnem", 10, repository.HistoryScope{}) |
| 545 | require.NoError(t, err) | 549 | require.NoError(t, err) | ||
| 546 | require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev") | 550 | require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev") | ||
| 547 | 551 | ||||
| @@ -584,7 +588,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||||
| 584 | } | 588 | } | ||
| 585 | 589 | ||||
| 586 | for _, q := range specialQueries { | 590 | for _, q := range specialQueries { | ||
| 587 | - | results, err := repo.Search(ctx, q, 10) | 591 | + | results, err := repo.Search(ctx, q, 10, repository.HistoryScope{}) |
| 588 | require.NoError(t, err, "query %q should not cause error", q) | 592 | require.NoError(t, err, "query %q should not cause error", q) | ||
| 589 | // May or may not find results, but should not error | 593 | // May or may not find results, but should not error | ||
| 590 | _ = results | 594 | _ = results | ||
| @@ -606,7 +610,7 @@ func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) { | |||||
| 606 | Title: "Dumber", | 610 | Title: "Dumber", | ||
| 607 | })) | 611 | })) | ||
| 608 | 612 | ||||
| 609 | - | results, err := repo.Search(ctx, "AND github OR", 10) | 613 | + | results, err := repo.Search(ctx, "AND github OR", 10, repository.HistoryScope{}) |
| 610 | require.NoError(t, err) | 614 | require.NoError(t, err) | ||
| 611 | require.Len(t, results, 1) | 615 | require.Len(t, results, 1) | ||
| 612 | assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL) | 616 | assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL) | ||
| @@ -627,7 +631,7 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |||||
| 627 | Title: "Dumber", | 631 | Title: "Dumber", | ||
| 628 | })) | 632 | })) | ||
| 629 | 633 | ||||
| 630 | - | results, err := repo.Search(ctx, "github.com/bnema", 10) | 634 | + | results, err := repo.Search(ctx, "github.com/bnema", 10, repository.HistoryScope{}) |
| 631 | require.NoError(t, err) | 635 | require.NoError(t, err) | ||
| 632 | require.NotEmpty(t, results, "slash-separated query should match history") | 636 | require.NotEmpty(t, results, "slash-separated query should match history") | ||
| 633 | } | 637 | } | ||
| @@ -651,7 +655,7 @@ func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T | |||||
| 651 | Title: "GitHub Issues Mirror", | 655 | Title: "GitHub Issues Mirror", | ||
| 652 | })) | 656 | })) | ||
| 653 | 657 | ||||
| 654 | - | results, err := repo.Search(ctx, "github issues", 10) | 658 | + | results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{}) |
| 655 | require.NoError(t, err) | 659 | require.NoError(t, err) | ||
| 656 | require.Len(t, results, 2) | 660 | require.Len(t, results, 2) | ||
| 657 | assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL) | 661 | assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL) | ||
| @@ -676,7 +680,7 @@ func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing. | |||||
| 676 | Title: "GitHub documentation mirror", | 680 | Title: "GitHub documentation mirror", | ||
| 677 | })) | 681 | })) | ||
| 678 | 682 | ||||
| 679 | - | results, err := repo.Search(ctx, "git", 10) | 683 | + | results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{}) |
| 680 | require.NoError(t, err) | 684 | require.NoError(t, err) | ||
| 681 | require.Len(t, results, 2) | 685 | require.Len(t, results, 2) | ||
| 682 | assert.Equal(t, "https://github.com", results[0].Entry.URL) | 686 | assert.Equal(t, "https://github.com", results[0].Entry.URL) | ||
| @@ -701,7 +705,7 @@ func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T | |||||
| 701 | Title: "Issue 123", | 705 | Title: "Issue 123", | ||
| 702 | })) | 706 | })) | ||
| 703 | 707 | ||||
| 704 | - | results, err := repo.Search(ctx, "github", 10) | 708 | + | results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{}) |
| 705 | require.NoError(t, err) | 709 | require.NoError(t, err) | ||
| 706 | require.Len(t, results, 2) | 710 | require.Len(t, results, 2) | ||
| 707 | assert.Equal(t, "https://github.com", results[0].Entry.URL) | 711 | assert.Equal(t, "https://github.com", results[0].Entry.URL) | ||
| @@ -829,11 +833,11 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) | |||||
| 829 | Title: "GitHub", | 833 | Title: "GitHub", | ||
| 830 | })) | 834 | })) | ||
| 831 | 835 | ||||
| 832 | - | results, err := repo.Search(ctx, "git", 0) | 836 | + | results, err := repo.Search(ctx, "git", 0, repository.HistoryScope{}) |
| 833 | require.NoError(t, err) | 837 | require.NoError(t, err) | ||
| 834 | assert.Empty(t, results) | 838 | assert.Empty(t, results) | ||
| 835 | 839 | ||||
| 836 | - | results, err = repo.Search(ctx, "git", -1) | 840 | + | results, err = repo.Search(ctx, "git", -1, repository.HistoryScope{}) |
| 837 | require.NoError(t, err) | 841 | require.NoError(t, err) | ||
| 838 | assert.Empty(t, results) | 842 | assert.Empty(t, results) | ||
| 839 | } | 843 | } | ||
| @@ -937,3 +941,370 @@ func historyIDs(entries []*entity.HistoryEntry) []int64 { | |||||
| 937 | } | 941 | } | ||
| 938 | return ids | 942 | return ids | ||
| 939 | } | 943 | } | ||
| 944 | + | ||||
| 945 | + | // insertHistoryRow seeds a history row with an explicit visit count and | |||
| 946 | + | // last_visited so age-scope tests can use stable, non-now timestamps. The | |||
| 947 | + | // timestamp is written in the same UTC second-precision format that SQLite's | |||
| 948 | + | // CURRENT_TIMESTAMP uses in production. | |||
| 949 | + | func insertHistoryRow( | |||
| 950 | + | t *testing.T, | |||
| 951 | + | ctx context.Context, | |||
| 952 | + | db *sql.DB, | |||
| 953 | + | url, title string, | |||
| 954 | + | visitCount int, | |||
| 955 | + | lastVisited time.Time, | |||
| 956 | + | ) { | |||
| 957 | + | t.Helper() | |||
| 958 | + | timestamp := sqliteTimestamp(lastVisited) | |||
| 959 | + | _, err := db.ExecContext(ctx, | |||
| 960 | + | `INSERT INTO history (url, title, last_visited, created_at, visit_count) VALUES (?, ?, ?, ?, ?)`, | |||
| 961 | + | url, title, timestamp, timestamp, visitCount) | |||
| 962 | + | require.NoError(t, err) | |||
| 963 | + | } | |||
| 964 | + | ||||
| 965 | + | // sqliteTimestamp formats a time the way SQLite's CURRENT_TIMESTAMP stores it: | |||
| 966 | + | // UTC at second precision without a timezone suffix. | |||
| 967 | + | func sqliteTimestamp(t time.Time) string { | |||
| 968 | + | return t.UTC().Format("2006-01-02 15:04:05") | |||
| 969 | + | } | |||
| 970 | + | ||||
| 971 | + | func TestHistoryRepository_GetMostVisitedWithin_ExcludesOldHighCountURL(t *testing.T) { | |||
| 972 | + | ctx := historyTestCtx() | |||
| 973 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 974 | + | require.NoError(t, err) | |||
| 975 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 976 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 977 | + | ||||
| 978 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 979 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 980 | + | insertHistoryRow(t, ctx, db, "https://old.example.com", "Old Popular", 50, now.AddDate(0, 0, -40)) | |||
| 981 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 2, now.AddDate(0, 0, -1)) | |||
| 982 | + | ||||
| 983 | + | results, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{Cutoff: cutoff}) | |||
| 984 | + | require.NoError(t, err) | |||
| 985 | + | require.Len(t, results, 1) | |||
| 986 | + | assert.Equal(t, "https://recent.example.com", results[0].URL) | |||
| 987 | + | ||||
| 988 | + | // A zero cutoff means all stored history, so the old popular URL returns. | |||
| 989 | + | all, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{}) | |||
| 990 | + | require.NoError(t, err) | |||
| 991 | + | require.Len(t, all, 2) | |||
| 992 | + | assert.Equal(t, "https://old.example.com", all[0].URL) | |||
| 993 | + | assert.Equal(t, int64(50), all[0].VisitCount) | |||
| 994 | + | } | |||
| 995 | + | ||||
| 996 | + | func TestHistoryRepository_GetMostVisitedWithin_BoundsResults(t *testing.T) { | |||
| 997 | + | ctx := historyTestCtx() | |||
| 998 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 999 | + | require.NoError(t, err) | |||
| 1000 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1001 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1002 | + | ||||
| 1003 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1004 | + | insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 5, now.Add(-time.Hour)) | |||
| 1005 | + | insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 3, now.Add(-2*time.Hour)) | |||
| 1006 | + | insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour)) | |||
| 1007 | + | ||||
| 1008 | + | results, err := repo.GetMostVisitedWithin(ctx, 2, repository.HistoryScope{}) | |||
| 1009 | + | require.NoError(t, err) | |||
| 1010 | + | require.Len(t, results, 2) | |||
| 1011 | + | assert.Equal(t, []string{"https://one.example.com", "https://two.example.com"}, historyURLs(results)) | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 1014 | + | func TestHistoryRepository_GetRecent_ScopeExcludesOldEntries(t *testing.T) { | |||
| 1015 | + | ctx := historyTestCtx() | |||
| 1016 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1017 | + | require.NoError(t, err) | |||
| 1018 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1019 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1020 | + | ||||
| 1021 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1022 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 1023 | + | insertHistoryRow(t, ctx, db, "https://old.example.com", "Old", 1, now.AddDate(0, 0, -40)) | |||
| 1024 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 1, now.AddDate(0, 0, -1)) | |||
| 1025 | + | ||||
| 1026 | + | scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1027 | + | require.NoError(t, err) | |||
| 1028 | + | require.Len(t, scoped, 1) | |||
| 1029 | + | assert.Equal(t, "https://recent.example.com", scoped[0].URL) | |||
| 1030 | + | ||||
| 1031 | + | all, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{}) | |||
| 1032 | + | require.NoError(t, err) | |||
| 1033 | + | require.Len(t, all, 2) | |||
| 1034 | + | ||||
| 1035 | + | bounded, err := repo.GetRecent(ctx, 1, 0, repository.HistoryScope{}) | |||
| 1036 | + | require.NoError(t, err) | |||
| 1037 | + | require.Len(t, bounded, 1) | |||
| 1038 | + | } | |||
| 1039 | + | ||||
| 1040 | + | func TestHistoryRepository_Search_ScopeExcludesOldTitleOnlyMatch(t *testing.T) { | |||
| 1041 | + | ctx := historyTestCtx() | |||
| 1042 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1043 | + | require.NoError(t, err) | |||
| 1044 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1045 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1046 | + | ||||
| 1047 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1048 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 1049 | + | // Matches only in the title, but is older than the window. | |||
| 1050 | + | insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Widget Old Guide", 1, now.AddDate(0, 0, -40)) | |||
| 1051 | + | // Matches in the title and is inside the window. | |||
| 1052 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com/guide", "Widget Recent Guide", 1, now.AddDate(0, 0, -1)) | |||
| 1053 | + | // Matches in the URL, but is older than the window. | |||
| 1054 | + | insertHistoryRow(t, ctx, db, "https://widget.example.com/page", "No match here", 1, now.AddDate(0, 0, -40)) | |||
| 1055 | + | ||||
| 1056 | + | scoped, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1057 | + | require.NoError(t, err) | |||
| 1058 | + | require.Len(t, scoped, 1) | |||
| 1059 | + | assert.Equal(t, "https://recent.example.com/guide", scoped[0].Entry.URL) | |||
| 1060 | + | ||||
| 1061 | + | // A zero cutoff keeps the general, unrestricted search behavior. | |||
| 1062 | + | all, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{}) | |||
| 1063 | + | require.NoError(t, err) | |||
| 1064 | + | require.Len(t, all, 3) | |||
| 1065 | + | } | |||
| 1066 | + | ||||
| 1067 | + | func historyURLs(entries []*entity.HistoryEntry) []string { | |||
| 1068 | + | urls := make([]string, len(entries)) | |||
| 1069 | + | for i, entry := range entries { | |||
| 1070 | + | urls[i] = entry.URL | |||
| 1071 | + | } | |||
| 1072 | + | return urls | |||
| 1073 | + | } | |||
| 1074 | + | ||||
| 1075 | + | func TestHistoryRepository_ScopeCutoffIsInclusiveAtSecondPrecision(t *testing.T) { | |||
| 1076 | + | ctx := historyTestCtx() | |||
| 1077 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1078 | + | require.NoError(t, err) | |||
| 1079 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1080 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1081 | + | ||||
| 1082 | + | cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1083 | + | insertHistoryRow(t, ctx, db, "https://exact.example.com", "Exact", 1, cutoff) | |||
| 1084 | + | insertHistoryRow(t, ctx, db, "https://before.example.com", "Before", 1, cutoff.Add(-time.Second)) | |||
| 1085 | + | ||||
| 1086 | + | results, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1087 | + | require.NoError(t, err) | |||
| 1088 | + | require.Len(t, results, 1, "the entry exactly on the cutoff must be included") | |||
| 1089 | + | assert.Equal(t, "https://exact.example.com", results[0].URL) | |||
| 1090 | + | } | |||
| 1091 | + | ||||
| 1092 | + | func TestHistoryRepository_UnboundedScopePreservesNullLastVisited(t *testing.T) { | |||
| 1093 | + | ctx := historyTestCtx() | |||
| 1094 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1095 | + | require.NoError(t, err) | |||
| 1096 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1097 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1098 | + | ||||
| 1099 | + | _, err = db.ExecContext( | |||
| 1100 | + | ctx, | |||
| 1101 | + | `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, NULL, ?)`, | |||
| 1102 | + | "https://null-last-visited.example.com", | |||
| 1103 | + | "Legacy Null Row", | |||
| 1104 | + | 1, | |||
| 1105 | + | ) | |||
| 1106 | + | require.NoError(t, err) | |||
| 1107 | + | ||||
| 1108 | + | cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1109 | + | ||||
| 1110 | + | recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{}) | |||
| 1111 | + | require.NoError(t, err) | |||
| 1112 | + | require.Len(t, recent, 1, "unbounded recent history must keep NULL last_visited rows") | |||
| 1113 | + | ||||
| 1114 | + | mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{}) | |||
| 1115 | + | require.NoError(t, err) | |||
| 1116 | + | require.Len(t, mostVisited, 1, "unbounded most-visited must keep NULL last_visited rows") | |||
| 1117 | + | ||||
| 1118 | + | matches, err := repo.Search(ctx, "legacy", 10, repository.HistoryScope{}) | |||
| 1119 | + | require.NoError(t, err) | |||
| 1120 | + | require.Len(t, matches, 1, "unbounded search must keep NULL last_visited rows") | |||
| 1121 | + | ||||
| 1122 | + | scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1123 | + | require.NoError(t, err) | |||
| 1124 | + | assert.Empty(t, scoped, "a bounded window must not include rows without a last_visited timestamp") | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1127 | + | func TestHistoryRepository_Search_FiltersBeforeLimitOnURLMatch(t *testing.T) { | |||
| 1128 | + | ctx := historyTestCtx() | |||
| 1129 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1130 | + | require.NoError(t, err) | |||
| 1131 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1132 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1133 | + | ||||
| 1134 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1135 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 1136 | + | // The old row outranks the recent row (higher visit count and domain boost), | |||
| 1137 | + | // so it would consume the single LIMIT slot if filtering happened after LIMIT. | |||
| 1138 | + | insertHistoryRow(t, ctx, db, "https://urlsecret.example.com/", "Old", 50, now.AddDate(0, 0, -40)) | |||
| 1139 | + | insertHistoryRow(t, ctx, db, "https://urlsecret-recent.example.com/", "Recent", 1, now.AddDate(0, 0, -1)) | |||
| 1140 | + | ||||
| 1141 | + | results, err := repo.Search(ctx, "urlsecret", 1, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1142 | + | require.NoError(t, err) | |||
| 1143 | + | require.Len(t, results, 1, "URL FTS must filter by age before applying LIMIT") | |||
| 1144 | + | assert.Equal(t, "https://urlsecret-recent.example.com/", results[0].Entry.URL) | |||
| 1145 | + | ||||
| 1146 | + | // Without a scope the old high-priority row wins the same query. | |||
| 1147 | + | all, err := repo.Search(ctx, "urlsecret", 10, repository.HistoryScope{}) | |||
| 1148 | + | require.NoError(t, err) | |||
| 1149 | + | require.Len(t, all, 2) | |||
| 1150 | + | assert.Equal(t, "https://urlsecret.example.com/", all[0].Entry.URL) | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1153 | + | func TestHistoryRepository_Search_FiltersBeforeLimitOnTitleMatch(t *testing.T) { | |||
| 1154 | + | ctx := historyTestCtx() | |||
| 1155 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1156 | + | require.NoError(t, err) | |||
| 1157 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1158 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1159 | + | ||||
| 1160 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1161 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 1162 | + | // The query only matches titles, so only the title FTS path is exercised. | |||
| 1163 | + | // The old row has the higher visit count and would win an unfiltered LIMIT. | |||
| 1164 | + | insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Titlesecret Old Guide", 50, now.AddDate(0, 0, -40)) | |||
| 1165 | + | insertHistoryRow(t, ctx, db, "https://recent.example.com/entry", "Titlesecret Recent Guide", 1, now.AddDate(0, 0, -1)) | |||
| 1166 | + | ||||
| 1167 | + | results, err := repo.Search(ctx, "titlesecret", 1, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1168 | + | require.NoError(t, err) | |||
| 1169 | + | require.Len(t, results, 1, "title FTS must filter by age before applying LIMIT") | |||
| 1170 | + | assert.Equal(t, "https://recent.example.com/entry", results[0].Entry.URL) | |||
| 1171 | + | ||||
| 1172 | + | all, err := repo.Search(ctx, "titlesecret", 10, repository.HistoryScope{}) | |||
| 1173 | + | require.NoError(t, err) | |||
| 1174 | + | require.Len(t, all, 2) | |||
| 1175 | + | } | |||
| 1176 | + | ||||
| 1177 | + | func TestHistoryRepository_GetRecent_BoundedLimitOffsetAndCutoff(t *testing.T) { | |||
| 1178 | + | ctx := historyTestCtx() | |||
| 1179 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1180 | + | require.NoError(t, err) | |||
| 1181 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1182 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1183 | + | ||||
| 1184 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1185 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 1186 | + | insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 1, now.Add(-time.Hour)) | |||
| 1187 | + | insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 1, now.Add(-2*time.Hour)) | |||
| 1188 | + | insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour)) | |||
| 1189 | + | insertHistoryRow(t, ctx, db, "https://stale.example.com", "Stale", 1, now.AddDate(0, 0, -40)) | |||
| 1190 | + | ||||
| 1191 | + | scope := repository.HistoryScope{Cutoff: cutoff} | |||
| 1192 | + | ||||
| 1193 | + | page, err := repo.GetRecent(ctx, 1, 1, scope) | |||
| 1194 | + | require.NoError(t, err) | |||
| 1195 | + | require.Len(t, page, 1) | |||
| 1196 | + | assert.Equal(t, "https://two.example.com", page[0].URL) | |||
| 1197 | + | ||||
| 1198 | + | // A non-positive limit means all eligible rows, with the offset applied and | |||
| 1199 | + | // the stale row excluded by the cutoff. | |||
| 1200 | + | rest, err := repo.GetRecent(ctx, 0, 1, scope) | |||
| 1201 | + | require.NoError(t, err) | |||
| 1202 | + | require.Len(t, rest, 2) | |||
| 1203 | + | assert.Equal(t, []string{"https://two.example.com", "https://three.example.com"}, historyURLs(rest)) | |||
| 1204 | + | } | |||
| 1205 | + | ||||
| 1206 | + | func TestHistoryRepository_Search_BoundedScopeEmptyResult(t *testing.T) { | |||
| 1207 | + | ctx := historyTestCtx() | |||
| 1208 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1209 | + | require.NoError(t, err) | |||
| 1210 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1211 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1212 | + | ||||
| 1213 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1214 | + | cutoff := now.AddDate(0, 0, -30) | |||
| 1215 | + | insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Ephemeral Secret", 1, now.AddDate(0, 0, -40)) | |||
| 1216 | + | ||||
| 1217 | + | scoped, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{Cutoff: cutoff}) | |||
| 1218 | + | require.NoError(t, err) | |||
| 1219 | + | assert.Empty(t, scoped, "a bounded search must not return age-excluded matches") | |||
| 1220 | + | ||||
| 1221 | + | all, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{}) | |||
| 1222 | + | require.NoError(t, err) | |||
| 1223 | + | require.Len(t, all, 1) | |||
| 1224 | + | } | |||
| 1225 | + | ||||
| 1226 | + | func TestHistoryRepository_GetMostVisitedWithin_NonPositiveLimitIsEmpty(t *testing.T) { | |||
| 1227 | + | ctx := historyTestCtx() | |||
| 1228 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1229 | + | require.NoError(t, err) | |||
| 1230 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1231 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1232 | + | ||||
| 1233 | + | now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1234 | + | insertHistoryRow(t, ctx, db, "https://example.com", "Example", 5, now.Add(-time.Hour)) | |||
| 1235 | + | ||||
| 1236 | + | unbounded, err := repo.GetMostVisitedWithin(ctx, 0, repository.HistoryScope{}) | |||
| 1237 | + | require.NoError(t, err) | |||
| 1238 | + | assert.Empty(t, unbounded) | |||
| 1239 | + | ||||
| 1240 | + | bounded, err := repo.GetMostVisitedWithin(ctx, -5, repository.HistoryScope{Cutoff: now.AddDate(0, 0, -30)}) | |||
| 1241 | + | require.NoError(t, err) | |||
| 1242 | + | assert.Empty(t, bounded) | |||
| 1243 | + | } | |||
| 1244 | + | ||||
| 1245 | + | // queryPlan records the actual driver's EXPLAIN QUERY PLAN output for a query. | |||
| 1246 | + | func queryPlan(t *testing.T, ctx context.Context, db *sql.DB, query string, args ...any) []string { | |||
| 1247 | + | t.Helper() | |||
| 1248 | + | ||||
| 1249 | + | rows, err := db.QueryContext(ctx, "EXPLAIN QUERY PLAN "+query, args...) | |||
| 1250 | + | require.NoError(t, err) | |||
| 1251 | + | defer func() { _ = rows.Close() }() | |||
| 1252 | + | ||||
| 1253 | + | var details []string | |||
| 1254 | + | for rows.Next() { | |||
| 1255 | + | var id, parent, notUsed int | |||
| 1256 | + | var detail string | |||
| 1257 | + | require.NoError(t, rows.Scan(&id, &parent, ¬Used, &detail)) | |||
| 1258 | + | details = append(details, detail) | |||
| 1259 | + | } | |||
| 1260 | + | require.NoError(t, rows.Err()) | |||
| 1261 | + | return details | |||
| 1262 | + | } | |||
| 1263 | + | ||||
| 1264 | + | func TestHistoryRepository_BoundedRecentPlanUsesCutoffIndex(t *testing.T) { | |||
| 1265 | + | ctx := historyTestCtx() | |||
| 1266 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1267 | + | require.NoError(t, err) | |||
| 1268 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1269 | + | ||||
| 1270 | + | // Exercise the exact generated statement that historyRepo.GetRecent runs for | |||
| 1271 | + | // a bounded scope (see GetRecentHistorySinceCutoff), passed verbatim including | |||
| 1272 | + | // sqlc's leading `-- name:` comment. This fails to compile if the bounded | |||
| 1273 | + | // variant disappears, and the plan assertions fail if it regresses to a | |||
| 1274 | + | // nullable `(? IS NULL OR last_visited >= ?)` predicate, which plans as a | |||
| 1275 | + | // SCAN using idx_history_last_visited rather than a SEARCH. | |||
| 1276 | + | cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) | |||
| 1277 | + | plan := queryPlan(t, ctx, db, sqlc.GetRecentHistorySinceCutoff, sql.NullTime{Time: cutoff.UTC(), Valid: true}, 0, 50) | |||
| 1278 | + | ||||
| 1279 | + | joined := strings.Join(plan, "\n") | |||
| 1280 | + | require.Contains(t, joined, "SEARCH", "bounded recent query must use an index search") | |||
| 1281 | + | require.Contains(t, joined, "idx_history_last_visited", "bounded recent query must use the last_visited index") | |||
| 1282 | + | } | |||
| 1283 | + | ||||
| 1284 | + | func TestHistoryRepository_ScopeCutoffSubSecondNormalization(t *testing.T) { | |||
| 1285 | + | ctx := historyTestCtx() | |||
| 1286 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |||
| 1287 | + | require.NoError(t, err) | |||
| 1288 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 1289 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 1290 | + | ||||
| 1291 | + | // Stored at exactly 12:00:05 with no fractional part, matching | |||
| 1292 | + | // CURRENT_TIMESTAMP. | |||
| 1293 | + | stored := time.Date(2026, 6, 24, 12, 0, 5, 0, time.UTC) | |||
| 1294 | + | insertHistoryRow(t, ctx, db, "https://subsecond.example.com/marker", "Subsecond Marker", 1, stored) | |||
| 1295 | + | scope := repository.HistoryScope{Cutoff: stored.Add(500 * time.Millisecond)} | |||
| 1296 | + | ||||
| 1297 | + | recent, err := repo.GetRecent(ctx, 10, 0, scope) | |||
| 1298 | + | require.NoError(t, err) | |||
| 1299 | + | require.Len(t, recent, 1, "a sub-second cutoff must normalize to the stored second and include the row") | |||
| 1300 | + | assert.Equal(t, "https://subsecond.example.com/marker", recent[0].URL) | |||
| 1301 | + | ||||
| 1302 | + | mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, scope) | |||
| 1303 | + | require.NoError(t, err) | |||
| 1304 | + | require.Len(t, mostVisited, 1) | |||
| 1305 | + | ||||
| 1306 | + | matches, err := repo.Search(ctx, "subsecond", 10, scope) | |||
| 1307 | + | require.NoError(t, err) | |||
| 1308 | + | require.Len(t, matches, 1, "the shared FTS cutoff helper must apply the same normalization") | |||
| 1309 | + | assert.Equal(t, "https://subsecond.example.com/marker", matches[0].Entry.URL) | |||
| 1310 | + | } | |||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +24 -4
| @@ -135,18 +135,27 @@ func (r *LazyHistoryRepository) UpdateMetadata(ctx context.Context, entry *entit | |||
| 135 | 135 | return r.repo.Save(ctx, entry) | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | - | func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 138 | + | func (r *LazyHistoryRepository) Search( | |
| 139 | + | ctx context.Context, | |
| 140 | + | query string, | |
| 141 | + | limit int, | |
| 142 | + | scope repository.HistoryScope, | |
| 143 | + | ) ([]entity.HistoryMatch, error) { | |
| 139 | 144 | if err := r.init(ctx); err != nil { | |
| 140 | 145 | return nil, err | |
| 141 | 146 | } | |
| 142 | - | return r.repo.Search(ctx, query, limit) | |
| 147 | + | return r.repo.Search(ctx, query, limit, scope) | |
| 143 | 148 | } | |
| 144 | 149 | ||
| 145 | - | func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 150 | + | func (r *LazyHistoryRepository) GetRecent( | |
| 151 | + | ctx context.Context, | |
| 152 | + | limit, offset int, | |
| 153 | + | scope repository.HistoryScope, | |
| 154 | + | ) ([]*entity.HistoryEntry, error) { | |
| 146 | 155 | if err := r.init(ctx); err != nil { | |
| 147 | 156 | return nil, err | |
| 148 | 157 | } | |
| 149 | - | return r.repo.GetRecent(ctx, limit, offset) | |
| 158 | + | return r.repo.GetRecent(ctx, limit, offset, scope) | |
| 150 | 159 | } | |
| 151 | 160 | ||
| 152 | 161 | func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| @@ -195,6 +204,17 @@ func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([ | |||
| 195 | 204 | return r.repo.GetMostVisited(ctx, days) | |
| 196 | 205 | } | |
| 197 | 206 | ||
| 207 | + | func (r *LazyHistoryRepository) GetMostVisitedWithin( | |
| 208 | + | ctx context.Context, | |
| 209 | + | limit int, | |
| 210 | + | scope repository.HistoryScope, | |
| 211 | + | ) ([]*entity.HistoryEntry, error) { | |
| 212 | + | if err := r.init(ctx); err != nil { | |
| 213 | + | return nil, err | |
| 214 | + | } | |
| 215 | + | return r.repo.GetMostVisitedWithin(ctx, limit, scope) | |
| 216 | + | } | |
| 217 | + | ||
| 198 | 218 | func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 199 | 219 | if err := r.init(ctx); err != nil { | |
| 200 | 220 | return nil, err | |
| @@ -135,18 +135,27 @@ func (r *LazyHistoryRepository) UpdateMetadata(ctx context.Context, entry *entit | |||||
| 135 | return r.repo.Save(ctx, entry) | 135 | return r.repo.Save(ctx, entry) | ||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
| 138 | - | func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 138 | + | func (r *LazyHistoryRepository) Search( |
| 139 | + | ctx context.Context, | |||
| 140 | + | query string, | |||
| 141 | + | limit int, | |||
| 142 | + | scope repository.HistoryScope, | |||
| 143 | + | ) ([]entity.HistoryMatch, error) { | |||
| 139 | if err := r.init(ctx); err != nil { | 144 | if err := r.init(ctx); err != nil { | ||
| 140 | return nil, err | 145 | return nil, err | ||
| 141 | } | 146 | } | ||
| 142 | - | return r.repo.Search(ctx, query, limit) | 147 | + | return r.repo.Search(ctx, query, limit, scope) |
| 143 | } | 148 | } | ||
| 144 | 149 | ||||
| 145 | - | func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 150 | + | func (r *LazyHistoryRepository) GetRecent( |
| 151 | + | ctx context.Context, | |||
| 152 | + | limit, offset int, | |||
| 153 | + | scope repository.HistoryScope, | |||
| 154 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 146 | if err := r.init(ctx); err != nil { | 155 | if err := r.init(ctx); err != nil { | ||
| 147 | return nil, err | 156 | return nil, err | ||
| 148 | } | 157 | } | ||
| 149 | - | return r.repo.GetRecent(ctx, limit, offset) | 158 | + | return r.repo.GetRecent(ctx, limit, offset, scope) |
| 150 | } | 159 | } | ||
| 151 | 160 | ||||
| 152 | func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | 161 | func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| @@ -195,6 +204,17 @@ func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([ | |||||
| 195 | return r.repo.GetMostVisited(ctx, days) | 204 | return r.repo.GetMostVisited(ctx, days) | ||
| 196 | } | 205 | } | ||
| 197 | 206 | ||||
| 207 | + | func (r *LazyHistoryRepository) GetMostVisitedWithin( | |||
| 208 | + | ctx context.Context, | |||
| 209 | + | limit int, | |||
| 210 | + | scope repository.HistoryScope, | |||
| 211 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 212 | + | if err := r.init(ctx); err != nil { | |||
| 213 | + | return nil, err | |||
| 214 | + | } | |||
| 215 | + | return r.repo.GetMostVisitedWithin(ctx, limit, scope) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 198 | func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | 218 | func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | ||
| 199 | if err := r.init(ctx); err != nil { | 219 | if err := r.init(ctx); err != nil { | ||
| 200 | return nil, err | 220 | return nil, err | ||
M internal/infrastructure/persistence/sqlite/queries/history.sql +23 -8
| @@ -3,6 +3,14 @@ SELECT * FROM history | |||
| 3 | 3 | ORDER BY last_visited DESC, id DESC | |
| 4 | 4 | LIMIT ? OFFSET ?; | |
| 5 | 5 | ||
| 6 | + | -- name: GetRecentHistorySinceCutoff :many | |
| 7 | + | -- Bounded window variant: the direct comparison keeps the last_visited index | |
| 8 | + | -- usable. Rows with a NULL last_visited are intentionally excluded. | |
| 9 | + | SELECT * FROM history | |
| 10 | + | WHERE last_visited >= @cutoff | |
| 11 | + | ORDER BY last_visited DESC, id DESC | |
| 12 | + | LIMIT @limit OFFSET @offset; | |
| 13 | + | ||
| 6 | 14 | -- name: GetRecentHistoryByDomain :many | |
| 7 | 15 | SELECT * FROM history | |
| 8 | 16 | WHERE domain = @domain | |
| @@ -108,14 +116,6 @@ ORDER BY day ASC; | |||
| 108 | 116 | -- name: DeleteHistoryByDomain :exec | |
| 109 | 117 | DELETE FROM history WHERE domain = @domain; | |
| 110 | 118 | ||
| 111 | - | -- name: SearchHistoryFTSUrl :many | |
| 112 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain | |
| 113 | - | FROM history_fts fts | |
| 114 | - | JOIN history h ON fts.rowid = h.id | |
| 115 | - | WHERE fts.url MATCH @query | |
| 116 | - | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 117 | - | LIMIT @limit; | |
| 118 | - | ||
| 119 | 119 | -- name: SearchHistoryFTSUrlWithDomainBoost :many | |
| 120 | 120 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, | |
| 121 | 121 | CASE | |
| @@ -128,6 +128,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||
| 128 | 128 | FROM history_fts fts | |
| 129 | 129 | JOIN history h ON fts.rowid = h.id | |
| 130 | 130 | WHERE fts.url MATCH @query | |
| 131 | + | AND (@cutoff IS NULL OR h.last_visited >= @cutoff) | |
| 131 | 132 | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | |
| 132 | 133 | LIMIT @limit; | |
| 133 | 134 | ||
| @@ -136,6 +137,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||
| 136 | 137 | FROM history_fts fts | |
| 137 | 138 | JOIN history h ON fts.rowid = h.id | |
| 138 | 139 | WHERE fts.title MATCH @query | |
| 140 | + | AND (@cutoff IS NULL OR h.last_visited >= @cutoff) | |
| 139 | 141 | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 140 | 142 | LIMIT @limit; | |
| 141 | 143 | ||
| @@ -157,5 +159,18 @@ ORDER BY last_visited DESC, id DESC; | |||
| 157 | 159 | SELECT * FROM history | |
| 158 | 160 | ORDER BY visit_count DESC, last_visited DESC; | |
| 159 | 161 | ||
| 162 | + | -- name: GetMostVisitedHistory :many | |
| 163 | + | SELECT * FROM history | |
| 164 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 165 | + | LIMIT @limit; | |
| 166 | + | ||
| 167 | + | -- name: GetMostVisitedHistorySinceCutoff :many | |
| 168 | + | -- Bounded window variant: the direct comparison keeps the last_visited index | |
| 169 | + | -- usable. Rows with a NULL last_visited are intentionally excluded. | |
| 170 | + | SELECT * FROM history | |
| 171 | + | WHERE last_visited >= @cutoff | |
| 172 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 173 | + | LIMIT @limit; | |
| 174 | + | ||
| 160 | 175 | -- name: CapVisitCount :exec | |
| 161 | 176 | UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?; | |
| @@ -3,6 +3,14 @@ SELECT * FROM history | |||||
| 3 | ORDER BY last_visited DESC, id DESC | 3 | ORDER BY last_visited DESC, id DESC | ||
| 4 | LIMIT ? OFFSET ?; | 4 | LIMIT ? OFFSET ?; | ||
| 5 | 5 | ||||
| 6 | + | -- name: GetRecentHistorySinceCutoff :many | |||
| 7 | + | -- Bounded window variant: the direct comparison keeps the last_visited index | |||
| 8 | + | -- usable. Rows with a NULL last_visited are intentionally excluded. | |||
| 9 | + | SELECT * FROM history | |||
| 10 | + | WHERE last_visited >= @cutoff | |||
| 11 | + | ORDER BY last_visited DESC, id DESC | |||
| 12 | + | LIMIT @limit OFFSET @offset; | |||
| 13 | + | ||||
| 6 | -- name: GetRecentHistoryByDomain :many | 14 | -- name: GetRecentHistoryByDomain :many | ||
| 7 | SELECT * FROM history | 15 | SELECT * FROM history | ||
| 8 | WHERE domain = @domain | 16 | WHERE domain = @domain | ||
| @@ -108,14 +116,6 @@ ORDER BY day ASC; | |||||
| 108 | -- name: DeleteHistoryByDomain :exec | 116 | -- name: DeleteHistoryByDomain :exec | ||
| 109 | DELETE FROM history WHERE domain = @domain; | 117 | DELETE FROM history WHERE domain = @domain; | ||
| 110 | 118 | ||||
| 111 | - | -- name: SearchHistoryFTSUrl :many | |||
| 112 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain | |||
| 113 | - | FROM history_fts fts | |||
| 114 | - | JOIN history h ON fts.rowid = h.id | |||
| 115 | - | WHERE fts.url MATCH @query | |||
| 116 | - | ORDER BY h.visit_count DESC, h.last_visited DESC | |||
| 117 | - | LIMIT @limit; | |||
| 118 | - | ||||
| 119 | -- name: SearchHistoryFTSUrlWithDomainBoost :many | 119 | -- name: SearchHistoryFTSUrlWithDomainBoost :many | ||
| 120 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, | 120 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, | ||
| 121 | CASE | 121 | CASE | ||
| @@ -128,6 +128,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||||
| 128 | FROM history_fts fts | 128 | FROM history_fts fts | ||
| 129 | JOIN history h ON fts.rowid = h.id | 129 | JOIN history h ON fts.rowid = h.id | ||
| 130 | WHERE fts.url MATCH @query | 130 | WHERE fts.url MATCH @query | ||
| 131 | + | AND (@cutoff IS NULL OR h.last_visited >= @cutoff) | |||
| 131 | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | 132 | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | ||
| 132 | LIMIT @limit; | 133 | LIMIT @limit; | ||
| 133 | 134 | ||||
| @@ -136,6 +137,7 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||||
| 136 | FROM history_fts fts | 137 | FROM history_fts fts | ||
| 137 | JOIN history h ON fts.rowid = h.id | 138 | JOIN history h ON fts.rowid = h.id | ||
| 138 | WHERE fts.title MATCH @query | 139 | WHERE fts.title MATCH @query | ||
| 140 | + | AND (@cutoff IS NULL OR h.last_visited >= @cutoff) | |||
| 139 | ORDER BY h.visit_count DESC, h.last_visited DESC | 141 | ORDER BY h.visit_count DESC, h.last_visited DESC | ||
| 140 | LIMIT @limit; | 142 | LIMIT @limit; | ||
| 141 | 143 | ||||
| @@ -157,5 +159,18 @@ ORDER BY last_visited DESC, id DESC; | |||||
| 157 | SELECT * FROM history | 159 | SELECT * FROM history | ||
| 158 | ORDER BY visit_count DESC, last_visited DESC; | 160 | ORDER BY visit_count DESC, last_visited DESC; | ||
| 159 | 161 | ||||
| 162 | + | -- name: GetMostVisitedHistory :many | |||
| 163 | + | SELECT * FROM history | |||
| 164 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 165 | + | LIMIT @limit; | |||
| 166 | + | ||||
| 167 | + | -- name: GetMostVisitedHistorySinceCutoff :many | |||
| 168 | + | -- Bounded window variant: the direct comparison keeps the last_visited index | |||
| 169 | + | -- usable. Rows with a NULL last_visited are intentionally excluded. | |||
| 170 | + | SELECT * FROM history | |||
| 171 | + | WHERE last_visited >= @cutoff | |||
| 172 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 173 | + | LIMIT @limit; | |||
| 174 | + | ||||
| 160 | -- name: CapVisitCount :exec | 175 | -- name: CapVisitCount :exec | ||
| 161 | UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?; | 176 | UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?; | ||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +149 -55
| @@ -386,6 +386,90 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||
| 386 | 386 | return items, nil | |
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | + | const GetMostVisitedHistory = `-- name: GetMostVisitedHistory :many | |
| 390 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 391 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 392 | + | LIMIT ?1 | |
| 393 | + | ` | |
| 394 | + | ||
| 395 | + | func (q *Queries) GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error) { | |
| 396 | + | rows, err := q.db.QueryContext(ctx, GetMostVisitedHistory, limit) | |
| 397 | + | if err != nil { | |
| 398 | + | return nil, err | |
| 399 | + | } | |
| 400 | + | defer rows.Close() | |
| 401 | + | items := []History{} | |
| 402 | + | for rows.Next() { | |
| 403 | + | var i History | |
| 404 | + | if err := rows.Scan( | |
| 405 | + | &i.ID, | |
| 406 | + | &i.Url, | |
| 407 | + | &i.Title, | |
| 408 | + | &i.FaviconUrl, | |
| 409 | + | &i.VisitCount, | |
| 410 | + | &i.LastVisited, | |
| 411 | + | &i.CreatedAt, | |
| 412 | + | &i.Domain, | |
| 413 | + | ); err != nil { | |
| 414 | + | return nil, err | |
| 415 | + | } | |
| 416 | + | items = append(items, i) | |
| 417 | + | } | |
| 418 | + | if err := rows.Close(); err != nil { | |
| 419 | + | return nil, err | |
| 420 | + | } | |
| 421 | + | if err := rows.Err(); err != nil { | |
| 422 | + | return nil, err | |
| 423 | + | } | |
| 424 | + | return items, nil | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | const GetMostVisitedHistorySinceCutoff = `-- name: GetMostVisitedHistorySinceCutoff :many | |
| 428 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 429 | + | WHERE last_visited >= ?1 | |
| 430 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 431 | + | LIMIT ?2 | |
| 432 | + | ` | |
| 433 | + | ||
| 434 | + | type GetMostVisitedHistorySinceCutoffParams struct { | |
| 435 | + | Cutoff sql.NullTime `json:"cutoff"` | |
| 436 | + | Limit int64 `json:"limit"` | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |
| 440 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |
| 441 | + | func (q *Queries) GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error) { | |
| 442 | + | rows, err := q.db.QueryContext(ctx, GetMostVisitedHistorySinceCutoff, arg.Cutoff, arg.Limit) | |
| 443 | + | if err != nil { | |
| 444 | + | return nil, err | |
| 445 | + | } | |
| 446 | + | defer rows.Close() | |
| 447 | + | items := []History{} | |
| 448 | + | for rows.Next() { | |
| 449 | + | var i History | |
| 450 | + | if err := rows.Scan( | |
| 451 | + | &i.ID, | |
| 452 | + | &i.Url, | |
| 453 | + | &i.Title, | |
| 454 | + | &i.FaviconUrl, | |
| 455 | + | &i.VisitCount, | |
| 456 | + | &i.LastVisited, | |
| 457 | + | &i.CreatedAt, | |
| 458 | + | &i.Domain, | |
| 459 | + | ); err != nil { | |
| 460 | + | return nil, err | |
| 461 | + | } | |
| 462 | + | items = append(items, i) | |
| 463 | + | } | |
| 464 | + | if err := rows.Close(); err != nil { | |
| 465 | + | return nil, err | |
| 466 | + | } | |
| 467 | + | if err := rows.Err(); err != nil { | |
| 468 | + | return nil, err | |
| 469 | + | } | |
| 470 | + | return items, nil | |
| 471 | + | } | |
| 472 | + | ||
| 389 | 473 | const GetRecentHistory = `-- name: GetRecentHistory :many | |
| 390 | 474 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 391 | 475 | ORDER BY last_visited DESC, id DESC | |
| @@ -512,6 +596,53 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||
| 512 | 596 | return items, nil | |
| 513 | 597 | } | |
| 514 | 598 | ||
| 599 | + | const GetRecentHistorySinceCutoff = `-- name: GetRecentHistorySinceCutoff :many | |
| 600 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 601 | + | WHERE last_visited >= ?1 | |
| 602 | + | ORDER BY last_visited DESC, id DESC | |
| 603 | + | LIMIT ?3 OFFSET ?2 | |
| 604 | + | ` | |
| 605 | + | ||
| 606 | + | type GetRecentHistorySinceCutoffParams struct { | |
| 607 | + | Cutoff sql.NullTime `json:"cutoff"` | |
| 608 | + | Offset int64 `json:"offset"` | |
| 609 | + | Limit int64 `json:"limit"` | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |
| 613 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |
| 614 | + | func (q *Queries) GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error) { | |
| 615 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistorySinceCutoff, arg.Cutoff, arg.Offset, arg.Limit) | |
| 616 | + | if err != nil { | |
| 617 | + | return nil, err | |
| 618 | + | } | |
| 619 | + | defer rows.Close() | |
| 620 | + | items := []History{} | |
| 621 | + | for rows.Next() { | |
| 622 | + | var i History | |
| 623 | + | if err := rows.Scan( | |
| 624 | + | &i.ID, | |
| 625 | + | &i.Url, | |
| 626 | + | &i.Title, | |
| 627 | + | &i.FaviconUrl, | |
| 628 | + | &i.VisitCount, | |
| 629 | + | &i.LastVisited, | |
| 630 | + | &i.CreatedAt, | |
| 631 | + | &i.Domain, | |
| 632 | + | ); err != nil { | |
| 633 | + | return nil, err | |
| 634 | + | } | |
| 635 | + | items = append(items, i) | |
| 636 | + | } | |
| 637 | + | if err := rows.Close(); err != nil { | |
| 638 | + | return nil, err | |
| 639 | + | } | |
| 640 | + | if err := rows.Err(); err != nil { | |
| 641 | + | return nil, err | |
| 642 | + | } | |
| 643 | + | return items, nil | |
| 644 | + | } | |
| 645 | + | ||
| 515 | 646 | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | |
| 516 | 647 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 517 | 648 | WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2) | |
| @@ -685,63 +816,19 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||
| 685 | 816 | FROM history_fts fts | |
| 686 | 817 | JOIN history h ON fts.rowid = h.id | |
| 687 | 818 | WHERE fts.title MATCH ?1 | |
| 819 | + | AND (?2 IS NULL OR h.last_visited >= ?2) | |
| 688 | 820 | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 689 | - | LIMIT ?2 | |
| 821 | + | LIMIT ?3 | |
| 690 | 822 | ` | |
| 691 | 823 | ||
| 692 | 824 | type SearchHistoryFTSTitleParams struct { | |
| 693 | - | Query string `json:"query"` | |
| 694 | - | Limit int64 `json:"limit"` | |
| 825 | + | Query string `json:"query"` | |
| 826 | + | Cutoff interface{} `json:"cutoff"` | |
| 827 | + | Limit int64 `json:"limit"` | |
| 695 | 828 | } | |
| 696 | 829 | ||
| 697 | 830 | func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) { | |
| 698 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit) | |
| 699 | - | if err != nil { | |
| 700 | - | return nil, err | |
| 701 | - | } | |
| 702 | - | defer rows.Close() | |
| 703 | - | items := []History{} | |
| 704 | - | for rows.Next() { | |
| 705 | - | var i History | |
| 706 | - | if err := rows.Scan( | |
| 707 | - | &i.ID, | |
| 708 | - | &i.Url, | |
| 709 | - | &i.Title, | |
| 710 | - | &i.FaviconUrl, | |
| 711 | - | &i.VisitCount, | |
| 712 | - | &i.LastVisited, | |
| 713 | - | &i.CreatedAt, | |
| 714 | - | &i.Domain, | |
| 715 | - | ); err != nil { | |
| 716 | - | return nil, err | |
| 717 | - | } | |
| 718 | - | items = append(items, i) | |
| 719 | - | } | |
| 720 | - | if err := rows.Close(); err != nil { | |
| 721 | - | return nil, err | |
| 722 | - | } | |
| 723 | - | if err := rows.Err(); err != nil { | |
| 724 | - | return nil, err | |
| 725 | - | } | |
| 726 | - | return items, nil | |
| 727 | - | } | |
| 728 | - | ||
| 729 | - | const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many | |
| 730 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain | |
| 731 | - | FROM history_fts fts | |
| 732 | - | JOIN history h ON fts.rowid = h.id | |
| 733 | - | WHERE fts.url MATCH ?1 | |
| 734 | - | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 735 | - | LIMIT ?2 | |
| 736 | - | ` | |
| 737 | - | ||
| 738 | - | type SearchHistoryFTSUrlParams struct { | |
| 739 | - | Query string `json:"query"` | |
| 740 | - | Limit int64 `json:"limit"` | |
| 741 | - | } | |
| 742 | - | ||
| 743 | - | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) { | |
| 744 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) | |
| 831 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Cutoff, arg.Limit) | |
| 745 | 832 | if err != nil { | |
| 746 | 833 | return nil, err | |
| 747 | 834 | } | |
| @@ -784,14 +871,16 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||
| 784 | 871 | FROM history_fts fts | |
| 785 | 872 | JOIN history h ON fts.rowid = h.id | |
| 786 | 873 | WHERE fts.url MATCH ?2 | |
| 874 | + | AND (?3 IS NULL OR h.last_visited >= ?3) | |
| 787 | 875 | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | |
| 788 | - | LIMIT ?3 | |
| 876 | + | LIMIT ?4 | |
| 789 | 877 | ` | |
| 790 | 878 | ||
| 791 | 879 | type SearchHistoryFTSUrlWithDomainBoostParams struct { | |
| 792 | - | Term sql.NullString `json:"term"` | |
| 793 | - | Query string `json:"query"` | |
| 794 | - | Limit int64 `json:"limit"` | |
| 880 | + | Term sql.NullString `json:"term"` | |
| 881 | + | Query string `json:"query"` | |
| 882 | + | Cutoff interface{} `json:"cutoff"` | |
| 883 | + | Limit int64 `json:"limit"` | |
| 795 | 884 | } | |
| 796 | 885 | ||
| 797 | 886 | type SearchHistoryFTSUrlWithDomainBoostRow struct { | |
| @@ -806,7 +895,12 @@ type SearchHistoryFTSUrlWithDomainBoostRow struct { | |||
| 806 | 895 | } | |
| 807 | 896 | ||
| 808 | 897 | func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) { | |
| 809 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, arg.Term, arg.Query, arg.Limit) | |
| 898 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, | |
| 899 | + | arg.Term, | |
| 900 | + | arg.Query, | |
| 901 | + | arg.Cutoff, | |
| 902 | + | arg.Limit, | |
| 903 | + | ) | |
| 810 | 904 | if err != nil { | |
| 811 | 905 | return nil, err | |
| 812 | 906 | } | |
| @@ -386,6 +386,90 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||||
| 386 | return items, nil | 386 | return items, nil | ||
| 387 | } | 387 | } | ||
| 388 | 388 | ||||
| 389 | + | const GetMostVisitedHistory = `-- name: GetMostVisitedHistory :many | |||
| 390 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 391 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 392 | + | LIMIT ?1 | |||
| 393 | + | ` | |||
| 394 | + | ||||
| 395 | + | func (q *Queries) GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error) { | |||
| 396 | + | rows, err := q.db.QueryContext(ctx, GetMostVisitedHistory, limit) | |||
| 397 | + | if err != nil { | |||
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | defer rows.Close() | |||
| 401 | + | items := []History{} | |||
| 402 | + | for rows.Next() { | |||
| 403 | + | var i History | |||
| 404 | + | if err := rows.Scan( | |||
| 405 | + | &i.ID, | |||
| 406 | + | &i.Url, | |||
| 407 | + | &i.Title, | |||
| 408 | + | &i.FaviconUrl, | |||
| 409 | + | &i.VisitCount, | |||
| 410 | + | &i.LastVisited, | |||
| 411 | + | &i.CreatedAt, | |||
| 412 | + | &i.Domain, | |||
| 413 | + | ); err != nil { | |||
| 414 | + | return nil, err | |||
| 415 | + | } | |||
| 416 | + | items = append(items, i) | |||
| 417 | + | } | |||
| 418 | + | if err := rows.Close(); err != nil { | |||
| 419 | + | return nil, err | |||
| 420 | + | } | |||
| 421 | + | if err := rows.Err(); err != nil { | |||
| 422 | + | return nil, err | |||
| 423 | + | } | |||
| 424 | + | return items, nil | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | const GetMostVisitedHistorySinceCutoff = `-- name: GetMostVisitedHistorySinceCutoff :many | |||
| 428 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 429 | + | WHERE last_visited >= ?1 | |||
| 430 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 431 | + | LIMIT ?2 | |||
| 432 | + | ` | |||
| 433 | + | ||||
| 434 | + | type GetMostVisitedHistorySinceCutoffParams struct { | |||
| 435 | + | Cutoff sql.NullTime `json:"cutoff"` | |||
| 436 | + | Limit int64 `json:"limit"` | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |||
| 440 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |||
| 441 | + | func (q *Queries) GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error) { | |||
| 442 | + | rows, err := q.db.QueryContext(ctx, GetMostVisitedHistorySinceCutoff, arg.Cutoff, arg.Limit) | |||
| 443 | + | if err != nil { | |||
| 444 | + | return nil, err | |||
| 445 | + | } | |||
| 446 | + | defer rows.Close() | |||
| 447 | + | items := []History{} | |||
| 448 | + | for rows.Next() { | |||
| 449 | + | var i History | |||
| 450 | + | if err := rows.Scan( | |||
| 451 | + | &i.ID, | |||
| 452 | + | &i.Url, | |||
| 453 | + | &i.Title, | |||
| 454 | + | &i.FaviconUrl, | |||
| 455 | + | &i.VisitCount, | |||
| 456 | + | &i.LastVisited, | |||
| 457 | + | &i.CreatedAt, | |||
| 458 | + | &i.Domain, | |||
| 459 | + | ); err != nil { | |||
| 460 | + | return nil, err | |||
| 461 | + | } | |||
| 462 | + | items = append(items, i) | |||
| 463 | + | } | |||
| 464 | + | if err := rows.Close(); err != nil { | |||
| 465 | + | return nil, err | |||
| 466 | + | } | |||
| 467 | + | if err := rows.Err(); err != nil { | |||
| 468 | + | return nil, err | |||
| 469 | + | } | |||
| 470 | + | return items, nil | |||
| 471 | + | } | |||
| 472 | + | ||||
| 389 | const GetRecentHistory = `-- name: GetRecentHistory :many | 473 | const GetRecentHistory = `-- name: GetRecentHistory :many | ||
| 390 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 474 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 391 | ORDER BY last_visited DESC, id DESC | 475 | ORDER BY last_visited DESC, id DESC | ||
| @@ -512,6 +596,53 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||||
| 512 | return items, nil | 596 | return items, nil | ||
| 513 | } | 597 | } | ||
| 514 | 598 | ||||
| 599 | + | const GetRecentHistorySinceCutoff = `-- name: GetRecentHistorySinceCutoff :many | |||
| 600 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 601 | + | WHERE last_visited >= ?1 | |||
| 602 | + | ORDER BY last_visited DESC, id DESC | |||
| 603 | + | LIMIT ?3 OFFSET ?2 | |||
| 604 | + | ` | |||
| 605 | + | ||||
| 606 | + | type GetRecentHistorySinceCutoffParams struct { | |||
| 607 | + | Cutoff sql.NullTime `json:"cutoff"` | |||
| 608 | + | Offset int64 `json:"offset"` | |||
| 609 | + | Limit int64 `json:"limit"` | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |||
| 613 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |||
| 614 | + | func (q *Queries) GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error) { | |||
| 615 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistorySinceCutoff, arg.Cutoff, arg.Offset, arg.Limit) | |||
| 616 | + | if err != nil { | |||
| 617 | + | return nil, err | |||
| 618 | + | } | |||
| 619 | + | defer rows.Close() | |||
| 620 | + | items := []History{} | |||
| 621 | + | for rows.Next() { | |||
| 622 | + | var i History | |||
| 623 | + | if err := rows.Scan( | |||
| 624 | + | &i.ID, | |||
| 625 | + | &i.Url, | |||
| 626 | + | &i.Title, | |||
| 627 | + | &i.FaviconUrl, | |||
| 628 | + | &i.VisitCount, | |||
| 629 | + | &i.LastVisited, | |||
| 630 | + | &i.CreatedAt, | |||
| 631 | + | &i.Domain, | |||
| 632 | + | ); err != nil { | |||
| 633 | + | return nil, err | |||
| 634 | + | } | |||
| 635 | + | items = append(items, i) | |||
| 636 | + | } | |||
| 637 | + | if err := rows.Close(); err != nil { | |||
| 638 | + | return nil, err | |||
| 639 | + | } | |||
| 640 | + | if err := rows.Err(); err != nil { | |||
| 641 | + | return nil, err | |||
| 642 | + | } | |||
| 643 | + | return items, nil | |||
| 644 | + | } | |||
| 645 | + | ||||
| 515 | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | 646 | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | ||
| 516 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | 647 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | ||
| 517 | WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2) | 648 | WHERE last_visited < ?1 OR (last_visited = ?1 AND id < ?2) | ||
| @@ -685,63 +816,19 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||||
| 685 | FROM history_fts fts | 816 | FROM history_fts fts | ||
| 686 | JOIN history h ON fts.rowid = h.id | 817 | JOIN history h ON fts.rowid = h.id | ||
| 687 | WHERE fts.title MATCH ?1 | 818 | WHERE fts.title MATCH ?1 | ||
| 819 | + | AND (?2 IS NULL OR h.last_visited >= ?2) | |||
| 688 | ORDER BY h.visit_count DESC, h.last_visited DESC | 820 | ORDER BY h.visit_count DESC, h.last_visited DESC | ||
| 689 | - | LIMIT ?2 | 821 | + | LIMIT ?3 |
| 690 | ` | 822 | ` | ||
| 691 | 823 | ||||
| 692 | type SearchHistoryFTSTitleParams struct { | 824 | type SearchHistoryFTSTitleParams struct { | ||
| 693 | - | Query string `json:"query"` | 825 | + | Query string `json:"query"` |
| 694 | - | Limit int64 `json:"limit"` | 826 | + | Cutoff interface{} `json:"cutoff"` |
| 827 | + | Limit int64 `json:"limit"` | |||
| 695 | } | 828 | } | ||
| 696 | 829 | ||||
| 697 | func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) { | 830 | func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) { | ||
| 698 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit) | 831 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Cutoff, arg.Limit) |
| 699 | - | if err != nil { | |||
| 700 | - | return nil, err | |||
| 701 | - | } | |||
| 702 | - | defer rows.Close() | |||
| 703 | - | items := []History{} | |||
| 704 | - | for rows.Next() { | |||
| 705 | - | var i History | |||
| 706 | - | if err := rows.Scan( | |||
| 707 | - | &i.ID, | |||
| 708 | - | &i.Url, | |||
| 709 | - | &i.Title, | |||
| 710 | - | &i.FaviconUrl, | |||
| 711 | - | &i.VisitCount, | |||
| 712 | - | &i.LastVisited, | |||
| 713 | - | &i.CreatedAt, | |||
| 714 | - | &i.Domain, | |||
| 715 | - | ); err != nil { | |||
| 716 | - | return nil, err | |||
| 717 | - | } | |||
| 718 | - | items = append(items, i) | |||
| 719 | - | } | |||
| 720 | - | if err := rows.Close(); err != nil { | |||
| 721 | - | return nil, err | |||
| 722 | - | } | |||
| 723 | - | if err := rows.Err(); err != nil { | |||
| 724 | - | return nil, err | |||
| 725 | - | } | |||
| 726 | - | return items, nil | |||
| 727 | - | } | |||
| 728 | - | ||||
| 729 | - | const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many | |||
| 730 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain | |||
| 731 | - | FROM history_fts fts | |||
| 732 | - | JOIN history h ON fts.rowid = h.id | |||
| 733 | - | WHERE fts.url MATCH ?1 | |||
| 734 | - | ORDER BY h.visit_count DESC, h.last_visited DESC | |||
| 735 | - | LIMIT ?2 | |||
| 736 | - | ` | |||
| 737 | - | ||||
| 738 | - | type SearchHistoryFTSUrlParams struct { | |||
| 739 | - | Query string `json:"query"` | |||
| 740 | - | Limit int64 `json:"limit"` | |||
| 741 | - | } | |||
| 742 | - | ||||
| 743 | - | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) { | |||
| 744 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) | |||
| 745 | if err != nil { | 832 | if err != nil { | ||
| 746 | return nil, err | 833 | return nil, err | ||
| 747 | } | 834 | } | ||
| @@ -784,14 +871,16 @@ SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.cre | |||||
| 784 | FROM history_fts fts | 871 | FROM history_fts fts | ||
| 785 | JOIN history h ON fts.rowid = h.id | 872 | JOIN history h ON fts.rowid = h.id | ||
| 786 | WHERE fts.url MATCH ?2 | 873 | WHERE fts.url MATCH ?2 | ||
| 874 | + | AND (?3 IS NULL OR h.last_visited >= ?3) | |||
| 787 | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | 875 | ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | ||
| 788 | - | LIMIT ?3 | 876 | + | LIMIT ?4 |
| 789 | ` | 877 | ` | ||
| 790 | 878 | ||||
| 791 | type SearchHistoryFTSUrlWithDomainBoostParams struct { | 879 | type SearchHistoryFTSUrlWithDomainBoostParams struct { | ||
| 792 | - | Term sql.NullString `json:"term"` | 880 | + | Term sql.NullString `json:"term"` |
| 793 | - | Query string `json:"query"` | 881 | + | Query string `json:"query"` |
| 794 | - | Limit int64 `json:"limit"` | 882 | + | Cutoff interface{} `json:"cutoff"` |
| 883 | + | Limit int64 `json:"limit"` | |||
| 795 | } | 884 | } | ||
| 796 | 885 | ||||
| 797 | type SearchHistoryFTSUrlWithDomainBoostRow struct { | 886 | type SearchHistoryFTSUrlWithDomainBoostRow struct { | ||
| @@ -806,7 +895,12 @@ type SearchHistoryFTSUrlWithDomainBoostRow struct { | |||||
| 806 | } | 895 | } | ||
| 807 | 896 | ||||
| 808 | func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) { | 897 | func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) { | ||
| 809 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, arg.Term, arg.Query, arg.Limit) | 898 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrlWithDomainBoost, |
| 899 | + | arg.Term, | |||
| 900 | + | arg.Query, | |||
| 901 | + | arg.Cutoff, | |||
| 902 | + | arg.Limit, | |||
| 903 | + | ) | |||
| 810 | if err != nil { | 904 | if err != nil { | ||
| 811 | return nil, err | 905 | return nil, err | ||
| 812 | } | 906 | } | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +7 -1
| @@ -50,10 +50,17 @@ type Querier interface { | |||
| 50 | 50 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | |
| 51 | 51 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | |
| 52 | 52 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | |
| 53 | + | GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error) | |
| 54 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |
| 55 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |
| 56 | + | GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error) | |
| 53 | 57 | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | |
| 54 | 58 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | |
| 55 | 59 | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | |
| 56 | 60 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | |
| 61 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |
| 62 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |
| 63 | + | GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error) | |
| 57 | 64 | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | |
| 58 | 65 | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | |
| 59 | 66 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| @@ -78,7 +85,6 @@ type Querier interface { | |||
| 78 | 85 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | |
| 79 | 86 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | |
| 80 | 87 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | |
| 81 | - | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | |
| 82 | 88 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | |
| 83 | 89 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | |
| 84 | 90 | SetPermission(ctx context.Context, arg SetPermissionParams) error | |
| @@ -50,10 +50,17 @@ type Querier interface { | |||||
| 50 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | 50 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | ||
| 51 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | 51 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | ||
| 52 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | 52 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 53 | + | GetMostVisitedHistory(ctx context.Context, limit int64) ([]History, error) | |||
| 54 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |||
| 55 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |||
| 56 | + | GetMostVisitedHistorySinceCutoff(ctx context.Context, arg GetMostVisitedHistorySinceCutoffParams) ([]History, error) | |||
| 53 | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | 57 | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | ||
| 54 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | 58 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | ||
| 55 | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | 59 | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | ||
| 56 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | 60 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 61 | + | // Bounded window variant: the direct comparison keeps the last_visited index | |||
| 62 | + | // usable. Rows with a NULL last_visited are intentionally excluded. | |||
| 63 | + | GetRecentHistorySinceCutoff(ctx context.Context, arg GetRecentHistorySinceCutoffParams) ([]History, error) | |||
| 57 | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | 64 | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | ||
| 58 | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | 65 | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | ||
| 59 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | 66 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | ||
| @@ -78,7 +85,6 @@ type Querier interface { | |||||
| 78 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | 85 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | ||
| 79 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | 86 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | ||
| 80 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | 87 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | ||
| 81 | - | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | |||
| 82 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | 88 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | ||
| 83 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | 89 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | ||
| 84 | SetPermission(ctx context.Context, arg SetPermissionParams) error | 90 | SetPermission(ctx context.Context, arg SetPermissionParams) error | ||
M internal/ui/app.go +1 -1
| @@ -1318,7 +1318,7 @@ func buildOmniboxConfig( | |||
| 1318 | 1318 | DefaultSearch: runtimeCfg.DefaultSearchEngine, | |
| 1319 | 1319 | NormalizeNavigationURL: callbacks.NormalizeNavigationURL, | |
| 1320 | 1320 | InitialBehavior: runtimeCfg.Omnibox.InitialBehavior, | |
| 1321 | - | MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays, | |
| 1321 | + | MaxHistoryDays: runtimeCfg.Omnibox.MaxHistoryDays, | |
| 1322 | 1322 | SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior, | |
| 1323 | 1323 | UIScale: runtimeCfg.DefaultUIScale, | |
| 1324 | 1324 | OnNavigate: callbacks.OnNavigate, | |
| @@ -1318,7 +1318,7 @@ func buildOmniboxConfig( | |||||
| 1318 | DefaultSearch: runtimeCfg.DefaultSearchEngine, | 1318 | DefaultSearch: runtimeCfg.DefaultSearchEngine, | ||
| 1319 | NormalizeNavigationURL: callbacks.NormalizeNavigationURL, | 1319 | NormalizeNavigationURL: callbacks.NormalizeNavigationURL, | ||
| 1320 | InitialBehavior: runtimeCfg.Omnibox.InitialBehavior, | 1320 | InitialBehavior: runtimeCfg.Omnibox.InitialBehavior, | ||
| 1321 | - | MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays, | 1321 | + | MaxHistoryDays: runtimeCfg.Omnibox.MaxHistoryDays, |
| 1322 | SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior, | 1322 | SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior, | ||
| 1323 | UIScale: runtimeCfg.DefaultUIScale, | 1323 | UIScale: runtimeCfg.DefaultUIScale, | ||
| 1324 | OnNavigate: callbacks.OnNavigate, | 1324 | OnNavigate: callbacks.OnNavigate, | ||
M internal/ui/app_omnibox_config_test.go +3 -3
| @@ -21,7 +21,7 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) { | |||
| 21 | 21 | Omnibox: entity.RuntimeOmniboxConfig{ | |
| 22 | 22 | Style: "command", | |
| 23 | 23 | InitialBehavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 24 | - | MostVisitedDays: 7, | |
| 24 | + | MaxHistoryDays: 7, | |
| 25 | 25 | }, | |
| 26 | 26 | } | |
| 27 | 27 | ||
| @@ -33,8 +33,8 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) { | |||
| 33 | 33 | if got.Style != "command" { | |
| 34 | 34 | t.Fatalf("Style = %q, want command", got.Style) | |
| 35 | 35 | } | |
| 36 | - | if got.MostVisitedDays != 7 { | |
| 37 | - | t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays) | |
| 36 | + | if got.MaxHistoryDays != 7 { | |
| 37 | + | t.Fatalf("MaxHistoryDays = %d, want 7", got.MaxHistoryDays) | |
| 38 | 38 | } | |
| 39 | 39 | if got.DefaultSearch != "https://search.example/?q=%s" { | |
| 40 | 40 | t.Fatalf("DefaultSearch = %q, want runtime default search", got.DefaultSearch) | |
| @@ -21,7 +21,7 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) { | |||||
| 21 | Omnibox: entity.RuntimeOmniboxConfig{ | 21 | Omnibox: entity.RuntimeOmniboxConfig{ | ||
| 22 | Style: "command", | 22 | Style: "command", | ||
| 23 | InitialBehavior: entity.OmniboxInitialBehaviorMostVisited, | 23 | InitialBehavior: entity.OmniboxInitialBehaviorMostVisited, | ||
| 24 | - | MostVisitedDays: 7, | 24 | + | MaxHistoryDays: 7, |
| 25 | }, | 25 | }, | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| @@ -33,8 +33,8 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) { | |||||
| 33 | if got.Style != "command" { | 33 | if got.Style != "command" { | ||
| 34 | t.Fatalf("Style = %q, want command", got.Style) | 34 | t.Fatalf("Style = %q, want command", got.Style) | ||
| 35 | } | 35 | } | ||
| 36 | - | if got.MostVisitedDays != 7 { | 36 | + | if got.MaxHistoryDays != 7 { |
| 37 | - | t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays) | 37 | + | t.Fatalf("MaxHistoryDays = %d, want 7", got.MaxHistoryDays) |
| 38 | } | 38 | } | ||
| 39 | if got.DefaultSearch != "https://search.example/?q=%s" { | 39 | if got.DefaultSearch != "https://search.example/?q=%s" { | ||
| 40 | t.Fatalf("DefaultSearch = %q, want runtime default search", got.DefaultSearch) | 40 | t.Fatalf("DefaultSearch = %q, want runtime default search", got.DefaultSearch) | ||
M internal/ui/component/omnibox.go +15 -16
| @@ -181,7 +181,7 @@ type Omnibox struct { | |||
| 181 | 181 | defaultSearch string | |
| 182 | 182 | normalizeNavigationURL func(ctx context.Context, input string) string | |
| 183 | 183 | initialBehavior entity.OmniboxInitialBehavior | |
| 184 | - | mostVisitedDays int | |
| 184 | + | maxHistoryDays int | |
| 185 | 185 | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error | |
| 186 | 186 | ctx context.Context | |
| 187 | 187 | ||
| @@ -236,7 +236,7 @@ type OmniboxConfig struct { | |||
| 236 | 236 | // It is injected so local filesystem probing stays outside the domain URL package. | |
| 237 | 237 | NormalizeNavigationURL func(ctx context.Context, input string) string | |
| 238 | 238 | InitialBehavior entity.OmniboxInitialBehavior | |
| 239 | - | MostVisitedDays int | |
| 239 | + | MaxHistoryDays int | |
| 240 | 240 | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | |
| 241 | 241 | UIScale float64 // UI scale for favicon sizing | |
| 242 | 242 | // OnNavigate is called when the user submits a URL; returning nil closes the omnibox. | |
| @@ -270,7 +270,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 270 | 270 | defaultSearch: cfg.DefaultSearch, | |
| 271 | 271 | normalizeNavigationURL: cfg.NormalizeNavigationURL, | |
| 272 | 272 | initialBehavior: cfg.InitialBehavior, | |
| 273 | - | mostVisitedDays: cfg.MostVisitedDays, | |
| 273 | + | maxHistoryDays: cfg.MaxHistoryDays, | |
| 274 | 274 | saveInitialBehaviorFn: cfg.SaveInitialBehavior, | |
| 275 | 275 | onToast: cfg.OnToast, | |
| 276 | 276 | onAccentKeyPress: cfg.OnAccentKeyPress, | |
| @@ -1761,6 +1761,10 @@ func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string { | |||
| 1761 | 1761 | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1762 | 1762 | // query is the search text; limit caps the number of results. | |
| 1763 | 1763 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | |
| 1764 | + | // Capture the age window before spawning so the goroutine never reads | |
| 1765 | + | // mutable widget state. | |
| 1766 | + | maxAgeDays := o.maxHistoryDays | |
| 1767 | + | ||
| 1764 | 1768 | go func() { | |
| 1765 | 1769 | ctx := o.ctx | |
| 1766 | 1770 | log := logging.FromContext(ctx) | |
| @@ -1778,8 +1782,9 @@ func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | |||
| 1778 | 1782 | ||
| 1779 | 1783 | go func() { | |
| 1780 | 1784 | searchInput := usecase.SearchInput{ | |
| 1781 | - | Query: query, | |
| 1782 | - | Limit: limit, | |
| 1785 | + | Query: query, | |
| 1786 | + | Limit: limit, | |
| 1787 | + | MaxAgeDays: maxAgeDays, | |
| 1783 | 1788 | } | |
| 1784 | 1789 | output, err := o.historyUC.Search(ctx, searchInput) | |
| 1785 | 1790 | searchCh <- searchResult{output, err} | |
| @@ -1822,10 +1827,11 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1822 | 1827 | return | |
| 1823 | 1828 | } | |
| 1824 | 1829 | ||
| 1825 | - | // Capture effective result limit on the GTK main thread before spawning goroutine | |
| 1830 | + | // Capture effective result limit and age window on the GTK main thread | |
| 1831 | + | // before spawning goroutine. | |
| 1826 | 1832 | initialLimit := o.effectiveMaxRows() | |
| 1827 | 1833 | initialBehavior := o.initialBehavior | |
| 1828 | - | mostVisitedDays := o.mostVisitedDays | |
| 1834 | + | maxHistoryDays := o.maxHistoryDays | |
| 1829 | 1835 | ||
| 1830 | 1836 | go func() { | |
| 1831 | 1837 | ctx := o.ctx | |
| @@ -1852,16 +1858,9 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1852 | 1858 | err error | |
| 1853 | 1859 | ) | |
| 1854 | 1860 | if initialBehavior == entity.OmniboxInitialBehaviorMostVisited { | |
| 1855 | - | if mostVisitedDays == 0 { | |
| 1856 | - | results, err = o.historyUC.GetMostVisited(ctx, 0) | |
| 1857 | - | } else { | |
| 1858 | - | results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays) | |
| 1859 | - | } | |
| 1860 | - | if err == nil && len(results) > initialLimit { | |
| 1861 | - | results = results[:initialLimit] | |
| 1862 | - | } | |
| 1861 | + | results, err = o.historyUC.GetMostVisitedWithin(ctx, initialLimit, maxHistoryDays) | |
| 1863 | 1862 | } else { | |
| 1864 | - | results, err = o.historyUC.GetRecent(ctx, initialLimit, 0) | |
| 1863 | + | results, err = o.historyUC.GetRecentWithin(ctx, initialLimit, maxHistoryDays) | |
| 1865 | 1864 | } | |
| 1866 | 1865 | historyCh <- historyResult{results, err} | |
| 1867 | 1866 | }() | |
| @@ -181,7 +181,7 @@ type Omnibox struct { | |||||
| 181 | defaultSearch string | 181 | defaultSearch string | ||
| 182 | normalizeNavigationURL func(ctx context.Context, input string) string | 182 | normalizeNavigationURL func(ctx context.Context, input string) string | ||
| 183 | initialBehavior entity.OmniboxInitialBehavior | 183 | initialBehavior entity.OmniboxInitialBehavior | ||
| 184 | - | mostVisitedDays int | 184 | + | maxHistoryDays int |
| 185 | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error | 185 | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error | ||
| 186 | ctx context.Context | 186 | ctx context.Context | ||
| 187 | 187 | ||||
| @@ -236,7 +236,7 @@ type OmniboxConfig struct { | |||||
| 236 | // It is injected so local filesystem probing stays outside the domain URL package. | 236 | // It is injected so local filesystem probing stays outside the domain URL package. | ||
| 237 | NormalizeNavigationURL func(ctx context.Context, input string) string | 237 | NormalizeNavigationURL func(ctx context.Context, input string) string | ||
| 238 | InitialBehavior entity.OmniboxInitialBehavior | 238 | InitialBehavior entity.OmniboxInitialBehavior | ||
| 239 | - | MostVisitedDays int | 239 | + | MaxHistoryDays int |
| 240 | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | 240 | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | ||
| 241 | UIScale float64 // UI scale for favicon sizing | 241 | UIScale float64 // UI scale for favicon sizing | ||
| 242 | // OnNavigate is called when the user submits a URL; returning nil closes the omnibox. | 242 | // OnNavigate is called when the user submits a URL; returning nil closes the omnibox. | ||
| @@ -270,7 +270,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 270 | defaultSearch: cfg.DefaultSearch, | 270 | defaultSearch: cfg.DefaultSearch, | ||
| 271 | normalizeNavigationURL: cfg.NormalizeNavigationURL, | 271 | normalizeNavigationURL: cfg.NormalizeNavigationURL, | ||
| 272 | initialBehavior: cfg.InitialBehavior, | 272 | initialBehavior: cfg.InitialBehavior, | ||
| 273 | - | mostVisitedDays: cfg.MostVisitedDays, | 273 | + | maxHistoryDays: cfg.MaxHistoryDays, |
| 274 | saveInitialBehaviorFn: cfg.SaveInitialBehavior, | 274 | saveInitialBehaviorFn: cfg.SaveInitialBehavior, | ||
| 275 | onToast: cfg.OnToast, | 275 | onToast: cfg.OnToast, | ||
| 276 | onAccentKeyPress: cfg.OnAccentKeyPress, | 276 | onAccentKeyPress: cfg.OnAccentKeyPress, | ||
| @@ -1761,6 +1761,10 @@ func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string { | |||||
| 1761 | // searchHistory runs a fuzzy history search in a background goroutine. | 1761 | // searchHistory runs a fuzzy history search in a background goroutine. | ||
| 1762 | // query is the search text; limit caps the number of results. | 1762 | // query is the search text; limit caps the number of results. | ||
| 1763 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | 1763 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | ||
| 1764 | + | // Capture the age window before spawning so the goroutine never reads | |||
| 1765 | + | // mutable widget state. | |||
| 1766 | + | maxAgeDays := o.maxHistoryDays | |||
| 1767 | + | ||||
| 1764 | go func() { | 1768 | go func() { | ||
| 1765 | ctx := o.ctx | 1769 | ctx := o.ctx | ||
| 1766 | log := logging.FromContext(ctx) | 1770 | log := logging.FromContext(ctx) | ||
| @@ -1778,8 +1782,9 @@ func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | |||||
| 1778 | 1782 | ||||
| 1779 | go func() { | 1783 | go func() { | ||
| 1780 | searchInput := usecase.SearchInput{ | 1784 | searchInput := usecase.SearchInput{ | ||
| 1781 | - | Query: query, | 1785 | + | Query: query, |
| 1782 | - | Limit: limit, | 1786 | + | Limit: limit, |
| 1787 | + | MaxAgeDays: maxAgeDays, | |||
| 1783 | } | 1788 | } | ||
| 1784 | output, err := o.historyUC.Search(ctx, searchInput) | 1789 | output, err := o.historyUC.Search(ctx, searchInput) | ||
| 1785 | searchCh <- searchResult{output, err} | 1790 | searchCh <- searchResult{output, err} | ||
| @@ -1822,10 +1827,11 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||||
| 1822 | return | 1827 | return | ||
| 1823 | } | 1828 | } | ||
| 1824 | 1829 | ||||
| 1825 | - | // Capture effective result limit on the GTK main thread before spawning goroutine | 1830 | + | // Capture effective result limit and age window on the GTK main thread |
| 1831 | + | // before spawning goroutine. | |||
| 1826 | initialLimit := o.effectiveMaxRows() | 1832 | initialLimit := o.effectiveMaxRows() | ||
| 1827 | initialBehavior := o.initialBehavior | 1833 | initialBehavior := o.initialBehavior | ||
| 1828 | - | mostVisitedDays := o.mostVisitedDays | 1834 | + | maxHistoryDays := o.maxHistoryDays |
| 1829 | 1835 | ||||
| 1830 | go func() { | 1836 | go func() { | ||
| 1831 | ctx := o.ctx | 1837 | ctx := o.ctx | ||
| @@ -1852,16 +1858,9 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||||
| 1852 | err error | 1858 | err error | ||
| 1853 | ) | 1859 | ) | ||
| 1854 | if initialBehavior == entity.OmniboxInitialBehaviorMostVisited { | 1860 | if initialBehavior == entity.OmniboxInitialBehaviorMostVisited { | ||
| 1855 | - | if mostVisitedDays == 0 { | 1861 | + | results, err = o.historyUC.GetMostVisitedWithin(ctx, initialLimit, maxHistoryDays) |
| 1856 | - | results, err = o.historyUC.GetMostVisited(ctx, 0) | |||
| 1857 | - | } else { | |||
| 1858 | - | results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays) | |||
| 1859 | - | } | |||
| 1860 | - | if err == nil && len(results) > initialLimit { | |||
| 1861 | - | results = results[:initialLimit] | |||
| 1862 | - | } | |||
| 1863 | } else { | 1862 | } else { | ||
| 1864 | - | results, err = o.historyUC.GetRecent(ctx, initialLimit, 0) | 1863 | + | results, err = o.historyUC.GetRecentWithin(ctx, initialLimit, maxHistoryDays) |
| 1865 | } | 1864 | } | ||
| 1866 | historyCh <- historyResult{results, err} | 1865 | historyCh <- historyResult{results, err} | ||
| 1867 | }() | 1866 | }() | ||
M internal/ui/component/omnibox_initial_behavior_test.go +95 -30
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | appusecase "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | repository "github.com/bnema/dumber/internal/domain/repository" | |
| 11 | 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 12 | 13 | "github.com/stretchr/testify/assert" | |
| 13 | 14 | "github.com/stretchr/testify/mock" | |
| @@ -159,19 +160,40 @@ func omniboxSuggestionUpdatesInline(o *Omnibox) { | |||
| 159 | 160 | o.scheduleSuggestionUpdate = func(fn func()) { fn() } | |
| 160 | 161 | } | |
| 161 | 162 | ||
| 163 | + | // scopeCutoffWithin matches a history scope whose cutoff is approximately the | |
| 164 | + | // given age, tolerating the small delay between test setup and query execution. | |
| 165 | + | func scopeCutoffWithin(want, tolerance time.Duration) any { | |
| 166 | + | return mock.MatchedBy(func(scope repository.HistoryScope) bool { | |
| 167 | + | if scope.Cutoff.IsZero() { | |
| 168 | + | return false | |
| 169 | + | } | |
| 170 | + | diff := time.Since(scope.Cutoff) - want | |
| 171 | + | if diff < 0 { | |
| 172 | + | diff = -diff | |
| 173 | + | } | |
| 174 | + | return diff <= tolerance | |
| 175 | + | }) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // scopeUnbounded matches a history scope with no age restriction. | |
| 179 | + | func scopeUnbounded() any { | |
| 180 | + | return mock.MatchedBy(func(scope repository.HistoryScope) bool { | |
| 181 | + | return scope.Cutoff.IsZero() | |
| 182 | + | }) | |
| 183 | + | } | |
| 184 | + | ||
| 162 | 185 | func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | |
| 163 | 186 | repo := repomocks.NewMockHistoryRepository(t) | |
| 164 | 187 | done := make(chan struct{}) | |
| 165 | - | repo.EXPECT().GetRecent(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 166 | - | func(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |
| 167 | - | close(done) | |
| 168 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | |
| 169 | - | }, | |
| 170 | - | ) | |
| 188 | + | repo.EXPECT(). | |
| 189 | + | GetRecent(mock.Anything, mock.Anything, 0, mock.Anything). | |
| 190 | + | Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }). | |
| 191 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) | |
| 171 | 192 | ||
| 172 | 193 | o := &Omnibox{ | |
| 173 | 194 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 174 | 195 | initialBehavior: entity.OmniboxInitialBehaviorRecent, | |
| 196 | + | maxHistoryDays: 30, | |
| 175 | 197 | ctx: context.Background(), | |
| 176 | 198 | } | |
| 177 | 199 | omniboxSuggestionUpdatesInline(o) | |
| @@ -186,20 +208,18 @@ func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | |||
| 186 | 208 | } | |
| 187 | 209 | } | |
| 188 | 210 | ||
| 189 | - | func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T) { | |
| 211 | + | func TestOmniboxLoadInitialHistory_RecentUsesConfiguredWindow(t *testing.T) { | |
| 190 | 212 | repo := repomocks.NewMockHistoryRepository(t) | |
| 191 | 213 | done := make(chan struct{}) | |
| 192 | - | repo.EXPECT().GetMostVisited(mock.Anything, 30).RunAndReturn( | |
| 193 | - | func(context.Context, int) ([]*entity.HistoryEntry, error) { | |
| 194 | - | close(done) | |
| 195 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | |
| 196 | - | }, | |
| 197 | - | ) | |
| 214 | + | repo.EXPECT(). | |
| 215 | + | GetRecent(mock.Anything, mock.Anything, 0, scopeCutoffWithin(7*24*time.Hour, time.Minute)). | |
| 216 | + | Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }). | |
| 217 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) | |
| 198 | 218 | ||
| 199 | 219 | o := &Omnibox{ | |
| 200 | 220 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 201 | - | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 202 | - | mostVisitedDays: 30, | |
| 221 | + | initialBehavior: entity.OmniboxInitialBehaviorRecent, | |
| 222 | + | maxHistoryDays: 7, | |
| 203 | 223 | ctx: context.Background(), | |
| 204 | 224 | } | |
| 205 | 225 | omniboxSuggestionUpdatesInline(o) | |
| @@ -209,24 +229,22 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T) | |||
| 209 | 229 | select { | |
| 210 | 230 | case <-done: | |
| 211 | 231 | case <-time.After(2 * time.Second): | |
| 212 | - | t.Fatal("timed out waiting for most-visited load") | |
| 232 | + | t.Fatal("timed out waiting for recent-history load") | |
| 213 | 233 | } | |
| 214 | 234 | } | |
| 215 | 235 | ||
| 216 | 236 | func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) { | |
| 217 | 237 | repo := repomocks.NewMockHistoryRepository(t) | |
| 218 | 238 | done := make(chan struct{}) | |
| 219 | - | repo.EXPECT().GetMostVisited(mock.Anything, 7).RunAndReturn( | |
| 220 | - | func(context.Context, int) ([]*entity.HistoryEntry, error) { | |
| 221 | - | close(done) | |
| 222 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | |
| 223 | - | }, | |
| 224 | - | ) | |
| 239 | + | repo.EXPECT(). | |
| 240 | + | GetMostVisitedWithin(mock.Anything, mock.Anything, scopeCutoffWithin(7*24*time.Hour, time.Minute)). | |
| 241 | + | Run(func(context.Context, int, repository.HistoryScope) { close(done) }). | |
| 242 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) | |
| 225 | 243 | ||
| 226 | 244 | o := &Omnibox{ | |
| 227 | 245 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 228 | 246 | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 229 | - | mostVisitedDays: 7, | |
| 247 | + | maxHistoryDays: 7, | |
| 230 | 248 | ctx: context.Background(), | |
| 231 | 249 | } | |
| 232 | 250 | omniboxSuggestionUpdatesInline(o) | |
| @@ -243,17 +261,15 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) | |||
| 243 | 261 | func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) { | |
| 244 | 262 | repo := repomocks.NewMockHistoryRepository(t) | |
| 245 | 263 | done := make(chan struct{}) | |
| 246 | - | repo.EXPECT().GetAllMostVisited(mock.Anything).RunAndReturn( | |
| 247 | - | func(context.Context) ([]*entity.HistoryEntry, error) { | |
| 248 | - | close(done) | |
| 249 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | |
| 250 | - | }, | |
| 251 | - | ) | |
| 264 | + | repo.EXPECT(). | |
| 265 | + | GetMostVisitedWithin(mock.Anything, mock.Anything, scopeUnbounded()). | |
| 266 | + | Run(func(context.Context, int, repository.HistoryScope) { close(done) }). | |
| 267 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) | |
| 252 | 268 | ||
| 253 | 269 | o := &Omnibox{ | |
| 254 | 270 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 255 | 271 | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 256 | - | mostVisitedDays: 0, | |
| 272 | + | maxHistoryDays: 0, | |
| 257 | 273 | ctx: context.Background(), | |
| 258 | 274 | } | |
| 259 | 275 | omniboxSuggestionUpdatesInline(o) | |
| @@ -266,3 +282,52 @@ func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testin | |||
| 266 | 282 | t.Fatal("timed out waiting for all-history most-visited load") | |
| 267 | 283 | } | |
| 268 | 284 | } | |
| 285 | + | ||
| 286 | + | func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) { | |
| 287 | + | repo := repomocks.NewMockHistoryRepository(t) | |
| 288 | + | done := make(chan struct{}) | |
| 289 | + | repo.EXPECT(). | |
| 290 | + | GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxVisibleRows, mock.Anything). | |
| 291 | + | Run(func(context.Context, int, repository.HistoryScope) { close(done) }). | |
| 292 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) | |
| 293 | + | ||
| 294 | + | o := &Omnibox{ | |
| 295 | + | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 296 | + | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | |
| 297 | + | maxHistoryDays: 30, | |
| 298 | + | ctx: context.Background(), | |
| 299 | + | } | |
| 300 | + | omniboxSuggestionUpdatesInline(o) | |
| 301 | + | ||
| 302 | + | o.loadInitialHistory(1) | |
| 303 | + | ||
| 304 | + | select { | |
| 305 | + | case <-done: | |
| 306 | + | case <-time.After(2 * time.Second): | |
| 307 | + | t.Fatal("timed out waiting for bounded most-visited load") | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func TestOmniboxSearchHistory_PassesConfiguredAgeScope(t *testing.T) { | |
| 312 | + | repo := repomocks.NewMockHistoryRepository(t) | |
| 313 | + | done := make(chan struct{}) | |
| 314 | + | repo.EXPECT(). | |
| 315 | + | Search(mock.Anything, "example", 5, scopeCutoffWithin(30*24*time.Hour, time.Minute)). | |
| 316 | + | Run(func(context.Context, string, int, repository.HistoryScope) { close(done) }). | |
| 317 | + | Return([]entity.HistoryMatch{}, nil) | |
| 318 | + | ||
| 319 | + | o := &Omnibox{ | |
| 320 | + | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |
| 321 | + | maxHistoryDays: 30, | |
| 322 | + | ctx: context.Background(), | |
| 323 | + | } | |
| 324 | + | omniboxSuggestionUpdatesInline(o) | |
| 325 | + | ||
| 326 | + | o.searchHistory("example", 5, 1) | |
| 327 | + | ||
| 328 | + | select { | |
| 329 | + | case <-done: | |
| 330 | + | case <-time.After(2 * time.Second): | |
| 331 | + | t.Fatal("timed out waiting for typed history search") | |
| 332 | + | } | |
| 333 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | appusecase "github.com/bnema/dumber/internal/application/usecase" | 9 | appusecase "github.com/bnema/dumber/internal/application/usecase" | ||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | + | repository "github.com/bnema/dumber/internal/domain/repository" | |||
| 11 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | ||
| 12 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| @@ -159,19 +160,40 @@ func omniboxSuggestionUpdatesInline(o *Omnibox) { | |||||
| 159 | o.scheduleSuggestionUpdate = func(fn func()) { fn() } | 160 | o.scheduleSuggestionUpdate = func(fn func()) { fn() } | ||
| 160 | } | 161 | } | ||
| 161 | 162 | ||||
| 163 | + | // scopeCutoffWithin matches a history scope whose cutoff is approximately the | |||
| 164 | + | // given age, tolerating the small delay between test setup and query execution. | |||
| 165 | + | func scopeCutoffWithin(want, tolerance time.Duration) any { | |||
| 166 | + | return mock.MatchedBy(func(scope repository.HistoryScope) bool { | |||
| 167 | + | if scope.Cutoff.IsZero() { | |||
| 168 | + | return false | |||
| 169 | + | } | |||
| 170 | + | diff := time.Since(scope.Cutoff) - want | |||
| 171 | + | if diff < 0 { | |||
| 172 | + | diff = -diff | |||
| 173 | + | } | |||
| 174 | + | return diff <= tolerance | |||
| 175 | + | }) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // scopeUnbounded matches a history scope with no age restriction. | |||
| 179 | + | func scopeUnbounded() any { | |||
| 180 | + | return mock.MatchedBy(func(scope repository.HistoryScope) bool { | |||
| 181 | + | return scope.Cutoff.IsZero() | |||
| 182 | + | }) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 162 | func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | 185 | func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | ||
| 163 | repo := repomocks.NewMockHistoryRepository(t) | 186 | repo := repomocks.NewMockHistoryRepository(t) | ||
| 164 | done := make(chan struct{}) | 187 | done := make(chan struct{}) | ||
| 165 | - | repo.EXPECT().GetRecent(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 188 | + | repo.EXPECT(). |
| 166 | - | func(context.Context, int, int) ([]*entity.HistoryEntry, error) { | 189 | + | GetRecent(mock.Anything, mock.Anything, 0, mock.Anything). |
| 167 | - | close(done) | 190 | + | Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }). |
| 168 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | 191 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) |
| 169 | - | }, | |||
| 170 | - | ) | |||
| 171 | 192 | ||||
| 172 | o := &Omnibox{ | 193 | o := &Omnibox{ | ||
| 173 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | 194 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | ||
| 174 | initialBehavior: entity.OmniboxInitialBehaviorRecent, | 195 | initialBehavior: entity.OmniboxInitialBehaviorRecent, | ||
| 196 | + | maxHistoryDays: 30, | |||
| 175 | ctx: context.Background(), | 197 | ctx: context.Background(), | ||
| 176 | } | 198 | } | ||
| 177 | omniboxSuggestionUpdatesInline(o) | 199 | omniboxSuggestionUpdatesInline(o) | ||
| @@ -186,20 +208,18 @@ func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) { | |||||
| 186 | } | 208 | } | ||
| 187 | } | 209 | } | ||
| 188 | 210 | ||||
| 189 | - | func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T) { | 211 | + | func TestOmniboxLoadInitialHistory_RecentUsesConfiguredWindow(t *testing.T) { |
| 190 | repo := repomocks.NewMockHistoryRepository(t) | 212 | repo := repomocks.NewMockHistoryRepository(t) | ||
| 191 | done := make(chan struct{}) | 213 | done := make(chan struct{}) | ||
| 192 | - | repo.EXPECT().GetMostVisited(mock.Anything, 30).RunAndReturn( | 214 | + | repo.EXPECT(). |
| 193 | - | func(context.Context, int) ([]*entity.HistoryEntry, error) { | 215 | + | GetRecent(mock.Anything, mock.Anything, 0, scopeCutoffWithin(7*24*time.Hour, time.Minute)). |
| 194 | - | close(done) | 216 | + | Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }). |
| 195 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | 217 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) |
| 196 | - | }, | |||
| 197 | - | ) | |||
| 198 | 218 | ||||
| 199 | o := &Omnibox{ | 219 | o := &Omnibox{ | ||
| 200 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | 220 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | ||
| 201 | - | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | 221 | + | initialBehavior: entity.OmniboxInitialBehaviorRecent, |
| 202 | - | mostVisitedDays: 30, | 222 | + | maxHistoryDays: 7, |
| 203 | ctx: context.Background(), | 223 | ctx: context.Background(), | ||
| 204 | } | 224 | } | ||
| 205 | omniboxSuggestionUpdatesInline(o) | 225 | omniboxSuggestionUpdatesInline(o) | ||
| @@ -209,24 +229,22 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesThirtyDayWindow(t *testing.T) | |||||
| 209 | select { | 229 | select { | ||
| 210 | case <-done: | 230 | case <-done: | ||
| 211 | case <-time.After(2 * time.Second): | 231 | case <-time.After(2 * time.Second): | ||
| 212 | - | t.Fatal("timed out waiting for most-visited load") | 232 | + | t.Fatal("timed out waiting for recent-history load") |
| 213 | } | 233 | } | ||
| 214 | } | 234 | } | ||
| 215 | 235 | ||||
| 216 | func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) { | 236 | func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) { | ||
| 217 | repo := repomocks.NewMockHistoryRepository(t) | 237 | repo := repomocks.NewMockHistoryRepository(t) | ||
| 218 | done := make(chan struct{}) | 238 | done := make(chan struct{}) | ||
| 219 | - | repo.EXPECT().GetMostVisited(mock.Anything, 7).RunAndReturn( | 239 | + | repo.EXPECT(). |
| 220 | - | func(context.Context, int) ([]*entity.HistoryEntry, error) { | 240 | + | GetMostVisitedWithin(mock.Anything, mock.Anything, scopeCutoffWithin(7*24*time.Hour, time.Minute)). |
| 221 | - | close(done) | 241 | + | Run(func(context.Context, int, repository.HistoryScope) { close(done) }). |
| 222 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | 242 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) |
| 223 | - | }, | |||
| 224 | - | ) | |||
| 225 | 243 | ||||
| 226 | o := &Omnibox{ | 244 | o := &Omnibox{ | ||
| 227 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | 245 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | ||
| 228 | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | 246 | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | ||
| 229 | - | mostVisitedDays: 7, | 247 | + | maxHistoryDays: 7, |
| 230 | ctx: context.Background(), | 248 | ctx: context.Background(), | ||
| 231 | } | 249 | } | ||
| 232 | omniboxSuggestionUpdatesInline(o) | 250 | omniboxSuggestionUpdatesInline(o) | ||
| @@ -243,17 +261,15 @@ func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) | |||||
| 243 | func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) { | 261 | func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) { | ||
| 244 | repo := repomocks.NewMockHistoryRepository(t) | 262 | repo := repomocks.NewMockHistoryRepository(t) | ||
| 245 | done := make(chan struct{}) | 263 | done := make(chan struct{}) | ||
| 246 | - | repo.EXPECT().GetAllMostVisited(mock.Anything).RunAndReturn( | 264 | + | repo.EXPECT(). |
| 247 | - | func(context.Context) ([]*entity.HistoryEntry, error) { | 265 | + | GetMostVisitedWithin(mock.Anything, mock.Anything, scopeUnbounded()). |
| 248 | - | close(done) | 266 | + | Run(func(context.Context, int, repository.HistoryScope) { close(done) }). |
| 249 | - | return []*entity.HistoryEntry{{URL: "https://example.com"}}, nil | 267 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) |
| 250 | - | }, | |||
| 251 | - | ) | |||
| 252 | 268 | ||||
| 253 | o := &Omnibox{ | 269 | o := &Omnibox{ | ||
| 254 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | 270 | historyUC: appusecase.NewSearchHistoryUseCase(repo), | ||
| 255 | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | 271 | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | ||
| 256 | - | mostVisitedDays: 0, | 272 | + | maxHistoryDays: 0, |
| 257 | ctx: context.Background(), | 273 | ctx: context.Background(), | ||
| 258 | } | 274 | } | ||
| 259 | omniboxSuggestionUpdatesInline(o) | 275 | omniboxSuggestionUpdatesInline(o) | ||
| @@ -266,3 +282,52 @@ func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testin | |||||
| 266 | t.Fatal("timed out waiting for all-history most-visited load") | 282 | t.Fatal("timed out waiting for all-history most-visited load") | ||
| 267 | } | 283 | } | ||
| 268 | } | 284 | } | ||
| 285 | + | ||||
| 286 | + | func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) { | |||
| 287 | + | repo := repomocks.NewMockHistoryRepository(t) | |||
| 288 | + | done := make(chan struct{}) | |||
| 289 | + | repo.EXPECT(). | |||
| 290 | + | GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxVisibleRows, mock.Anything). | |||
| 291 | + | Run(func(context.Context, int, repository.HistoryScope) { close(done) }). | |||
| 292 | + | Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil) | |||
| 293 | + | ||||
| 294 | + | o := &Omnibox{ | |||
| 295 | + | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |||
| 296 | + | initialBehavior: entity.OmniboxInitialBehaviorMostVisited, | |||
| 297 | + | maxHistoryDays: 30, | |||
| 298 | + | ctx: context.Background(), | |||
| 299 | + | } | |||
| 300 | + | omniboxSuggestionUpdatesInline(o) | |||
| 301 | + | ||||
| 302 | + | o.loadInitialHistory(1) | |||
| 303 | + | ||||
| 304 | + | select { | |||
| 305 | + | case <-done: | |||
| 306 | + | case <-time.After(2 * time.Second): | |||
| 307 | + | t.Fatal("timed out waiting for bounded most-visited load") | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func TestOmniboxSearchHistory_PassesConfiguredAgeScope(t *testing.T) { | |||
| 312 | + | repo := repomocks.NewMockHistoryRepository(t) | |||
| 313 | + | done := make(chan struct{}) | |||
| 314 | + | repo.EXPECT(). | |||
| 315 | + | Search(mock.Anything, "example", 5, scopeCutoffWithin(30*24*time.Hour, time.Minute)). | |||
| 316 | + | Run(func(context.Context, string, int, repository.HistoryScope) { close(done) }). | |||
| 317 | + | Return([]entity.HistoryMatch{}, nil) | |||
| 318 | + | ||||
| 319 | + | o := &Omnibox{ | |||
| 320 | + | historyUC: appusecase.NewSearchHistoryUseCase(repo), | |||
| 321 | + | maxHistoryDays: 30, | |||
| 322 | + | ctx: context.Background(), | |||
| 323 | + | } | |||
| 324 | + | omniboxSuggestionUpdatesInline(o) | |||
| 325 | + | ||||
| 326 | + | o.searchHistory("example", 5, 1) | |||
| 327 | + | ||||
| 328 | + | select { | |||
| 329 | + | case <-done: | |||
| 330 | + | case <-time.After(2 * time.Second): | |||
| 331 | + | t.Fatal("timed out waiting for typed history search") | |||
| 332 | + | } | |||
| 333 | + | } | |||