Files
Gnuboard7/templates/_bundled/sirsoft-basic/__tests__/layouts/checkout-deposit-due-days.test.ts
T
HeuJung 6bde24bc75 fix(ecommerce): 숫자 설정 문자열 영속으로 인한 주문 생성 실패 해소
관리자 환경설정에서 미입금 자동취소 기한을 저장하면 HTML number 입력의
문자열이 그대로 영속되고, Carbon 3.13 의 strict 타입 경계에서 TypeError 가
발생해 무통장입금·가상계좌 주문이 전면 실패했다(공개 제보). 한 곳만 막으면
다른 저장 경로가 남으므로 저장·조회·소비·모델 네 계층에 각각 방어를 두고,
저장소 전역에서 같은 형태가 더 없음을 audit 룰로 확정했다.

같은 계획서 수행 중 드러난 인접 결함도 함께 해소했다.
- 쿠폰 수정 경로로 (발급일 기준, 일수 NULL) 조합이 저장되어 발급 즉시
 만료된 쿠폰이 조용히 나가던 문제 — 저장·발급 양쪽에서 차단
- 리뷰 작성 기한을 화면과 저장 판정이 서로 다른 기준으로 계산하던 문제
- 결제 플러그인이 PaymentMethodEnum 과 다른 어휘로 지원 결제수단을 선언해
 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제

엔진에서는 `_localInit` 적용 여부를 전역 해시로만 판정해 인스턴스별 저장소가
리셋되지 않던 결함(engine-v1.54.5)과, 제거 대상이 없어도 상태 갱신·캐시
무효화가 상시 발생하던 판정 결함(engine-v1.54.6)을 고쳤다.

Playwright MCP 정밀 점검(T1~T11)에서 설정이 빈 문자열로 남은 사이트의
주문서 입금기한 안내가 미치환 플레이스홀더를 노출하는 것을 실측해, 화면
폴백을 서버와 같은 규칙으로 맞췄다.
2026-08-02 18:35:28 +09:00

111 lines
5.3 KiB
TypeScript

