diff --git a/src/models/requests/messages/getMessagesRequest.ts b/src/models/requests/messages/getMessagesRequest.ts index 47f53a5..12898ee 100644 --- a/src/models/requests/messages/getMessagesRequest.ts +++ b/src/models/requests/messages/getMessagesRequest.ts @@ -5,6 +5,45 @@ import {messageTypeSchema} from '../../base/messages/message'; export const dateTypeSchema = Schema.Literal('CREATED', 'UPDATED'); export type DateType = Schema.Schema.Type; +export const messageSearchCriteriaSchema = Schema.Literal( + 'messageId', + 'groupId', + 'to', + 'from', + 'type', + 'statusCode', + 'dateCreated', + 'dateUpdated', + 'replacement', + 'coolsmsMessageId', + 'coolsmsGroupId', + 'status', + 'kakaoTemplateId', +); +export type MessageSearchCriteria = Schema.Schema.Type< + typeof messageSearchCriteriaSchema +>; + +export const messageSearchConditionSchema = Schema.Literal( + 'eq', + 'ne', + 'gte', + 'gt', + 'lt', + 'lte', +); +export type MessageSearchCondition = Schema.Schema.Type< + typeof messageSearchConditionSchema +>; + +const oneOrMany = (item: Schema.Schema) => + Schema.Union(item, Schema.Array(item)); + +const toList = ( + input: T | ReadonlyArray | undefined, +): ReadonlyArray => + input == null ? [] : Array.isArray(input) ? input : [input as T]; + const baseGetMessagesRequestSchema = Schema.Struct({ startKey: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number), @@ -18,6 +57,9 @@ const baseGetMessagesRequestSchema = Schema.Struct({ dateType: Schema.optional(dateTypeSchema), startDate: Schema.optional(Schema.Union(Schema.String, Schema.DateFromSelf)), endDate: Schema.optional(Schema.Union(Schema.String, Schema.DateFromSelf)), + criteria: Schema.optional(oneOrMany(messageSearchCriteriaSchema)), + cond: Schema.optional(oneOrMany(messageSearchConditionSchema)), + value: Schema.optional(oneOrMany(Schema.String)), }); export const getMessagesRequestSchema = baseGetMessagesRequestSchema.pipe( @@ -27,6 +69,20 @@ export const getMessagesRequestSchema = baseGetMessagesRequestSchema.pipe( if (hasDateType && !hasDate) { return 'dateType은 startDate 또는 endDate와 함께 사용해야 합니다.'; } + const criteria = toList(data.criteria); + const cond = toList(data.cond); + const value = toList(data.value); + const used = [criteria, cond, value].filter(list => list.length > 0); + if (used.length === 0) return true; + if (used.length < 3) { + return 'criteria, cond, value는 함께 지정해야 합니다.'; + } + if (criteria.length !== cond.length || cond.length !== value.length) { + return 'criteria, cond, value의 개수가 같아야 합니다.'; + } + if (value.some(v => v.includes(','))) { + return 'value에는 쉼표(,)를 포함할 수 없습니다.'; + } return true; }), ); @@ -75,6 +131,9 @@ export type GetMessagesFinalizedPayload = { statusCode?: string; startDate?: string; endDate?: string; + criteria?: string; + cond?: string; + value?: string; }; export function finalizeGetMessagesRequest( @@ -98,6 +157,13 @@ export function finalizeGetMessagesRequest( statusCode: data.statusCode, }; + const criteria = toList(data.criteria); + if (criteria.length > 0) { + payload.criteria = criteria.join(','); + payload.cond = toList(data.cond).join(','); + payload.value = toList(data.value).join(','); + } + if (data.startDate != null) { payload.startDate = formatWithTransfer(data.startDate); } diff --git a/src/models/responses/messageResponses.ts b/src/models/responses/messageResponses.ts index 328da7f..124fe3f 100644 --- a/src/models/responses/messageResponses.ts +++ b/src/models/responses/messageResponses.ts @@ -95,7 +95,7 @@ export type GetGroupsResponse = Schema.Schema.Type< typeof getGroupsResponseSchema >; -const statisticsPeriodResultSchema = Schema.Struct({ +const statisticsPeriodResultFields = { total: Schema.Number, sms: Schema.Number, lms: Schema.Number, @@ -121,7 +121,21 @@ const statisticsPeriodResultSchema = Schema.Struct({ bms_commerce: Schema.optional(Schema.Number), bms_carousel_commerce: Schema.optional(Schema.Number), bms_free: Schema.optional(Schema.Number), +}; +const statisticsPeriodResultSchema = Schema.Struct( + statisticsPeriodResultFields, +); +type StatisticsPeriodResult = Schema.Schema.Type< + typeof statisticsPeriodResultSchema +>; +const emptyStatisticsPeriodResult = (): StatisticsPeriodResult => + Object.fromEntries( + Object.keys(statisticsPeriodResultFields).map(key => [key, 0]), + ) as StatisticsPeriodResult; +const optionalPeriodResult = Schema.optionalWith(statisticsPeriodResultSchema, { + default: emptyStatisticsPeriodResult, }); +const optionalZero = Schema.optionalWith(Schema.Number, {default: () => 0}); const refundSchema = Schema.Struct({ balance: Schema.Number, @@ -170,17 +184,20 @@ const monthPeriodSchema = Schema.Struct({ failed: statisticsPeriodResultSchema, }); +// 통계 문서가 없는 계정·기간은 서버가 빈 객체 {} 를 돌려주므로 모든 필드를 기본값으로 채운다 export const getStatisticsResponseSchema = Schema.Struct({ - balance: Schema.Number, - point: Schema.Number, + balance: optionalZero, + point: optionalZero, deposit: Schema.optional(Schema.Number), - monthlyBalanceAvg: Schema.Number, - monthlyPointAvg: Schema.Number, + monthlyBalanceAvg: optionalZero, + monthlyPointAvg: optionalZero, monthlyDepositAvg: Schema.optional(Schema.Number), - monthPeriod: Schema.Array(monthPeriodSchema), - total: statisticsPeriodResultSchema, - successed: statisticsPeriodResultSchema, - failed: statisticsPeriodResultSchema, + monthPeriod: Schema.optionalWith(Schema.Array(monthPeriodSchema), { + default: () => [], + }), + total: optionalPeriodResult, + successed: optionalPeriodResult, + failed: optionalPeriodResult, dailyBalanceAvg: Schema.optional(Schema.Number), dailyPointAvg: Schema.optional(Schema.Number), dailyTotalCountAvg: Schema.optional(Schema.Number), diff --git a/test/assets/statistics.json b/test/assets/statistics.json new file mode 100644 index 0000000..9d5673f --- /dev/null +++ b/test/assets/statistics.json @@ -0,0 +1,461 @@ +{ + "balance": 0, + "point": 0, + "deposit": 0, + "monthlyBalanceAvg": 0, + "monthlyPointAvg": 0, + "monthlyDepositAvg": 0, + "monthPeriod": [ + { + "date": "2026/11", + "balance": 0, + "balanceAvg": 0, + "point": 0, + "pointAvg": 0, + "deposit": 0, + "depositAvg": 0, + "dayPeriod": [ + { + "_id": "2026-11-04T15:00:00.000Z", + "month": "2026/11", + "date": "2026/11/05", + "balance": 0, + "point": 0, + "deposit": 0, + "statusCode": { + "2011": { + "mms": 1242 + }, + "2061": { + "sms": 1242 + }, + "3058": { + "fax": 1242, + "lms": 1248, + "nsa": 1242, + "rcs_itpl": 1242, + "rcs_lms": 1242, + "rcs_ltpl": 1242, + "rcs_mms": 1242, + "rcs_sms": 1242, + "rcs_tpl": 1242, + "sms": 4189, + "voice": 1242 + }, + "3104": { + "ata": 2486, + "bms_carousel_commerce": 1242, + "bms_carousel_feed": 1242, + "bms_commerce": 1242, + "bms_free": 1249, + "bms_image": 1242, + "bms_premium_video": 1242, + "bms_text": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242 + } + }, + "refund": { + "balance": 0, + "point": 0, + "deposit": 0 + }, + "total": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + }, + "successed": { + "total": 0, + "sms": 0, + "lms": 0, + "mms": 0, + "ata": 0, + "cta": 0, + "cti": 0, + "nsa": 0, + "rcs_sms": 0, + "rcs_lms": 0, + "rcs_mms": 0, + "rcs_tpl": 0, + "rcs_itpl": 0, + "rcs_ltpl": 0, + "fax": 0, + "voice": 0, + "bms_text": 0, + "bms_image": 0, + "bms_wide": 0, + "bms_wide_item_list": 0, + "bms_carousel_feed": 0, + "bms_premium_video": 0, + "bms_commerce": 0, + "bms_carousel_commerce": 0, + "bms_free": 0 + }, + "failed": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + } + } + ], + "refund": { + "balance": 0, + "balanceAvg": 0, + "point": 0, + "pointAvg": 0, + "deposit": 0, + "depositAvg": 0 + }, + "total": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + }, + "successed": { + "total": 0, + "sms": 0, + "lms": 0, + "mms": 0, + "ata": 0, + "cta": 0, + "cti": 0, + "nsa": 0, + "rcs_sms": 0, + "rcs_lms": 0, + "rcs_mms": 0, + "rcs_tpl": 0, + "rcs_itpl": 0, + "rcs_ltpl": 0, + "fax": 0, + "voice": 0, + "bms_text": 0, + "bms_image": 0, + "bms_wide": 0, + "bms_wide_item_list": 0, + "bms_carousel_feed": 0, + "bms_premium_video": 0, + "bms_commerce": 0, + "bms_carousel_commerce": 0, + "bms_free": 0 + }, + "failed": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + } + } + ], + "refund": { + "balance": 0, + "balanceAvg": 0, + "point": 0, + "pointAvg": 0, + "deposit": 0, + "depositAvg": 0 + }, + "dayPeriod": [ + { + "_id": "2026-11-04T15:00:00.000Z", + "month": "2026/11", + "date": "2026/11/05", + "balance": 0, + "point": 0, + "deposit": 0, + "statusCode": { + "2011": { + "mms": 1242 + }, + "2061": { + "sms": 1242 + }, + "3058": { + "fax": 1242, + "lms": 1248, + "nsa": 1242, + "rcs_itpl": 1242, + "rcs_lms": 1242, + "rcs_ltpl": 1242, + "rcs_mms": 1242, + "rcs_sms": 1242, + "rcs_tpl": 1242, + "sms": 4189, + "voice": 1242 + }, + "3104": { + "ata": 2486, + "bms_carousel_commerce": 1242, + "bms_carousel_feed": 1242, + "bms_commerce": 1242, + "bms_free": 1249, + "bms_image": 1242, + "bms_premium_video": 1242, + "bms_text": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242 + } + }, + "refund": { + "balance": 0, + "point": 0, + "deposit": 0 + }, + "total": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + }, + "successed": { + "total": 0, + "sms": 0, + "lms": 0, + "mms": 0, + "ata": 0, + "cta": 0, + "cti": 0, + "nsa": 0, + "rcs_sms": 0, + "rcs_lms": 0, + "rcs_mms": 0, + "rcs_tpl": 0, + "rcs_itpl": 0, + "rcs_ltpl": 0, + "fax": 0, + "voice": 0, + "bms_text": 0, + "bms_image": 0, + "bms_wide": 0, + "bms_wide_item_list": 0, + "bms_carousel_feed": 0, + "bms_premium_video": 0, + "bms_commerce": 0, + "bms_carousel_commerce": 0, + "bms_free": 0 + }, + "failed": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + } + } + ], + "total": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + }, + "successed": { + "total": 0, + "sms": 0, + "lms": 0, + "mms": 0, + "ata": 0, + "cta": 0, + "cti": 0, + "nsa": 0, + "rcs_sms": 0, + "rcs_lms": 0, + "rcs_mms": 0, + "rcs_tpl": 0, + "rcs_itpl": 0, + "rcs_ltpl": 0, + "fax": 0, + "voice": 0, + "bms_text": 0, + "bms_image": 0, + "bms_wide": 0, + "bms_wide_item_list": 0, + "bms_carousel_feed": 0, + "bms_premium_video": 0, + "bms_commerce": 0, + "bms_carousel_commerce": 0, + "bms_free": 0 + }, + "failed": { + "total": 32770, + "sms": 5431, + "lms": 1248, + "mms": 1242, + "ata": 2486, + "cta": 0, + "cti": 0, + "nsa": 1242, + "rcs_sms": 1242, + "rcs_lms": 1242, + "rcs_mms": 1242, + "rcs_tpl": 1242, + "rcs_itpl": 1242, + "rcs_ltpl": 1242, + "fax": 1242, + "voice": 1242, + "bms_text": 1242, + "bms_image": 1242, + "bms_wide": 1242, + "bms_wide_item_list": 1242, + "bms_carousel_feed": 1242, + "bms_premium_video": 1242, + "bms_commerce": 1242, + "bms_carousel_commerce": 1242, + "bms_free": 1249 + }, + "dailyBalanceAvg": 0, + "dailyPointAvg": 0, + "dailyDepositAvg": 0, + "dailyTotalCountAvg": 32770, + "dailyFailedCountAvg": 32770, + "dailySuccessedCountAvg": 0 +} diff --git a/test/models/requests/messages/getMessagesRequest.test.ts b/test/models/requests/messages/getMessagesRequest.test.ts index 5e6dab8..edcb222 100644 --- a/test/models/requests/messages/getMessagesRequest.test.ts +++ b/test/models/requests/messages/getMessagesRequest.test.ts @@ -1,4 +1,4 @@ -import {Schema} from 'effect'; +import {Either, Schema} from 'effect'; import {describe, expect, it} from 'vitest'; import {InvalidDateError} from '@/errors/defaultError'; import { @@ -108,3 +108,79 @@ describe('finalizeGetMessagesRequest', () => { expect(result.startKey).toBe('key123'); }); }); + +describe('getMessagesRequestSchema advanced search (#183)', () => { + const decode = Schema.decodeUnknownEither(getMessagesRequestSchema); + + it('should accept matching criteria, cond and value arrays', () => { + const result = decode({ + criteria: ['type', 'statusCode'], + cond: ['eq', 'eq'], + value: ['SMS', '2000'], + }); + expect(Either.isRight(result)).toBe(true); + }); + + it('should accept a single string for each field', () => { + const result = decode({criteria: 'to', cond: 'eq', value: '01000000000'}); + expect(Either.isRight(result)).toBe(true); + }); + + const rejected: Array<[unknown, string]> = [ + [{criteria: ['type'], cond: ['eq']}, 'value 누락'], + [{criteria: ['type'], cond: ['eq', 'ne'], value: ['SMS']}, '개수 불일치'], + [ + {criteria: ['type'], cond: ['like'], value: ['SMS']}, + '허용되지 않는 cond', + ], + [ + {criteria: ['text'], cond: ['eq'], value: ['hi']}, + '허용되지 않는 criteria', + ], + [{criteria: ['to'], cond: ['eq'], value: ['010,020']}, 'value 에 쉼표'], + ]; + it.each(rejected)('should reject %j (%s)', input => { + expect(Either.isLeft(decode(input))).toBe(true); + }); + + it('should accept an empty criteria set', () => { + expect(Either.isRight(decode({criteria: [], cond: [], value: []}))).toBe( + true, + ); + }); +}); + +describe('finalizeGetMessagesRequest advanced search (#183)', () => { + it('should join criteria, cond and value with commas', () => { + const payload = finalizeGetMessagesRequest({ + limit: 5, + criteria: ['type', 'dateCreated'], + cond: ['eq', 'gte'], + value: ['SMS', '2026-11-05'], + }); + expect(payload.criteria).toBe('type,dateCreated'); + expect(payload.cond).toBe('eq,gte'); + expect(payload.value).toBe('SMS,2026-11-05'); + expect(payload.limit).toBe(5); + }); + + it('should pass single strings through unchanged', () => { + const payload = finalizeGetMessagesRequest({ + criteria: 'groupId', + cond: 'eq', + value: 'G4V0', + }); + expect(payload).toMatchObject({ + criteria: 'groupId', + cond: 'eq', + value: 'G4V0', + }); + }); + + it('should omit search keys when criteria is empty', () => { + const payload = finalizeGetMessagesRequest({limit: 1}); + expect(payload).not.toHaveProperty('criteria'); + expect(payload).not.toHaveProperty('cond'); + expect(payload).not.toHaveProperty('value'); + }); +}); diff --git a/test/models/responses/getStatisticsResponse.test.ts b/test/models/responses/getStatisticsResponse.test.ts new file mode 100644 index 0000000..7a273ac --- /dev/null +++ b/test/models/responses/getStatisticsResponse.test.ts @@ -0,0 +1,56 @@ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {Either, Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; +import {getStatisticsResponseSchema} from '@/models/responses/messageResponses'; + +const fixture = JSON.parse( + readFileSync(join(__dirname, '../../assets/statistics.json'), 'utf-8'), +); + +describe('getStatisticsResponseSchema', () => { + it('should decode a full statistics response unchanged', () => { + const result = Schema.decodeUnknownSync(getStatisticsResponseSchema)( + fixture, + ); + + expect(result.balance).toBe(fixture.balance); + expect(result.monthPeriod).toHaveLength(fixture.monthPeriod.length); + expect(result.total.total).toBe(fixture.total.total); + expect(result.failed.sms).toBe(fixture.failed.sms); + }); + + it('should fill defaults when the server returns an empty object (#182)', () => { + const result = Schema.decodeUnknownSync(getStatisticsResponseSchema)({}); + + expect(result.balance).toBe(0); + expect(result.point).toBe(0); + expect(result.monthlyBalanceAvg).toBe(0); + expect(result.monthlyPointAvg).toBe(0); + expect(result.monthPeriod).toEqual([]); + expect(result.total.total).toBe(0); + expect(result.successed.sms).toBe(0); + expect(result.failed.bms_free).toBe(0); + expect(result.deposit).toBeUndefined(); + }); + + it('should keep provided values when only some fields are present', () => { + const result = Schema.decodeUnknownSync(getStatisticsResponseSchema)({ + balance: 1200, + point: 30, + }); + + expect(result.balance).toBe(1200); + expect(result.point).toBe(30); + expect(result.monthPeriod).toEqual([]); + expect(result.total.total).toBe(0); + }); + + it('should still reject wrong field types', () => { + const result = Schema.decodeUnknownEither(getStatisticsResponseSchema)({ + balance: 'many', + }); + + expect(Either.isLeft(result)).toBe(true); + }); +});