geo_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  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_GetGeoDistribution_Success(t *testing.T) {
  10. mockSearcher := &MockSearcher{}
  11. s := NewService(mockSearcher)
  12. ctx := context.Background()
  13. req := &GeoQueryRequest{
  14. StartTime: 1000,
  15. EndTime: 2000,
  16. LogPaths: []string{"/var/log/nginx/access.log"},
  17. }
  18. expectedResult := &searcher.SearchResult{
  19. TotalHits: 4,
  20. Facets: map[string]*searcher.Facet{
  21. "region_code": {
  22. Terms: []*searcher.FacetTerm{
  23. {Term: "US", Count: 2},
  24. {Term: "CA", Count: 1},
  25. {Term: "GB", Count: 1},
  26. },
  27. },
  28. },
  29. }
  30. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  31. result, err := s.GetGeoDistribution(ctx, req)
  32. assert.NoError(t, err)
  33. assert.NotNil(t, result)
  34. assert.Len(t, result.Countries, 3)
  35. assert.Equal(t, 2, result.Countries["US"])
  36. assert.Equal(t, 1, result.Countries["CA"])
  37. assert.Equal(t, 1, result.Countries["GB"])
  38. mockSearcher.AssertExpectations(t)
  39. }
  40. func TestService_GetGeoDistributionByCountry_Success(t *testing.T) {
  41. mockSearcher := &MockSearcher{}
  42. s := NewService(mockSearcher)
  43. ctx := context.Background()
  44. req := &GeoQueryRequest{
  45. StartTime: 1000,
  46. EndTime: 2000,
  47. LogPaths: []string{"/var/log/nginx/access.log"},
  48. }
  49. // Mock search result with province data for CN
  50. expectedResult := &searcher.SearchResult{
  51. TotalHits: 4185, // Same as WorldMap CN count
  52. Facets: map[string]*searcher.Facet{
  53. "province": {
  54. Total: 4185,
  55. Missing: 0,
  56. Terms: []*searcher.FacetTerm{
  57. {Term: "广东", Count: 2000},
  58. {Term: "北京", Count: 1500},
  59. {Term: "上海", Count: 500},
  60. {Term: "其它", Count: 185},
  61. },
  62. },
  63. },
  64. }
  65. // Verify that the search request uses Countries filter correctly
  66. mockSearcher.On("Search", ctx, mock.MatchedBy(func(searchReq *searcher.SearchRequest) bool {
  67. // Check that Countries filter is set correctly
  68. return len(searchReq.Countries) == 1 &&
  69. searchReq.Countries[0] == "CN" &&
  70. len(searchReq.FacetFields) == 1 &&
  71. searchReq.FacetFields[0] == "province"
  72. })).Return(expectedResult, nil)
  73. result, err := s.GetGeoDistributionByCountry(ctx, req, "CN")
  74. assert.NoError(t, err)
  75. assert.NotNil(t, result)
  76. assert.Len(t, result.Countries, 4) // 4 provinces
  77. assert.Equal(t, 2000, result.Countries["广东"])
  78. assert.Equal(t, 1500, result.Countries["北京"])
  79. assert.Equal(t, 500, result.Countries["上海"])
  80. assert.Equal(t, 185, result.Countries["其它"])
  81. mockSearcher.AssertExpectations(t)
  82. }
  83. func TestService_GetGeoDistributionByCountry_EmptyProvinceFacet(t *testing.T) {
  84. mockSearcher := &MockSearcher{}
  85. s := NewService(mockSearcher)
  86. ctx := context.Background()
  87. req := &GeoQueryRequest{
  88. StartTime: 1000,
  89. EndTime: 2000,
  90. LogPaths: []string{"/var/log/nginx/access.log"},
  91. }
  92. // Mock search result with no province facet (current real-world behavior)
  93. expectedResult := &searcher.SearchResult{
  94. TotalHits: 4185,
  95. Facets: map[string]*searcher.Facet{
  96. "region_code": {
  97. Total: 1,
  98. Terms: []*searcher.FacetTerm{
  99. {Term: "CN", Count: 4185},
  100. },
  101. },
  102. },
  103. }
  104. mockSearcher.On("Search", ctx, mock.MatchedBy(func(searchReq *searcher.SearchRequest) bool {
  105. return len(searchReq.Countries) == 1 && searchReq.Countries[0] == "CN"
  106. })).Return(expectedResult, nil)
  107. result, err := s.GetGeoDistributionByCountry(ctx, req, "CN")
  108. assert.NoError(t, err)
  109. assert.NotNil(t, result)
  110. assert.Len(t, result.Countries, 0) // No provinces returned
  111. mockSearcher.AssertExpectations(t)
  112. }
  113. func TestService_GetGeoDistributionByCountry_CountriesFilterValidation(t *testing.T) {
  114. mockSearcher := &MockSearcher{}
  115. s := NewService(mockSearcher)
  116. ctx := context.Background()
  117. req := &GeoQueryRequest{
  118. StartTime: 1000,
  119. EndTime: 2000,
  120. LogPaths: []string{"/var/log/nginx/access.log"},
  121. }
  122. tests := []struct {
  123. name string
  124. countryCode string
  125. expectError bool
  126. }{
  127. {
  128. name: "valid country code CN",
  129. countryCode: "CN",
  130. expectError: false,
  131. },
  132. {
  133. name: "valid country code US",
  134. countryCode: "US",
  135. expectError: false,
  136. },
  137. {
  138. name: "empty country code",
  139. countryCode: "",
  140. expectError: false, // Should work, just return empty results
  141. },
  142. }
  143. for _, tt := range tests {
  144. t.Run(tt.name, func(t *testing.T) {
  145. expectedResult := &searcher.SearchResult{
  146. TotalHits: 100,
  147. Facets: map[string]*searcher.Facet{},
  148. }
  149. mockSearcher.On("Search", ctx, mock.MatchedBy(func(searchReq *searcher.SearchRequest) bool {
  150. if tt.countryCode == "" {
  151. return len(searchReq.Countries) == 1 && searchReq.Countries[0] == ""
  152. }
  153. return len(searchReq.Countries) == 1 && searchReq.Countries[0] == tt.countryCode
  154. })).Return(expectedResult, nil).Once()
  155. result, err := s.GetGeoDistributionByCountry(ctx, req, tt.countryCode)
  156. if tt.expectError {
  157. assert.Error(t, err)
  158. } else {
  159. assert.NoError(t, err)
  160. assert.NotNil(t, result)
  161. }
  162. })
  163. }
  164. mockSearcher.AssertExpectations(t)
  165. }
  166. func TestService_GeoDataConsistency_ChinaVsWorld(t *testing.T) {
  167. // This test verifies that ChinaMap total matches WorldMap CN count
  168. mockSearcher := &MockSearcher{}
  169. s := NewService(mockSearcher)
  170. ctx := context.Background()
  171. req := &GeoQueryRequest{
  172. StartTime: 1755014400,
  173. EndTime: 1755705599,
  174. LogPaths: []string{"/var/log/nginx/access.log"},
  175. }
  176. // Mock WorldMap result
  177. worldMapResult := &searcher.SearchResult{
  178. TotalHits: 12845,
  179. Facets: map[string]*searcher.Facet{
  180. "region_code": {
  181. Total: 53,
  182. Terms: []*searcher.FacetTerm{
  183. {Term: "CN", Count: 4185}, // Key: CN count should match ChinaMap total
  184. {Term: "FR", Count: 3056},
  185. {Term: "US", Count: 1456},
  186. {Term: "DE", Count: 1152},
  187. // ... other countries
  188. },
  189. },
  190. },
  191. }
  192. // Mock ChinaMap result with same total
  193. chinaMapResult := &searcher.SearchResult{
  194. TotalHits: 4185, // Should match CN count from WorldMap
  195. Facets: map[string]*searcher.Facet{
  196. "province": {
  197. Total: 4185,
  198. Missing: 0,
  199. Terms: []*searcher.FacetTerm{
  200. {Term: "广东", Count: 2000},
  201. {Term: "北京", Count: 1500},
  202. {Term: "上海", Count: 500},
  203. {Term: "其它", Count: 185},
  204. },
  205. },
  206. },
  207. }
  208. // Setup mock expectations
  209. // First call: GetGeoDistribution (WorldMap)
  210. mockSearcher.On("Search", ctx, mock.MatchedBy(func(searchReq *searcher.SearchRequest) bool {
  211. return len(searchReq.Countries) == 0 && // No country filter for world map
  212. len(searchReq.FacetFields) == 1 &&
  213. searchReq.FacetFields[0] == "region_code"
  214. })).Return(worldMapResult, nil).Once()
  215. // Second call: GetGeoDistributionByCountry (ChinaMap)
  216. mockSearcher.On("Search", ctx, mock.MatchedBy(func(searchReq *searcher.SearchRequest) bool {
  217. return len(searchReq.Countries) == 1 &&
  218. searchReq.Countries[0] == "CN" &&
  219. len(searchReq.FacetFields) == 1 &&
  220. searchReq.FacetFields[0] == "province"
  221. })).Return(chinaMapResult, nil).Once()
  222. // Test WorldMap
  223. worldResult, err := s.GetGeoDistribution(ctx, req)
  224. assert.NoError(t, err)
  225. assert.NotNil(t, worldResult)
  226. cnCountInWorld := worldResult.Countries["CN"]
  227. assert.Equal(t, 4185, cnCountInWorld)
  228. // Test ChinaMap
  229. chinaResult, err := s.GetGeoDistributionByCountry(ctx, req, "CN")
  230. assert.NoError(t, err)
  231. assert.NotNil(t, chinaResult)
  232. // Calculate total from provinces
  233. totalChinaVisits := 0
  234. for _, count := range chinaResult.Countries {
  235. totalChinaVisits += count
  236. }
  237. // Verify consistency: WorldMap CN count should equal ChinaMap total
  238. assert.Equal(t, cnCountInWorld, totalChinaVisits,
  239. "WorldMap CN count (%d) should equal ChinaMap total (%d)",
  240. cnCountInWorld, totalChinaVisits)
  241. mockSearcher.AssertExpectations(t)
  242. }
  243. func TestService_GetGeoDistribution_NilRequest(t *testing.T) {
  244. mockSearcher := &MockSearcher{}
  245. s := NewService(mockSearcher)
  246. ctx := context.Background()
  247. result, err := s.GetGeoDistribution(ctx, nil)
  248. assert.Error(t, err)
  249. assert.Nil(t, result)
  250. assert.Contains(t, err.Error(), "request cannot be nil")
  251. }
  252. func TestService_GetGeoDistribution_InvalidTimeRange(t *testing.T) {
  253. mockSearcher := &MockSearcher{}
  254. s := NewService(mockSearcher)
  255. ctx := context.Background()
  256. req := &GeoQueryRequest{
  257. StartTime: 2000,
  258. EndTime: 1000, // End before start
  259. }
  260. // We don't need to mock the searcher as the time range validation should fail first.
  261. result, err := s.GetGeoDistribution(ctx, req)
  262. assert.Error(t, err)
  263. assert.Nil(t, result)
  264. assert.Contains(t, err.Error(), "invalid time range")
  265. }
  266. func TestService_GetGeoDistribution_SearchError(t *testing.T) {
  267. mockSearcher := &MockSearcher{}
  268. s := NewService(mockSearcher)
  269. ctx := context.Background()
  270. req := &GeoQueryRequest{
  271. StartTime: 1000,
  272. EndTime: 2000,
  273. }
  274. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(nil, assert.AnError)
  275. result, err := s.GetGeoDistribution(ctx, req)
  276. assert.Error(t, err)
  277. assert.Nil(t, result)
  278. assert.Contains(t, err.Error(), "failed to get geo distribution")
  279. mockSearcher.AssertExpectations(t)
  280. }
  281. func TestService_GetTopCountries_Success(t *testing.T) {
  282. mockSearcher := &MockSearcher{}
  283. s := NewService(mockSearcher)
  284. ctx := context.Background()
  285. req := &GeoQueryRequest{
  286. StartTime: 1000,
  287. EndTime: 2000,
  288. Limit: 2, // Limit to top 2
  289. LogPaths: []string{"/var/log/nginx/access.log"},
  290. }
  291. expectedResult := &searcher.SearchResult{
  292. TotalHits: 6,
  293. Facets: map[string]*searcher.Facet{
  294. "region_code": {
  295. Terms: []*searcher.FacetTerm{
  296. {Term: "US", Count: 3},
  297. {Term: "CN", Count: 2},
  298. },
  299. },
  300. },
  301. }
  302. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  303. result, err := s.GetTopCountries(ctx, req)
  304. assert.NoError(t, err)
  305. assert.NotNil(t, result)
  306. assert.Len(t, result, 2)
  307. // Check that results are sorted and limited
  308. assert.Equal(t, "US", result[0].Country)
  309. assert.Equal(t, 3, result[0].Requests)
  310. assert.Equal(t, "CN", result[1].Country)
  311. assert.Equal(t, 2, result[1].Requests)
  312. mockSearcher.AssertExpectations(t)
  313. }
  314. func TestService_GetTopCities_Success(t *testing.T) {
  315. mockSearcher := &MockSearcher{}
  316. s := NewService(mockSearcher)
  317. ctx := context.Background()
  318. req := &GeoQueryRequest{
  319. StartTime: 1000,
  320. EndTime: 2000,
  321. Limit: 2,
  322. }
  323. expectedResult := &searcher.SearchResult{
  324. TotalHits: 1000,
  325. Facets: map[string]*searcher.Facet{
  326. "city": {
  327. Field: "city",
  328. Total: 1000,
  329. Terms: []*searcher.FacetTerm{
  330. {Term: "New York", Count: 400},
  331. {Term: "Toronto", Count: 300},
  332. },
  333. },
  334. },
  335. }
  336. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  337. result, err := s.GetTopCities(ctx, req)
  338. assert.NoError(t, err)
  339. assert.NotNil(t, result)
  340. assert.Len(t, result, 2)
  341. assert.Equal(t, "New York", result[0].City)
  342. assert.Equal(t, 400, result[0].Count)
  343. mockSearcher.AssertExpectations(t)
  344. }
  345. func TestService_GetGeoStatsForIP_Success(t *testing.T) {
  346. mockSearcher := &MockSearcher{}
  347. s := NewService(mockSearcher)
  348. ctx := context.Background()
  349. req := &GeoQueryRequest{
  350. StartTime: 1000,
  351. EndTime: 2000,
  352. }
  353. ip := "192.168.1.1"
  354. expectedResult := &searcher.SearchResult{
  355. TotalHits: 150,
  356. Facets: map[string]*searcher.Facet{
  357. "country": {
  358. Field: "country",
  359. Terms: []*searcher.FacetTerm{
  360. {Term: "United States", Count: 150},
  361. },
  362. },
  363. "country_code": {
  364. Field: "country_code",
  365. Terms: []*searcher.FacetTerm{
  366. {Term: "US", Count: 150},
  367. },
  368. },
  369. "city": {
  370. Field: "city",
  371. Terms: []*searcher.FacetTerm{
  372. {Term: "New York", Count: 150},
  373. },
  374. },
  375. },
  376. }
  377. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  378. result, err := s.GetGeoStatsForIP(ctx, req, ip)
  379. assert.NoError(t, err)
  380. assert.NotNil(t, result)
  381. assert.Equal(t, "New York", result.City)
  382. assert.Equal(t, "United States", result.Country)
  383. assert.Equal(t, "US", result.CountryCode)
  384. assert.Equal(t, 150, result.Count)
  385. assert.Equal(t, 100.0, result.Percent) // 100% for single IP
  386. mockSearcher.AssertExpectations(t)
  387. }
  388. func TestService_GetGeoStatsForIP_EmptyIP(t *testing.T) {
  389. mockSearcher := &MockSearcher{}
  390. s := NewService(mockSearcher)
  391. ctx := context.Background()
  392. req := &GeoQueryRequest{
  393. StartTime: 1000,
  394. EndTime: 2000,
  395. }
  396. result, err := s.GetGeoStatsForIP(ctx, req, "")
  397. assert.Error(t, err)
  398. assert.Nil(t, result)
  399. assert.Contains(t, err.Error(), "IP address cannot be empty")
  400. }
  401. func TestService_GetGeoStatsForIP_NoData(t *testing.T) {
  402. mockSearcher := &MockSearcher{}
  403. s := NewService(mockSearcher)
  404. ctx := context.Background()
  405. req := &GeoQueryRequest{
  406. StartTime: 1000,
  407. EndTime: 2000,
  408. }
  409. ip := "192.168.1.1"
  410. expectedResult := &searcher.SearchResult{
  411. TotalHits: 0, // No data found
  412. Facets: make(map[string]*searcher.Facet),
  413. }
  414. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  415. result, err := s.GetGeoStatsForIP(ctx, req, ip)
  416. assert.Error(t, err)
  417. assert.Nil(t, result)
  418. assert.Contains(t, err.Error(), "no data found for IP")
  419. mockSearcher.AssertExpectations(t)
  420. }
  421. func TestService_GetGeoStatsForIP_NoGeoData(t *testing.T) {
  422. mockSearcher := &MockSearcher{}
  423. s := NewService(mockSearcher)
  424. ctx := context.Background()
  425. req := &GeoQueryRequest{
  426. StartTime: 1000,
  427. EndTime: 2000,
  428. }
  429. ip := "192.168.1.1"
  430. expectedResult := &searcher.SearchResult{
  431. TotalHits: 100,
  432. Facets: nil, // No geo facets
  433. }
  434. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  435. result, err := s.GetGeoStatsForIP(ctx, req, ip)
  436. assert.Error(t, err)
  437. assert.Nil(t, result)
  438. assert.Contains(t, err.Error(), "could not extract geo information")
  439. mockSearcher.AssertExpectations(t)
  440. }
  441. func TestService_GetGeoDistribution_DefaultLimit(t *testing.T) {
  442. mockSearcher := &MockSearcher{}
  443. s := NewService(mockSearcher)
  444. ctx := context.Background()
  445. req := &GeoQueryRequest{
  446. StartTime: 1000,
  447. EndTime: 2000,
  448. Limit: 0, // Should use default
  449. }
  450. expectedResult := &searcher.SearchResult{
  451. TotalHits: 1000,
  452. Facets: map[string]*searcher.Facet{
  453. "country": {
  454. Field: "country",
  455. Total: 1000,
  456. Terms: []*searcher.FacetTerm{
  457. {Term: "United States", Count: 600},
  458. {Term: "Canada", Count: 400},
  459. },
  460. },
  461. },
  462. }
  463. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  464. result, err := s.GetGeoDistribution(ctx, req)
  465. assert.NoError(t, err)
  466. assert.NotNil(t, result)
  467. // Should work with default limit
  468. mockSearcher.AssertExpectations(t)
  469. }
  470. func TestService_GetGeoDistribution_MaxLimit(t *testing.T) {
  471. mockSearcher := &MockSearcher{}
  472. s := NewService(mockSearcher)
  473. ctx := context.Background()
  474. req := &GeoQueryRequest{
  475. StartTime: 1000,
  476. EndTime: 2000,
  477. Limit: 99999, // Should be capped to MaxLimit
  478. }
  479. expectedResult := &searcher.SearchResult{
  480. TotalHits: 1000,
  481. Facets: map[string]*searcher.Facet{
  482. "country": {
  483. Field: "country",
  484. Total: 1000,
  485. Terms: []*searcher.FacetTerm{
  486. {Term: "United States", Count: 600},
  487. {Term: "Canada", Count: 400},
  488. },
  489. },
  490. },
  491. }
  492. mockSearcher.On("Search", ctx, mock.AnythingOfType("*searcher.SearchRequest")).Return(expectedResult, nil)
  493. result, err := s.GetGeoDistribution(ctx, req)
  494. assert.NoError(t, err)
  495. assert.NotNil(t, result)
  496. // Should work with capped limit
  497. mockSearcher.AssertExpectations(t)
  498. }