ADD ALOT OF POLISHING AND UPDATES...
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"));
|
||||
}
|
||||
|
||||
+18
-2
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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;
|
||||
}
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+73
@@ -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
|
||||
|
||||
+9
@@ -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:
|
||||
|
||||
+45
-303
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+128
@@ -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());
|
||||
}
|
||||
}
|
||||
+40
-10
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-20
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user