1
0

calculations_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. package analytics
  2. import (
  3. "context"
  4. "testing"
  5. "github.com/0xJacky/Nginx-UI/internal/nginx_log/searcher"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/mock"
  8. )
  9. func TestService_GetDashboardAnalytics_HourlyStats(t *testing.T) {
  10. if testing.Short() {
  11. t.Skip("skipping test in short mode")
  12. }
  13. mockSearcher := &MockSearcher{}
  14. s := NewService(mockSearcher)
  15. ctx := context.Background()
  16. req := &DashboardQueryRequest{
  17. StartTime: 1640995200, // 2022-01-01 00:00:00 UTC
  18. EndTime: 1641006000, // 2022-01-01 03:00:00 UTC (same day as test data)
  19. LogPaths: []string{"/var/log/nginx/access.log"},
  20. }
  21. expectedResult := &searcher.SearchResult{
  22. TotalHits: 3,
  23. Hits: []*searcher.SearchHit{
  24. {
  25. Fields: map[string]interface{}{
  26. "timestamp": float64(1640995800), // 2022-01-01 00:10:00
  27. "ip": "192.168.1.1",
  28. "bytes": int64(1024),
  29. },
  30. },
  31. {
  32. Fields: map[string]interface{}{
  33. "timestamp": float64(1640999400), // 2022-01-01 01:10:00
  34. "ip": "192.168.1.2",
  35. "bytes": int64(2048),
  36. },
  37. },
  38. {
  39. Fields: map[string]interface{}{
  40. "timestamp": float64(1640999500), // 2022-01-01 01:11:40
  41. "ip": "192.168.1.1", // Same IP as first hit
  42. "bytes": int64(512),
  43. },
  44. },
  45. },
  46. Facets: map[string]*searcher.Facet{
  47. "ip": {
  48. Terms: []*searcher.FacetTerm{
  49. {Term: "192.168.1.1", Count: 2},
  50. {Term: "192.168.1.2", Count: 1},
  51. },
  52. },
  53. },
  54. }
  55. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  56. result, err := s.GetDashboardAnalytics(ctx, req)
  57. assert.NoError(t, err)
  58. assert.NotNil(t, result)
  59. assert.NotEmpty(t, result.HourlyStats)
  60. // Check that we have some hourly data - the specific hours depend on the test data timestamps
  61. var totalPV, totalUV int
  62. for _, stat := range result.HourlyStats {
  63. totalPV += stat.PV
  64. totalUV += stat.UV
  65. }
  66. // We should have some aggregated data
  67. assert.Greater(t, totalPV, 0)
  68. assert.Greater(t, totalUV, 0)
  69. mockSearcher.AssertExpectations(t)
  70. }
  71. // Duplicate test functions removed - they exist in dashboard_test.go
  72. func TestService_calculateHourlyStats_HourlyInterval(t *testing.T) {
  73. if testing.Short() {
  74. t.Skip("skipping test in short mode")
  75. }
  76. mockSearcher := &MockSearcher{}
  77. s := NewService(mockSearcher).(*service)
  78. result := &searcher.SearchResult{
  79. TotalHits: 2,
  80. Hits: []*searcher.SearchHit{
  81. {
  82. Fields: map[string]interface{}{
  83. "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC
  84. "ip": "192.168.1.1",
  85. },
  86. },
  87. {
  88. Fields: map[string]interface{}{
  89. "timestamp": float64(1641002400), // 2022-01-01 02:00:00 UTC
  90. "ip": "192.168.1.2",
  91. },
  92. },
  93. },
  94. }
  95. startTime := int64(1640995200) // 2022-01-01 00:00:00 UTC
  96. endTime := int64(1641002400) // 2022-01-01 02:00:00 UTC
  97. stats := s.calculateHourlyStats(result, startTime, endTime)
  98. assert.NotNil(t, stats)
  99. assert.GreaterOrEqual(t, len(stats), 2) // Should have at least 2 hours
  100. // Check that stats are sorted by timestamp (not just hour, since we have 48 hours of data)
  101. for i := 1; i < len(stats); i++ {
  102. assert.LessOrEqual(t, stats[i-1].Timestamp, stats[i].Timestamp)
  103. }
  104. }
  105. func TestService_calculateDailyStats_DailyInterval(t *testing.T) {
  106. if testing.Short() {
  107. t.Skip("skipping test in short mode")
  108. }
  109. mockSearcher := &MockSearcher{}
  110. s := NewService(mockSearcher).(*service)
  111. result := &searcher.SearchResult{
  112. TotalHits: 2,
  113. Hits: []*searcher.SearchHit{
  114. {
  115. Fields: map[string]interface{}{
  116. "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC
  117. "ip": "192.168.1.1",
  118. },
  119. },
  120. {
  121. Fields: map[string]interface{}{
  122. "timestamp": float64(1641168000), // 2022-01-03 00:00:00 UTC
  123. "ip": "192.168.1.2",
  124. },
  125. },
  126. },
  127. }
  128. startTime := int64(1640995200) // 2022-01-01 00:00:00 UTC
  129. endTime := int64(1641168000) // 2022-01-03 00:00:00 UTC
  130. stats := s.calculateDailyStats(result, startTime, endTime)
  131. assert.NotNil(t, stats)
  132. assert.GreaterOrEqual(t, len(stats), 2) // Should have at least 2 days
  133. // Check that stats are sorted by timestamp
  134. for i := 1; i < len(stats); i++ {
  135. assert.LessOrEqual(t, stats[i-1].Timestamp, stats[i].Timestamp)
  136. }
  137. }
  138. func TestService_calculateDashboardSummary_MonthlyData(t *testing.T) {
  139. if testing.Short() {
  140. t.Skip("skipping test in short mode")
  141. }
  142. mockSearcher := &MockSearcher{}
  143. s := NewService(mockSearcher).(*service)
  144. analytics := &DashboardAnalytics{
  145. HourlyStats: []HourlyAccessStats{
  146. {Hour: 0, UV: 10, PV: 100},
  147. {Hour: 1, UV: 20, PV: 200},
  148. {Hour: 2, UV: 15, PV: 150},
  149. },
  150. DailyStats: []DailyAccessStats{
  151. {Date: "2022-01-01", UV: 30, PV: 300, Timestamp: 1640995200},
  152. {Date: "2022-01-02", UV: 25, PV: 250, Timestamp: 1641081600},
  153. {Date: "2022-01-03", UV: 28, PV: 280, Timestamp: 1641168000},
  154. },
  155. }
  156. result := &searcher.SearchResult{
  157. TotalHits: 830,
  158. Facets: map[string]*searcher.Facet{
  159. "ip": {
  160. Total: 50, // 50 unique IPs
  161. },
  162. },
  163. }
  164. summary := s.calculateDashboardSummary(analytics, result)
  165. assert.Equal(t, 50, summary.TotalUV)
  166. assert.Equal(t, 830, summary.TotalPV)
  167. assert.InDelta(t, 16.67, summary.AvgDailyUV, 0.01) // 50 total UV / 3 days
  168. assert.InDelta(t, 276.67, summary.AvgDailyPV, 0.01) // (300+250+280)/3
  169. // Peak hour should be hour 1 with 200 PV
  170. assert.Equal(t, 1, summary.PeakHour)
  171. assert.Equal(t, 200, summary.PeakHourTraffic)
  172. }
  173. func TestService_calculateTopFieldStats_Generic(t *testing.T) {
  174. if testing.Short() {
  175. t.Skip("skipping test in short mode")
  176. }
  177. facet := &searcher.Facet{
  178. Terms: []*searcher.FacetTerm{
  179. {Term: "/api/users", Count: 100},
  180. {Term: "/api/posts", Count: 50},
  181. {Term: "/", Count: 25},
  182. },
  183. }
  184. totalHits := 200
  185. result := calculateTopFieldStats(facet, totalHits, func(term string, count int, percent float64) URLAccessStats {
  186. return URLAccessStats{URL: term, Visits: count, Percent: percent}
  187. })
  188. assert.NotNil(t, result)
  189. assert.Len(t, result, 3)
  190. // Check first item
  191. assert.Equal(t, "/api/users", result[0].URL)
  192. assert.Equal(t, 100, result[0].Visits)
  193. assert.Equal(t, 50.0, result[0].Percent) // 100/200 * 100
  194. // Check second item
  195. assert.Equal(t, "/api/posts", result[1].URL)
  196. assert.Equal(t, 50, result[1].Visits)
  197. assert.Equal(t, 25.0, result[1].Percent) // 50/200 * 100
  198. }
  199. func TestCalculateTopFieldStats_EmptyFacet(t *testing.T) {
  200. if testing.Short() {
  201. t.Skip("skipping test in short mode")
  202. }
  203. result := calculateTopFieldStats[URLAccessStats](nil, 100, func(term string, count int, percent float64) URLAccessStats {
  204. return URLAccessStats{URL: term, Visits: count, Percent: percent}
  205. })
  206. assert.NotNil(t, result)
  207. assert.Len(t, result, 0)
  208. }
  209. func TestCalculateTopFieldStats_ZeroHits(t *testing.T) {
  210. if testing.Short() {
  211. t.Skip("skipping test in short mode")
  212. }
  213. facet := &searcher.Facet{
  214. Terms: []*searcher.FacetTerm{
  215. {Term: "/api/users", Count: 100},
  216. },
  217. }
  218. result := calculateTopFieldStats(facet, 0, func(term string, count int, percent float64) URLAccessStats {
  219. return URLAccessStats{URL: term, Visits: count, Percent: percent}
  220. })
  221. assert.NotNil(t, result)
  222. assert.Len(t, result, 0)
  223. }
  224. func TestService_ValidateTimeRange_Comprehensive(t *testing.T) {
  225. if testing.Short() {
  226. t.Skip("skipping test in short mode")
  227. }
  228. mockSearcher := &MockSearcher{}
  229. s := NewService(mockSearcher)
  230. tests := []struct {
  231. name string
  232. startTime int64
  233. endTime int64
  234. expected bool
  235. }{
  236. {"valid range", 1000, 2000, true},
  237. {"invalid range - same", 1000, 1000, false},
  238. {"invalid range - backwards", 2000, 1000, false},
  239. {"zero range", 0, 0, true},
  240. {"negative start", -1000, 2000, false},
  241. {"negative end", 1000, -2000, false},
  242. }
  243. for _, tt := range tests {
  244. t.Run(tt.name, func(t *testing.T) {
  245. err := s.ValidateTimeRange(tt.startTime, tt.endTime)
  246. if tt.expected {
  247. assert.NoError(t, err)
  248. } else {
  249. assert.Error(t, err)
  250. }
  251. })
  252. }
  253. }
  254. func TestGetTopKeyValuesFromMap(t *testing.T) {
  255. if testing.Short() {
  256. t.Skip("skipping test in short mode")
  257. }
  258. counts := map[string]int{
  259. "200": 2,
  260. "404": 1,
  261. "500": 3,
  262. }
  263. result := getTopKeyValuesFromMap(counts, 10) // Set a reasonable limit
  264. assert.NotNil(t, result)
  265. assert.Len(t, result, 3)
  266. // Should be sorted by value descending
  267. assert.Equal(t, "500", result[0].Key)
  268. assert.Equal(t, 3, result[0].Value)
  269. assert.Equal(t, "200", result[1].Key)
  270. assert.Equal(t, 2, result[1].Value)
  271. assert.Equal(t, "404", result[2].Key)
  272. assert.Equal(t, 1, result[2].Value)
  273. }
  274. func TestGetTopKeyValuesFromMap_WithLimit(t *testing.T) {
  275. if testing.Short() {
  276. t.Skip("skipping test in short mode")
  277. }
  278. counts := map[string]int{
  279. "a": 1,
  280. "b": 2,
  281. "c": 3,
  282. }
  283. result := getTopKeyValuesFromMap(counts, 2)
  284. assert.NotNil(t, result)
  285. assert.Len(t, result, 2) // Should be limited to 2
  286. // Should be sorted by value descending
  287. assert.Equal(t, "c", result[0].Key)
  288. assert.Equal(t, 3, result[0].Value)
  289. assert.Equal(t, "b", result[1].Key)
  290. assert.Equal(t, 2, result[1].Value)
  291. }
  292. func TestService_calculateBrowserStats_FromFacets(t *testing.T) {
  293. if testing.Short() {
  294. t.Skip("skipping test in short mode")
  295. }
  296. result := &searcher.SearchResult{
  297. TotalHits: 1000,
  298. Facets: map[string]*searcher.Facet{
  299. "browser": {
  300. Terms: []*searcher.FacetTerm{
  301. {Term: "Chrome", Count: 600},
  302. {Term: "Firefox", Count: 300},
  303. {Term: "Safari", Count: 100},
  304. },
  305. },
  306. },
  307. }
  308. mockSearcher := &MockSearcher{}
  309. s := NewService(mockSearcher).(*service)
  310. stats := s.calculateBrowserStats(result)
  311. assert.NotNil(t, stats)
  312. assert.Len(t, stats, 3)
  313. // Check sorting and calculations
  314. assert.Equal(t, "Chrome", stats[0].Browser)
  315. assert.Equal(t, 600, stats[0].Count)
  316. assert.Equal(t, 60.0, stats[0].Percent) // 600/1000 * 100
  317. assert.Equal(t, "Firefox", stats[1].Browser)
  318. assert.Equal(t, 300, stats[1].Count)
  319. assert.Equal(t, 30.0, stats[1].Percent) // 300/1000 * 100
  320. }
  321. func TestService_calculateOSStats_FromFacets(t *testing.T) {
  322. if testing.Short() {
  323. t.Skip("skipping test in short mode")
  324. }
  325. result := &searcher.SearchResult{
  326. TotalHits: 800,
  327. Facets: map[string]*searcher.Facet{
  328. "os": {
  329. Terms: []*searcher.FacetTerm{
  330. {Term: "Windows", Count: 400},
  331. {Term: "macOS", Count: 250},
  332. {Term: "Linux", Count: 150},
  333. },
  334. },
  335. },
  336. }
  337. mockSearcher := &MockSearcher{}
  338. s := NewService(mockSearcher).(*service)
  339. stats := s.calculateOSStats(result)
  340. assert.NotNil(t, stats)
  341. assert.Len(t, stats, 3)
  342. // Check sorting and calculations
  343. assert.Equal(t, "Windows", stats[0].OS)
  344. assert.Equal(t, 400, stats[0].Count)
  345. assert.Equal(t, 50.0, stats[0].Percent) // 400/800 * 100
  346. assert.Equal(t, "macOS", stats[1].OS)
  347. assert.Equal(t, 250, stats[1].Count)
  348. assert.Equal(t, 31.25, stats[1].Percent) // 250/800 * 100
  349. }
  350. func TestService_GetVisitorsByCountry_Success(t *testing.T) {
  351. if testing.Short() {
  352. t.Skip("skipping test in short mode")
  353. }
  354. mockSearcher := &MockSearcher{}
  355. s := NewService(mockSearcher).(*service)
  356. ctx := context.Background()
  357. req := &VisitorsByCountryRequest{
  358. StartTime: 1000,
  359. EndTime: 2000,
  360. LogPaths: []string{"/var/log/nginx/access.log"},
  361. }
  362. expectedResult := &searcher.SearchResult{
  363. Facets: map[string]*searcher.Facet{
  364. "region_code": {
  365. Terms: []*searcher.FacetTerm{
  366. {Term: "US", Count: 100},
  367. {Term: "CN", Count: 50},
  368. },
  369. },
  370. },
  371. }
  372. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  373. result, err := s.GetVisitorsByCountry(ctx, req)
  374. assert.NoError(t, err)
  375. assert.NotNil(t, result)
  376. assert.Equal(t, 100, result.Data["US"])
  377. assert.Equal(t, 50, result.Data["CN"])
  378. }
  379. func TestService_GetErrorDistribution_Success(t *testing.T) {
  380. if testing.Short() {
  381. t.Skip("skipping test in short mode")
  382. }
  383. mockSearcher := &MockSearcher{}
  384. s := NewService(mockSearcher).(*service)
  385. ctx := context.Background()
  386. req := &ErrorDistributionRequest{
  387. StartTime: 1000,
  388. EndTime: 2000,
  389. LogPaths: []string{"/var/log/nginx/access.log"},
  390. }
  391. expectedResult := &searcher.SearchResult{
  392. Facets: map[string]*searcher.Facet{
  393. "status": {
  394. Terms: []*searcher.FacetTerm{
  395. {Term: "404", Count: 20},
  396. {Term: "500", Count: 5},
  397. },
  398. },
  399. },
  400. }
  401. mockSearcher.On("Search", ctx, mock.MatchedBy(func(r *searcher.SearchRequest) bool {
  402. return r.Query == "status:[400 TO 599]"
  403. })).Return(expectedResult, nil)
  404. result, err := s.GetErrorDistribution(ctx, req)
  405. assert.NoError(t, err)
  406. assert.NotNil(t, result)
  407. assert.Equal(t, 20, result.Data["404"])
  408. assert.Equal(t, 5, result.Data["500"])
  409. }