Skip to content

Commit dc1d572

Browse files
feat(bms): Implement validation for WIDE_ITEM_LIST and enhance commerce pricing rules
- Added validation to ensure WIDE_ITEM_LIST type has a minimum of 3 sub items. - Introduced a new function to validate pricing combinations for BMS commerce, enforcing rules on the use of regularPrice, discountPrice, discountRate, and discountFixed. - Updated test cases to cover new validation scenarios for both WIDE_ITEM_LIST and commerce pricing combinations, ensuring robust error handling and compliance with business rules. These changes improve the integrity of BMS message types and enhance the overall user experience by providing clearer validation feedback.
1 parent e1d9d2e commit dc1d572

5 files changed

Lines changed: 226 additions & 30 deletions

File tree

src/models/base/kakao/bms/bmsCommerce.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,78 @@ const NumberOrNumericString: Schema.Schema<number, number> =
5050
},
5151
) as Schema.Schema<number, number>;
5252

53+
/**
54+
* BMS 커머스 가격 조합 검증
55+
*
56+
* 카카오 BMS 커머스 타입은 다음 가격 조합만 허용합니다:
57+
* 1. regularPrice만 사용 (정가만 표기)
58+
* 2. regularPrice + discountPrice + discountRate (할인율 표기)
59+
* 3. regularPrice + discountPrice + discountFixed (정액 할인 표기)
60+
*
61+
* discountRate와 discountFixed를 동시에 사용하거나,
62+
* discountPrice 없이 discountRate/discountFixed만 사용하는 것은 허용되지 않습니다.
63+
*/
64+
const validateCommercePricingCombination = (commerce: {
65+
regularPrice: number;
66+
discountPrice?: number;
67+
discountRate?: number;
68+
discountFixed?: number;
69+
}): boolean | string => {
70+
const hasDiscountPrice = commerce.discountPrice !== undefined;
71+
const hasDiscountRate = commerce.discountRate !== undefined;
72+
const hasDiscountFixed = commerce.discountFixed !== undefined;
73+
74+
// Case 1: regularPrice만 사용 (정가만 표기) - valid
75+
if (!hasDiscountPrice && !hasDiscountRate && !hasDiscountFixed) {
76+
return true;
77+
}
78+
79+
// Case 2: regularPrice + discountPrice + discountRate (할인율 표기) - valid
80+
if (hasDiscountPrice && hasDiscountRate && !hasDiscountFixed) {
81+
return true;
82+
}
83+
84+
// Case 3: regularPrice + discountPrice + discountFixed (정액 할인 표기) - valid
85+
if (hasDiscountPrice && hasDiscountFixed && !hasDiscountRate) {
86+
return true;
87+
}
88+
89+
// Invalid combinations
90+
if (hasDiscountRate && hasDiscountFixed) {
91+
return 'discountRate와 discountFixed는 동시에 사용할 수 없습니다. 할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요.';
92+
}
93+
94+
if (!hasDiscountPrice && (hasDiscountRate || hasDiscountFixed)) {
95+
return 'discountRate 또는 discountFixed를 사용하려면 discountPrice(할인가)도 함께 지정해야 합니다.';
96+
}
97+
98+
// discountPrice만 있는 경우 (discountRate/discountFixed 없음)
99+
if (hasDiscountPrice && !hasDiscountRate && !hasDiscountFixed) {
100+
return 'discountPrice를 사용하려면 discountRate(할인율) 또는 discountFixed(정액할인) 중 하나를 함께 지정해야 합니다.';
101+
}
102+
103+
return '알 수 없는 가격 조합입니다. regularPrice만 사용하거나, regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요.';
104+
};
105+
53106
/**
54107
* BMS 커머스 정보 스키마
55108
* - title: 상품명 (필수)
56109
* - regularPrice: 정가 (필수, 숫자 또는 숫자형 문자열)
57110
* - discountPrice: 할인가 (선택, 숫자 또는 숫자형 문자열)
58111
* - discountRate: 할인율 (선택, 숫자 또는 숫자형 문자열)
59112
* - discountFixed: 고정 할인금액 (선택, 숫자 또는 숫자형 문자열)
113+
*
114+
* 가격 조합 규칙:
115+
* - regularPrice만 사용 (정가만 표기)
116+
* - regularPrice + discountPrice + discountRate (할인율 표기)
117+
* - regularPrice + discountPrice + discountFixed (정액 할인 표기)
60118
*/
61119
export const bmsCommerceSchema = Schema.Struct({
62120
title: Schema.String,
63121
regularPrice: NumberOrNumericString,
64122
discountPrice: Schema.optional(NumberOrNumericString),
65123
discountRate: Schema.optional(NumberOrNumericString),
66124
discountFixed: Schema.optional(NumberOrNumericString),
67-
});
125+
}).pipe(Schema.filter(validateCommercePricingCombination));
68126

