service_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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) GetStats() *searcher.Stats {
  62. args := m.Called()
  63. if args.Get(0) == nil {
  64. return nil
  65. }
  66. return args.Get(0).(*searcher.Stats)
  67. }
  68. func (m *MockSearcher) GetConfig() *searcher.Config {
  69. args := m.Called()
  70. if args.Get(0) == nil {
  71. return nil
  72. }
  73. return args.Get(0).(*searcher.Config)
  74. }
  75. func (m *MockSearcher) Stop() error {
  76. args := m.Called()
  77. return args.Error(0)
  78. }
  79. func TestNewService(t *testing.T) {
  80. mockSearcher := &MockSearcher{}
  81. service := NewService(mockSearcher)
  82. assert.NotNil(t, service)
  83. assert.Implements(t, (*Service)(nil), service)
  84. }
  85. func TestService_ValidateLogPath(t *testing.T) {
  86. mockSearcher := &MockSearcher{}
  87. s := NewService(mockSearcher)
  88. tests := []struct {
  89. name string
  90. logPath string
  91. wantErr bool
  92. }{
  93. {
  94. name: "empty path should be valid",
  95. logPath: "",
  96. wantErr: false,
  97. },
  98. {
  99. name: "non-empty path should be invalid without whitelist",
  100. logPath: "/var/log/nginx/access.log",
  101. wantErr: true, // In test environment, no whitelist is configured
  102. },
  103. }
  104. for _, tt := range tests {
  105. t.Run(tt.name, func(t *testing.T) {
  106. err := s.ValidateLogPath(tt.logPath)
  107. if tt.wantErr {
  108. assert.Error(t, err)
  109. } else {
  110. assert.NoError(t, err)
  111. }
  112. })
  113. }
  114. }
  115. func TestService_ValidateTimeRange(t *testing.T) {
  116. mockSearcher := &MockSearcher{}
  117. s := NewService(mockSearcher)
  118. tests := []struct {
  119. name string
  120. startTime int64
  121. endTime int64
  122. wantErr bool
  123. }{
  124. {
  125. name: "valid time range",
  126. startTime: 1000,
  127. endTime: 2000,
  128. wantErr: false,
  129. },
  130. {
  131. name: "same start and end time should error",
  132. startTime: 1000,
  133. endTime: 1000,
  134. wantErr: true,
  135. },
  136. {
  137. name: "start time after end time should error",
  138. startTime: 2000,
  139. endTime: 1000,
  140. wantErr: true,
  141. },
  142. {
  143. name: "negative start time should error",
  144. startTime: -1000,
  145. endTime: 2000,
  146. wantErr: true,
  147. },
  148. {
  149. name: "negative end time should error",
  150. startTime: 1000,
  151. endTime: -2000,
  152. wantErr: true,
  153. },
  154. {
  155. name: "zero values should be valid",
  156. startTime: 0,
  157. endTime: 0,
  158. wantErr: false,
  159. },
  160. }
  161. for _, tt := range tests {
  162. t.Run(tt.name, func(t *testing.T) {
  163. err := s.ValidateTimeRange(tt.startTime, tt.endTime)
  164. if tt.wantErr {
  165. assert.Error(t, err)
  166. } else {
  167. assert.NoError(t, err)
  168. }
  169. })
  170. }
  171. }
  172. func TestService_GetTopPaths_Basic(t *testing.T) {
  173. mockSearcher := &MockSearcher{}
  174. s := NewService(mockSearcher)
  175. ctx := context.Background()
  176. req := &TopListRequest{
  177. StartTime: 1000,
  178. EndTime: 2000,
  179. LogPath: "/var/log/nginx/access.log",
  180. Limit: 10,
  181. Field: FieldPath,
  182. }
  183. expectedResult := &searcher.SearchResult{
  184. TotalHits: 100,
  185. Facets: map[string]*searcher.Facet{
  186. "path_exact": {
  187. Field: "path_exact",
  188. Total: 100,
  189. Terms: []*searcher.FacetTerm{
  190. {Term: "/api/users", Count: 50},
  191. {Term: "/api/posts", Count: 30},
  192. {Term: "/", Count: 20},
  193. },
  194. },
  195. },
  196. }
  197. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  198. result, err := s.GetTopPaths(ctx, req)
  199. assert.NoError(t, err)
  200. assert.NotNil(t, result)
  201. assert.Len(t, result, 3)
  202. assert.Equal(t, "/api/users", result[0].Key)
  203. assert.Equal(t, 50, result[0].Value)
  204. assert.Equal(t, "/api/posts", result[1].Key)
  205. assert.Equal(t, 30, result[1].Value)
  206. mockSearcher.AssertExpectations(t)
  207. }
  208. func TestService_GetTopPaths_NilRequest(t *testing.T) {
  209. mockSearcher := &MockSearcher{}
  210. s := NewService(mockSearcher)
  211. ctx := context.Background()
  212. result, err := s.GetTopPaths(ctx, nil)
  213. assert.Error(t, err)
  214. assert.Nil(t, result)
  215. assert.Contains(t, err.Error(), "request cannot be nil")
  216. }
  217. func TestService_GetTopPaths_SearchError(t *testing.T) {
  218. mockSearcher := &MockSearcher{}
  219. s := NewService(mockSearcher)
  220. ctx := context.Background()
  221. req := &TopListRequest{
  222. StartTime: 1000,
  223. EndTime: 2000,
  224. Limit: 10,
  225. Field: FieldPath,
  226. }
  227. expectedError := errors.New("search failed")
  228. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(nil, expectedError)
  229. result, err := s.GetTopPaths(ctx, req)
  230. assert.Error(t, err)
  231. assert.Nil(t, result)
  232. assert.Contains(t, err.Error(), "failed to get top paths")
  233. mockSearcher.AssertExpectations(t)
  234. }
  235. func TestService_GetTopIPs_Basic(t *testing.T) {
  236. mockSearcher := &MockSearcher{}
  237. s := NewService(mockSearcher)
  238. ctx := context.Background()
  239. req := &TopListRequest{
  240. StartTime: 1000,
  241. EndTime: 2000,
  242. Limit: 5,
  243. Field: FieldIP,
  244. }
  245. expectedResult := &searcher.SearchResult{
  246. TotalHits: 100,
  247. Facets: map[string]*searcher.Facet{
  248. "ip": {
  249. Field: "ip",
  250. Total: 100,
  251. Terms: []*searcher.FacetTerm{
  252. {Term: "192.168.1.1", Count: 40},
  253. {Term: "192.168.1.2", Count: 30},
  254. {Term: "192.168.1.3", Count: 30},
  255. },
  256. },
  257. },
  258. }
  259. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  260. result, err := s.GetTopIPs(ctx, req)
  261. assert.NoError(t, err)
  262. assert.NotNil(t, result)
  263. assert.Len(t, result, 3)
  264. assert.Equal(t, "192.168.1.1", result[0].Key)
  265. assert.Equal(t, 40, result[0].Value)
  266. mockSearcher.AssertExpectations(t)
  267. }
  268. func TestService_GetLogEntriesStats_Basic(t *testing.T) {
  269. mockSearcher := &MockSearcher{}
  270. s := NewService(mockSearcher)
  271. ctx := context.Background()
  272. req := &searcher.SearchRequest{
  273. Limit: 100,
  274. Offset: 0,
  275. }
  276. expectedResult := &searcher.SearchResult{
  277. TotalHits: 1000,
  278. Facets: map[string]*searcher.Facet{
  279. "status": {
  280. Terms: []*searcher.FacetTerm{
  281. {Term: "200", Count: 800},
  282. {Term: "404", Count: 150},
  283. {Term: "500", Count: 50},
  284. },
  285. },
  286. "method": {
  287. Terms: []*searcher.FacetTerm{
  288. {Term: "GET", Count: 700},
  289. {Term: "POST", Count: 300},
  290. },
  291. },
  292. "path_exact": {
  293. Terms: []*searcher.FacetTerm{
  294. {Term: "/api/users", Count: 400},
  295. {Term: "/api/posts", Count: 300},
  296. },
  297. },
  298. "ip": {
  299. Terms: []*searcher.FacetTerm{
  300. {Term: "192.168.1.1", Count: 500},
  301. {Term: "192.168.1.2", Count: 300},
  302. },
  303. },
  304. "user_agent": {
  305. Terms: []*searcher.FacetTerm{
  306. {Term: "Chrome", Count: 600},
  307. {Term: "Firefox", Count: 400},
  308. },
  309. },
  310. },
  311. Stats: &searcher.SearchStats{
  312. TotalBytes: 1000000,
  313. AvgBytes: 1000,
  314. MinBytes: 100,
  315. MaxBytes: 5000,
  316. AvgReqTime: 0.5,
  317. MinReqTime: 0.1,
  318. MaxReqTime: 2.0,
  319. },
  320. }
  321. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  322. result, err := s.GetLogEntriesStats(ctx, req)
  323. assert.NoError(t, err)
  324. assert.NotNil(t, result)
  325. assert.Equal(t, int64(1000), result.TotalEntries)
  326. assert.Equal(t, 800, result.StatusCodeDist["200"])
  327. assert.Equal(t, 150, result.StatusCodeDist["404"])
  328. assert.Equal(t, 700, result.MethodDist["GET"])
  329. assert.Equal(t, 300, result.MethodDist["POST"])
  330. assert.NotNil(t, result.BytesStats)
  331. assert.Equal(t, int64(1000000), result.BytesStats.Total)
  332. assert.NotNil(t, result.ResponseTimeStats)
  333. assert.Equal(t, 0.5, result.ResponseTimeStats.Average)
  334. mockSearcher.AssertExpectations(t)
  335. }
  336. func TestService_buildBaseSearchRequest(t *testing.T) {
  337. mockSearcher := &MockSearcher{}
  338. s := NewService(mockSearcher).(*service)
  339. tests := []struct {
  340. name string
  341. startTime int64
  342. endTime int64
  343. logPath string
  344. }{
  345. {
  346. name: "with time range",
  347. startTime: 1000,
  348. endTime: 2000,
  349. logPath: "/var/log/nginx/access.log",
  350. },
  351. {
  352. name: "without time range",
  353. startTime: 0,
  354. endTime: 0,
  355. logPath: "",
  356. },
  357. }
  358. for _, tt := range tests {
  359. t.Run(tt.name, func(t *testing.T) {
  360. req := s.buildBaseSearchRequest(tt.startTime, tt.endTime, tt.logPath)
  361. assert.NotNil(t, req)
  362. assert.Equal(t, DefaultLimit, req.Limit)
  363. assert.Equal(t, 0, req.Offset)
  364. assert.True(t, req.UseCache)
  365. if tt.startTime > 0 {
  366. assert.NotNil(t, req.StartTime)
  367. assert.Equal(t, tt.startTime, *req.StartTime)
  368. } else {
  369. assert.Nil(t, req.StartTime)
  370. }
  371. if tt.endTime > 0 {
  372. assert.NotNil(t, req.EndTime)
  373. assert.Equal(t, tt.endTime, *req.EndTime)
  374. } else {
  375. assert.Nil(t, req.EndTime)
  376. }
  377. })
  378. }
  379. }
  380. func TestService_validateAndNormalizeSearchRequest(t *testing.T) {
  381. mockSearcher := &MockSearcher{}
  382. s := NewService(mockSearcher).(*service)
  383. tests := []struct {
  384. name string
  385. req *searcher.SearchRequest
  386. wantErr bool
  387. }{
  388. {
  389. name: "nil request",
  390. req: nil,
  391. wantErr: true,
  392. },
  393. {
  394. name: "valid request",
  395. req: &searcher.SearchRequest{
  396. Limit: 10,
  397. Offset: 0,
  398. },
  399. wantErr: false,
  400. },
  401. {
  402. name: "zero limit gets default",
  403. req: &searcher.SearchRequest{
  404. Limit: 0,
  405. Offset: 0,
  406. },
  407. wantErr: false,
  408. },
  409. {
  410. name: "negative offset gets normalized",
  411. req: &searcher.SearchRequest{
  412. Limit: 10,
  413. Offset: -10,
  414. },
  415. wantErr: false,
  416. },
  417. {
  418. name: "limit too high gets capped",
  419. req: &searcher.SearchRequest{
  420. Limit: 10000,
  421. Offset: 0,
  422. },
  423. wantErr: false,
  424. },
  425. }
  426. for _, tt := range tests {
  427. t.Run(tt.name, func(t *testing.T) {
  428. err := s.validateAndNormalizeSearchRequest(tt.req)
  429. if tt.wantErr {
  430. assert.Error(t, err)
  431. } else {
  432. assert.NoError(t, err)
  433. if tt.req != nil {
  434. if tt.name == "zero limit gets default" {
  435. assert.Equal(t, DefaultLimit, tt.req.Limit)
  436. }
  437. if tt.name == "negative offset gets normalized" {
  438. assert.Equal(t, 0, tt.req.Offset)
  439. }
  440. if tt.name == "limit too high gets capped" {
  441. assert.Equal(t, MaxLimit, tt.req.Limit)
  442. }
  443. }
  444. }
  445. })
  446. }
  447. }