diff --git a/src/models/responses/sendManyDetailResponse.ts b/src/models/responses/sendManyDetailResponse.ts index 7ed0ce98..b75bfbce 100644 --- a/src/models/responses/sendManyDetailResponse.ts +++ b/src/models/responses/sendManyDetailResponse.ts @@ -56,3 +56,32 @@ export const detailGroupMessageResponseSchema = Schema.Struct({ export type DetailGroupMessageResponse = Schema.Schema.Type< typeof detailGroupMessageResponseSchema >; + +// 고속접수(send-many/fast) 응답은 폐기된 친구톡(cta/cti) 과금 필드를 내려주지 않으므로 +// 기존 send-many/detail 응답 계약 유지를 위해 빈 값으로 채운다. +export const withFastSendDefaults = ( + response: DetailGroupMessageResponse, +): DetailGroupMessageResponse => { + const {groupInfo} = response; + const {countForCharge, app} = groupInfo; + return { + ...response, + groupInfo: { + ...groupInfo, + isRefunded: groupInfo.isRefunded ?? false, + countForCharge: { + ...countForCharge, + cta: countForCharge.cta ?? {}, + cti: countForCharge.cti ?? {}, + }, + app: { + ...app, + profit: { + ...app.profit, + cta: app.profit.cta ?? 0, + cti: app.profit.cti ?? 0, + }, + }, + }, + }; +}; diff --git a/src/services/messages/groupService.ts b/src/services/messages/groupService.ts index 455bd38f..c57e5fb8 100644 --- a/src/services/messages/groupService.ts +++ b/src/services/messages/groupService.ts @@ -161,13 +161,14 @@ export default class GroupService extends DefaultService { /** * 단일 그룹정보 조회 + * 고속접수 조회 경로는 접수 직후에도 그룹을 반환하며, 일반 그룹도 동일한 형식으로 조회된다. * @param groupId 그룹 ID */ async getGroup(groupId: GroupId): Promise { return runSafePromise( this.requestEffect({ httpMethod: 'GET', - url: `messages/v4/groups/${groupId}`, + url: `messages/v4/groups/fast/${groupId}`, responseSchema: groupMessageResponseSchema, }), ); diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index a874eaec..82a7c3af 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -26,7 +26,10 @@ import { getMessagesResponseSchema, getStatisticsResponseSchema, } from '@models/responses/messageResponses'; -import {DetailGroupMessageResponse} from '@models/responses/sendManyDetailResponse'; +import { + type DetailGroupMessageResponse, + withFastSendDefaults, +} from '@models/responses/sendManyDetailResponse'; import * as Effect from 'effect/Effect'; import { BadRequestError, @@ -84,14 +87,16 @@ export default class MessageService extends DefaultService { parameterObject, ); - const response = yield* reqEffect< - MultipleMessageSendingRequestSchema, - DetailGroupMessageResponse - >({ - httpMethod: 'POST', - url: 'messages/v4/send-many/detail', - body: parameter, - }); + const response = withFastSendDefaults( + yield* reqEffect< + MultipleMessageSendingRequestSchema, + DetailGroupMessageResponse + >({ + httpMethod: 'POST', + url: 'messages/v4/send-many/fast', + body: parameter, + }), + ); const {count} = response.groupInfo; const failedAll = diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index c5928137..872a66af 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -18,13 +18,14 @@ const countryChargeStatusSchema = Schema.Record({ value: Schema.Number, }); +// 고속접수(v4-fast) 그룹은 폐기된 친구톡(cta/cti) 키 없이 저장되므로 조회 응답에서 기본값으로 채운다. export const countForChargeSchema = Schema.Struct({ sms: countryChargeStatusSchema, lms: countryChargeStatusSchema, mms: countryChargeStatusSchema, ata: countryChargeStatusSchema, - cta: countryChargeStatusSchema, - cti: countryChargeStatusSchema, + cta: Schema.optionalWith(countryChargeStatusSchema, {default: () => ({})}), + cti: Schema.optionalWith(countryChargeStatusSchema, {default: () => ({})}), nsa: countryChargeStatusSchema, rcs_sms: countryChargeStatusSchema, rcs_lms: countryChargeStatusSchema, @@ -56,7 +57,7 @@ export type CommonCashResponse = Schema.Schema.Type< typeof commonCashResponseSchema >; -export const messageTypeRecordSchema = Schema.Struct({ +const messageTypeRecordFields = { sms: Schema.Number, lms: Schema.Number, mms: Schema.Number, @@ -81,6 +82,11 @@ export const messageTypeRecordSchema = Schema.Struct({ bms_commerce: Schema.optional(Schema.Number), bms_carousel_commerce: Schema.optional(Schema.Number), bms_free: Schema.optional(Schema.Number), +}; +export const messageTypeRecordSchema = Schema.Struct({ + ...messageTypeRecordFields, + cta: Schema.optionalWith(Schema.Number, {default: () => 0}), + cti: Schema.optionalWith(Schema.Number, {default: () => 0}), }); export type MessageTypeRecord = Schema.Schema.Type< typeof messageTypeRecordSchema @@ -92,7 +98,7 @@ export type MessageTypeRecord = Schema.Schema.Type< * 모든 키를 optional로 풀어 서버가 한두 필드만 내려줘도 decode가 통과하도록 한다. */ export const partialMessageTypeRecordSchema = Schema.partial( - messageTypeRecordSchema, + Schema.Struct(messageTypeRecordFields), ); export type PartialMessageTypeRecord = Schema.Schema.Type< typeof partialMessageTypeRecordSchema diff --git a/test/assets/fastGroup.json b/test/assets/fastGroup.json new file mode 100644 index 00000000..33729412 --- /dev/null +++ b/test/assets/fastGroup.json @@ -0,0 +1,146 @@ +{ + "deposit": { + "requested": 0, + "replacement": 0, + "additional": 0, + "refund": 0, + "sum": 0 + }, + "isRefunded": false, + "workspaceId": null, + "_id": "G4V20260101000000TESTGROUP00000", + "groupId": "G4V20260101000000TESTGROUP00000", + "serviceMethod": "MT", + "accountId": "12345678901234", + "apiVersion": "v4-fast", + "sdkVersion": "nodejs/probe", + "osPlatform": "probe", + "count": { + "total": 1, + "sentTotal": 0, + "sentFailed": 0, + "sentSuccess": 0, + "sentPending": 0, + "sentReplacement": 0, + "refund": 0, + "registeredFailed": 0, + "registeredSuccess": 1 + }, + "countForCharge": { + "sms": { + "82": 1 + }, + "lms": {}, + "mms": {}, + "ata": {}, + "nsa": {}, + "rcs_sms": {}, + "rcs_lms": {}, + "rcs_mms": {}, + "rcs_tpl": {}, + "rcs_itpl": {}, + "rcs_ltpl": {}, + "fax": {}, + "voice": {}, + "bms_text": {}, + "bms_image": {}, + "bms_wide": {}, + "bms_wide_item_list": {}, + "bms_carousel_feed": {}, + "bms_premium_video": {}, + "bms_commerce": {}, + "bms_carousel_commerce": {}, + "bms_free": {} + }, + "log": [ + { + "createAt": "2026-01-01T00:00:00.000Z", + "message": "메시지 그룹이 생성되었습니다." + } + ], + "status": "SEND-REQUESTED", + "dateSent": "2026-01-01T00:00:00.000Z", + "scheduledDate": null, + "dateCompleted": null, + "balance": { + "requested": 0, + "replacement": 0, + "additional": 0, + "refund": 0, + "sum": 0 + }, + "point": { + "requested": 13, + "replacement": 0, + "additional": 0, + "refund": 0, + "sum": 13 + }, + "clusterKey": null, + "app": { + "profit": { + "sms": 0, + "lms": 0, + "mms": 0, + "ata": 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 + }, + "appId": null, + "version": null + }, + "price": { + "82": { + "sms": 13, + "lms": 29, + "mms": 60, + "ata": 8, + "cta": 14, + "cti": 22, + "nsa": 6, + "rcs_sms": 11, + "rcs_lms": 28, + "rcs_mms": 58, + "rcs_tpl": 8, + "bms_free": 100, + "bms_carousel_commerce": 103, + "bms_carousel_feed": 103, + "bms_commerce": 103, + "bms_image": 83, + "bms_premium_video": 103, + "bms_text": 53, + "bms_wide": 88, + "bms_wide_item_list": 103, + "fax": 100, + "rcs_itpl": 80, + "rcs_ltpl": 32, + "voice": 200 + } + }, + "flagUpdated": false, + "customFields": {}, + "prepaid": true, + "strict": false, + "hint": {}, + "masterAccountId": null, + "allowDuplicates": false, + "originCode": null, + "dateCreated": "2026-01-01T00:00:00.000Z", + "dateUpdated": "2026-01-01T00:00:00.000Z" +} diff --git a/test/assets/fastSendResponse.json b/test/assets/fastSendResponse.json new file mode 100644 index 00000000..c666b7d0 --- /dev/null +++ b/test/assets/fastSendResponse.json @@ -0,0 +1,157 @@ +{ + "messageList": [ + { + "messageId": "M4V20260101000000TESTMESSAGE000", + "statusCode": "2000", + "statusMessage": "정상 접수(이통사로 접수 예정) ", + "customFields": {} + } + ], + "failedMessageList": [], + "groupInfo": { + "deposit": { + "requested": 0, + "replacement": 0, + "additional": 0, + "refund": 0, + "sum": 0 + }, + "isRefunded": false, + "workspaceId": null, + "_id": "G4V20260101000000TESTGROUP00000", + "groupId": "G4V20260101000000TESTGROUP00000", + "serviceMethod": "MT", + "accountId": "12345678901234", + "apiVersion": "v4-fast", + "sdkVersion": "nodejs/probe", + "osPlatform": "probe", + "count": { + "total": 1, + "sentTotal": 0, + "sentFailed": 0, + "sentSuccess": 0, + "sentPending": 0, + "sentReplacement": 0, + "refund": 0, + "registeredFailed": 0, + "registeredSuccess": 1 + }, + "countForCharge": { + "sms": { + "82": 1 + }, + "lms": {}, + "mms": {}, + "ata": {}, + "nsa": {}, + "rcs_sms": {}, + "rcs_lms": {}, + "rcs_mms": {}, + "rcs_tpl": {}, + "rcs_itpl": {}, + "rcs_ltpl": {}, + "fax": {}, + "voice": {}, + "bms_text": {}, + "bms_image": {}, + "bms_wide": {}, + "bms_wide_item_list": {}, + "bms_carousel_feed": {}, + "bms_premium_video": {}, + "bms_commerce": {}, + "bms_carousel_commerce": {}, + "bms_free": {} + }, + "log": [ + { + "createAt": "2026-01-01T00:00:00.000Z", + "message": "메시지 그룹이 생성되었습니다." + } + ], + "status": "SEND-REQUESTED", + "dateSent": "2026-01-01T00:00:00.000Z", + "scheduledDate": null, + "dateCompleted": null, + "balance": { + "requested": 0, + "replacement": 0, + "additional": 0, + "refund": 0, + "sum": 0 + }, + "point": { + "requested": 13, + "replacement": 0, + "additional": 0, + "refund": 0, + "sum": 13 + }, + "clusterKey": null, + "app": { + "profit": { + "sms": 0, + "lms": 0, + "mms": 0, + "ata": 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 + }, + "appId": null, + "version": null + }, + "price": { + "82": { + "sms": 13, + "lms": 29, + "mms": 60, + "ata": 8, + "cta": 14, + "cti": 22, + "nsa": 6, + "rcs_sms": 11, + "rcs_lms": 28, + "rcs_mms": 58, + "rcs_tpl": 8, + "bms_free": 100, + "bms_carousel_commerce": 103, + "bms_carousel_feed": 103, + "bms_commerce": 103, + "bms_image": 83, + "bms_premium_video": 103, + "bms_text": 53, + "bms_wide": 88, + "bms_wide_item_list": 103, + "fax": 100, + "rcs_itpl": 80, + "rcs_ltpl": 32, + "voice": 200 + } + }, + "flagUpdated": false, + "customFields": {}, + "prepaid": true, + "strict": false, + "hint": {}, + "masterAccountId": null, + "allowDuplicates": false, + "originCode": null, + "dateCreated": "2026-01-01T00:00:00.000Z", + "dateUpdated": "2026-01-01T00:00:00.000Z" + } +} diff --git a/test/services/messages/groupService.fast.test.ts b/test/services/messages/groupService.fast.test.ts new file mode 100644 index 00000000..5df7148a --- /dev/null +++ b/test/services/messages/groupService.fast.test.ts @@ -0,0 +1,87 @@ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import GroupService from '@/services/messages/groupService'; + +const fixturePath = join(__dirname, '../../assets/fastGroup.json'); +const loadGroup = (): Record => + JSON.parse(readFileSync(fixturePath, 'utf-8')); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }); + +describe('GroupService fast intake group decoding', () => { + const fetchMock = vi.fn(); + const service = new GroupService('testApiKey12345', 'testApiSecret12345'); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('getGroup should decode a v4-fast group lacking cta/cti keys', async () => { + const group = loadGroup(); + fetchMock.mockResolvedValue(jsonResponse(group)); + + const result = await service.getGroup(group.groupId as string); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + `https://api.solapi.com/messages/v4/groups/fast/${group.groupId}`, + ); + expect(result.groupId).toBe(group.groupId); + expect(result.countForCharge.cta).toEqual({}); + expect(result.countForCharge.cti).toEqual({}); + expect(result.app.profit.cta).toBe(0); + expect(result.app.profit.cti).toBe(0); + expect(result.app.profit.sms).toBe( + (group.app as {profit: {sms: number}}).profit.sms, + ); + }); + + it('getGroups should decode a list containing a v4-fast group', async () => { + const group = loadGroup(); + fetchMock.mockResolvedValue( + jsonResponse({ + startKey: null, + nextKey: null, + limit: 20, + groupList: {[group.groupId as string]: group}, + }), + ); + + const result = await service.getGroups({groupId: group.groupId as string}); + + const decoded = result.groupList[group.groupId as string]; + expect(decoded.countForCharge.cta).toEqual({}); + expect(decoded.app.profit.cti).toBe(0); + }); + + it('getGroup should keep cta/cti values when the server returns them', async () => { + const group = loadGroup(); + const legacy = { + ...group, + countForCharge: { + ...(group.countForCharge as object), + cta: {'82': 3}, + cti: {}, + }, + app: { + ...(group.app as object), + profit: {...(group.app as {profit: object}).profit, cta: 5, cti: 0}, + }, + }; + fetchMock.mockResolvedValue(jsonResponse(legacy)); + + const result = await service.getGroup(group.groupId as string); + + expect(result.countForCharge.cta).toEqual({'82': 3}); + expect(result.app.profit.cta).toBe(5); + }); +}); diff --git a/test/services/messages/messageService.e2e.test.ts b/test/services/messages/messageService.e2e.test.ts index ce49d253..a491d661 100644 --- a/test/services/messages/messageService.e2e.test.ts +++ b/test/services/messages/messageService.e2e.test.ts @@ -45,6 +45,7 @@ import { } from '@test/lib/test-layers'; import {Config, Console, Effect} from 'effect'; import path from 'path'; +import {retryWithBackoff} from '../../lib/retry-utils'; describe('MessageService E2E', () => { it.effect('should return messages', () => @@ -436,10 +437,12 @@ describe('MessageService E2E', () => { expect(result.groupInfo).toBeDefined(); expect(result.groupInfo.count.total).toBeGreaterThan(0); - // 예약 직후 취소 처리 + // 고속접수 그룹은 조회/취소 API 에 수백 ms 뒤 반영되므로 재시도 const groupId = result.groupInfo.groupId; const cancelResult = yield* Effect.tryPromise(() => - groupService.removeReservationToGroup(groupId), + retryWithBackoff(() => + groupService.removeReservationToGroup(groupId), + ), ); expect(cancelResult).toBeDefined(); }).pipe(Effect.provide(MessageTestServicesLive)), diff --git a/test/services/messages/messageService.send.test.ts b/test/services/messages/messageService.send.test.ts new file mode 100644 index 00000000..85faaf3c --- /dev/null +++ b/test/services/messages/messageService.send.test.ts @@ -0,0 +1,122 @@ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {MessageNotReceivedError} from '@/errors/defaultError'; +import {DetailGroupMessageResponse} from '@/models/responses/sendManyDetailResponse'; +import MessageService from '@/services/messages/messageService'; + +const fixturePath = join(__dirname, '../../assets/fastSendResponse.json'); +const loadFixture = (): DetailGroupMessageResponse => + JSON.parse(readFileSync(fixturePath, 'utf-8')); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }); + +describe('MessageService.send fast intake', () => { + const fetchMock = vi.fn(); + const service = new MessageService('testApiKey12345', 'testApiSecret12345'); + const message = {to: '01000000000', from: '01000000000', text: '테스트'}; + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should POST to messages/v4/send-many/fast', async () => { + fetchMock.mockResolvedValue(jsonResponse(loadFixture())); + + await service.send(message); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.solapi.com/messages/v4/send-many/fast'); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toMatchObject({messages: [message]}); + }); + + it('should fill deprecated cta/cti charge fields missing from fast response', async () => { + const fixture = loadFixture(); + expect(fixture.groupInfo.countForCharge).not.toHaveProperty('cta'); + expect(fixture.groupInfo.app.profit).not.toHaveProperty('cti'); + fetchMock.mockResolvedValue(jsonResponse(fixture)); + + const result = await service.send(message); + + expect(result.groupInfo.countForCharge.cta).toEqual({}); + expect(result.groupInfo.countForCharge.cti).toEqual({}); + expect(result.groupInfo.app.profit.cta).toBe(0); + expect(result.groupInfo.app.profit.cti).toBe(0); + expect(result.groupInfo.isRefunded).toBe(false); + expect(result.groupInfo.countForCharge.sms).toEqual( + fixture.groupInfo.countForCharge.sms, + ); + expect(result.groupInfo.status).toBe('SEND-REQUESTED'); + expect(result.groupInfo.apiVersion).toBe('v4-fast'); + expect(result.messageList).toHaveLength(1); + }); + + it('should keep server-provided cta/cti values untouched', async () => { + const fixture = loadFixture(); + const withCta = { + ...fixture, + groupInfo: { + ...fixture.groupInfo, + isRefunded: true, + countForCharge: {...fixture.groupInfo.countForCharge, cta: {'82': 2}}, + app: { + ...fixture.groupInfo.app, + profit: {...fixture.groupInfo.app.profit, cti: 7}, + }, + }, + }; + fetchMock.mockResolvedValue(jsonResponse(withCta)); + + const result = await service.send(message); + + expect(result.groupInfo.countForCharge.cta).toEqual({'82': 2}); + expect(result.groupInfo.countForCharge.cti).toEqual({}); + expect(result.groupInfo.app.profit.cti).toBe(7); + expect(result.groupInfo.app.profit.cta).toBe(0); + expect(result.groupInfo.isRefunded).toBe(true); + }); + + it('should throw MessageNotReceivedError when every message fails registration', async () => { + const fixture = loadFixture(); + const allFailed = { + ...fixture, + failedMessageList: [ + { + to: message.to, + from: message.from, + type: 'SMS', + statusMessage: '발신번호 미등록', + country: '82', + messageId: 'M4V20260101000000TESTFAIL000000', + statusCode: '1021', + accountId: fixture.groupInfo.accountId, + }, + ], + groupInfo: { + ...fixture.groupInfo, + count: { + ...fixture.groupInfo.count, + total: 1, + registeredFailed: 1, + registeredSuccess: 0, + }, + }, + }; + fetchMock.mockResolvedValue(jsonResponse(allFailed)); + + await expect(service.send(message)).rejects.toBeInstanceOf( + MessageNotReceivedError, + ); + }); +});