ADD ALOT OF POLISHING AND UPDATES...

This commit is contained in:
Wowieee4
2026-05-26 05:52:12 +07:00
parent 2e3ecdf1d1
commit b8ad8df993
53 changed files with 3194 additions and 3860 deletions
@@ -4,12 +4,9 @@ import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import com.walkguide.service.CallNotificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -17,23 +14,13 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Controller untuk VoIP call via Agora RTC.
*
* FLOW:
* 1. Caller → POST /shared/call/token → dapat Agora token + channelName
* 2. Caller join Agora channel (di Flutter)
* 3. Caller → POST /shared/call/notify → FCM "Incoming Call" dikirim ke receiver
* 4. Receiver terima FCM → join channel yang sama
* 5. Audio call tersambung via Agora
*
* Route: /api/v1/shared/call
* Auth: Authenticated (Guardian ATAU User)
*/
@RestController
@RequestMapping("/api/v1/shared/call")
@RequiredArgsConstructor
@@ -43,17 +30,10 @@ import java.util.Map;
public class CallController {
private final AgoraTokenService agoraTokenService;
private final FcmService fcmService;
private final UserRepository userRepository;
private final CallNotificationService callNotificationService;
/**
* Generate Agora RTC token untuk call session.
* Dipanggil oleh CALLER sebelum mulai call.
*
* POST /api/v1/shared/call/token
*/
@PostMapping("/token")
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel")
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
@Valid @RequestBody CallTokenRequest req) {
@@ -66,84 +46,24 @@ public class CallController {
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
}
/**
* Kirim FCM "Incoming Call" ke receiver.
* Dipanggil oleh CALLER setelah berhasil join Agora channel.
*
* POST /api/v1/shared/call/notify
*/
@PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call",
description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama")
@Operation(summary = "Notify receiver of incoming call")
public ResponseEntity<ApiResponse<Void>> notifyCall(
@Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
// Ambil info caller untuk notifikasi
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
// Ambil FCM token receiver
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} tidak punya FCM token — call notify gagal", req.getReceiverId());
return ResponseEntity.ok(ApiResponse.ok(null,
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
}
// Payload FCM untuk incoming call
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(),
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
// High priority karena incoming call harus segera muncul
fcmService.sendHighPriority(
receiver.getFcmToken(),
"📞 Panggilan Masuk",
"Panggilan dari " + (caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail()),
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi panggilan berhasil dikirim"));
String message = callNotificationService.notifyIncomingCall(callerId, req);
return ResponseEntity.ok(ApiResponse.ok(null, message));
}
/**
* End call — notifikasi ke pihak lain bahwa call sudah berakhir.
* Opsional: Flutter bisa handle end call secara lokal juga.
*
* POST /api/v1/shared/call/end
*/
@PostMapping("/end")
@Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall(
@RequestBody Map<String, Long> body) {
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
if (otherId != null) {
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() != null && !other.getFcmToken().isBlank()) {
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
}
});
}
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
callNotificationService.notifyCallEnded(callerId, otherId);
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
}
@@ -51,7 +51,7 @@ public class GuardianController {
Long guardianId = SecurityHelper.getCurrentUserId();
// Perlu ambil userId dulu — delegasikan ke service
return ResponseEntity.ok(ApiResponse.ok(
locationService.getLocationHistory(guardianId,
locationService.getLocationHistoryForGuardian(guardianId,
PageRequest.of(page, size, Sort.by("createdAt").descending())),
"Riwayat lokasi"));
}
@@ -2,9 +2,11 @@ package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.PairingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -16,12 +18,26 @@ public class PairingController {
private final PairingService pairingService;
@GetMapping("/code")
public ResponseEntity<ApiResponse<PairingCodeResponse>> code() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.getOrCreatePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code aktif"));
}
@PostMapping("/code/regenerate")
public ResponseEntity<ApiResponse<PairingCodeResponse>> regenerateCode() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.regeneratePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code baru dibuat"));
}
@PostMapping("/invite")
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
@RequestBody InviteUserRequest req) {
@Valid @RequestBody InviteUserRequest req) {
Long guardianId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(
pairingService.inviteUser(guardianId, req.getUniqueUserId()),
pairingService.inviteUser(guardianId, req.resolveSubmittedCode()),
"Undangan dikirim ke user"));
}
@@ -5,7 +5,16 @@ import lombok.Data;
@Data
public class InviteUserRequest {
@NotBlank(message = "User ID tidak boleh kosong")
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
private String uniqueUserId;
@Size(min = 8, max = 8, message = "Pairing code harus tepat 8 karakter")
private String pairingCode;
public String resolveSubmittedCode() {
if (pairingCode != null && !pairingCode.isBlank()) {
return pairingCode.trim().toUpperCase();
}
return uniqueUserId == null ? null : uniqueUserId.trim().toUpperCase();
}
}
@@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class PairingCodeResponse {
private String pairingCode;
private LocalDateTime expiresAt;
private long expiresInSeconds;
}
@@ -12,6 +12,8 @@ public class PairingStatusResponse {
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
private String pairedWithEmail;
private String uniqueUserId; // ID user yang di-pair
private String pairingCode; // temporary code for new pairing flow
private LocalDateTime pairingCodeExpiresAt;
private LocalDateTime invitedAt;
private LocalDateTime respondedAt;
}
@@ -29,6 +29,12 @@ public class User {
@Column(name = "unique_user_id", unique = true, length = 12)
private String uniqueUserId;
@Column(name = "pairing_code", unique = true, length = 8)
private String pairingCode;
@Column(name = "pairing_code_expires_at")
private LocalDateTime pairingCodeExpiresAt;
@Column(name = "display_name", length = 100)
private String displayName;
@@ -9,5 +9,6 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUniqueUserId(String uniqueUserId);
Optional<User> findByPairingCode(String pairingCode);
boolean existsByEmail(String email);
}
@@ -0,0 +1,73 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class CallNotificationService {
private final FcmService fcmService;
private final UserRepository userRepository;
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
}
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", callerName,
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
fcmService.sendHighPriority(
receiver.getFcmToken(),
"Panggilan Masuk",
"Panggilan dari " + callerName,
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return "Notifikasi panggilan berhasil dikirim";
}
public void notifyCallEnded(Long callerId, Long otherId) {
if (otherId == null) {
return;
}
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
return;
}
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
});
}
}
@@ -6,6 +6,7 @@ import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
@@ -79,6 +80,15 @@ public class LocationService {
.map(this::toResponse);
}
public Page<LocationResponse> getLocationHistoryForGuardian(Long guardianId, Pageable pageable) {
Long pairedUserId = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.map(pairing -> pairing.getUser().getId())
.orElseThrow(() -> new PairingException("Guardian belum terhubung dengan User aktif"));
return getLocationHistory(pairedUserId, pageable);
}
// ========== GEOFENCE ==========
private void checkGeofence(Long userId, double lat, double lng) {
@@ -1,6 +1,7 @@
package com.walkguide.service;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.entity.*;
import com.walkguide.enums.*;
import com.walkguide.exception.PairingException;
@@ -10,7 +11,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
@@ -26,8 +29,46 @@ public class PairingService {
private final ActivityLogService activityLogService;
private final FcmService fcmService;
private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static final int PAIRING_CODE_LENGTH = 8;
private static final int PAIRING_CODE_TTL_MINUTES = 15;
private static final SecureRandom RANDOM = new SecureRandom();
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) {
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
if (user.getPairingCode() == null
|| user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(now)) {
assignNewPairingCode(user, now);
userRepository.save(user);
}
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingCodeResponse regeneratePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
assignNewPairingCode(user, now);
userRepository.save(user);
activityLogService.createLog(user, ActivityLogType.PAIRING_INVITE_SENT,
"User membuat pairing code baru", null);
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
@@ -38,8 +79,7 @@ public class PairingService {
User guardian = userRepository.findById(guardianId)
.orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan"));
User user = userRepository.findByUniqueUserId(uniqueUserId)
.orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan"));
User user = resolveUserByPairingCode(submittedCode);
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar.");
@@ -55,6 +95,10 @@ public class PairingService {
.build();
pairing = pairingRelationRepository.save(pairing);
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
// Kirim FCM ke user
fcmService.sendToToken(user.getFcmToken(),
"Pairing Request",
@@ -199,6 +243,51 @@ public class PairingService {
.enabled(name != null).build();
}
private User resolveUserByPairingCode(String submittedCode) {
if (submittedCode == null || submittedCode.isBlank()) {
throw new PairingException("Pairing code tidak boleh kosong.");
}
String code = submittedCode.trim().toUpperCase();
User user = userRepository.findByPairingCode(code)
.orElseThrow(() -> new ResourceNotFoundException(
"Pairing code '" + code + "' tidak ditemukan. Minta User generate kode baru."));
if (user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(LocalDateTime.now())) {
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
throw new PairingException("Pairing code sudah kadaluarsa. Minta User generate kode baru.");
}
return user;
}
private void assignNewPairingCode(User user, LocalDateTime now) {
String candidate;
do {
candidate = randomCode();
} while (userRepository.findByPairingCode(candidate).isPresent());
user.setPairingCode(candidate);
user.setPairingCodeExpiresAt(now.plusMinutes(PAIRING_CODE_TTL_MINUTES));
}
private String randomCode() {
StringBuilder sb = new StringBuilder(PAIRING_CODE_LENGTH);
for (int i = 0; i < PAIRING_CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private PairingCodeResponse buildPairingCodeResponse(User user, LocalDateTime now) {
long seconds = Math.max(0,
ChronoUnit.SECONDS.between(now, user.getPairingCodeExpiresAt()));
return PairingCodeResponse.builder()
.pairingCode(user.getPairingCode())
.expiresAt(user.getPairingCodeExpiresAt())
.expiresInSeconds(seconds)
.build();
}
private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) {
String pairedWithName = "GUARDIAN".equals(viewerRole)
? user.getDisplayName() : guardian.getDisplayName();
@@ -211,6 +300,8 @@ public class PairingService {
.pairedWithName(pairedWithName)
.pairedWithEmail(pairedWithEmail)
.uniqueUserId(user.getUniqueUserId())
.pairingCode(user.getPairingCode())
.pairingCodeExpiresAt(user.getPairingCodeExpiresAt())
.invitedAt(p.getInvitedAt())
.respondedAt(p.getRespondedAt())
.build();
@@ -4,11 +4,11 @@
# atau set env: SPRING_PROFILES_ACTIVE=dev
# ===================================================
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
username: ${DB_USERNAME:5803024001}
password: ${DB_PASSWORD:pw5803024001}
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
show-sql: true
@@ -16,9 +16,9 @@ spring:
hibernate:
format_sql: true
jwt:
secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
expiration: 86400000
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
agora:
app-id: ${AGORA_APP_ID:}
@@ -28,4 +28,4 @@ logging:
level:
com.walkguide: DEBUG
org.springframework.messaging: DEBUG
org.springframework.web.socket: DEBUG
org.springframework.web.socket: DEBUG
@@ -0,0 +1,9 @@
-- V17: Expiring pairing code for lecturer revision.
-- unique_user_id remains a stable account identifier; pairing_code is temporary.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS pairing_code VARCHAR(8) UNIQUE,
ADD COLUMN IF NOT EXISTS pairing_code_expires_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_pairing_code ON users(pairing_code);
CREATE INDEX IF NOT EXISTS idx_users_pairing_code_expires_at ON users(pairing_code_expires_at);
@@ -4,7 +4,8 @@ info:
version: 1.0.0
description: Design contract for WalkGuide Flutter and Spring Boot integration.
servers:
- url: http://localhost:8080/api/v1
- url: https://api.walkguide.example/api/v1
description: Production deployment URL placeholder
security:
- bearerAuth: []
components:
@@ -37,9 +38,16 @@ components:
role: { type: string, enum: [USER, GUARDIAN] }
PairingInviteRequest:
type: object
required: [uniqueUserId]
required: [pairingCode]
properties:
uniqueUserId: { type: string, minLength: 12, maxLength: 12 }
pairingCode: { type: string, minLength: 8, maxLength: 8, description: "Temporary code generated by the User app; expires automatically." }
uniqueUserId: { type: string, minLength: 12, maxLength: 12, deprecated: true }
PairingCodeResponse:
type: object
properties:
pairingCode: { type: string }
expiresAt: { type: string, format: date-time }
expiresInSeconds: { type: integer, format: int64 }
PairingRespondRequest:
type: object
required: [pairingId, accept]
@@ -107,6 +115,14 @@ paths:
schema: { $ref: "#/components/schemas/PairingInviteRequest" }
responses:
"200": { description: Invite sent }
/shared/pairing/code:
get:
responses:
"200": { description: Active expiring pairing code }
/shared/pairing/code/regenerate:
post:
responses:
"200": { description: New expiring pairing code generated }
/shared/pairing/respond:
post:
requestBody:
@@ -4,13 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.JwtAuthFilter;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import org.junit.jupiter.api.BeforeEach;
import com.walkguide.service.CallNotificationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
@@ -18,19 +15,22 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import com.walkguide.security.JwtAuthFilter;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc(addFilters = false)
@WebMvcTest(CallController.class)
@@ -38,7 +38,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DisplayName("CallController Unit Tests")
class CallControllerTest {
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean
private JwtAuthFilter jwtAuthFilter;
@Autowired
private MockMvc mockMvc;
@@ -50,35 +51,10 @@ class CallControllerTest {
private AgoraTokenService agoraTokenService;
@MockBean
private FcmService fcmService;
@MockBean
private UserRepository userRepository;
private User sampleCaller;
private User sampleReceiver;
@BeforeEach
void setUp() {
sampleCaller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.fcmToken("fcm-caller-token")
.build();
sampleReceiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver Guardian")
.fcmToken("fcm-receiver-token")
.build();
}
// ===== GENERATE TOKEN =====
private CallNotificationService callNotificationService;
@Test
@DisplayName("POST /api/v1/shared/call/token - valid request harus return Agora token")
@DisplayName("POST /api/v1/shared/call/token - valid request returns Agora token")
void generateToken_validRequest_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@@ -88,7 +64,7 @@ class CallControllerTest {
AgoraTokenResponse tokenResp = AgoraTokenResponse.builder()
.token("agora-rtc-token-xyz")
.channelName("call_1_2_1234567890")
.channelName("call_1_2")
.uid(1001)
.build();
@@ -102,34 +78,24 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate"))
.andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz"))
.andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890"));
verify(agoraTokenService).generateToken(1L, 2L);
.andExpect(jsonPath("$.data.channelName").value("call_1_2"));
}
}
@Test
@DisplayName("POST /api/v1/shared/call/token - receiverId null harus return 400")
@DisplayName("POST /api/v1/shared/call/token - null receiverId returns 400")
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
CallTokenRequest req = new CallTokenRequest();
// CallTokenRequest dengan receiverId null — @Valid harus menolak
CallTokenRequest req = new CallTokenRequest();
// receiverId tidak di-set (null)
mockMvc.perform(post("/api/v1/shared/call/token")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
verify(agoraTokenService, never()).generateToken(anyLong(), anyLong());
}
mockMvc.perform(post("/api/v1/shared/call/token")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("POST /api/v1/shared/call/token - service throw harus return 500")
@DisplayName("POST /api/v1/shared/call/token - service error returns 500")
void generateToken_serviceThrows_shouldReturn500() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@@ -148,21 +114,17 @@ class CallControllerTest {
}
}
// ===== NOTIFY CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi")
void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() throws Exception {
@DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service")
void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
when(callNotificationService.notifyIncomingCall(eq(1L), any(CallNotifyRequest.class)))
.thenReturn("Notifikasi panggilan berhasil dikirim");
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_1234567890");
req.setChannelName("call_1_2");
req.setAgoraToken("agora-token-xyz");
req.setReceiverUid(1002);
@@ -174,260 +136,40 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim"));
verify(fcmService).sendHighPriority(
eq("fcm-receiver-token"),
eq("📞 Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
verify(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class));
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM")
void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service")
void notifyCall_invalidRequest_shouldReturn400() throws Exception {
CallNotifyRequest req = new CallNotifyRequest();
req.setChannelName("call_1_2");
User receiverNoFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(null) // tidak punya FCM token
.build();
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_abc");
req.setAgoraToken("token-xyz");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
// FCM tidak boleh dipanggil karena tidak ada token
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
verify(callNotificationService, never()).notifyIncomingCall(any(), any());
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM")
void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() throws Exception {
@DisplayName("POST /api/v1/shared/call/end - delegates to call notification service")
void endCall_validOtherId_shouldDelegateToService() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User receiverBlankFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(" ") // blank
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverBlankFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller tidak ditemukan harus return 404")
void notifyCall_callerNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L))
.thenThrow(new ResourceNotFoundException("Caller not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak ditemukan harus return 404")
void notifyCall_receiverNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L))
.thenThrow(new ResourceNotFoundException("Receiver not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller displayName null harus pakai email sebagai pengganti")
void notifyCall_callerNoDisplayName_shouldUseEmailAsFallback() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User callerNoName = User.builder()
.id(1L)
.email("noreply@test.com")
.displayName(null) // tidak ada displayName
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(callerNoName));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
// Pastikan body notifikasi menggunakan email sebagai fallback
verify(fcmService).sendHighPriority(
anyString(),
anyString(),
contains("noreply@test.com"),
anyMap()
);
}
}
// ===== END CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId valid dan punya FCM token harus kirim notifikasi berakhir")
void endCall_otherHasFcmToken_shouldSendEndNotification() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.content(objectMapper.writeValueAsString(Map.of("otherId", 2L))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService).sendToToken(
eq("fcm-receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId null harus return 200 tanpa kirim FCM")
void endCall_nullOtherId_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
Map<String, Long> body = Map.of(); // tidak ada otherId
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - other tidak punya FCM token harus return 200 tanpa FCM")
void endCall_otherNoFcmToken_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User otherNoFcm = User.builder()
.id(2L)
.email("other@test.com")
.fcmToken(null)
.build();
when(userRepository.findById(2L)).thenReturn(Optional.of(otherNoFcm));
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@WithMockUser(username = "2", roles = "GUARDIAN")
@DisplayName("POST /api/v1/shared/call/end - Guardian juga bisa end call")
void endCall_asGuardian_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 1L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(callNotificationService).notifyCallEnded(1L, 2L);
}
}
}
@@ -92,9 +92,9 @@ class PairingControllerTest {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId("INVALID999");
req.setPairingCode("BAD99999");
when(pairingService.inviteUser(2L, "INVALID999"))
when(pairingService.inviteUser(2L, "BAD99999"))
.thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan"));
mockMvc.perform(post("/api/v1/shared/pairing/invite")
@@ -0,0 +1,128 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("CallNotificationService Unit Tests")
class CallNotificationServiceTest {
@Mock
private FcmService fcmService;
@Mock
private UserRepository userRepository;
@InjectMocks
private CallNotificationService service;
private User caller;
private User receiver;
private CallNotifyRequest request;
@BeforeEach
void setUp() {
caller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.build();
receiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken("receiver-token")
.build();
request = new CallNotifyRequest();
request.setReceiverId(2L);
request.setChannelName("call_1_2");
request.setAgoraToken("agora-token");
request.setReceiverUid(1002);
}
@Test
@DisplayName("notifyIncomingCall sends high priority FCM when receiver has token")
void notifyIncomingCall_receiverHasToken_shouldSendFcm() {
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Notifikasi panggilan berhasil dikirim", message);
verify(fcmService).sendHighPriority(
eq("receiver-token"),
eq("Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
}
@Test
@DisplayName("notifyIncomingCall skips FCM when receiver token is missing")
void notifyIncomingCall_receiverHasNoToken_shouldSkipFcm() {
receiver.setFcmToken(null);
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyIncomingCall throws 404 when caller is missing")
void notifyIncomingCall_missingCaller_shouldThrow() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> service.notifyIncomingCall(1L, request));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyCallEnded sends FCM when other user has token")
void notifyCallEnded_otherHasToken_shouldSendFcm() {
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
service.notifyCallEnded(1L, 2L);
verify(fcmService).sendToToken(
eq("receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
@Test
@DisplayName("notifyCallEnded skips repository lookup when otherId is null")
void notifyCallEnded_nullOtherId_shouldReturn() {
service.notifyCallEnded(1L, null);
verify(userRepository, never()).findById(2L);
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@@ -5,8 +5,9 @@ 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.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach;
@@ -217,9 +218,9 @@ class LocationServiceTest {
// ===== getLocationHistory TESTS =====
@Test
@DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable")
void getLocationHistory_shouldReturnPagedLocations() {
@Test
@DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable")
void getLocationHistory_shouldReturnPagedLocations() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
@@ -229,10 +230,39 @@ class LocationServiceTest {
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
}
// ===== haversineMeters TESTS =====
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
}
@Test
@DisplayName("getLocationHistoryForGuardian - harus ambil histori milik User yang dipair, bukan guardian")
void getLocationHistoryForGuardian_activePairing_shouldUsePairedUserId() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(locationHistoryRepository.findByUserIdOrderByCreatedAtDesc(2L, pageable)).thenReturn(page);
Page<LocationResponse> result = locationService.getLocationHistoryForGuardian(1L, pageable);
assertThat(result.getContent()).hasSize(1);
verify(locationHistoryRepository).findByUserIdOrderByCreatedAtDesc(2L, pageable);
verify(locationHistoryRepository, never()).findByUserIdOrderByCreatedAtDesc(1L, pageable);
}
@Test
@DisplayName("getLocationHistoryForGuardian - tanpa pairing aktif harus error pairing")
void getLocationHistoryForGuardian_noPairing_shouldThrow() {
Pageable pageable = PageRequest.of(0, 10);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> locationService.getLocationHistoryForGuardian(1L, pageable))
.isInstanceOf(PairingException.class)
.hasMessageContaining("Guardian belum terhubung");
}
// ===== haversineMeters TESTS =====
@Test
@DisplayName("haversineMeters - titik sama: jarak harus 0")
@@ -256,4 +286,4 @@ class LocationServiceTest {
double d2 = LocationService.haversineMeters(-7.300, 112.800, -7.257, 112.752);
assertThat(d1).isCloseTo(d2, within(0.001));
}
}
}
@@ -15,7 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.Optional;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -52,10 +53,12 @@ class PairingServiceTest {
.id(2L)
.email("user@test.com")
.role("ROLE_USER")
.displayName("User Test")
.uniqueUserId("ABC123DEF456")
.fcmToken("user-fcm-token")
.build();
.displayName("User Test")
.uniqueUserId("ABC123DEF456")
.pairingCode("AB12CD34")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.fcmToken("user-fcm-token")
.build();
}
// ===== INVITE USER TESTS =====
@@ -66,7 +69,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> {
PairingRelation p = inv.getArgument(0);
@@ -74,7 +77,7 @@ class PairingServiceTest {
return p;
});
PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456");
PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34");
assertThat(result).isNotNull();
verify(pairingRelationRepository).save(any(PairingRelation.class));
@@ -113,7 +116,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("INVALID")).thenReturn(Optional.empty());
when(userRepository.findByPairingCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
.isInstanceOf(ResourceNotFoundException.class)
@@ -123,17 +126,20 @@ class PairingServiceTest {
@Test
@DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException")
void inviteUser_targetNotUser_shouldThrow() {
User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build();
User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN")
.pairingCode("GRD00001")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.build();
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User");
when(userRepository.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User");
}
@Test
@@ -142,12 +148,12 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true);
assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain");
assertThatThrownBy(() -> pairingService.inviteUser(1L, "AB12CD34"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain");
}
// ===== RESPOND TO PAIRING TESTS =====
@@ -211,4 +217,4 @@ class PairingServiceTest {
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah direspons");
}
}
}