From a279548c89ef266eb013d15e552e215dad0457e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 26 Dec 2021 17:02:14 +0100 Subject: [PATCH] feat: comprehensive summary-level filtering (resolve #262) --- mocks/duration_service.go | 4 +- models/alias.go | 6 + models/compat/shields/v1/badge.go | 12 +- models/compat/wakatime/v1/all_time.go | 11 +- models/compat/wakatime/v1/summaries.go | 3 +- models/filters.go | 170 ++++++++++++++++++++----- models/summary.go | 12 +- models/summary_test.go | 19 +-- routes/api/metrics.go | 6 +- routes/compat/shields/v1/badge.go | 15 ++- routes/compat/wakatime/v1/all_time.go | 23 ++-- routes/compat/wakatime/v1/stats.go | 7 +- routes/compat/wakatime/v1/statusbar.go | 4 +- routes/compat/wakatime/v1/summaries.go | 12 +- routes/utils/summary_utils.go | 25 +++- services/aggregation.go | 2 +- services/alias.go | 65 ++++++---- services/alias_test.go | 9 -- services/duration.go | 6 +- services/duration_test.go | 6 +- services/misc.go | 2 +- services/report.go | 2 +- services/services.go | 8 +- services/summary.go | 46 +++++-- services/summary_test.go | 41 +++--- 25 files changed, 333 insertions(+), 183 deletions(-) diff --git a/mocks/duration_service.go b/mocks/duration_service.go index 7842a5d..a62b1da 100644 --- a/mocks/duration_service.go +++ b/mocks/duration_service.go @@ -10,7 +10,7 @@ type DurationServiceMock struct { mock.Mock } -func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User) (models.Durations, error) { - args := m.Called(time, time2, user) +func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User, f *models.Filters) (models.Durations, error) { + args := m.Called(time, time2, user, f) return args.Get(0).(models.Durations), args.Error(1) } diff --git a/models/alias.go b/models/alias.go index 8649e98..28fbdd5 100644 --- a/models/alias.go +++ b/models/alias.go @@ -1,5 +1,11 @@ package models +// AliasResolver returns the alias of an entity, given its original name. I.e., it returns Alias.Key, given an Alias.Value +type AliasResolver func(t uint8, k string) string + +// AliasReverseResolver returns all original names, which have the given alias as mapping target. I.e., it returns a list of Alias.Value, given an Alias.Key +type AliasReverseResolver func(t uint8, k string) []string + type Alias struct { ID uint `gorm:"primary_key"` Type uint8 `gorm:"not null; index:idx_alias_type_key"` diff --git a/models/compat/shields/v1/badge.go b/models/compat/shields/v1/badge.go index 27fc438..f9055ac 100644 --- a/models/compat/shields/v1/badge.go +++ b/models/compat/shields/v1/badge.go @@ -3,7 +3,6 @@ package v1 import ( "github.com/muety/wakapi/models" "github.com/muety/wakapi/utils" - "time" ) // https://shields.io/endpoint @@ -20,18 +19,11 @@ type BadgeData struct { Color string `json:"color"` } -func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData { - var total time.Duration - if hasFilter, _, _ := filters.One(); hasFilter { - total = summary.TotalTimeByFilters(filters) - } else { - total = summary.TotalTime() - } - +func NewBadgeDataFrom(summary *models.Summary) *BadgeData { return &BadgeData{ SchemaVersion: 1, Label: defaultLabel, - Message: utils.FmtWakatimeDuration(total), + Message: utils.FmtWakatimeDuration(summary.TotalTime()), Color: defaultColor, } } diff --git a/models/compat/wakatime/v1/all_time.go b/models/compat/wakatime/v1/all_time.go index 11704a3..b56eac4 100644 --- a/models/compat/wakatime/v1/all_time.go +++ b/models/compat/wakatime/v1/all_time.go @@ -3,7 +3,6 @@ package v1 import ( "github.com/muety/wakapi/models" "github.com/muety/wakapi/utils" - "time" ) // https://wakatime.com/developers#all_time_since_today @@ -27,14 +26,8 @@ type AllTimeRange struct { Timezone string `json:"timezone"` } -func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel { - var total time.Duration - if key := filters.Project; key != "" { - total = summary.TotalTimeByFilters(filters) - } else { - total = summary.TotalTime() - } - +func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel { + total := summary.TotalTime() return &AllTimeViewModel{ Data: &AllTimeData{ TotalSeconds: float32(total.Seconds()), diff --git a/models/compat/wakatime/v1/summaries.go b/models/compat/wakatime/v1/summaries.go index d668396..5614e8d 100644 --- a/models/compat/wakatime/v1/summaries.go +++ b/models/compat/wakatime/v1/summaries.go @@ -57,8 +57,7 @@ type SummariesRange struct { Timezone string `json:"timezone"` } -func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel { - // TODO: implement filtering (https://github.com/muety/wakapi/issues/58) +func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel { data := make([]*SummariesData, len(summaries)) minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} diff --git a/models/filters.go b/models/filters.go index 7637872..a2ca2c3 100644 --- a/models/filters.go +++ b/models/filters.go @@ -1,50 +1,158 @@ package models +import ( + "fmt" + "github.com/emvi/logbuch" + "github.com/mitchellh/hashstructure/v2" +) + type Filters struct { - Project string - OS string - Language string - Editor string - Machine string - Label string + Project OrFilter + OS OrFilter + Language OrFilter + Editor OrFilter + Machine OrFilter + Label OrFilter +} + +type OrFilter []string + +func (f OrFilter) Exists() bool { + return len(f) > 0 && f[0] != "" +} + +func (f OrFilter) MatchAny(search string) bool { + for _, s := range f { + if s == search { + return true + } + } + return false } type FilterElement struct { - Type uint8 - Key string + entity uint8 + filter OrFilter } func NewFiltersWith(entity uint8, key string) *Filters { - switch entity { - case SummaryProject: - return &Filters{Project: key} - case SummaryOS: - return &Filters{OS: key} - case SummaryLanguage: - return &Filters{Language: key} - case SummaryEditor: - return &Filters{Editor: key} - case SummaryMachine: - return &Filters{Machine: key} - case SummaryLabel: - return &Filters{Label: key} - } - return &Filters{} + return NewFilterWithMultiple(entity, []string{key}) } -func (f *Filters) One() (bool, uint8, string) { - if f.Project != "" { +func NewFilterWithMultiple(entity uint8, keys []string) *Filters { + filters := &Filters{} + return filters.WithMultiple(entity, keys) +} + +func (f *Filters) With(entity uint8, key string) *Filters { + return f.WithMultiple(entity, []string{key}) +} + +func (f *Filters) WithMultiple(entity uint8, keys []string) *Filters { + switch entity { + case SummaryProject: + f.Project = append(f.Project, keys...) + case SummaryOS: + f.OS = append(f.OS, keys...) + case SummaryLanguage: + f.Language = append(f.Language, keys...) + case SummaryEditor: + f.Editor = append(f.Editor, keys...) + case SummaryMachine: + f.Machine = append(f.Machine, keys...) + case SummaryLabel: + f.Label = append(f.Label, keys...) + } + return f +} + +func (f *Filters) One() (bool, uint8, OrFilter) { + if f.Project != nil && f.Project.Exists() { return true, SummaryProject, f.Project - } else if f.OS != "" { + } else if f.OS != nil && f.OS.Exists() { return true, SummaryOS, f.OS - } else if f.Language != "" { + } else if f.Language != nil && f.Language.Exists() { return true, SummaryLanguage, f.Language - } else if f.Editor != "" { + } else if f.Editor != nil && f.Editor.Exists() { return true, SummaryEditor, f.Editor - } else if f.Machine != "" { + } else if f.Machine != nil && f.Machine.Exists() { return true, SummaryMachine, f.Machine - } else if f.Label != "" { + } else if f.Label != nil && f.Label.Exists() { return true, SummaryLabel, f.Label } - return false, 0, "" + return false, 0, OrFilter{} +} + +func (f *Filters) OneOrEmpty() FilterElement { + if ok, t, of := f.One(); ok { + return FilterElement{entity: t, filter: of} + } + return FilterElement{} +} + +func (f *Filters) IsEmpty() bool { + nonEmpty, _, _ := f.One() + return !nonEmpty +} + +func (f *Filters) Hash() string { + hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil) + if err != nil { + logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err) + } + return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported" +} + +func (f *Filters) Match(h *Heartbeat) bool { + // TODO: labels? + return (f.Project == nil || f.Project.MatchAny(h.Project)) && + (f.OS == nil || f.OS.MatchAny(h.OperatingSystem)) && + (f.Language == nil || f.Language.MatchAny(h.Language)) && + (f.Editor == nil || f.Editor.MatchAny(h.Editor)) && + (f.Machine == nil || f.Machine.MatchAny(h.Machine)) +} + +// WithAliases adds OR-conditions for every alias of a filter key as additional filter keys +func (f *Filters) WithAliases(resolver AliasReverseResolver) *Filters { + if f.Project != nil { + updated := OrFilter(make([]string, 0, len(f.Project))) + for _, e := range f.Project { + updated = append(updated, e) + updated = append(updated, resolver(SummaryProject, e)...) + } + f.Project = updated + } + if f.OS != nil { + updated := OrFilter(make([]string, 0, len(f.OS))) + for _, e := range f.OS { + updated = append(updated, e) + updated = append(updated, resolver(SummaryOS, e)...) + } + f.OS = updated + } + if f.Language != nil { + updated := OrFilter(make([]string, 0, len(f.Language))) + for _, e := range f.Language { + updated = append(updated, e) + updated = append(updated, resolver(SummaryLanguage, e)...) + } + f.Language = updated + } + if f.Editor != nil { + updated := OrFilter(make([]string, 0, len(f.Editor))) + for _, e := range f.Editor { + updated = append(updated, e) + updated = append(updated, resolver(SummaryEditor, e)...) + } + f.Editor = updated + } + if f.Machine != nil { + updated := OrFilter(make([]string, 0, len(f.Machine))) + for _, e := range f.Machine { + updated = append(updated, e) + updated = append(updated, resolver(SummaryMachine, e)...) + } + f.Machine = updated + } + return f } diff --git a/models/summary.go b/models/summary.go index f52a12e..9a575c3 100644 --- a/models/summary.go +++ b/models/summary.go @@ -57,8 +57,6 @@ type SummaryParams struct { Recompute bool } -type AliasResolver func(t uint8, k string) string - func SummaryTypes() []uint8 { return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel} } @@ -204,12 +202,12 @@ func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Dur return timeSum } -func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration { - do, typeId, key := filters.One() - if do { - return s.TotalTimeByKey(typeId, key) +func (s *Summary) TotalTimeByFilter(filter FilterElement) time.Duration { + var total time.Duration + for _, f := range filter.filter { + total += s.TotalTimeByKey(filter.entity, f) } - return 0 + return total } func (s *Summary) MaxBy(entityType uint8) *SummaryItem { diff --git a/models/summary_test.go b/models/summary_test.go index 8b04a4d..9a72dbf 100644 --- a/models/summary_test.go +++ b/models/summary_test.go @@ -98,20 +98,13 @@ func TestSummary_TotalTimeByFilters(t *testing.T) { }, } - // Specifying filters about multiple entites is not supported at the moment - // as the current, very rudimentary, time calculation logic wouldn't make sense then. - // Evaluating a filter like (project="wakapi", language="go") can only be realized - // before computing the summary in the first place, because afterwards we can't know - // what time coded in "Go" was in the "Wakapi" project - // See https://github.com/muety/wakapi/issues/108 + filters1 := NewFiltersWith(SummaryProject, "wakapi").OneOrEmpty() + filters2 := NewFiltersWith(SummaryLanguage, "Go").OneOrEmpty() + filters3 := FilterElement{} - filters1 := &Filters{Project: "wakapi"} - filters2 := &Filters{Language: "Go"} - filters3 := &Filters{} - - assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1)) - assert.Equal(t, testDuration3, sut.TotalTimeByFilters(filters2)) - assert.Zero(t, sut.TotalTimeByFilters(filters3)) + assert.Equal(t, testDuration1, sut.TotalTimeByFilter(filters1)) + assert.Equal(t, testDuration3, sut.TotalTimeByFilter(filters2)) + assert.Zero(t, sut.TotalTimeByFilter(filters3)) } func TestSummary_WithResolvedAliases(t *testing.T) { diff --git a/routes/api/metrics.go b/routes/api/metrics.go index fce6499..fc40b27 100644 --- a/routes/api/metrics.go +++ b/routes/api/metrics.go @@ -116,7 +116,7 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) { var metrics mm.Metrics - summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, false) + summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, nil, false) if err != nil { logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID) return nil, err @@ -124,7 +124,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) from, to := utils.MustResolveIntervalRawTZ("today", user.TZ()) - summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false) + summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false) if err != nil { logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID) return nil, err @@ -141,7 +141,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) metrics = append(metrics, &mm.CounterMetric{ Name: MetricsPrefix + "_cumulative_seconds_total", Desc: DescAllTime, - Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds), + Value: int(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds), Labels: []mm.Label{}, }) diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go index 91db649..d71f90d 100644 --- a/routes/compat/shields/v1/badge.go +++ b/routes/compat/shields/v1/badge.go @@ -122,19 +122,19 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) { return } - summary, err, status := h.loadUserSummary(user, interval) + summary, err, status := h.loadUserSummary(user, interval, filters) if err != nil { w.WriteHeader(status) w.Write([]byte(err.Error())) return } - vm := v1.NewBadgeDataFrom(summary, filters) + vm := v1.NewBadgeDataFrom(summary) h.cache.SetDefault(cacheKey, vm) utils.RespondJSON(w, r, http.StatusOK, vm) } -func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) { +func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey, filters *models.Filters) (*models.Summary, error, int) { err, from, to := utils.ResolveIntervalTZ(interval, user.TZ()) if err != nil { return nil, err, http.StatusBadRequest @@ -151,7 +151,14 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.Inter retrieveSummary = h.summarySrvc.Summarize } - summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute) + summary, err := h.summarySrvc.Aliased( + summaryParams.From, + summaryParams.To, + summaryParams.User, + retrieveSummary, + filters, + summaryParams.Recompute, + ) if err != nil { return nil, err, http.StatusInternalServerError } diff --git a/routes/compat/wakatime/v1/all_time.go b/routes/compat/wakatime/v1/all_time.go index 81b0614..e1b3e23 100644 --- a/routes/compat/wakatime/v1/all_time.go +++ b/routes/compat/wakatime/v1/all_time.go @@ -1,10 +1,6 @@ package v1 import ( - "net/http" - "net/url" - "time" - "github.com/gorilla/mux" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/middlewares" @@ -13,6 +9,8 @@ import ( routeutils "github.com/muety/wakapi/routes/utils" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" + "net/http" + "time" ) type AllTimeHandler struct { @@ -47,25 +45,23 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) { // @Success 200 {object} v1.AllTimeViewModel // @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get] func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) { - values, _ := url.ParseQuery(r.URL.RawQuery) - user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current") if err != nil { return // response was already sent by util function } - summary, err, status := h.loadUserSummary(user) + summary, err, status := h.loadUserSummary(user, routeutils.ParseFilters(r)) if err != nil { w.WriteHeader(status) w.Write([]byte(err.Error())) return } - vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project"))) + vm := v1.NewAllTimeFrom(summary) utils.RespondJSON(w, r, http.StatusOK, vm) } -func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { +func (h *AllTimeHandler) loadUserSummary(user *models.User, filters *models.Filters) (*models.Summary, error, int) { summaryParams := &models.SummaryParams{ From: time.Time{}, To: time.Now(), @@ -78,7 +74,14 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er retrieveSummary = h.summarySrvc.Summarize } - summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute) + summary, err := h.summarySrvc.Aliased( + summaryParams.From, + summaryParams.To, + summaryParams.User, + retrieveSummary, + filters, + summaryParams.Recompute, + ) if err != nil { return nil, err, http.StatusInternalServerError } diff --git a/routes/compat/wakatime/v1/stats.go b/routes/compat/wakatime/v1/stats.go index 72292c5..a9e82c5 100644 --- a/routes/compat/wakatime/v1/stats.go +++ b/routes/compat/wakatime/v1/stats.go @@ -9,6 +9,7 @@ import ( "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" v1 "github.com/muety/wakapi/models/compat/wakatime/v1" + routeutils "github.com/muety/wakapi/routes/utils" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" ) @@ -88,7 +89,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { return } - summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo) + summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, routeutils.ParseFilters(r)) if err != nil { w.WriteHeader(status) w.Write([]byte(err.Error())) @@ -117,7 +118,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { utils.RespondJSON(w, r, http.StatusOK, stats) } -func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) { +func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time, filters *models.Filters) (*models.Summary, error, int) { overallParams := &models.SummaryParams{ From: start, To: end, @@ -125,7 +126,7 @@ func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) Recompute: false, } - summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve, false) + summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve, filters, false) if err != nil { return nil, err, http.StatusInternalServerError } diff --git a/routes/compat/wakatime/v1/statusbar.go b/routes/compat/wakatime/v1/statusbar.go index 3facac1..9fade55 100644 --- a/routes/compat/wakatime/v1/statusbar.go +++ b/routes/compat/wakatime/v1/statusbar.go @@ -78,7 +78,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) { w.Write([]byte(err.Error())) return } - summariesView := v1.NewSummariesFrom([]*models.Summary{summary}, &models.Filters{}) + summariesView := v1.NewSummariesFrom([]*models.Summary{summary}) utils.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{ CachedAt: time.Now(), Data: *summariesView.Data[0], @@ -98,7 +98,7 @@ func (h *StatusBarHandler) loadUserSummary(user *models.User, start, end time.Ti retrieveSummary = h.summarySrvc.Summarize } - summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute) + summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, nil, summaryParams.Recompute) if err != nil { return nil, http.StatusInternalServerError, err } diff --git a/routes/compat/wakatime/v1/summaries.go b/routes/compat/wakatime/v1/summaries.go index c0d26b0..c28c596 100644 --- a/routes/compat/wakatime/v1/summaries.go +++ b/routes/compat/wakatime/v1/summaries.go @@ -68,12 +68,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) { return } - filters := &models.Filters{} - if projectQuery := r.URL.Query().Get("project"); projectQuery != "" { - filters.Project = projectQuery - } - - vm := v1.NewSummariesFrom(summaries, filters) + vm := v1.NewSummariesFrom(summaries) utils.RespondJSON(w, r, http.StatusOK, vm) } @@ -130,8 +125,11 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To) summaries := make([]*models.Summary, len(intervals)) + // filtering + filters := routeutils.ParseFilters(r) + for i, interval := range intervals { - summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, end.After(time.Now())) + summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now())) if err != nil { return nil, err, http.StatusInternalServerError } diff --git a/routes/utils/summary_utils.go b/routes/utils/summary_utils.go index 8467229..7922840 100644 --- a/routes/utils/summary_utils.go +++ b/routes/utils/summary_utils.go @@ -20,7 +20,7 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ retrieveSummary = ss.Summarize } - summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute) + summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, ParseFilters(r), summaryParams.Recompute) if err != nil { return nil, err, http.StatusInternalServerError } @@ -30,3 +30,26 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ return summary, nil, http.StatusOK } + +func ParseFilters(r *http.Request) *models.Filters { + filters := &models.Filters{} + if q := r.URL.Query().Get("project"); q != "" { + filters.With(models.SummaryProject, q) + } + if q := r.URL.Query().Get("language"); q != "" { + filters.With(models.SummaryLanguage, q) + } + if q := r.URL.Query().Get("editor"); q != "" { + filters.With(models.SummaryEditor, q) + } + if q := r.URL.Query().Get("machine"); q != "" { + filters.With(models.SummaryMachine, q) + } + if q := r.URL.Query().Get("operating_system"); q != "" { + filters.With(models.SummaryOS, q) + } + if q := r.URL.Query().Get("label"); q != "" { + filters.With(models.SummaryLabel, q) + } + return filters +} diff --git a/services/aggregation.go b/services/aggregation.go index 8965c7d..d400228 100644 --- a/services/aggregation.go +++ b/services/aggregation.go @@ -83,7 +83,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error { func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) { for job := range jobs { - if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil { + if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil { config.Log().Error("failed to generate summary (%v, %v, %s) – %v", job.From, job.To, job.UserID, err) } else { logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID) diff --git a/services/alias.go b/services/alias.go index b9369bf..eddb4b4 100644 --- a/services/alias.go +++ b/services/alias.go @@ -2,6 +2,7 @@ package services import ( "errors" + "fmt" "github.com/emvi/logbuch" "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" @@ -38,35 +39,53 @@ func (srv *AliasService) InitializeUser(userId string) error { return err } -func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) { - aliases, err := srv.repository.GetByUser(userId) - if err != nil { - return nil, err +func (srv *AliasService) MayInitializeUser(userId string) { + if err := srv.InitializeUser(userId); err != nil { + logbuch.Error("failed to initialize user alias map for user %s", userId) + } +} + +func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) { + if !srv.IsInitialized(userId) { + srv.MayInitializeUser(userId) + } + if aliases, ok := userAliases.Load(userId); ok { + return aliases.([]*models.Alias), nil + } else { + return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId)) } - return aliases, nil } func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) { - aliases, err := srv.repository.GetByUserAndKeyAndType(userId, key, summaryType) - if err != nil { - return nil, err + if !srv.IsInitialized(userId) { + srv.MayInitializeUser(userId) + } + if aliases, ok := userAliases.Load(userId); ok { + filteredAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias))) + for _, a := range aliases.([]*models.Alias) { + if a.Key == key && a.Type == summaryType { + filteredAliases = append(filteredAliases, a) + } + } + return filteredAliases, nil + } else { + return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId)) } - return aliases, nil } func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) { if !srv.IsInitialized(userId) { - if err := srv.InitializeUser(userId); err != nil { - return "", err + srv.MayInitializeUser(userId) + } + + if aliases, ok := userAliases.Load(userId); ok { + for _, a := range aliases.([]*models.Alias) { + if a.Type == summaryType && a.Value == value { + return a.Key, nil + } } } - aliases, _ := userAliases.Load(userId) - for _, a := range aliases.([]*models.Alias) { - if a.Type == summaryType && a.Value == value { - return a.Key, nil - } - } return value, nil } @@ -75,7 +94,7 @@ func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) { if err != nil { return nil, err } - go srv.reinitUser(alias.UserID) + go srv.MayInitializeUser(alias.UserID) return result, nil } @@ -84,7 +103,7 @@ func (srv *AliasService) Delete(alias *models.Alias) error { return errors.New("no user id specified") } err := srv.repository.Delete(alias.ID) - go srv.reinitUser(alias.UserID) + go srv.MayInitializeUser(alias.UserID) return err } @@ -102,14 +121,8 @@ func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error { err := srv.repository.DeleteBatch(ids) for k := range affectedUsers { - go srv.reinitUser(k) + go srv.MayInitializeUser(k) } return err } - -func (srv *AliasService) reinitUser(userId string) { - if err := srv.InitializeUser(userId); err != nil { - logbuch.Error("error initializing user aliases – %v", err) - } -} diff --git a/services/alias_test.go b/services/alias_test.go index d01741b..5f76ca6 100644 --- a/services/alias_test.go +++ b/services/alias_test.go @@ -52,12 +52,3 @@ func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() { assert.Equal(suite.T(), "anchr", result3) assert.Nil(suite.T(), err3) } - -func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() { - sut := NewAliasService(suite.AliasRepository) - - result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile") - - assert.Empty(suite.T(), result) - assert.Error(suite.T(), err) -} diff --git a/services/duration.go b/services/duration.go index dc7f9d0..1df6800 100644 --- a/services/duration.go +++ b/services/duration.go @@ -21,7 +21,7 @@ func NewDurationService(heartbeatService IHeartbeatService) *DurationService { return srv } -func (srv *DurationService) Get(from, to time.Time, user *models.User) (models.Durations, error) { +func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *models.Filters) (models.Durations, error) { heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user) if err != nil { return nil, err @@ -34,6 +34,10 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User) (models.D mapping := make(map[string][]*models.Duration) for _, h := range heartbeats { + if filters != nil && !filters.Match(h) { + continue + } + d1 := models.NewDurationFromHeartbeat(h) if list, ok := mapping[d1.GroupHash]; !ok || len(list) < 1 { diff --git a/services/duration_test.go b/services/duration_test.go index 35f783c..7eadc4f 100644 --- a/services/duration_test.go +++ b/services/duration_test.go @@ -126,7 +126,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() { from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil) - durations, err = sut.Get(from, to, suite.TestUser) + durations, err = sut.Get(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.Empty(suite.T(), durations) @@ -135,7 +135,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() { from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil) - durations, err = sut.Get(from, to, suite.TestUser) + durations, err = sut.Get(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.Len(suite.T(), durations, 1) @@ -146,7 +146,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() { from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil) - durations, err = sut.Get(from, to, suite.TestUser) + durations, err = sut.Get(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.Len(suite.T(), durations, 3) diff --git a/services/misc.go b/services/misc.go index 868cdae..e1b03d5 100644 --- a/services/misc.go +++ b/services/misc.go @@ -97,7 +97,7 @@ func (srv *MiscService) runCountTotalTime() error { func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) { for job := range jobs { - if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, false); err != nil { + if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, nil, false); err != nil { config.Log().Error("failed to count total for user %s: %v", job.UserID, err) } else { results <- &CountTotalTimeResult{ diff --git a/services/report.go b/services/report.go index 9eeee17..e28f5c9 100644 --- a/services/report.go +++ b/services/report.go @@ -112,7 +112,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error { end := time.Now().In(user.TZ()) start := time.Now().Add(-1 * duration) - summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, false) + summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false) if err != nil { config.Log().Error("failed to generate report for '%s' – %v", user.ID, err) return err diff --git a/services/services.go b/services/services.go index 4b32f55..e7b5696 100644 --- a/services/services.go +++ b/services/services.go @@ -75,13 +75,13 @@ type IMailService interface { } type IDurationService interface { - Get(time.Time, time.Time, *models.User) (models.Durations, error) + Get(time.Time, time.Time, *models.User, *models.Filters) (models.Durations, error) } type ISummaryService interface { - Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error) - Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error) - Summarize(time.Time, time.Time, *models.User) (*models.Summary, error) + Aliased(time.Time, time.Time, *models.User, SummaryRetriever, *models.Filters, bool) (*models.Summary, error) + Retrieve(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error) + Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error) GetLatestByUser() ([]*models.TimeByUser, error) DeleteByUser(string) error Insert(*models.Summary) error diff --git a/services/summary.go b/services/summary.go index 6e714d8..1a81e38 100644 --- a/services/summary.go +++ b/services/summary.go @@ -24,7 +24,7 @@ type SummaryService struct { projectLabelService IProjectLabelService } -type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error) +type SummaryRetriever func(f, t time.Time, u *models.User, filters *models.Filters) (*models.Summary, error) func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService { srv := &SummaryService{ @@ -55,10 +55,10 @@ func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationServ // Public summary generation methods // Aliased retrieves or computes a new summary based on the given SummaryRetriever and augments it with entity aliases and project labels -func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, skipCache bool) (*models.Summary, error) { +func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, filters *models.Filters, skipCache bool) (*models.Summary, error) { // Check cache - cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased") - if cacheResult, ok := srv.cache.Get(cacheKey); ok && !skipCache && false { + cacheKey := srv.getHash(from.String(), to.String(), user.ID, filters.Hash(), "--aliased") + if cacheResult, ok := srv.cache.Get(cacheKey); ok && !skipCache { return cacheResult.(*models.Summary), nil } @@ -67,6 +67,19 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k) return s } + resolveReverse := func(t uint8, k string) []string { + aliases, _ := srv.aliasService.GetByUserAndKeyAndType(user.ID, k, t) + aliasStrings := make([]string, 0, len(aliases)) + for _, a := range aliases { + aliasStrings = append(aliasStrings, a.Value) + } + return aliasStrings + } + + // Post-process filters + if filters != nil { + filters = filters.WithAliases(resolveReverse) + } // Initialize alias resolver service if err := srv.aliasService.InitializeUser(user.ID); err != nil { @@ -74,7 +87,7 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ } // Get actual summary - s, err := f(from, to, user) + s, err := f(from, to, user, filters) if err != nil { return nil, err } @@ -89,17 +102,24 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ return summary.Sorted(), nil } -func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) { - // Get all already existing, pre-generated summaries that fall into the requested interval - summaries, err := srv.repository.GetByUserWithin(user, from, to) - if err != nil { - return nil, err +func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User, filters *models.Filters) (*models.Summary, error) { + summaries := make([]*models.Summary, 0) + + // Filtered summaries are not persisted currently + if filters == nil || filters.IsEmpty() { + // Get all already existing, pre-generated summaries that fall into the requested interval + result, err := srv.repository.GetByUserWithin(user, from, to) + if err == nil { + summaries = result + } else { + return nil, err + } } // Generate missing slots (especially before and after existing summaries) from durations (formerly raw heartbeats) missingIntervals := srv.getMissingIntervals(from, to, summaries) for _, interval := range missingIntervals { - if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil { + if s, err := srv.Summarize(interval.Start, interval.End, user, filters); err == nil { summaries = append(summaries, s) } else { return nil, err @@ -115,9 +135,9 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod return summary.Sorted(), nil } -func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) { +func (srv *SummaryService) Summarize(from, to time.Time, user *models.User, filters *models.Filters) (*models.Summary, error) { // Initialize and fetch data - durations, err := srv.durationService.Get(from, to, user) + durations, err := srv.durationService.Get(from, to, user, filters) if err != nil { return nil, err } diff --git a/services/summary_test.go b/services/summary_test.go index b7a4da4..76e4031 100644 --- a/services/summary_test.go +++ b/services/summary_test.go @@ -108,9 +108,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { /* TEST 1 */ from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute) - suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil) + suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(filterDurations(from, to, suite.TestDurations), nil) - result, err = sut.Summarize(from, to, suite.TestUser) + result, err = sut.Summarize(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -122,9 +122,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { /* TEST 2 */ from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second) - suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil) + suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(filterDurations(from, to, suite.TestDurations), nil) - result, err = sut.Summarize(from, to, suite.TestUser) + result, err = sut.Summarize(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -136,9 +136,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { /* TEST 3 */ from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) - suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil) + suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(filterDurations(from, to, suite.TestDurations), nil) - result, err = sut.Summarize(from, to, suite.TestUser) + result, err = sut.Summarize(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -187,10 +187,10 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { } suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) - suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(models.Durations{}, nil) - suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return(models.Durations{}, nil) + suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser, mock.Anything).Return(models.Durations{}, nil) + suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser, mock.Anything).Return(models.Durations{}, nil) - result, err = sut.Retrieve(from, to, suite.TestUser) + result, err = sut.Retrieve(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -241,9 +241,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { } suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) - suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(filterDurations(from, summaries[0].FromTime.T(), suite.TestDurations), nil) + suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser, mock.Anything).Return(filterDurations(from, summaries[0].FromTime.T(), suite.TestDurations), nil) - result, err = sut.Retrieve(from, to, suite.TestUser) + result, err = sut.Retrieve(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -297,9 +297,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { } suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) - suite.DurationService.On("Get", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser).Return(filterDurations(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestDurations), nil) + suite.DurationService.On("Get", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser, mock.Anything).Return(filterDurations(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestDurations), nil) - result, err = sut.Retrieve(from, to, suite.TestUser) + result, err = sut.Retrieve(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -347,10 +347,10 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice! suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) - suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(models.Durations{}, nil) - suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return(models.Durations{}, nil) + suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser, mock.Anything).Return(models.Durations{}, nil) + suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser, mock.Anything).Return(models.Durations{}, nil) - result, err = sut.Retrieve(from, to, suite.TestUser) + result, err = sut.Retrieve(from, to, suite.TestUser, nil) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -362,6 +362,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService) + suite.AliasService.On("InitializeUser", suite.TestUser.ID).Return(nil) suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil) var ( @@ -385,14 +386,14 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { Duration: 0, // not relevant here }) - suite.DurationService.On("Get", from, to, suite.TestUser).Return(models.Durations(durations), nil) + suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(models.Durations(durations), nil) suite.AliasService.On("InitializeUser", TestUserId).Return(nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject2, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject2, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil) suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once() - result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false) + result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, nil, false) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) @@ -426,13 +427,13 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() }) suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once() - suite.DurationService.On("Get", from, to, suite.TestUser).Return(models.Durations(durations), nil) + suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(models.Durations(durations), nil) suite.AliasService.On("InitializeUser", TestUserId).Return(nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject1, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject1, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil) - result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false) + result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, nil, false) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result)