package analytics import ( "context" "testing" "github.com/0xJacky/Nginx-UI/internal/nginx_log/searcher" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestService_GetDashboardAnalytics_HourlyStats(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher) ctx := context.Background() req := &DashboardQueryRequest{ StartTime: 1640995200, // 2022-01-01 00:00:00 UTC EndTime: 1641006000, // 2022-01-01 03:00:00 UTC (same day as test data) LogPaths: []string{"/var/log/nginx/access.log"}, } expectedResult := &searcher.SearchResult{ TotalHits: 3, Hits: []*searcher.SearchHit{ { Fields: map[string]interface{}{ "timestamp": float64(1640995800), // 2022-01-01 00:10:00 "ip": "192.168.1.1", "bytes": int64(1024), }, }, { Fields: map[string]interface{}{ "timestamp": float64(1640999400), // 2022-01-01 01:10:00 "ip": "192.168.1.2", "bytes": int64(2048), }, }, { Fields: map[string]interface{}{ "timestamp": float64(1640999500), // 2022-01-01 01:11:40 "ip": "192.168.1.1", // Same IP as first hit "bytes": int64(512), }, }, }, Facets: map[string]*searcher.Facet{ "ip": { Terms: []*searcher.FacetTerm{ {Term: "192.168.1.1", Count: 2}, {Term: "192.168.1.2", Count: 1}, }, }, }, } mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil) result, err := s.GetDashboardAnalytics(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.NotEmpty(t, result.HourlyStats) // Check that we have some hourly data - the specific hours depend on the test data timestamps var totalPV, totalUV int for _, stat := range result.HourlyStats { totalPV += stat.PV totalUV += stat.UV } // We should have some aggregated data assert.Greater(t, totalPV, 0) assert.Greater(t, totalUV, 0) mockSearcher.AssertExpectations(t) } // Duplicate test functions removed - they exist in dashboard_test.go func TestService_calculateHourlyStats_HourlyInterval(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) result := &searcher.SearchResult{ TotalHits: 2, Hits: []*searcher.SearchHit{ { Fields: map[string]interface{}{ "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC "ip": "192.168.1.1", }, }, { Fields: map[string]interface{}{ "timestamp": float64(1641002400), // 2022-01-01 02:00:00 UTC "ip": "192.168.1.2", }, }, }, } startTime := int64(1640995200) // 2022-01-01 00:00:00 UTC endTime := int64(1641002400) // 2022-01-01 02:00:00 UTC stats := s.calculateHourlyStats(result, startTime, endTime) assert.NotNil(t, stats) assert.GreaterOrEqual(t, len(stats), 2) // Should have at least 2 hours // Check that stats are sorted by timestamp (not just hour, since we have 48 hours of data) for i := 1; i < len(stats); i++ { assert.LessOrEqual(t, stats[i-1].Timestamp, stats[i].Timestamp) } } func TestService_calculateDailyStats_DailyInterval(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) result := &searcher.SearchResult{ TotalHits: 2, Hits: []*searcher.SearchHit{ { Fields: map[string]interface{}{ "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC "ip": "192.168.1.1", }, }, { Fields: map[string]interface{}{ "timestamp": float64(1641168000), // 2022-01-03 00:00:00 UTC "ip": "192.168.1.2", }, }, }, } startTime := int64(1640995200) // 2022-01-01 00:00:00 UTC endTime := int64(1641168000) // 2022-01-03 00:00:00 UTC stats := s.calculateDailyStats(result, startTime, endTime) assert.NotNil(t, stats) assert.GreaterOrEqual(t, len(stats), 2) // Should have at least 2 days // Check that stats are sorted by timestamp for i := 1; i < len(stats); i++ { assert.LessOrEqual(t, stats[i-1].Timestamp, stats[i].Timestamp) } } func TestService_calculateDashboardSummary_MonthlyData(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) analytics := &DashboardAnalytics{ HourlyStats: []HourlyAccessStats{ {Hour: 0, UV: 10, PV: 100}, {Hour: 1, UV: 20, PV: 200}, {Hour: 2, UV: 15, PV: 150}, }, DailyStats: []DailyAccessStats{ {Date: "2022-01-01", UV: 30, PV: 300, Timestamp: 1640995200}, {Date: "2022-01-02", UV: 25, PV: 250, Timestamp: 1641081600}, {Date: "2022-01-03", UV: 28, PV: 280, Timestamp: 1641168000}, }, } result := &searcher.SearchResult{ TotalHits: 830, Facets: map[string]*searcher.Facet{ "ip": { Total: 50, // 50 unique IPs }, }, } summary := s.calculateDashboardSummary(analytics, result) assert.Equal(t, 50, summary.TotalUV) assert.Equal(t, 830, summary.TotalPV) assert.InDelta(t, 16.67, summary.AvgDailyUV, 0.01) // 50 total UV / 3 days assert.InDelta(t, 276.67, summary.AvgDailyPV, 0.01) // (300+250+280)/3 // Peak hour should be hour 1 with 200 PV assert.Equal(t, 1, summary.PeakHour) assert.Equal(t, 200, summary.PeakHourTraffic) } func TestService_calculateTopFieldStats_Generic(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } facet := &searcher.Facet{ Terms: []*searcher.FacetTerm{ {Term: "/api/users", Count: 100}, {Term: "/api/posts", Count: 50}, {Term: "/", Count: 25}, }, } totalHits := 200 result := calculateTopFieldStats(facet, totalHits, func(term string, count int, percent float64) URLAccessStats { return URLAccessStats{URL: term, Visits: count, Percent: percent} }) assert.NotNil(t, result) assert.Len(t, result, 3) // Check first item assert.Equal(t, "/api/users", result[0].URL) assert.Equal(t, 100, result[0].Visits) assert.Equal(t, 50.0, result[0].Percent) // 100/200 * 100 // Check second item assert.Equal(t, "/api/posts", result[1].URL) assert.Equal(t, 50, result[1].Visits) assert.Equal(t, 25.0, result[1].Percent) // 50/200 * 100 } func TestCalculateTopFieldStats_EmptyFacet(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } result := calculateTopFieldStats[URLAccessStats](nil, 100, func(term string, count int, percent float64) URLAccessStats { return URLAccessStats{URL: term, Visits: count, Percent: percent} }) assert.NotNil(t, result) assert.Len(t, result, 0) } func TestCalculateTopFieldStats_ZeroHits(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } facet := &searcher.Facet{ Terms: []*searcher.FacetTerm{ {Term: "/api/users", Count: 100}, }, } result := calculateTopFieldStats(facet, 0, func(term string, count int, percent float64) URLAccessStats { return URLAccessStats{URL: term, Visits: count, Percent: percent} }) assert.NotNil(t, result) assert.Len(t, result, 0) } func TestService_ValidateTimeRange_Comprehensive(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher) tests := []struct { name string startTime int64 endTime int64 expected bool }{ {"valid range", 1000, 2000, true}, {"invalid range - same", 1000, 1000, false}, {"invalid range - backwards", 2000, 1000, false}, {"zero range", 0, 0, true}, {"negative start", -1000, 2000, false}, {"negative end", 1000, -2000, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := s.ValidateTimeRange(tt.startTime, tt.endTime) if tt.expected { assert.NoError(t, err) } else { assert.Error(t, err) } }) } } func TestGetTopKeyValuesFromMap(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } counts := map[string]int{ "200": 2, "404": 1, "500": 3, } result := getTopKeyValuesFromMap(counts, 10) // Set a reasonable limit assert.NotNil(t, result) assert.Len(t, result, 3) // Should be sorted by value descending assert.Equal(t, "500", result[0].Key) assert.Equal(t, 3, result[0].Value) assert.Equal(t, "200", result[1].Key) assert.Equal(t, 2, result[1].Value) assert.Equal(t, "404", result[2].Key) assert.Equal(t, 1, result[2].Value) } func TestGetTopKeyValuesFromMap_WithLimit(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } counts := map[string]int{ "a": 1, "b": 2, "c": 3, } result := getTopKeyValuesFromMap(counts, 2) assert.NotNil(t, result) assert.Len(t, result, 2) // Should be limited to 2 // Should be sorted by value descending assert.Equal(t, "c", result[0].Key) assert.Equal(t, 3, result[0].Value) assert.Equal(t, "b", result[1].Key) assert.Equal(t, 2, result[1].Value) } func TestService_calculateBrowserStats_FromFacets(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } result := &searcher.SearchResult{ TotalHits: 1000, Facets: map[string]*searcher.Facet{ "browser": { Terms: []*searcher.FacetTerm{ {Term: "Chrome", Count: 600}, {Term: "Firefox", Count: 300}, {Term: "Safari", Count: 100}, }, }, }, } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) stats := s.calculateBrowserStats(result) assert.NotNil(t, stats) assert.Len(t, stats, 3) // Check sorting and calculations assert.Equal(t, "Chrome", stats[0].Browser) assert.Equal(t, 600, stats[0].Count) assert.Equal(t, 60.0, stats[0].Percent) // 600/1000 * 100 assert.Equal(t, "Firefox", stats[1].Browser) assert.Equal(t, 300, stats[1].Count) assert.Equal(t, 30.0, stats[1].Percent) // 300/1000 * 100 } func TestService_calculateOSStats_FromFacets(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } result := &searcher.SearchResult{ TotalHits: 800, Facets: map[string]*searcher.Facet{ "os": { Terms: []*searcher.FacetTerm{ {Term: "Windows", Count: 400}, {Term: "macOS", Count: 250}, {Term: "Linux", Count: 150}, }, }, }, } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) stats := s.calculateOSStats(result) assert.NotNil(t, stats) assert.Len(t, stats, 3) // Check sorting and calculations assert.Equal(t, "Windows", stats[0].OS) assert.Equal(t, 400, stats[0].Count) assert.Equal(t, 50.0, stats[0].Percent) // 400/800 * 100 assert.Equal(t, "macOS", stats[1].OS) assert.Equal(t, 250, stats[1].Count) assert.Equal(t, 31.25, stats[1].Percent) // 250/800 * 100 } func TestService_GetVisitorsByCountry_Success(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) ctx := context.Background() req := &VisitorsByCountryRequest{ StartTime: 1000, EndTime: 2000, LogPaths: []string{"/var/log/nginx/access.log"}, } expectedResult := &searcher.SearchResult{ Facets: map[string]*searcher.Facet{ "region_code": { Terms: []*searcher.FacetTerm{ {Term: "US", Count: 100}, {Term: "CN", Count: 50}, }, }, }, } mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil) result, err := s.GetVisitorsByCountry(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, 100, result.Data["US"]) assert.Equal(t, 50, result.Data["CN"]) } func TestService_GetErrorDistribution_Success(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode") } mockSearcher := &MockSearcher{} s := NewService(mockSearcher).(*service) ctx := context.Background() req := &ErrorDistributionRequest{ StartTime: 1000, EndTime: 2000, LogPaths: []string{"/var/log/nginx/access.log"}, } expectedResult := &searcher.SearchResult{ Facets: map[string]*searcher.Facet{ "status": { Terms: []*searcher.FacetTerm{ {Term: "404", Count: 20}, {Term: "500", Count: 5}, }, }, }, } mockSearcher.On("Search", ctx, mock.MatchedBy(func(r *searcher.SearchRequest) bool { return r.Query == "status:[400 TO 599]" })).Return(expectedResult, nil) result, err := s.GetErrorDistribution(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, 20, result.Data["404"]) assert.Equal(t, 5, result.Data["500"]) }