# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: 4019/dev/manual-payment-error-source.spec.ts >> [4019][dev] [QA] 수기결제 실패 메시지 PG 구분 검증 >> [BO] role / 스토어 수기결제 / 수기 결제 1회 한도 이상 금액으로 결제 시도 후 에러 메시지에 (PG)가 표시되지 않는지 확인
- Location: src/qa/scenarios/manualPaymentErrorSource.ts:121:7

# Error details

```
Error: BO 계정에 VE 수기결제 권한 접근 제한 안내가 표시되면 안 됩니다.

expect(received).toBe(expected) // Object.is equality

Expected: ""
Received: "접근 제한 가맹점(VE) 수기결제 권한이 없어 홈으로 이동합니다."
```

# Test source

```ts
  130 |           await openAuthenticatedTarget(page, runtime, paymentPage);
  131 |           await assertPaymentAccess(page, testInfo, paymentPage.name, role, "limit-error-without-pg-prefix");
  132 |           const userInfoResponse = await userInfoPromise;
  133 |           const onceLimit = await readOnceLimit(userInfoResponse);
  134 |           const attemptAmount = onceLimit + 1;
  135 |           const inputs: PaymentInputs = {
  136 |             ...paymentConfig.values!,
  137 |             productName: `QA4019-${role}-LIMIT-${Date.now()}`,
  138 |             amount: attemptAmount
  139 |           };
  140 |
  141 |           let observedResponse: Response | undefined;
  142 |           const responseListener = (response: Response): void => {
  143 |             if (!observedResponse && response.request().method() === "POST" && response.url().includes(PAYMENT_PATH)) {
  144 |               observedResponse = response;
  145 |             }
  146 |           };
  147 |           page.on("response", responseListener);
  148 |           try {
  149 |             await fillPaymentForm(page, inputs);
  150 |             await submitPayment(page);
  151 |             const visibleMessage = await waitForVisibleError(page, /1회|일회|한도|초과/, 30_000);
  152 |             await page.waitForTimeout(1_000);
  153 |             const observation = await readPaymentObservation(observedResponse);
  154 |
  155 |             await attachEvidenceLog(testInfo, paymentPage.name, role, "limit-error-without-pg-prefix", [
  156 |               `조회 시점 1회 결제 한도: ${formatAmount(onceLimit)}원`,
  157 |               `결제 시도 금액: ${formatAmount(attemptAmount)}원`,
  158 |               `결제 API 호출 관찰 여부: ${observation.responseObserved ? "관찰됨" : "클라이언트에서 차단되어 미관찰"}`,
  159 |               `결제 API 응답 상태: ${observation.responseStatus ?? "해당 없음"}`,
  160 |               `API 실패 메시지: ${observation.message || "해당 없음"}`,
  161 |               `화면 실패 메시지: ${visibleMessage}`,
  162 |               `거래 ID 생성 여부: ${observation.transactionId ? "생성됨(비정상)" : "미생성"}`,
  163 |               "결론: 1회 한도 초과 실패 메시지에는 (PG) 접두어가 없고 승인 거래는 생성되지 않았습니다."
  164 |             ]);
  165 |             await attachMaskedScreenshot(page, testInfo, paymentPage.name, role, "limit-error-without-pg-prefix");
  166 |
  167 |             expect(observation.success, "1회 한도 초과 결제가 성공하면 안 됩니다.").not.toBe(true);
  168 |             expect(observation.transactionId, "한도 초과 실패에서 거래 ID가 생성되면 안 됩니다.").toBeFalsy();
  169 |             expect(visibleMessage, "1회 한도 초과 화면 메시지가 있어야 합니다.").toMatch(/1회|일회|한도|초과/);
  170 |             expect(visibleMessage, "내부 한도 초과 화면 메시지에 (PG)가 표시되면 안 됩니다.").not.toContain("(PG)");
  171 |             if (observation.message) {
  172 |               expect(observation.message, "내부 한도 초과 API 메시지에 (PG)가 표시되면 안 됩니다.").not.toContain("(PG)");
  173 |             }
  174 |           } finally {
  175 |             page.off("response", responseListener);
  176 |           }
  177 |         });
  178 |       });
  179 |     }
  180 |   });
  181 | }
  182 |
  183 | async function openAuthenticatedTarget(
  184 |   page: Page,
  185 |   runtime: RuntimeQaEnv,
  186 |   pageDefinition: QaPageDefinition
  187 | ): Promise<void> {
  188 |   if (!runtime.baseUrl) {
  189 |     blocked("스토어 base URL 설정이 필요합니다.");
  190 |   }
  191 |   const targetUrl = buildPageUrl(runtime.baseUrl, pageDefinition, runtime.tenantId ?? "");
  192 |
  193 |   if (!runtime.storageState) {
  194 |     await loginWithCredentials(page, runtime);
  195 |   }
  196 |   await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
  197 |   await waitForNavigationToSettle(page);
  198 |   if (await isLoginPage(page, runtime)) {
  199 |     await loginWithCredentials(page, runtime);
  200 |     await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
  201 |     await waitForNavigationToSettle(page);
  202 |   }
  203 |
  204 |   expect(await isLoginPage(page, runtime), "수기결제 검증 전 로그인 화면을 벗어나야 합니다.").toBe(false);
  205 |   await expect(page.locator("body"), "스토어 화면 본문이 표시되어야 합니다.").toBeVisible();
  206 | }
  207 |
  208 | async function assertPaymentAccess(
  209 |   page: Page,
  210 |   testInfo: TestInfo,
  211 |   pageName: string,
  212 |   role: string,
  213 |   suffix: string
  214 | ): Promise<void> {
  215 |   const bodyText = normalizeText(await page.locator("body").innerText());
  216 |   const restrictionText = bodyText.match(ACCESS_RESTRICTION)?.[0] ?? "";
  217 |   const paymentForm = page.getByPlaceholder("상품명을 입력해주세요.");
  218 |   const paymentFormVisible = await paymentForm.isVisible({ timeout: 5_000 }).catch(() => false);
  219 |
  220 |   if (restrictionText || !paymentFormVisible) {
  221 |     await attachEvidenceLog(testInfo, pageName, role, suffix, [
  222 |       `${role} 개발계 계정의 새 로그인 세션으로 스토어 수기결제 URL에 직접 접근했습니다.`,
  223 |       "권한 반영 후 기대 결과: 수기결제 입력 폼 표시",
  224 |       `실제 접근 결과: ${restrictionText || "수기결제 입력 폼 미표시"}`,
  225 |       "결론: 권한 반영 후에도 수기결제 입력 단계에 진입하지 못해 오류 메시지 검증을 수행할 수 없습니다."
  226 |     ]);
  227 |     await attachMaskedScreenshot(page, testInfo, pageName, role, suffix);
  228 |   }
  229 |
> 230 |   expect(restrictionText, `${role} 계정에 VE 수기결제 권한 접근 제한 안내가 표시되면 안 됩니다.`).toBe("");
      |                                                                           ^ Error: BO 계정에 VE 수기결제 권한 접근 제한 안내가 표시되면 안 됩니다.
  231 |   await expect(paymentForm, `${role} 계정에 수기결제 입력 화면이 표시되어야 합니다.`).toBeVisible({ timeout: 20_000 });
  232 | }
  233 |
  234 | async function fillPaymentForm(page: Page, values: PaymentInputs): Promise<void> {
  235 |   await fillVisible(page.getByPlaceholder("상품명을 입력해주세요."), values.productName);
  236 |   await fillVisible(page.getByPlaceholder("판매가격"), String(values.amount));
  237 |   const quantity = page.getByPlaceholder("수량");
  238 |   if (await quantity.first().isVisible({ timeout: 1_000 }).catch(() => false)) {
  239 |     await quantity.first().fill("1");
  240 |   }
  241 |   await fillVisible(page.getByPlaceholder("- 없이 입력해주세요."), values.cardNumber);
  242 |   await selectDropdown(page, "MM", values.expiryMonth);
  243 |   await selectDropdown(page, "YYYY", values.expiryYear);
  244 |   await fillVisible(page.getByPlaceholder("2자리"), values.cardPassword);
  245 |   await fillVisible(page.getByPlaceholder("6자리"), values.birth);
  246 |   await fillVisible(page.getByPlaceholder("휴대폰 번호"), values.payerPhone.replace(/^010/, ""));
  247 |   await fillVisible(page.getByPlaceholder("이름을 입력해주세요."), values.payerName);
  248 |   await expect(page.getByRole("button", { name: "결제하기", exact: true }).last(), "결제 입력 완료 후 결제하기 버튼이 활성화되어야 합니다.").toBeEnabled();
  249 | }
  250 |
  251 | async function submitPayment(page: Page): Promise<void> {
  252 |   await page.getByRole("button", { name: "결제하기", exact: true }).last().click();
  253 |   const confirmButton = page.getByRole("button", { name: "결제", exact: true }).last();
  254 |   if (await confirmButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
  255 |     await confirmButton.click();
  256 |   }
  257 | }
  258 |
  259 | async function readOnceLimit(response: Response | undefined): Promise<number> {
  260 |   if (!response) {
  261 |     blocked("사용자 정보 API 응답을 확인하지 못해 1회 결제 한도를 동적으로 산정할 수 없습니다.");
  262 |   }
  263 |   expect(response.status(), "사용자 정보 API가 성공해야 합니다.").toBe(200);
  264 |   const body = await response.json() as UserInfoEnvelope;
  265 |   const onceLimit = Number(body.payload?.department?.paymentPolicy?.onceLimit);
  266 |   if (!Number.isFinite(onceLimit) || onceLimit <= 0) {
  267 |     blocked("사용자 정보 API에서 유효한 1회 결제 한도를 확인하지 못했습니다.");
  268 |   }
  269 |   return onceLimit;
  270 | }
  271 |
  272 | async function readPaymentObservation(response: Response | undefined): Promise<PaymentObservation> {
  273 |   if (!response) {
  274 |     return { responseObserved: false, message: "" };
  275 |   }
  276 |   const body = await response.json().catch(() => ({})) as Record<string, unknown>;
  277 |   const payload = asRecord(body.payload);
  278 |   const result = asRecord(body.result);
  279 |   const payloadResult = asRecord(payload.result);
  280 |   return {
  281 |     responseObserved: true,
  282 |     responseStatus: response.status(),
  283 |     success: firstBoolean(
  284 |       payload.isSuccess,
  285 |       payload.success,
  286 |       payloadResult.isSuccess,
  287 |       payloadResult.success,
  288 |       body.isSuccess,
  289 |       body.success
  290 |     ),
  291 |     transactionId: firstString(
  292 |       payload.transactionId,
  293 |       payload.paymentId,
  294 |       payloadResult.transactionId,
  295 |       body.transactionId
  296 |     ),
  297 |     resultCode: firstString(payload.resultCode, payload.code, payloadResult.code, result.code, body.code),
  298 |     message: firstString(
  299 |       payload.resultMessage,
  300 |       payload.message,
  301 |       payloadResult.message,
  302 |       result.message,
  303 |       body.resultMessage,
  304 |       body.message
  305 |     ) ?? ""
  306 |   };
  307 | }
  308 |
  309 | async function waitForVisibleError(page: Page, expected: RegExp, timeoutMs: number): Promise<string> {
  310 |   let matched = "";
  311 |   await expect.poll(async () => {
  312 |     matched = await readVisibleMessage(page, expected);
  313 |     return matched;
  314 |   }, {
  315 |     message: `화면에서 오류 문구 ${expected}가 표시되어야 합니다.`,
  316 |     timeout: timeoutMs,
  317 |     intervals: [250, 500, 1_000]
  318 |   }).not.toBe("");
  319 |   return matched;
  320 | }
  321 |
  322 | async function readVisibleMessage(page: Page, expected: RegExp): Promise<string> {
  323 |   const candidates = page.locator("[role='dialog']:visible, [role='alert']:visible, .Toastify__toast:visible, [class*='toast']:visible, [class*='swal']:visible");
  324 |   const count = Math.min(await candidates.count().catch(() => 0), 20);
  325 |   for (let index = 0; index < count; index += 1) {
  326 |     const text = normalizeText(await candidates.nth(index).innerText().catch(() => ""));
  327 |     if (text && expected.test(text)) {
  328 |       return extractMatchingText(text, expected) || text;
  329 |     }
  330 |   }
```