diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java new file mode 100644 index 0000000..ec2f655 --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java @@ -0,0 +1,259 @@ +package com.walkguide.service; + +import com.walkguide.dto.request.LocationUpdateRequest; +import com.walkguide.dto.response.LocationResponse; +import com.walkguide.entity.GeofenceConfig; +import com.walkguide.entity.LocationHistory; +import com.walkguide.entity.PairingRelation; +import com.walkguide.entity.User; +import com.walkguide.enums.PairingStatus; +import com.walkguide.repository.*; +import com.walkguide.websocket.LocationBroadcaster; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LocationService Unit Tests") +class LocationServiceTest { + + @Mock LocationHistoryRepository locationHistoryRepository; + @Mock GeofenceConfigRepository geofenceConfigRepository; + @Mock PairingRelationRepository pairingRelationRepository; + @Mock UserRepository userRepository; + @Mock ActivityLogService activityLogService; + @Mock FcmService fcmService; + @Mock LocationBroadcaster locationBroadcaster; + + @InjectMocks LocationService locationService; + + private User guardian; + private User user; + private PairingRelation activePairing; + private LocationHistory sampleLocation; + + @BeforeEach + void setUp() { + guardian = User.builder() + .id(1L).email("guardian@test.com").displayName("Guardian Test") + .fcmToken("guardian-fcm-token").build(); + + user = User.builder() + .id(2L).email("user@test.com").displayName("User Test") + .fcmToken("user-fcm-token").build(); + + activePairing = PairingRelation.builder() + .id(1L).guardian(guardian).user(user).status(PairingStatus.ACTIVE).build(); + + sampleLocation = LocationHistory.builder() + .id(100L).userId(2L).lat(-7.257).lng(112.752) + .accuracy(10.0).speed(1.5).heading(90.0) + .createdAt(LocalDateTime.now()).build(); + } + + // ===== updateLocation TESTS ===== + + @Test + @DisplayName("updateLocation - harus simpan lokasi dan broadcast via WebSocket") + void updateLocation_shouldSaveAndBroadcast() { + LocationUpdateRequest req = new LocationUpdateRequest(); + req.setLat(-7.257); + req.setLng(112.752); + req.setAccuracy(10.0); + req.setSpeed(1.5); + req.setHeading(90.0); + + when(locationHistoryRepository.save(any(LocationHistory.class))).thenReturn(sampleLocation); + when(geofenceConfigRepository.findByUserId(2L)).thenReturn(Optional.empty()); + + LocationResponse result = locationService.updateLocation(2L, req); + + assertThat(result).isNotNull(); + assertThat(result.getLat()).isEqualTo(-7.257); + assertThat(result.getLng()).isEqualTo(112.752); + verify(locationHistoryRepository).save(any(LocationHistory.class)); + verify(locationBroadcaster).broadcastLocation(eq(2L), any(LocationResponse.class)); + } + + @Test + @DisplayName("updateLocation - user dalam geofence: tidak boleh kirim FCM ke guardian") + void updateLocation_insideGeofence_shouldNotTriggerFcm() { + LocationUpdateRequest req = new LocationUpdateRequest(); + req.setLat(-7.257); // tepat di center + req.setLng(112.752); + + GeofenceConfig cfg = GeofenceConfig.builder() + .userId(2L).centerLat(-7.257).centerLng(112.752) + .radiusMeters(500.0).enabled(true).build(); + + when(locationHistoryRepository.save(any())).thenReturn(sampleLocation); + when(geofenceConfigRepository.findByUserId(2L)).thenReturn(Optional.of(cfg)); + + locationService.updateLocation(2L, req); + + verify(fcmService, never()).sendHighPriority(any(), any(), any(), any()); + } + + @Test + @DisplayName("updateLocation - user keluar geofence: harus kirim FCM ke guardian") + void updateLocation_outsideGeofence_shouldSendFcmToGuardian() { + LocationUpdateRequest req = new LocationUpdateRequest(); + req.setLat(-7.500); // jauh dari center + req.setLng(113.000); + + GeofenceConfig cfg = GeofenceConfig.builder() + .userId(2L).centerLat(-7.257).centerLng(112.752) + .radiusMeters(100.0).enabled(true).build(); + + LocationHistory savedLoc = LocationHistory.builder() + .id(101L).userId(2L).lat(-7.500).lng(113.000) + .createdAt(LocalDateTime.now()).build(); + + when(locationHistoryRepository.save(any())).thenReturn(savedLoc); + when(geofenceConfigRepository.findByUserId(2L)).thenReturn(Optional.of(cfg)); + when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + doNothing().when(activityLogService).createLog(any(), any(), any(), any()); + + locationService.updateLocation(2L, req); + + verify(fcmService).sendHighPriority( + eq("guardian-fcm-token"), + contains("Keluar Area Aman"), + anyString(), + anyMap() + ); + } + + @Test + @DisplayName("updateLocation - geofence disabled: tidak boleh cek geofence") + void updateLocation_geofenceDisabled_shouldSkipGeofenceCheck() { + LocationUpdateRequest req = new LocationUpdateRequest(); + req.setLat(-7.500); + req.setLng(113.000); + + GeofenceConfig cfg = GeofenceConfig.builder() + .userId(2L).centerLat(-7.257).centerLng(112.752) + .radiusMeters(100.0).enabled(false).build(); // disabled + + when(locationHistoryRepository.save(any())).thenReturn(sampleLocation); + when(geofenceConfigRepository.findByUserId(2L)).thenReturn(Optional.of(cfg)); + + locationService.updateLocation(2L, req); + + verify(fcmService, never()).sendHighPriority(any(), any(), any(), any()); + } + + // ===== getLastLocation TESTS ===== + + @Test + @DisplayName("getLastLocation - ada history: harus return Optional berisi lokasi terakhir") + void getLastLocation_historyExists_shouldReturnLocation() { + when(locationHistoryRepository.findTopByUserIdOrderByCreatedAtDesc(2L)) + .thenReturn(Optional.of(sampleLocation)); + + Optional result = locationService.getLastLocation(2L); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(100L); + assertThat(result.get().getLat()).isEqualTo(-7.257); + } + + @Test + @DisplayName("getLastLocation - belum ada history: harus return Optional kosong") + void getLastLocation_noHistory_shouldReturnEmpty() { + when(locationHistoryRepository.findTopByUserIdOrderByCreatedAtDesc(99L)) + .thenReturn(Optional.empty()); + + Optional result = locationService.getLastLocation(99L); + + assertThat(result).isEmpty(); + } + + // ===== getLastLocationForGuardian TESTS ===== + + @Test + @DisplayName("getLastLocationForGuardian - ada pairing aktif: harus return lokasi user yang dipair") + void getLastLocationForGuardian_activePairing_shouldReturnUserLocation() { + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(locationHistoryRepository.findTopByUserIdOrderByCreatedAtDesc(2L)) + .thenReturn(Optional.of(sampleLocation)); + + Optional result = locationService.getLastLocationForGuardian(1L); + + assertThat(result).isPresent(); + assertThat(result.get().getLat()).isEqualTo(-7.257); + } + + @Test + @DisplayName("getLastLocationForGuardian - tidak ada pairing aktif: harus return Optional kosong") + void getLastLocationForGuardian_noPairing_shouldReturnEmpty() { + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + Optional result = locationService.getLastLocationForGuardian(1L); + + assertThat(result).isEmpty(); + verify(locationHistoryRepository, never()).findTopByUserIdOrderByCreatedAtDesc(any()); + } + + // ===== getLocationHistory TESTS ===== + + @Test + @DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable") + void getLocationHistory_shouldReturnPagedLocations() { + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(sampleLocation), pageable, 1); + + when(locationHistoryRepository.findByUserIdOrderByCreatedAtDesc(2L, pageable)).thenReturn(page); + + Page result = locationService.getLocationHistory(2L, pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257); + } + + // ===== haversineMeters TESTS ===== + + @Test + @DisplayName("haversineMeters - titik sama: jarak harus 0") + void haversineMeters_samePoint_shouldReturnZero() { + double dist = LocationService.haversineMeters(-7.257, 112.752, -7.257, 112.752); + assertThat(dist).isEqualTo(0.0); + } + + @Test + @DisplayName("haversineMeters - dua titik berbeda: harus menghitung jarak yang masuk akal") + void haversineMeters_differentPoints_shouldReturnPositiveDistance() { + // Surabaya ke Sidoarjo ~ 20km + double dist = LocationService.haversineMeters(-7.257, 112.752, -7.447, 112.718); + assertThat(dist).isGreaterThan(10_000).isLessThan(30_000); + } + + @Test + @DisplayName("haversineMeters - jarak simetris: A→B sama dengan B→A") + void haversineMeters_isSymmetric() { + double d1 = LocationService.haversineMeters(-7.257, 112.752, -7.300, 112.800); + double d2 = LocationService.haversineMeters(-7.300, 112.800, -7.257, 112.752); + assertThat(d1).isCloseTo(d2, within(0.001)); + } +} \ No newline at end of file