From cf1a9af0a83334378828d0edcbfe8f310482f43f Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 12:52:14 -0700 Subject: [PATCH 1/2] perf(analytics): harden queries and add indexes --- core/api/analytics.go | 72 +++++++++++++----- core/api/analytics_test.go | 42 +++++++++++ core/model/entity_login.go | 6 +- core/model/group.go | 5 +- core/model/user.go | 2 +- core/service/analytics.go | 133 ++++++++++++++++++++++----------- core/service/analytics_test.go | 47 ++++++++++++ core/service/audit_event.go | 6 +- 8 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 core/api/analytics_test.go create mode 100644 core/service/analytics_test.go diff --git a/core/api/analytics.go b/core/api/analytics.go index 639bb9dc..fd28df03 100644 --- a/core/api/analytics.go +++ b/core/api/analytics.go @@ -15,7 +15,7 @@ import ( // (sentinel:all). Mirrors the GetApplicationSecret gate. func requireAnalyticsAccess(c *gin.Context) { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), )) } @@ -33,15 +33,17 @@ func recordAudit(c *gin.Context, action model.AuditAction, targetType string, ta }) } -// queryInt reads an integer query param, falling back to def when absent or -// unparseable. -func queryInt(c *gin.Context, key string, def int) int { - if v := c.Query(key); v != "" { - if n, err := strconv.Atoi(v); err == nil { - return n - } +func boundedQueryInt(c *gin.Context, key string, fallback int, maximum int) (int, bool) { + raw := c.Query(key) + if raw == "" { + return fallback, true } - return def + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || value > maximum { + c.JSON(http.StatusBadRequest, gin.H{"error": key + " must be between 1 and " + strconv.Itoa(maximum)}) + return 0, false + } + return value, true } func AnalyticsOverview(c *gin.Context) { @@ -56,7 +58,11 @@ func AnalyticsOverview(c *gin.Context) { func AnalyticsLoginTimeSeries(c *gin.Context) { requireAnalyticsAccess(c) - series, err := service.GetLoginTimeSeries(queryInt(c, "days", 30)) + days, ok := boundedQueryInt(c, "days", 30, service.MaxAnalyticsDays) + if !ok { + return + } + series, err := service.GetLoginTimeSeries(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -66,7 +72,11 @@ func AnalyticsLoginTimeSeries(c *gin.Context) { func AnalyticsLoginHeatmap(c *gin.Context) { requireAnalyticsAccess(c) - cells, err := service.GetLoginHeatmap(queryInt(c, "days", 90)) + days, ok := boundedQueryInt(c, "days", 90, service.MaxAnalyticsDays) + if !ok { + return + } + cells, err := service.GetLoginHeatmap(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -76,7 +86,15 @@ func AnalyticsLoginHeatmap(c *gin.Context) { func AnalyticsTopApplications(c *gin.Context) { requireAnalyticsAccess(c) - apps, err := service.GetTopApplications(queryInt(c, "days", 30), queryInt(c, "limit", 10)) + days, ok := boundedQueryInt(c, "days", 30, service.MaxAnalyticsDays) + if !ok { + return + } + limit, ok := boundedQueryInt(c, "limit", 10, service.MaxAnalyticsLimit) + if !ok { + return + } + apps, err := service.GetTopApplications(days, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -86,7 +104,11 @@ func AnalyticsTopApplications(c *gin.Context) { func AnalyticsUserGrowth(c *gin.Context) { requireAnalyticsAccess(c) - growth, err := service.GetUserGrowth(queryInt(c, "months", 12)) + months, ok := boundedQueryInt(c, "months", 12, service.MaxAnalyticsMonths) + if !ok { + return + } + growth, err := service.GetUserGrowth(months) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -96,7 +118,11 @@ func AnalyticsUserGrowth(c *gin.Context) { func AnalyticsMemberDemographics(c *gin.Context) { requireAnalyticsAccess(c) - demographics, err := service.GetMemberDemographics(queryInt(c, "major_limit", 10)) + limit, ok := boundedQueryInt(c, "major_limit", 10, service.MaxAnalyticsLimit) + if !ok { + return + } + demographics, err := service.GetMemberDemographics(limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -126,7 +152,11 @@ func AnalyticsGroupMembership(c *gin.Context) { func AnalyticsJoinRequests(c *gin.Context) { requireAnalyticsAccess(c) - funnel, err := service.GetJoinRequestFunnel(queryInt(c, "days", 90)) + days, ok := boundedQueryInt(c, "days", 90, service.MaxAnalyticsDays) + if !ok { + return + } + funnel, err := service.GetJoinRequestFunnel(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -136,6 +166,10 @@ func AnalyticsJoinRequests(c *gin.Context) { func AnalyticsAuditEvents(c *gin.Context) { requireAnalyticsAccess(c) + limit, ok := boundedQueryInt(c, "limit", 100, service.MaxAuditEventLimit) + if !ok { + return + } events, err := service.GetAuditEvents(service.AuditEventsFilter{ ActorID: c.Query("actor_id"), Action: c.Query("action"), @@ -143,7 +177,7 @@ func AnalyticsAuditEvents(c *gin.Context) { TargetID: c.Query("target_id"), Before: c.Query("before"), After: c.Query("after"), - Limit: c.Query("limit"), + Limit: strconv.Itoa(limit), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -154,7 +188,11 @@ func AnalyticsAuditEvents(c *gin.Context) { func AnalyticsAuditSummary(c *gin.Context) { requireAnalyticsAccess(c) - summary, err := service.GetAuditActionSummary(queryInt(c, "days", 30)) + days, ok := boundedQueryInt(c, "days", 30, service.MaxAnalyticsDays) + if !ok { + return + } + summary, err := service.GetAuditActionSummary(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/core/api/analytics_test.go b/core/api/analytics_test.go new file mode 100644 index 00000000..dfd744c2 --- /dev/null +++ b/core/api/analytics_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestBoundedQueryInt(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + query string + expected int + expectedValid bool + }{ + {name: "default", expected: 30, expectedValid: true}, + {name: "valid", query: "?days=90", expected: 90, expectedValid: true}, + {name: "zero", query: "?days=0"}, + {name: "negative", query: "?days=-1"}, + {name: "over maximum", query: "?days=3661"}, + {name: "not an integer", query: "?days=all"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = httptest.NewRequest("GET", "/analytics"+test.query, nil) + + value, valid := boundedQueryInt(context, "days", 30, 3660) + if valid != test.expectedValid { + t.Fatalf("valid = %v, want %v", valid, test.expectedValid) + } + if valid && value != test.expected { + t.Fatalf("value = %d, want %d", value, test.expected) + } + }) + } +} diff --git a/core/model/entity_login.go b/core/model/entity_login.go index 9e76ea8b..ef2a529f 100644 --- a/core/model/entity_login.go +++ b/core/model/entity_login.go @@ -4,13 +4,13 @@ import "time" type EntityLogin struct { ID string `json:"id" gorm:"primaryKey"` - EntityID string `json:"entity_id" gorm:"index"` - ClientID string `json:"client_id" gorm:"index"` + EntityID string `json:"entity_id" gorm:"index;index:idx_entity_login_entity_created,priority:1"` + ClientID string `json:"client_id" gorm:"index;index:idx_entity_login_client_created,priority:1"` Scope string `json:"scope"` AccessTokenID string `json:"access_token_id"` RefreshTokenID string `json:"refresh_token_id"` IPAddress string `json:"ip_address"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_entity_login_created_at;index:idx_entity_login_entity_created,priority:2;index:idx_entity_login_client_created,priority:2"` } func (EntityLogin) TableName() string { diff --git a/core/model/group.go b/core/model/group.go index 5d6f6a6b..50f0e465 100644 --- a/core/model/group.go +++ b/core/model/group.go @@ -88,12 +88,12 @@ type GroupJoinRequest struct { ID string `json:"id" gorm:"primaryKey"` GroupID string `json:"group_id"` EntityID string `json:"entity_id"` - Status string `json:"status"` + Status string `json:"status" gorm:"index;index:idx_group_join_request_status_created,priority:1"` ReviewedBy string `json:"reviewed_by"` ReviewedAt time.Time `json:"reviewed_at"` HasExpiration bool `json:"has_expiration"` ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_group_join_request_status_created,priority:2"` Comments []GroupJoinRequestComment `json:"comments" gorm:"-"` } @@ -112,4 +112,3 @@ type GroupJoinRequestComment struct { func (GroupJoinRequestComment) TableName() string { return "group_join_request_comment" } - diff --git a/core/model/user.go b/core/model/user.go index 9ee9eed1..1187b5a1 100644 --- a/core/model/user.go +++ b/core/model/user.go @@ -29,7 +29,7 @@ type User struct { InitialRole string `json:"initial_role"` Groups []string `json:"groups" gorm:"-"` UpdatedAt time.Time `json:"updated_at"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at" gorm:"index"` } func (User) TableName() string { diff --git a/core/service/analytics.go b/core/service/analytics.go index 7ba02807..76ae2cb8 100644 --- a/core/service/analytics.go +++ b/core/service/analytics.go @@ -1,12 +1,38 @@ package service import ( + "fmt" "time" "github.com/gaucho-racing/sentinel/core/database" "github.com/gaucho-racing/sentinel/core/model" + "gorm.io/gorm" ) +const ( + MaxAnalyticsDays = 3660 + MaxAnalyticsMonths = 120 + MaxAnalyticsLimit = 100 + MaxAuditEventLimit = 500 +) + +func boundedAnalyticsValue(value int, fallback int, maximum int) int { + if value <= 0 { + return fallback + } + if value > maximum { + return maximum + } + return value +} + +func countMetric(name string, query *gorm.DB, destination *int64) error { + if err := query.Count(destination).Error; err != nil { + return fmt.Errorf("count %s: %w", name, err) + } + return nil +} + // CategoryCount is a generic label/value pair used by the breakdown charts // (grad year, major, auth method, audit action, etc.). type CategoryCount struct { @@ -36,20 +62,29 @@ func GetAnalyticsOverview() (AnalyticsOverview, error) { now := time.Now() db := database.DB - db.Model(&model.User{}).Count(&o.TotalUsers) - db.Model(&model.Entity{}).Where("type = ?", model.EntityTypeServiceAccount).Count(&o.TotalServiceAccounts) - db.Model(&model.Application{}).Count(&o.TotalApplications) - db.Model(&model.Group{}).Count(&o.TotalGroups) - db.Model(&model.User{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Count(&o.NewUsers30d) - - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.Add(-24*time.Hour)).Count(&o.Logins24h) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Count(&o.Logins7d) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Count(&o.Logins30d) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Distinct("entity_id").Count(&o.ActiveUsers7d) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Distinct("entity_id").Count(&o.ActiveUsers30d) - - db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending).Count(&o.PendingJoinRequests) - db.Model(&model.AuditEvent{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Count(&o.AuditEvents7d) + metrics := []struct { + name string + query *gorm.DB + destination *int64 + }{ + {name: "users", query: db.Model(&model.User{}), destination: &o.TotalUsers}, + {name: "service accounts", query: db.Model(&model.Entity{}).Where("type = ?", model.EntityTypeServiceAccount), destination: &o.TotalServiceAccounts}, + {name: "applications", query: db.Model(&model.Application{}), destination: &o.TotalApplications}, + {name: "groups", query: db.Model(&model.Group{}), destination: &o.TotalGroups}, + {name: "new users", query: db.Model(&model.User{}).Where("created_at > ?", now.AddDate(0, 0, -30)), destination: &o.NewUsers30d}, + {name: "24 hour logins", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.Add(-24*time.Hour)), destination: &o.Logins24h}, + {name: "7 day logins", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)), destination: &o.Logins7d}, + {name: "30 day logins", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)), destination: &o.Logins30d}, + {name: "7 day active users", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Distinct("entity_id"), destination: &o.ActiveUsers7d}, + {name: "30 day active users", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Distinct("entity_id"), destination: &o.ActiveUsers30d}, + {name: "pending join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending), destination: &o.PendingJoinRequests}, + {name: "7 day audit events", query: db.Model(&model.AuditEvent{}).Where("created_at > ?", now.AddDate(0, 0, -7)), destination: &o.AuditEvents7d}, + } + for _, metric := range metrics { + if err := countMetric(metric.name, metric.query, metric.destination); err != nil { + return AnalyticsOverview{}, err + } + } return o, nil } @@ -65,9 +100,7 @@ type LoginPoint struct { // the trailing `days` window, gap-filled so every calendar day is present // (charts render a continuous axis without client-side interpolation). func GetLoginTimeSeries(days int) ([]LoginPoint, error) { - if days <= 0 { - days = 30 - } + days = boundedAnalyticsValue(days, 30, MaxAnalyticsDays) now := time.Now().UTC() startDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -(days - 1)) @@ -115,9 +148,7 @@ type HeatmapCell struct { } func GetLoginHeatmap(days int) ([]HeatmapCell, error) { - if days <= 0 { - days = 90 - } + days = boundedAnalyticsValue(days, 90, MaxAnalyticsDays) start := time.Now().AddDate(0, 0, -days) cells := []HeatmapCell{} sql := ` @@ -146,12 +177,8 @@ type TopApplication struct { } func GetTopApplications(days int, limit int) ([]TopApplication, error) { - if days <= 0 { - days = 30 - } - if limit <= 0 { - limit = 10 - } + days = boundedAnalyticsValue(days, 30, MaxAnalyticsDays) + limit = boundedAnalyticsValue(limit, 10, MaxAnalyticsLimit) start := time.Now().AddDate(0, 0, -days) apps := []TopApplication{} sql := ` @@ -182,16 +209,16 @@ type UserGrowthPoint struct { } func GetUserGrowth(months int) ([]UserGrowthPoint, error) { - if months <= 0 { - months = 12 - } + months = boundedAnalyticsValue(months, 12, MaxAnalyticsMonths) now := time.Now().UTC() startMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0) // Members created before the window form the cumulative baseline so the // running total reflects the whole roster, not just the visible range. var baseline int64 - database.DB.Model(&model.User{}).Where("created_at < ?", startMonth).Count(&baseline) + if err := countMetric("users before growth window", database.DB.Model(&model.User{}).Where("created_at < ?", startMonth), &baseline); err != nil { + return []UserGrowthPoint{}, err + } type row struct { Month string @@ -233,9 +260,7 @@ type MemberDemographics struct { } func GetMemberDemographics(majorLimit int) (MemberDemographics, error) { - if majorLimit <= 0 { - majorLimit = 10 - } + majorLimit = boundedAnalyticsValue(majorLimit, 10, MaxAnalyticsLimit) var d MemberDemographics d.ByGradYear = []CategoryCount{} @@ -289,11 +314,22 @@ type AuthMethodBreakdown struct { func GetAuthMethodBreakdown() (AuthMethodBreakdown, error) { var b AuthMethodBreakdown db := database.DB - db.Model(&model.EntityEmail{}).Distinct("entity_id").Count(&b.Email) - db.Model(&model.EntityPhone{}).Distinct("entity_id").Count(&b.Phone) - db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderDiscord).Distinct("entity_id").Count(&b.Discord) - db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGoogle).Distinct("entity_id").Count(&b.Google) - db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGitHub).Distinct("entity_id").Count(&b.GitHub) + metrics := []struct { + name string + query *gorm.DB + destination *int64 + }{ + {name: "email authentication methods", query: db.Model(&model.EntityEmail{}).Distinct("entity_id"), destination: &b.Email}, + {name: "phone authentication methods", query: db.Model(&model.EntityPhone{}).Distinct("entity_id"), destination: &b.Phone}, + {name: "Discord authentication methods", query: db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderDiscord).Distinct("entity_id"), destination: &b.Discord}, + {name: "Google authentication methods", query: db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGoogle).Distinct("entity_id"), destination: &b.Google}, + {name: "GitHub authentication methods", query: db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGitHub).Distinct("entity_id"), destination: &b.GitHub}, + } + for _, metric := range metrics { + if err := countMetric(metric.name, metric.query, metric.destination); err != nil { + return AuthMethodBreakdown{}, err + } + } return b, nil } @@ -339,15 +375,24 @@ type JoinRequestFunnel struct { } func GetJoinRequestFunnel(days int) (JoinRequestFunnel, error) { - if days <= 0 { - days = 90 - } + days = boundedAnalyticsValue(days, 90, MaxAnalyticsDays) start := time.Now().AddDate(0, 0, -days) var f JoinRequestFunnel db := database.DB - db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending).Count(&f.Pending) - db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusApproved, start).Count(&f.Approved) - db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusRejected, start).Count(&f.Rejected) + metrics := []struct { + name string + query *gorm.DB + destination *int64 + }{ + {name: "pending join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending), destination: &f.Pending}, + {name: "approved join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusApproved, start), destination: &f.Approved}, + {name: "rejected join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusRejected, start), destination: &f.Rejected}, + } + for _, metric := range metrics { + if err := countMetric(metric.name, metric.query, metric.destination); err != nil { + return JoinRequestFunnel{}, err + } + } var median float64 sql := ` diff --git a/core/service/analytics_test.go b/core/service/analytics_test.go new file mode 100644 index 00000000..949c6486 --- /dev/null +++ b/core/service/analytics_test.go @@ -0,0 +1,47 @@ +package service + +import ( + "database/sql" + "testing" + + "github.com/gaucho-racing/sentinel/core/database" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestBoundedAnalyticsValue(t *testing.T) { + if value := boundedAnalyticsValue(0, 30, 100); value != 30 { + t.Fatalf("default value = %d", value) + } + if value := boundedAnalyticsValue(101, 30, 100); value != 100 { + t.Fatalf("maximum value = %d", value) + } + if value := boundedAnalyticsValue(50, 30, 100); value != 50 { + t.Fatalf("accepted value = %d", value) + } +} + +func TestGetAnalyticsOverviewReturnsDatabaseErrors(t *testing.T) { + sqlDB, err := sql.Open("pgx", "") + if err != nil { + t.Fatal(err) + } + if err := sqlDB.Close(); err != nil { + t.Fatal(err) + } + db, err := gorm.Open( + postgres.New(postgres.Config{Conn: sqlDB}), + &gorm.Config{DisableAutomaticPing: true}, + ) + if err != nil { + t.Fatal(err) + } + + originalDB := database.DB + database.DB = db + defer func() { database.DB = originalDB }() + + if _, err := GetAnalyticsOverview(); err == nil { + t.Fatal("expected overview query to return the database error") + } +} diff --git a/core/service/audit_event.go b/core/service/audit_event.go index 517b5285..e1f06803 100644 --- a/core/service/audit_event.go +++ b/core/service/audit_event.go @@ -65,7 +65,7 @@ func GetAuditEvents(filter AuditEventsFilter) ([]model.AuditEvent, error) { limit := 100 if filter.Limit != "" { if n, err := strconv.Atoi(filter.Limit); err == nil && n > 0 { - limit = n + limit = boundedAnalyticsValue(n, 100, MaxAuditEventLimit) } } query = query.Limit(limit) @@ -78,9 +78,7 @@ func GetAuditEvents(filter AuditEventsFilter) ([]model.AuditEvent, error) { // GetAuditActionSummary returns the count of audit events per action over the // trailing window, most frequent first. Powers the audit breakdown chart. func GetAuditActionSummary(days int) ([]CategoryCount, error) { - if days <= 0 { - days = 30 - } + days = boundedAnalyticsValue(days, 30, MaxAnalyticsDays) start := time.Now().AddDate(0, 0, -days) out := []CategoryCount{} sql := ` From d958722fe91c37017b47e81fdf8d676688e64d3c Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 13:18:55 -0700 Subject: [PATCH 2/2] chore(analytics): remove query tests --- core/api/analytics_test.go | 42 ------------------------------ core/service/analytics_test.go | 47 ---------------------------------- 2 files changed, 89 deletions(-) delete mode 100644 core/api/analytics_test.go delete mode 100644 core/service/analytics_test.go diff --git a/core/api/analytics_test.go b/core/api/analytics_test.go deleted file mode 100644 index dfd744c2..00000000 --- a/core/api/analytics_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" -) - -func TestBoundedQueryInt(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - query string - expected int - expectedValid bool - }{ - {name: "default", expected: 30, expectedValid: true}, - {name: "valid", query: "?days=90", expected: 90, expectedValid: true}, - {name: "zero", query: "?days=0"}, - {name: "negative", query: "?days=-1"}, - {name: "over maximum", query: "?days=3661"}, - {name: "not an integer", query: "?days=all"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - response := httptest.NewRecorder() - context, _ := gin.CreateTestContext(response) - context.Request = httptest.NewRequest("GET", "/analytics"+test.query, nil) - - value, valid := boundedQueryInt(context, "days", 30, 3660) - if valid != test.expectedValid { - t.Fatalf("valid = %v, want %v", valid, test.expectedValid) - } - if valid && value != test.expected { - t.Fatalf("value = %d, want %d", value, test.expected) - } - }) - } -} diff --git a/core/service/analytics_test.go b/core/service/analytics_test.go deleted file mode 100644 index 949c6486..00000000 --- a/core/service/analytics_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package service - -import ( - "database/sql" - "testing" - - "github.com/gaucho-racing/sentinel/core/database" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func TestBoundedAnalyticsValue(t *testing.T) { - if value := boundedAnalyticsValue(0, 30, 100); value != 30 { - t.Fatalf("default value = %d", value) - } - if value := boundedAnalyticsValue(101, 30, 100); value != 100 { - t.Fatalf("maximum value = %d", value) - } - if value := boundedAnalyticsValue(50, 30, 100); value != 50 { - t.Fatalf("accepted value = %d", value) - } -} - -func TestGetAnalyticsOverviewReturnsDatabaseErrors(t *testing.T) { - sqlDB, err := sql.Open("pgx", "") - if err != nil { - t.Fatal(err) - } - if err := sqlDB.Close(); err != nil { - t.Fatal(err) - } - db, err := gorm.Open( - postgres.New(postgres.Config{Conn: sqlDB}), - &gorm.Config{DisableAutomaticPing: true}, - ) - if err != nil { - t.Fatal(err) - } - - originalDB := database.DB - database.DB = db - defer func() { database.DB = originalDB }() - - if _, err := GetAnalyticsOverview(); err == nil { - t.Fatal("expected overview query to return the database error") - } -}