From 390d9f560c19cc0257534d06f940d5928e23569d Mon Sep 17 00:00:00 2001 From: jucheonsu Date: Fri, 24 Apr 2026 22:52:25 +0900 Subject: [PATCH] =?UTF-8?q?[Fix]=20=EC=95=8C=EB=A6=BC=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?/=EA=B0=9C=EC=9D=B8=20=EB=85=B8=EC=B6=9C=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NotificationRepository.java | 5 +- .../service/NotificationService.java | 18 +- .../AdminNoticeIntegrationTest.java | 206 ++++++++++++++++-- .../service/NotificationServiceTest.java | 27 +++ 4 files changed, 230 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java index 973a589..7ab6759 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -14,9 +14,8 @@ public interface NotificationRepository extends JpaRepository com.swyp.picke.domain.notification.enums.NotificationCategory.CONTENT AND n.user IS NULL) + (n.user IS NOT NULL AND n.user.id = :userId) + OR n.user IS NULL ) AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java index 8053990..e8116e5 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -80,7 +80,7 @@ public NotificationListResponse getNotifications(Long userId, NotificationCatego userId, filterCategory, PageRequest.of(page, pageSize)); List broadcastIds = slice.getContent().stream() - .filter(n -> n.getCategory() != NotificationCategory.CONTENT) + .filter(n -> n.getUser() == null) .map(Notification::getId) .toList(); @@ -102,7 +102,7 @@ public NotificationListResponse getNotifications(Long userId, NotificationCatego public NotificationDetailResponse getNotificationDetail(Long userId, Long notificationId) { Notification notification = getAccessibleNotification(userId, notificationId); - if (notification.getCategory() == NotificationCategory.CONTENT) { + if (notification.getUser() != null) { return toDetailResponse(notification, notification.isRead(), notification.getReadAt()); } @@ -114,7 +114,7 @@ public NotificationDetailResponse getNotificationDetail(Long userId, Long notifi public void markAsRead(Long userId, Long notificationId) { Notification notification = getAccessibleNotification(userId, notificationId); - if (notification.getCategory() == NotificationCategory.CONTENT) { + if (notification.getUser() != null) { notification.markAsRead(); return; } @@ -144,9 +144,8 @@ private Notification getAccessibleNotification(Long userId, Long notificationId) Notification notification = notificationRepository.findById(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); - boolean isAccessible = notification.getCategory() == NotificationCategory.CONTENT - ? notification.getUser() != null && notification.getUser().getId().equals(userId) - : notification.getUser() == null; + boolean isAccessible = notification.getUser() == null + || notification.getUser().getId().equals(userId); if (!isAccessible) { throw new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND); @@ -156,10 +155,9 @@ private Notification getAccessibleNotification(Long userId, Long notificationId) } private boolean resolveIsRead(Notification notification, Set readBroadcastIds) { - if (notification.getCategory() == NotificationCategory.CONTENT) { - return notification.isRead(); - } - return readBroadcastIds.contains(notification.getId()); + return notification.getUser() != null + ? notification.isRead() + : readBroadcastIds.contains(notification.getId()); } private NotificationDetailResponse toDetailResponse(Notification notification, boolean isRead, java.time.LocalDateTime readAt) { diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java index 404f813..d06b468 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; import com.swyp.picke.domain.notification.repository.NotificationRepository; import com.swyp.picke.domain.oauth.jwt.JwtProvider; import com.swyp.picke.domain.user.entity.User; @@ -25,6 +26,9 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -57,15 +61,15 @@ class AdminNoticeIntegrationTest { private S3PresignedUrlService s3PresignedUrlService; @Test - @DisplayName("관리자 공지 생성 및 목록 조회가 동작한다") + @DisplayName("admin can create and list notices") void admin_can_create_and_list_notices() throws Exception { String adminToken = createAdminToken(); + String title = "notice-" + UUID.randomUUID().toString().substring(0, 8); Map payload = Map.of( "category", "NOTICE", - "title", "서비스 점검 안내", - "body", "오늘 22시에 점검이 진행됩니다.", - "referenceId", 123L + "title", title, + "body", "maintenance at 22:00" ); mockMvc.perform(post("/api/v1/admin/notices") @@ -75,10 +79,10 @@ void admin_can_create_and_list_notices() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.notificationId").exists()) .andExpect(jsonPath("$.data.category").value("NOTICE")) - .andExpect(jsonPath("$.data.title").value("서비스 점검 안내")); + .andExpect(jsonPath("$.data.title").value(title)); Notification saved = notificationRepository.findAll().stream() - .filter(notification -> "서비스 점검 안내".equals(notification.getTitle())) + .filter(notification -> title.equals(notification.getTitle())) .findFirst() .orElseThrow(); @@ -90,19 +94,195 @@ void admin_can_create_and_list_notices() throws Exception { .param("page", "0") .param("size", "20")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items[0].notificationId").exists()) - .andExpect(jsonPath("$.data.items[0].title").isNotEmpty()); + .andExpect(jsonPath("$.data.items[*].title", hasItem(title))); + } + + @Test + @DisplayName("admin notice page form flow persists notice and user can fetch it") + void admin_notice_page_form_flow_persists_notice_and_user_can_fetch_it() throws Exception { + String adminToken = createAdminToken(); + String userToken = createUserToken(); + String marker = UUID.randomUUID().toString().substring(0, 8); + String title = "ui-notice-" + marker; + String body = "ui-body-" + marker; + + mockMvc.perform(get("/api/v1/admin/picke/notice")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("id=\"notice-form\""))) + .andExpect(content().string(containsString("id=\"notice-title\""))) + .andExpect(content().string(containsString("id=\"notice-body\""))) + .andExpect(content().string(containsString("/js/admin/notice/notice.js"))); + + Map payload = Map.of( + "category", "NOTICE", + "title", title, + "body", body + ); + + String createResponse = mockMvc.perform(post("/api/v1/admin/notices") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.category").value("NOTICE")) + .andExpect(jsonPath("$.data.title").value(title)) + .andReturn() + .getResponse() + .getContentAsString(); + + long noticeId = objectMapper.readTree(createResponse) + .path("data") + .path("notificationId") + .asLong(); + + assertThat(noticeId).isPositive(); + + Notification saved = notificationRepository.findById(noticeId).orElseThrow(); + assertThat(saved.getTitle()).isEqualTo(title); + assertThat(saved.getBody()).isEqualTo(body); + assertThat(saved.getCategory()).isEqualTo(NotificationCategory.NOTICE); + assertThat(saved.getUser()).isNull(); + + mockMvc.perform(get("/api/v1/admin/notices") + .header("Authorization", "Bearer " + adminToken) + .param("category", "NOTICE") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[*].title", hasItem(title))); + + mockMvc.perform(get("/api/v1/notifications") + .header("Authorization", "Bearer " + userToken) + .param("category", "NOTICE") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[*].title", hasItem(title))); + + mockMvc.perform(get("/api/v1/notifications/{notificationId}", noticeId) + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notificationId").value((int) noticeId)) + .andExpect(jsonPath("$.data.title").value(title)) + .andExpect(jsonPath("$.data.body").value(body)) + .andExpect(jsonPath("$.data.category").value("NOTICE")); + } + + @Test + @DisplayName("admin registration creates CONTENT NOTICE EVENT broadcasts and user can list/detail them") + void admin_registration_creates_all_categories_and_user_can_fetch_list_and_detail() throws Exception { + String adminToken = createAdminToken(); + String userToken = createUserToken(); + String marker = UUID.randomUUID().toString().substring(0, 8); + + String contentTitle = "content-" + marker; + String noticeTitle = "notice-" + marker; + String eventTitle = "event-" + marker; + + Notification content = assertCategoryFlow( + adminToken, userToken, NotificationCategory.CONTENT, contentTitle, NotificationDetailCode.NEW_BATTLE); + Notification notice = assertCategoryFlow( + adminToken, userToken, NotificationCategory.NOTICE, noticeTitle, NotificationDetailCode.POLICY_CHANGE); + Notification event = assertCategoryFlow( + adminToken, userToken, NotificationCategory.EVENT, eventTitle, NotificationDetailCode.PROMOTION); + + mockMvc.perform(get("/api/v1/notifications") + .header("Authorization", "Bearer " + userToken) + .param("category", "ALL") + .param("page", "0") + .param("size", "50")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[*].title", hasItem(contentTitle))) + .andExpect(jsonPath("$.data.items[*].title", hasItem(noticeTitle))) + .andExpect(jsonPath("$.data.items[*].title", hasItem(eventTitle))); + + mockMvc.perform(get("/api/v1/notifications/{notificationId}", content.getId()) + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notificationId").value(content.getId().intValue())) + .andExpect(jsonPath("$.data.category").value("CONTENT")); + + mockMvc.perform(get("/api/v1/notifications/{notificationId}", notice.getId()) + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notificationId").value(notice.getId().intValue())) + .andExpect(jsonPath("$.data.category").value("NOTICE")); + + mockMvc.perform(get("/api/v1/notifications/{notificationId}", event.getId()) + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notificationId").value(event.getId().intValue())) + .andExpect(jsonPath("$.data.category").value("EVENT")); + } + + private Notification assertCategoryFlow( + String adminToken, + String userToken, + NotificationCategory category, + String title, + NotificationDetailCode expectedDetailCode + ) throws Exception { + String body = "body-" + category.name(); + Map payload = Map.of( + "category", category.name(), + "title", title, + "body", body + ); + + mockMvc.perform(post("/api/v1/admin/notices") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.category").value(category.name())) + .andExpect(jsonPath("$.data.title").value(title)); + + Notification saved = notificationRepository.findAll().stream() + .filter(notification -> title.equals(notification.getTitle())) + .findFirst() + .orElseThrow(); + + assertThat(saved.getUser()).isNull(); + assertThat(saved.getCategory()).isEqualTo(category); + assertThat(saved.getDetailCode()).isEqualTo(expectedDetailCode); + assertThat(saved.getBody()).isEqualTo(body); + + mockMvc.perform(get("/api/v1/admin/notices") + .header("Authorization", "Bearer " + adminToken) + .param("category", category.name()) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[*].title", hasItem(title))); + + mockMvc.perform(get("/api/v1/notifications") + .header("Authorization", "Bearer " + userToken) + .param("category", category.name()) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[*].title", hasItem(title))); + + return saved; } private String createAdminToken() { - User admin = userRepository.save( + return createToken(UserRole.ADMIN, "adm"); + } + + private String createUserToken() { + return createToken(UserRole.USER, "usr"); + } + + private String createToken(UserRole role, String prefix) { + User user = userRepository.save( User.builder() - .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) - .nickname("admin") - .role(UserRole.ADMIN) + .userTag(prefix + "-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname(prefix) + .role(role) .status(UserStatus.ACTIVE) .build() ); - return jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + return jwtProvider.createAccessToken(user.getId(), role.name()); } } diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java index d952b47..69aea51 100644 --- a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java @@ -134,6 +134,33 @@ void getNotifications_resolves_broadcast_read_status() { assertThat(response.items().getFirst().isRead()).isTrue(); } + @Test + @DisplayName("content broadcast is visible to users") + void getNotifications_includes_content_broadcast_notifications() { + Long userId = 1L; + Notification broadcastContent = Notification.builder() + .user(null) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.NEW_BATTLE) + .title("New content") + .body("Broadcast content notification") + .referenceId(77L) + .build(); + + setNotificationId(broadcastContent, 77L); + + when(notificationRepository.findVisibleNotifications(eq(userId), eq(NotificationCategory.CONTENT), any(Pageable.class))) + .thenReturn(new SliceImpl<>(List.of(broadcastContent))); + when(notificationReadRepository.findByUserIdAndNotificationIdIn(userId, List.of(77L))) + .thenReturn(List.of()); + + NotificationListResponse response = notificationService.getNotifications(userId, NotificationCategory.CONTENT, 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().category()).isEqualTo(NotificationCategory.CONTENT); + assertThat(response.items().getFirst().isRead()).isFalse(); + } + @Test @DisplayName("존재하지 않는 알림 읽음 처리 시 예외를 던진다") void markAsRead_throws_when_not_found() {