/**
* 주문서 입금 기한 안내 — 서버 SSoT 바인딩 계약 테스트 (#493 E1)
*
* 배경: 입금 기한은 결제수단과 무관하게 관리자 환경설정의 미입금 자동취소 기한
* (`order_settings.auto_cancel_days`) 하나만 사용한다. 서버는 가상계좌·무통장입금
* 양쪽 모두 이 값으로 `vbank_due_at` / `deposit_due_at` 을 계산한다
* (`OrderProcessingService`). 클라이언트가 보낸 기한 값은 무시된다.
*
* 결함: 주문서 화면은 서버에 존재하지 않는 `vbank_due_days` / `dbank_due_days` 를
* 참조하고 있었다. 두 키는 설정 응답에 없으므로 표현식이 항상 `undefined` 가 되고
* 하드코딩된 폴백(가상계좌 3, 무통장입금 7)이 그대로 노출됐다.
*
* 실측(2026-07-29): `auto_cancel_days = 3` 인 사이트에서 무통장입금 주문서가
* "입금 기한: 7일 이내" 로 안내했다. 실제로는 3일 뒤 자동취소된다 —
* 안내를 믿은 구매자는 주문이 취소된 뒤에야 알게 된다.
*
* 회귀 차단 포인트:
* 1. 두 안내 모두 `order_settings.auto_cancel_days` 를 참조한다.
* 2. 서버에 없는 `*_due_days` 키를 참조하지 않는다 (되살아나면 폴백으로 조용히 회귀).
* 3. 폴백 값은 서버 기본치(3)와 같다 — 설정 응답이 늦거나 실패해도 안내가 어긋나지 않도록.
*
* 후속 결함(브라우저 실측 2026-08-01): 폴백이 `?? 3`(nullish) 이라 `null`/`undefined` 만
* 막고 **빈 문자열은 통과**했다. 설정에 `auto_cancel_days: ""` 가 남아 있으면
* `days=` 가 빈 값으로 넘어가 치환이 스킵되고 주문서에 `입금 기한: {{days}}일 이내` 가
* 그대로 노출된다(실측: `/api/modules/sirsoft-ecommerce/settings/payment` 가 `""` 를 그대로 응답).
* 서버(`OrderProcessingService::resolveAutoCancelDays`)는 같은 값에서 3일로 폴백하므로,
* 화면만 깨지고 실제 기한은 정상이라 조용한 불일치가 된다.
*
* 따라서 폴백 판정은 서버와 같은 규칙(숫자로 해석되는 양수만 사용, 그 외 기본치 3)이어야 한다.
*/
import { describe, it, expect } from 'vitest';
import checkoutPayment from '../../layouts/partials/shop/_checkout_payment.json';
const raw = JSON.stringify(checkoutPayment);
/** 서버 SSoT 키 — 결제수단 무관 단일 기준 */
const SSOT_KEY = 'order_settings?.auto_cancel_days';
/** 서버 기본치 (`OrderProcessingService::AUTO_CANCEL_DAYS_DEFAULT`) */
const SERVER_DEFAULT = 3;
/**
* 안내 문구의 `days=` 바인딩 표현식을 레이아웃에서 추출합니다.
*
* @param translationKey 다국어 키 (vbank_due_notice / dbank_due_notice)
* @returns `{{ }}` 안쪽 표현식 본문
*/
function extractDaysExpression(translationKey: string): string {
const entry = raw.match(new RegExp(`\\$t:shop\\.checkout\\.${translationKey}\\|days=\\{\\{(.+?)\\}\\}`));
expect(entry, `${translationKey} 안내의 days 바인딩을 찾지 못했습니다`).not.toBeNull();
return entry![1];
}
/**
* 추출한 표현식을 실제로 평가합니다 — 문자열 형태가 아닌 **동작**을 잠급니다.
*
* @param expression 레이아웃에서 추출한 표현식
* @param autoCancelDays 설정 응답이 담고 있는 원본 값
* @returns 안내 문구에 치환될 값
*/
function evaluateDays(expression: string, autoCancelDays: unknown): unknown {
const fn = new Function('paymentSettings', `return (${expression});`);
return fn({ data: { order_settings: { auto_cancel_days: autoCancelDays } } });
}
const NOTICES: Array<[string, string]> = [
['가상계좌', 'vbank_due_notice'],
['무통장입금', 'dbank_due_notice'],
];
describe('주문서 입금 기한 안내 — 서버 SSoT 바인딩 (#493 E1)', () => {
it.each(NOTICES)('%s 안내가 auto_cancel_days 를 참조한다', (_label, translationKey) => {
expect(extractDaysExpression(translationKey)).toContain(SSOT_KEY);
});
it.each([
['vbank_due_days'],
['dbank_due_days'],
])('서버에 없는 %s 키를 참조하지 않는다', (orphanKey) => {
expect(
raw.includes(orphanKey),
`${orphanKey} 는 설정 응답에 존재하지 않는 키입니다 — 표현식이 항상 undefined 가 되어 하드코딩 폴백이 노출됩니다.`
).toBe(false);
});
});
describe('주문서 입금 기한 안내 — 오염된 설정값 폴백 (서버 규칙 일치)', () => {
/** [라벨, 저장된 값, 기대 표시값] — 서버 resolveAutoCancelDays 와 동일 규칙 */
const CASES: Array<[string, unknown, number]> = [
['빈 문자열 (검증 강화 이전 저장분)', '', SERVER_DEFAULT],
['null', null, SERVER_DEFAULT],
['undefined (키 부재)', undefined, SERVER_DEFAULT],
['0 (즉시 만료 — 실질 사용 불가)', 0, SERVER_DEFAULT],
['음수', -1, SERVER_DEFAULT],
['비숫자 문자열', 'abc', SERVER_DEFAULT],
['숫자 문자열', '5', 5],
['정수', 5, 5],
];
describe.each(NOTICES)('%s 안내', (_label, translationKey) => {
it.each(CASES)('%s → %s 일로 안내한다', (_caseLabel, stored, expected) => {
expect(evaluateDays(extractDaysExpression(translationKey), stored)).toBe(expected);
});
});
});