69127
export type BmsCommerceSchema = Schema.Schema.Type<typeof bmsCommerceSchema>;

src/models/base/kakao/kakaoOption.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,8 @@ const baseBmsSchema = Schema.Struct({
9797

9898
type BaseBmsSchemaType = Schema.Schema.Type<typeof baseBmsSchema>;
9999

100-
/**
101-
* chatBubbleType별 필수 필드 검증 및 에러 메시지 반환
102-
* - 검증 통과 시: true 반환
103-
* - 검증 실패 시: 에러 메시지 문자열 반환
104-
*/
100+
const WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3;
101+
105102
const validateBmsRequiredFields = (
106103
bms: BaseBmsSchemaType,
107104
): boolean | string => {
@@ -116,6 +113,16 @@ const validateBmsRequiredFields = (
116113
return `BMS ${chatBubbleType} 타입에 필수 필드가 누락되었습니다: ${missingFields.join(', ')}`;
117114
}
118115

116+
if (chatBubbleType === 'WIDE_ITEM_LIST') {
117+
const subWideItemList = bms.subWideItemList;
118+
if (
119+
!subWideItemList ||
120+
subWideItemList.length < WIDE_ITEM_LIST_MIN_SUB_ITEMS
121+
) {
122+
return `WIDE_ITEM_LIST 타입의 subWideItemList는 최소 ${WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. 현재: ${subWideItemList?.length ?? 0}개`;
123+
}
124+
}
125+
119126
return true;
120127
};
121128

test/models/base/kakao/bms/bmsCommerce.test.ts

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,42 +67,36 @@ describe('BMS Commerce Schema', () => {
6767
});
6868

6969
describe('선택적 숫자 필드 검증', () => {
70-
it('should accept mixed number and string for optional fields', () => {
70+
it('should accept mixed number and string for discountRate combination', () => {
7171
const valid = {
7272
title: '상품명',
7373
regularPrice: 10000,
7474
discountPrice: '8000',
7575
discountRate: 20,
76-
discountFixed: '2000',
7776
};
7877
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
7978
expect(result._tag).toBe('Right');
8079
if (result._tag === 'Right') {
8180
expect(result.right.discountPrice).toBe(8000);
8281
expect(result.right.discountRate).toBe(20);
83-
expect(result.right.discountFixed).toBe(2000);
8482
}
8583
});
8684

87-
it('should accept all string values for numeric fields', () => {
85+
it('should accept all string values for discountFixed combination', () => {
8886
const valid = {
8987
title: '상품명',
9088
regularPrice: '15000',
9189
discountPrice: '12000',
92-
discountRate: '20',
9390
discountFixed: '3000',
9491
};
9592
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
9693
expect(result._tag).toBe('Right');
9794
if (result._tag === 'Right') {
9895
expect(result.right.regularPrice).toBe(15000);
9996
expect(result.right.discountPrice).toBe(12000);
100-
expect(result.right.discountRate).toBe(20);
10197
expect(result.right.discountFixed).toBe(3000);
102-
// 모든 필드가 number 타입으로 변환되었는지 확인
10398
expect(typeof result.right.regularPrice).toBe('number');
10499
expect(typeof result.right.discountPrice).toBe('number');
105-
expect(typeof result.right.discountRate).toBe('number');
106100
expect(typeof result.right.discountFixed).toBe('number');
107101
}
108102
});
@@ -151,39 +145,111 @@ describe('BMS Commerce Schema', () => {
151145
});
152146

153147
describe('실제 사용 사례 테스트', () => {
154-
it('should handle CAROUSEL_COMMERCE style input (string prices)', () => {
155-
// debug/bms_free/hosy_test.js의 CAROUSEL_COMMERCE 템플릿과 동일한 구조
148+
it('should handle CAROUSEL_COMMERCE style input (string prices with discountRate)', () => {
156149
const valid = {
157150
title: '상품명2',
158151
regularPrice: '10000',
159-
discountPrice: '50',
152+
discountPrice: '5000',
160153
discountRate: '50',
161154
};
162155
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
163156
expect(result._tag).toBe('Right');
164157
if (result._tag === 'Right') {
165158
expect(result.right.regularPrice).toBe(10000);
166-
expect(result.right.discountPrice).toBe(50);
159+
expect(result.right.discountPrice).toBe(5000);
167160
expect(result.right.discountRate).toBe(50);
168161
}
169162
});
170163

171-
it('should handle COMMERCE style input (mixed types)', () => {
164+
it('should handle COMMERCE style input with discountFixed', () => {
172165
const valid = {
173166
title: '상품명',
174167
regularPrice: 1000,
175168
discountPrice: '800',
176-
discountRate: 20,
177169
discountFixed: '200',
178170
};
179171
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
180172
expect(result._tag).toBe('Right');
181173
if (result._tag === 'Right') {
182174
expect(result.right.regularPrice).toBe(1000);
183175
expect(result.right.discountPrice).toBe(800);
184-
expect(result.right.discountRate).toBe(20);
185176
expect(result.right.discountFixed).toBe(200);
186177
}
187178
});
188179
});
180+
181+
describe('가격 조합 검증', () => {
182+
it('should accept regularPrice only (정가만 표기)', () => {
183+
const valid = {
184+
title: '상품명',
185+
regularPrice: 10000,
186+
};
187+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
188+
expect(result._tag).toBe('Right');
189+
});
190+
191+
it('should accept regularPrice + discountPrice + discountRate (할인율 표기)', () => {
192+
const valid = {
193+
title: '상품명',
194+
regularPrice: 10000,
195+
discountPrice: 8000,
196+
discountRate: 20,
197+
};
198+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
199+
expect(result._tag).toBe('Right');
200+
});
201+
202+
it('should accept regularPrice + discountPrice + discountFixed (정액 할인 표기)', () => {
203+
const valid = {
204+
title: '상품명',
205+
regularPrice: 10000,
206+
discountPrice: 8000,
207+
discountFixed: 2000,
208+
};
209+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid);
210+
expect(result._tag).toBe('Right');
211+
});
212+
213+
it('should reject discountRate and discountFixed together', () => {
214+
const invalid = {
215+
title: '상품명',
216+
regularPrice: 10000,
217+
discountPrice: 8000,
218+
discountRate: 20,
219+
discountFixed: 2000,
220+
};
221+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid);
222+
expect(result._tag).toBe('Left');
223+
});
224+
225+
it('should reject discountRate without discountPrice', () => {
226+
const invalid = {
227+
title: '상품명',
228+
regularPrice: 10000,
229+
discountRate: 20,
230+
};
231+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid);
232+
expect(result._tag).toBe('Left');
233+
});
234+
235+
it('should reject discountFixed without discountPrice', () => {
236+
const invalid = {
237+
title: '상품명',
238+
regularPrice: 10000,
239+
discountFixed: 2000,
240+
};
241+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid);
242+
expect(result._tag).toBe('Left');
243+
});
244+
245+
it('should reject discountPrice without discountRate or discountFixed', () => {
246+
const invalid = {
247+
title: '상품명',
248+
regularPrice: 10000,
249+
discountPrice: 8000,
250+
};
251+
const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid);
252+
expect(result._tag).toBe('Left');
253+
});
254+
});
189255
});

test/models/base/kakao/bms/bmsOption.test.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('BMS Option Schema in KakaoOption', () => {
9595
}).toThrow('BMS WIDE 타입에 필수 필드가 누락되었습니다: imageId');
9696
});
9797

98-
it('should accept valid BMS_WIDE_ITEM_LIST message', () => {
98+
it('should accept valid BMS_WIDE_ITEM_LIST message with 3 sub items (minimum)', () => {
9999
const validBmsWideItemList = {
100100
pfId: 'test-pf-id',
101101
bms: {
@@ -118,6 +118,11 @@ describe('BMS Option Schema in KakaoOption', () => {
118118
imageId: 'img-sub-2',
119119
linkMobile: 'https://example.com/sub2',
120120
},
121+
{
122+
title: '서브 아이템 3',
123+
imageId: 'img-sub-3',
124+
linkMobile: 'https://example.com/sub3',
125+
},
121126
],
122127
},
123128
};
@@ -137,9 +142,19 @@ describe('BMS Option Schema in KakaoOption', () => {
137142
header: '헤더 제목',
138143
subWideItemList: [
139144
{
140-
title: '서브 아이템',
141-
imageId: 'img-sub',
142-
linkMobile: 'https://example.com/sub',
145+
title: '서브 아이템 1',
146+
imageId: 'img-sub-1',
147+
linkMobile: 'https://example.com/sub1',
148+
},
149+
{
150+
title: '서브 아이템 2',
151+
imageId: 'img-sub-2',
152+
linkMobile: 'https://example.com/sub2',
153+
},
154+
{
155+
title: '서브 아이템 3',
156+
imageId: 'img-sub-3',
157+
linkMobile: 'https://example.com/sub3',
143158
},
144159
],
145160
},
@@ -150,6 +165,40 @@ describe('BMS Option Schema in KakaoOption', () => {
150165
}).toThrow('BMS WIDE_ITEM_LIST 타입에 필수 필드가 누락되었습니다');
151166
});
152167

168+
it('should reject BMS_WIDE_ITEM_LIST with less than 3 sub items', () => {
169+
const invalidBmsWideItemList = {
170+
pfId: 'test-pf-id',
171+
bms: {
172+
targeting: 'M',
173+
chatBubbleType: 'WIDE_ITEM_LIST',
174+
header: '헤더 제목',
175+
mainWideItem: {
176+
title: '메인 아이템',
177+
imageId: 'img-main',
178+
linkMobile: 'https://example.com/main',
179+
},
180+
subWideItemList: [
181+
{
182+
title: '서브 아이템 1',
183+
imageId: 'img-sub-1',
184+
linkMobile: 'https://example.com/sub1',
185+
},
186+
{
187+
title: '서브 아이템 2',
188+
imageId: 'img-sub-2',
189+
linkMobile: 'https://example.com/sub2',
190+
},
191+
],
192+
},
193+
};
194+
195+
expect(() => {
196+
Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsWideItemList);
197+
}).toThrow(
198+
'WIDE_ITEM_LIST 타입의 subWideItemList는 최소 3개 이상이어야 합니다',
199+
);
200+
});
201+
153202
it('should accept valid BMS_COMMERCE message', () => {
154203
const validBmsCommerce = {
155204
pfId: 'test-pf-id',
@@ -161,6 +210,7 @@ describe('BMS Option Schema in KakaoOption', () => {
161210
title: '상품명',
162211
regularPrice: 10000,
163212
discountPrice: 8000,
213+
discountRate: 20,
164214
},
165215
buttons: [
166216
{
@@ -422,9 +472,19 @@ describe('BMS Option Schema in KakaoOption', () => {
422472
},
423473
subWideItemList: [
424474
{
425-
title: '서브',
426-
imageId: 'img-sub',
427-
linkMobile: 'https://example.com/sub',
475+
title: '서브 1',
476+
imageId: 'img-sub-1',
477+
linkMobile: 'https://example.com/sub1',
478+
},
479+
{
480+
title: '서브 2',
481+
imageId: 'img-sub-2',
482+
linkMobile: 'https://example.com/sub2',
483+
},
484+
{
485+
title: '서브 3',
486+
imageId: 'img-sub-3',
487+
linkMobile: 'https://example.com/sub3',
428488
},
429489
],
430490
};

0 commit comments

Comments
 (0)