From b7a9079930d79a1861cfe04d1438e88c58e7d731 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Fri, 15 May 2026 22:33:42 +0700 Subject: [PATCH] test: add VoiceCommandServiceTest unit test --- .../service/VoiceCommandServiceTest.java | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/service/VoiceCommandServiceTest.java diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/VoiceCommandServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/VoiceCommandServiceTest.java new file mode 100644 index 0000000..b0a48f0 --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/VoiceCommandServiceTest.java @@ -0,0 +1,394 @@ +package com.walkguide.service; + +import com.walkguide.dto.request.VoiceCommandUpdateRequest; +import com.walkguide.dto.response.VoiceCommandResponse; +import com.walkguide.entity.PairingRelation; +import com.walkguide.entity.User; +import com.walkguide.entity.VoiceCommandConfig; +import com.walkguide.enums.PairingStatus; +import com.walkguide.enums.VoiceCommandKey; +import com.walkguide.exception.PairingException; +import com.walkguide.exception.ResourceNotFoundException; +import com.walkguide.repository.PairingRelationRepository; +import com.walkguide.repository.VoiceCommandConfigRepository; +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 java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("VoiceCommandService Unit Tests") +class VoiceCommandServiceTest { + + @Mock + VoiceCommandConfigRepository voiceCommandConfigRepository; + + @Mock + PairingRelationRepository pairingRelationRepository; + + @Mock + FcmService fcmService; + + @InjectMocks + VoiceCommandService voiceCommandService; + + private User guardianUser; + private User blindUser; + private PairingRelation activePairing; + private VoiceCommandConfig vcOpenWalkguide; + private VoiceCommandConfig vcCallGuardian; + private VoiceCommandConfig vcSendSos; + + @BeforeEach + void setUp() { + guardianUser = User.builder() + .id(1L) + .email("guardian@test.com") + .role("ROLE_GUARDIAN") + .displayName("Guardian Test") + .fcmToken("guardian-fcm-token") + .build(); + + blindUser = User.builder() + .id(2L) + .email("user@test.com") + .role("ROLE_USER") + .displayName("User Test") + .fcmToken("user-fcm-token-xyz") + .build(); + + activePairing = PairingRelation.builder() + .id(10L) + .guardian(guardianUser) + .user(blindUser) + .status(PairingStatus.ACTIVE) + .build(); + + vcOpenWalkguide = VoiceCommandConfig.builder() + .id(100L) + .userId(2L) + .commandKey(VoiceCommandKey.OPEN_WALKGUIDE) + .triggerPhrase("buka walkguide") + .enabled(true) + .build(); + + vcCallGuardian = VoiceCommandConfig.builder() + .id(101L) + .userId(2L) + .commandKey(VoiceCommandKey.CALL_GUARDIAN) + .triggerPhrase("telepon guardian") + .enabled(true) + .build(); + + vcSendSos = VoiceCommandConfig.builder() + .id(102L) + .userId(2L) + .commandKey(VoiceCommandKey.SEND_SOS) + .triggerPhrase("kirim sos") + .enabled(false) + .build(); + } + + // ===== getAll TESTS ===== + + @Test + @DisplayName("getAll - harus return semua voice command milik user sebagai list response") + void getAll_shouldReturnAllVoiceCommandsForUser() { + when(voiceCommandConfigRepository.findByUserId(2L)) + .thenReturn(List.of(vcOpenWalkguide, vcCallGuardian, vcSendSos)); + + List result = voiceCommandService.getAll(2L); + + assertThat(result).hasSize(3); + + VoiceCommandResponse first = result.get(0); + assertThat(first.getId()).isEqualTo(100L); + assertThat(first.getCommandKey()).isEqualTo("OPEN_WALKGUIDE"); + assertThat(first.getTriggerPhrase()).isEqualTo("buka walkguide"); + assertThat(first.getEnabled()).isTrue(); + assertThat(first.getDescription()).isEqualTo("Buka menu WalkGuide"); + + VoiceCommandResponse second = result.get(1); + assertThat(second.getCommandKey()).isEqualTo("CALL_GUARDIAN"); + assertThat(second.getDescription()).isEqualTo("Telepon Guardian"); + + VoiceCommandResponse third = result.get(2); + assertThat(third.getCommandKey()).isEqualTo("SEND_SOS"); + assertThat(third.getEnabled()).isFalse(); + assertThat(third.getDescription()).isEqualTo("Kirim sinyal darurat SOS"); + } + + @Test + @DisplayName("getAll - harus return list kosong jika user tidak punya voice command") + void getAll_emptyList_whenUserHasNoVoiceCommands() { + when(voiceCommandConfigRepository.findByUserId(99L)).thenReturn(List.of()); + + List result = voiceCommandService.getAll(99L); + + assertThat(result).isEmpty(); + verify(voiceCommandConfigRepository).findByUserId(99L); + } + + @Test + @DisplayName("getAll - description harus ada untuk semua VoiceCommandKey yang terdefinisi") + void getAll_shouldProvideDescriptionForAllDefinedKeys() { + // Buat config dengan setiap key yang ada + List allConfigs = List.of( + buildVc(1L, 2L, VoiceCommandKey.OPEN_WALKGUIDE, "cmd1"), + buildVc(2L, 2L, VoiceCommandKey.START_WALKGUIDE, "cmd2"), + buildVc(3L, 2L, VoiceCommandKey.STOP_WALKGUIDE, "cmd3"), + buildVc(4L, 2L, VoiceCommandKey.CALL_GUARDIAN, "cmd4"), + buildVc(5L, 2L, VoiceCommandKey.OPEN_NOTIFICATION, "cmd5"), + buildVc(6L, 2L, VoiceCommandKey.READ_ALL_NOTIF, "cmd6"), + buildVc(7L, 2L, VoiceCommandKey.OPEN_SOS, "cmd7"), + buildVc(8L, 2L, VoiceCommandKey.SEND_SOS, "cmd8"), + buildVc(9L, 2L, VoiceCommandKey.WHERE_AM_I, "cmd9"), + buildVc(10L, 2L, VoiceCommandKey.OPEN_ACTIVITY, "cmd10"), + buildVc(11L, 2L, VoiceCommandKey.OPEN_NAVIGATION, "cmd11"), + buildVc(12L, 2L, VoiceCommandKey.OPEN_SETTINGS, "cmd12"), + buildVc(13L, 2L, VoiceCommandKey.REPEAT_LAST, "cmd13"), + buildVc(14L, 2L, VoiceCommandKey.STOP_TTS, "cmd14") + ); + + when(voiceCommandConfigRepository.findByUserId(2L)).thenReturn(allConfigs); + + List result = voiceCommandService.getAll(2L); + + assertThat(result).hasSize(14); + // Semua description tidak boleh kosong atau null + result.forEach(r -> assertThat(r.getDescription()) + .as("Description untuk key %s tidak boleh kosong", r.getCommandKey()) + .isNotNull() + .isNotBlank()); + } + + // ===== updateByGuardian TESTS ===== + + @Test + @DisplayName("updateByGuardian - harus update triggerPhrase dan enabled jika semua field diisi") + void updateByGuardian_allFields_shouldUpdateAll() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("OPEN_WALKGUIDE"); + req.setTriggerPhrase("halo walkguide"); + req.setEnabled(false); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.OPEN_WALKGUIDE)) + .thenReturn(Optional.of(vcOpenWalkguide)); + when(voiceCommandConfigRepository.save(any(VoiceCommandConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + VoiceCommandResponse result = voiceCommandService.updateByGuardian(1L, req); + + assertThat(result.getCommandKey()).isEqualTo("OPEN_WALKGUIDE"); + assertThat(result.getTriggerPhrase()).isEqualTo("halo walkguide"); + assertThat(result.getEnabled()).isFalse(); + assertThat(result.getDescription()).isEqualTo("Buka menu WalkGuide"); + } + + @Test + @DisplayName("updateByGuardian - triggerPhrase null tidak boleh mengubah nilai yang sudah ada") + void updateByGuardian_nullTriggerPhrase_shouldKeepExistingPhrase() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("CALL_GUARDIAN"); + req.setTriggerPhrase(null); + req.setEnabled(false); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.CALL_GUARDIAN)) + .thenReturn(Optional.of(vcCallGuardian)); + when(voiceCommandConfigRepository.save(any(VoiceCommandConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + VoiceCommandResponse result = voiceCommandService.updateByGuardian(1L, req); + + // triggerPhrase tidak berubah + assertThat(result.getTriggerPhrase()).isEqualTo("telepon guardian"); + assertThat(result.getEnabled()).isFalse(); + } + + @Test + @DisplayName("updateByGuardian - triggerPhrase blank tidak boleh mengubah nilai yang sudah ada") + void updateByGuardian_blankTriggerPhrase_shouldKeepExistingPhrase() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("CALL_GUARDIAN"); + req.setTriggerPhrase(" "); // blank string + req.setEnabled(null); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.CALL_GUARDIAN)) + .thenReturn(Optional.of(vcCallGuardian)); + when(voiceCommandConfigRepository.save(any(VoiceCommandConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + VoiceCommandResponse result = voiceCommandService.updateByGuardian(1L, req); + + assertThat(result.getTriggerPhrase()).isEqualTo("telepon guardian"); + } + + @Test + @DisplayName("updateByGuardian - enabled null tidak boleh mengubah nilai yang sudah ada") + void updateByGuardian_nullEnabled_shouldKeepExistingEnabled() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("SEND_SOS"); + req.setTriggerPhrase("darurat sekarang"); + req.setEnabled(null); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.SEND_SOS)) + .thenReturn(Optional.of(vcSendSos)); + when(voiceCommandConfigRepository.save(any(VoiceCommandConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + VoiceCommandResponse result = voiceCommandService.updateByGuardian(1L, req); + + // enabled tetap false (nilai awal vcSendSos) + assertThat(result.getEnabled()).isFalse(); + assertThat(result.getTriggerPhrase()).isEqualTo("darurat sekarang"); + } + + @Test + @DisplayName("updateByGuardian - guardian tanpa pairing aktif harus throw PairingException") + void updateByGuardian_noActivePairing_shouldThrowPairingException() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("OPEN_WALKGUIDE"); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> voiceCommandService.updateByGuardian(1L, req)) + .isInstanceOf(PairingException.class) + .hasMessageContaining("Tidak ada user yang dipair"); + + verify(voiceCommandConfigRepository, never()).findByUserIdAndCommandKey(any(), any()); + verify(voiceCommandConfigRepository, never()).save(any()); + } + + @Test + @DisplayName("updateByGuardian - commandKey tidak ditemukan harus throw ResourceNotFoundException") + void updateByGuardian_voiceCommandNotFound_shouldThrowResourceNotFoundException() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("WHERE_AM_I"); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.WHERE_AM_I)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> voiceCommandService.updateByGuardian(1L, req)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Voice command tidak ditemukan"); + + verify(voiceCommandConfigRepository, never()).save(any()); + } + + @Test + @DisplayName("updateByGuardian - commandKey enum invalid harus throw IllegalArgumentException") + void updateByGuardian_invalidEnumKey_shouldThrowIllegalArgumentException() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("INVALID_COMMAND_XYZ"); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + + assertThatThrownBy(() -> voiceCommandService.updateByGuardian(1L, req)) + .isInstanceOf(IllegalArgumentException.class); + + verify(voiceCommandConfigRepository, never()).save(any()); + } + + @Test + @DisplayName("updateByGuardian - harus menyimpan entitas yang sudah dimodifikasi ke repository") + void updateByGuardian_shouldPersistChangesToRepository() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("SEND_SOS"); + req.setTriggerPhrase("minta tolong segera"); + req.setEnabled(true); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.SEND_SOS)) + .thenReturn(Optional.of(vcSendSos)); + when(voiceCommandConfigRepository.save(any(VoiceCommandConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + voiceCommandService.updateByGuardian(1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VoiceCommandConfig.class); + verify(voiceCommandConfigRepository).save(captor.capture()); + + VoiceCommandConfig saved = captor.getValue(); + assertThat(saved.getCommandKey()).isEqualTo(VoiceCommandKey.SEND_SOS); + assertThat(saved.getTriggerPhrase()).isEqualTo("minta tolong segera"); + assertThat(saved.getEnabled()).isTrue(); + } + + @Test + @DisplayName("updateByGuardian - harus mengirim FCM notification ke user setelah update berhasil") + void updateByGuardian_shouldSendFcmNotificationToUser() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("OPEN_WALKGUIDE"); + req.setTriggerPhrase("ayo walkguide"); + req.setEnabled(true); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(voiceCommandConfigRepository.findByUserIdAndCommandKey(2L, VoiceCommandKey.OPEN_WALKGUIDE)) + .thenReturn(Optional.of(vcOpenWalkguide)); + when(voiceCommandConfigRepository.save(any(VoiceCommandConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + voiceCommandService.updateByGuardian(1L, req); + + verify(fcmService).sendToToken( + eq("user-fcm-token-xyz"), + eq("Voice Command Diperbarui"), + eq("Guardian mengubah perintah suara kamu"), + argThat(data -> "SETTINGS_UPDATED".equals(data.get("type")) + && "VOICE_COMMAND".equals(data.get("settingType"))) + ); + } + + @Test + @DisplayName("updateByGuardian - FCM tidak boleh dipanggil jika update gagal karena pairing tidak ada") + void updateByGuardian_noPairing_fcmShouldNotBeCalled() { + VoiceCommandUpdateRequest req = new VoiceCommandUpdateRequest(); + req.setCommandKey("OPEN_WALKGUIDE"); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> voiceCommandService.updateByGuardian(1L, req)) + .isInstanceOf(PairingException.class); + + verifyNoInteractions(fcmService); + } + + // ===== Helper ===== + + private VoiceCommandConfig buildVc(Long id, Long userId, VoiceCommandKey key, String phrase) { + return VoiceCommandConfig.builder() + .id(id) + .userId(userId) + .commandKey(key) + .triggerPhrase(phrase) + .enabled(true) + .build(); + } +}