diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index d490410f..8fd919eb 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -129,6 +129,37 @@ export class UnhandledExitError extends Data.TaggedError('UnhandledExitError')<{ } } +/** + * @description 서버가 2xx로 응답했으나 body가 SDK가 기대하는 스키마를 만족하지 못할 때 발생. + * 5xx를 의미하지 않으므로 ServerError와 분리하여 소비자의 재시도/알림 분기가 오염되지 않게 한다. + */ +export class ResponseSchemaMismatchError extends Data.TaggedError( + 'ResponseSchemaMismatchError', +)<{ + readonly message: string; + readonly url?: string; + readonly validationErrors: ReadonlyArray; + readonly responseBody?: string; +}> { + toString(): string { + const header = `ResponseSchemaMismatchError: ${this.message}`; + const url = this.url ? `\nURL: ${this.url}` : ''; + const issues = + this.validationErrors.length > 0 + ? `\nIssues:\n- ${this.validationErrors.join('\n- ')}` + : ''; + // defense-in-depth: 이 클래스는 public이라 외부에서 직접 생성될 수 있으므로, + // creation 시점 정책과 무관하게 redact 환경에서는 responseBody를 출력하지 않는다. + const env = process.env.NODE_ENV?.trim().toLowerCase(); + const isVerbose = env === 'development' || env === 'test'; + const body = + isVerbose && this.responseBody + ? `\nResponse: ${this.responseBody.substring(0, 500)}` + : ''; + return `${header}${url}${issues}${body}`; + } +} + // 5xx 서버 에러용 export class ServerError extends Data.TaggedError('ServerError')<{ readonly errorCode: string; diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 27196cc4..5f366d99 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -1,6 +1,10 @@ import {ParseResult, Schema} from 'effect'; import * as Effect from 'effect/Effect'; -import {BadRequestError, InvalidDateError} from '../errors/defaultError'; +import { + BadRequestError, + InvalidDateError, + ResponseSchemaMismatchError, +} from '../errors/defaultError'; import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer'; /** @@ -74,3 +78,95 @@ export const safeFinalize = ( message: error instanceof Error ? error.message : String(error), }), }); + +const stringifyResponseBody = (data: unknown): string | undefined => { + if (data === undefined) return undefined; + if (typeof data === 'string') return data; + try { + return JSON.stringify(data); + } catch (err) { + // circular / BigInt 등 직렬화 실패를 silent 하게 버리지 않고 + // 최소한 실패 사유와 타입 태그를 운영 로그에서 확인할 수 있도록 둔다. + const reason = err instanceof Error ? err.message : String(err); + return `[unserializable: ${reason}] ${Object.prototype.toString.call(data)}`; + } +}; + +/** + * URL에서 PII가 실릴 수 있는 모든 부분(query, fragment, userinfo)을 redact 한다. + * SOLAPI 조회 API는 `to`, `from`, `startDate` 등을 query string에 싣고, + * 소비자가 전달한 URL에 userinfo가 포함될 여지도 있으므로 모두 제거한다. + */ +export const redactUrlForProduction = ( + url: string | undefined, +): string | undefined => { + if (!url) return url; + try { + const parsed = new URL(url); + const hadQuery = parsed.search.length > 0; + parsed.search = hadQuery ? '?[redacted]' : ''; + parsed.hash = ''; + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } catch { + // 파싱 불가한 상대/비정상 URL은 보수적으로 첫 구분자 이후 전부 마스킹 + const cut = url.search(/[?#;]/); + return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`; + } +}; + +/** + * PII 보호 gate는 safe-by-default: 명시적으로 개발자 환경(development/test)일 때만 + * 상세 정보를 노출한다. 운영/스테이징/NODE_ENV 미설정 환경은 모두 redact 경로를 탄다 — + * 원본 값이 로그/Sentry 등으로 유출되지 않도록 하기 위함. + * + * NODE_ENV는 `.trim().toLowerCase()`로 정규화해 Windows PowerShell 등에서 흔한 + * `Development` 오타를 verbose 모드로 인식하도록 한다. + */ +export const shouldRedactSensitive = (): boolean => { + const env = process.env.NODE_ENV?.trim().toLowerCase(); + return env !== 'development' && env !== 'test'; +}; + +/** + * API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ResponseSchemaMismatchError로 래핑. + * 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신 + * 스키마 불일치 위치(ArrayFormatter issue path)와 원본 responseBody를 함께 보존하여 + * 운영 환경에서도 재현 가능하게 한다. + * + * Schema는 requirement 채널을 never로 제한 — 외부 서비스를 요구하는 transform을 금지하여 + * 응답 디코딩이 항상 순수하게 끝나도록 강제한다. + */ +export const decodeServerResponse = ( + schema: Schema.Schema, + data: unknown, + context?: {url?: string}, +): Effect.Effect => + // onExcessProperty: 'preserve' — 서버가 추가로 내려준 미선언 필드를 strip 하지 않는다. + // 부분 스키마로 검증하는 조회 엔드포인트에서 필드 조용히 사라지는 silent data loss를 방지. + Effect.mapError( + Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data), + err => { + // PII 누출을 차단한다 (safe-by-default: development/test 외에는 모두 redact): + // - responseBody: 원본 payload에 전화번호/계정 데이터가 실릴 수 있음 + // - validationErrors 메시지: ParseResult 포맷터는 기대치와 함께 *실제 값*을 문자열로 삽입함 + // - url: getMessages 등 조회 API는 to/from 등 전화번호를 query string에 실음 + // Sentry 등은 toString() 대신 enumerable 필드를 직렬화하므로 creation 단계에서 제거해야 안전. + const redact = shouldRedactSensitive(); + const issues = ParseResult.ArrayFormatter.formatErrorSync(err); + return new ResponseSchemaMismatchError({ + message: redact + ? `Response schema mismatch on ${issues.length} field(s)` + : ParseResult.TreeFormatter.formatErrorSync(err), + validationErrors: issues.map(issue => { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return redact + ? `${path}: [${issue._tag}]` + : `${path}: ${issue.message}`; + }), + url: redact ? redactUrlForProduction(context?.url) : context?.url, + responseBody: redact ? undefined : stringifyResponseBody(data), + }); + }, + ); diff --git a/src/models/base/kakao/kakaoChannel.ts b/src/models/base/kakao/kakaoChannel.ts index 084d5372..41b03658 100644 --- a/src/models/base/kakao/kakaoChannel.ts +++ b/src/models/base/kakao/kakaoChannel.ts @@ -21,7 +21,7 @@ export const kakaoChannelSchema = Schema.Struct({ channelId: Schema.String, searchId: Schema.String, accountId: Schema.String, - phoneNumber: Schema.String, + phoneNumber: Schema.optional(Schema.String), sharedAccountIds: Schema.Array(Schema.String), dateCreated: Schema.optional( Schema.Union(Schema.String, Schema.DateFromSelf), @@ -40,7 +40,7 @@ export type KakaoChannel = { channelId: string; searchId: string; accountId: string; - phoneNumber: string; + phoneNumber?: string; sharedAccountIds: ReadonlyArray; dateCreated?: Date; dateUpdated?: Date; @@ -63,6 +63,6 @@ export function decodeKakaoChannel( sharedAccountIds: data.sharedAccountIds, dateCreated, dateUpdated, - }; + } satisfies KakaoChannel; }); } diff --git a/src/models/base/messages/storedMessage.ts b/src/models/base/messages/storedMessage.ts new file mode 100644 index 00000000..ede14304 --- /dev/null +++ b/src/models/base/messages/storedMessage.ts @@ -0,0 +1,107 @@ +import {ParseResult, Schema} from 'effect'; +import {messageTypeSchema} from './message'; + +/** + * 서버가 동일 필드를 boolean 또는 0/1 정수로 섞어 내려주는 경우가 있어 + * 소비자에게는 boolean으로만 노출되도록 wire 단계에서 정규화한다. + * 0/1 외의 숫자(NaN, 2, -1 등)는 drift 신호이므로 silent 처리하지 않고 + * ResponseSchemaMismatchError로 전파되도록 transformOrFail을 사용한다. + */ +const booleanOrZeroOne = Schema.transformOrFail( + Schema.Union(Schema.Boolean, Schema.Number), + Schema.Boolean, + { + decode: (value, _opts, ast) => { + if (typeof value === 'boolean') return ParseResult.succeed(value); + if (value === 0) return ParseResult.succeed(false); + if (value === 1) return ParseResult.succeed(true); + return ParseResult.fail( + new ParseResult.Type( + ast, + value, + `Expected boolean, 0, or 1 but received ${String(value)}`, + ), + ); + }, + encode: value => ParseResult.succeed(value), + strict: true, + }, +); + +/** + * 조회 응답(getMessages/getGroupMessages)에 포함된 메시지 아이템 스키마. + * + * 발송용 messageSchema와 달리 서버가 저장해둔 값을 그대로 반환하므로 + * - optional 필드 상당수가 null로 내려올 수 있다. + * - kakaoOptions/rcsOptions 등 내부 구조가 발송 요청과 다르다(서버 정규화 포맷). + * + * 핵심 필드만 선언하고 타입 수준에서 검증/정규화한다. 여기에 없는 필드는 + * decodeServerResponse의 onExcessProperty:'preserve' 옵션으로 런타임에 그대로 보존된다. + */ +export const storedMessageSchema = Schema.Struct({ + messageId: Schema.optional(Schema.String), + type: Schema.NullishOr(messageTypeSchema), + to: Schema.optional(Schema.Union(Schema.String, Schema.Array(Schema.String))), + from: Schema.NullishOr(Schema.String), + text: Schema.NullishOr(Schema.String), + imageId: Schema.NullishOr(Schema.String), + subject: Schema.NullishOr(Schema.String), + country: Schema.NullishOr(Schema.String), + accountId: Schema.optional(Schema.String), + groupId: Schema.optional(Schema.String), + status: Schema.NullishOr(Schema.String), + statusCode: Schema.NullishOr(Schema.String), + reason: Schema.NullishOr(Schema.String), + networkName: Schema.NullishOr(Schema.String), + networkCode: Schema.NullishOr(Schema.String), + customFields: Schema.optional( + Schema.NullishOr(Schema.Record({key: Schema.String, value: Schema.String})), + ), + autoTypeDetect: Schema.optional(booleanOrZeroOne), + replacement: Schema.optional(booleanOrZeroOne), + resendCount: Schema.optional(Schema.Number), + dateCreated: Schema.optional(Schema.String), + dateUpdated: Schema.optional(Schema.String), + dateProcessed: Schema.NullishOr(Schema.String), + dateReceived: Schema.NullishOr(Schema.String), + dateReported: Schema.NullishOr(Schema.String), + // 옵션 객체는 서버 정규화 포맷(저장 형태)으로 발송 요청용 스키마와 필드가 다르다. + // 상세 타이핑을 확정하려면 각 옵션별 별도 조회 스키마 정의가 필요하지만 본 PR 범위를 + // 벗어나므로, 최소한 "object"임을 보장해 원시 값이 섞이는 drift를 감지할 수 있게 한다. + kakaoOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + rcsOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + naverOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + faxOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + voiceOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + replacements: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))), + log: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))), + queues: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))), + currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)), + clusterKey: Schema.NullishOr(Schema.String), + unavailableSenderNumber: Schema.optional(Schema.NullishOr(booleanOrZeroOne)), + faxPageCount: Schema.optional(Schema.NullishOr(Schema.Number)), + voiceDuration: Schema.optional(Schema.NullishOr(Schema.Number)), + voiceReplied: Schema.optional(Schema.NullishOr(booleanOrZeroOne)), + _id: Schema.optional(Schema.String), +}); +export type StoredMessage = Schema.Schema.Type; diff --git a/src/models/index.ts b/src/models/index.ts index a2ceee66..580bdae8 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -65,6 +65,10 @@ export { messageSchema, messageTypeSchema, } from './base/messages/message'; +export { + type StoredMessage, + storedMessageSchema, +} from './base/messages/storedMessage'; export { type NaverOptionSchema, naverOptionSchema, diff --git a/src/models/responses/iam/getBlacksResponse.ts b/src/models/responses/iam/getBlacksResponse.ts index c97dc957..f8231878 100644 --- a/src/models/responses/iam/getBlacksResponse.ts +++ b/src/models/responses/iam/getBlacksResponse.ts @@ -1,11 +1,11 @@ -import {blackSchema, handleKeySchema} from '@internal-types/commonTypes'; +import {blackSchema} from '@internal-types/commonTypes'; import {Schema} from 'effect'; export const getBlacksResponseSchema = Schema.Struct({ startKey: Schema.NullishOr(Schema.String), limit: Schema.Number, nextKey: Schema.NullishOr(Schema.String), - blackList: Schema.Record({key: handleKeySchema, value: blackSchema}), + blackList: Schema.Array(blackSchema), }); export type GetBlacksResponse = Schema.Schema.Type< typeof getBlacksResponseSchema diff --git a/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts b/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts index 1389c33a..a3849c5e 100644 --- a/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts +++ b/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts @@ -5,8 +5,8 @@ import {getKakaoTemplateResponseSchema} from './getKakaoTemplateResponse'; export const getKakaoAlimtalkTemplatesResponseSchema = Schema.Struct({ limit: Schema.Number, templateList: Schema.Array(getKakaoTemplateResponseSchema), - startKey: Schema.String, - nextKey: Schema.NullOr(Schema.String), + startKey: Schema.NullishOr(Schema.String), + nextKey: Schema.NullishOr(Schema.String), }); export type GetKakaoAlimtalkTemplatesResponseSchema = Schema.Schema.Type< typeof getKakaoAlimtalkTemplatesResponseSchema @@ -17,6 +17,6 @@ export type GetKakaoAlimtalkTemplatesResponse = export type GetKakaoAlimtalkTemplatesFinalizeResponse = { limit: number; templateList: Array; - startKey: string; - nextKey: string | null; + startKey: string | null | undefined; + nextKey: string | null | undefined; }; diff --git a/src/models/responses/kakao/getKakaoChannelsResponse.ts b/src/models/responses/kakao/getKakaoChannelsResponse.ts index 55f439d0..ce3fb75d 100644 --- a/src/models/responses/kakao/getKakaoChannelsResponse.ts +++ b/src/models/responses/kakao/getKakaoChannelsResponse.ts @@ -6,8 +6,8 @@ import { export const getKakaoChannelsResponseSchema = Schema.Struct({ limit: Schema.Number, - startKey: Schema.String, - nextKey: Schema.NullOr(Schema.String), + startKey: Schema.NullishOr(Schema.String), + nextKey: Schema.NullishOr(Schema.String), channelList: Schema.Array(kakaoChannelSchema), }); @@ -17,7 +17,7 @@ export type GetKakaoChannelsResponse = Schema.Schema.Type< export type GetKakaoChannelsFinalizeResponse = { limit: number; - startKey: string; - nextKey: string | null; + startKey: string | null | undefined; + nextKey: string | null | undefined; channelList: Array; }; diff --git a/src/models/responses/kakao/getKakaoTemplateResponse.ts b/src/models/responses/kakao/getKakaoTemplateResponse.ts index 9f116233..11a57fbc 100644 --- a/src/models/responses/kakao/getKakaoTemplateResponse.ts +++ b/src/models/responses/kakao/getKakaoTemplateResponse.ts @@ -9,7 +9,7 @@ export const getKakaoTemplateResponseSchema = kakaoAlimtalkTemplateSchema.pipe( Schema.extend( Schema.Struct({ assignType: kakaoAlimtalkTemplateAssignTypeSchema, - accountId: Schema.String, + accountId: Schema.NullishOr(Schema.String), commentable: Schema.Boolean, dateCreated: Schema.String, dateUpdated: Schema.String, diff --git a/src/models/responses/messageResponses.ts b/src/models/responses/messageResponses.ts index 5807b151..328da7f3 100644 --- a/src/models/responses/messageResponses.ts +++ b/src/models/responses/messageResponses.ts @@ -6,10 +6,10 @@ import { groupIdSchema, groupSchema, logSchema, - messageTypeRecordSchema, + partialMessageTypeRecordSchema, } from '@internal-types/commonTypes'; import {Schema} from 'effect'; -import {messageSchema} from '../base/messages/message'; +import {storedMessageSchema} from '../base/messages/storedMessage'; export const groupMessageResponseSchema = Schema.Struct({ count: countSchema, @@ -28,9 +28,9 @@ export const groupMessageResponseSchema = Schema.Struct({ price: Schema.Record({key: Schema.String, value: Schema.Unknown}), dateCreated: Schema.String, dateUpdated: Schema.String, - scheduledDate: Schema.optional(Schema.String), - dateSent: Schema.optional(Schema.String), - dateCompleted: Schema.optional(Schema.String), + scheduledDate: Schema.NullishOr(Schema.String), + dateSent: Schema.NullishOr(Schema.String), + dateCompleted: Schema.NullishOr(Schema.String), }); export type GroupMessageResponse = Schema.Schema.Type< typeof groupMessageResponseSchema @@ -62,10 +62,10 @@ export type AddMessageResponse = Schema.Schema.Type< >; export const getMessagesResponseSchema = Schema.Struct({ - startKey: Schema.NullOr(Schema.String), - nextKey: Schema.NullOr(Schema.String), + startKey: Schema.optional(Schema.NullOr(Schema.String)), + nextKey: Schema.optional(Schema.NullOr(Schema.String)), limit: Schema.Number, - messageList: Schema.Record({key: Schema.String, value: messageSchema}), + messageList: Schema.Record({key: Schema.String, value: storedMessageSchema}), }); export type GetMessagesResponse = Schema.Schema.Type< typeof getMessagesResponseSchema @@ -108,21 +108,37 @@ const statisticsPeriodResultSchema = Schema.Struct({ rcs_lms: Schema.Number, rcs_mms: Schema.Number, rcs_tpl: Schema.Number, + rcs_itpl: Schema.optional(Schema.Number), + rcs_ltpl: Schema.optional(Schema.Number), + fax: Schema.optional(Schema.Number), + voice: Schema.optional(Schema.Number), + bms_text: Schema.optional(Schema.Number), + bms_image: Schema.optional(Schema.Number), + bms_wide: Schema.optional(Schema.Number), + bms_wide_item_list: Schema.optional(Schema.Number), + bms_carousel_feed: Schema.optional(Schema.Number), + bms_premium_video: Schema.optional(Schema.Number), + bms_commerce: Schema.optional(Schema.Number), + bms_carousel_commerce: Schema.optional(Schema.Number), + bms_free: Schema.optional(Schema.Number), }); const refundSchema = Schema.Struct({ balance: Schema.Number, point: Schema.Number, + deposit: Schema.optional(Schema.Number), }); const dayPeriodSchema = Schema.Struct({ _id: Schema.String, month: Schema.String, + date: Schema.optional(Schema.String), balance: Schema.Number, point: Schema.Number, + deposit: Schema.optional(Schema.Number), statusCode: Schema.Record({ key: Schema.String, - value: messageTypeRecordSchema, + value: partialMessageTypeRecordSchema, }), refund: refundSchema, total: statisticsPeriodResultSchema, @@ -135,6 +151,8 @@ const monthPeriodRefundSchema = Schema.Struct({ balanceAvg: Schema.Number, point: Schema.Number, pointAvg: Schema.Number, + deposit: Schema.optional(Schema.Number), + depositAvg: Schema.optional(Schema.Number), }); const monthPeriodSchema = Schema.Struct({ @@ -143,8 +161,10 @@ const monthPeriodSchema = Schema.Struct({ balanceAvg: Schema.Number, point: Schema.Number, pointAvg: Schema.Number, + deposit: Schema.optional(Schema.Number), + depositAvg: Schema.optional(Schema.Number), dayPeriod: Schema.Array(dayPeriodSchema), - refund: monthPeriodRefundSchema, + refund: Schema.optional(monthPeriodRefundSchema), total: statisticsPeriodResultSchema, successed: statisticsPeriodResultSchema, failed: statisticsPeriodResultSchema, @@ -153,25 +173,44 @@ const monthPeriodSchema = Schema.Struct({ export const getStatisticsResponseSchema = Schema.Struct({ balance: Schema.Number, point: Schema.Number, + deposit: Schema.optional(Schema.Number), monthlyBalanceAvg: Schema.Number, monthlyPointAvg: Schema.Number, + monthlyDepositAvg: Schema.optional(Schema.Number), monthPeriod: Schema.Array(monthPeriodSchema), total: statisticsPeriodResultSchema, successed: statisticsPeriodResultSchema, failed: statisticsPeriodResultSchema, - dailyBalanceAvg: Schema.Number, - dailyPointAvg: Schema.Number, - dailyTotalCountAvg: Schema.Number, - dailyFailedCountAvg: Schema.Number, - dailySuccessedCountAvg: Schema.Number, + dailyBalanceAvg: Schema.optional(Schema.Number), + dailyPointAvg: Schema.optional(Schema.Number), + dailyTotalCountAvg: Schema.optional(Schema.Number), + dailyFailedCountAvg: Schema.optional(Schema.Number), + dailySuccessedCountAvg: Schema.optional(Schema.Number), }); export type GetStatisticsResponse = Schema.Schema.Type< typeof getStatisticsResponseSchema >; +const lowBalanceAlertSchema = Schema.Struct({ + notificationBalance: Schema.String, + currentBalance: Schema.String, + balances: Schema.Array(Schema.Number), + channels: Schema.Array(Schema.String), + enabled: Schema.Boolean, +}); +export type LowBalanceAlert = Schema.Schema.Type; + export const getBalanceResponseSchema = Schema.Struct({ - balance: Schema.Number, + lowBalanceAlert: Schema.optional(lowBalanceAlertSchema), point: Schema.Number, + minimumCash: Schema.optional(Schema.Number), + rechargeTo: Schema.optional(Schema.Number), + rechargeTryCount: Schema.optional(Schema.Number), + autoRecharge: Schema.optional(Schema.Number), + accountId: Schema.optional(Schema.String), + balance: Schema.Number, + deposit: Schema.optional(Schema.Number), + balanceOnly: Schema.optional(Schema.Number), }); export type GetBalanceResponse = Schema.Schema.Type< typeof getBalanceResponseSchema diff --git a/src/services/cash/cashService.ts b/src/services/cash/cashService.ts index 82d8f3e6..85c7470e 100644 --- a/src/services/cash/cashService.ts +++ b/src/services/cash/cashService.ts @@ -1,5 +1,8 @@ import {runSafePromise} from '@lib/effectErrorHandler'; -import {type GetBalanceResponse} from '@models/responses/messageResponses'; +import { + type GetBalanceResponse, + getBalanceResponseSchema, +} from '@models/responses/messageResponses'; import DefaultService from '../defaultService'; export default class CashService extends DefaultService { @@ -9,9 +12,10 @@ export default class CashService extends DefaultService { */ async getBalance(): Promise { return runSafePromise( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: 'cash/v1/balance', + responseSchema: getBalanceResponseSchema, }), ); } diff --git a/src/services/defaultService.ts b/src/services/defaultService.ts index 8da9c7b2..94a48a7c 100644 --- a/src/services/defaultService.ts +++ b/src/services/defaultService.ts @@ -1,7 +1,11 @@ import {AuthenticationParameter} from '@lib/authenticator'; import {defaultFetcherEffect} from '@lib/defaultFetcher'; import {runSafePromise} from '@lib/effectErrorHandler'; -import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils'; +import { + decodeServerResponse, + decodeWithBadRequest, + safeFinalize, +} from '@lib/schemaUtils'; import stringifyQuery from '@lib/stringifyQuery'; import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; @@ -12,6 +16,7 @@ import type { DefaultError, InvalidDateError, NetworkError, + ResponseSchemaMismatchError, ServerError, } from '../errors/defaultError'; @@ -20,10 +25,16 @@ type RequestConfig = { url: string; }; -type DefaultServiceParameter = { +type DefaultServiceParameter = { httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; url: string; body?: T; + /** + * 2xx 응답 body에 대해 실행되는 런타임 검증 스키마. + * Schema.decodeUnknown은 requirement 채널을 요구하지 않도록 never로 고정 — + * 외부 의존성이 필요한 transform은 허용하지 않아 디코딩 결과가 항상 pure해진다. + */ + responseSchema?: Schema.Schema; }; export default class DefaultService { @@ -37,31 +48,44 @@ export default class DefaultService { }; } - protected requestEffect( - parameter: DefaultServiceParameter, + protected requestEffect( + parameter: DefaultServiceParameter, ): Effect.Effect< R, - ApiKeyError | ClientError | ServerError | NetworkError | DefaultError + | ApiKeyError + | ClientError + | ServerError + | NetworkError + | DefaultError + | ResponseSchemaMismatchError > { - const {httpMethod, url, body} = parameter; + const {httpMethod, url, body, responseSchema} = parameter; const requestConfig: RequestConfig = { method: httpMethod, url: `${this.baseUrl}/${url}`, }; + if (responseSchema) { + return Effect.flatMap( + defaultFetcherEffect(this.authInfo, requestConfig, body), + data => + decodeServerResponse(responseSchema, data, {url: requestConfig.url}), + ); + } return defaultFetcherEffect(this.authInfo, requestConfig, body); } - protected async request( - parameter: DefaultServiceParameter, + protected async request( + parameter: DefaultServiceParameter, ): Promise { - return runSafePromise(this.requestEffect(parameter)); + return runSafePromise(this.requestEffect(parameter)); } - protected getWithQuery(config: { + protected getWithQuery(config: { schema: Schema.Schema; finalize: (validated?: A) => object; url: string; data?: unknown; + responseSchema?: Schema.Schema; }): Effect.Effect< R, | ApiKeyError @@ -71,6 +95,7 @@ export default class DefaultService { | DefaultError | BadRequestError | InvalidDateError + | ResponseSchemaMismatchError > { const reqEffect = this.requestEffect.bind(this); return Effect.gen(function* () { @@ -82,9 +107,10 @@ export default class DefaultService { indices: false, addQueryPrefix: true, }); - return yield* reqEffect({ + return yield* reqEffect({ httpMethod: 'GET', url: `${config.url}${parameter}`, + responseSchema: config.responseSchema, }); }); } diff --git a/src/services/iam/iamService.ts b/src/services/iam/iamService.ts index 1bcddadb..20f6b77e 100644 --- a/src/services/iam/iamService.ts +++ b/src/services/iam/iamService.ts @@ -14,9 +14,18 @@ import { type GetBlockNumbersRequest, getBlockNumbersRequestSchema, } from '@models/requests/iam/getBlockNumbersRequest'; -import {GetBlacksResponse} from '@models/responses/iam/getBlacksResponse'; -import {GetBlockGroupsResponse} from '@models/responses/iam/getBlockGroupsResponse'; -import {GetBlockNumbersResponse} from '@models/responses/iam/getBlockNumbersResponse'; +import { + GetBlacksResponse, + getBlacksResponseSchema, +} from '@models/responses/iam/getBlacksResponse'; +import { + GetBlockGroupsResponse, + getBlockGroupsResponseSchema, +} from '@models/responses/iam/getBlockGroupsResponse'; +import { + GetBlockNumbersResponse, + getBlockNumbersResponseSchema, +} from '@models/responses/iam/getBlockNumbersResponse'; import DefaultService from '../defaultService'; export default class IamService extends DefaultService { @@ -32,6 +41,7 @@ export default class IamService extends DefaultService { finalize: finalizeGetBlacksRequest, url: 'iam/v1/black', data, + responseSchema: getBlacksResponseSchema, }), ); } @@ -50,6 +60,7 @@ export default class IamService extends DefaultService { finalize: finalizeGetBlockGroupsRequest, url: 'iam/v1/block/groups', data, + responseSchema: getBlockGroupsResponseSchema, }), ); } @@ -68,6 +79,7 @@ export default class IamService extends DefaultService { finalize: finalizeGetBlockNumbersRequest, url: 'iam/v1/block/numbers', data, + responseSchema: getBlockNumbersResponseSchema, }), ); } diff --git a/src/services/kakao/channels/kakaoChannelService.ts b/src/services/kakao/channels/kakaoChannelService.ts index b91dc754..6cb66595 100644 --- a/src/services/kakao/channels/kakaoChannelService.ts +++ b/src/services/kakao/channels/kakaoChannelService.ts @@ -6,6 +6,8 @@ import { type KakaoChannel, type KakaoChannelCategory, type KakaoChannelSchema, + kakaoChannelCategorySchema, + kakaoChannelSchema, } from '@models/base/kakao/kakaoChannel'; import { type CreateKakaoChannelRequest, @@ -18,22 +20,29 @@ import { } from '@models/requests/kakao/getKakaoChannelsRequest'; import { type GetKakaoChannelsFinalizeResponse, - type GetKakaoChannelsResponse, + getKakaoChannelsResponseSchema, } from '@models/responses/kakao/getKakaoChannelsResponse'; import { type CreateKakaoChannelResponse, type RequestKakaoChannelTokenResponse, } from '@models/responses/messageResponses'; +import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import DefaultService from '../../defaultService'; +const kakaoChannelCategoryListSchema = Schema.Array(kakaoChannelCategorySchema); + export default class KakaoChannelService extends DefaultService { async getKakaoChannelCategories(): Promise> { return runSafePromise( - this.requestEffect>({ - httpMethod: 'GET', - url: 'kakao/v2/channels/categories', - }), + Effect.map( + this.requestEffect({ + httpMethod: 'GET', + url: 'kakao/v2/channels/categories', + responseSchema: kakaoChannelCategoryListSchema, + }), + list => [...list], + ), ); } @@ -53,9 +62,10 @@ export default class KakaoChannelService extends DefaultService { indices: false, addQueryPrefix: true, }); - const response = yield* reqEffect({ - httpMethod: 'GET', + const response = yield* reqEffect({ + httpMethod: 'GET' as const, url: `kakao/v2/channels${parameter}`, + responseSchema: getKakaoChannelsResponseSchema, }); return { limit: response.limit, @@ -72,9 +82,10 @@ export default class KakaoChannelService extends DefaultService { async getKakaoChannel(channelId: string): Promise { return runSafePromise( Effect.flatMap( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `kakao/v2/channels/${channelId}`, + responseSchema: kakaoChannelSchema, }), decodeKakaoChannel, ), diff --git a/src/services/kakao/templates/kakaoTemplateService.ts b/src/services/kakao/templates/kakaoTemplateService.ts index 15054627..70ba95fb 100644 --- a/src/services/kakao/templates/kakaoTemplateService.ts +++ b/src/services/kakao/templates/kakaoTemplateService.ts @@ -8,6 +8,7 @@ import { type KakaoAlimtalkTemplateSchema, kakaoAlimtalkTemplateSchema, } from '@models/base/kakao/kakaoAlimtalkTemplate'; +import {kakaoChannelCategorySchema} from '@models/base/kakao/kakaoChannel'; import {type CreateKakaoAlimtalkTemplateRequest} from '@models/requests/kakao/createKakaoAlimtalkTemplateRequest'; import { finalizeGetKakaoAlimtalkTemplatesRequest, @@ -17,12 +18,17 @@ import { import {type UpdateKakaoAlimtalkTemplateRequest} from '@models/requests/kakao/updateKakaoAlimtalkTemplateRequest'; import { type GetKakaoAlimtalkTemplatesFinalizeResponse, - type GetKakaoAlimtalkTemplatesResponseSchema, + getKakaoAlimtalkTemplatesResponseSchema, } from '@models/responses/kakao/getKakaoAlimtalkTemplatesResponse'; -import {type GetKakaoTemplateResponse} from '@models/responses/kakao/getKakaoTemplateResponse'; +import {getKakaoTemplateResponseSchema} from '@models/responses/kakao/getKakaoTemplateResponse'; +import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import DefaultService from '../../defaultService'; +const kakaoAlimtalkTemplateCategoryListSchema = Schema.Array( + kakaoChannelCategorySchema, +); + export default class KakaoTemplateService extends DefaultService { /** * 카카오 템플릿 카테고리 조회 @@ -31,10 +37,14 @@ export default class KakaoTemplateService extends DefaultService { Array > { return runSafePromise( - this.requestEffect>({ - httpMethod: 'GET', - url: 'kakao/v2/templates/categories', - }), + Effect.map( + this.requestEffect({ + httpMethod: 'GET', + url: 'kakao/v2/templates/categories', + responseSchema: kakaoAlimtalkTemplateCategoryListSchema, + }), + list => [...list], + ), ); } @@ -81,12 +91,10 @@ export default class KakaoTemplateService extends DefaultService { indices: false, addQueryPrefix: true, }); - const response = yield* reqEffect< - never, - GetKakaoAlimtalkTemplatesResponseSchema - >({ - httpMethod: 'GET', + const response = yield* reqEffect({ + httpMethod: 'GET' as const, url: `kakao/v2/templates${parameter}`, + responseSchema: getKakaoAlimtalkTemplatesResponseSchema, }); const templateList = yield* Effect.all( @@ -116,9 +124,10 @@ export default class KakaoTemplateService extends DefaultService { ): Promise { return runSafePromise( Effect.flatMap( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `kakao/v2/templates/${templateId}`, + responseSchema: getKakaoTemplateResponseSchema, }), decodeKakaoAlimtalkTemplate, ), diff --git a/src/services/messages/groupService.ts b/src/services/messages/groupService.ts index 0cd62910..455bd38f 100644 --- a/src/services/messages/groupService.ts +++ b/src/services/messages/groupService.ts @@ -24,6 +24,9 @@ import { GetGroupsResponse, GetMessagesResponse, GroupMessageResponse, + getGroupsResponseSchema, + getMessagesResponseSchema, + groupMessageResponseSchema, RemoveGroupMessagesResponse, } from '@models/responses/messageResponses'; import * as Effect from 'effect/Effect'; @@ -151,6 +154,7 @@ export default class GroupService extends DefaultService { finalize: finalizeGetGroupsRequest, url: 'messages/v4/groups', data, + responseSchema: getGroupsResponseSchema, }), ); } @@ -161,9 +165,10 @@ export default class GroupService extends DefaultService { */ async getGroup(groupId: GroupId): Promise { return runSafePromise( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `messages/v4/groups/${groupId}`, + responseSchema: groupMessageResponseSchema, }), ); } @@ -182,9 +187,10 @@ export default class GroupService extends DefaultService { addQueryPrefix: true, }); return runSafePromise( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `messages/v4/groups/${groupId}/messages${parameter}`, + responseSchema: getMessagesResponseSchema, }), ); } diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 0a685854..a874eaec 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -23,6 +23,8 @@ import { import { GetMessagesResponse, GetStatisticsResponse, + getMessagesResponseSchema, + getStatisticsResponseSchema, } from '@models/responses/messageResponses'; import {DetailGroupMessageResponse} from '@models/responses/sendManyDetailResponse'; import * as Effect from 'effect/Effect'; @@ -121,6 +123,7 @@ export default class MessageService extends DefaultService { finalize: finalizeGetMessagesRequest, url: 'messages/v4/list', data, + responseSchema: getMessagesResponseSchema, }), ); } @@ -139,6 +142,7 @@ export default class MessageService extends DefaultService { finalize: finalizeGetStatisticsRequest, url: 'messages/v4/statistics', data, + responseSchema: getStatisticsResponseSchema, }), ); } diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index 83ca6d7a..c5928137 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -30,6 +30,19 @@ export const countForChargeSchema = Schema.Struct({ rcs_lms: countryChargeStatusSchema, rcs_mms: countryChargeStatusSchema, rcs_tpl: countryChargeStatusSchema, + rcs_itpl: Schema.optional(countryChargeStatusSchema), + rcs_ltpl: Schema.optional(countryChargeStatusSchema), + fax: Schema.optional(countryChargeStatusSchema), + voice: Schema.optional(countryChargeStatusSchema), + bms_text: Schema.optional(countryChargeStatusSchema), + bms_image: Schema.optional(countryChargeStatusSchema), + bms_wide: Schema.optional(countryChargeStatusSchema), + bms_wide_item_list: Schema.optional(countryChargeStatusSchema), + bms_carousel_feed: Schema.optional(countryChargeStatusSchema), + bms_premium_video: Schema.optional(countryChargeStatusSchema), + bms_commerce: Schema.optional(countryChargeStatusSchema), + bms_carousel_commerce: Schema.optional(countryChargeStatusSchema), + bms_free: Schema.optional(countryChargeStatusSchema), }); export type CountForCharge = Schema.Schema.Type; @@ -55,11 +68,36 @@ export const messageTypeRecordSchema = Schema.Struct({ rcs_lms: Schema.Number, rcs_mms: Schema.Number, rcs_tpl: Schema.Number, + rcs_itpl: Schema.optional(Schema.Number), + rcs_ltpl: Schema.optional(Schema.Number), + fax: Schema.optional(Schema.Number), + voice: Schema.optional(Schema.Number), + bms_text: Schema.optional(Schema.Number), + bms_image: Schema.optional(Schema.Number), + bms_wide: Schema.optional(Schema.Number), + bms_wide_item_list: Schema.optional(Schema.Number), + bms_carousel_feed: Schema.optional(Schema.Number), + bms_premium_video: Schema.optional(Schema.Number), + bms_commerce: Schema.optional(Schema.Number), + bms_carousel_commerce: Schema.optional(Schema.Number), + bms_free: Schema.optional(Schema.Number), }); export type MessageTypeRecord = Schema.Schema.Type< typeof messageTypeRecordSchema >; +/** + * 통계 dayPeriod.statusCode 같이 status code 별로 **일부 메시지 타입만** 카운트를 내려주는 + * sparse 응답용. MessageTypeRecord의 타입 정보(`sms`/`lms` 등 개별 키)를 유지하면서도 + * 모든 키를 optional로 풀어 서버가 한두 필드만 내려줘도 decode가 통과하도록 한다. + */ +export const partialMessageTypeRecordSchema = Schema.partial( + messageTypeRecordSchema, +); +export type PartialMessageTypeRecord = Schema.Schema.Type< + typeof partialMessageTypeRecordSchema +>; + export const appSchema = Schema.Struct({ profit: messageTypeRecordSchema, appId: Schema.NullishOr(Schema.String), @@ -79,13 +117,13 @@ export const groupSchema = Schema.Struct({ balance: commonCashResponseSchema, point: commonCashResponseSchema, app: appSchema, - sdkVersion: Schema.String, - osPlatform: Schema.String, + sdkVersion: Schema.NullishOr(Schema.String), + osPlatform: Schema.NullishOr(Schema.String), log: logSchema, status: Schema.String, - scheduledDate: Schema.optional(Schema.String), - dateSent: Schema.optional(Schema.String), - dateCompleted: Schema.optional(Schema.String), + scheduledDate: Schema.NullishOr(Schema.String), + dateSent: Schema.NullishOr(Schema.String), + dateCompleted: Schema.NullishOr(Schema.String), isRefunded: Schema.Boolean, groupId: groupIdSchema, accountId: Schema.String, @@ -112,7 +150,7 @@ export const blockGroupSchema = Schema.Struct({ blockGroupId: Schema.String, accountId: Schema.String, status: Schema.Literal('INACTIVE', 'ACTIVE'), - name: Schema.String, + name: Schema.NullishOr(Schema.String), useAll: Schema.Boolean, senderNumbers: Schema.Array(Schema.String), dateCreated: Schema.String, diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts new file mode 100644 index 00000000..ce73a019 --- /dev/null +++ b/test/lib/decodeServerResponse.test.ts @@ -0,0 +1,239 @@ +import {Schema} from 'effect'; +import * as Effect from 'effect/Effect'; +import {describe, expect, it} from 'vitest'; +import {ResponseSchemaMismatchError} from '@/errors/defaultError'; +import {decodeServerResponse} from '@/lib/schemaUtils'; +import {storedMessageSchema} from '@/models/base/messages/storedMessage'; +import {getBalanceResponseSchema} from '@/models/responses/messageResponses'; + +const balanceFixture = { + lowBalanceAlert: { + notificationBalance: '200', + currentBalance: '196.00000000001592', + balances: [200, 1000000, 30000000], + channels: ['EMAIL', 'ATA'], + enabled: true, + }, + point: 0, + minimumCash: 3000, + rechargeTo: 50000, + rechargeTryCount: 0, + autoRecharge: 0, + accountId: '486', + balance: 0, + deposit: 0, + balanceOnly: 0, +}; + +describe('decodeServerResponse', () => { + it('실제 getBalance 응답을 성공적으로 디코딩한다', () => { + const result = Effect.runSync( + decodeServerResponse(getBalanceResponseSchema, balanceFixture), + ); + expect(result.balance).toBe(0); + expect(result.lowBalanceAlert?.enabled).toBe(true); + expect(result.lowBalanceAlert?.channels).toEqual(['EMAIL', 'ATA']); + }); + + it('타입이 맞지 않으면 ResponseSchemaMismatchError로 실패하고 path 정보를 보존한다', () => { + const invalid = {...balanceFixture, balance: 'not a number'}; + const result = Effect.runSync( + Effect.either(decodeServerResponse(getBalanceResponseSchema, invalid)), + ); + + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const err = result.left; + expect(err).toBeInstanceOf(ResponseSchemaMismatchError); + expect(err.validationErrors.length).toBeGreaterThan(0); + expect(err.validationErrors.some(m => m.includes('balance'))).toBe(true); + }); + + it('실패 시 responseBody에 원본 JSON을 보존한다', () => { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(getBalanceResponseSchema, {bogus: 'data'}), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const body = result.left.responseBody; + expect(body).toBeDefined(); + expect(body).toContain('bogus'); + }); + + it('JSON.stringify 실패 시 responseBody에 사유 메타데이터를 기록한다', () => { + const circular: Record = {foo: 1}; + circular.self = circular; + const result = Effect.runSync( + Effect.either(decodeServerResponse(getBalanceResponseSchema, circular)), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.responseBody).toMatch(/unserializable/); + }); + + describe('safe-by-default redact gate', () => { + const piiPhone = '01012345678'; + const withEnv = (env: string | undefined, fn: () => T): T => { + const original = process.env.NODE_ENV; + if (env === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = env; + } + try { + return fn(); + } finally { + if (original === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = original; + } + } + }; + + const runDecode = () => + Effect.runSync( + Effect.either( + decodeServerResponse( + getBalanceResponseSchema, + // balance는 number 기대이므로 PII 전화번호를 주입하면 formatter 메시지에 값이 노출됨 + {...balanceFixture, balance: piiPhone}, + { + url: `https://user:secret@api.example.com/messages/v4/list?to=${piiPhone}&from=02#section-${piiPhone}`, + }, + ), + ), + ); + + it.each([ + 'production', + 'staging', + 'PRODUCTION', + '', + undefined, + ])('NODE_ENV=%s 환경은 PII를 redact 한다', envValue => { + const result = withEnv(envValue, runDecode); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const err = result.left; + expect(err.responseBody).toBeUndefined(); + expect(err.message).not.toContain(piiPhone); + for (const ve of err.validationErrors) { + expect(ve).not.toContain(piiPhone); + } + expect(err.url).not.toContain(piiPhone); + expect(err.url).not.toContain('secret'); + expect(err.url).toContain('/messages/v4/list'); + expect(err.validationErrors.length).toBeGreaterThan(0); + }); + + it.each([ + 'development', + 'test', + ])('NODE_ENV=%s 환경은 상세 정보를 유지해 디버깅에 활용 가능하다', envValue => { + const result = withEnv(envValue, runDecode); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const err = result.left; + expect(err.responseBody).toBeDefined(); + expect(err.responseBody).toContain(piiPhone); + expect(err.validationErrors.join(' ')).toContain(piiPhone); + expect(err.url).toContain(piiPhone); + }); + + it.each([ + ' DEVELOPMENT ', + 'Development', + ' test ', + 'TEST', + ])('대소문자/공백이 섞인 %s는 정규화되어 verbose로 인식된다', envValue => { + const result = withEnv(envValue, runDecode); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.responseBody).toBeDefined(); + }); + }); + + it('context.url이 있으면 에러에 반영된다', () => { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(getBalanceResponseSchema, null, { + url: 'https://api.example.com/foo', + }), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.url).toBe('https://api.example.com/foo'); + }); + + it('onExcessProperty: preserve로 미선언 필드를 strip 하지 않는다', () => { + const schema = Schema.Struct({a: Schema.Number}); + const result = Effect.runSync( + decodeServerResponse(schema, {a: 1, b: 'kept', nested: {x: 'also'}}), + ); + expect(result).toEqual({a: 1, b: 'kept', nested: {x: 'also'}}); + }); +}); + +describe('storedMessageSchema boolean|number 정규화', () => { + it('autoTypeDetect/replacement/voiceReplied/unavailableSenderNumber가 0/1로 와도 boolean으로 정규화된다', () => { + const result = Effect.runSync( + decodeServerResponse(storedMessageSchema, { + autoTypeDetect: 0, + replacement: 1, + voiceReplied: 0, + unavailableSenderNumber: 1, + }), + ); + expect(result.autoTypeDetect).toBe(false); + expect(result.replacement).toBe(true); + expect(result.voiceReplied).toBe(false); + expect(result.unavailableSenderNumber).toBe(true); + }); + + it('boolean 값은 그대로 통과한다', () => { + const result = Effect.runSync( + decodeServerResponse(storedMessageSchema, { + autoTypeDetect: true, + replacement: false, + }), + ); + expect(result.autoTypeDetect).toBe(true); + expect(result.replacement).toBe(false); + }); + + it.each([ + 2, + -1, + 0.5, + Number.NaN, + ])('0/1 외의 숫자(%s)는 drift로 간주되어 ResponseSchemaMismatchError로 실패', invalid => { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(storedMessageSchema, { + autoTypeDetect: invalid, + }), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left).toBeInstanceOf(ResponseSchemaMismatchError); + expect(result.left.validationErrors.length).toBeGreaterThan(0); + }); + + it('미선언 필드(dateReceived 등)를 응답에서 strip 하지 않는다', () => { + const raw = { + messageId: 'M123', + foo: 'bar', + nested: {x: 1}, + }; + const result = Effect.runSync( + decodeServerResponse(storedMessageSchema, raw), + ); + expect((result as Record).foo).toBe('bar'); + expect((result as Record).nested).toEqual({x: 1}); + }); +});