dashboard_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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_Success(t *testing.T) {
  10. mockSearcher := &MockSearcher{}
  11. s := NewService(mockSearcher)
  12. ctx := context.Background()
  13. req := &DashboardQueryRequest{
  14. StartTime: 1640995200, // 2022-01-01 00:00:00 UTC
  15. EndTime: 1641081600, // 2022-01-02 00:00:00 UTC
  16. LogPath: "/var/log/nginx/access.log",
  17. }
  18. // Mock search result with more comprehensive sample data for in-memory calculation
  19. expectedResult := &searcher.SearchResult{
  20. TotalHits: 4,
  21. Hits: []*searcher.SearchHit{
  22. {
  23. Fields: map[string]interface{}{
  24. "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC (hour 0)
  25. "ip": "192.168.1.1",
  26. "path": "/api/users",
  27. "browser": "Chrome",
  28. "os": "Windows",
  29. "device_type": "Desktop",
  30. },
  31. },
  32. {
  33. Fields: map[string]interface{}{
  34. "timestamp": float64(1640999400), // 2022-01-01 01:10:00 UTC (hour 1)
  35. "ip": "192.168.1.2",
  36. "path": "/api/posts",
  37. "browser": "Firefox",
  38. "os": "Linux",
  39. "device_type": "Desktop",
  40. },
  41. },
  42. {
  43. Fields: map[string]interface{}{
  44. "timestamp": float64(1640999500), // 2022-01-01 01:11:40 UTC (hour 1)
  45. "ip": "192.168.1.1",
  46. "path": "/api/users",
  47. "browser": "Chrome",
  48. "os": "Windows",
  49. "device_type": "Mobile",
  50. },
  51. },
  52. {
  53. Fields: map[string]interface{}{
  54. "timestamp": float64(1641082200), // 2022-01-02 00:10:00 UTC (day 2)
  55. "ip": "192.168.1.3",
  56. "path": "/",
  57. "browser": "Chrome",
  58. "os": "macOS",
  59. "device_type": "Desktop",
  60. },
  61. },
  62. },
  63. Facets: map[string]*searcher.Facet{
  64. "path_exact": {
  65. Terms: []*searcher.FacetTerm{
  66. {Term: "/api/users", Count: 2},
  67. {Term: "/api/posts", Count: 1},
  68. {Term: "/", Count: 1},
  69. },
  70. },
  71. "browser": {
  72. Terms: []*searcher.FacetTerm{
  73. {Term: "Chrome", Count: 3},
  74. {Term: "Firefox", Count: 1},
  75. },
  76. },
  77. "os": {
  78. Terms: []*searcher.FacetTerm{
  79. {Term: "Windows", Count: 2},
  80. {Term: "Linux", Count: 1},
  81. {Term: "macOS", Count: 1},
  82. },
  83. },
  84. "device_type": {
  85. Terms: []*searcher.FacetTerm{
  86. {Term: "Desktop", Count: 3},
  87. {Term: "Mobile", Count: 1},
  88. },
  89. },
  90. "ip": {
  91. Total: 3, // 3 unique IPs
  92. Terms: []*searcher.FacetTerm{
  93. {Term: "192.168.1.1", Count: 2},
  94. {Term: "192.168.1.2", Count: 1},
  95. {Term: "192.168.1.3", Count: 1},
  96. },
  97. },
  98. },
  99. }
  100. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  101. result, err := s.GetDashboardAnalytics(ctx, req)
  102. assert.NoError(t, err)
  103. assert.NotNil(t, result)
  104. // Check that all arrays are initialized
  105. assert.NotNil(t, result.HourlyStats)
  106. assert.NotNil(t, result.DailyStats)
  107. assert.NotNil(t, result.TopURLs)
  108. assert.NotNil(t, result.Browsers)
  109. assert.NotNil(t, result.OperatingSystems)
  110. assert.NotNil(t, result.Devices)
  111. // Check TopURLs (calculated from hits)
  112. assert.Len(t, result.TopURLs, 3)
  113. assert.Equal(t, "/api/users", result.TopURLs[0].URL)
  114. assert.Equal(t, 2, result.TopURLs[0].Visits)
  115. // Check Browsers (calculated from hits)
  116. assert.Len(t, result.Browsers, 2)
  117. assert.Equal(t, "Chrome", result.Browsers[0].Browser)
  118. assert.Equal(t, 3, result.Browsers[0].Count)
  119. // Check Devices (calculated from hits)
  120. assert.Len(t, result.Devices, 2)
  121. assert.Equal(t, "Desktop", result.Devices[0].Device)
  122. assert.Equal(t, 3, result.Devices[0].Count)
  123. // Check Summary
  124. assert.Equal(t, 3, result.Summary.TotalUV) // 3 unique IPs
  125. assert.Equal(t, 4, result.Summary.TotalPV)
  126. mockSearcher.AssertExpectations(t)
  127. }
  128. func TestService_GetDashboardAnalytics_NilRequest(t *testing.T) {
  129. mockSearcher := &MockSearcher{}
  130. s := NewService(mockSearcher)
  131. ctx := context.Background()
  132. result, err := s.GetDashboardAnalytics(ctx, nil)
  133. assert.Error(t, err)
  134. assert.Nil(t, result)
  135. assert.Contains(t, err.Error(), "request cannot be nil")
  136. }
  137. func TestService_GetDashboardAnalytics_InvalidTimeRange(t *testing.T) {
  138. mockSearcher := &MockSearcher{}
  139. s := NewService(mockSearcher)
  140. ctx := context.Background()
  141. req := &DashboardQueryRequest{
  142. StartTime: 2000,
  143. EndTime: 1000, // End before start
  144. LogPath: "/var/log/nginx/access.log",
  145. }
  146. result, err := s.GetDashboardAnalytics(ctx, req)
  147. assert.Error(t, err)
  148. assert.Nil(t, result)
  149. assert.Contains(t, err.Error(), "invalid time range")
  150. }
  151. func TestService_GetDashboardAnalytics_SearchError(t *testing.T) {
  152. mockSearcher := &MockSearcher{}
  153. s := NewService(mockSearcher)
  154. ctx := context.Background()
  155. req := &DashboardQueryRequest{
  156. StartTime: 1000,
  157. EndTime: 2000,
  158. LogPath: "/var/log/nginx/access.log",
  159. }
  160. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(nil, assert.AnError)
  161. result, err := s.GetDashboardAnalytics(ctx, req)
  162. assert.Error(t, err)
  163. assert.Nil(t, result)
  164. assert.Contains(t, err.Error(), "failed to search logs")
  165. mockSearcher.AssertExpectations(t)
  166. }
  167. func TestService_GetDashboardAnalytics_EmptyResult(t *testing.T) {
  168. mockSearcher := &MockSearcher{}
  169. s := NewService(mockSearcher)
  170. ctx := context.Background()
  171. req := &DashboardQueryRequest{
  172. StartTime: 1000,
  173. EndTime: 2000,
  174. LogPath: "/var/log/nginx/access.log",
  175. }
  176. // Empty search result
  177. expectedResult := &searcher.SearchResult{
  178. TotalHits: 0,
  179. Hits: []*searcher.SearchHit{},
  180. }
  181. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  182. result, err := s.GetDashboardAnalytics(ctx, req)
  183. assert.NoError(t, err)
  184. assert.NotNil(t, result)
  185. // All arrays should not be nil, but can be empty
  186. assert.NotNil(t, result.HourlyStats)
  187. assert.NotNil(t, result.DailyStats)
  188. assert.Len(t, result.TopURLs, 0)
  189. assert.Len(t, result.Browsers, 0)
  190. assert.Len(t, result.OperatingSystems, 0)
  191. assert.Len(t, result.Devices, 0)
  192. // Summary should have zero values
  193. assert.Equal(t, 0, result.Summary.TotalUV)
  194. assert.Equal(t, 0, result.Summary.TotalPV)
  195. mockSearcher.AssertExpectations(t)
  196. }
  197. func TestService_calculateHourlyStats(t *testing.T) {
  198. mockSearcher := &MockSearcher{}
  199. s := NewService(mockSearcher).(*service)
  200. // Create test data spanning multiple hours
  201. result := &searcher.SearchResult{
  202. TotalHits: 3,
  203. Hits: []*searcher.SearchHit{
  204. {
  205. Fields: map[string]interface{}{
  206. "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC (hour 0)
  207. "ip": "192.168.1.1",
  208. },
  209. },
  210. {
  211. Fields: map[string]interface{}{
  212. "timestamp": float64(1640999400), // 2022-01-01 01:10:00 UTC (hour 1)
  213. "ip": "192.168.1.2",
  214. },
  215. },
  216. {
  217. Fields: map[string]interface{}{
  218. "timestamp": float64(1640999500), // 2022-01-01 01:11:40 UTC (hour 1)
  219. "ip": "192.168.1.1", // Same IP as first hit
  220. },
  221. },
  222. },
  223. }
  224. startTime := int64(1640995200) // 2022-01-01 00:00:00 UTC
  225. endTime := int64(1641006000) // 2022-01-01 03:00:00 UTC (extended range)
  226. stats := s.calculateHourlyStats(result, startTime, endTime)
  227. assert.NotNil(t, stats)
  228. assert.GreaterOrEqual(t, len(stats), 3) // Should have at least 3 hours
  229. // Find stats with actual data (non-zero PV)
  230. var statsWithData []*HourlyAccessStats
  231. for i := range stats {
  232. if stats[i].PV > 0 {
  233. statsWithData = append(statsWithData, &stats[i])
  234. }
  235. }
  236. assert.Len(t, statsWithData, 2) // Should have 2 hours with data
  237. // First hour with data should have 1 PV and 1 UV
  238. assert.Equal(t, 1, statsWithData[0].PV)
  239. assert.Equal(t, 1, statsWithData[0].UV)
  240. // Second hour with data should have 2 PV and 2 UV
  241. assert.Equal(t, 2, statsWithData[1].PV)
  242. assert.Equal(t, 2, statsWithData[1].UV)
  243. }
  244. func TestService_calculateDailyStats(t *testing.T) {
  245. mockSearcher := &MockSearcher{}
  246. s := NewService(mockSearcher).(*service)
  247. // Create test data spanning multiple days
  248. result := &searcher.SearchResult{
  249. TotalHits: 3,
  250. Hits: []*searcher.SearchHit{
  251. {
  252. Fields: map[string]interface{}{
  253. "timestamp": float64(1640995800), // 2022-01-01 00:10:00 UTC
  254. "ip": "192.168.1.1",
  255. },
  256. },
  257. {
  258. Fields: map[string]interface{}{
  259. "timestamp": float64(1641082200), // 2022-01-02 00:10:00 UTC
  260. "ip": "192.168.1.2",
  261. },
  262. },
  263. {
  264. Fields: map[string]interface{}{
  265. "timestamp": float64(1641082800), // 2022-01-02 00:20:00 UTC
  266. "ip": "192.168.1.1", // Same IP as first hit
  267. },
  268. },
  269. },
  270. }
  271. startTime := int64(1640995200) // 2022-01-01 00:00:00 UTC
  272. endTime := int64(1641168000) // 2022-01-03 00:00:00 UTC
  273. stats := s.calculateDailyStats(result, startTime, endTime)
  274. assert.NotNil(t, stats)
  275. assert.Len(t, stats, 3) // Should have 3 days because we initialize for the full range
  276. // Verify stats are sorted by timestamp
  277. for i := 1; i < len(stats); i++ {
  278. assert.LessOrEqual(t, stats[i-1].Timestamp, stats[i].Timestamp)
  279. }
  280. // Find the days with data
  281. var day1Stats, day2Stats *DailyAccessStats
  282. for i := range stats {
  283. if stats[i].Date == "2022-01-01" {
  284. day1Stats = &stats[i]
  285. } else if stats[i].Date == "2022-01-02" {
  286. day2Stats = &stats[i]
  287. }
  288. }
  289. assert.NotNil(t, day1Stats)
  290. assert.NotNil(t, day2Stats)
  291. // Day 1 should have 1 PV and 1 UV
  292. assert.Equal(t, 1, day1Stats.PV)
  293. assert.Equal(t, 1, day1Stats.UV)
  294. // Day 2 should have 2 PV and 2 UV
  295. assert.Equal(t, 2, day2Stats.PV)
  296. assert.Equal(t, 2, day2Stats.UV)
  297. }
  298. // Test for the generic top field stats calculator
  299. func Test_calculateTopFieldStats(t *testing.T) {
  300. facet := &searcher.Facet{
  301. Terms: []*searcher.FacetTerm{
  302. {Term: "/a", Count: 3},
  303. {Term: "/b", Count: 2},
  304. {Term: "/c", Count: 1},
  305. },
  306. }
  307. stats := calculateTopFieldStats(facet, 6, func(term string, count int, percent float64) URLAccessStats {
  308. return URLAccessStats{URL: term, Visits: count, Percent: percent}
  309. })
  310. assert.NotNil(t, stats)
  311. assert.Len(t, stats, 3) // Should have all 3 terms from facet
  312. // Should be sorted by visits descending
  313. assert.Equal(t, "/a", stats[0].URL)
  314. assert.Equal(t, 3, stats[0].Visits)
  315. assert.InDelta(t, 50.0, stats[0].Percent, 0.01) // 3/6
  316. assert.Equal(t, "/b", stats[1].URL)
  317. assert.Equal(t, 2, stats[1].Visits)
  318. assert.InDelta(t, 33.33, stats[1].Percent, 0.01) // 2/6
  319. }
  320. func TestService_calculateDashboardSummary(t *testing.T) {
  321. mockSearcher := &MockSearcher{}
  322. s := NewService(mockSearcher).(*service)
  323. analytics := &DashboardAnalytics{
  324. HourlyStats: []HourlyAccessStats{
  325. {Hour: 0, UV: 10, PV: 100},
  326. {Hour: 1, UV: 20, PV: 200}, // Peak hour
  327. {Hour: 2, UV: 15, PV: 150},
  328. },
  329. DailyStats: []DailyAccessStats{
  330. {Date: "2022-01-01", UV: 30, PV: 300},
  331. {Date: "2022-01-02", UV: 25, PV: 250},
  332. },
  333. }
  334. result := &searcher.SearchResult{
  335. TotalHits: 550,
  336. Hits: []*searcher.SearchHit{
  337. {Fields: map[string]interface{}{"ip": "192.168.1.1"}},
  338. {Fields: map[string]interface{}{"ip": "192.168.1.2"}},
  339. {Fields: map[string]interface{}{"ip": "192.168.1.1"}}, // Duplicate
  340. },
  341. Facets: map[string]*searcher.Facet{
  342. "ip": {
  343. Total: 2, // 2 unique IPs
  344. Terms: []*searcher.FacetTerm{
  345. {Term: "192.168.1.1", Count: 2},
  346. {Term: "192.168.1.2", Count: 1},
  347. },
  348. },
  349. },
  350. }
  351. summary := s.calculateDashboardSummary(analytics, result)
  352. assert.Equal(t, 2, summary.TotalUV) // 2 unique IPs from hits
  353. assert.Equal(t, 550, summary.TotalPV) // Total hits from result
  354. // Average daily values (2 days)
  355. assert.InDelta(t, 1.0, summary.AvgDailyUV, 0.01) // 2 total UV / 2 days = 1
  356. assert.InDelta(t, 275.0, summary.AvgDailyPV, 0.01) // (300 + 250) / 2
  357. // Peak hour should be hour 1 with 200 PV
  358. assert.Equal(t, 1, summary.PeakHour)
  359. assert.Equal(t, 200, summary.PeakHourTraffic)
  360. }