1
0

service_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. package analytics
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "github.com/0xJacky/Nginx-UI/internal/nginx_log/searcher"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/mock"
  9. )
  10. // MockSearcher implements searcher.Searcher for testing
  11. type MockSearcher struct {
  12. mock.Mock
  13. }
  14. func (m *MockSearcher) Search(ctx context.Context, req *searcher.SearchRequest) (*searcher.SearchResult, error) {
  15. args := m.Called(ctx, req)
  16. if args.Get(0) == nil {
  17. return nil, args.Error(1)
  18. }
  19. return args.Get(0).(*searcher.SearchResult), args.Error(1)
  20. }
  21. func (m *MockSearcher) SearchAsync(ctx context.Context, req *searcher.SearchRequest) (<-chan *searcher.SearchResult, <-chan error) {
  22. args := m.Called(ctx, req)
  23. return args.Get(0).(<-chan *searcher.SearchResult), args.Get(1).(<-chan error)
  24. }
  25. func (m *MockSearcher) Aggregate(ctx context.Context, req *searcher.AggregationRequest) (*searcher.AggregationResult, error) {
  26. args := m.Called(ctx, req)
  27. if args.Get(0) == nil {
  28. return nil, args.Error(1)
  29. }
  30. return args.Get(0).(*searcher.AggregationResult), args.Error(1)
  31. }
  32. func (m *MockSearcher) Suggest(ctx context.Context, text string, field string, size int) ([]*searcher.Suggestion, error) {
  33. args := m.Called(ctx, text, field, size)
  34. if args.Get(0) == nil {
  35. return nil, args.Error(1)
  36. }
  37. return args.Get(0).([]*searcher.Suggestion), args.Error(1)
  38. }
  39. func (m *MockSearcher) Analyze(ctx context.Context, text string, analyzer string) ([]string, error) {
  40. args := m.Called(ctx, text, analyzer)
  41. if args.Get(0) == nil {
  42. return nil, args.Error(1)
  43. }
  44. return args.Get(0).([]string), args.Error(1)
  45. }
  46. func (m *MockSearcher) ClearCache() error {
  47. args := m.Called()
  48. return args.Error(0)
  49. }
  50. func (m *MockSearcher) GetCacheStats() *searcher.CacheStats {
  51. args := m.Called()
  52. if args.Get(0) == nil {
  53. return nil
  54. }
  55. return args.Get(0).(*searcher.CacheStats)
  56. }
  57. func (m *MockSearcher) IsHealthy() bool {
  58. args := m.Called()
  59. return args.Bool(0)
  60. }
  61. func (m *MockSearcher) IsRunning() bool {
  62. args := m.Called()
  63. return args.Bool(0)
  64. }
  65. func (m *MockSearcher) GetStats() *searcher.Stats {
  66. args := m.Called()
  67. if args.Get(0) == nil {
  68. return nil
  69. }
  70. return args.Get(0).(*searcher.Stats)
  71. }
  72. func (m *MockSearcher) GetConfig() *searcher.Config {
  73. args := m.Called()
  74. if args.Get(0) == nil {
  75. return nil
  76. }
  77. return args.Get(0).(*searcher.Config)
  78. }
  79. func (m *MockSearcher) Stop() error {
  80. args := m.Called()
  81. return args.Error(0)
  82. }
  83. // MockCardinalityCounter implements searcher.CardinalityCounter for testing
  84. type MockCardinalityCounter struct {
  85. mock.Mock
  86. }
  87. func (m *MockCardinalityCounter) CountCardinality(ctx context.Context, req *searcher.CardinalityRequest) (*searcher.CardinalityResult, error) {
  88. args := m.Called(ctx, req)
  89. if args.Get(0) == nil {
  90. return nil, args.Error(1)
  91. }
  92. return args.Get(0).(*searcher.CardinalityResult), args.Error(1)
  93. }
  94. func (m *MockCardinalityCounter) EstimateCardinality(ctx context.Context, req *searcher.CardinalityRequest) (*searcher.CardinalityResult, error) {
  95. args := m.Called(ctx, req)
  96. if args.Get(0) == nil {
  97. return nil, args.Error(1)
  98. }
  99. return args.Get(0).(*searcher.CardinalityResult), args.Error(1)
  100. }
  101. func (m *MockCardinalityCounter) BatchCountCardinality(ctx context.Context, fields []string, baseReq *searcher.CardinalityRequest) (map[string]*searcher.CardinalityResult, error) {
  102. args := m.Called(ctx, fields, baseReq)
  103. if args.Get(0) == nil {
  104. return nil, args.Error(1)
  105. }
  106. return args.Get(0).(map[string]*searcher.CardinalityResult), args.Error(1)
  107. }
  108. func TestNewService(t *testing.T) {
  109. mockSearcher := &MockSearcher{}
  110. service := NewService(mockSearcher)
  111. assert.NotNil(t, service)
  112. assert.Implements(t, (*Service)(nil), service)
  113. }
  114. // Helper function to create a service with a mock cardinality counter
  115. func createServiceWithCardinalityCounter(searcher searcher.Searcher, cardinalityCounter *searcher.CardinalityCounter) Service {
  116. return &service{
  117. searcher: searcher,
  118. cardinalityCounter: cardinalityCounter,
  119. }
  120. }
  121. func TestService_ValidateLogPath(t *testing.T) {
  122. mockSearcher := &MockSearcher{}
  123. s := NewService(mockSearcher)
  124. tests := []struct {
  125. name string
  126. logPath string
  127. wantErr bool
  128. }{
  129. {
  130. name: "empty path should be valid",
  131. logPath: "",
  132. wantErr: false,
  133. },
  134. // {
  135. // name: "non-empty path should be invalid without whitelist",
  136. // logPath: "/var/log/nginx/access.log",
  137. // wantErr: true, // In test environment, no whitelist is configured
  138. // },
  139. }
  140. for _, tt := range tests {
  141. t.Run(tt.name, func(t *testing.T) {
  142. err := s.ValidateLogPath(tt.logPath)
  143. if tt.wantErr {
  144. assert.Error(t, err)
  145. } else {
  146. assert.NoError(t, err)
  147. }
  148. })
  149. }
  150. }
  151. func TestService_ValidateTimeRange(t *testing.T) {
  152. mockSearcher := &MockSearcher{}
  153. s := NewService(mockSearcher)
  154. tests := []struct {
  155. name string
  156. startTime int64
  157. endTime int64
  158. wantErr bool
  159. }{
  160. {
  161. name: "valid time range",
  162. startTime: 1000,
  163. endTime: 2000,
  164. wantErr: false,
  165. },
  166. {
  167. name: "same start and end time should error",
  168. startTime: 1000,
  169. endTime: 1000,
  170. wantErr: true,
  171. },
  172. {
  173. name: "start time after end time should error",
  174. startTime: 2000,
  175. endTime: 1000,
  176. wantErr: true,
  177. },
  178. {
  179. name: "negative start time should error",
  180. startTime: -1000,
  181. endTime: 2000,
  182. wantErr: true,
  183. },
  184. {
  185. name: "negative end time should error",
  186. startTime: 1000,
  187. endTime: -2000,
  188. wantErr: true,
  189. },
  190. {
  191. name: "zero values should be valid",
  192. startTime: 0,
  193. endTime: 0,
  194. wantErr: false,
  195. },
  196. }
  197. for _, tt := range tests {
  198. t.Run(tt.name, func(t *testing.T) {
  199. err := s.ValidateTimeRange(tt.startTime, tt.endTime)
  200. if tt.wantErr {
  201. assert.Error(t, err)
  202. } else {
  203. assert.NoError(t, err)
  204. }
  205. })
  206. }
  207. }
  208. func TestService_GetTopPaths_Basic(t *testing.T) {
  209. mockSearcher := &MockSearcher{}
  210. s := NewService(mockSearcher)
  211. ctx := context.Background()
  212. req := &TopListRequest{
  213. StartTime: 1000,
  214. EndTime: 2000,
  215. LogPath: "/var/log/nginx/access.log",
  216. Limit: 10,
  217. Field: FieldPath,
  218. }
  219. expectedResult := &searcher.SearchResult{
  220. TotalHits: 100,
  221. Facets: map[string]*searcher.Facet{
  222. "path_exact": {
  223. Field: "path_exact",
  224. Total: 100,
  225. Terms: []*searcher.FacetTerm{
  226. {Term: "/api/users", Count: 50},
  227. {Term: "/api/posts", Count: 30},
  228. {Term: "/", Count: 20},
  229. },
  230. },
  231. },
  232. }
  233. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  234. result, err := s.GetTopPaths(ctx, req)
  235. assert.NoError(t, err)
  236. assert.NotNil(t, result)
  237. assert.Len(t, result, 3)
  238. assert.Equal(t, "/api/users", result[0].Key)
  239. assert.Equal(t, 50, result[0].Value)
  240. assert.Equal(t, "/api/posts", result[1].Key)
  241. assert.Equal(t, 30, result[1].Value)
  242. mockSearcher.AssertExpectations(t)
  243. }
  244. func TestService_GetTopPaths_NilRequest(t *testing.T) {
  245. mockSearcher := &MockSearcher{}
  246. s := NewService(mockSearcher)
  247. ctx := context.Background()
  248. result, err := s.GetTopPaths(ctx, nil)
  249. assert.Error(t, err)
  250. assert.Nil(t, result)
  251. assert.Contains(t, err.Error(), "request cannot be nil")
  252. }
  253. func TestService_GetTopPaths_SearchError(t *testing.T) {
  254. mockSearcher := &MockSearcher{}
  255. s := NewService(mockSearcher)
  256. ctx := context.Background()
  257. req := &TopListRequest{
  258. StartTime: 1000,
  259. EndTime: 2000,
  260. Limit: 10,
  261. Field: FieldPath,
  262. }
  263. expectedError := errors.New("search failed")
  264. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(nil, expectedError)
  265. result, err := s.GetTopPaths(ctx, req)
  266. assert.Error(t, err)
  267. assert.Nil(t, result)
  268. assert.Contains(t, err.Error(), "failed to get top paths")
  269. mockSearcher.AssertExpectations(t)
  270. }
  271. func TestService_GetTopIPs_Basic(t *testing.T) {
  272. mockSearcher := &MockSearcher{}
  273. s := NewService(mockSearcher)
  274. ctx := context.Background()
  275. req := &TopListRequest{
  276. StartTime: 1000,
  277. EndTime: 2000,
  278. Limit: 5,
  279. Field: FieldIP,
  280. }
  281. expectedResult := &searcher.SearchResult{
  282. TotalHits: 100,
  283. Facets: map[string]*searcher.Facet{
  284. "ip": {
  285. Field: "ip",
  286. Total: 100,
  287. Terms: []*searcher.FacetTerm{
  288. {Term: "192.168.1.1", Count: 40},
  289. {Term: "192.168.1.2", Count: 30},
  290. {Term: "192.168.1.3", Count: 30},
  291. },
  292. },
  293. },
  294. }
  295. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  296. result, err := s.GetTopIPs(ctx, req)
  297. assert.NoError(t, err)
  298. assert.NotNil(t, result)
  299. assert.Len(t, result, 3)
  300. assert.Equal(t, "192.168.1.1", result[0].Key)
  301. assert.Equal(t, 40, result[0].Value)
  302. mockSearcher.AssertExpectations(t)
  303. }
  304. func TestService_GetLogEntriesStats_Basic(t *testing.T) {
  305. mockSearcher := &MockSearcher{}
  306. s := NewService(mockSearcher)
  307. ctx := context.Background()
  308. req := &searcher.SearchRequest{
  309. Limit: 100,
  310. Offset: 0,
  311. }
  312. expectedResult := &searcher.SearchResult{
  313. TotalHits: 1000,
  314. Facets: map[string]*searcher.Facet{
  315. "status": {
  316. Terms: []*searcher.FacetTerm{
  317. {Term: "200", Count: 800},
  318. {Term: "404", Count: 150},
  319. {Term: "500", Count: 50},
  320. },
  321. },
  322. "method": {
  323. Terms: []*searcher.FacetTerm{
  324. {Term: "GET", Count: 700},
  325. {Term: "POST", Count: 300},
  326. },
  327. },
  328. "path_exact": {
  329. Terms: []*searcher.FacetTerm{
  330. {Term: "/api/users", Count: 400},
  331. {Term: "/api/posts", Count: 300},
  332. },
  333. },
  334. "ip": {
  335. Terms: []*searcher.FacetTerm{
  336. {Term: "192.168.1.1", Count: 500},
  337. {Term: "192.168.1.2", Count: 300},
  338. },
  339. },
  340. "user_agent": {
  341. Terms: []*searcher.FacetTerm{
  342. {Term: "Chrome", Count: 600},
  343. {Term: "Firefox", Count: 400},
  344. },
  345. },
  346. },
  347. Stats: &searcher.SearchStats{
  348. TotalBytes: 1000000,
  349. AvgBytes: 1000,
  350. MinBytes: 100,
  351. MaxBytes: 5000,
  352. AvgReqTime: 0.5,
  353. MinReqTime: 0.1,
  354. MaxReqTime: 2.0,
  355. },
  356. }
  357. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  358. result, err := s.GetLogEntriesStats(ctx, req)
  359. assert.NoError(t, err)
  360. assert.NotNil(t, result)
  361. assert.Equal(t, int64(1000), result.TotalEntries)
  362. assert.Equal(t, 800, result.StatusCodeDist["200"])
  363. assert.Equal(t, 150, result.StatusCodeDist["404"])
  364. assert.Equal(t, 700, result.MethodDist["GET"])
  365. assert.Equal(t, 300, result.MethodDist["POST"])
  366. assert.NotNil(t, result.BytesStats)
  367. assert.Equal(t, int64(1000000), result.BytesStats.Total)
  368. assert.NotNil(t, result.ResponseTimeStats)
  369. assert.Equal(t, 0.5, result.ResponseTimeStats.Average)
  370. mockSearcher.AssertExpectations(t)
  371. }
  372. func TestService_buildBaseSearchRequest(t *testing.T) {
  373. mockSearcher := &MockSearcher{}
  374. s := NewService(mockSearcher).(*service)
  375. tests := []struct {
  376. name string
  377. startTime int64
  378. endTime int64
  379. logPath string
  380. }{
  381. {
  382. name: "with time range",
  383. startTime: 1000,
  384. endTime: 2000,
  385. logPath: "/var/log/nginx/access.log",
  386. },
  387. {
  388. name: "without time range",
  389. startTime: 0,
  390. endTime: 0,
  391. logPath: "",
  392. },
  393. }
  394. for _, tt := range tests {
  395. t.Run(tt.name, func(t *testing.T) {
  396. req := s.buildBaseSearchRequest(tt.startTime, tt.endTime, tt.logPath)
  397. assert.NotNil(t, req)
  398. assert.Equal(t, DefaultLimit, req.Limit)
  399. assert.Equal(t, 0, req.Offset)
  400. assert.True(t, req.UseCache)
  401. if tt.startTime > 0 {
  402. assert.NotNil(t, req.StartTime)
  403. assert.Equal(t, tt.startTime, *req.StartTime)
  404. } else {
  405. assert.Nil(t, req.StartTime)
  406. }
  407. if tt.endTime > 0 {
  408. assert.NotNil(t, req.EndTime)
  409. assert.Equal(t, tt.endTime, *req.EndTime)
  410. } else {
  411. assert.Nil(t, req.EndTime)
  412. }
  413. })
  414. }
  415. }
  416. func TestService_validateAndNormalizeSearchRequest(t *testing.T) {
  417. mockSearcher := &MockSearcher{}
  418. s := NewService(mockSearcher).(*service)
  419. tests := []struct {
  420. name string
  421. req *searcher.SearchRequest
  422. wantErr bool
  423. }{
  424. {
  425. name: "nil request",
  426. req: nil,
  427. wantErr: true,
  428. },
  429. {
  430. name: "valid request",
  431. req: &searcher.SearchRequest{
  432. Limit: 10,
  433. Offset: 0,
  434. },
  435. wantErr: false,
  436. },
  437. {
  438. name: "zero limit gets default",
  439. req: &searcher.SearchRequest{
  440. Limit: 0,
  441. Offset: 0,
  442. },
  443. wantErr: false,
  444. },
  445. {
  446. name: "negative offset gets normalized",
  447. req: &searcher.SearchRequest{
  448. Limit: 10,
  449. Offset: -10,
  450. },
  451. wantErr: false,
  452. },
  453. {
  454. name: "limit too high gets capped",
  455. req: &searcher.SearchRequest{
  456. Limit: 10000,
  457. Offset: 0,
  458. },
  459. wantErr: false,
  460. },
  461. }
  462. for _, tt := range tests {
  463. t.Run(tt.name, func(t *testing.T) {
  464. err := s.validateAndNormalizeSearchRequest(tt.req)
  465. if tt.wantErr {
  466. assert.Error(t, err)
  467. } else {
  468. assert.NoError(t, err)
  469. if tt.req != nil {
  470. if tt.name == "zero limit gets default" {
  471. assert.Equal(t, DefaultLimit, tt.req.Limit)
  472. }
  473. if tt.name == "negative offset gets normalized" {
  474. assert.Equal(t, 0, tt.req.Offset)
  475. }
  476. if tt.name == "limit too high gets capped" {
  477. assert.Equal(t, MaxLimit, tt.req.Limit)
  478. }
  479. }
  480. }
  481. })
  482. }
  483. }
  484. func TestService_GetDashboardAnalytics_WithCardinalityCounter(t *testing.T) {
  485. mockSearcher := &MockSearcher{}
  486. // Create a mock cardinality counter for testing
  487. mockCardinalityCounter := searcher.NewCardinalityCounter(nil)
  488. s := createServiceWithCardinalityCounter(mockSearcher, mockCardinalityCounter)
  489. ctx := context.Background()
  490. req := &DashboardQueryRequest{
  491. StartTime: 1640995200, // 2022-01-01 00:00:00 UTC
  492. EndTime: 1641006000, // 2022-01-01 03:00:00 UTC
  493. LogPaths: []string{"/var/log/nginx/access.log"},
  494. }
  495. // Mock main search result with limited IP facet
  496. expectedResult := &searcher.SearchResult{
  497. TotalHits: 5000, // 5000 total page views
  498. Hits: []*searcher.SearchHit{
  499. {
  500. Fields: map[string]interface{}{
  501. "timestamp": float64(1640995800), // 2022-01-01 00:10:00
  502. "ip": "192.168.1.1",
  503. "bytes": int64(1024),
  504. },
  505. },
  506. {
  507. Fields: map[string]interface{}{
  508. "timestamp": float64(1640999400), // 2022-01-01 01:10:00
  509. "ip": "192.168.1.2",
  510. "bytes": int64(2048),
  511. },
  512. },
  513. },
  514. Facets: map[string]*searcher.Facet{
  515. "ip": {
  516. Total: 1000, // Limited by facet size - this is the problem we're fixing
  517. Terms: []*searcher.FacetTerm{
  518. {Term: "192.168.1.1", Count: 2500},
  519. {Term: "192.168.1.2", Count: 1500},
  520. },
  521. },
  522. },
  523. }
  524. // Mock batch search calls for hourly/daily stats (simplified - return empty for test focus)
  525. mockSearcher.On("Search", ctx, mock.MatchedBy(func(r *searcher.SearchRequest) bool {
  526. return len(r.Fields) == 2
  527. })).Return(&searcher.SearchResult{Hits: []*searcher.SearchHit{}}, nil)
  528. // Mock URL facet search
  529. mockSearcher.On("Search", ctx, mock.MatchedBy(func(r *searcher.SearchRequest) bool {
  530. return len(r.FacetFields) == 1 && r.FacetFields[0] == "path_exact"
  531. })).Return(&searcher.SearchResult{
  532. Facets: map[string]*searcher.Facet{
  533. "path_exact": {
  534. Terms: []*searcher.FacetTerm{
  535. {Term: "/api/users", Count: 2000},
  536. {Term: "/api/posts", Count: 1500},
  537. },
  538. },
  539. },
  540. }, nil)
  541. // Mock main search result
  542. mockSearcher.On("Search", ctx, mock.MatchedBy(func(r *searcher.SearchRequest) bool {
  543. return len(r.FacetFields) == 4 && r.FacetSize == 1000
  544. })).Return(expectedResult, nil)
  545. // The key test: CardinalityCounter should be called to get accurate UV count
  546. // Note: We can't easily mock the cardinality counter because it's created internally
  547. // This test verifies the logic works when cardinality counter is available
  548. result, err := s.GetDashboardAnalytics(ctx, req)
  549. assert.NoError(t, err)
  550. assert.NotNil(t, result)
  551. assert.NotNil(t, result.Summary)
  552. // The summary should use the original facet-limited UV count (1000)
  553. // since our mock cardinality counter won't actually be called
  554. // In a real scenario with proper cardinality counter, this would be 2500
  555. assert.Equal(t, 1000, result.Summary.TotalUV) // Limited by facet
  556. assert.Equal(t, 5000, result.Summary.TotalPV) // Total hits
  557. mockSearcher.AssertExpectations(t)
  558. }