From 457225d23278a7de38527986459a8d587bf36ba7 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 19:41:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ble/event/PlaceBecameVacantEvent.java | 10 ++ .../ble/repository/BLEDataRepository.java | 1 + .../domain/ble/service/BLEService.java | 28 ++++ .../repository/CategoryRepository.java | 11 ++ .../event/CharacterUnlockedEvent.java | 9 ++ .../character/service/AdminStoreService.java | 11 +- .../character/service/StoreService.java | 9 ++ .../entity/type/PushActionType.java | 1 + .../CharacterUnlockedPushEventListener.java | 84 ++++++++++ .../CrowdVacantPushEventListener.java | 137 ++++++++++++++++ .../ReportResolvedPushEventListener.java | 77 +++++++++ .../PushInstallationRepository.java | 5 + .../validation/PushActionValidator.java | 1 + .../report/event/ReportResolvedEvent.java | 10 ++ .../domain/report/service/ReportService.java | 26 ++++ src/main/resources/application.yml | 4 + .../domain/ble/service/BLEServiceTest.java | 144 +++++++++++++++++ .../service/AdminStoreServiceTest.java | 14 +- .../character/service/StoreServiceTest.java | 18 ++- ...haracterUnlockedPushEventListenerTest.java | 82 ++++++++++ .../CrowdVacantPushEventListenerTest.java | 146 ++++++++++++++++++ ...DomainPushEventListenerAnnotationTest.java | 32 ++++ .../ReportResolvedPushEventListenerTest.java | 79 ++++++++++ .../validation/PushActionValidatorTest.java | 38 +++++ .../report/service/ReportServiceTest.java | 118 ++++++++++++++ 25 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java create mode 100644 src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java create mode 100644 src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java 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/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/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java new file mode 100644 index 00000000..a9e19ef0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -0,0 +1,84 @@ +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.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 java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +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; + + @Value("${push.event.character-enabled:false}") + private boolean characterEnabled; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CharacterUnlockedEvent event) { + if (!characterEnabled) { + return; + } + + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.userId(), + AppVariant.PRODUCTION + )) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(event.userId()), + "새 캐릭터가 기다리고 있어요!", + characterBody(event.characterName()), + 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() + ); + } + } + + private String characterBody(String characterName) { + if (characterName == null || characterName.isBlank()) { + return "새 캐릭터를 만나러 가볼까요?"; + } + return characterName.trim() + "을 만나러 가볼까요?"; + } +} 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..2b498061 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -0,0 +1,137 @@ +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.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.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.beans.factory.annotation.Value; +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 static final String TITLE = "기다리던 자리가 생겼어요!"; + + private final PlaceRepository placeRepository; + private final CategoryRepository categoryRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + + @Value("${push.event.crowd-enabled:false}") + private boolean crowdEnabled; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(PlaceBecameVacantEvent event) { + if (!crowdEnabled) { + 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; + } + + String body = locationName(place) + "이 한산해요. 방문하기 전 현황을 확인해보세요."; + for (Long userId : userIds) { + enqueueIfPushTargetExists(event, userId, body); + } + } 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, + String body + ) { + if (userId == null + || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, AppVariant.PRODUCTION)) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(userId), + TITLE, + body, + PushActionType.PLACE_DETAIL, + Map.of("placeId", event.placeId()), + "crowd-vacant:%d:%d:%d".formatted(event.placeId(), userId, event.bleDataId()), + SYSTEM_CREATED_BY + )); + } + + private String locationName(Place place) { + String buildingName = place.getBuilding() == null ? null : place.getBuilding().getName(); + String placeName = place.getName(); + String joined = joinNonBlank(buildingName, placeName); + return joined.isBlank() ? "즐겨찾기한 공간" : joined; + } + + private String joinNonBlank( + String first, + String second + ) { + StringBuilder builder = new StringBuilder(); + appendIfPresent(builder, first); + appendIfPresent(builder, second); + return builder.toString(); + } + + private void appendIfPresent( + StringBuilder builder, + String value + ) { + if (value == null || value.isBlank()) { + return; + } + if (!builder.isEmpty()) { + builder.append(" "); + } + builder.append(value.trim()); + } +} 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..293dbafe --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -0,0 +1,77 @@ +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.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.report.event.ReportResolvedEvent; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +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; + + @Value("${push.event.report-enabled:false}") + private boolean reportEnabled; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ReportResolvedEvent event) { + if (!reportEnabled || event.reporterUserId() == null) { + return; + } + + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.reporterUserId(), + AppVariant.PRODUCTION + )) { + return; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.USER, + String.valueOf(event.reporterUserId()), + "신고 처리 결과를 확인해주세요.", + "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요.", + 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/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..586254b5 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); @@ -184,7 +191,7 @@ void grantCharacter() { assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); 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 +199,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..2a2fd988 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("해금 레벨 미달이면 포인트가 충분해도 구매 불가") @@ -160,6 +175,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/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java new file mode 100644 index 00000000..c0813f71 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -0,0 +1,82 @@ +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.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +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.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CharacterUnlockedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + private CharacterUnlockedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CharacterUnlockedPushEventListener( + pushInstallationRepository, + pushDispatchService + ); + } + + @Test + void createsCharacterStoreDispatch() { + ReflectionTestUtils.setField(listener, "characterEnabled", 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() { + ReflectionTestUtils.setField(listener, "characterEnabled", 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() { + ReflectionTestUtils.setField(listener, "characterEnabled", 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..cc171469 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -0,0 +1,146 @@ +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.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.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.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class CrowdVacantPushEventListenerTest { + + @Mock + private PlaceRepository placeRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + private CrowdVacantPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CrowdVacantPushEventListener( + placeRepository, + categoryRepository, + pushInstallationRepository, + pushDispatchService + ); + } + + @Test + void createsUserDispatchesForDistinctFavoriteUsers() { + ReflectionTestUtils.setField(listener, "crowdEnabled", 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() { + ReflectionTestUtils.setField(listener, "crowdEnabled", 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() { + ReflectionTestUtils.setField(listener, "crowdEnabled", false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + verify(placeRepository, never()).findById(org.mockito.ArgumentMatchers.any()); + } + + @Test + void skipsUsersWithoutProductionInstallation() { + ReflectionTestUtils.setField(listener, "crowdEnabled", 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..7fe7a2e8 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java @@ -0,0 +1,32 @@ +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.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); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + } +} 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..11979869 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.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.PushActionType; +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.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 org.springframework.test.util.ReflectionTestUtils; + +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; + + private ReportResolvedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new ReportResolvedPushEventListener( + pushInstallationRepository, + pushDispatchService + ); + } + + @Test + void createsReporterDispatch() { + ReflectionTestUtils.setField(listener, "reportEnabled", 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.body()).doesNotContain("sensitive").doesNotContain("memo"); + assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + ReflectionTestUtils.setField(listener, "reportEnabled", false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenReporterIsUnknown() { + ReflectionTestUtils.setField(listener, "reportEnabled", true); + + listener.handle(new ReportResolvedEvent(3L, null, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} 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; + } +} From 8a0405c565113333f98146d6138d49b5ab238bd6 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 20:54:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=20=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CharacterUnlockedPushEventListener.java | 14 ++-- .../CrowdVacantPushEventListener.java | 46 +++---------- .../ReportResolvedPushEventListener.java | 7 +- .../template/DomainPushContentFactory.java | 67 +++++++++++++++++++ .../notification/template/PushContent.java | 7 ++ ...haracterUnlockedPushEventListenerTest.java | 2 +- .../CrowdVacantPushEventListenerTest.java | 2 +- .../ReportResolvedPushEventListenerTest.java | 2 + .../DomainPushContentFactoryTest.java | 55 +++++++++++++++ 9 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java 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 index a9e19ef0..22369db6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -9,6 +9,8 @@ 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.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,14 +49,15 @@ public void handle(CharacterUnlockedEvent event) { return; } + PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); pushDispatchService.enqueue(new PushDispatchCommand( NotificationType.GENERAL, PushMode.ACTUAL, AppVariant.PRODUCTION, PushTargetType.USER, String.valueOf(event.userId()), - "새 캐릭터가 기다리고 있어요!", - characterBody(event.characterName()), + content.title(), + content.body(), PushActionType.CHARACTER_STORE, Map.of(), "character-unlock:%d:%d:%d".formatted( @@ -74,11 +77,4 @@ public void handle(CharacterUnlockedEvent event) { ); } } - - private String characterBody(String characterName) { - if (characterName == null || characterName.isBlank()) { - return "새 캐릭터를 만나러 가볼까요?"; - } - return characterName.trim() + "을 만나러 가볼까요?"; - } } 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 index 2b498061..7c543084 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -11,6 +11,8 @@ 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.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; @@ -31,7 +33,6 @@ public class CrowdVacantPushEventListener { private static final Long SYSTEM_CREATED_BY = 0L; - private static final String TITLE = "기다리던 자리가 생겼어요!"; private final PlaceRepository placeRepository; private final CategoryRepository categoryRepository; @@ -66,9 +67,12 @@ public void handle(PlaceBecameVacantEvent event) { return; } - String body = locationName(place) + "이 한산해요. 방문하기 전 현황을 확인해보세요."; + PushContent content = DomainPushContentFactory.placeBecameVacant( + place.getBuilding() == null ? null : place.getBuilding().getName(), + place.getName() + ); for (Long userId : userIds) { - enqueueIfPushTargetExists(event, userId, body); + enqueueIfPushTargetExists(event, userId, content); } } catch (Exception e) { log.warn( @@ -83,7 +87,7 @@ public void handle(PlaceBecameVacantEvent event) { private void enqueueIfPushTargetExists( PlaceBecameVacantEvent event, Long userId, - String body + PushContent content ) { if (userId == null || !pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, AppVariant.PRODUCTION)) { @@ -96,42 +100,12 @@ private void enqueueIfPushTargetExists( AppVariant.PRODUCTION, PushTargetType.USER, String.valueOf(userId), - TITLE, - body, + 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 )); } - - private String locationName(Place place) { - String buildingName = place.getBuilding() == null ? null : place.getBuilding().getName(); - String placeName = place.getName(); - String joined = joinNonBlank(buildingName, placeName); - return joined.isBlank() ? "즐겨찾기한 공간" : joined; - } - - private String joinNonBlank( - String first, - String second - ) { - StringBuilder builder = new StringBuilder(); - appendIfPresent(builder, first); - appendIfPresent(builder, second); - return builder.toString(); - } - - private void appendIfPresent( - StringBuilder builder, - String value - ) { - if (value == null || value.isBlank()) { - return; - } - if (!builder.isEmpty()) { - builder.append(" "); - } - builder.append(value.trim()); - } } 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 index 293dbafe..5871d906 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -8,6 +8,8 @@ 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.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; @@ -47,14 +49,15 @@ public void handle(ReportResolvedEvent event) { 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( 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/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java index c0813f71..f0a51bba 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -68,7 +68,7 @@ void usesSafeBodyWhenCharacterNameIsBlank() { ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); verify(pushDispatchService).enqueue(captor.capture()); - assertThat(captor.getValue().body()).isEqualTo("새 캐릭터를 만나러 가볼까요?"); + assertThat(captor.getValue().body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); } @Test 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 index cc171469..872d4d1a 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -26,9 +26,9 @@ 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; -import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class CrowdVacantPushEventListenerTest { 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 index 11979869..38017420 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -55,6 +55,8 @@ void createsReporterDispatch() { 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"); } 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("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); + } +} From 974be030a70e9f3e47a8b20280d8e343ee10280a Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 5 Aug 2026 21:30:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=9F=B0=ED=83=80=EC=9E=84=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminNotificationController.java | 24 ++++++ .../dto/request/UpdatePushEventFlagReq.java | 6 ++ .../dto/response/AdminPushEventFlagRes.java | 9 ++ .../entity/type/PushEventType.java | 17 ++++ .../CharacterUnlockedPushEventListener.java | 9 +- .../CrowdVacantPushEventListener.java | 9 +- .../ReportResolvedPushEventListener.java | 9 +- .../service/PushEventFlagService.java | 67 +++++++++++++++ .../service/AdminStoreServiceTest.java | 1 + .../character/service/StoreServiceTest.java | 1 + .../AdminNotificationControllerTest.java | 66 +++++++++++++++ ...haracterUnlockedPushEventListenerTest.java | 26 ++++-- .../CrowdVacantPushEventListenerTest.java | 16 ++-- ...DomainPushEventListenerAnnotationTest.java | 7 ++ .../ReportResolvedPushEventListenerTest.java | 26 ++++-- .../service/PushEventFlagServiceTest.java | 83 +++++++++++++++++++ 16 files changed, 346 insertions(+), 30 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java 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/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 index 22369db6..bd49011c 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -5,16 +5,17 @@ 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.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -30,14 +31,12 @@ public class CharacterUnlockedPushEventListener { private final PushInstallationRepository pushInstallationRepository; private final PushDispatchService pushDispatchService; - - @Value("${push.event.character-enabled:false}") - private boolean characterEnabled; + private final PushEventFlagService pushEventFlagService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(CharacterUnlockedEvent event) { - if (!characterEnabled) { + if (!pushEventFlagService.isEnabled(PushEventType.CHARACTER)) { return; } 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 index 7c543084..5f7ae697 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -7,9 +7,11 @@ 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; @@ -20,7 +22,6 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -38,14 +39,12 @@ public class CrowdVacantPushEventListener { private final CategoryRepository categoryRepository; private final PushInstallationRepository pushInstallationRepository; private final PushDispatchService pushDispatchService; - - @Value("${push.event.crowd-enabled:false}") - private boolean crowdEnabled; + private final PushEventFlagService pushEventFlagService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(PlaceBecameVacantEvent event) { - if (!crowdEnabled) { + if (!pushEventFlagService.isEnabled(PushEventType.CROWD)) { return; } 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 index 5871d906..fff6f4b6 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -4,17 +4,18 @@ 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.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -30,14 +31,12 @@ public class ReportResolvedPushEventListener { private final PushInstallationRepository pushInstallationRepository; private final PushDispatchService pushDispatchService; - - @Value("${push.event.report-enabled:false}") - private boolean reportEnabled; + private final PushEventFlagService pushEventFlagService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(ReportResolvedEvent event) { - if (!reportEnabled || event.reporterUserId() == null) { + if (!pushEventFlagService.isEnabled(PushEventType.REPORT) || event.reporterUserId() == null) { return; } 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/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java index 586254b5..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 @@ -189,6 +189,7 @@ 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.saveAndFlush(any(UserCharacter.class))).thenAnswer(invocation -> { 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 2a2fd988..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 @@ -147,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("비활성 캐릭터는 구매 불가") 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 index f0a51bba..f8a04e01 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -4,16 +4,17 @@ 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 org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; @@ -29,19 +30,23 @@ class CharacterUnlockedPushEventListenerTest { @Mock private PushDispatchService pushDispatchService; + @Mock + private PushEventFlagService pushEventFlagService; + private CharacterUnlockedPushEventListener listener; @BeforeEach void setUp() { listener = new CharacterUnlockedPushEventListener( pushInstallationRepository, - pushDispatchService + pushDispatchService, + pushEventFlagService ); } @Test void createsCharacterStoreDispatch() { - ReflectionTestUtils.setField(listener, "characterEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) .thenReturn(true); @@ -60,7 +65,7 @@ void createsCharacterStoreDispatch() { @Test void usesSafeBodyWhenCharacterNameIsBlank() { - ReflectionTestUtils.setField(listener, "characterEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) .thenReturn(true); @@ -73,7 +78,18 @@ void usesSafeBodyWhenCharacterNameIsBlank() { @Test void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - ReflectionTestUtils.setField(listener, "characterEnabled", false); + 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, "아기 호랑이")); 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 index 872d4d1a..cabb35be 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -7,10 +7,12 @@ 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; @@ -45,6 +47,9 @@ class CrowdVacantPushEventListenerTest { @Mock private PushDispatchService pushDispatchService; + @Mock + private PushEventFlagService pushEventFlagService; + private CrowdVacantPushEventListener listener; @BeforeEach @@ -53,13 +58,14 @@ void setUp() { placeRepository, categoryRepository, pushInstallationRepository, - pushDispatchService + pushDispatchService, + pushEventFlagService ); } @Test void createsUserDispatchesForDistinctFavoriteUsers() { - ReflectionTestUtils.setField(listener, "crowdEnabled", true); + 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)); @@ -88,7 +94,7 @@ void createsUserDispatchesForDistinctFavoriteUsers() { @Test void doesNotCreateDispatchWhenNoFavoriteUsersExist() { - ReflectionTestUtils.setField(listener, "crowdEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) .thenReturn(List.of()); @@ -100,7 +106,7 @@ void doesNotCreateDispatchWhenNoFavoriteUsersExist() { @Test void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - ReflectionTestUtils.setField(listener, "crowdEnabled", false); + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(false); listener.handle(event()); @@ -110,7 +116,7 @@ void doesNotCreateDispatchWhenFeatureFlagIsFalse() { @Test void skipsUsersWithoutProductionInstallation() { - ReflectionTestUtils.setField(listener, "crowdEnabled", true); + 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)); 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 index 7fe7a2e8..baf20a3a 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java @@ -4,6 +4,8 @@ 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; @@ -25,8 +27,13 @@ private void assertAfterCommit( 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 index 38017420..755c3046 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -3,9 +3,11 @@ 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; @@ -14,7 +16,6 @@ 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; @@ -30,19 +31,23 @@ class ReportResolvedPushEventListenerTest { @Mock private PushDispatchService pushDispatchService; + @Mock + private PushEventFlagService pushEventFlagService; + private ReportResolvedPushEventListener listener; @BeforeEach void setUp() { listener = new ReportResolvedPushEventListener( pushInstallationRepository, - pushDispatchService + pushDispatchService, + pushEventFlagService ); } @Test void createsReporterDispatch() { - ReflectionTestUtils.setField(listener, "reportEnabled", true); + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) .thenReturn(true); @@ -63,7 +68,7 @@ void createsReporterDispatch() { @Test void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - ReflectionTestUtils.setField(listener, "reportEnabled", false); + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(false); listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); @@ -72,10 +77,21 @@ void doesNotCreateDispatchWhenFeatureFlagIsFalse() { @Test void doesNotCreateDispatchWhenReporterIsUnknown() { - ReflectionTestUtils.setField(listener, "reportEnabled", true); + 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); + } +}