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 1b9cdd1a..1e6dbb09 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,10 @@ interface NotificationHistoryApi { "`refId` 로 이동 대상을 정하고, `id` 로 단건 읽음 처리(`POST /read`)를 한다.\n\n" + "**알림 타입 카탈로그 (전 10종)**\n\n" + "`type` 으로 화면을 분기하고 `refId` 로 이동 대상을 정한다. `kind` 는 **전 알림 공통 필드**로 항상 실리며 카드 라벨·아이콘(위시/토너먼트/시스템)이 된다. " + - "`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" + @@ -43,9 +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 | {아이템 이름} 파싱이 완료되었어요 |\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` 는 `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 6a5ebfd2..cd30c1e2 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt @@ -152,8 +152,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). 두 값 다 템플릿이 렌더한 실제 모양으로 둔다 — + // body 문구는 DB 템플릿(V20260811010101)이 소유하므로 referenceItem 의 title 과 같이 리터럴로 적는다. + title = "에어 조던 1 미드", + body = PARSING_COMPLETED_BODY, refId = 512, isRead = true, createdAt = LocalDateTime.of(2026, 6, 8, 10, 5, 0), @@ -165,8 +167,8 @@ class NotificationHistoryApiExamples( id = 1024, type = NotificationType.ITEM_PARSING_COMPLETED, kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.TOURNAMENT), - title = "나이키 덩크 로우 파싱이 완료되었어요", - body = "", + title = "나이키 덩크 로우", + body = PARSING_COMPLETED_BODY, refId = 513, isRead = false, createdAt = LocalDateTime.of(2026, 6, 8, 10, 0, 0), @@ -175,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/ItemDisplayName.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt new file mode 100644 index 00000000..9b880f7d --- /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 17d5aeab..5938fbb7 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,19 @@ 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 문구는 출처(위시/토너먼트)와 무관하게 하나다. 출처별로 가르려면 수신자마다 달라져야 하는데, dispatcher 는 +// 라우팅·문구를 수신자 루프 **밖에서 한 번** 해석해 전원에게 같은 값을 박는다. 한 snapshot 에 위시 주인과 +// 토너먼트 등록자가 함께 붙을 수 있어(공유 #825 의 "진행 중 합류"), 그 상태로 가르면 위시 주인이 토너먼트 문구를 +// 받는다. 출처별 문구는 수신자별 라우팅 해석과 함께 후속(#933)에서 다룬다. @Component class ItemParsingCompletedHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -22,47 +29,10 @@ class ItemParsingCompletedHandler( override fun resolveRouting(event: ItemParsingCompleted): NotificationRouting = recipientResolver.resolveRouting(event.snapshotId) - // 문구 변수 itemName — 그 버전(snapshot)의 name 을 조회해 표시용으로 자른다. + // 문구 변수는 itemName(제목) 하나다. body 는 변수 없는 고정 문구라 템플릿이 통째로 소유한다 — + // 백오피스(#252)에서 title·body 둘 다 그대로 편집된다. // best-effort: 버전이 없거나 이름이 비어도 이름 하나 때문에 알림 전체를 떨구지 않고 기본값을 쓴다 // (토너먼트 알림의 tournamentName fallback 과 같은 결). 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 - } - - // 마지막 안전망 — grapheme 경계와 무관하게 char 길이를 자른다. 여기 걸리는 입력은 정상 상품명이 아니라 - // 조합 부호를 쌓아 만든 비정상 값이라, 글자 깨짐보다 알림 누락을 막는 쪽을 택한다. - private fun String.capChars(): String = if (length <= MAX_NAME_CHARS) this else take(MAX_NAME_CHARS) + ELLIPSIS - - private val WHITESPACE = Regex("\\s+") - } + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) } 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 86a11b50..349e2070 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 dccff1cb..22e66d9b 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt @@ -32,6 +32,7 @@ object NotificationTemplateVariables { NotificationType.TOURNAMENT_PLAYED_FROM_LINK to TOURNAMENT, NotificationType.TOURNAMENT_COMPLETED to TOURNAMENT, NotificationType.TOURNAMENT_RESULT_READY to TOURNAMENT, + // title 만 변수를 쓴다(#913) — body 는 변수 없는 고정 문구라 백오피스가 통째로 편집한다. NotificationType.ITEM_PARSING_COMPLETED to listOf(TemplateVariable("itemName", "나이키 에어맥스")), NotificationType.ITEM_PARSING_FAILED to emptyList(), NotificationType.ANNOUNCEMENT to 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 00000000..95c0c795 --- /dev/null +++ b/src/main/kotlin/db/migration/V20260811010101__split_item_parsing_completed_template_title_body.kt @@ -0,0 +1,31 @@ +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 "파싱이 완료되었어요" +// +// OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. +// body 는 두 줄까지 보이므로 상태를 그쪽으로 옮긴다. +// +// body 는 변수 없는 고정 문구다 — 백오피스(#252)가 title·body 를 그대로 편집한다. 등록 출처(위시/토너먼트)별로 +// 문구를 가르는 건 수신자별 라우팅 해석이 먼저라 후속(#933)으로 미뤘다. +// +// 리터럴 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, "파싱이 완료되었어요") + 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 00000000..4da8ff1c --- /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 dc5f535c..00000000 --- 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 d1ee9e04..93a8b226 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,22 @@ class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { assertEquals("상품", context.variables["itemName"]) } + + // 파싱 완료 문구는 등록 출처(위시/토너먼트)로 갈리지 않는다 — 갈리면 안 되는 이유가 구조에 있다. + // dispatcher 는 문구·라우팅을 수신자 루프 **밖에서 한 번** 해석해 전 수신자에게 같은 값을 박는데, 공유(#825)의 + // "진행 중 합류" 로 한 snapshot 에 위시 주인과 토너먼트 등록자가 함께 붙을 수 있다. 그 상태로 출처별 문구를 쓰면 + // 위시 주인이 토너먼트 문구를 받는다. 출처별 분기는 수신자별 라우팅 해석과 함께 후속(#933)에서 한다. + // + // 그래서 "출전 pin 이 있어도 문구 변수가 늘지 않는다" 를 못 박는다 — 수신자별 해석 없이 분기를 되살리면 여기서 깨진다. + @Test + fun `파싱 완료 문구 변수는 출전 pin 이 있어도 itemName 하나뿐이다`() { + val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9103L, name = "나이키 에어맥스")).getId() + // 이 버전을 pin 한 출전이 있으면 라우팅은 Tournament 로 해석된다. 그래도 문구는 그대로여야 한다. + tournamentItemJpaRepository.save(TournamentItem(tournamentId = 9201L, userId = UUID.randomUUID(), snapshotId = snapshotId)) + + val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9103L, snapshotId = snapshotId)) + + 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 6e1ead09..4cfc9baf 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,56 @@ 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 + + // "짧아졌나" 가 아니라 정확한 결과를 못 박는다 — 길이만 보면 핸들러가 캡을 10 대신 20 으로 바꿔도 통과하고, + // ItemDisplayNameTest 는 캡을 인자로 받아 검증하므로 핸들러가 고른 캡 값은 여기서만 고정된다. + assertEquals("가".repeat(10) + "…", variables.getValue("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") + // 기대값을 통째로 못 박는다 — 코드 유닛으로 잘랐다면 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())) || + (c.isLowSurrogate() && (i == 0 || !itemName[i - 1].isHighSurrogate())) + } + assertFalse(orphanSurrogate, "잘린 이름에 짝 잃은 surrogate 가 남았다 (실제=$itemName)") + } + @Test fun `토너먼트 아이템 삭제 변수 itemName 은 상품명이 아직 없으면 fallback 이다`() { // 파싱 전(PROCESSING) 아이템을 지운 경우 — snapshot.name 이 null 이라 fallback 문구로 채운다.