package com.ruoyi.app.map; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.spring.SpringUtils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.support.StaticMessageSource; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class MapLocationServiceTest { private static ConfigurableListableBeanFactory originalBeanFactory; private final MapSourceSelector selector = mock(MapSourceSelector.class); private final SelfHostedMapClient selfHosted = mock(SelfHostedMapClient.class); private final GoogleMapLocationClient google = mock(GoogleMapLocationClient.class); private MapLocationService service; @BeforeAll static void messages() { originalBeanFactory = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory"); StaticMessageSource messages = new StaticMessageSource(); messages.setUseCodeAsDefaultMessage(true); DefaultListableBeanFactory beans = new DefaultListableBeanFactory(); beans.registerSingleton("messageSource", messages); new SpringUtils().postProcessBeanFactory(beans); } @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(originalBeanFactory); } @BeforeEach void setUp() { service = new MapLocationService(selector, selfHosted, google); when(selector.selfHostedEnabled()).thenReturn(true); } @Test void searchMapsFlatSelfHostedAddressAndProviderNamespace() { when(selfHosted.getArray(eq("/search"), anyMap())).thenReturn(JSON.parseArray(""" [{"id":"osm:123","name":"台北車站","address":"臺北市中正區","lat":25.0478, "lng":121.517,"source":"osm","precision":"poi"}] """)); MapLocation result = service.search(" 台北車站 ").get(0); assertEquals("self_hosted:osm:123", result.getId()); assertEquals("台北車站", result.getName()); assertEquals(new BigDecimal("25.0478"), result.getLatitude()); assertEquals("SELF_HOSTED", result.getSource()); assertTrue(result.isRequiresConfirmation()); verifyNoInteractions(google); } @Test void reverseRetainsExactTapInsteadOfNearestCoordinates() { when(selfHosted.getObject(eq("/reverse_geocode"), anyMap())).thenReturn(JSON.parseObject(""" {"id":"pin:25,121","name":"松智路","address":"松智路","lat":25.03,"lng":121.56, "precision":"nearby_place","requires_confirmation":true, "nearest":{"id":"osm:8","lat":25.04,"lng":121.57}} """)); MapLocation result = service.reverse(new BigDecimal("25.033000123"), new BigDecimal("121.565400987")); assertEquals(new BigDecimal("25.033000123"), result.getLatitude()); assertEquals(new BigDecimal("121.565400987"), result.getLongitude()); assertEquals("nearby_place", result.getPrecision()); assertTrue(result.isRequiresConfirmation()); } @Test void googleSearchAndReverseNormalizeGeometryAndStructuredAddress() { when(selector.selfHostedEnabled()).thenReturn(false); JSONObject place = JSON.parseObject(""" {"place_id":"ChIJ123","name":"台北車站","formatted_address":"臺北市中正區", "geometry":{"location":{"lat":25.0478,"lng":121.517},"location_type":"ROOFTOP"}, "address_components":[{"long_name":"臺灣","types":["country"]}, {"long_name":"臺北市","types":["administrative_area_level_1"]}]} """); JSONObject response = new JSONObject(); response.put("status", "OK"); response.put("results", List.of(place)); when(google.getObject(eq("/maps/api/place/textsearch/json"), anyMap())).thenReturn(response); when(google.getObject(eq("/maps/api/geocode/json"), anyMap())).thenReturn(response); MapLocation found = service.search("台北").get(0); assertEquals("google:ChIJ123", found.getId()); assertEquals("GOOGLE", found.getSource()); assertEquals("臺灣", found.getCountry()); assertEquals("臺北市", found.getProvince()); MapLocation reverse = service.reverse(new BigDecimal("25.1"), new BigDecimal("121.6")); assertEquals(new BigDecimal("25.1"), reverse.getLatitude()); assertEquals(new BigDecimal("121.6"), reverse.getLongitude()); assertEquals("google:pin:25.1,121.6", reverse.getId()); assertEquals("nearby_place", reverse.getPrecision()); assertTrue(reverse.isRequiresConfirmation()); verifyNoInteractions(selfHosted); } @Test void namespacedDetailRemainsResolvableAfterSourceSwitch() { when(selector.selfHostedEnabled()).thenReturn(false); when(selfHosted.getObject("/place_detail", Map.of("id", "osm:9"))).thenReturn(JSON.parseObject(""" {"id":"osm:9","name":"門店","lat":25,"lng":121,"precision":"poi"} """)); assertEquals("self_hosted:osm:9", service.place("self_hosted:osm:9").getId()); verifyNoInteractions(google); when(selector.selfHostedEnabled()).thenReturn(true); when(google.getObject("/maps/api/place/details/json", Map.of("place_id", "abc"))) .thenReturn(JSON.parseObject(""" {"status":"OK","result":{"place_id":"abc","name":"店", "geometry":{"location":{"lat":25,"lng":121}}}} """)); assertEquals("google:abc", service.place("google:abc").getId()); } @Test void nearbyMapsDistanceAndForwardsBoundedRequest() { when(selfHosted.getArray(eq("/nearby"), anyMap())).thenReturn(JSON.parseArray(""" [{"id":"osm:1","name":"道路","lat":25,"lng":121, "precision":"street","distance_meters":123.4}] """)); MapLocation result = service.nearby(BigDecimal.valueOf(25), BigDecimal.valueOf(121), 300, 10).get(0); assertEquals(new BigDecimal("123.4"), result.getDistanceMeters()); verify(selfHosted).getArray("/nearby", Map.of("lat", BigDecimal.valueOf(25), "lng", BigDecimal.valueOf(121), "radius", 300, "limit", 10)); } @Test void malformedResponsesAndFailureNeverFallBackToGoogle() { for (String body : List.of("[{\"id\":\"1\",\"name\":\"bad\"}]", "[{\"id\":\"1\",\"name\":\"bad\",\"lat\":91,\"lng\":121}]", "[null]")) { when(selfHosted.getArray(eq("/search"), anyMap())).thenReturn(JSON.parseArray(body)); assertEquals("map.location.unavailable", assertThrows(ServiceException.class, () -> service.search("台北")).getMessage()); } when(selfHosted.getArray(eq("/search"), anyMap())).thenThrow(new IllegalStateException("offline")); assertThrows(ServiceException.class, () -> service.search("台北")); verifyNoInteractions(google); } @Test void googleZeroResultsIsEmptyButDeniedOrMalformedResponseFails() { when(selector.selfHostedEnabled()).thenReturn(false); when(google.getObject(anyString(), anyMap())).thenReturn(JSON.parseObject("{\"status\":\"ZERO_RESULTS\"}")); assertTrue(service.search("unknown").isEmpty()); MapLocation pin = service.reverse(new BigDecimal("25.1"), new BigDecimal("121.6")); assertEquals("google:pin:25.1,121.6", pin.getId()); assertEquals("coordinate", pin.getPrecision()); assertTrue(pin.isRequiresConfirmation()); for (String body : List.of("{\"status\":\"REQUEST_DENIED\"}", "{\"status\":\"OK\"}", "{\"status\":\"OK\",\"results\":[{\"place_id\":\"bad\"}]}")) { when(google.getObject(anyString(), anyMap())).thenReturn(JSON.parseObject(body)); assertThrows(ServiceException.class, () -> service.search("台北")); } } @Test void invalidRequestsAreRejectedBeforeProviderCall() { assertThrows(ServiceException.class, () -> service.search(" ")); assertThrows(ServiceException.class, () -> service.search("a".repeat(201))); assertThrows(ServiceException.class, () -> service.reverse(null, BigDecimal.ONE)); assertThrows(ServiceException.class, () -> service.reverse(BigDecimal.valueOf(91), BigDecimal.ONE)); assertThrows(ServiceException.class, () -> service.nearby(BigDecimal.ONE, BigDecimal.ONE, 2001, 10)); assertThrows(ServiceException.class, () -> service.nearby(BigDecimal.ONE, BigDecimal.ONE, 300, 51)); assertThrows(ServiceException.class, () -> service.place("osm:123")); verifyNoInteractions(selfHosted, google); } }