From 8c8a8bf42501207b73866f2edde2dbb6fdb96364 Mon Sep 17 00:00:00 2001 From: sevineleven Date: Tue, 11 Aug 2026 10:04:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=8C=8C=EC=8B=B1=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=A0=9C=EB=AA=A9=3D?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=9D=B4=EB=A6=84=20/=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=3D=EC=83=81=ED=83=9C=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OS 푸시 제목은 줄바꿈 없이 뒤가 잘린다. 이름과 상태를 한 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라져서, 지금은 이름을 10글자로 잘라 막고 있었다 - 길이를 줄이려는 게 아니라 잘리는 자리를 고르려는 것이었고, 대가로 실제 상품명 대부분이 일찍 잘렸다 - 이름을 제목에, 상태를 본문에 둔다. 제목이 이름뿐이면 OS 가 잘라도 잃는 게 없고 body 는 두 줄까지 보인다. 그래서 파싱 완료의 표시 글자 절단을 없앴다(char 안전망만 유지) - body 문구는 등록 출처로 갈린다 - 위시에 직접 담은 것과 토너먼트에 직접 올린 것은 다른 사건이다(토너먼트에 올려도 위시리스트에 안 들어간다). 판정은 라우팅이 이미 하고 있어 그대로 재사용했고 새 조회가 없다 - 문장을 통째로 변수로 채운다. notification_templates 가 타입당 한 행(PK=type)이라 위시용·토너먼트용 body 를 따로 둘 자리가 없고, 두 문장이 구조도 달라 공통 뼈대 + 변수로도 안 쪼개진다. 대가로 이 body 는 백오피스(#252)에서 편집할 수 없다 - title 은 여전히 템플릿이 소유한다 - 절단 유틸을 ItemDisplayName 으로 공유하고 캡을 인자로 받게 했다. 두 알림의 요구가 다르다: 파싱 완료는 제목이 이름뿐이라 절단 없음, 아이템 삭제는 "OO님이 '{이름}'을(를) 삭제했어요" 라 이름 뒤에 문장이 붙어 캡이 필요하다 - TournamentItemDeletedHandler 의 절단 누락도 함께 고쳤다. 상품명이 512자까지 허용되는데 캡이 없어, 긴 이름이 엔티티 불변식(require(title.length <= 255))에 걸리면 dispatcher 의 runCatching 이 예외를 삼켜 삭제 알림이 전 수신자에게 조용히 누락됐다 - 문서·example 갱신: "body 는 전 타입 빈 문자열" 서술 폐기, 출처별 문구 설명 추가. example 의 상태 문구는 리터럴 대신 핸들러 상수를 끌어와 문구가 바뀌면 따라오게 했다 - 테스트: ItemDisplayNameTest 로 이관하며 절단 없음/있음 양쪽 계약을 고정(이모지·ZWJ·조합부호·공백 정규화), 출처별 completionMessage 통합테스트 2건 추가 클라 대응(TeamPiKi/client#460)이 선행 또는 동반돼야 한다 - 지금 카드는 title 만 렌더해서 서버만 먼저 나가면 상품명만 보인다. API 계약은 안 바뀌어(body 필드 이미 존재) 클라를 먼저 배포해도 안전하다. --- .../controller/NotificationHistoryApi.kt | 8 +- .../NotificationHistoryApiExamples.kt | 11 ++- .../notification/handler/ItemDisplayName.kt | 55 +++++++++++++ .../handler/ItemParsingCompletedHandler.kt | 72 +++++++---------- .../handler/TournamentItemDeletedHandler.kt | 9 ++- .../service/NotificationTemplateVariables.kt | 9 ++- ...m_parsing_completed_template_title_body.kt | 32 ++++++++ .../handler/ItemDisplayNameTest.kt | 81 +++++++++++++++++++ .../ItemParsingCompletedHandlerTest.kt | 76 ----------------- ...NotificationEventHandlerIntegrationTest.kt | 27 +++++++ 10 files changed, 253 insertions(+), 127 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt create mode 100644 src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt create mode 100644 src/test/kotlin/com/depromeet/piki/notification/handler/ItemDisplayNameTest.kt delete mode 100644 src/test/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandlerTest.kt diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt index 1b9cdd1a6..27ef7c145 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt @@ -33,7 +33,9 @@ interface NotificationHistoryApi { "`refId` 로 이동 대상을 정하고, `id` 로 단건 읽음 처리(`POST /read`)를 한다.\n\n" + "**알림 타입 카탈로그 (전 10종)**\n\n" + "`type` 으로 화면을 분기하고 `refId` 로 이동 대상을 정한다. `kind` 는 **전 알림 공통 필드**로 항상 실리며 카드 라벨·아이콘(위시/토너먼트/시스템)이 된다. " + - "`body` 는 현재 전 타입 빈 문자열(`\"\"`).\n\n" + + "`body` 는 **`ITEM_PARSING_COMPLETED` 만 값이 있고 나머지 타입은 빈 문자열(`\"\"`)** 이다. " + + "그 타입만 `title` 에 아이템 이름을, `body` 에 상태 문구를 나눠 싣는다 — OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, " + + "이름과 상태를 한 줄에 담으면 이름이 길 때 상태가 사라지기 때문이다. 클라이언트는 `body` 가 비어 있을 수 있음을 전제로 그린다.\n\n" + "| `type` | 트리거 | `kind` | `refId` | `title` 예시 |\n" + "|---|---|---|---|---|\n" + "| `TOURNAMENT_JOINED` | 토너먼트 참가 | `TOURNAMENT` | tournamentId | {참가자}님이 참가했어요 |\n" + @@ -43,9 +45,11 @@ interface NotificationHistoryApi { "| `TOURNAMENT_PLAYED_FROM_LINK` | 플레이링크로 플레이 시작 | `TOURNAMENT` | ROOT 토너먼트 id | {플레이어}님이 회원님 토너먼트를 플레이했어요 |\n" + "| `TOURNAMENT_COMPLETED` | 멤버가 클론 완료 | `TOURNAMENT` | ROOT 토너먼트 id | {멤버}님이 회원님 토너먼트를 완료했어요 |\n" + "| `TOURNAMENT_RESULT_READY` | 주최자가 ROOT 완료 | `TOURNAMENT` | ROOT 토너먼트 id | 참여하신 {주최자}님의 토너먼트 결과가 나왔어요 |\n" + - "| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | {아이템 이름} 파싱이 완료되었어요 |\n" + + "| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` 에 상태 문구) |\n" + "| `ITEM_PARSING_FAILED` | 상품 추출 실패 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | 상품 정보를 가져오지 못했어요 |\n" + "| `ANNOUNCEMENT` | 관리자 공지(후속) | `SYSTEM` | 공지 id/0 | (관리자 입력) |\n\n" + + "> `ITEM_PARSING_COMPLETED` 의 `body` 도 같은 출처로 갈린다 — 위시에 직접 담았으면 \"위시 저장이 성공했어요\", " + + "토너먼트에 직접 올렸으면 \"아이템이 등록됐어요\". 토너먼트에 올린 상품이 위시리스트에 들어가지는 않으므로 두 문구를 구분한다.\n\n" + "> 파싱 알림(`ITEM_PARSING_*`)만 `kind` 가 발행 출처(위시 등록 / 토너먼트 추가)에 따라 갈린다 — 같은 `type` 이 두 플로우에서 발행되기 때문. " + "나머지 타입은 위 표의 값 하나로 고정이다.\n\n" + "> 아이템 좌표(`tournamentId`·`tournamentItemId`)가 추가로 실리는 타입: 토너먼트 출처 파싱 알림(`ITEM_PARSING_*` + `kind`=TOURNAMENT)과 " + diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt index 6a5ebfd21..1b3b67a85 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt @@ -6,6 +6,7 @@ import com.depromeet.piki.common.openapi.binds import com.depromeet.piki.common.openapi.examples import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.response.PageResponse +import com.depromeet.piki.notification.handler.ItemParsingCompletedHandler import com.depromeet.piki.notification.controller.dto.NotificationDeleteRequest import com.depromeet.piki.notification.controller.dto.NotificationDeleteResponse import com.depromeet.piki.notification.controller.dto.NotificationHistoryResponse @@ -152,8 +153,10 @@ class NotificationHistoryApiExamples( id = 1025, type = NotificationType.ITEM_PARSING_COMPLETED, kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.WISH), - title = "에어 조던 1 미드 파싱이 완료되었어요", - body = "", + // 파싱 완료만 title=이름 / body=상태 로 나뉜다(#913). 상태 문구는 리터럴로 박지 않고 핸들러 상수를 끌어와 + // 문구가 바뀌면 example 이 따라오게 한다(kind 를 NotificationKind.of 로 파생시키는 것과 같은 결). + title = "에어 조던 1 미드", + body = ItemParsingCompletedHandler.WISH_MESSAGE, refId = 512, isRead = true, createdAt = LocalDateTime.of(2026, 6, 8, 10, 5, 0), @@ -165,8 +168,8 @@ class NotificationHistoryApiExamples( id = 1024, type = NotificationType.ITEM_PARSING_COMPLETED, kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.TOURNAMENT), - title = "나이키 덩크 로우 파싱이 완료되었어요", - body = "", + title = "나이키 덩크 로우", + body = ItemParsingCompletedHandler.TOURNAMENT_MESSAGE, refId = 513, isRead = false, createdAt = LocalDateTime.of(2026, 6, 8, 10, 0, 0), diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt new file mode 100644 index 000000000..9b880f7d5 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt @@ -0,0 +1,55 @@ +package com.depromeet.piki.notification.handler + +import java.text.BreakIterator + +// 알림 문구에 실을 상품명을 표시용으로 다듬는다. 파싱 완료(#913)와 아이템 삭제 두 알림이 공유한다. +// +// 표시 글자 절단은 **선택**이다 — 두 알림의 사정이 다르다. +// - 파싱 완료: title 이 이름뿐이라 OS 가 잘라도 의미가 안 사라진다. 절단하지 않는다(maxGraphemes = null). +// - 아이템 삭제: "OO님이 '{이름}'을(를) 삭제했어요" 처럼 이름 뒤에 문장이 붙어, 이름이 길면 정작 무슨 일인지가 +// OS 절단에 먹힌다. 그래서 이쪽은 캡이 필요하다. +object ItemDisplayName { + // 이름이 없거나 공백뿐일 때. 파싱 전(PENDING·PROCESSING)이나 추출 실패로 name 이 비는 경우가 실재한다. + const val FALLBACK = "상품" + + // 표시 글자 수와 무관하게 거는 UTF-16 char 상한. grapheme cluster 1개가 조합 부호로 수십~수백 char 일 수 있어 + // "N 글자" 가 곧 짧은 문자열을 뜻하지 않는다. 그대로 두면 Notification 생성자의 require(title.length <= 255)에 + // 걸리고, dispatcher 의 수신자별 runCatching 이 그 예외를 삼켜 알림이 전 수신자에게 조용히 누락된다. + // 문구의 나머지 부분(닉네임·고정 문장)을 감안해 넉넉히 남긴다. + private const val MAX_CHARS = 100 + + private const val ELLIPSIS = "…" + private val WHITESPACE = Regex("\\s+") + + /** + * @param maxGraphemes 표시 글자(grapheme cluster) 상한. null 이면 절단하지 않고 char 안전망만 건다. + */ + fun of( + rawName: String?, + maxGraphemes: Int? = null, + ): String { + // 공백을 먼저 접는다 — 추출 이름은 상류(ProductSnapshot)에서 trim 되지 않아 앞뒤 공백·개행이 그대로 온다. + // 앞 공백이 글자 예산을 먹어 이름이 일찍 잘리고, 개행이 섞이면 알림·푸시가 두 줄이 된다. + val name = rawName?.replace(WHITESPACE, " ")?.trim()?.takeIf { it.isNotEmpty() } ?: return FALLBACK + maxGraphemes ?: return name.capChars() + return name.truncateGraphemes(maxGraphemes) + } + + // 절단 단위는 grapheme cluster(BreakIterator) 다 — String.length·take 는 UTF-16 코드 단위라, 이모지(surrogate + // pair)·ZWJ 시퀀스·조합문자가 경계에 걸치면 반쪽만 남아 깨진 문자로 노출된다. 사용자가 보는 "글자" 로 자른다. + private fun String.truncateGraphemes(limit: Int): String { + val boundary = BreakIterator.getCharacterInstance().apply { setText(this@truncateGraphemes) } + var end = boundary.first() + repeat(limit) { + val next = boundary.next() + if (next == BreakIterator.DONE) return capChars() // 표시 글자 수가 한도 이하 — 자를 필요 없음 + end = next + } + // 한도째 경계까지 왔다. 그 뒤에 글자가 더 있으면(다음 경계가 DONE 이 아니면) 잘라서 말줄임표를 붙인다. + return if (boundary.next() == BreakIterator.DONE) capChars() else substring(0, end).capChars() + ELLIPSIS + } + + // 마지막 안전망 — grapheme 경계와 무관하게 char 길이를 자른다. 여기 걸리는 입력은 정상 상품명이 아니라 + // 조합 부호를 쌓아 만든 비정상 값이라, 글자 깨짐보다 알림 누락을 막는 쪽을 택한다. + private fun String.capChars(): String = if (length <= MAX_CHARS) this else take(MAX_CHARS) + ELLIPSIS +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt index 17d5aeab1..1673e81d6 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt @@ -5,12 +5,17 @@ import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component -import java.text.BreakIterator import java.util.UUID -// 아이템 파싱 완료 알림. 위시·토너먼트 어느 쪽으로 올린 아이템이든 동일하게 "{이름} 파싱이 완료되었어요" 를 알린다 — -// 수신자는 snapshotId(버전)를 역조회해 위시 주인 ∪ 토너먼트 참가자로 모은다(ItemParsingRecipientResolver, 실패 알림과 공유). -// 문구에 어떤 아이템인지 담으려고(#895) 그 버전(snapshot)의 name 을 조회해 itemName 변수로 채운다 — 긴 이름은 displayName 이 자른다. +// 아이템 파싱 완료 알림. 수신자는 snapshotId(버전)를 역조회해 위시 주인 ∪ 토너먼트 참가자로 모은다 +// (ItemParsingRecipientResolver, 실패 알림과 공유). +// +// 문구는 title=아이템 이름 / body=상태 로 나뉜다(#913). OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한 +// 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. 이름만 제목에 두면 잘려도 잃는 게 없고, body 는 두 줄까지 +// 보이므로 상태 문구가 온전히 남는다. 그래서 이름은 표시 글자 절단을 하지 않는다(char 안전망만). +// +// body 문구는 등록 출처에 따라 갈린다 — 위시에 직접 담은 것과 토너먼트에 직접 올린 것은 사용자에게 다른 사건이다 +// (토너먼트에 올려도 위시리스트에는 안 들어간다). 출처 판정은 라우팅이 이미 하고 있어 그대로 재사용한다. @Component class ItemParsingCompletedHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -22,47 +27,32 @@ class ItemParsingCompletedHandler( override fun resolveRouting(event: ItemParsingCompleted): NotificationRouting = recipientResolver.resolveRouting(event.snapshotId) - // 문구 변수 itemName — 그 버전(snapshot)의 name 을 조회해 표시용으로 자른다. + // 문구 변수 둘 — itemName(제목)·completionMessage(본문). // best-effort: 버전이 없거나 이름이 비어도 이름 하나 때문에 알림 전체를 떨구지 않고 기본값을 쓴다 // (토너먼트 알림의 tournamentName fallback 과 같은 결). + // + // 이벤트당 한 번만 호출된다(dispatcher 가 수신자 루프 밖에서 부른다) — 라우팅 조회가 한 번 더 들어가도 수신자 + // 수와 무관하다. override fun resolveActorContext(event: ItemParsingCompleted): ActorContext = - ActorContext(variables = mapOf("itemName" to displayName(itemSnapshotRepository.findById(event.snapshotId)?.name))) - - companion object { - const val MAX_NAME_LENGTH = 10 - - // grapheme 절단 뒤 한 번 더 거는 UTF-16 char 상한. 클러스터 1개가 조합 부호로 수십~수백 char 일 수 있어 - // "10글자" 가 곧 짧은 문자열을 뜻하지 않는다. 그대로 두면 Notification 생성자의 require(title.length <= 255)에 - // 걸리고, dispatcher 의 수신자별 runCatching 이 그 예외를 삼켜 완료 알림이 전 수신자에게 조용히 누락된다. - // 문구 나머지("… 파싱이 완료되었어요")를 감안해 넉넉히 남긴다. - private const val MAX_NAME_CHARS = 100 - private const val ELLIPSIS = "…" - private const val FALLBACK_NAME = "상품" - - // 알림·푸시 한 줄을 유지하도록 10자 초과면 앞 10자 + … 로 자른다. 이름이 없거나 공백뿐이면 기본값. - // 절단 단위는 grapheme cluster(BreakIterator) 다 — String.length·take 는 UTF-16 코드 단위라, 이모지(surrogate - // pair)·조합문자가 10번째 경계에 걸치면 반쪽만 남아 깨진 문자로 노출된다(#896 CodeRabbit). 사용자가 보는 "글자" - // 경계로 잘라 깨짐을 막는다. - // - // 공백은 먼저 한 칸으로 접는다 — 추출 이름은 상류(ProductSnapshot)에서 trim 되지 않아 앞뒤 공백·개행이 그대로 - // 온다. 앞 공백이 10글자 예산을 먹어 이름이 일찍 잘리고, 개행이 섞이면 "한 줄 유지" 목표 자체가 깨진다. - fun displayName(rawName: String?): String { - val name = rawName?.replace(WHITESPACE, " ")?.trim()?.takeIf { it.isNotEmpty() } ?: return FALLBACK_NAME - val boundary = BreakIterator.getCharacterInstance().apply { setText(name) } - var end = boundary.first() - repeat(MAX_NAME_LENGTH) { - val next = boundary.next() - if (next == BreakIterator.DONE) return name.capChars() // 표시 글자 수가 한도 이하 — 자를 필요 없음 - end = next - } - // 한도째 경계까지 왔다. 그 뒤에 글자가 더 있으면(다음 경계가 DONE 이 아니면) 잘라서 말줄임표를 붙인다. - return if (boundary.next() == BreakIterator.DONE) name.capChars() else name.substring(0, end).capChars() + ELLIPSIS + ActorContext( + variables = + mapOf( + "itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name), + "completionMessage" to completionMessageOf(resolveRouting(event)), + ), + ) + + // 문장을 통째로 변수로 채운다. notification_templates 는 타입당 한 행(PK=type)이라 위시용·토너먼트용 body 를 + // 따로 둘 자리가 없고, 두 문장이 구조도 달라("위시 저장이 성공" vs "아이템이 등록") 공통 뼈대 + 변수로도 안 쪼개진다. + // 대가로 이 body 문구는 백오피스(#252)에서 편집할 수 없다 — title 은 여전히 템플릿이 소유한다. + private fun completionMessageOf(routing: NotificationRouting): String = + when (routing) { + NotificationRouting.Wish -> WISH_MESSAGE + is NotificationRouting.Tournament -> TOURNAMENT_MESSAGE } - // 마지막 안전망 — grapheme 경계와 무관하게 char 길이를 자른다. 여기 걸리는 입력은 정상 상품명이 아니라 - // 조합 부호를 쌓아 만든 비정상 값이라, 글자 깨짐보다 알림 누락을 막는 쪽을 택한다. - private fun String.capChars(): String = if (length <= MAX_NAME_CHARS) this else take(MAX_NAME_CHARS) + ELLIPSIS - - private val WHITESPACE = Regex("\\s+") + companion object { + const val WISH_MESSAGE = "위시 저장이 성공했어요" + const val TOURNAMENT_MESSAGE = "아이템이 등록됐어요" } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt index 86a11b505..349e20709 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt @@ -31,12 +31,15 @@ class TournamentItemDeletedHandler( override fun resolveActorContext(event: TournamentItemDeleted): ActorContext { val base = tournamentVariables.context(event.tournamentId, event.actorId) - val itemName = itemSnapshotRepository.findById(event.snapshotId)?.name ?: FALLBACK_ITEM_NAME + val itemName = ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name, MAX_NAME_LENGTH) return base.copy(variables = base.variables + ("itemName" to itemName)) } companion object { - // 삭제 시점에 상품명이 아직 없을 때(파싱 전 PENDING/PROCESSING 아이템 삭제)의 대체 문구. - private const val FALLBACK_ITEM_NAME = "상품" + // 이 문구는 이름 뒤에 "…을(를) 삭제했어요" 가 붙고 앞에는 닉네임이 온다. 이름을 안 자르면 OS 푸시 제목 절단에 + // 정작 무슨 일인지가 먹히고, 길면 엔티티 불변식(require(title.length <= 255))까지 걸려 dispatcher 의 + // runCatching 이 예외를 삼켜 삭제 알림이 전 수신자에게 조용히 누락된다(상품명은 512자까지 허용된다). + // 파싱 완료 알림은 제목이 이름뿐이라 절단하지 않지만(#913) 이쪽은 캡이 필요하다. + private const val MAX_NAME_LENGTH = 10 } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt index dccff1cb4..210982f2e 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.notification.service import com.depromeet.piki.notification.domain.NotificationType +import com.depromeet.piki.notification.handler.ItemParsingCompletedHandler // 타입별 사용 가능한 템플릿 변수 카탈로그. 백오피스(#250) 편집 화면의 "쓸 수 있는 변수" 표시 + 검증(선언 안 된 // 변수 차단) + 미리보기 샘플값의 SSOT 다. 현재는 발송 dispatch 가 실제로 채우는 변수만 선언한다 — @@ -32,7 +33,13 @@ object NotificationTemplateVariables { NotificationType.TOURNAMENT_PLAYED_FROM_LINK to TOURNAMENT, NotificationType.TOURNAMENT_COMPLETED to TOURNAMENT, NotificationType.TOURNAMENT_RESULT_READY to TOURNAMENT, - NotificationType.ITEM_PARSING_COMPLETED to listOf(TemplateVariable("itemName", "나이키 에어맥스")), + // completionMessage 는 문장을 통째로 담는다(#913) — 등록 출처(위시/토너먼트)에 따라 갈리는데 이 테이블이 + // 타입당 한 행이라 body 를 두 벌 둘 자리가 없다. 그래서 이 문구만 백오피스 편집 대상이 아니다. + NotificationType.ITEM_PARSING_COMPLETED to + listOf( + TemplateVariable("itemName", "나이키 에어맥스"), + TemplateVariable("completionMessage", ItemParsingCompletedHandler.WISH_MESSAGE), + ), NotificationType.ITEM_PARSING_FAILED to emptyList(), NotificationType.ANNOUNCEMENT to listOf( diff --git a/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt b/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt new file mode 100644 index 000000000..388150a1f --- /dev/null +++ b/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt @@ -0,0 +1,32 @@ +package db.migration + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +// ITEM_PARSING_COMPLETED 문구를 title=아이템 이름 / body=상태 로 나눈다(#913). +// 전: title "${itemName} 파싱이 완료되었어요" · body "" +// 후: title "${itemName}" · body "${completionMessage}" +// +// OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. +// body 는 두 줄까지 보이므로 상태를 그쪽으로 옮긴다. +// +// completionMessage 는 문장을 통째로 담는 변수다 — 이 테이블은 타입당 한 행(PK=type)이라 위시용·토너먼트용 body 를 +// 따로 둘 자리가 없고, 두 문장이 구조도 달라 공통 뼈대 + 변수로 안 쪼개진다. ItemParsingCompletedHandler 가 등록 +// 출처(라우팅)를 보고 채운다. +// +// 리터럴 dollar-brace(${...})를 SQL 마이그레이션에 두면 Flyway 가 placeholder 로 오인해 파싱이 깨지므로, +// seed(V20260615015148)·직전 변경(V20260806230101)과 같이 JDBC 로 직접 UPDATE 한다. +@Suppress("ClassName") +class V20260811010101__split_item_parsing_completed_template_title_body : BaseJavaMigration() { + override fun migrate(context: Context) { + context.connection + .prepareStatement( + "UPDATE notification_templates SET title_template = ?, body_template = ?, updated_at = NOW(6) " + + "WHERE type = 'ITEM_PARSING_COMPLETED'", + ).use { statement -> + statement.setString(1, "\${itemName}") + statement.setString(2, "\${completionMessage}") + statement.executeUpdate() + } + } +} diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/ItemDisplayNameTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/ItemDisplayNameTest.kt new file mode 100644 index 000000000..4da8ff1c5 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/ItemDisplayNameTest.kt @@ -0,0 +1,81 @@ +package com.depromeet.piki.notification.handler + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// 상품명 표시 다듬기. 두 호출부가 캡 요구가 달라(파싱 완료는 절단 없음, 아이템 삭제는 10글자) 양쪽을 다 고정한다. +class ItemDisplayNameTest { + // ---- 절단 없음 (파싱 완료 알림 #913) ---- + + @Test + fun `maxGraphemes 를 안 주면 긴 이름도 자르지 않는다`() { + // 파싱 완료는 title 이 이름뿐이라 OS 가 잘라도 의미가 안 사라진다. 우리가 미리 자를 이유가 없다. + val long = "나이키 에어포스 1 07 화이트 로우탑 스니커즈" + assertEquals(long, ItemDisplayName.of(long)) + } + + @Test + fun `절단을 안 해도 char 상한은 걸린다`() { + // 조합 부호를 쌓으면 grapheme 1개가 수백 char 이 된다. 그대로 두면 Notification 의 + // require(title.length <= 255)에 걸리고, dispatcher 가 그 예외를 삼켜 알림이 전 수신자에게 누락된다. + val heavy = "가" + "́".repeat(300) + assertTrue(ItemDisplayName.of(heavy).length <= 101, "char 상한을 넘었다") + } + + // ---- 절단 있음 (아이템 삭제 알림) ---- + + @Test + fun `maxGraphemes 를 주면 초과분을 말줄임표로 자른다`() { + assertEquals("일이삼사오육칠팔구십…", ItemDisplayName.of("일이삼사오육칠팔구십일이삼", 10)) + } + + @Test + fun `정확히 한도인 이름은 자르지 않는다`() { + val ten = "일이삼사오육칠팔구십" + assertEquals(ten, ItemDisplayName.of(ten, 10)) + } + + @Test + fun `한도 이하 이름은 그대로 쓴다`() { + assertEquals("나이키 에어맥스", ItemDisplayName.of("나이키 에어맥스", 10)) + } + + @Test + fun `경계에 걸친 이모지는 반쪽으로 잘리지 않는다`() { + // UTF-16 take(10) 이면 surrogate pair 가 쪼개져 깨진 문자가 노출된다. grapheme 경계로 잘라 통째로 살린다. + assertEquals("일이삼사오육칠팔구😀…", ItemDisplayName.of("일이삼사오육칠팔구😀가나", 10)) + } + + @Test + fun `ZWJ 로 이어진 이모지 시퀀스도 통째로 유지된다`() { + // 가족 이모지는 사람 4개를 ZWJ 로 이은 확장 grapheme cluster 1개(UTF-16 길이 11). + // codePoint 기반 절단이면 ZWJ 경계에서 쪼개진다. + val family = "👨‍👩‍👧‍👦" + assertEquals("일이삼사오육칠팔구" + family + "…", ItemDisplayName.of("일이삼사오육칠팔구" + family + "가나", 10)) + } + + // ---- 공통 정규화 ---- + + @Test + fun `이름이 null 이면 기본값을 쓴다`() { + assertEquals(ItemDisplayName.FALLBACK, ItemDisplayName.of(null)) + } + + @Test + fun `이름이 공백뿐이면 기본값을 쓴다`() { + assertEquals(ItemDisplayName.FALLBACK, ItemDisplayName.of(" ")) + } + + @Test + fun `앞뒤 공백은 제거하고 글자 예산에 넣지 않는다`() { + // 추출 이름은 상류에서 trim 되지 않는다. 앞 공백이 예산을 먹으면 실제 이름이 일찍 잘린다. + assertEquals("일이삼사오육칠팔구십", ItemDisplayName.of(" 일이삼사오육칠팔구십 ", 10)) + } + + @Test + fun `이름 속 개행은 한 칸 공백으로 접는다`() { + // 개행이 그대로 들어가면 알림·푸시가 의도치 않게 두 줄이 된다. + assertEquals("나이키 에어맥스", ItemDisplayName.of("나이키\n에어맥스")) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandlerTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandlerTest.kt deleted file mode 100644 index dc5f535c9..000000000 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandlerTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.depromeet.piki.notification.handler - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class ItemParsingCompletedHandlerTest { - @Test - fun `10자 이하 이름은 그대로 쓴다`() { - assertEquals("나이키 에어맥스", ItemParsingCompletedHandler.displayName("나이키 에어맥스")) - } - - @Test - fun `정확히 10자 이름은 자르지 않는다`() { - val ten = "일이삼사오육칠팔구십" // 10자 - assertEquals(ten, ItemParsingCompletedHandler.displayName(ten)) - } - - @Test - fun `10자 초과 이름은 앞 10자 + 말줄임표로 자른다`() { - assertEquals("일이삼사오육칠팔구십…", ItemParsingCompletedHandler.displayName("일이삼사오육칠팔구십일이삼")) // 13자 - } - - @Test - fun `경계에 걸친 이모지는 반쪽으로 잘리지 않고 통째로 유지된다`() { - // 앞 9자(한글) + 이모지(surrogate pair) = 표시 10글자. UTF-16 take(10) 이면 이모지가 반쪽 나 깨지지만, - // grapheme 절단이라 이모지가 통째로 살고 그 뒤(11번째 글자부터)만 말줄임표로 잘린다. - assertEquals("일이삼사오육칠팔구😀…", ItemParsingCompletedHandler.displayName("일이삼사오육칠팔구😀가나")) - } - - @Test - fun `표시 10글자째가 이모지면 그대로 두고 자르지 않는다`() { - val ten = "일이삼사오육칠팔구😀" // 한글 9 + 이모지 1 = 표시 10글자 (UTF-16 길이는 11) - assertEquals(ten, ItemParsingCompletedHandler.displayName(ten)) - } - - @Test - fun `ZWJ 로 이어진 이모지 시퀀스는 경계에 걸쳐도 통째로 유지된다`() { - // 👨‍👩‍👧‍👦 = 사람 이모지 4개를 ZWJ(U+200D)로 이은 확장 grapheme cluster 1개 (UTF-16 길이는 11). - // codePointCount 기반 절단이면 ZWJ 경계에서 쪼개져 깨지지만, grapheme 절단이라 통째로 살고 그 뒤만 잘린다. - val family = "👨‍👩‍👧‍👦" - val name = "일이삼사오육칠팔구" + family + "가나" // 한글 9 + 가족 이모지 1 = 표시 10글자, 그 뒤에 더 있음 - assertEquals("일이삼사오육칠팔구" + family + "…", ItemParsingCompletedHandler.displayName(name)) - } - - @Test - fun `이름이 null 이면 기본값을 쓴다`() { - assertEquals("상품", ItemParsingCompletedHandler.displayName(null)) - } - - @Test - fun `이름이 공백뿐이면 기본값을 쓴다`() { - assertEquals("상품", ItemParsingCompletedHandler.displayName(" ")) - } - - @Test - fun `앞뒤 공백은 제거하고 이름 길이 예산에 넣지 않는다`() { - // 추출 이름은 상류에서 trim 되지 않는다. 앞 공백이 10글자 예산을 먹으면 실제 이름이 일찍 잘린다. - assertEquals("일이삼사오육칠팔구십", ItemParsingCompletedHandler.displayName(" 일이삼사오육칠팔구십 ")) - } - - @Test - fun `이름 속 개행은 한 칸 공백으로 접어 한 줄을 유지한다`() { - // 개행이 그대로 들어가면 알림·푸시가 두 줄이 되어 이 문구 설계의 전제가 깨진다. - assertEquals("나이키 에어맥스", ItemParsingCompletedHandler.displayName("나이키\n에어맥스")) - } - - @Test - fun `표시 10글자여도 char 길이가 과도하면 한 번 더 자른다`() { - // 조합 부호를 쌓으면 grapheme 1개가 수백 char 이 될 수 있다. 그대로 두면 Notification 의 - // require(title.length <= 255)에 걸리고, dispatcher 가 그 예외를 삼켜 알림이 전 수신자에게 누락된다. - val heavyCluster = "가" + "́".repeat(300) // 표시상 1글자, UTF-16 301 char - val result = ItemParsingCompletedHandler.displayName(heavyCluster) - assertTrue(result.length <= 101, "char 상한을 넘었다: ${result.length}") - } -} diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt index d1ee9e044..5a1db8246 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt @@ -6,6 +6,8 @@ import com.depromeet.piki.item.event.ItemParsingFailed import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.notification.domain.NotificationType import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.tournament.domain.TournamentItem +import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.tournament.event.TournamentItemAdded import com.depromeet.piki.tournament.event.TournamentItemDeleted import com.depromeet.piki.tournament.event.TournamentJoined @@ -13,6 +15,7 @@ import com.depromeet.piki.tournament.event.TournamentStarted import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.transaction.annotation.Transactional +import java.util.UUID import kotlin.test.assertEquals // 핸들러가 이제 repo·resolver 를 주입받으므로 무인자 생성이 불가하다. 실제 와이어링된 빈을 autowire 해 @@ -39,6 +42,8 @@ class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository + @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository + // eventType 은 제네릭 타입 인자 E 에서 GenericTypeResolver 로 자동 도출된다(::class 명시 제거). // reflection 기반이라 클래스 계층이 바뀌면 조용히 틀어질 수 있어, 도출 결과를 직접 못 박아 회귀를 잡는다. @Test @@ -86,4 +91,26 @@ class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { assertEquals("상품", context.variables["itemName"]) } + + // body 문구는 등록 출처로 갈린다(#913) — 토너먼트에 올린 상품이 위시리스트에 들어가지는 않으므로 같은 문구를 쓸 수 없다. + // 출처 판정은 라우팅(그 버전을 pin 한 출전이 있나)이 이미 하고 있고, 그 결과를 그대로 재사용한다. + @Test + fun `위시 출처 파싱 완료는 completionMessage 를 위시 문구로 담는다`() { + val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9103L, name = "나이키 에어맥스")).getId() + + val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9103L, snapshotId = snapshotId)) + + assertEquals(ItemParsingCompletedHandler.WISH_MESSAGE, context.variables["completionMessage"]) + } + + @Test + fun `토너먼트 출처 파싱 완료는 completionMessage 를 토너먼트 문구로 담는다`() { + val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9104L, name = "나이키 덩크")).getId() + // 그 버전을 pin 한 출전이 있으면 라우팅이 Tournament 로 해석된다. + tournamentItemJpaRepository.save(TournamentItem(tournamentId = 9201L, userId = UUID.randomUUID(), snapshotId = snapshotId)) + + val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9104L, snapshotId = snapshotId)) + + assertEquals(ItemParsingCompletedHandler.TOURNAMENT_MESSAGE, context.variables["completionMessage"]) + } } From 1773031758d58828bfccf348cd52c4fafc35fd2f Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:30:13 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20ANNOUNCEMENT=20=EC=9D=98=20body=20?= =?UTF-8?q?=EB=A5=BC=20API=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20+=20=EC=82=AD=EC=A0=9C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A0=88=EB=8B=A8=20=EC=9C=84=EC=9E=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 대응. - 문서가 "body 는 ITEM_PARSING_COMPLETED 만 값이 있다" 고 단정했는데 ANNOUNCEMENT 도 body 변수를 갖는다(관리자가 입력한 공지 본문). 클라가 공지 본문을 숨기거나 잘못 처리할 수 있어 두 타입을 함께 명시한다. - TournamentItemDeletedHandler 가 이번에 도입한 10자 절단은 ItemDisplayName 단위 테스트가 규칙을 망라하지만, 핸들러가 그 규칙에 이름을 실제로 통과시키는지는 검증되지 않았다. 긴 이름·이모지 두 케이스로 위임을 고정한다. 이모지 케이스는 입력 설계에 두 가지를 반영했다. - 단순 이모지(1 grapheme = 2 char)를 써서 절단 결과가 char 안전망(MAX_CHARS) 아래에 남게 했다. 가족 이모지처럼 1 grapheme 이 11 char 인 입력은 안전망에 걸리는데, 그 안전망의 코드 유닛 절단은 "조합 부호를 쌓은 비정상 입력에서 글자 깨짐보다 알림 누락 방지를 택한다" 는 의도된 트레이드오프라 검증 대상이 아니다. - 앞에 1 char 를 둬 절단 경계를 홀수로 밀었다. 이모지만 있으면 코드 유닛으로 잘라도 짝이 맞아떨어져 회귀가 드러나지 않는다. grapheme 절단을 코드 유닛 절단으로 바꾸면 실패하는 것을 실측 확인했다. --- .../controller/NotificationHistoryApi.kt | 7 +-- ...ationRecipientResolutionIntegrationTest.kt | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt index 27ef7c145..2b02ff7dd 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt @@ -33,9 +33,10 @@ interface NotificationHistoryApi { "`refId` 로 이동 대상을 정하고, `id` 로 단건 읽음 처리(`POST /read`)를 한다.\n\n" + "**알림 타입 카탈로그 (전 10종)**\n\n" + "`type` 으로 화면을 분기하고 `refId` 로 이동 대상을 정한다. `kind` 는 **전 알림 공통 필드**로 항상 실리며 카드 라벨·아이콘(위시/토너먼트/시스템)이 된다. " + - "`body` 는 **`ITEM_PARSING_COMPLETED` 만 값이 있고 나머지 타입은 빈 문자열(`\"\"`)** 이다. " + - "그 타입만 `title` 에 아이템 이름을, `body` 에 상태 문구를 나눠 싣는다 — OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, " + - "이름과 상태를 한 줄에 담으면 이름이 길 때 상태가 사라지기 때문이다. 클라이언트는 `body` 가 비어 있을 수 있음을 전제로 그린다.\n\n" + + "`body` 는 **`ITEM_PARSING_COMPLETED` 와 `ANNOUNCEMENT` 만 값이 있고 나머지 타입은 빈 문자열(`\"\"`)** 이다. " + + "전자는 `title` 에 아이템 이름을, `body` 에 상태 문구를 나눠 싣는다 — OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, " + + "이름과 상태를 한 줄에 담으면 이름이 길 때 상태가 사라지기 때문이다. 후자는 관리자가 입력한 공지 본문이 그대로 `body` 에 실린다. " + + "클라이언트는 `body` 가 비어 있을 수 있음을 전제로 그린다.\n\n" + "| `type` | 트리거 | `kind` | `refId` | `title` 예시 |\n" + "|---|---|---|---|---|\n" + "| `TOURNAMENT_JOINED` | 토너먼트 참가 | `TOURNAMENT` | tournamentId | {참가자}님이 참가했어요 |\n" + diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt index 6e1ead096..1373212c6 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt @@ -33,6 +33,7 @@ import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue // 핸들러의 수신자(resolveRecipients)·actor 컨텍스트(resolveActorContext) 도출은 DB 역조회에 의존하므로 통합으로 검증한다. @@ -128,6 +129,53 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() ) } + // 삭제 알림은 이름 앞뒤로 닉네임과 "…을(를) 삭제했어요" 가 붙어, 이름을 안 자르면 엔티티 불변식(title 255자)에 + // 걸려 dispatcher 의 runCatching 이 예외를 삼키고 알림이 조용히 누락된다(상품명은 512자까지 허용). + // 절단 규칙 자체는 ItemDisplayNameTest 가 망라하고, 여기선 핸들러가 그 규칙에 이름을 실제로 통과시키는지만 본다. + @Test + fun `토너먼트 아이템 삭제 변수의 itemName 은 표시 길이로 잘린다`() { + val actor = UUID.randomUUID() + userRepository.save(User(id = actor, nickname = "홍길동", profileImage = "https://x/p.jpg", identityType = IdentityType.GUEST)) + val longName = "가".repeat(30) + val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 7205L, name = longName)).getId() + + val variables = + itemDeletedHandler.resolveActorContext( + TournamentItemDeleted(tournamentId = 1205L, tournamentItemId = 1L, snapshotId = snapshotId, actorId = actor), + ).variables + + val itemName = variables.getValue("itemName") + assertTrue(itemName.length < longName.length, "긴 상품명이 그대로 실리면 안 된다 (실제=$itemName)") + assertTrue(longName.startsWith(itemName.trimEnd('…')), "잘린 이름은 원본의 앞부분이어야 한다 (실제=$itemName)") + } + + // 이모지는 UTF-16 코드 유닛으로 자르면 surrogate pair 가 쪼개져 깨진다. 핸들러가 grapheme 기준 절단을 거치는지 본다. + // 입력을 단순 이모지(1 grapheme = 2 char)로 둬 절단 결과가 char 안전망(ItemDisplayName.MAX_CHARS) 아래에 남게 한다 — + // 그 안전망은 조합 부호를 쌓은 비정상 입력에 한해 글자 깨짐보다 알림 누락 방지를 택하는 의도된 트레이드오프라, + // 여기서 검증할 대상은 그 앞단의 grapheme 절단이다. + @Test + fun `토너먼트 아이템 삭제 변수의 itemName 은 이모지를 쪼개지 않는다`() { + val actor = UUID.randomUUID() + userRepository.save(User(id = actor, nickname = "홍길동", profileImage = "https://x/p.jpg", identityType = IdentityType.GUEST)) + // 앞에 1 char 를 둬 절단 경계를 홀수로 민다 — 이모지(2 char)만 있으면 코드 유닛으로 잘라도 짝이 맞아떨어져 + // 회귀가 드러나지 않는다. 이 한 글자가 경계를 surrogate pair 한가운데로 옮긴다. + val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 7206L, name = "가" + "😀".repeat(20))).getId() + + val variables = + itemDeletedHandler.resolveActorContext( + TournamentItemDeleted(tournamentId = 1206L, tournamentItemId = 1L, snapshotId = snapshotId, actorId = actor), + ).variables + + val itemName = variables.getValue("itemName") + // 코드 유닛으로 자르면 surrogate pair 가 반쪽만 남아 깨진 문자가 된다. 짝 잃은 surrogate 가 없어야 한다. + val orphanSurrogate = + itemName.withIndex().any { (i, c) -> + (c.isHighSurrogate() && (i + 1 >= itemName.length || !itemName[i + 1].isLowSurrogate())) || + (c.isLowSurrogate() && (i == 0 || !itemName[i - 1].isHighSurrogate())) + } + assertFalse(orphanSurrogate, "잘린 이름에 짝 잃은 surrogate 가 남았다 (실제=$itemName)") + } + @Test fun `토너먼트 아이템 삭제 변수 itemName 은 상품명이 아직 없으면 fallback 이다`() { // 파싱 전(PROCESSING) 아이템을 지운 경우 — snapshot.name 이 null 이라 fallback 문구로 채운다. From 28dcb3ab58923a6144c4992f9ea728287a33df59 Mon Sep 17 00:00:00 2001 From: sevineleven Date: Wed, 12 Aug 2026 18:03:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=8C=8C=EC=8B=B1=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20body=20=EB=A5=BC=20=EC=B6=9C=EC=B2=98=20=EB=AC=B4?= =?UTF-8?q?=EA=B4=80=20=EB=8B=A8=EC=9D=BC=20=EB=AC=B8=EA=B5=AC=EB=A1=9C=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출처별로 문구를 가르려 했는데(위시 "위시 저장이 성공했어요" / 토너먼트 "아이템이 등록됐어요"), 지금 구조에선 위시 수신자에게 토너먼트 문구가 갈 수 있다. dispatcher 는 라우팅과 title/body 렌더를 수신자 루프 밖에서 한 번만 해석해 전 수신자에게 같은 값을 박는다. 그런데 한 snapshot 에 서로 다른 출처의 수신자가 함께 붙을 수 있다 - 공유 정체성(#825)의 "진행 중 합류" 경로다. A 가 URL 을 위시에 담아 파싱이 도는 중에 B 가 같은 URL 을 토너먼트에 올리면, resolveAttachment 가 그 진행 중 snapshot 을 그대로 물려줘 tournament_item(B) 와 wish(A) 가 같은 버전을 가리킨다. 파싱이 끝나면 수신자는 둘인데 라우팅은 firstOrNull 이 고른 토너먼트 하나라, A 가 토너먼트 문구를 받는다. 딥링크가 어긋나는 것 자체는 이 PR 이전부터 있었고 resolveRouting 주석이 알면서 수용한다고 적어 뒀다. 다만 문구까지 그 라우팅에서 파생시키면 이 PR 이 "거짓 문구" 를 새로 만드는 셈이라, 분기를 걷어내고 단일 문구로 되돌린다. - body_template 을 변수 없는 고정 문구("파싱이 완료되었어요")로 둔다. 부수 효과로 백오피스(#252) 편집 손실이 사라졌다 - 문장을 통째로 변수에 담느라 잃었던 것이라, 분기를 접으니 title·body 둘 다 다시 템플릿이 온전히 소유한다 - 변수 카탈로그에서 completionMessage 를 제거하고 itemName 만 남긴다 - example 의 두 파싱 항목이 같은 body 를 쓰는 게 계약이라 상수로 묶고, 문구 소유자가 DB 템플릿임을 주석으로 남긴다 (referenceItem 의 title 과 같은 방식) - 회귀 가드: 출전 pin 이 있어도 문구 변수가 itemName 하나뿐임을 단언한다. 수신자별 해석 없이 분기를 되살리면 여기서 깨진다 출처별 문구는 수신자별 라우팅 해석·wishId 딥링크와 함께 #933 에서 다룬다. 삭제 알림 테스트 단언도 함께 조였다. "짧아졌나" 와 "짝 잃은 surrogate 가 없나" 만 보고 있어서, 핸들러가 캡을 10 대신 20 으로 바꾸거나 이모지 이름을 아예 안 잘라도 통과했다. ItemDisplayNameTest 는 캡을 인자로 받아 검증하므로 핸들러가 고른 캡 값은 이 테스트에서만 고정된다. 정확한 기대값으로 못 박았다. --- .../controller/NotificationHistoryApi.kt | 6 ++-- .../NotificationHistoryApiExamples.kt | 14 +++++--- .../handler/ItemParsingCompletedHandler.kt | 34 ++++--------------- .../service/NotificationTemplateVariables.kt | 10 ++---- ...m_parsing_completed_template_title_body.kt | 9 +++-- ...NotificationEventHandlerIntegrationTest.kt | 26 ++++++-------- ...ationRecipientResolutionIntegrationTest.kt | 11 +++--- 7 files changed, 43 insertions(+), 67 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt index 2b02ff7dd..1e6dbb095 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt @@ -46,11 +46,11 @@ interface NotificationHistoryApi { "| `TOURNAMENT_PLAYED_FROM_LINK` | 플레이링크로 플레이 시작 | `TOURNAMENT` | ROOT 토너먼트 id | {플레이어}님이 회원님 토너먼트를 플레이했어요 |\n" + "| `TOURNAMENT_COMPLETED` | 멤버가 클론 완료 | `TOURNAMENT` | ROOT 토너먼트 id | {멤버}님이 회원님 토너먼트를 완료했어요 |\n" + "| `TOURNAMENT_RESULT_READY` | 주최자가 ROOT 완료 | `TOURNAMENT` | ROOT 토너먼트 id | 참여하신 {주최자}님의 토너먼트 결과가 나왔어요 |\n" + - "| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` 에 상태 문구) |\n" + + "| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = 파싱이 완료되었어요) |\n" + "| `ITEM_PARSING_FAILED` | 상품 추출 실패 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | 상품 정보를 가져오지 못했어요 |\n" + "| `ANNOUNCEMENT` | 관리자 공지(후속) | `SYSTEM` | 공지 id/0 | (관리자 입력) |\n\n" + - "> `ITEM_PARSING_COMPLETED` 의 `body` 도 같은 출처로 갈린다 — 위시에 직접 담았으면 \"위시 저장이 성공했어요\", " + - "토너먼트에 직접 올렸으면 \"아이템이 등록됐어요\". 토너먼트에 올린 상품이 위시리스트에 들어가지는 않으므로 두 문구를 구분한다.\n\n" + + "> `ITEM_PARSING_COMPLETED` 의 `body` 는 `kind` 와 무관하게 하나다 — 위시로 담았든 토너먼트로 올렸든 같은 문구가 온다. " + + "화면 분기는 `kind` 로 하고 `body` 문구에 기대지 않는다.\n\n" + "> 파싱 알림(`ITEM_PARSING_*`)만 `kind` 가 발행 출처(위시 등록 / 토너먼트 추가)에 따라 갈린다 — 같은 `type` 이 두 플로우에서 발행되기 때문. " + "나머지 타입은 위 표의 값 하나로 고정이다.\n\n" + "> 아이템 좌표(`tournamentId`·`tournamentItemId`)가 추가로 실리는 타입: 토너먼트 출처 파싱 알림(`ITEM_PARSING_*` + `kind`=TOURNAMENT)과 " + diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt index 1b3b67a85..cd30c1e24 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt @@ -6,7 +6,6 @@ import com.depromeet.piki.common.openapi.binds import com.depromeet.piki.common.openapi.examples import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.response.PageResponse -import com.depromeet.piki.notification.handler.ItemParsingCompletedHandler import com.depromeet.piki.notification.controller.dto.NotificationDeleteRequest import com.depromeet.piki.notification.controller.dto.NotificationDeleteResponse import com.depromeet.piki.notification.controller.dto.NotificationHistoryResponse @@ -153,10 +152,10 @@ class NotificationHistoryApiExamples( id = 1025, type = NotificationType.ITEM_PARSING_COMPLETED, kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.WISH), - // 파싱 완료만 title=이름 / body=상태 로 나뉜다(#913). 상태 문구는 리터럴로 박지 않고 핸들러 상수를 끌어와 - // 문구가 바뀌면 example 이 따라오게 한다(kind 를 NotificationKind.of 로 파생시키는 것과 같은 결). + // 파싱 완료만 title=이름 / body=상태 로 나뉜다(#913). 두 값 다 템플릿이 렌더한 실제 모양으로 둔다 — + // body 문구는 DB 템플릿(V20260811010101)이 소유하므로 referenceItem 의 title 과 같이 리터럴로 적는다. title = "에어 조던 1 미드", - body = ItemParsingCompletedHandler.WISH_MESSAGE, + body = PARSING_COMPLETED_BODY, refId = 512, isRead = true, createdAt = LocalDateTime.of(2026, 6, 8, 10, 5, 0), @@ -169,7 +168,7 @@ class NotificationHistoryApiExamples( type = NotificationType.ITEM_PARSING_COMPLETED, kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.TOURNAMENT), title = "나이키 덩크 로우", - body = ItemParsingCompletedHandler.TOURNAMENT_MESSAGE, + body = PARSING_COMPLETED_BODY, refId = 513, isRead = false, createdAt = LocalDateTime.of(2026, 6, 8, 10, 0, 0), @@ -178,4 +177,9 @@ class NotificationHistoryApiExamples( ) private val sampleItems = listOf(referenceItem, wishParsingItem, tournamentParsingItem) + + companion object { + // 파싱 완료 body — 출처(위시/토너먼트)와 무관하게 하나다. 두 example 이 같은 값을 쓰는 게 계약이라 상수로 묶는다. + private const val PARSING_COMPLETED_BODY = "파싱이 완료되었어요" + } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt index 1673e81d6..5938fbb77 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt @@ -14,8 +14,10 @@ import java.util.UUID // 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. 이름만 제목에 두면 잘려도 잃는 게 없고, body 는 두 줄까지 // 보이므로 상태 문구가 온전히 남는다. 그래서 이름은 표시 글자 절단을 하지 않는다(char 안전망만). // -// body 문구는 등록 출처에 따라 갈린다 — 위시에 직접 담은 것과 토너먼트에 직접 올린 것은 사용자에게 다른 사건이다 -// (토너먼트에 올려도 위시리스트에는 안 들어간다). 출처 판정은 라우팅이 이미 하고 있어 그대로 재사용한다. +// body 문구는 출처(위시/토너먼트)와 무관하게 하나다. 출처별로 가르려면 수신자마다 달라져야 하는데, dispatcher 는 +// 라우팅·문구를 수신자 루프 **밖에서 한 번** 해석해 전원에게 같은 값을 박는다. 한 snapshot 에 위시 주인과 +// 토너먼트 등록자가 함께 붙을 수 있어(공유 #825 의 "진행 중 합류"), 그 상태로 가르면 위시 주인이 토너먼트 문구를 +// 받는다. 출처별 문구는 수신자별 라우팅 해석과 함께 후속(#933)에서 다룬다. @Component class ItemParsingCompletedHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -27,32 +29,10 @@ class ItemParsingCompletedHandler( override fun resolveRouting(event: ItemParsingCompleted): NotificationRouting = recipientResolver.resolveRouting(event.snapshotId) - // 문구 변수 둘 — itemName(제목)·completionMessage(본문). + // 문구 변수는 itemName(제목) 하나다. body 는 변수 없는 고정 문구라 템플릿이 통째로 소유한다 — + // 백오피스(#252)에서 title·body 둘 다 그대로 편집된다. // best-effort: 버전이 없거나 이름이 비어도 이름 하나 때문에 알림 전체를 떨구지 않고 기본값을 쓴다 // (토너먼트 알림의 tournamentName fallback 과 같은 결). - // - // 이벤트당 한 번만 호출된다(dispatcher 가 수신자 루프 밖에서 부른다) — 라우팅 조회가 한 번 더 들어가도 수신자 - // 수와 무관하다. override fun resolveActorContext(event: ItemParsingCompleted): ActorContext = - ActorContext( - variables = - mapOf( - "itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name), - "completionMessage" to completionMessageOf(resolveRouting(event)), - ), - ) - - // 문장을 통째로 변수로 채운다. notification_templates 는 타입당 한 행(PK=type)이라 위시용·토너먼트용 body 를 - // 따로 둘 자리가 없고, 두 문장이 구조도 달라("위시 저장이 성공" vs "아이템이 등록") 공통 뼈대 + 변수로도 안 쪼개진다. - // 대가로 이 body 문구는 백오피스(#252)에서 편집할 수 없다 — title 은 여전히 템플릿이 소유한다. - private fun completionMessageOf(routing: NotificationRouting): String = - when (routing) { - NotificationRouting.Wish -> WISH_MESSAGE - is NotificationRouting.Tournament -> TOURNAMENT_MESSAGE - } - - companion object { - const val WISH_MESSAGE = "위시 저장이 성공했어요" - const val TOURNAMENT_MESSAGE = "아이템이 등록됐어요" - } + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt index 210982f2e..22e66d9bf 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt @@ -1,7 +1,6 @@ package com.depromeet.piki.notification.service import com.depromeet.piki.notification.domain.NotificationType -import com.depromeet.piki.notification.handler.ItemParsingCompletedHandler // 타입별 사용 가능한 템플릿 변수 카탈로그. 백오피스(#250) 편집 화면의 "쓸 수 있는 변수" 표시 + 검증(선언 안 된 // 변수 차단) + 미리보기 샘플값의 SSOT 다. 현재는 발송 dispatch 가 실제로 채우는 변수만 선언한다 — @@ -33,13 +32,8 @@ object NotificationTemplateVariables { NotificationType.TOURNAMENT_PLAYED_FROM_LINK to TOURNAMENT, NotificationType.TOURNAMENT_COMPLETED to TOURNAMENT, NotificationType.TOURNAMENT_RESULT_READY to TOURNAMENT, - // completionMessage 는 문장을 통째로 담는다(#913) — 등록 출처(위시/토너먼트)에 따라 갈리는데 이 테이블이 - // 타입당 한 행이라 body 를 두 벌 둘 자리가 없다. 그래서 이 문구만 백오피스 편집 대상이 아니다. - NotificationType.ITEM_PARSING_COMPLETED to - listOf( - TemplateVariable("itemName", "나이키 에어맥스"), - TemplateVariable("completionMessage", ItemParsingCompletedHandler.WISH_MESSAGE), - ), + // title 만 변수를 쓴다(#913) — body 는 변수 없는 고정 문구라 백오피스가 통째로 편집한다. + NotificationType.ITEM_PARSING_COMPLETED to listOf(TemplateVariable("itemName", "나이키 에어맥스")), NotificationType.ITEM_PARSING_FAILED to emptyList(), NotificationType.ANNOUNCEMENT to listOf( diff --git a/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt b/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt index 388150a1f..95c0c7959 100644 --- a/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt +++ b/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt @@ -5,14 +5,13 @@ import org.flywaydb.core.api.migration.Context // ITEM_PARSING_COMPLETED 문구를 title=아이템 이름 / body=상태 로 나눈다(#913). // 전: title "${itemName} 파싱이 완료되었어요" · body "" -// 후: title "${itemName}" · body "${completionMessage}" +// 후: title "${itemName}" · body "파싱이 완료되었어요" // // OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. // body 는 두 줄까지 보이므로 상태를 그쪽으로 옮긴다. // -// completionMessage 는 문장을 통째로 담는 변수다 — 이 테이블은 타입당 한 행(PK=type)이라 위시용·토너먼트용 body 를 -// 따로 둘 자리가 없고, 두 문장이 구조도 달라 공통 뼈대 + 변수로 안 쪼개진다. ItemParsingCompletedHandler 가 등록 -// 출처(라우팅)를 보고 채운다. +// body 는 변수 없는 고정 문구다 — 백오피스(#252)가 title·body 를 그대로 편집한다. 등록 출처(위시/토너먼트)별로 +// 문구를 가르는 건 수신자별 라우팅 해석이 먼저라 후속(#933)으로 미뤘다. // // 리터럴 dollar-brace(${...})를 SQL 마이그레이션에 두면 Flyway 가 placeholder 로 오인해 파싱이 깨지므로, // seed(V20260615015148)·직전 변경(V20260806230101)과 같이 JDBC 로 직접 UPDATE 한다. @@ -25,7 +24,7 @@ class V20260811010101__split_item_parsing_completed_template_title_body : BaseJa "WHERE type = 'ITEM_PARSING_COMPLETED'", ).use { statement -> statement.setString(1, "\${itemName}") - statement.setString(2, "\${completionMessage}") + statement.setString(2, "파싱이 완료되었어요") statement.executeUpdate() } } diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt index 5a1db8246..93a8b2267 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt @@ -92,25 +92,21 @@ class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { assertEquals("상품", context.variables["itemName"]) } - // body 문구는 등록 출처로 갈린다(#913) — 토너먼트에 올린 상품이 위시리스트에 들어가지는 않으므로 같은 문구를 쓸 수 없다. - // 출처 판정은 라우팅(그 버전을 pin 한 출전이 있나)이 이미 하고 있고, 그 결과를 그대로 재사용한다. + // 파싱 완료 문구는 등록 출처(위시/토너먼트)로 갈리지 않는다 — 갈리면 안 되는 이유가 구조에 있다. + // dispatcher 는 문구·라우팅을 수신자 루프 **밖에서 한 번** 해석해 전 수신자에게 같은 값을 박는데, 공유(#825)의 + // "진행 중 합류" 로 한 snapshot 에 위시 주인과 토너먼트 등록자가 함께 붙을 수 있다. 그 상태로 출처별 문구를 쓰면 + // 위시 주인이 토너먼트 문구를 받는다. 출처별 분기는 수신자별 라우팅 해석과 함께 후속(#933)에서 한다. + // + // 그래서 "출전 pin 이 있어도 문구 변수가 늘지 않는다" 를 못 박는다 — 수신자별 해석 없이 분기를 되살리면 여기서 깨진다. @Test - fun `위시 출처 파싱 완료는 completionMessage 를 위시 문구로 담는다`() { + fun `파싱 완료 문구 변수는 출전 pin 이 있어도 itemName 하나뿐이다`() { val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9103L, name = "나이키 에어맥스")).getId() - - val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9103L, snapshotId = snapshotId)) - - assertEquals(ItemParsingCompletedHandler.WISH_MESSAGE, context.variables["completionMessage"]) - } - - @Test - fun `토너먼트 출처 파싱 완료는 completionMessage 를 토너먼트 문구로 담는다`() { - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9104L, name = "나이키 덩크")).getId() - // 그 버전을 pin 한 출전이 있으면 라우팅이 Tournament 로 해석된다. + // 이 버전을 pin 한 출전이 있으면 라우팅은 Tournament 로 해석된다. 그래도 문구는 그대로여야 한다. tournamentItemJpaRepository.save(TournamentItem(tournamentId = 9201L, userId = UUID.randomUUID(), snapshotId = snapshotId)) - val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9104L, snapshotId = snapshotId)) + val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9103L, snapshotId = snapshotId)) - assertEquals(ItemParsingCompletedHandler.TOURNAMENT_MESSAGE, context.variables["completionMessage"]) + assertEquals(setOf("itemName"), context.variables.keys) + assertEquals("나이키 에어맥스", context.variables["itemName"]) } } diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt index 1373212c6..4cfc9bafb 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt @@ -144,9 +144,9 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() TournamentItemDeleted(tournamentId = 1205L, tournamentItemId = 1L, snapshotId = snapshotId, actorId = actor), ).variables - val itemName = variables.getValue("itemName") - assertTrue(itemName.length < longName.length, "긴 상품명이 그대로 실리면 안 된다 (실제=$itemName)") - assertTrue(longName.startsWith(itemName.trimEnd('…')), "잘린 이름은 원본의 앞부분이어야 한다 (실제=$itemName)") + // "짧아졌나" 가 아니라 정확한 결과를 못 박는다 — 길이만 보면 핸들러가 캡을 10 대신 20 으로 바꿔도 통과하고, + // ItemDisplayNameTest 는 캡을 인자로 받아 검증하므로 핸들러가 고른 캡 값은 여기서만 고정된다. + assertEquals("가".repeat(10) + "…", variables.getValue("itemName")) } // 이모지는 UTF-16 코드 유닛으로 자르면 surrogate pair 가 쪼개져 깨진다. 핸들러가 grapheme 기준 절단을 거치는지 본다. @@ -167,7 +167,10 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() ).variables val itemName = variables.getValue("itemName") - // 코드 유닛으로 자르면 surrogate pair 가 반쪽만 남아 깨진 문자가 된다. 짝 잃은 surrogate 가 없어야 한다. + // 기대값을 통째로 못 박는다 — 코드 유닛으로 잘랐다면 10번째 경계가 surrogate pair 한가운데라 다른 문자열이 나온다. + // "짝 잃은 surrogate 없음" 만 보면 아예 안 자르는 구현도 통과한다(원본도 짝이 맞아서다). + assertEquals("가" + "😀".repeat(9) + "…", itemName) + // 위 단언이 깨졌을 때 원인이 절단 경계임을 바로 읽히게 남긴다. val orphanSurrogate = itemName.withIndex().any { (i, c) -> (c.isHighSurrogate() && (i + 1 >= itemName.length || !itemName[i + 1].isLowSurrogate())) ||