Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
@Query("""
SELECT n FROM Notification n
WHERE (
(n.category = com.swyp.picke.domain.notification.enums.NotificationCategory.CONTENT AND n.user.id = :userId)
OR
(n.category <> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public NotificationListResponse getNotifications(Long userId, NotificationCatego
userId, filterCategory, PageRequest.of(page, pageSize));

List<Long> broadcastIds = slice.getContent().stream()
.filter(n -> n.getCategory() != NotificationCategory.CONTENT)
.filter(n -> n.getUser() == null)
.map(Notification::getId)
.toList();

Expand All @@ -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());
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -156,10 +155,9 @@ private Notification getAccessibleNotification(Long userId, Long notificationId)
}

private boolean resolveIsRead(Notification notification, Set<Long> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, Object> payload = Map.of(
"category", "NOTICE",
"title", "서비스 점검 안내",
"body", "오늘 22시에 점검이 진행됩니다.",
"referenceId", 123L
"title", title,
"body", "maintenance at 22:00"
);

mockMvc.perform(post("/api/v1/admin/notices")
Expand All @@ -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();

Expand All @@ -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<String, Object> 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<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down