diff --git a/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java new file mode 100644 index 00000000..ae3ca230 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.ble.event; + +import java.time.LocalDateTime; + +public record PlaceBecameVacantEvent( + Long placeId, + Long bleDataId, + LocalDateTime occurredAt +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java index 5e3c6854..3ca5fb51 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java @@ -11,5 +11,6 @@ public interface BLEDataRepository extends JpaRepository { Optional findTopByDeviceOrderByLastTimeDesc(BLEDevice device); + Optional findTopByDeviceOrderByLastTimeDescIdDesc(BLEDevice device); List findAllByDeviceAndLastTimeBetweenOrderByLastTimeAsc(BLEDevice device, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java index ecddca8b..d5a876f7 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java @@ -9,6 +9,7 @@ import devkor.com.teamcback.domain.ble.entity.BLEData; import devkor.com.teamcback.domain.ble.entity.BLEDevice; import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; import devkor.com.teamcback.domain.place.entity.Place; @@ -17,6 +18,7 @@ import devkor.com.teamcback.global.response.ResultCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +36,7 @@ public class BLEService { private final BLEDeviceRepository bledeviceRepository; private final BLEDataRepository bleDataRepository; private final PlaceRepository placeRepository; + private final ApplicationEventPublisher eventPublisher; // 평균 구해올 시간대 라벨 private static final int[] TIME_SLOTS = {7, 10, 13, 16, 19, 22}; @@ -46,6 +49,11 @@ public class BLEService { @Transactional public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { BLEDevice bleDevice = bledeviceRepository.findByDeviceName(updateBLEReq.getDeviceName()); + if (bleDevice == null) { + throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); + } + BLEData previousData = bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(bleDevice) + .orElse(null); int capacity = bleDevice.getCapacity(); int people = getBlEPeople(updateBLEReq.getLastCount(), bleDevice); double final_ratio = (double) people / capacity; @@ -61,9 +69,29 @@ public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { bleData.setLastTime(updateBLEReq.getLastTime()); bleDataRepository.save(bleData); + if (becameVacant(previousData, status) && bleDevice.getPlace() != null) { + eventPublisher.publishEvent(new PlaceBecameVacantEvent( + bleDevice.getPlace().getId(), + bleData.getId(), + bleData.getLastTime() + )); + } + return new UpdateBLERes(bleData); } + private boolean becameVacant( + BLEData previousData, + BLEstatus newStatus + ) { + if (!BLEstatus.VACANT.equals(newStatus) || previousData == null) { + return false; + } + + return BLEstatus.AVAILABLE.equals(previousData.getLastStatus()) + || BLEstatus.CROWDED.equals(previousData.getLastStatus()); + } + private int getBlEPeople(int lastCount, BLEDevice bleDevice) { if (bleDevice == null) throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); double ratio = bleDevice.getRatio(); diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java index 3051eaa1..aa5a3ff7 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java @@ -27,4 +27,15 @@ List findCategoriesByUserAndLocationTypeAndLocationId( @Query("SELECT c FROM Category c LEFT JOIN FETCH c.categoryBookmarkList WHERE c.user = :user") List findByUser(@Param("user") User user); + + @Query(""" + SELECT DISTINCT c.user.userId FROM CategoryBookmark cb + JOIN cb.category c + JOIN cb.bookmark b + WHERE c.user IS NOT NULL AND b.locationType = :locationType AND b.locationId = :locationId + """) + List findDistinctUserIdsByLocationTypeAndLocationId( + @Param("locationType") LocationType locationType, + @Param("locationId") Long locationId + ); } diff --git a/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java new file mode 100644 index 00000000..0c6f0c6c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.character.event; + +public record CharacterUnlockedEvent( + Long userId, + Long characterId, + Long userCharacterId, + String characterName +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java index 033241d2..c7fb35f0 100644 --- a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java +++ b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java @@ -16,6 +16,7 @@ import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Level; @@ -26,6 +27,7 @@ import devkor.com.teamcback.infra.s3.S3Util; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -37,6 +39,7 @@ public class AdminStoreService { private final UserCharacterRepository userCharacterRepository; private final UserRepository userRepository; private final S3Util s3Util; + private final ApplicationEventPublisher eventPublisher; /** * 캐릭터 목록 조회 (비활성 포함) @@ -119,7 +122,13 @@ public GrantCharacterRes grantCharacter(Long characterId, Long userId) { throw new GlobalException(ALREADY_OWNED_CHARACTER); } - UserCharacter userCharacter = userCharacterRepository.save(new UserCharacter(user, character)); + UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + eventPublisher.publishEvent(new CharacterUnlockedEvent( + user.getUserId(), + character.getCharacterId(), + userCharacter.getUserCharacterId(), + character.getName() + )); return new GrantCharacterRes(userCharacter.getUserCharacterId()); } diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java index 578393e4..5ee5fd66 100644 --- a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java +++ b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java @@ -18,6 +18,7 @@ import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.PurchaseStatus; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Level; @@ -30,6 +31,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +42,7 @@ public class StoreService { private final CharacterRepository characterRepository; private final UserCharacterRepository userCharacterRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /** * 스토어 조회 (보유 포인트 + 캐릭터 목록) @@ -118,6 +121,12 @@ public PurchaseCharacterRes purchaseCharacter(Long userId, Long characterId) { try { UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + eventPublisher.publishEvent(new CharacterUnlockedEvent( + user.getUserId(), + character.getCharacterId(), + userCharacter.getUserCharacterId(), + character.getName() + )); return new PurchaseCharacterRes(userCharacter, user.getPoint()); } catch (DataIntegrityViolationException e) { // 동시 중복 구매는 UNIQUE 제약으로 차단 (롤백으로 차감 복구) throw new GlobalException(ALREADY_OWNED_CHARACTER); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java index 8fa34668..825f0a87 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java @@ -1,16 +1,21 @@ package devkor.com.teamcback.domain.notification.controller; import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.UpdatePushEventFlagReq; import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; import devkor.com.teamcback.global.exception.exception.GlobalException; import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.response.ResultCode; import devkor.com.teamcback.global.security.UserDetailsImpl; import java.util.List; @@ -20,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -38,6 +44,7 @@ public class AdminNotificationController { private static final String DEFAULT_SIZE = "20"; private final AdminNotificationService adminNotificationService; + private final PushEventFlagService pushEventFlagService; @Operation( summary = "푸시 대상 installation 검색", @@ -133,4 +140,21 @@ public CommonResponse getDispatch( ) { return CommonResponse.success(adminNotificationService.getDispatch(dispatchId)); } + + @GetMapping("/event-flags") + public CommonResponse> getEventFlags() { + return CommonResponse.success(pushEventFlagService.getFlags()); + } + + @PatchMapping("/event-flags/{eventType}") + public CommonResponse updateEventFlag( + @PathVariable PushEventType eventType, + @RequestBody UpdatePushEventFlagReq request + ) { + if (request == null || request.enabled() == null) { + throw new GlobalException(ResultCode.INVALID_INPUT); + } + + return CommonResponse.success(pushEventFlagService.updateFlag(eventType, request.enabled())); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java new file mode 100644 index 00000000..70a5508e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +public record UpdatePushEventFlagReq( + Boolean enabled +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java new file mode 100644 index 00000000..79df8c2a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; + +public record AdminPushEventFlagRes( + PushEventType eventType, + boolean enabled +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java index c24f13e5..7b110df6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java @@ -7,5 +7,6 @@ public enum PushActionType { BUS_STOP, BUILDING_DETAIL, PLACE_DETAIL, + CHARACTER_STORE, TEST } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java new file mode 100644 index 00000000..52d298c5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushEventType { + CROWD("push:event:crowd-enabled"), + REPORT("push:event:report-enabled"), + CHARACTER("push:event:character-enabled"); + + private final String redisKey; + + PushEventType(String redisKey) { + this.redisKey = redisKey; + } + + public String redisKey() { + return redisKey; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java new file mode 100644 index 00000000..bd49011c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CharacterUnlockedPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CharacterUnlockedEvent event) { + if (!pushEventFlagService.isEnabled(PushEventType.CHARACTER)) { + return; + } + + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.userId(), + AppVariant.PRODUCTION + )) { + return; + } + + PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(event.userId()), + content.title(), + content.body(), + PushActionType.CHARACTER_STORE, + Map.of(), + "character-unlock:%d:%d:%d".formatted( + event.userId(), + event.characterId(), + event.userCharacterId() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "character unlock push failed: userId={}, characterId={}, userCharacterId={}, error={}", + event.userId(), + event.characterId(), + event.userCharacterId(), + e.getMessage() + ); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java new file mode 100644 index 00000000..5f7ae697 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -0,0 +1,110 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CrowdVacantPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PlaceRepository placeRepository; + private final CategoryRepository categoryRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(PlaceBecameVacantEvent event) { + if (!pushEventFlagService.isEnabled(PushEventType.CROWD)) { + return; + } + + try { + Place place = placeRepository.findById(event.placeId()) + .orElse(null); + if (place == null) { + log.warn("crowd vacant push skipped: place not found, placeId={}", event.placeId()); + return; + } + + Set userIds = new LinkedHashSet<>( + categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId( + LocationType.PLACE, + event.placeId() + ) + ); + if (userIds.isEmpty()) { + return; + } + + PushContent content = DomainPushContentFactory.placeBecameVacant( + place.getBuilding() == null ? null : place.getBuilding().getName(), + place.getName() + ); + for (Long userId : userIds) { + enqueueIfPushTargetExists(event, userId, content); + } + } catch (Exception e) { + log.warn( + "crowd vacant push failed: placeId={}, bleDataId={}, error={}", + event.placeId(), + event.bleDataId(), + e.getMessage() + ); + } + } + + private void enqueueIfPushTargetExists( + PlaceBecameVacantEvent event, + Long userId, + PushContent content + ) { + if (userId == null + || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, AppVariant.PRODUCTION)) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(userId), + content.title(), + content.body(), + PushActionType.PLACE_DETAIL, + Map.of("placeId", event.placeId()), + "crowd-vacant:%d:%d:%d".formatted(event.placeId(), userId, event.bleDataId()), + SYSTEM_CREATED_BY + )); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java new file mode 100644 index 00000000..fff6f4b6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReportResolvedPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ReportResolvedEvent event) { + if (!pushEventFlagService.isEnabled(PushEventType.REPORT) || event.reporterUserId() == null) { + return; + } + + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.reporterUserId(), + AppVariant.PRODUCTION + )) { + return; + } + + PushContent content = DomainPushContentFactory.reportResolved(); + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(event.reporterUserId()), + content.title(), + content.body(), + PushActionType.HOME, + Map.of(), + "report-result:%d:%s:%d".formatted( + event.reportId(), + event.finalStatus().name(), + event.reporterUserId() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "report result push failed: reportId={}, reporterUserId={}, finalStatus={}, error={}", + event.reportId(), + event.reporterUserId(), + event.finalStatus(), + e.getMessage() + ); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index 283a102b..3d8e1124 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -40,6 +40,11 @@ List findAllByUserIdAndAppVariantAndActiveTrue( AppVariant appVariant ); + boolean existsByUserIdAndAppVariantAndActiveTrue( + Long userId, + AppVariant appVariant + ); + Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( Long pushInstallationId, String installationId, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java new file mode 100644 index 00000000..7e621e03 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -0,0 +1,67 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PushEventFlagService { + + private final StringRedisTemplate redisTemplate; + + @Value("${push.event.crowd-enabled:false}") + private boolean crowdDefaultEnabled; + + @Value("${push.event.report-enabled:false}") + private boolean reportDefaultEnabled; + + @Value("${push.event.character-enabled:false}") + private boolean characterDefaultEnabled; + + public boolean isEnabled(PushEventType eventType) { + String redisValue = getRedisValue(eventType); + if ("true".equalsIgnoreCase(redisValue)) { + return true; + } + if ("false".equalsIgnoreCase(redisValue)) { + return false; + } + return defaultEnabled(eventType); + } + + public List getFlags() { + return Arrays.stream(PushEventType.values()) + .map(eventType -> new AdminPushEventFlagRes(eventType, isEnabled(eventType))) + .toList(); + } + + public AdminPushEventFlagRes updateFlag( + PushEventType eventType, + boolean enabled + ) { + redisTemplate.opsForValue().set(eventType.redisKey(), Boolean.toString(enabled)); + return new AdminPushEventFlagRes(eventType, isEnabled(eventType)); + } + + private String getRedisValue(PushEventType eventType) { + try { + return redisTemplate.opsForValue().get(eventType.redisKey()); + } catch (RuntimeException e) { + return null; + } + } + + private boolean defaultEnabled(PushEventType eventType) { + return switch (eventType) { + case CROWD -> crowdDefaultEnabled; + case REPORT -> reportDefaultEnabled; + case CHARACTER -> characterDefaultEnabled; + }; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java new file mode 100644 index 00000000..1e1581e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java @@ -0,0 +1,67 @@ +package devkor.com.teamcback.domain.notification.template; + +public final class DomainPushContentFactory { + + private static final String VACANT_TITLE = + "기다리던 자리가 생겼어요!"; + + private static final String REPORT_RESOLVED_TITLE = + "신고 처리 결과를 확인해주세요."; + + private static final String REPORT_RESOLVED_BODY = + "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."; + + private static final String CHARACTER_UNLOCKED_TITLE = + "새 캐릭터가 기다리고 있어요!"; + + private DomainPushContentFactory() { + } + + public static PushContent placeBecameVacant( + String buildingName, + String placeName + ) { + String location = joinNonBlank(buildingName, placeName); + + return new PushContent( + VACANT_TITLE, + location + "이 한산해요. 방문하기 전 현황을 확인해보세요." + ); + } + + public static PushContent reportResolved() { + return new PushContent( + REPORT_RESOLVED_TITLE, + REPORT_RESOLVED_BODY + ); + } + + public static PushContent characterUnlocked(String characterName) { + String name = isBlank(characterName) + ? "새로운 캐릭터" + : characterName.trim(); + + return new PushContent( + CHARACTER_UNLOCKED_TITLE, + name + "을 만나러 가볼까요?" + ); + } + + private static String joinNonBlank( + String first, + String second + ) { + String firstValue = isBlank(first) ? "" : first.trim(); + String secondValue = isBlank(second) ? "" : second.trim(); + + String result = (firstValue + " " + secondValue).trim(); + + return result.isBlank() + ? "즐겨찾기한 공간" + : result; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java new file mode 100644 index 00000000..dd8a3747 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.notification.template; + +public record PushContent( + String title, + String body +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java index 2e22c2bd..70c78479 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java @@ -19,6 +19,7 @@ public class PushActionValidator { PushActionType.HOME, PushActionType.NOTICE, PushActionType.MY_PAGE, + PushActionType.CHARACTER_STORE, PushActionType.TEST ); diff --git a/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java new file mode 100644 index 00000000..025c971c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.report.event; + +import devkor.com.teamcback.domain.report.entity.ReportStatus; + +public record ReportResolvedEvent( + Long reportId, + Long reporterUserId, + ReportStatus finalStatus +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java index ad81dfbd..60c5d81c 100644 --- a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java +++ b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java @@ -6,6 +6,7 @@ import devkor.com.teamcback.domain.report.entity.Report; import devkor.com.teamcback.domain.report.entity.ReportStatus; import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import devkor.com.teamcback.domain.report.repository.ReportRepository; import devkor.com.teamcback.domain.review.entity.Review; import devkor.com.teamcback.domain.review.repository.ReviewRepository; @@ -14,6 +15,7 @@ import devkor.com.teamcback.global.exception.exception.GlobalException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +33,7 @@ public class ReportService { private final ReportRepository reportRepository; private final ReviewRepository reviewRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /** * 리뷰에 대한 신고 작성 @@ -98,6 +101,7 @@ public GetReportListRes getReportList(ReportStatus status) { public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatusReq req) { // 신고 Report report = findReport(reportId); + ReportStatus previousStatus = report.getStatus(); // 신고 상태 수정 report.setStatus(req.getStatus()); @@ -115,9 +119,31 @@ public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatu } } + if (shouldPublishReportResolved(previousStatus, req.getStatus())) { + eventPublisher.publishEvent(new ReportResolvedEvent( + report.getId(), + report.getReporter() == null ? null : report.getReporter().getUserId(), + req.getStatus() + )); + } + return new UpdateReportStatusRes(); } + private boolean shouldPublishReportResolved( + ReportStatus previousStatus, + ReportStatus newStatus + ) { + return PENDING.equals(previousStatus) + && isFinalStatus(newStatus); + } + + private boolean isFinalStatus(ReportStatus status) { + return RESOLVED.equals(status) + || REJECTED.equals(status) + || EXPIRED.equals(status); + } + /** * 신고 유효일 체크하고 상태 수정 */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1b5b17ec..863190c8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -163,6 +163,10 @@ push: access-token: ${EXPO_ACCESS_TOKEN:} connect-timeout: 3s read-timeout: 10s + event: + crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} + report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} + character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} worker: enabled: ${PUSH_WORKER_ENABLED:false} fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} diff --git a/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java new file mode 100644 index 00000000..25caebab --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java @@ -0,0 +1,144 @@ +package devkor.com.teamcback.domain.ble.service; + +import devkor.com.teamcback.domain.ble.dto.request.UpdateBLEReq; +import devkor.com.teamcback.domain.ble.entity.BLEData; +import devkor.com.teamcback.domain.ble.entity.BLEDevice; +import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; +import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BLEServiceTest { + + @Mock + private BLEDeviceRepository bleDeviceRepository; + + @Mock + private BLEDataRepository bleDataRepository; + + @Mock + private PlaceRepository placeRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private BLEService bleService; + private BLEDevice device; + + @BeforeEach + void setUp() { + bleService = new BLEService( + bleDeviceRepository, + bleDataRepository, + placeRepository, + eventPublisher + ); + + Place place = new Place(); + ReflectionTestUtils.setField(place, "id", 10L); + + device = new BLEDevice(); + ReflectionTestUtils.setField(device, "id", 3L); + device.setDeviceName("device-1"); + device.setCapacity(100); + device.setDefaultCount(0); + device.setRatio(1); + device.setPlace(place); + + when(bleDeviceRepository.findByDeviceName("device-1")).thenReturn(device); + when(bleDataRepository.save(any(BLEData.class))).thenAnswer(invocation -> { + BLEData saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 99L); + return saved; + }); + } + + @Test + void publishesEventWhenAvailableBecomesVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.AVAILABLE))); + + bleService.updateBLE(req(20)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlaceBecameVacantEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().placeId()).isEqualTo(10L); + assertThat(captor.getValue().bleDataId()).isEqualTo(99L); + } + + @Test + void publishesEventWhenCrowdedBecomesVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.CROWDED))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher).publishEvent(any(PlaceBecameVacantEvent.class)); + } + + @Test + void doesNotPublishForRepeatedVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.VACANT))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenPreviousDataDoesNotExist() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.empty()); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenPreviousStatusIsFailure() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.FAILURE))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + private UpdateBLEReq req(int lastCount) { + UpdateBLEReq req = new UpdateBLEReq(); + req.setDeviceName("device-1"); + req.setLastCount(lastCount); + req.setLastTime(LocalDateTime.parse("2026-08-05T10:00:00")); + return req; + } + + private BLEData data(BLEstatus status) { + BLEData data = new BLEData(); + data.setDevice(device); + data.setLastStatus(status); + data.setLastCount(50); + data.setLastTime(LocalDateTime.parse("2026-08-05T09:59:00")); + return data; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java index 6d6998f8..09020cbb 100644 --- a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java @@ -12,6 +12,7 @@ import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Provider; @@ -23,12 +24,14 @@ import devkor.com.teamcback.infra.s3.FilePath; import devkor.com.teamcback.infra.s3.S3Util; import java.util.Optional; +import org.mockito.ArgumentCaptor; 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 org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; @@ -45,6 +48,8 @@ class AdminStoreServiceTest { UserRepository userRepository; @Mock S3Util s3Util; + @Mock + ApplicationEventPublisher eventPublisher; @DisplayName("캐릭터 생성 시 S3 업로드 후 URL과 가격 저장") @Test @@ -174,7 +179,9 @@ void deleteCharacter() { @Test void grantCharacter() { KoCharacter character = new KoCharacter("이벤트 캐릭터", null, null, "url", 100, 1, 1, true); + ReflectionTestUtils.setField(character, "characterId", 1L); User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 2L); when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); @@ -182,9 +189,10 @@ void grantCharacter() { GlobalException e = assertThrows(GlobalException.class, () -> adminStoreService.grantCharacter(1L, 2L)); assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(eventPublisher, never()).publishEvent(any()); when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - when(userCharacterRepository.save(any(UserCharacter.class))).thenAnswer(invocation -> { + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))).thenAnswer(invocation -> { UserCharacter userCharacter = invocation.getArgument(0); ReflectionTestUtils.setField(userCharacter, "userCharacterId", 5L); return userCharacter; @@ -192,5 +200,10 @@ void grantCharacter() { assertEquals(5L, adminStoreService.grantCharacter(1L, 2L).getUserCharacterId()); assertEquals(0L, user.getPoint()); // 지급은 포인트를 건드리지 않음 + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(2L, eventCaptor.getValue().userId()); + assertEquals(5L, eventCaptor.getValue().userCharacterId()); } } diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java index 658db82e..76914615 100644 --- a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java @@ -14,6 +14,7 @@ import devkor.com.teamcback.domain.character.entity.KoCharacter; import devkor.com.teamcback.domain.character.entity.PurchaseStatus; import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.user.entity.Provider; @@ -29,8 +30,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; @@ -45,6 +48,8 @@ class StoreServiceTest { UserCharacterRepository userCharacterRepository; @Mock UserRepository userRepository; + @Mock + ApplicationEventPublisher eventPublisher; static final Long USER_ID = 1L; static final Long CHARACTER_ID = 10L; @@ -75,13 +80,23 @@ void purchaseCharacter() { when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); + .thenAnswer(invocation -> { + UserCharacter userCharacter = invocation.getArgument(0); + ReflectionTestUtils.setField(userCharacter, "userCharacterId", 55L); + return userCharacter; + }); PurchaseCharacterRes res = storeService.purchaseCharacter(USER_ID, CHARACTER_ID); assertEquals(CHARACTER_ID, res.getCharacterId()); assertEquals(10, res.getPrice()); verify(userRepository).deductPoint(USER_ID, 10); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(USER_ID, eventCaptor.getValue().userId()); + assertEquals(CHARACTER_ID, eventCaptor.getValue().characterId()); + assertEquals(55L, eventCaptor.getValue().userCharacterId()); } @DisplayName("해금 레벨 미달이면 포인트가 충분해도 구매 불가") @@ -132,6 +147,7 @@ void purchaseAlreadyOwned() { () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + verify(eventPublisher, never()).publishEvent(any()); } @DisplayName("비활성 캐릭터는 구매 불가") @@ -160,6 +176,7 @@ void purchaseRaceMappedToAlreadyOwned() { GlobalException e = assertThrows(GlobalException.class, () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(eventPublisher, never()).publishEvent(any()); } @DisplayName("미보유 캐릭터 장착 시 예외, 보유 캐릭터는 장착/해제 성공") diff --git a/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java new file mode 100644 index 00000000..55d5c870 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java @@ -0,0 +1,66 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationControllerTest { + + @Mock + private AdminNotificationService adminNotificationService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + AdminNotificationController controller = new AdminNotificationController( + adminNotificationService, + pushEventFlagService + ); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void updatedEventFlagIsReflectedInAdminApiQueryResult() throws Exception { + when(pushEventFlagService.updateFlag(PushEventType.REPORT, true)) + .thenReturn(new AdminPushEventFlagRes(PushEventType.REPORT, true)); + when(pushEventFlagService.getFlags()) + .thenReturn(List.of( + new AdminPushEventFlagRes(PushEventType.CROWD, false), + new AdminPushEventFlagRes(PushEventType.REPORT, true), + new AdminPushEventFlagRes(PushEventType.CHARACTER, false) + )); + + mockMvc.perform(patch("/api/admin/notifications/event-flags/REPORT") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.eventType").value("REPORT")) + .andExpect(jsonPath("$.data.enabled").value(true)); + + mockMvc.perform(get("/api/admin/notifications/event-flags")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[1].eventType").value("REPORT")) + .andExpect(jsonPath("$.data[1].enabled").value(true)); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java new file mode 100644 index 00000000..f8a04e01 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -0,0 +1,98 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CharacterUnlockedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private CharacterUnlockedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CharacterUnlockedPushEventListener( + pushInstallationRepository, + pushDispatchService, + pushEventFlagService + ); + } + + @Test + void createsCharacterStoreDispatch() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.targetType()).isEqualTo(PushTargetType.USER); + assertThat(command.actionType()).isEqualTo(PushActionType.CHARACTER_STORE); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); + assertThat(command.body()).isEqualTo("아기 호랑이을 만나러 가볼까요?"); + assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44"); + } + + @Test + void usesSafeBodyWhenCharacterNameIsBlank() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, " ")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(false); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenUserHasNoProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java new file mode 100644 index 00000000..cabb35be --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -0,0 +1,152 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.building.entity.Building; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CrowdVacantPushEventListenerTest { + + @Mock + private PlaceRepository placeRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private CrowdVacantPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CrowdVacantPushEventListener( + placeRepository, + categoryRepository, + pushInstallationRepository, + pushDispatchService, + pushEventFlagService + ); + } + + @Test + void createsUserDispatchesForDistinctFavoriteUsers() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of(1L, 1L, 2L)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) + .thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(2L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(event()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService, times(2)).enqueue(captor.capture()); + + PushDispatchCommand first = captor.getAllValues().get(0); + assertThat(first.targetType()).isEqualTo(PushTargetType.USER); + assertThat(first.targetValue()).isEqualTo("1"); + assertThat(first.mode()).isEqualTo(PushMode.ACTUAL); + assertThat(first.appVariant()).isEqualTo(AppVariant.PRODUCTION); + assertThat(first.actionType()).isEqualTo(PushActionType.PLACE_DETAIL); + assertThat(first.actionParams()).containsEntry("placeId", 10L); + assertThat(first.title()).isEqualTo("기다리던 자리가 생겼어요!"); + assertThat(first.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(first.body()).doesNotContain("null"); + assertThat(first.idempotencyKey()).isEqualTo("crowd-vacant:10:1:99"); + } + + @Test + void doesNotCreateDispatchWhenNoFavoriteUsersExist() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of()); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + verify(placeRepository, never()).findById(org.mockito.ArgumentMatchers.any()); + } + + @Test + void skipsUsersWithoutProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place(null, "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of(1L)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + private PlaceBecameVacantEvent event() { + return new PlaceBecameVacantEvent( + 10L, + 99L, + LocalDateTime.parse("2026-08-05T10:00:00") + ); + } + + private Place place( + String buildingName, + String placeName + ) { + Building building = new Building(); + ReflectionTestUtils.setField(building, "name", buildingName); + + Place place = new Place(); + ReflectionTestUtils.setField(place, "id", 10L); + place.setBuilding(building); + place.setName(placeName); + return place; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java new file mode 100644 index 00000000..baf20a3a --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java @@ -0,0 +1,39 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomainPushEventListenerAnnotationTest { + + @Test + void listenersRunAfterCommit() throws NoSuchMethodException { + assertAfterCommit(CrowdVacantPushEventListener.class, PlaceBecameVacantEvent.class); + assertAfterCommit(ReportResolvedPushEventListener.class, ReportResolvedEvent.class); + assertAfterCommit(CharacterUnlockedPushEventListener.class, CharacterUnlockedEvent.class); + } + + private void assertAfterCommit( + Class listenerClass, + Class eventClass + ) throws NoSuchMethodException { + TransactionalEventListener annotation = listenerClass + .getDeclaredMethod("handle", eventClass) + .getAnnotation(TransactionalEventListener.class); + Transactional transactional = listenerClass + .getDeclaredMethod("handle", eventClass) + .getAnnotation(Transactional.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + assertThat(transactional).isNotNull(); + assertThat(transactional.propagation()).isEqualTo(Propagation.REQUIRES_NEW); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java new file mode 100644 index 00000000..755c3046 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -0,0 +1,97 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportResolvedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private ReportResolvedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new ReportResolvedPushEventListener( + pushInstallationRepository, + pushDispatchService, + pushEventFlagService + ); + } + + @Test + void createsReporterDispatch() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.targetType()).isEqualTo(PushTargetType.USER); + assertThat(command.targetValue()).isEqualTo("7"); + assertThat(command.actionType()).isEqualTo(PushActionType.HOME); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.title()).isEqualTo("신고 처리 결과를 확인해주세요."); + assertThat(command.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); + assertThat(command.body()).doesNotContain("sensitive").doesNotContain("memo"); + assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenReporterIsUnknown() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + + listener.handle(new ReportResolvedEvent(3L, null, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenReporterHasNoProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java new file mode 100644 index 00000000..c554106b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -0,0 +1,83 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushEventFlagServiceTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private PushEventFlagService service; + + @BeforeEach + void setUp() { + service = new PushEventFlagService(redisTemplate); + ReflectionTestUtils.setField(service, "crowdDefaultEnabled", false); + ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); + ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); + + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + void returnsYamlDefaultWhenRedisValueDoesNotExist() { + when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn(null); + + assertThat(service.isEnabled(PushEventType.CROWD)).isFalse(); + assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + } + + @Test + void redisTrueOrFalseOverridesYamlDefault() { + when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn("true"); + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn("false"); + + assertThat(service.isEnabled(PushEventType.CROWD)).isTrue(); + assertThat(service.isEnabled(PushEventType.REPORT)).isFalse(); + } + + @Test + void returnsYamlDefaultWhenRedisReadFails() { + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenThrow(new RuntimeException("redis down")); + + assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + } + + @Test + void updatedValueIsReflectedImmediatelyInQueryResult() { + when(valueOperations.get(PushEventType.CHARACTER.redisKey())) + .thenReturn(null) + .thenReturn("true") + .thenReturn("true"); + + assertThat(service.isEnabled(PushEventType.CHARACTER)).isFalse(); + + AdminPushEventFlagRes updated = service.updateFlag(PushEventType.CHARACTER, true); + assertThat(updated.enabled()).isTrue(); + + List flags = service.getFlags(); + assertThat(flags) + .filteredOn(flag -> flag.eventType() == PushEventType.CHARACTER) + .singleElement() + .extracting(AdminPushEventFlagRes::enabled) + .isEqualTo(true); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java new file mode 100644 index 00000000..0ea3ef2c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java @@ -0,0 +1,55 @@ +package devkor.com.teamcback.domain.notification.template; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomainPushContentFactoryTest { + + @Test + void placeBecameVacantJoinsBuildingAndPlaceWithSingleSpace() { + PushContent content = DomainPushContentFactory.placeBecameVacant( + " 신공학관 ", + " 라운지 " + ); + + assertThat(content.title()).isEqualTo("기다리던 자리가 생겼어요!"); + assertThat(content.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + } + + @Test + void placeBecameVacantDoesNotIncludeNullWhenOneValueExists() { + PushContent buildingOnly = DomainPushContentFactory.placeBecameVacant("신공학관", null); + PushContent placeOnly = DomainPushContentFactory.placeBecameVacant(null, "라운지"); + + assertThat(buildingOnly.body()).isEqualTo("신공학관이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(placeOnly.body()).isEqualTo("라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(buildingOnly.body()).doesNotContain("null"); + assertThat(placeOnly.body()).doesNotContain("null"); + } + + @Test + void placeBecameVacantUsesFallbackWhenBothValuesAreBlank() { + PushContent content = DomainPushContentFactory.placeBecameVacant(" ", null); + + assertThat(content.body()).isEqualTo("즐겨찾기한 공간이 한산해요. 방문하기 전 현황을 확인해보세요."); + } + + @Test + void characterUnlockedUsesFallbackWhenNameIsNullOrBlank() { + PushContent nullName = DomainPushContentFactory.characterUnlocked(null); + PushContent blankName = DomainPushContentFactory.characterUnlocked(" "); + + assertThat(nullName.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); + assertThat(nullName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + assertThat(blankName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + } + + @Test + void reportResolvedCreatesConfiguredTitleAndBody() { + PushContent content = DomainPushContentFactory.reportResolved(); + + assertThat(content.title()).isEqualTo("신고 처리 결과를 확인해주세요."); + assertThat(content.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java new file mode 100644 index 00000000..b58033b0 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java @@ -0,0 +1,38 @@ +package devkor.com.teamcback.domain.notification.validation; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PushActionValidatorTest { + + private final PushActionValidator validator = new PushActionValidator(); + + @Test + void characterStoreAllowsNoParams() { + Map params = validator.validateAndNormalize( + PushActionType.CHARACTER_STORE, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + Map.of() + ); + + assertThat(params).isEmpty(); + } + + @Test + void characterStoreRejectsParams() { + assertThatThrownBy(() -> validator.validateAndNormalize( + PushActionType.CHARACTER_STORE, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + Map.of("characterId", 1L) + )).isInstanceOf(GlobalException.class); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java new file mode 100644 index 00000000..32020469 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java @@ -0,0 +1,118 @@ +package devkor.com.teamcback.domain.report.service; + +import devkor.com.teamcback.domain.report.dto.request.UpdateReportStatusReq; +import devkor.com.teamcback.domain.report.entity.ReasonCategory; +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import devkor.com.teamcback.domain.report.repository.ReportRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private ReviewRepository reviewRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private ReportService reportService; + private User reporter; + + @BeforeEach + void setUp() { + reportService = new ReportService( + reportRepository, + reviewRepository, + userRepository, + eventPublisher + ); + + reporter = new User("reporter", "reporter@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(reporter, "userId", 7L); + } + + @Test + void publishesEventWhenPendingReportBecomesFinalStatus() { + Report report = report(ReportStatus.PENDING, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReportResolvedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().reportId()).isEqualTo(1L); + assertThat(captor.getValue().reporterUserId()).isEqualTo(7L); + assertThat(captor.getValue().finalStatus()).isEqualTo(ReportStatus.REJECTED); + } + + @Test + void doesNotPublishWhenFinalReportIsReprocessed() { + Report report = report(ReportStatus.RESOLVED, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenStatusDoesNotChangeToFinal() { + Report report = report(ReportStatus.PENDING, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.PENDING)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + private UpdateReportStatusReq req(ReportStatus status) { + UpdateReportStatusReq req = new UpdateReportStatusReq(); + req.setStatus(status); + return req; + } + + private Report report( + ReportStatus status, + User reporter + ) { + Report report = new Report( + TargetType.REVIEW, + 20L, + ReasonCategory.SPAM_OR_ADVERTISING, + "sensitive report content", + status, + reporter, + null + ); + ReflectionTestUtils.setField(report, "id", 1L); + return report; + } +}