diff --git a/src/models/base/kakao/bms/bmsButton.ts b/src/models/base/kakao/bms/bmsButton.ts index 19bf18df..0b285485 100644 --- a/src/models/base/kakao/bms/bmsButton.ts +++ b/src/models/base/kakao/bms/bmsButton.ts @@ -58,7 +58,11 @@ export const bmsAppButtonSchema = Schema.Struct({ targetOut: Schema.optional(Schema.Boolean), }).pipe( Schema.filter(button => { - const hasLink = button.linkMobile || button.linkAndroid || button.linkIos; + // present-but-empty 문자열을 "제공됨"으로 간주해서는 안 되므로 trim 후 길이로 판단 + const hasLink = + (button.linkMobile !== undefined && button.linkMobile.trim() !== '') || + (button.linkAndroid !== undefined && button.linkAndroid.trim() !== '') || + (button.linkIos !== undefined && button.linkIos.trim() !== ''); return hasLink ? true : 'AL 타입 버튼은 linkMobile, linkAndroid, linkIos 중 하나 이상 필수입니다.'; diff --git a/src/models/base/kakao/bms/bmsCarousel.ts b/src/models/base/kakao/bms/bmsCarousel.ts index b03f3056..a18e4bdc 100644 --- a/src/models/base/kakao/bms/bmsCarousel.ts +++ b/src/models/base/kakao/bms/bmsCarousel.ts @@ -18,7 +18,17 @@ export const bmsCarouselHeadSchema = Schema.Struct({ linkPc: Schema.optional(Schema.String), linkAndroid: Schema.optional(Schema.String), linkIos: Schema.optional(Schema.String), -}); +}).pipe( + Schema.filter(head => { + const hasOther = + head.linkPc !== undefined || + head.linkAndroid !== undefined || + head.linkIos !== undefined; + return hasOther && head.linkMobile === undefined + ? 'linkPc, linkAndroid, linkIos 중 하나라도 있으면 linkMobile 값이 필수입니다.' + : true; + }), +); export type BmsCarouselHeadSchema = Schema.Schema.Type< typeof bmsCarouselHeadSchema diff --git a/src/models/base/kakao/bms/bmsChatBubbleType.ts b/src/models/base/kakao/bms/bmsChatBubbleType.ts new file mode 100644 index 00000000..b054490d --- /dev/null +++ b/src/models/base/kakao/bms/bmsChatBubbleType.ts @@ -0,0 +1,16 @@ +import {Schema} from 'effect'; + +export const bmsChatBubbleTypeSchema = Schema.Literal( + 'TEXT', + 'IMAGE', + 'WIDE', + 'WIDE_ITEM_LIST', + 'COMMERCE', + 'CAROUSEL_FEED', + 'CAROUSEL_COMMERCE', + 'PREMIUM_VIDEO', +); + +export type BmsChatBubbleType = Schema.Schema.Type< + typeof bmsChatBubbleTypeSchema +>; diff --git a/src/models/base/kakao/bms/bmsCommerce.ts b/src/models/base/kakao/bms/bmsCommerce.ts index 6e2f1d33..d2af13b0 100644 --- a/src/models/base/kakao/bms/bmsCommerce.ts +++ b/src/models/base/kakao/bms/bmsCommerce.ts @@ -51,6 +51,24 @@ const NumberOrNumericString: Schema.Schema = }, ) as Schema.Schema; +/** + * 커머스 가격 필드 범위 제약 (서버 검증 규칙과 일치) + */ +const COMMERCE_PRICE_MAX = 99_999_999; +const DISCOUNT_RATE_MAX = 100; + +const numberInRange = ( + fieldName: string, + min: number, + max: number, +): Schema.Schema => + NumberOrNumericString.pipe( + Schema.filter(n => n >= min && n <= max, { + message: () => + `${fieldName} 값이 잘못되었습니다. ${min} 이상 ${max} 이하의 숫자여야 합니다.`, + }), + ) as Schema.Schema; + /** * BMS 커머스 가격 조합 검증 * @@ -119,10 +137,16 @@ const validateCommercePricingCombination = (commerce: { */ export const bmsCommerceSchema = Schema.Struct({ title: Schema.String, - regularPrice: NumberOrNumericString, - discountPrice: Schema.optional(NumberOrNumericString), - discountRate: Schema.optional(NumberOrNumericString), - discountFixed: Schema.optional(NumberOrNumericString), + regularPrice: numberInRange('regularPrice', 0, COMMERCE_PRICE_MAX), + discountPrice: Schema.optional( + numberInRange('discountPrice', 0, COMMERCE_PRICE_MAX), + ), + discountRate: Schema.optional( + numberInRange('discountRate', 0, DISCOUNT_RATE_MAX), + ), + discountFixed: Schema.optional( + numberInRange('discountFixed', 0, COMMERCE_PRICE_MAX), + ), }).pipe(Schema.filter(validateCommercePricingCombination)); export type BmsCommerceSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsConstraints.ts b/src/models/base/kakao/bms/bmsConstraints.ts new file mode 100644 index 00000000..9d419815 --- /dev/null +++ b/src/models/base/kakao/bms/bmsConstraints.ts @@ -0,0 +1,725 @@ +import type {BmsChatBubbleType} from './bmsChatBubbleType'; + +type LinkType = 'AC' | 'WL' | 'AL' | 'BK' | 'MD' | 'BC' | 'BT' | 'BF'; + +/** + * 쿠폰 설명 길이 제한 + * - WIDE 계열: 18자 + * - 그 외: 12자 + */ +export const BMS_COUPON_DESCRIPTION_MAX: Record = { + TEXT: 12, + IMAGE: 12, + WIDE: 18, + WIDE_ITEM_LIST: 18, + COMMERCE: 12, + CAROUSEL_FEED: 12, + CAROUSEL_COMMERCE: 12, + PREMIUM_VIDEO: 12, +}; + +/** + * 버튼 최대/최소 개수 (쿠폰 미사용/사용 시) + * COMMERCE·CAROUSEL_FEED·CAROUSEL_COMMERCE는 버튼이 최소 1개 이상 필수 + */ +export const BMS_BUTTON_COUNT: Record< + BmsChatBubbleType, + {max: number; maxWithCoupon?: number; min?: number} +> = { + TEXT: {max: 5, maxWithCoupon: 4}, + IMAGE: {max: 5, maxWithCoupon: 4}, + WIDE: {max: 2}, + WIDE_ITEM_LIST: {max: 2}, + COMMERCE: {max: 2, min: 1}, + CAROUSEL_FEED: {max: 2, min: 1}, + CAROUSEL_COMMERCE: {max: 2, min: 1}, + PREMIUM_VIDEO: {max: 1}, +}; + +/** + * 버튼명 최대 길이 + * - TEXT/IMAGE: 14자 + * - 그 외: 8자 + */ +export const BMS_BUTTON_NAME_MAX: Record = { + TEXT: 14, + IMAGE: 14, + WIDE: 8, + WIDE_ITEM_LIST: 8, + COMMERCE: 8, + CAROUSEL_FEED: 8, + CAROUSEL_COMMERCE: 8, + PREMIUM_VIDEO: 8, +}; + +/** + * chatBubbleType별 허용 linkType (추가 제약) + * - 키가 없는 타입: bmsButtonSchema의 Literal Union이 이미 8종으로 제한(AC/WL/AL/BK/MD/BC/BT/BF). 여기서는 추가 제약 없음 + * - 지정된 타입: 지정된 값만 허용 (CAROUSEL_FEED/CAROUSEL_COMMERCE/COMMERCE는 WL/AL 전용) + */ +export const BMS_ALLOWED_LINK_TYPES: Partial< + Record> +> = { + COMMERCE: ['WL', 'AL'], + CAROUSEL_FEED: ['WL', 'AL'], + CAROUSEL_COMMERCE: ['WL', 'AL'], +}; + +/** + * 텍스트 길이 제한 (자) + * - header/content/additionalContent + * - mainWideItem.title / subWideItem.title + * - carousel head/list의 header/content + */ +export const BMS_HEADER_MAX = 20; +export const BMS_ADDITIONAL_CONTENT_MAX = 34; +export const BMS_MAIN_WIDE_ITEM_TITLE_MAX = 25; +export const BMS_SUB_WIDE_ITEM_TITLE_MAX = 30; +export const BMS_CAROUSEL_HEAD_CONTENT_MAX = 50; +export const BMS_CAROUSEL_FEED_ITEM_CONTENT_MAX = 180; +export const BMS_CAROUSEL_COMMERCE_ADDITIONAL_CONTENT_MAX = 34; + +/** + * chatBubbleType별 content 길이 + * TEXT/IMAGE: 1300자, WIDE/PREMIUM_VIDEO: 76자 + */ +export const BMS_CONTENT_MAX: Partial> = { + TEXT: 1300, + IMAGE: 1300, + WIDE: 76, + PREMIUM_VIDEO: 76, +}; + +/** + * 캐러셀 리스트 개수 범위 (head 유무에 따라 다름) + * - head 有: 1~5 + * - head 無: 2~6 + */ +export const BMS_CAROUSEL_LIST_RANGE = { + withHead: {min: 1, max: 5}, + withoutHead: {min: 2, max: 6}, +} as const; + +type LinkFields = { + readonly linkPc?: string; + readonly linkMobile?: string; + readonly linkAndroid?: string; + readonly linkIos?: string; +}; + +/** + * 검증 함수 입력 타입 — baseBmsSchema의 decoded shape과 구조적으로 호환되어야 함 + * 개별 필드를 optional로 선언하여 모든 chatBubbleType을 포괄 + * + * NOTE: baseBmsSchema에 필드가 추가/변경되면 이 타입도 반드시 동기화할 것. + * 현재는 구조적 호환으로 kakaoOption.ts에서 직접 대입이 가능하지만, + * 필드가 누락되면 validator가 해당 필드를 읽지 못한 채 silent pass 가능. + */ +export type BmsConstraintInput = { + readonly chatBubbleType: BmsChatBubbleType; + readonly content?: string; + readonly header?: string; + readonly additionalContent?: string; + readonly imageId?: string; + readonly imageLink?: string; + readonly coupon?: {readonly description: string} & LinkFields; + readonly buttons?: ReadonlyArray< + {readonly name?: string; readonly linkType: string} & LinkFields + >; + readonly mainWideItem?: {readonly title?: string} & LinkFields; + readonly subWideItemList?: ReadonlyArray< + {readonly title: string} & LinkFields + >; + readonly carousel?: { + readonly list?: ReadonlyArray<{ + readonly header?: string; + readonly content?: string; + readonly additionalContent?: string; + readonly imageLink?: string; + readonly buttons?: ReadonlyArray< + {readonly name?: string; readonly linkType: string} & LinkFields + >; + readonly coupon?: {readonly description: string} & LinkFields; + }>; + readonly head?: { + readonly header?: string; + readonly content?: string; + } & LinkFields; + readonly tail?: LinkFields; + }; +}; + +const hasCoupon = (bms: BmsConstraintInput): boolean => bms.coupon != null; + +/** + * 쿠폰 설명 길이 검증 + * - 서버 에러와 동일 문구 사용: "쿠폰 설명은 최대 N자 이하로 입력해주세요." + * - CAROUSEL_FEED/CAROUSEL_COMMERCE는 top-level coupon이 금지되고 per-item coupon만 허용됨 + */ +export const validateCouponDescription = ( + bms: BmsConstraintInput, +): true | string => { + const limit = BMS_COUPON_DESCRIPTION_MAX[bms.chatBubbleType]; + + if (bms.coupon && bms.coupon.description.length > limit) { + return `쿠폰 설명은 최대 ${limit}자 이하로 입력해주세요.`; + } + + for (const item of bms.carousel?.list ?? []) { + if (item.coupon && item.coupon.description.length > limit) { + return `쿠폰 설명은 최대 ${limit}자 이하로 입력해주세요.`; + } + } + + return true; +}; + +const validateSingleButtonGroup = ( + chatBubbleType: BmsChatBubbleType, + buttons: ReadonlyArray<{name?: string; linkType: string}>, + withCoupon: boolean, +): true | string => { + const range = BMS_BUTTON_COUNT[chatBubbleType]; + const max = + withCoupon && range.maxWithCoupon != null ? range.maxWithCoupon : range.max; + + if (buttons.length > max) { + if (withCoupon && range.maxWithCoupon != null) { + return `${chatBubbleType} 타입에서는 쿠폰 사용 시 최대 ${max}개의 버튼만 사용할 수 있습니다.`; + } + return `${chatBubbleType} 타입에서는 최대 ${max}개의 버튼만 사용할 수 있습니다.`; + } + + if (range.min != null && buttons.length < range.min) { + return `${chatBubbleType} 타입에서는 최소 ${range.min}개의 버튼이 필요합니다.`; + } + + return true; +}; + +/** + * 버튼 개수 검증 (chatBubbleType/쿠폰 유무별) + * - CAROUSEL_*는 캐러셀 리스트 아이템별 buttons에 대해 검사 + */ +export const validateButtonCount = (bms: BmsConstraintInput): true | string => { + const type = bms.chatBubbleType; + const isCarousel = type === 'CAROUSEL_FEED' || type === 'CAROUSEL_COMMERCE'; + + if (isCarousel) { + for (const item of bms.carousel?.list ?? []) { + const result = validateSingleButtonGroup( + type, + item.buttons ?? [], + hasCoupon(bms) || item.coupon != null, + ); + if (result !== true) return result; + } + return true; + } + + if (!bms.buttons) return true; + return validateSingleButtonGroup(type, bms.buttons, hasCoupon(bms)); +}; + +/** + * 버튼명 길이 검증 — TEXT/IMAGE 14자, 그 외 8자 + * AC(채널 추가) 타입은 서버가 name을 삭제하므로 검증 제외 + */ +export const validateButtonNames = (bms: BmsConstraintInput): true | string => { + const type = bms.chatBubbleType; + const limit = BMS_BUTTON_NAME_MAX[type]; + + const checkList = ( + buttons: ReadonlyArray<{name?: string; linkType: string}>, + ): true | string => { + for (const button of buttons) { + if (button.linkType === 'AC') continue; + if (button.name != null && button.name.length > limit) { + return `${type} 타입 button.name은 최대 ${limit}자 이하로 입력해주세요.`; + } + } + return true; + }; + + if (bms.buttons) { + const result = checkList(bms.buttons); + if (result !== true) return result; + } + + for (const item of bms.carousel?.list ?? []) { + const result = checkList(item.buttons ?? []); + if (result !== true) return result; + } + + return true; +}; + +/** + * 허용 linkType 검증 — CAROUSEL/COMMERCE는 WL/AL 전용 + */ +export const validateAllowedLinkTypes = ( + bms: BmsConstraintInput, +): true | string => { + const type = bms.chatBubbleType; + const allowed = BMS_ALLOWED_LINK_TYPES[type]; + if (!allowed) return true; + + const checkList = ( + buttons: ReadonlyArray<{linkType: string}>, + ): true | string => { + for (const button of buttons) { + if (!allowed.includes(button.linkType as LinkType)) { + return `${type} 타입에서는 ${allowed.join(', ')} 타입의 버튼만 사용할 수 있습니다.`; + } + } + return true; + }; + + if (bms.buttons) { + const result = checkList(bms.buttons); + if (result !== true) return result; + } + + for (const item of bms.carousel?.list ?? []) { + const result = checkList(item.buttons ?? []); + if (result !== true) return result; + } + + return true; +}; + +const lengthError = ( + chatBubbleType: BmsChatBubbleType, + fieldName: string, + max: number, +): string => + `${chatBubbleType} 타입 ${fieldName}은 최대 ${max}자 이하로 입력해주세요.`; + +/** + * 각 텍스트 필드 길이 검증 (header, content, additionalContent, titles, carousel 텍스트) + */ +export const validateTextLengths = (bms: BmsConstraintInput): true | string => { + const type = bms.chatBubbleType; + + if (bms.header != null && bms.header.length > BMS_HEADER_MAX) { + return lengthError(type, 'header', BMS_HEADER_MAX); + } + + const contentLimit = BMS_CONTENT_MAX[type]; + if ( + contentLimit != null && + bms.content != null && + bms.content.length > contentLimit + ) { + return lengthError(type, 'content', contentLimit); + } + + if ( + bms.additionalContent != null && + bms.additionalContent.length > BMS_ADDITIONAL_CONTENT_MAX + ) { + return lengthError(type, 'additionalContent', BMS_ADDITIONAL_CONTENT_MAX); + } + + if ( + bms.mainWideItem?.title != null && + bms.mainWideItem.title.length > BMS_MAIN_WIDE_ITEM_TITLE_MAX + ) { + return lengthError( + type, + 'mainWideItem.title', + BMS_MAIN_WIDE_ITEM_TITLE_MAX, + ); + } + + for (const sub of bms.subWideItemList ?? []) { + if (sub.title.length > BMS_SUB_WIDE_ITEM_TITLE_MAX) { + return lengthError( + type, + 'subWideItem.title', + BMS_SUB_WIDE_ITEM_TITLE_MAX, + ); + } + } + + if (bms.carousel?.head) { + const {header, content} = bms.carousel.head; + if (header != null && header.length > BMS_HEADER_MAX) { + return lengthError(type, 'carousel.head.header', BMS_HEADER_MAX); + } + if (content != null && content.length > BMS_CAROUSEL_HEAD_CONTENT_MAX) { + return lengthError( + type, + 'carousel.head.content', + BMS_CAROUSEL_HEAD_CONTENT_MAX, + ); + } + } + + if (type === 'CAROUSEL_FEED') { + for (const item of bms.carousel?.list ?? []) { + if (item.header != null && item.header.length > BMS_HEADER_MAX) { + return lengthError(type, 'carousel.list.header', BMS_HEADER_MAX); + } + if ( + item.content != null && + item.content.length > BMS_CAROUSEL_FEED_ITEM_CONTENT_MAX + ) { + return lengthError( + type, + 'carousel.list.content', + BMS_CAROUSEL_FEED_ITEM_CONTENT_MAX, + ); + } + } + } + + if (type === 'CAROUSEL_COMMERCE') { + for (const item of bms.carousel?.list ?? []) { + if ( + item.additionalContent != null && + item.additionalContent.length > + BMS_CAROUSEL_COMMERCE_ADDITIONAL_CONTENT_MAX + ) { + return lengthError( + type, + 'carousel.list.additionalContent', + BMS_CAROUSEL_COMMERCE_ADDITIONAL_CONTENT_MAX, + ); + } + } + } + + return true; +}; + +/** + * 캐러셀 리스트 개수 검증 + * - head 有: 1~5 + * - head 無: 2~6 + */ +export const validateCarouselListCount = ( + bms: BmsConstraintInput, +): true | string => { + const type = bms.chatBubbleType; + if (type !== 'CAROUSEL_FEED' && type !== 'CAROUSEL_COMMERCE') return true; + + const list = bms.carousel?.list ?? []; + const hasHead = bms.carousel?.head != null; + const range = hasHead + ? BMS_CAROUSEL_LIST_RANGE.withHead + : BMS_CAROUSEL_LIST_RANGE.withoutHead; + + if (list.length < range.min || list.length > range.max) { + const prefix = hasHead + ? '캐러셀 인트로가 있는 경우 캐러셀 리스트는' + : '캐러셀 리스트는'; + return `${prefix} 최소 ${range.min}개, 최대 ${range.max}개까지 가능합니다.`; + } + + return true; +}; + +const countNewlines = (text: string): number => { + const matches = text.match(/\r\n|\r|\n/g); + return matches ? matches.length : 0; +}; + +const newlineError = ( + chatBubbleType: BmsChatBubbleType, + fieldName: string, + max: number, +): string => + `${chatBubbleType} 타입 ${fieldName}은 줄바꿈 최대 ${max}개 까지 가능합니다.`; + +const contentNewlineLimit = (type: BmsChatBubbleType): number | null => { + if (type === 'TEXT' || type === 'IMAGE') return 99; + if (type === 'WIDE' || type === 'PREMIUM_VIDEO') return 1; + return null; +}; + +/** + * 줄바꿈 개수 검증 + * - 서버 에러와 동일 문구 사용: "${field}은 줄바꿈 최대 N개 까지 가능합니다." + */ +export const validateNewlineLimits = ( + bms: BmsConstraintInput, +): true | string => { + const type = bms.chatBubbleType; + + if (bms.header != null && countNewlines(bms.header) > 0) { + return newlineError(type, 'header', 0); + } + + const contentNL = contentNewlineLimit(type); + if ( + contentNL != null && + bms.content != null && + countNewlines(bms.content) > contentNL + ) { + return newlineError(type, 'content', contentNL); + } + + if ( + bms.mainWideItem?.title != null && + countNewlines(bms.mainWideItem.title) > 1 + ) { + return newlineError(type, 'mainWideItem.title', 1); + } + + for (const sub of bms.subWideItemList ?? []) { + if (countNewlines(sub.title) > 1) { + return newlineError(type, 'subWideItem.title', 1); + } + } + + if (bms.carousel?.head) { + const {header, content} = bms.carousel.head; + if (header != null && countNewlines(header) > 0) { + return newlineError(type, 'carousel.head.header', 0); + } + if (content != null && countNewlines(content) > 2) { + return newlineError(type, 'carousel.head.content', 2); + } + } + + for (const item of bms.carousel?.list ?? []) { + if (item.header != null && countNewlines(item.header) > 0) { + return newlineError(type, 'carousel.list.header', 0); + } + if ( + type === 'CAROUSEL_FEED' && + item.content != null && + countNewlines(item.content) > 2 + ) { + return newlineError(type, 'carousel.list.content', 2); + } + if ( + type === 'CAROUSEL_COMMERCE' && + item.additionalContent != null && + countNewlines(item.additionalContent) > 1 + ) { + return newlineError(type, 'carousel.list.additionalContent', 1); + } + } + + return true; +}; + +// 서버 URL 검증 regex와 일치 — domain.tld 구조 요구 (whole-string anchor) +const HTTP_URL_PATTERN = + /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,64}\.[a-z0-9]{1,64}\b([-a-zA-Z0-9@:%~_~+.~#?&//=]*)$/i; +const VARIABLE_PLACEHOLDER_PATTERN = /#\{[^{}]+\}/; +const VARIABLE_PLACEHOLDER_GLOBAL = /#\{[^{}]+\}/g; +const WHITESPACE_IN_LINK_PATTERN = /\s/; + +/** + * 링크 URL 형식 검증 + * - 공백/제어문자 포함 시 거부 + * - 정상 http(s) URL이면 통과 + * - `#{변수}` 치환형은 변수를 placeholder로 바꿔 HTTP_URL_PATTERN을 다시 검사 + * (prefix만 체크하면 `https:///#{var}` 같은 malformed URL이 통과하는 문제 방지) + */ +const isValidLink = (link: string): boolean => { + if (WHITESPACE_IN_LINK_PATTERN.test(link)) return false; + if (HTTP_URL_PATTERN.test(link)) return true; + if (!VARIABLE_PLACEHOLDER_PATTERN.test(link)) return false; + const normalized = link.replace(VARIABLE_PLACEHOLDER_GLOBAL, 'x'); + return HTTP_URL_PATTERN.test(normalized); +}; + +/** + * 빈 문자열은 present-but-empty로 간주해 검증 대상에 포함시킨다 + * - Schema.String 레벨에서 required 필드는 빈 문자열도 통과시키므로 + * link validator가 truthy gate만 쓰면 서버에서 거부될 값이 SDK에선 통과 + */ +const checkLinkPair = (obj: LinkFields | undefined): true | string => { + if (!obj) return true; + if (obj.linkPc !== undefined && !isValidLink(obj.linkPc)) { + return 'linkPc 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.'; + } + if (obj.linkMobile !== undefined && !isValidLink(obj.linkMobile)) { + return 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.'; + } + return true; +}; + +const IMAGE_LINK_ERROR = + 'imageLink 값이 잘못되었습니다. http:// 또는 https:// 로 시작하는 정상적인 주소를 올려주세요.'; + +const checkImageLink = (imageLink: string | undefined): true | string => { + if (imageLink === undefined) return true; + return isValidLink(imageLink) ? true : IMAGE_LINK_ERROR; +}; + +/** + * 링크 필드 전체 검증 (linkPc/linkMobile/imageLink) + * - linkAndroid/linkIos는 앱 스키마라 레퍼런스도 검증 안함 + */ +export const validateLinks = (bms: BmsConstraintInput): true | string => { + const imageLinkResult = checkImageLink(bms.imageLink); + if (imageLinkResult !== true) return imageLinkResult; + + const couponResult = checkLinkPair(bms.coupon); + if (couponResult !== true) return couponResult; + + for (const button of bms.buttons ?? []) { + const r = checkLinkPair(button); + if (r !== true) return r; + } + + const mainResult = checkLinkPair(bms.mainWideItem); + if (mainResult !== true) return mainResult; + + for (const sub of bms.subWideItemList ?? []) { + const r = checkLinkPair(sub); + if (r !== true) return r; + } + + const headResult = checkLinkPair(bms.carousel?.head); + if (headResult !== true) return headResult; + + const tailResult = checkLinkPair(bms.carousel?.tail); + if (tailResult !== true) return tailResult; + + for (const item of bms.carousel?.list ?? []) { + const imageResult = checkImageLink(item.imageLink); + if (imageResult !== true) return imageResult; + for (const button of item.buttons ?? []) { + const r = checkLinkPair(button); + if (r !== true) return r; + } + const itemCouponResult = checkLinkPair(item.coupon); + if (itemCouponResult !== true) return itemCouponResult; + } + + return true; +}; + +const FORBIDDEN_VARIABLE_PATTERN = /#\{((?!(#{|})).)+\}/; + +/** + * 변수 금지 검증 + * - `#{...}` 변수 포함 시 거부 (서버 규칙과 일치) + * - 적용 대상: button.name, carousel.tail의 모든 링크 + */ +export const validateForbiddenVariables = ( + bms: BmsConstraintInput, +): true | string => { + const checkButtons = ( + buttons: ReadonlyArray<{name?: string; linkType: string}>, + ): true | string => { + for (const button of buttons) { + if (button.name && FORBIDDEN_VARIABLE_PATTERN.test(button.name)) { + return 'button.name에는 변수를 사용할 수 없습니다.'; + } + } + return true; + }; + + if (bms.buttons) { + const r = checkButtons(bms.buttons); + if (r !== true) return r; + } + + for (const item of bms.carousel?.list ?? []) { + const r = checkButtons(item.buttons ?? []); + if (r !== true) return r; + } + + const tail = bms.carousel?.tail; + if (tail) { + const linkKeys: ReadonlyArray = [ + 'linkMobile', + 'linkPc', + 'linkAndroid', + 'linkIos', + ]; + for (const key of linkKeys) { + const value = tail[key]; + if (value && FORBIDDEN_VARIABLE_PATTERN.test(value)) { + return `${key}에는 변수를 사용할 수 없습니다.`; + } + } + } + + return true; +}; + +/** + * chatBubbleType별 최상위 허용 필드 + * - 서버가 chatBubbleType마다 허용하는 필드 목록과 일치 + * - 여기 없는 필드는 서버가 거부하므로 SDK도 사전 차단 + * - targeting, chatBubbleType은 schema 필수라 별도 제외 + */ +export const BMS_ACCEPTABLE_FIELDS = { + TEXT: ['adult', 'content', 'buttons', 'coupon'], + IMAGE: ['adult', 'content', 'imageId', 'imageLink', 'buttons', 'coupon'], + WIDE: ['adult', 'content', 'imageId', 'buttons', 'coupon'], + WIDE_ITEM_LIST: [ + 'adult', + 'header', + 'mainWideItem', + 'subWideItemList', + 'buttons', + 'coupon', + ], + COMMERCE: [ + 'adult', + 'additionalContent', + 'imageId', + 'commerce', + 'buttons', + 'coupon', + ], + CAROUSEL_FEED: ['adult', 'carousel'], + CAROUSEL_COMMERCE: ['adult', 'additionalContent', 'carousel'], + PREMIUM_VIDEO: ['adult', 'header', 'content', 'video', 'buttons', 'coupon'], +} as const satisfies Record>; + +// baseBmsSchema 필수 필드 — acceptable 목록에 없어도 reject하면 안 됨 +const RESERVED_FIELDS: ReadonlyArray = ['targeting', 'chatBubbleType']; + +/** + * chatBubbleType별 허용 필드 외 reject + * - baseBmsSchema에 선언된 필드만 검사 대상. Schema.Struct가 기본적으로 스키마 미정의 필드를 strip하므로 + * 진짜 알 수 없는 필드(오타 등)는 이 validator보다 먼저 schema 단계에서 제거됨 + * - 따라서 "chatBubbleType에 부적합한 공용 필드" 감지에 특화됨 (예: TEXT에 mainWideItem) + */ +export const validateAcceptableFields = ( + bms: BmsConstraintInput, +): true | string => { + const type = bms.chatBubbleType; + const acceptable = BMS_ACCEPTABLE_FIELDS[type]; + const allowed = new Set([...acceptable, ...RESERVED_FIELDS]); + + // 타입에 선언되지 않은 key를 dynamic 순회하므로 index access는 피할 수 없음. + // Object.keys는 runtime key만 반환하므로 allowed 검사에 직접 사용 + for (const key of Object.keys(bms)) { + if (getField(bms, key) == null) continue; + if (!allowed.has(key)) { + return `${type}타입 에서는 ${acceptable.join(', ')} 값만 사용이 가능합니다.`; + } + } + return true; +}; + +const getField = (obj: T, key: string): unknown => + (obj as {readonly [k: string]: unknown})[key]; + +const BMS_IMAGE_ID_MAX = 32; + +/** + * IMAGE 타입 imageId 공백 제거 후 32자 검증 + */ +export const validateImageIdLength = ( + bms: BmsConstraintInput, +): true | string => { + if (bms.chatBubbleType !== 'IMAGE') return true; + if (!bms.imageId) return true; + const trimmed = bms.imageId.replace(/\s/g, ''); + if (trimmed.length > BMS_IMAGE_ID_MAX) { + return `IMAGE 타입 imageId은 최대 ${BMS_IMAGE_ID_MAX}자 이하로 입력해주세요.`; + } + return true; +}; diff --git a/src/models/base/kakao/bms/bmsCoupon.ts b/src/models/base/kakao/bms/bmsCoupon.ts index 38fa8ebe..2e6d7fe6 100644 --- a/src/models/base/kakao/bms/bmsCoupon.ts +++ b/src/models/base/kakao/bms/bmsCoupon.ts @@ -6,30 +6,24 @@ const wonDiscountPattern = /^([1-9]\d{0,7})원 할인 쿠폰$/; // 퍼센트 할인 쿠폰: 1~100% const percentDiscountPattern = /^([1-9]\d?|100)% 할인 쿠폰$/; -// 무료 쿠폰: 앞 1~7자 (공백 포함 가능) -const freeCouponPattern = /^.{1,7} 무료 쿠폰$/; +// 무료 쿠폰: 앞 1~7자 (공백 금지) +// Why: 공백 허용 시 서버 검증에서 거부됨 (서버는 prefix에서 공백 문자 금지) +const freeCouponPattern = /^\S{1,7} 무료 쿠폰$/; -// UP 쿠폰: 앞 1~7자 (공백 포함 가능) -const upCouponPattern = /^.{1,7} UP 쿠폰$/; +// UP 쿠폰: 앞 1~7자 (공백 금지) +const upCouponPattern = /^\S{1,7} UP 쿠폰$/; const isValidCouponTitle = (title: string): boolean => { - // 1. 배송비 할인 쿠폰 (고정) if (title === '배송비 할인 쿠폰') return true; - // 2. 숫자원 할인 쿠폰 const wonMatch = title.match(wonDiscountPattern); if (wonMatch) { const num = parseInt(wonMatch[1], 10); return num >= 1 && num <= 99_999_999; } - // 3. 퍼센트 할인 쿠폰 if (percentDiscountPattern.test(title)) return true; - - // 4. 무료 쿠폰 if (freeCouponPattern.test(title)) return true; - - // 5. UP 쿠폰 return upCouponPattern.test(title); }; diff --git a/src/models/base/kakao/bms/index.ts b/src/models/base/kakao/bms/index.ts index f3b33aaa..f7809668 100644 --- a/src/models/base/kakao/bms/index.ts +++ b/src/models/base/kakao/bms/index.ts @@ -39,12 +39,43 @@ export { bmsCarouselHeadSchema, bmsCarouselTailSchema, } from './bmsCarousel'; - +export { + type BmsChatBubbleType, + bmsChatBubbleTypeSchema, +} from './bmsChatBubbleType'; export { type BmsCommerce, type BmsCommerceSchema, bmsCommerceSchema, } from './bmsCommerce'; +export { + BMS_ACCEPTABLE_FIELDS, + BMS_ADDITIONAL_CONTENT_MAX, + BMS_ALLOWED_LINK_TYPES, + BMS_BUTTON_COUNT, + BMS_BUTTON_NAME_MAX, + BMS_CAROUSEL_COMMERCE_ADDITIONAL_CONTENT_MAX, + BMS_CAROUSEL_FEED_ITEM_CONTENT_MAX, + BMS_CAROUSEL_HEAD_CONTENT_MAX, + BMS_CAROUSEL_LIST_RANGE, + BMS_CONTENT_MAX, + BMS_COUPON_DESCRIPTION_MAX, + BMS_HEADER_MAX, + BMS_MAIN_WIDE_ITEM_TITLE_MAX, + BMS_SUB_WIDE_ITEM_TITLE_MAX, + type BmsConstraintInput, + validateAcceptableFields, + validateAllowedLinkTypes, + validateButtonCount, + validateButtonNames, + validateCarouselListCount, + validateCouponDescription, + validateForbiddenVariables, + validateImageIdLength, + validateLinks, + validateNewlineLimits, + validateTextLengths, +} from './bmsConstraints'; export { type BmsCoupon, type BmsCouponSchema, diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index e87de302..69e64faf 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -7,14 +7,28 @@ import { Schema, } from 'effect'; import { + type BmsChatBubbleType, + type BmsConstraintInput, bmsButtonSchema, bmsCarouselCommerceSchema, bmsCarouselFeedSchema, + bmsChatBubbleTypeSchema, bmsCommerceSchema, bmsCouponSchema, bmsMainWideItemSchema, bmsSubWideItemSchema, bmsVideoSchema, + validateAcceptableFields, + validateAllowedLinkTypes, + validateButtonCount, + validateButtonNames, + validateCarouselListCount, + validateCouponDescription, + validateForbiddenVariables, + validateImageIdLength, + validateLinks, + validateNewlineLimits, + validateTextLengths, } from './bms'; import {kakaoButtonSchema} from './kakaoButton'; @@ -33,25 +47,6 @@ export class VariableValidationError extends Data.TaggedError( } } -/** - * BMS chatBubbleType 스키마 - * 지원하는 8가지 말풍선 타입 - */ -export const bmsChatBubbleTypeSchema = Schema.Literal( - 'TEXT', - 'IMAGE', - 'WIDE', - 'WIDE_ITEM_LIST', - 'COMMERCE', - 'CAROUSEL_FEED', - 'CAROUSEL_COMMERCE', - 'PREMIUM_VIDEO', -); - -export type BmsChatBubbleType = Schema.Schema.Type< - typeof bmsChatBubbleTypeSchema ->; - /** * chatBubbleType별 필수 필드 정의 * - TEXT: content는 메시지의 text 필드에서 가져옴 @@ -110,9 +105,7 @@ type BaseBmsSchemaType = Schema.Schema.Type; const WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3; -const validateBmsRequiredFields = ( - bms: BaseBmsSchemaType, -): boolean | string => { +const validateBmsRequiredFields = (bms: BaseBmsSchemaType): true | string => { const chatBubbleType = bms.chatBubbleType; const requiredFields = BMS_REQUIRED_FIELDS[chatBubbleType] ?? []; const missingFields = requiredFields.filter( @@ -137,10 +130,40 @@ const validateBmsRequiredFields = ( }; /** - * BMS 옵션 스키마 (chatBubbleType별 필수 필드 검증 포함) + * 사전 접수 전 BMS 옵션 전체 제약 검증 + * - 서버 왕복 없이 동일 에러 문구로 즉시 실패 (fail-fast, 싼 체크 우선) + */ +const validateBmsConstraints = (bms: BaseBmsSchemaType): true | string => { + const input: BmsConstraintInput = bms; + const validators: ReadonlyArray<(b: BmsConstraintInput) => true | string> = [ + validateAcceptableFields, + validateCarouselListCount, + validateCouponDescription, + validateAllowedLinkTypes, + validateButtonCount, + validateButtonNames, + validateImageIdLength, + validateTextLengths, + validateNewlineLimits, + validateForbiddenVariables, + validateLinks, + ]; + + const requiredResult = validateBmsRequiredFields(bms); + if (requiredResult !== true) return requiredResult; + + for (const validator of validators) { + const result = validator(input); + if (result !== true) return result; + } + return true; +}; + +/** + * BMS 옵션 스키마 (chatBubbleType별 필수 필드 + 전체 제약 검증 포함) */ const kakaoOptionBmsSchema = baseBmsSchema.pipe( - Schema.filter(validateBmsRequiredFields), + Schema.filter(validateBmsConstraints), ); export type KakaoOptionBmsSchema = Schema.Schema.Type< diff --git a/src/models/index.ts b/src/models/index.ts index 580bdae8..f1d00bcc 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -51,9 +51,7 @@ export { } from './base/kakao/kakaoChannel'; export { - type BmsChatBubbleType, baseKakaoOptionSchema, - bmsChatBubbleTypeSchema, type KakaoOptionBmsSchema, transformVariables, VariableValidationError, diff --git a/test/models/base/kakao/bms/bmsCommerce.test.ts b/test/models/base/kakao/bms/bmsCommerce.test.ts index c01476e4..8135f6b4 100644 --- a/test/models/base/kakao/bms/bmsCommerce.test.ts +++ b/test/models/base/kakao/bms/bmsCommerce.test.ts @@ -252,4 +252,83 @@ describe('BMS Commerce Schema', () => { expect(result._tag).toBe('Left'); }); }); + + describe('가격 범위 검증', () => { + it('should accept regularPrice at max (99999999)', () => { + const valid = {title: '상품', regularPrice: 99_999_999}; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should reject regularPrice over 99999999', () => { + const invalid = {title: '상품', regularPrice: 100_000_000}; + expect(() => { + Schema.decodeUnknownSync(bmsCommerceSchema)(invalid); + }).toThrow( + 'regularPrice 값이 잘못되었습니다. 0 이상 99999999 이하의 숫자여야 합니다.', + ); + }); + + it('should reject regularPrice as negative', () => { + const invalid = {title: '상품', regularPrice: -1}; + expect(() => { + Schema.decodeUnknownSync(bmsCommerceSchema)(invalid); + }).toThrow( + 'regularPrice 값이 잘못되었습니다. 0 이상 99999999 이하의 숫자여야 합니다.', + ); + }); + + it('should accept discountRate at 100 (boundary)', () => { + const valid = { + title: '상품', + regularPrice: 1000, + discountPrice: 500, + discountRate: 100, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should reject discountRate over 100', () => { + const invalid = { + title: '상품', + regularPrice: 1000, + discountPrice: 500, + discountRate: 150, + }; + expect(() => { + Schema.decodeUnknownSync(bmsCommerceSchema)(invalid); + }).toThrow( + 'discountRate 값이 잘못되었습니다. 0 이상 100 이하의 숫자여야 합니다.', + ); + }); + + it('should reject discountFixed over 99999999', () => { + const invalid = { + title: '상품', + regularPrice: 1000, + discountPrice: 500, + discountFixed: 100_000_000, + }; + expect(() => { + Schema.decodeUnknownSync(bmsCommerceSchema)(invalid); + }).toThrow( + 'discountFixed 값이 잘못되었습니다. 0 이상 99999999 이하의 숫자여야 합니다.', + ); + }); + + it('should reject discountPrice over 99999999 via numeric string', () => { + const invalid = { + title: '상품', + regularPrice: 1000, + discountPrice: '100000000', + discountRate: 50, + }; + expect(() => { + Schema.decodeUnknownSync(bmsCommerceSchema)(invalid); + }).toThrow( + 'discountPrice 값이 잘못되었습니다. 0 이상 99999999 이하의 숫자여야 합니다.', + ); + }); + }); }); diff --git a/test/models/base/kakao/bms/bmsConstraints.test.ts b/test/models/base/kakao/bms/bmsConstraints.test.ts new file mode 100644 index 00000000..ca746a4e --- /dev/null +++ b/test/models/base/kakao/bms/bmsConstraints.test.ts @@ -0,0 +1,1013 @@ +import { + type BmsConstraintInput, + validateAcceptableFields, + validateAllowedLinkTypes, + validateButtonCount, + validateButtonNames, + validateCarouselListCount, + validateCouponDescription, + validateForbiddenVariables, + validateImageIdLength, + validateLinks, + validateNewlineLimits, + validateTextLengths, +} from '@models/base/kakao/bms/bmsConstraints'; +import {describe, expect, it} from 'vitest'; + +const baseTextBms = ( + overrides: Partial = {}, +): BmsConstraintInput => ({ + chatBubbleType: 'TEXT', + ...overrides, +}); + +describe('validateCouponDescription', () => { + it('should pass when coupon is not provided', () => { + expect(validateCouponDescription(baseTextBms())).toBe(true); + }); + + it.each([ + ['TEXT', 12, true], + ['IMAGE', 12, true], + ['COMMERCE', 12, true], + ['CAROUSEL_FEED', 12, true], + ['CAROUSEL_COMMERCE', 12, true], + ['PREMIUM_VIDEO', 12, true], + ['WIDE', 18, true], + ['WIDE_ITEM_LIST', 18, true], + ] as const)('should accept coupon description at exact max length for %s (%d chars)', (chatBubbleType, maxLen) => { + const result = validateCouponDescription({ + chatBubbleType, + coupon: {description: 'x'.repeat(maxLen)}, + }); + expect(result).toBe(true); + }); + + it.each([ + ['TEXT', 13, 12], + ['IMAGE', 13, 12], + ['WIDE', 19, 18], + ['WIDE_ITEM_LIST', 19, 18], + ] as const)('should reject coupon description exceeding max for %s (%d > %d)', (chatBubbleType, descLen, maxLen) => { + const result = validateCouponDescription({ + chatBubbleType, + coupon: {description: 'x'.repeat(descLen)}, + }); + expect(result).toBe(`쿠폰 설명은 최대 ${maxLen}자 이하로 입력해주세요.`); + }); + + it('should reproduce server error message exactly (regression: sdk-testing BMS_FREE)', () => { + const result = validateCouponDescription({ + chatBubbleType: 'TEXT', + coupon: {description: '이것은 13자짜리 설명입'}, + }); + expect(result).toBe('쿠폰 설명은 최대 12자 이하로 입력해주세요.'); + }); + + it('should validate per-item coupon description on CAROUSEL_FEED (ultrareview merged_bug_002)', () => { + const result = validateCouponDescription({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [{coupon: {description: 'x'.repeat(13)}}], + }, + }); + expect(result).toBe('쿠폰 설명은 최대 12자 이하로 입력해주세요.'); + }); + + it('should validate per-item coupon description on CAROUSEL_COMMERCE', () => { + const result = validateCouponDescription({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [{coupon: {description: 'x'.repeat(13)}}], + }, + }); + expect(result).toBe('쿠폰 설명은 최대 12자 이하로 입력해주세요.'); + }); + + it('should accept per-item coupon description within limit', () => { + expect( + validateCouponDescription({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [{coupon: {description: 'x'.repeat(12)}}], + }, + }), + ).toBe(true); + }); +}); + +describe('validateButtonCount', () => { + it('should pass when no buttons provided for TEXT', () => { + expect(validateButtonCount(baseTextBms())).toBe(true); + }); + + it('should accept 5 buttons on TEXT without coupon', () => { + const buttons = Array.from({length: 5}, (_, i) => ({ + name: `b${i}`, + linkType: 'WL' as const, + })); + expect(validateButtonCount(baseTextBms({buttons}))).toBe(true); + }); + + it('should reject 6 buttons on TEXT without coupon', () => { + const buttons = Array.from({length: 6}, (_, i) => ({ + name: `b${i}`, + linkType: 'WL' as const, + })); + const result = validateButtonCount(baseTextBms({buttons})); + expect(result).toBe( + 'TEXT 타입에서는 최대 5개의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should accept 4 buttons on IMAGE with coupon', () => { + const buttons = Array.from({length: 4}, (_, i) => ({ + name: `b${i}`, + linkType: 'WL' as const, + })); + expect( + validateButtonCount({ + chatBubbleType: 'IMAGE', + coupon: {description: '10%'}, + buttons, + }), + ).toBe(true); + }); + + it('should reject 5 buttons on IMAGE when coupon present', () => { + const buttons = Array.from({length: 5}, (_, i) => ({ + name: `b${i}`, + linkType: 'WL' as const, + })); + const result = validateButtonCount({ + chatBubbleType: 'IMAGE', + coupon: {description: '10%'}, + buttons, + }); + expect(result).toBe( + 'IMAGE 타입에서는 쿠폰 사용 시 최대 4개의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should reject 3 buttons on WIDE', () => { + const buttons = Array.from({length: 3}, (_, i) => ({ + name: `b${i}`, + linkType: 'WL' as const, + })); + const result = validateButtonCount({ + chatBubbleType: 'WIDE', + buttons, + }); + expect(result).toBe( + 'WIDE 타입에서는 최대 2개의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should reject 2 buttons on PREMIUM_VIDEO', () => { + const result = validateButtonCount({ + chatBubbleType: 'PREMIUM_VIDEO', + buttons: [ + {name: 'b1', linkType: 'WL'}, + {name: 'b2', linkType: 'WL'}, + ], + }); + expect(result).toBe( + 'PREMIUM_VIDEO 타입에서는 최대 1개의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should validate each CAROUSEL_FEED item buttons separately', () => { + const result = validateButtonCount({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + buttons: [ + {name: 'a', linkType: 'WL'}, + {name: 'b', linkType: 'WL'}, + {name: 'c', linkType: 'WL'}, + ], + }, + ], + }, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입에서는 최대 2개의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should reject empty buttons on COMMERCE (min=1)', () => { + const result = validateButtonCount({ + chatBubbleType: 'COMMERCE', + buttons: [], + }); + expect(result).toBe('COMMERCE 타입에서는 최소 1개의 버튼이 필요합니다.'); + }); + + it('should reject empty buttons inside CAROUSEL_FEED list item (min=1)', () => { + const result = validateButtonCount({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list: [{buttons: []}]}, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입에서는 최소 1개의 버튼이 필요합니다.', + ); + }); + + it('should reject empty buttons inside CAROUSEL_COMMERCE list item (min=1)', () => { + const result = validateButtonCount({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: {list: [{buttons: []}]}, + }); + expect(result).toBe( + 'CAROUSEL_COMMERCE 타입에서는 최소 1개의 버튼이 필요합니다.', + ); + }); +}); + +describe('validateButtonNames', () => { + it('should accept 14-char name on TEXT', () => { + expect( + validateButtonNames( + baseTextBms({ + buttons: [{name: 'x'.repeat(14), linkType: 'WL'}], + }), + ), + ).toBe(true); + }); + + it('should reject 15-char name on TEXT', () => { + const result = validateButtonNames( + baseTextBms({ + buttons: [{name: 'x'.repeat(15), linkType: 'WL'}], + }), + ); + expect(result).toBe( + 'TEXT 타입 button.name은 최대 14자 이하로 입력해주세요.', + ); + }); + + it('should reject 9-char name on WIDE', () => { + const result = validateButtonNames({ + chatBubbleType: 'WIDE', + buttons: [{name: 'x'.repeat(9), linkType: 'WL'}], + }); + expect(result).toBe( + 'WIDE 타입 button.name은 최대 8자 이하로 입력해주세요.', + ); + }); + + it('should skip AC linkType even with long name', () => { + expect( + validateButtonNames( + baseTextBms({ + buttons: [{name: 'x'.repeat(100), linkType: 'AC'}], + }), + ), + ).toBe(true); + }); + + it('should check button names in carousel list items', () => { + const result = validateButtonNames({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + buttons: [{name: 'x'.repeat(9), linkType: 'WL'}], + }, + ], + }, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입 button.name은 최대 8자 이하로 입력해주세요.', + ); + }); +}); + +describe('validateAllowedLinkTypes', () => { + it('should allow any linkType on TEXT', () => { + expect( + validateAllowedLinkTypes( + baseTextBms({ + buttons: [ + {name: 'a', linkType: 'AC'}, + {name: 'b', linkType: 'BK'}, + {name: 'c', linkType: 'BT'}, + ], + }), + ), + ).toBe(true); + }); + + it('should reject AC on COMMERCE', () => { + const result = validateAllowedLinkTypes({ + chatBubbleType: 'COMMERCE', + buttons: [{name: 'a', linkType: 'AC'}], + }); + expect(result).toBe( + 'COMMERCE 타입에서는 WL, AL 타입의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should reject BK on CAROUSEL_FEED item buttons', () => { + const result = validateAllowedLinkTypes({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + buttons: [{name: 'a', linkType: 'BK'}], + }, + ], + }, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입에서는 WL, AL 타입의 버튼만 사용할 수 있습니다.', + ); + }); +}); + +describe('validateTextLengths', () => { + it('should reject header over 20 chars', () => { + const result = validateTextLengths(baseTextBms({header: 'x'.repeat(21)})); + expect(result).toBe('TEXT 타입 header은 최대 20자 이하로 입력해주세요.'); + }); + + it('should accept header of exactly 20 chars', () => { + expect(validateTextLengths(baseTextBms({header: 'x'.repeat(20)}))).toBe( + true, + ); + }); + + it('should reject content over 1300 chars on TEXT', () => { + const result = validateTextLengths( + baseTextBms({content: 'x'.repeat(1301)}), + ); + expect(result).toBe('TEXT 타입 content은 최대 1300자 이하로 입력해주세요.'); + }); + + it('should reject content over 76 chars on WIDE', () => { + const result = validateTextLengths({ + chatBubbleType: 'WIDE', + content: 'x'.repeat(77), + }); + expect(result).toBe('WIDE 타입 content은 최대 76자 이하로 입력해주세요.'); + }); + + it('should reject additionalContent over 34 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'COMMERCE', + additionalContent: 'x'.repeat(35), + }); + expect(result).toBe( + 'COMMERCE 타입 additionalContent은 최대 34자 이하로 입력해주세요.', + ); + }); + + it('should reject mainWideItem.title over 25 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'WIDE_ITEM_LIST', + mainWideItem: {title: 'x'.repeat(26)}, + }); + expect(result).toBe( + 'WIDE_ITEM_LIST 타입 mainWideItem.title은 최대 25자 이하로 입력해주세요.', + ); + }); + + it('should reject subWideItem.title over 30 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'WIDE_ITEM_LIST', + subWideItemList: [{title: 'x'.repeat(31)}], + }); + expect(result).toBe( + 'WIDE_ITEM_LIST 타입 subWideItem.title은 최대 30자 이하로 입력해주세요.', + ); + }); + + it('should reject carousel.head.content over 50 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + head: {content: 'x'.repeat(51)}, + }, + }); + expect(result).toBe( + 'CAROUSEL_COMMERCE 타입 carousel.head.content은 최대 50자 이하로 입력해주세요.', + ); + }); + + it('should reject carousel.head.header over 20 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + head: {header: 'x'.repeat(21)}, + }, + }); + expect(result).toBe( + 'CAROUSEL_COMMERCE 타입 carousel.head.header은 최대 20자 이하로 입력해주세요.', + ); + }); + + it('should reject CAROUSEL_FEED list content over 180 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [{content: 'x'.repeat(181)}], + }, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입 carousel.list.content은 최대 180자 이하로 입력해주세요.', + ); + }); + + it('should reject CAROUSEL_COMMERCE list additionalContent over 34 chars', () => { + const result = validateTextLengths({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [{additionalContent: 'x'.repeat(35)}], + }, + }); + expect(result).toBe( + 'CAROUSEL_COMMERCE 타입 carousel.list.additionalContent은 최대 34자 이하로 입력해주세요.', + ); + }); +}); + +describe('validateCarouselListCount', () => { + it('should skip validation for non-carousel types', () => { + expect(validateCarouselListCount(baseTextBms())).toBe(true); + }); + + it('should accept 2-6 list without head', () => { + for (const count of [2, 3, 6]) { + const list = Array.from({length: count}, () => ({})); + expect( + validateCarouselListCount({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list}, + }), + ).toBe(true); + } + }); + + it('should reject 1 list item without head', () => { + const result = validateCarouselListCount({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list: [{}]}, + }); + expect(result).toBe('캐러셀 리스트는 최소 2개, 최대 6개까지 가능합니다.'); + }); + + it('should reject 7 list items without head', () => { + const list = Array.from({length: 7}, () => ({})); + const result = validateCarouselListCount({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list}, + }); + expect(result).toBe('캐러셀 리스트는 최소 2개, 최대 6개까지 가능합니다.'); + }); + + it('should accept 1-5 list with head', () => { + for (const count of [1, 3, 5]) { + const list = Array.from({length: count}, () => ({})); + expect( + validateCarouselListCount({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: {head: {}, list}, + }), + ).toBe(true); + } + }); + + it('should reject 6 list items with head', () => { + const list = Array.from({length: 6}, () => ({})); + const result = validateCarouselListCount({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: {head: {}, list}, + }); + expect(result).toBe( + '캐러셀 인트로가 있는 경우 캐러셀 리스트는 최소 1개, 최대 5개까지 가능합니다.', + ); + }); +}); + +describe('validateNewlineLimits', () => { + it('should reject header with any newline', () => { + const result = validateNewlineLimits(baseTextBms({header: '제목\n부제'})); + expect(result).toBe('TEXT 타입 header은 줄바꿈 최대 0개 까지 가능합니다.'); + }); + + it('should accept TEXT content with 99 newlines', () => { + const content = Array.from({length: 100}, () => 'x').join('\n'); + expect(validateNewlineLimits(baseTextBms({content}))).toBe(true); + }); + + it('should reject WIDE content with 2 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'WIDE', + content: 'a\nb\nc', + }); + expect(result).toBe('WIDE 타입 content은 줄바꿈 최대 1개 까지 가능합니다.'); + }); + + it('should reject PREMIUM_VIDEO content with 2 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'PREMIUM_VIDEO', + content: 'a\nb\nc', + }); + expect(result).toBe( + 'PREMIUM_VIDEO 타입 content은 줄바꿈 최대 1개 까지 가능합니다.', + ); + }); + + it('should reject mainWideItem.title with 2 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'WIDE_ITEM_LIST', + mainWideItem: {title: 'a\nb\nc'}, + }); + expect(result).toBe( + 'WIDE_ITEM_LIST 타입 mainWideItem.title은 줄바꿈 최대 1개 까지 가능합니다.', + ); + }); + + it('should reject subWideItem.title with 2 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'WIDE_ITEM_LIST', + subWideItemList: [{title: 'a\nb\nc'}], + }); + expect(result).toBe( + 'WIDE_ITEM_LIST 타입 subWideItem.title은 줄바꿈 최대 1개 까지 가능합니다.', + ); + }); + + it('should reject carousel.head.content with 3 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: {head: {content: 'a\nb\nc\nd'}}, + }); + expect(result).toBe( + 'CAROUSEL_COMMERCE 타입 carousel.head.content은 줄바꿈 최대 2개 까지 가능합니다.', + ); + }); + + it('should reject carousel.list.header with newline', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list: [{header: 'a\nb'}]}, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입 carousel.list.header은 줄바꿈 최대 0개 까지 가능합니다.', + ); + }); + + it('should reject CAROUSEL_FEED carousel.list.content with 3 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list: [{content: 'a\nb\nc\nd'}]}, + }); + expect(result).toBe( + 'CAROUSEL_FEED 타입 carousel.list.content은 줄바꿈 최대 2개 까지 가능합니다.', + ); + }); + + it('should reject CAROUSEL_COMMERCE carousel.list.additionalContent with 2 newlines', () => { + const result = validateNewlineLimits({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: {list: [{additionalContent: 'a\nb\nc'}]}, + }); + expect(result).toBe( + 'CAROUSEL_COMMERCE 타입 carousel.list.additionalContent은 줄바꿈 최대 1개 까지 가능합니다.', + ); + }); +}); + +describe('validateLinks', () => { + it('should accept valid http(s) linkMobile on buttons', () => { + expect( + validateLinks( + baseTextBms({ + buttons: [ + { + name: 'b', + linkType: 'WL', + linkMobile: 'https://example.com/path?x=1', + }, + ], + }), + ), + ).toBe(true); + }); + + it('should reject non-URL linkMobile', () => { + const result = validateLinks( + baseTextBms({ + buttons: [{name: 'b', linkType: 'WL', linkMobile: 'not-a-url'}], + }), + ); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should accept http(s) prefix with #{variable}', () => { + expect( + validateLinks( + baseTextBms({ + coupon: { + description: '10%', + linkMobile: 'https://example.com/#{couponId}/view', + }, + }), + ), + ).toBe(true); + }); + + it('should reject linkPc with malformed URL', () => { + const result = validateLinks( + baseTextBms({ + buttons: [ + { + name: 'b', + linkType: 'WL', + linkMobile: 'https://ok.example.com', + linkPc: 'broken://not-http', + }, + ], + }), + ); + expect(result).toBe( + 'linkPc 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject invalid imageLink at top level', () => { + const result = validateLinks(baseTextBms({imageLink: 'just-text'})); + expect(result).toBe( + 'imageLink 값이 잘못되었습니다. http:// 또는 https:// 로 시작하는 정상적인 주소를 올려주세요.', + ); + }); + + it('should reject invalid imageLink inside carousel list item', () => { + const result = validateLinks({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [{imageLink: 'bad'}], + }, + }); + expect(result).toBe( + 'imageLink 값이 잘못되었습니다. http:// 또는 https:// 로 시작하는 정상적인 주소를 올려주세요.', + ); + }); + + it('should reject invalid linkMobile on mainWideItem', () => { + const result = validateLinks({ + chatBubbleType: 'WIDE_ITEM_LIST', + mainWideItem: {linkMobile: 'not-url'}, + }); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject invalid linkMobile on carousel.tail', () => { + const result = validateLinks({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {tail: {linkMobile: 'bad-url'}}, + }); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string linkMobile (present-but-empty silent pass regression)', () => { + const result = validateLinks( + baseTextBms({ + buttons: [{name: 'b', linkType: 'WL', linkMobile: ''}], + }), + ); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string imageLink', () => { + const result = validateLinks(baseTextBms({imageLink: ''})); + expect(result).toBe( + 'imageLink 값이 잘못되었습니다. http:// 또는 https:// 로 시작하는 정상적인 주소를 올려주세요.', + ); + }); + + it('should reject https URL with whitespace/control char', () => { + const result = validateLinks( + baseTextBms({ + buttons: [ + { + name: 'b', + linkType: 'WL', + linkMobile: 'https://example.com\nalert(1)', + }, + ], + }), + ); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject malformed https://host#{var} without domain', () => { + const result = validateLinks( + baseTextBms({ + buttons: [ + { + name: 'b', + linkType: 'WL', + linkMobile: 'https:///#{var}', + }, + ], + }), + ); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string linkMobile on subWideItem', () => { + const result = validateLinks({ + chatBubbleType: 'WIDE_ITEM_LIST', + subWideItemList: [{title: 's', linkMobile: ''}], + }); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string linkPc on button', () => { + const result = validateLinks( + baseTextBms({ + buttons: [ + { + name: 'b', + linkType: 'WL', + linkMobile: 'https://ok.example.com', + linkPc: '', + }, + ], + }), + ); + expect(result).toBe( + 'linkPc 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string linkMobile on top-level coupon', () => { + const result = validateLinks( + baseTextBms({ + coupon: {description: '10%', linkMobile: ''}, + }), + ); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string linkMobile on mainWideItem', () => { + const result = validateLinks({ + chatBubbleType: 'WIDE_ITEM_LIST', + mainWideItem: {linkMobile: ''}, + }); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string linkMobile on carousel.head', () => { + const result = validateLinks({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: {head: {linkMobile: ''}}, + }); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject empty-string imageLink on carousel list item', () => { + const result = validateLinks({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: {list: [{imageLink: ''}]}, + }); + expect(result).toBe( + 'imageLink 값이 잘못되었습니다. http:// 또는 https:// 로 시작하는 정상적인 주소를 올려주세요.', + ); + }); + + it('should reject malformed https:///#{var} imageLink', () => { + const result = validateLinks( + baseTextBms({imageLink: 'https:///#{brandId}/image.png'}), + ); + expect(result).toBe( + 'imageLink 값이 잘못되었습니다. http:// 또는 https:// 로 시작하는 정상적인 주소를 올려주세요.', + ); + }); + + it('should reject invalid linkMobile on per-item coupon (ultrareview merged_bug_002)', () => { + const result = validateLinks({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + coupon: {description: '10%', linkMobile: 'not-a-url'}, + }, + ], + }, + }); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject https without domain.tld (localhost)', () => { + const result = validateLinks( + baseTextBms({ + buttons: [{name: 'b', linkType: 'WL', linkMobile: 'https://localhost'}], + }), + ); + expect(result).toBe( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); +}); + +describe('countNewlines — CRLF/CR 대응', () => { + it('should count CRLF as one newline', () => { + const result = validateNewlineLimits(baseTextBms({header: '제목\r\n부제'})); + expect(result).toBe('TEXT 타입 header은 줄바꿈 최대 0개 까지 가능합니다.'); + }); + + it('should count CR as one newline', () => { + const result = validateNewlineLimits(baseTextBms({header: '제목\r부제'})); + expect(result).toBe('TEXT 타입 header은 줄바꿈 최대 0개 까지 가능합니다.'); + }); +}); + +describe('validateForbiddenVariables', () => { + it('should accept button.name without variables', () => { + expect( + validateForbiddenVariables( + baseTextBms({ + buttons: [{name: '자세히 보기', linkType: 'WL'}], + }), + ), + ).toBe(true); + }); + + it('should reject button.name with variable', () => { + const result = validateForbiddenVariables( + baseTextBms({ + buttons: [{name: '#{user}님', linkType: 'WL'}], + }), + ); + expect(result).toBe('button.name에는 변수를 사용할 수 없습니다.'); + }); + + it('should reject button.name with variable inside carousel list', () => { + const result = validateForbiddenVariables({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [{buttons: [{name: '#{v}', linkType: 'WL'}]}], + }, + }); + expect(result).toBe('button.name에는 변수를 사용할 수 없습니다.'); + }); + + it('should reject variable in carousel.tail.linkMobile', () => { + const result = validateForbiddenVariables({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + tail: {linkMobile: 'https://example.com/#{path}'}, + }, + }); + expect(result).toBe('linkMobile에는 변수를 사용할 수 없습니다.'); + }); + + it('should reject variable in carousel.tail.linkPc', () => { + const result = validateForbiddenVariables({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + tail: {linkPc: 'https://example.com/#{path}'}, + }, + }); + expect(result).toBe('linkPc에는 변수를 사용할 수 없습니다.'); + }); + + it('should reject variable in carousel.tail.linkAndroid', () => { + const result = validateForbiddenVariables({ + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + tail: {linkAndroid: 'intent://app/#{param}'}, + }, + }); + expect(result).toBe('linkAndroid에는 변수를 사용할 수 없습니다.'); + }); + + it('should reject variable in carousel.tail.linkIos', () => { + const result = validateForbiddenVariables({ + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + tail: {linkIos: 'app://open/#{param}'}, + }, + }); + expect(result).toBe('linkIos에는 변수를 사용할 수 없습니다.'); + }); +}); + +describe('validateAcceptableFields', () => { + it('should accept TEXT with only allowed fields', () => { + expect( + validateAcceptableFields({ + chatBubbleType: 'TEXT', + content: '내용', + }), + ).toBe(true); + }); + + it('should reject TEXT with mainWideItem field', () => { + const result = validateAcceptableFields({ + chatBubbleType: 'TEXT', + mainWideItem: {title: '나쁨'}, + }); + expect(result).toBe( + 'TEXT타입 에서는 adult, content, buttons, coupon 값만 사용이 가능합니다.', + ); + }); + + it('should reject CAROUSEL_FEED with additionalContent', () => { + const result = validateAcceptableFields({ + chatBubbleType: 'CAROUSEL_FEED', + additionalContent: '추가 내용', + }); + expect(result).toBe( + 'CAROUSEL_FEED타입 에서는 adult, carousel 값만 사용이 가능합니다.', + ); + }); + + it('should reject WIDE with imageLink (not in acceptable list)', () => { + const result = validateAcceptableFields({ + chatBubbleType: 'WIDE', + imageLink: 'https://example.com', + }); + expect(result).toBe( + 'WIDE타입 에서는 adult, content, imageId, buttons, coupon 값만 사용이 가능합니다.', + ); + }); + + it('should skip null/undefined fields', () => { + // null 필드는 레퍼런스에서 삭제되므로 검증 대상에서 제외 + const result = validateAcceptableFields({ + chatBubbleType: 'TEXT', + content: '내용', + header: undefined, + }); + expect(result).toBe(true); + }); +}); + +describe('validateImageIdLength', () => { + it('should pass for non-IMAGE type', () => { + expect( + validateImageIdLength({ + chatBubbleType: 'TEXT', + imageId: 'x'.repeat(100), + }), + ).toBe(true); + }); + + it('should pass for IMAGE with no imageId', () => { + expect(validateImageIdLength({chatBubbleType: 'IMAGE'})).toBe(true); + }); + + it('should accept IMAGE with 32-char imageId', () => { + expect( + validateImageIdLength({ + chatBubbleType: 'IMAGE', + imageId: 'a'.repeat(32), + }), + ).toBe(true); + }); + + it('should reject IMAGE with 33-char imageId', () => { + const result = validateImageIdLength({ + chatBubbleType: 'IMAGE', + imageId: 'a'.repeat(33), + }); + expect(result).toBe('IMAGE 타입 imageId은 최대 32자 이하로 입력해주세요.'); + }); + + it('should strip whitespace before measuring length', () => { + // 공백 포함 40자지만 공백 제거 후 32자 — valid + expect( + validateImageIdLength({ + chatBubbleType: 'IMAGE', + imageId: `${'a'.repeat(32)} `, + }), + ).toBe(true); + }); +}); diff --git a/test/models/base/kakao/bms/bmsCoupon.test.ts b/test/models/base/kakao/bms/bmsCoupon.test.ts index 737a17af..6053e830 100644 --- a/test/models/base/kakao/bms/bmsCoupon.test.ts +++ b/test/models/base/kakao/bms/bmsCoupon.test.ts @@ -15,7 +15,6 @@ describe('BMS Coupon Schema', () => { '배송비 할인 쿠폰', '신규가입 무료 쿠폰', '포인트 UP 쿠폰', - '신규 가입 무료 쿠폰', // 공백 포함 7자 ]; it.each(validTitles)('should accept valid title: %s', title => { @@ -31,6 +30,8 @@ describe('BMS Coupon Schema', () => { '101% 할인 쿠폰', // 100 초과 '12345678 무료 쿠폰', // 8자 이상 '12345678 UP 쿠폰', // 8자 이상 + '신규 가입 무료 쿠폰', // prefix에 공백 포함 불가 (레퍼런스 /^[^\s]{1,7}$/) + '내 폰 UP 쿠폰', // prefix에 공백 포함 불가 ]; it.each(invalidTitles)('should reject invalid title: %s', title => { diff --git a/test/models/base/kakao/bms/bmsOption.test.ts b/test/models/base/kakao/bms/bmsOption.test.ts index 03aa8490..3913d6b8 100644 --- a/test/models/base/kakao/bms/bmsOption.test.ts +++ b/test/models/base/kakao/bms/bmsOption.test.ts @@ -19,8 +19,9 @@ describe('BMS Option Schema in KakaoOption', () => { expect(result._tag).toBe('Right'); }); - it('should accept BMS_TEXT with optional header', () => { - const validBmsText = { + it('should reject BMS_TEXT with header (not in TEXT acceptable fields)', () => { + // TEXT 타입 서버 허용 필드: adult, content, buttons, coupon + const invalidBmsText = { pfId: 'test-pf-id', bms: { targeting: 'M', @@ -29,10 +30,11 @@ describe('BMS Option Schema in KakaoOption', () => { }, }; - const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( - validBmsText, + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsText); + }).toThrow( + 'TEXT타입 에서는 adult, content, buttons, coupon 값만 사용이 가능합니다.', ); - expect(result._tag).toBe('Right'); }); it('should accept valid BMS_IMAGE message with imageId', () => { @@ -287,6 +289,18 @@ describe('BMS Option Schema in KakaoOption', () => { }, ], }, + { + header: '캐러셀 2', + content: '내용 2', + imageId: 'img-2', + buttons: [ + { + name: '자세히', + linkType: 'WL', + linkMobile: 'https://example.com/2', + }, + ], + }, ], }, }, @@ -336,6 +350,20 @@ describe('BMS Option Schema in KakaoOption', () => { }, ], }, + { + commerce: { + title: '상품 2', + regularPrice: 20000, + }, + imageId: 'img-2', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://shop.example.com/2', + }, + ], + }, ], }, }, @@ -505,14 +533,26 @@ describe('BMS Option Schema in KakaoOption', () => { carousel: { list: [ { - header: '헤더', - content: '내용', + header: '헤더1', + content: '내용1', imageId: 'img-1', buttons: [ { name: '버튼', linkType: 'WL', - linkMobile: 'https://example.com', + linkMobile: 'https://example.com/1', + }, + ], + }, + { + header: '헤더2', + content: '내용2', + imageId: 'img-2', + buttons: [ + { + name: '버튼', + linkType: 'WL', + linkMobile: 'https://example.com/2', }, ], }, @@ -526,13 +566,24 @@ describe('BMS Option Schema in KakaoOption', () => { carousel: { list: [ { - commerce: {title: '상품', regularPrice: 10000}, + commerce: {title: '상품1', regularPrice: 10000}, imageId: 'img-1', buttons: [ { name: '구매', linkType: 'WL', - linkMobile: 'https://example.com', + linkMobile: 'https://example.com/1', + }, + ], + }, + { + commerce: {title: '상품2', regularPrice: 20000}, + imageId: 'img-2', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://example.com/2', }, ], }, @@ -640,12 +691,22 @@ describe('BMS Option Schema in KakaoOption', () => { expect(result._tag).toBe('Right'); }); - it('should accept BMS with additionalContent', () => { + it('should accept COMMERCE with additionalContent (allowed field)', () => { + // additionalContent는 COMMERCE/CAROUSEL_COMMERCE만 허용 const bmsWithAdditionalContent = { pfId: 'test-pf-id', bms: { targeting: 'I', - chatBubbleType: 'TEXT', + chatBubbleType: 'COMMERCE', + imageId: 'img-1', + commerce: {title: '상품', regularPrice: 10000}, + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://example.com', + }, + ], additionalContent: '추가 내용', }, }; @@ -656,4 +717,416 @@ describe('BMS Option Schema in KakaoOption', () => { expect(result._tag).toBe('Right'); }); }); + + describe('쿠폰 설명 길이 사전 검증', () => { + it('should reject BMS_FREE TEXT with coupon description 13 chars (regression: sdk-testing 1011)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + coupon: { + title: '10000원 할인 쿠폰', + description: '이것은 13자짜리 설명입', + }, + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('쿠폰 설명은 최대 12자 이하로 입력해주세요.'); + }); + + it('should accept TEXT with coupon description of 12 chars (boundary)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + coupon: { + title: '10000원 할인 쿠폰', + description: 'x'.repeat(12), + }, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)(bms); + expect(result._tag).toBe('Right'); + }); + + it('should accept WIDE with coupon description of 18 chars (boundary)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: 'img-1', + coupon: { + title: '배송비 할인 쿠폰', + description: 'x'.repeat(18), + }, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)(bms); + expect(result._tag).toBe('Right'); + }); + + it('should reject WIDE with coupon description of 19 chars', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: 'img-1', + coupon: { + title: '배송비 할인 쿠폰', + description: 'x'.repeat(19), + }, + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('쿠폰 설명은 최대 18자 이하로 입력해주세요.'); + }); + }); + + describe('버튼/텍스트 사전 검증 (통합)', () => { + it('should reject TEXT with 6 buttons', () => { + const buttons = Array.from({length: 6}, (_, i) => ({ + name: `btn${i}`, + linkType: 'WL', + linkMobile: 'https://example.com', + })); + const bms = { + pfId: 'test-pf-id', + bms: {targeting: 'I', chatBubbleType: 'TEXT', buttons}, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('TEXT 타입에서는 최대 5개의 버튼만 사용할 수 있습니다.'); + }); + + it('should reject TEXT with button.name over 14 chars', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + name: 'x'.repeat(15), + linkType: 'WL', + linkMobile: 'https://example.com', + }, + ], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('TEXT 타입 button.name은 최대 14자 이하로 입력해주세요.'); + }); + + it('should reject COMMERCE with BK button (disallowed linkType)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: 'img-commerce', + commerce: {title: '상품', regularPrice: 10000}, + buttons: [{name: '봇', linkType: 'BK'}], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'COMMERCE 타입에서는 WL, AL 타입의 버튼만 사용할 수 있습니다.', + ); + }); + + it('should reject WIDE_ITEM_LIST with header over 20 chars', () => { + // header는 WIDE_ITEM_LIST/PREMIUM_VIDEO만 허용 + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + header: 'x'.repeat(21), + mainWideItem: { + title: '메인', + imageId: 'img-main', + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '서브 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + { + title: '서브 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', + }, + ], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('WIDE_ITEM_LIST 타입 header은 최대 20자 이하로 입력해주세요.'); + }); + + it('should reject CAROUSEL_FEED with single list item (below 2-min without head)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'N', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + header: 'h', + content: 'c', + imageId: 'img-1', + buttons: [ + { + name: 'btn', + linkType: 'WL', + linkMobile: 'https://example.com', + }, + ], + }, + ], + }, + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('캐러셀 리스트는 최소 2개, 최대 6개까지 가능합니다.'); + }); + }); + + describe('줄바꿈/링크/가격/carousel.head 사전 검증 (통합)', () => { + it('should reject PREMIUM_VIDEO with header containing newline', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: {videoUrl: 'https://tv.kakao.com/v/123'}, + header: '제목\n부제', + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'PREMIUM_VIDEO 타입 header은 줄바꿈 최대 0개 까지 가능합니다.', + ); + }); + + it('should reject button with malformed linkMobile', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [{name: 'b', linkType: 'WL', linkMobile: 'not-a-url'}], + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'linkMobile 값이 잘못되었습니다. 올바른 형식은 웹 링크 형식이어야 합니다.', + ); + }); + + it('should reject COMMERCE with regularPrice over 99999999', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: 'img-1', + commerce: {title: '상품', regularPrice: 100_000_000}, + buttons: [ + {name: '구매', linkType: 'WL', linkMobile: 'https://example.com'}, + ], + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'regularPrice 값이 잘못되었습니다. 0 이상 99999999 이하의 숫자여야 합니다.', + ); + }); + + it('should fail validateAcceptableFields before other validators (fail-fast order)', () => { + // TEXT에 header(허용X) + content 1301자(content 길이 초과) + // → validateAcceptableFields가 먼저 실패해야 함 + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + header: '불허 필드', + content: 'x'.repeat(1301), + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'TEXT타입 에서는 adult, content, buttons, coupon 값만 사용이 가능합니다.', + ); + }); + + it('should fail CAROUSEL_FEED carousel list count before button name check', () => { + // 아이템 1개(min:2 위반) + button.name 9자(IMAGE 제외 8자 제한 위반) + // → carousel list count가 먼저 실패해야 함 (재정렬 후) + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + header: 'h', + content: 'c', + imageId: 'img', + buttons: [ + { + name: 'x'.repeat(9), + linkType: 'WL', + linkMobile: 'https://example.com', + }, + ], + }, + ], + }, + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow('캐러셀 리스트는 최소 2개, 최대 6개까지 가능합니다.'); + }); + + it('should reject CAROUSEL_COMMERCE.head with empty-string linkAndroid but no linkMobile (present-but-empty silent pass)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + head: { + header: 'h', + content: 'c', + imageId: 'img-head', + linkAndroid: '', + }, + list: [ + { + commerce: {title: '상품1', regularPrice: 10000}, + imageId: 'img-1', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://example.com/1', + }, + ], + }, + { + commerce: {title: '상품2', regularPrice: 20000}, + imageId: 'img-2', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://example.com/2', + }, + ], + }, + ], + }, + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'linkPc, linkAndroid, linkIos 중 하나라도 있으면 linkMobile 값이 필수입니다.', + ); + }); + + it('should reject AL button with all empty-string links (present-but-empty)', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + name: 'b', + linkType: 'AL', + linkMobile: '', + linkAndroid: '', + linkIos: '', + }, + ], + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'AL 타입 버튼은 linkMobile, linkAndroid, linkIos 중 하나 이상 필수입니다.', + ); + }); + + it('should reject CAROUSEL_COMMERCE.head with linkPc but no linkMobile', () => { + const bms = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + head: { + header: 'h', + content: 'c', + imageId: 'img-head', + linkPc: 'https://example.com/pc', + }, + list: [ + { + commerce: {title: '상품1', regularPrice: 10000}, + imageId: 'img-1', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://example.com/1', + }, + ], + }, + ], + }, + }, + }; + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(bms); + }).toThrow( + 'linkPc, linkAndroid, linkIos 중 하나라도 있으면 linkMobile 값이 필수입니다.', + ); + }); + }); });