From 7842da81084fcdd6b302dd4e8c3d4444402cd90f Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Fri, 24 Apr 2026 20:30:25 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=EA=B2=B0=EA=B3=BC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/createResultPageFetcher.test.ts | 45 +++++++++++++++++++ .../Result/api/createResultPageFetcher.ts | 13 +++++- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 src/pages/Result/api/createResultPageFetcher.test.ts diff --git a/src/pages/Result/api/createResultPageFetcher.test.ts b/src/pages/Result/api/createResultPageFetcher.test.ts new file mode 100644 index 0000000..1dec5e4 --- /dev/null +++ b/src/pages/Result/api/createResultPageFetcher.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '@/shared/api/errors/apiError'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; + +import { createResultPageFetcher } from './createResultPageFetcher'; + +vi.mock('@/shared/lib/scan-session/ensureScanDetail', () => { + return { + ensureScanDetail: vi.fn(), + resolveRequestedUrlFromSearch: vi.fn(() => null), + }; +}); + +describe('createResultPageFetcher', () => { + beforeEach(() => { + useScanSessionStore.getState().clearSession(); + vi.clearAllMocks(); + }); + + it('falls back to session result data when scan detail is not found yet', async () => { + const { ensureScanDetail } = await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(ensureScanDetail).mockRejectedValue( + new ApiError({ + message: 'Request failed with status code 404', + statusCode: 404, + }), + ); + + useScanSessionStore.getState().setFinalResult({ + finalUrl: 'http://naver-login-check.xyz', + originalUrl: 'http://naver-login-check.xyz', + riskLevel: 'safe', + score: 23, + }); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).resolves.toMatchObject({ + siteName: 'http://naver-login-check.xyz', + siteUrl: 'http://naver-login-check.xyz', + trustScore: 23, + }); + }); +}); diff --git a/src/pages/Result/api/createResultPageFetcher.ts b/src/pages/Result/api/createResultPageFetcher.ts index 81003b9..e46b1de 100644 --- a/src/pages/Result/api/createResultPageFetcher.ts +++ b/src/pages/Result/api/createResultPageFetcher.ts @@ -1,3 +1,4 @@ +import { isApiError } from '@/shared/api/errors/apiError'; import { ensureScanDetail, resolveRequestedUrlFromSearch, @@ -32,8 +33,16 @@ export function createResultPageFetcher(tone: ResultTone): ResultPageFetcher { ); if (!session.analysisDetail && hasRecoverableUrl) { - const detailSession = await ensureScanDetail(); - return toResultPageData(detailSession, tone); + try { + const detailSession = await ensureScanDetail(); + return toResultPageData(detailSession, tone); + } catch (error) { + if (isApiError(error) && error.statusCode === 404 && hasSessionResult(session)) { + return toResultPageData(session, tone); + } + + throw error; + } } if (hasSessionResult(session)) { From f95f2a4511269eed97bccf90e23704c9e4936c30 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Fri, 24 Apr 2026 20:31:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=EC=BA=A1=EC=B0=A8=20=EC=97=94?= =?UTF-8?q?=ED=84=B0=ED=94=84=EB=9D=BC=EC=9D=B4=EC=A6=88=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- scripts/setupEnv.mjs | 2 +- src/pages/Captcha/CaptchaPage.tsx | 17 +- src/pages/Captcha/hooks/useCaptchaPage.ts | 37 ++-- src/pages/Captcha/lib/recaptchaEnterprise.ts | 115 +++++++++++ src/pages/Captcha/ui/CaptchaWidget.tsx | 204 ++----------------- 6 files changed, 151 insertions(+), 226 deletions(-) create mode 100644 src/pages/Captcha/lib/recaptchaEnterprise.ts diff --git a/.env.example b/.env.example index e97236d..408e04d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Google reCAPTCHA Enterprise checkbox site key +# Google reCAPTCHA Enterprise site key VITE_RECAPTCHA_SITE_KEY= # Browser-facing backend base paths. In Vercel these route through vercel.json rewrites. diff --git a/scripts/setupEnv.mjs b/scripts/setupEnv.mjs index 29c71a9..4478298 100644 --- a/scripts/setupEnv.mjs +++ b/scripts/setupEnv.mjs @@ -14,7 +14,7 @@ const serverEnvLocalPath = path.join(rootDir, '.env.server.local'); const OWNER_READ_WRITE_MODE = 0o600; const defaultEnvContent = [ - '# Google reCAPTCHA Enterprise checkbox site key (required when provider is googleRecaptchaEnterprise)', + '# Google reCAPTCHA Enterprise site key (required when provider is googleRecaptchaEnterprise)', 'VITE_RECAPTCHA_SITE_KEY=', '', '# Backend base URLs. Provide deployed values via .env.local or secret management.', diff --git a/src/pages/Captcha/CaptchaPage.tsx b/src/pages/Captcha/CaptchaPage.tsx index 5f8d164..507c3fe 100644 --- a/src/pages/Captcha/CaptchaPage.tsx +++ b/src/pages/Captcha/CaptchaPage.tsx @@ -6,14 +6,8 @@ import * as styles from './styles/captchaPage.css'; import CaptchaWidget from './ui/CaptchaWidget'; export default function CaptchaPage() { - const { - canSubmit, - feedbackMessage, - handleCaptchaTokenChange, - handleSubmit, - isVerifying, - recaptchaSiteKey, - } = useCaptchaPage(); + const { canSubmit, feedbackMessage, handleSubmit, isVerifying, recaptchaSiteKey } = + useCaptchaPage(); return (
@@ -28,12 +22,7 @@ export default function CaptchaPage() {

Google reCAPTCHA Enterprise

-
- -
+

체크 후 바로 통과될 수도 있고, 필요 시 이미지 문제 풀이가 추가로 표시됩니다. diff --git a/src/pages/Captcha/hooks/useCaptchaPage.ts b/src/pages/Captcha/hooks/useCaptchaPage.ts index 3a70925..5c321fd 100644 --- a/src/pages/Captcha/hooks/useCaptchaPage.ts +++ b/src/pages/Captcha/hooks/useCaptchaPage.ts @@ -2,64 +2,63 @@ import { useNavigate } from '@tanstack/react-router'; import { useCallback, useState } from 'react'; import { submitCaptchaVerification } from '../api/submitCaptchaVerification'; +import { executeRecaptchaEnterprise } from '../lib/recaptchaEnterprise'; type UseCaptchaPageReturn = { canSubmit: boolean; feedbackMessage: string | null; - handleCaptchaTokenChange: (nextToken: string | null) => void; handleSubmit: () => Promise; isVerifying: boolean; recaptchaSiteKey: string; }; +function resolveRecaptchaTokenErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return `reCAPTCHA 토큰 발급 실패: ${error.message}`; + } + + return 'reCAPTCHA 토큰을 발급하지 못했습니다.'; +} + export function useCaptchaPage(): UseCaptchaPageReturn { const navigate = useNavigate(); - const [token, setToken] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(null); const [isVerifying, setIsVerifying] = useState(false); const recaptchaSiteKey = (import.meta.env.VITE_RECAPTCHA_SITE_KEY ?? '').trim(); - const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0; - - const handleCaptchaTokenChange = useCallback((nextToken: string | null) => { - setToken(nextToken); - setFeedbackMessage(null); - }, []); + const canSubmit = recaptchaSiteKey.length > 0 && !isVerifying; const handleSubmit = useCallback(async () => { setFeedbackMessage(null); - const trimmedToken = token?.trim() ?? ''; if (recaptchaSiteKey.length === 0) { - setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다. `.env.local`을 확인해 주세요.'); - return; - } - - if (!trimmedToken) { - setFeedbackMessage('캡차를 먼저 완료해 주세요.'); + setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다.'); return; } setIsVerifying(true); try { - const result = await submitCaptchaVerification({ token: trimmedToken }); + const captchaToken = await executeRecaptchaEnterprise(recaptchaSiteKey); + const result = await submitCaptchaVerification({ token: captchaToken }); if (!result.success) { setFeedbackMessage(result.message ?? '캡차 검증에 실패했습니다.'); return; } - setFeedbackMessage('캡차 검증이 완료되었습니다. 스캔 화면으로 이동합니다.'); + setFeedbackMessage('캡차 검증이 완료되었습니다.'); void navigate({ to: '/qr-scan' }); + } catch (error) { + console.error('Failed to execute reCAPTCHA Enterprise.', error); + setFeedbackMessage(resolveRecaptchaTokenErrorMessage(error)); } finally { setIsVerifying(false); } - }, [navigate, recaptchaSiteKey, token]); + }, [navigate, recaptchaSiteKey]); return { canSubmit, feedbackMessage, - handleCaptchaTokenChange, handleSubmit, isVerifying, recaptchaSiteKey, diff --git a/src/pages/Captcha/lib/recaptchaEnterprise.ts b/src/pages/Captcha/lib/recaptchaEnterprise.ts new file mode 100644 index 0000000..80eae24 --- /dev/null +++ b/src/pages/Captcha/lib/recaptchaEnterprise.ts @@ -0,0 +1,115 @@ +const DEFAULT_RECAPTCHA_ACTION = 'USER_ACTION'; + +type GrecaptchaEnterprise = { + execute: (siteKey: string, options: { action: string }) => Promise; + ready: (callback: () => void) => void; +}; + +declare global { + interface Window { + grecaptcha?: { + enterprise?: GrecaptchaEnterprise; + }; + } +} + +let enterpriseScriptPromise: Promise | null = null; + +function getEnterpriseApi(): GrecaptchaEnterprise | null { + return window.grecaptcha?.enterprise ?? null; +} + +function isEnterpriseApiReady(): boolean { + return typeof window.grecaptcha?.enterprise?.execute === 'function'; +} + +export function loadRecaptchaEnterprise(siteKey: string): Promise { + if (isEnterpriseApiReady()) { + return Promise.resolve(); + } + + if (enterpriseScriptPromise) { + return enterpriseScriptPromise; + } + + const scriptPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector( + 'script[data-recaptcha-enterprise="true"]', + ); + + const onLoad = () => { + if (isEnterpriseApiReady()) { + resolve(); + return; + } + + reject(new Error('Enterprise API is not ready after script load.')); + }; + + const onError = () => { + reject(new Error('Failed to load reCAPTCHA Enterprise script.')); + }; + + if (existingScript) { + const loadStatus = existingScript.dataset.loadStatus; + + if (loadStatus === 'loaded' || isEnterpriseApiReady()) { + onLoad(); + return; + } + + if (loadStatus === 'error') { + onError(); + return; + } + + existingScript.addEventListener('load', onLoad, { once: true }); + existingScript.addEventListener('error', onError, { once: true }); + return; + } + + const script = document.createElement('script'); + script.async = true; + script.defer = true; + script.dataset.loadStatus = 'loading'; + script.dataset.recaptchaEnterprise = 'true'; + script.src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`; + + script.onload = () => { + script.dataset.loadStatus = 'loaded'; + onLoad(); + }; + + script.onerror = () => { + script.dataset.loadStatus = 'error'; + onError(); + }; + + document.head.appendChild(script); + }).catch((error) => { + enterpriseScriptPromise = null; + throw error; + }); + + enterpriseScriptPromise = scriptPromise; + return scriptPromise; +} + +export async function executeRecaptchaEnterprise( + siteKey: string, + action = DEFAULT_RECAPTCHA_ACTION, +): Promise { + await loadRecaptchaEnterprise(siteKey); + + const enterprise = getEnterpriseApi(); + + if (!enterprise) { + throw new Error('reCAPTCHA Enterprise API is unavailable.'); + } + + return new Promise((resolve, reject) => { + enterprise.ready(() => { + enterprise.execute(siteKey, { action }).then(resolve).catch(reject); + }); + }); +} diff --git a/src/pages/Captcha/ui/CaptchaWidget.tsx b/src/pages/Captcha/ui/CaptchaWidget.tsx index ee45efe..8d6649e 100644 --- a/src/pages/Captcha/ui/CaptchaWidget.tsx +++ b/src/pages/Captcha/ui/CaptchaWidget.tsx @@ -1,209 +1,31 @@ import { useEffect, useRef } from 'react'; -import * as styles from '../styles/captchaPage.css'; +import { loadRecaptchaEnterprise } from '../lib/recaptchaEnterprise'; type CaptchaWidgetProps = { - onTokenChange: (token: string | null) => void; recaptchaSiteKey: string; }; -type GrecaptchaEnterprise = { - ready: (callback: () => void) => void; - render: ( - container: HTMLElement, - parameters: { - callback: (token: string) => void; - 'error-callback': () => void; - 'expired-callback': () => void; - sitekey: string; - theme?: 'light' | 'dark'; - }, - ) => number; - reset?: (widgetId: number) => void; -}; - -declare global { - interface Window { - grecaptcha?: { - enterprise?: GrecaptchaEnterprise; - }; - } -} - -let enterpriseScriptPromise: Promise | null = null; - -function isEnterpriseApiReady() { - return typeof window.grecaptcha?.enterprise?.render === 'function'; -} - -function loadEnterpriseScript(): Promise { - if (isEnterpriseApiReady()) { - return Promise.resolve(); - } - - if (enterpriseScriptPromise) { - return enterpriseScriptPromise; - } - - const scriptPromise = new Promise((resolve, reject) => { - const existingScript = document.querySelector( - 'script[data-recaptcha-enterprise="true"]', - ); - - const onLoad = () => { - if (isEnterpriseApiReady()) { - resolve(); - return; - } - - reject(new Error('Enterprise API is not ready after script load')); - }; - - const onError = () => { - reject(new Error('Failed to load reCAPTCHA Enterprise script')); - }; - - if (existingScript) { - const loadStatus = existingScript.dataset.loadStatus; - - if (loadStatus === 'loaded' || isEnterpriseApiReady()) { - onLoad(); - return; - } - - if (loadStatus === 'error') { - onError(); - return; - } - - existingScript.addEventListener('load', onLoad, { once: true }); - existingScript.addEventListener('error', onError, { once: true }); - return; - } - - const script = document.createElement('script'); - script.async = true; - script.defer = true; - script.dataset.loadStatus = 'loading'; - script.dataset.recaptchaEnterprise = 'true'; - script.src = 'https://www.google.com/recaptcha/enterprise.js?render=explicit'; - - script.onload = () => { - script.dataset.loadStatus = 'loaded'; - onLoad(); - }; - - script.onerror = () => { - script.dataset.loadStatus = 'error'; - onError(); - }; - - document.head.appendChild(script); - }).catch((error) => { - enterpriseScriptPromise = null; - throw error; - }); - - enterpriseScriptPromise = scriptPromise; - return scriptPromise; -} - -export default function CaptchaWidget({ onTokenChange, recaptchaSiteKey }: CaptchaWidgetProps) { - const widgetContainerRef = useRef(null); +export default function CaptchaWidget({ recaptchaSiteKey }: CaptchaWidgetProps) { + const isMountedRef = useRef(false); useEffect(() => { - const container = widgetContainerRef.current; - let cancelled = false; - let widgetId: number | null = null; - - if (!container || !recaptchaSiteKey) { + if (!recaptchaSiteKey) { return; } - container.innerHTML = ''; - - loadEnterpriseScript() - .then(() => { - if (cancelled || widgetContainerRef.current !== container) { - return; - } - - const enterprise = window.grecaptcha?.enterprise; - - if (!enterprise) { - if (cancelled) { - return; - } + isMountedRef.current = true; - onTokenChange(null); - return; - } - - enterprise.ready(() => { - if (cancelled || widgetContainerRef.current !== container) { - return; - } - - try { - widgetId = enterprise.render(container, { - callback: (nextToken) => { - if (cancelled) { - return; - } - - onTokenChange(nextToken); - }, - 'error-callback': () => { - if (cancelled) { - return; - } - - onTokenChange(null); - }, - 'expired-callback': () => { - if (cancelled) { - return; - } - - onTokenChange(null); - }, - sitekey: recaptchaSiteKey, - theme: 'light', - }); - } catch (error) { - widgetId = null; - - if (cancelled) { - return; - } - - console.error('Failed to render reCAPTCHA Enterprise widget.', error); - onTokenChange(null); - } - }); - }) - .catch(() => { - if (cancelled) { - return; - } - - onTokenChange(null); - }); - - return () => { - cancelled = true; - - if (widgetId !== null) { - window.grecaptcha?.enterprise?.reset?.(widgetId); + loadRecaptchaEnterprise(recaptchaSiteKey).catch((error) => { + if (isMountedRef.current) { + console.error('Failed to load reCAPTCHA Enterprise script.', error); } + }); - container.innerHTML = ''; + return () => { + isMountedRef.current = false; }; - }, [onTokenChange, recaptchaSiteKey]); + }, [recaptchaSiteKey]); - return ( -

-
-
- ); + return null; } From bd2ca51617f513bc5785ab9c518e3a1765ca0797 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Fri, 24 Apr 2026 22:13:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=BA=A1=EC=B0=A8=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=EC=95=88=EC=A0=95=ED=99=94=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20404=20=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Captcha/CaptchaPage.tsx | 17 +- src/pages/Captcha/hooks/useCaptchaPage.ts | 34 +-- src/pages/Captcha/lib/recaptchaEnterprise.ts | 214 ++++++++++++++---- src/pages/Captcha/styles/captchaPage.css.ts | 9 + src/pages/Captcha/ui/CaptchaWidget.tsx | 79 +++++-- src/pages/Result/ResultPage.tsx | 45 +++- .../api/createResultPageFetcher.test.ts | 22 ++ .../Result/api/createResultPageFetcher.ts | 29 ++- src/pages/Result/lib/toResultPageData.ts | 1 + src/pages/Result/types/resultPage.types.ts | 1 + src/pages/Result/ui/ResultStatusPage.tsx | 18 +- 11 files changed, 371 insertions(+), 98 deletions(-) diff --git a/src/pages/Captcha/CaptchaPage.tsx b/src/pages/Captcha/CaptchaPage.tsx index 507c3fe..5f8d164 100644 --- a/src/pages/Captcha/CaptchaPage.tsx +++ b/src/pages/Captcha/CaptchaPage.tsx @@ -6,8 +6,14 @@ import * as styles from './styles/captchaPage.css'; import CaptchaWidget from './ui/CaptchaWidget'; export default function CaptchaPage() { - const { canSubmit, feedbackMessage, handleSubmit, isVerifying, recaptchaSiteKey } = - useCaptchaPage(); + const { + canSubmit, + feedbackMessage, + handleCaptchaTokenChange, + handleSubmit, + isVerifying, + recaptchaSiteKey, + } = useCaptchaPage(); return (
@@ -22,7 +28,12 @@ export default function CaptchaPage() {

Google reCAPTCHA Enterprise

- +
+ +

체크 후 바로 통과될 수도 있고, 필요 시 이미지 문제 풀이가 추가로 표시됩니다. diff --git a/src/pages/Captcha/hooks/useCaptchaPage.ts b/src/pages/Captcha/hooks/useCaptchaPage.ts index 5c321fd..1a3e2a9 100644 --- a/src/pages/Captcha/hooks/useCaptchaPage.ts +++ b/src/pages/Captcha/hooks/useCaptchaPage.ts @@ -2,30 +2,28 @@ import { useNavigate } from '@tanstack/react-router'; import { useCallback, useState } from 'react'; import { submitCaptchaVerification } from '../api/submitCaptchaVerification'; -import { executeRecaptchaEnterprise } from '../lib/recaptchaEnterprise'; type UseCaptchaPageReturn = { canSubmit: boolean; feedbackMessage: string | null; + handleCaptchaTokenChange: (nextToken: string | null) => void; handleSubmit: () => Promise; isVerifying: boolean; recaptchaSiteKey: string; }; -function resolveRecaptchaTokenErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return `reCAPTCHA 토큰 발급 실패: ${error.message}`; - } - - return 'reCAPTCHA 토큰을 발급하지 못했습니다.'; -} - export function useCaptchaPage(): UseCaptchaPageReturn { const navigate = useNavigate(); + const [token, setToken] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(null); const [isVerifying, setIsVerifying] = useState(false); const recaptchaSiteKey = (import.meta.env.VITE_RECAPTCHA_SITE_KEY ?? '').trim(); - const canSubmit = recaptchaSiteKey.length > 0 && !isVerifying; + const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0 && !isVerifying; + + const handleCaptchaTokenChange = useCallback((nextToken: string | null) => { + setToken(nextToken); + setFeedbackMessage(null); + }, []); const handleSubmit = useCallback(async () => { setFeedbackMessage(null); @@ -35,11 +33,17 @@ export function useCaptchaPage(): UseCaptchaPageReturn { return; } + const trimmedToken = token?.trim() ?? ''; + + if (!trimmedToken) { + setFeedbackMessage('캡차를 먼저 완료해 주세요.'); + return; + } + setIsVerifying(true); try { - const captchaToken = await executeRecaptchaEnterprise(recaptchaSiteKey); - const result = await submitCaptchaVerification({ token: captchaToken }); + const result = await submitCaptchaVerification({ token: trimmedToken }); if (!result.success) { setFeedbackMessage(result.message ?? '캡차 검증에 실패했습니다.'); @@ -48,17 +52,15 @@ export function useCaptchaPage(): UseCaptchaPageReturn { setFeedbackMessage('캡차 검증이 완료되었습니다.'); void navigate({ to: '/qr-scan' }); - } catch (error) { - console.error('Failed to execute reCAPTCHA Enterprise.', error); - setFeedbackMessage(resolveRecaptchaTokenErrorMessage(error)); } finally { setIsVerifying(false); } - }, [navigate, recaptchaSiteKey]); + }, [navigate, recaptchaSiteKey, token]); return { canSubmit, feedbackMessage, + handleCaptchaTokenChange, handleSubmit, isVerifying, recaptchaSiteKey, diff --git a/src/pages/Captcha/lib/recaptchaEnterprise.ts b/src/pages/Captcha/lib/recaptchaEnterprise.ts index 80eae24..e5b6c92 100644 --- a/src/pages/Captcha/lib/recaptchaEnterprise.ts +++ b/src/pages/Captcha/lib/recaptchaEnterprise.ts @@ -1,8 +1,24 @@ -const DEFAULT_RECAPTCHA_ACTION = 'USER_ACTION'; +type RenderCaptchaOptions = { + onError: () => void; + onExpired: () => void; + onToken: (token: string) => void; + signal?: AbortSignal; + siteKey: string; +}; type GrecaptchaEnterprise = { - execute: (siteKey: string, options: { action: string }) => Promise; ready: (callback: () => void) => void; + render: ( + container: HTMLElement, + parameters: { + callback: (token: string) => void; + 'error-callback': () => void; + 'expired-callback': () => void; + sitekey: string; + theme?: 'light' | 'dark'; + }, + ) => number; + reset?: (widgetId: number) => void; }; declare global { @@ -14,79 +30,135 @@ declare global { } let enterpriseScriptPromise: Promise | null = null; +const enterpriseScriptSources = [ + 'https://www.google.com/recaptcha/enterprise.js?render=explicit', + 'https://www.recaptcha.net/recaptcha/enterprise.js?render=explicit', +] as const; +const enterpriseApiReadyTimeoutMs = 5_000; +const enterpriseApiReadyCheckIntervalMs = 50; function getEnterpriseApi(): GrecaptchaEnterprise | null { return window.grecaptcha?.enterprise ?? null; } function isEnterpriseApiReady(): boolean { - return typeof window.grecaptcha?.enterprise?.execute === 'function'; + return typeof window.grecaptcha?.enterprise?.render === 'function'; } -export function loadRecaptchaEnterprise(siteKey: string): Promise { +function waitForEnterpriseApiReady(timeoutMs = enterpriseApiReadyTimeoutMs): Promise { if (isEnterpriseApiReady()) { return Promise.resolve(); } - if (enterpriseScriptPromise) { - return enterpriseScriptPromise; - } - - const scriptPromise = new Promise((resolve, reject) => { - const existingScript = document.querySelector( - 'script[data-recaptcha-enterprise="true"]', - ); + return new Promise((resolve, reject) => { + const startedAt = Date.now(); - const onLoad = () => { + const checkReady = () => { if (isEnterpriseApiReady()) { resolve(); return; } - reject(new Error('Enterprise API is not ready after script load.')); + if (Date.now() - startedAt >= timeoutMs) { + reject(new Error('Enterprise API is not ready after script load.')); + return; + } + + window.setTimeout(checkReady, enterpriseApiReadyCheckIntervalMs); + }; + + checkReady(); + }); +} + +function buildScriptSelector(sourceUrl: string): string { + return `script[data-recaptcha-enterprise-src="${sourceUrl}"]`; +} + +function waitForScriptReady(script: HTMLScriptElement): Promise { + return new Promise((resolve, reject) => { + const onLoad = () => { + void waitForEnterpriseApiReady().then(resolve).catch(reject); }; const onError = () => { reject(new Error('Failed to load reCAPTCHA Enterprise script.')); }; - if (existingScript) { - const loadStatus = existingScript.dataset.loadStatus; - - if (loadStatus === 'loaded' || isEnterpriseApiReady()) { - onLoad(); - return; - } + script.addEventListener('load', onLoad, { once: true }); + script.addEventListener('error', onError, { once: true }); + }); +} - if (loadStatus === 'error') { - onError(); - return; - } +async function loadEnterpriseScriptBySource(sourceUrl: string): Promise { + const existingScript = document.querySelector(buildScriptSelector(sourceUrl)); - existingScript.addEventListener('load', onLoad, { once: true }); - existingScript.addEventListener('error', onError, { once: true }); + if (existingScript) { + if (existingScript.dataset.loadStatus === 'loaded' || isEnterpriseApiReady()) { + await waitForEnterpriseApiReady(); return; } - const script = document.createElement('script'); - script.async = true; - script.defer = true; - script.dataset.loadStatus = 'loading'; - script.dataset.recaptchaEnterprise = 'true'; - script.src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`; + if (existingScript.dataset.loadStatus === 'error') { + throw new Error('Failed to load reCAPTCHA Enterprise script.'); + } - script.onload = () => { - script.dataset.loadStatus = 'loaded'; - onLoad(); - }; + await waitForScriptReady(existingScript); + return; + } - script.onerror = () => { - script.dataset.loadStatus = 'error'; - onError(); - }; + const script = document.createElement('script'); + script.async = true; + script.defer = true; + script.dataset.loadStatus = 'loading'; + script.dataset.recaptchaEnterprise = 'true'; + script.dataset.recaptchaEnterpriseSrc = sourceUrl; + script.src = sourceUrl; + + const readyPromise = waitForScriptReady(script); + + script.onload = () => { + script.dataset.loadStatus = 'loaded'; + }; + + script.onerror = () => { + script.dataset.loadStatus = 'error'; + }; - document.head.appendChild(script); - }).catch((error) => { + document.head.appendChild(script); + await readyPromise; +} + +export function loadRecaptchaEnterprise(): Promise { + if (isEnterpriseApiReady()) { + return Promise.resolve(); + } + + if (enterpriseScriptPromise) { + return enterpriseScriptPromise; + } + + const scriptPromise = (async () => { + let lastError: unknown = null; + + for (const sourceUrl of enterpriseScriptSources) { + try { + await loadEnterpriseScriptBySource(sourceUrl); + + if (isEnterpriseApiReady()) { + return; + } + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) { + throw lastError; + } + + throw new Error('Failed to load reCAPTCHA Enterprise script.'); + })().catch((error) => { enterpriseScriptPromise = null; throw error; }); @@ -95,11 +167,15 @@ export function loadRecaptchaEnterprise(siteKey: string): Promise { return scriptPromise; } -export async function executeRecaptchaEnterprise( - siteKey: string, - action = DEFAULT_RECAPTCHA_ACTION, -): Promise { - await loadRecaptchaEnterprise(siteKey); +export async function renderRecaptchaEnterprise( + container: HTMLElement, + { onError, onExpired, onToken, signal, siteKey }: RenderCaptchaOptions, +): Promise { + if (signal?.aborted) { + throw new Error('RECAPTCHA_RENDER_ABORTED'); + } + + await loadRecaptchaEnterprise(); const enterprise = getEnterpriseApi(); @@ -108,8 +184,48 @@ export async function executeRecaptchaEnterprise( } return new Promise((resolve, reject) => { + const abortError = new Error('RECAPTCHA_RENDER_ABORTED'); + const onAbort = () => { + reject(abortError); + }; + + if (signal?.aborted) { + reject(abortError); + return; + } + + signal?.addEventListener('abort', onAbort, { once: true }); + + const detachAbortListener = () => { + signal?.removeEventListener('abort', onAbort); + }; + enterprise.ready(() => { - enterprise.execute(siteKey, { action }).then(resolve).catch(reject); + if (signal?.aborted) { + detachAbortListener(); + reject(abortError); + return; + } + + try { + const widgetId = enterprise.render(container, { + callback: onToken, + 'error-callback': onError, + 'expired-callback': onExpired, + sitekey: siteKey, + theme: 'light', + }); + + detachAbortListener(); + resolve(widgetId); + } catch (error) { + detachAbortListener(); + reject(error); + } }); }); } + +export function resetRecaptchaEnterprise(widgetId: number): void { + window.grecaptcha?.enterprise?.reset?.(widgetId); +} diff --git a/src/pages/Captcha/styles/captchaPage.css.ts b/src/pages/Captcha/styles/captchaPage.css.ts index 0f3e4ae..bbeccaf 100644 --- a/src/pages/Captcha/styles/captchaPage.css.ts +++ b/src/pages/Captcha/styles/captchaPage.css.ts @@ -70,9 +70,18 @@ export const widgetFrame = style({ export const enterpriseBox = style({ display: 'flex', + flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: '78px', + gap: vars.spacing.xs, +}); + +export const captchaError = style({ + margin: 0, + color: '#B42318', + fontSize: vars.font.size.xs, + lineHeight: 1.4, }); export const captchaHint = style({ diff --git a/src/pages/Captcha/ui/CaptchaWidget.tsx b/src/pages/Captcha/ui/CaptchaWidget.tsx index 8d6649e..e04cdb3 100644 --- a/src/pages/Captcha/ui/CaptchaWidget.tsx +++ b/src/pages/Captcha/ui/CaptchaWidget.tsx @@ -1,31 +1,82 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import { loadRecaptchaEnterprise } from '../lib/recaptchaEnterprise'; +import { renderRecaptchaEnterprise, resetRecaptchaEnterprise } from '../lib/recaptchaEnterprise'; +import * as styles from '../styles/captchaPage.css'; type CaptchaWidgetProps = { + onTokenChange: (token: string | null) => void; recaptchaSiteKey: string; }; -export default function CaptchaWidget({ recaptchaSiteKey }: CaptchaWidgetProps) { - const isMountedRef = useRef(false); +export default function CaptchaWidget({ onTokenChange, recaptchaSiteKey }: CaptchaWidgetProps) { + const widgetContainerRef = useRef(null); + const [loadErrorMessage, setLoadErrorMessage] = useState(null); useEffect(() => { - if (!recaptchaSiteKey) { + const container = widgetContainerRef.current; + let disposed = false; + let widgetId: number | null = null; + const renderAbortController = new AbortController(); + + if (!container || !recaptchaSiteKey) { return; } - isMountedRef.current = true; + container.innerHTML = ''; + setLoadErrorMessage(null); - loadRecaptchaEnterprise(recaptchaSiteKey).catch((error) => { - if (isMountedRef.current) { - console.error('Failed to load reCAPTCHA Enterprise script.', error); - } - }); + renderRecaptchaEnterprise(container, { + onError: () => { + if (!disposed) { + onTokenChange(null); + } + }, + onExpired: () => { + if (!disposed) { + onTokenChange(null); + } + }, + onToken: (token) => { + if (!disposed) { + onTokenChange(token); + } + }, + signal: renderAbortController.signal, + siteKey: recaptchaSiteKey, + }) + .then((nextWidgetId) => { + widgetId = nextWidgetId; + }) + .catch((error) => { + if (disposed) { + return; + } + + console.error('Failed to render reCAPTCHA Enterprise widget.', error); + onTokenChange(null); + setLoadErrorMessage( + error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'reCAPTCHA widget failed to load.', + ); + }); return () => { - isMountedRef.current = false; + disposed = true; + renderAbortController.abort(); + + if (widgetId !== null) { + resetRecaptchaEnterprise(widgetId); + } + + container.innerHTML = ''; }; - }, [recaptchaSiteKey]); + }, [onTokenChange, recaptchaSiteKey]); - return null; + return ( +

+
+ {loadErrorMessage ?

{loadErrorMessage}

: null} +
+ ); } diff --git a/src/pages/Result/ResultPage.tsx b/src/pages/Result/ResultPage.tsx index 3013533..20f1547 100644 --- a/src/pages/Result/ResultPage.tsx +++ b/src/pages/Result/ResultPage.tsx @@ -74,6 +74,33 @@ const resultViewConfig = { }, } as const; +const detailUnavailableViewConfig = { + hero: ( + + 아직 분석 결과를 +
+ 가져오지 못했습니다 + + } + tone="warning" + /> + ), + riskLevelCard: { + description: '스캔 결과 상세 데이터가 준비되지 않았습니다.', + levelText: '분석 대기', + tone: 'warning' as const, + }, + siteCard: { + badgeLabel: '?', + statusLabel: 'ANALYSIS NEEDED', + tone: 'warning' as const, + visitLabel: '다시 검사하기', + }, +} as const; + export default function ResultPage({ tone }: ResultPageProps) { const { handleReport, @@ -84,6 +111,8 @@ export default function ResultPage({ tone }: ResultPageProps) { resultData, } = useResultPage(tone); const config = resultViewConfig[tone]; + const isDetailUnavailable = resultData?.detailUnavailable === true; + const resolvedTone: ResultTone = isDetailUnavailable ? 'warning' : tone; if (!resultData) { return null; @@ -92,22 +121,26 @@ export default function ResultPage({ tone }: ResultPageProps) { return ( ); } diff --git a/src/pages/Result/api/createResultPageFetcher.test.ts b/src/pages/Result/api/createResultPageFetcher.test.ts index 1dec5e4..8c42c3a 100644 --- a/src/pages/Result/api/createResultPageFetcher.test.ts +++ b/src/pages/Result/api/createResultPageFetcher.test.ts @@ -42,4 +42,26 @@ describe('createResultPageFetcher', () => { trustScore: 23, }); }); + + it('returns detail-unavailable data when scan detail is missing and only URL query is available', async () => { + const { ensureScanDetail, resolveRequestedUrlFromSearch } = + await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(ensureScanDetail).mockRejectedValue( + new ApiError({ + message: 'Request failed with status code 404', + statusCode: 404, + }), + ); + vi.mocked(resolveRequestedUrlFromSearch).mockReturnValue('https://www.daum.net/'); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).resolves.toMatchObject({ + detailUnavailable: true, + siteMeta: '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.', + siteName: 'https://www.daum.net/', + siteUrl: 'https://www.daum.net/', + trustScore: 0, + }); + }); }); diff --git a/src/pages/Result/api/createResultPageFetcher.ts b/src/pages/Result/api/createResultPageFetcher.ts index e46b1de..7ce5130 100644 --- a/src/pages/Result/api/createResultPageFetcher.ts +++ b/src/pages/Result/api/createResultPageFetcher.ts @@ -25,20 +25,39 @@ function hasSessionResult(session: ScanSessionSnapshot): boolean { ); } +function resolveRecoverableUrl(session: ScanSessionSnapshot): string | null { + return session.decodedUrl ?? session.historySelection?.url ?? resolveRequestedUrlFromSearch(); +} + +function createDetailUnavailableResultPageData(url: string): ResultPageData { + return { + detailUnavailable: true, + previewUrl: url, + siteMeta: '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.', + siteName: url, + siteUrl: url, + trustScore: 0, + visitUrl: url, + }; +} + export function createResultPageFetcher(tone: ResultTone): ResultPageFetcher { async function fetchResultPageData(): Promise { const session = getScanSessionSnapshot(); - const hasRecoverableUrl = Boolean( - session.decodedUrl || session.historySelection?.url || resolveRequestedUrlFromSearch(), - ); + const recoverableUrl = resolveRecoverableUrl(session); + const hasRecoverableUrl = Boolean(recoverableUrl); if (!session.analysisDetail && hasRecoverableUrl) { try { const detailSession = await ensureScanDetail(); return toResultPageData(detailSession, tone); } catch (error) { - if (isApiError(error) && error.statusCode === 404 && hasSessionResult(session)) { - return toResultPageData(session, tone); + if (isApiError(error) && error.statusCode === 404) { + if (hasSessionResult(session)) { + return toResultPageData(session, tone); + } + + return createDetailUnavailableResultPageData(recoverableUrl ?? ''); } throw error; diff --git a/src/pages/Result/lib/toResultPageData.ts b/src/pages/Result/lib/toResultPageData.ts index 7201303..17f5304 100644 --- a/src/pages/Result/lib/toResultPageData.ts +++ b/src/pages/Result/lib/toResultPageData.ts @@ -154,6 +154,7 @@ export function toResultPageData( trustScoreFallbackByTone[resolvedTone]; return { + detailUnavailable: false, previewUrl: resolvedPreviewUrl, siteMeta: buildSiteMeta(sources), siteName: resolvedOriginalUrl, diff --git a/src/pages/Result/types/resultPage.types.ts b/src/pages/Result/types/resultPage.types.ts index 03d092c..f1ca30b 100644 --- a/src/pages/Result/types/resultPage.types.ts +++ b/src/pages/Result/types/resultPage.types.ts @@ -1,4 +1,5 @@ export type ResultPageData = { + detailUnavailable?: boolean; previewUrl: string; siteMeta: string; siteName: string; diff --git a/src/pages/Result/ui/ResultStatusPage.tsx b/src/pages/Result/ui/ResultStatusPage.tsx index 619a444..da11057 100644 --- a/src/pages/Result/ui/ResultStatusPage.tsx +++ b/src/pages/Result/ui/ResultStatusPage.tsx @@ -39,6 +39,8 @@ type ResultStatusPageProps = { resultData: ResultStatusPageData; riskLevelCard?: RiskLevelCardOptions; siteCard?: SafeSiteCardOptions; + showMetrics?: boolean; + showReportToggle?: boolean; tone: ResultTone; }; @@ -54,6 +56,8 @@ export default function ResultStatusPage({ resultData, riskLevelCard, siteCard, + showMetrics = true, + showReportToggle = true, tone, }: ResultStatusPageProps) { const currentYear = new Date().getFullYear(); @@ -66,10 +70,12 @@ export default function ResultStatusPage({
{hero} -
- - -
+ {showMetrics ? ( +
+ + +
+ ) : null} - + {showReportToggle ? ( + + ) : null}

{`Veri-Q Security Engine ${SECURITY_ENGINE_VERSION} (c) ${currentYear}. All rights reserved.`} From 2724794facee602b5dcf7f09c35a396148d209dc Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Fri, 24 Apr 2026 22:39:20 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Captcha/lib/recaptchaEnterprise.ts | 29 ++++++++++++-- src/pages/Captcha/ui/CaptchaWidget.tsx | 5 +++ src/pages/Result/ResultPage.tsx | 17 ++++---- .../api/createResultPageFetcher.test.ts | 39 +++++++++++++++++-- .../Result/api/createResultPageFetcher.ts | 10 +++-- 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/pages/Captcha/lib/recaptchaEnterprise.ts b/src/pages/Captcha/lib/recaptchaEnterprise.ts index e5b6c92..4ccd8ab 100644 --- a/src/pages/Captcha/lib/recaptchaEnterprise.ts +++ b/src/pages/Captcha/lib/recaptchaEnterprise.ts @@ -78,15 +78,38 @@ function buildScriptSelector(sourceUrl: string): string { function waitForScriptReady(script: HTMLScriptElement): Promise { return new Promise((resolve, reject) => { const onLoad = () => { - void waitForEnterpriseApiReady().then(resolve).catch(reject); + void waitForEnterpriseApiReady() + .then(() => { + cleanup(); + resolve(); + }) + .catch((error) => { + cleanup(); + reject(error); + }); }; const onError = () => { + cleanup(); reject(new Error('Failed to load reCAPTCHA Enterprise script.')); }; - script.addEventListener('load', onLoad, { once: true }); - script.addEventListener('error', onError, { once: true }); + const cleanup = () => { + script.removeEventListener('load', onLoad); + script.removeEventListener('error', onError); + }; + + script.addEventListener('load', onLoad); + script.addEventListener('error', onError); + + if (script.dataset.loadStatus === 'loaded' || isEnterpriseApiReady()) { + onLoad(); + return; + } + + if (script.dataset.loadStatus === 'error') { + onError(); + } }); } diff --git a/src/pages/Captcha/ui/CaptchaWidget.tsx b/src/pages/Captcha/ui/CaptchaWidget.tsx index e04cdb3..2d2a3d9 100644 --- a/src/pages/Captcha/ui/CaptchaWidget.tsx +++ b/src/pages/Captcha/ui/CaptchaWidget.tsx @@ -45,6 +45,11 @@ export default function CaptchaWidget({ onTokenChange, recaptchaSiteKey }: Captc siteKey: recaptchaSiteKey, }) .then((nextWidgetId) => { + if (disposed) { + resetRecaptchaEnterprise(nextWidgetId); + return; + } + widgetId = nextWidgetId; }) .catch((error) => { diff --git a/src/pages/Result/ResultPage.tsx b/src/pages/Result/ResultPage.tsx index 20f1547..ee3a3bb 100644 --- a/src/pages/Result/ResultPage.tsx +++ b/src/pages/Result/ResultPage.tsx @@ -1,6 +1,7 @@ import type { ResultTone } from '@/shared/types/resultTone'; import ResultHero from '@/shared/ui/resultHero'; +import { DETAIL_UNAVAILABLE_MESSAGE } from './api/createResultPageFetcher'; import { useResultPage } from './hooks/useResultPage'; import ResultStatusPage from './ui/ResultStatusPage'; @@ -15,13 +16,13 @@ const resultViewConfig = { }, hero: ( ), riskLevelCard: { - description: '피싱 의심 패턴이 강하게 감지됨', + description: '실시간 위협 신호가 강하게 감지됨', levelText: '위험', }, siteCard: { @@ -37,7 +38,7 @@ const resultViewConfig = { description="Veri-Q 분석 결과, 해당 QR 코드는 검증된 안전한 웹사이트로 연결됩니다." title={ <> - 안심하세요! + 안심하세요
안전한 사이트입니다 @@ -55,7 +56,7 @@ const resultViewConfig = { description="Veri-Q 분석 결과, 해당 QR 코드는 주의가 필요한 웹사이트로 분류되었습니다." title={ <> - 주의하세요! + 주의하세요
주의가 필요한 사이트입니다 @@ -64,12 +65,12 @@ const resultViewConfig = { /> ), riskLevelCard: { - description: '의심 패턴 일부 감지됨', + description: '의심 신호 일부 감지됨', levelText: '주의', }, siteCard: { badgeLabel: '!', - visitLabel: '주의하여 사이트 방문하기', + visitLabel: '주의하며 사이트 방문하기', }, }, } as const; @@ -77,7 +78,7 @@ const resultViewConfig = { const detailUnavailableViewConfig = { hero: ( 아직 분석 결과를 @@ -95,7 +96,7 @@ const detailUnavailableViewConfig = { }, siteCard: { badgeLabel: '?', - statusLabel: 'ANALYSIS NEEDED', + statusLabel: '분석 대기', tone: 'warning' as const, visitLabel: '다시 검사하기', }, diff --git a/src/pages/Result/api/createResultPageFetcher.test.ts b/src/pages/Result/api/createResultPageFetcher.test.ts index 8c42c3a..5958af2 100644 --- a/src/pages/Result/api/createResultPageFetcher.test.ts +++ b/src/pages/Result/api/createResultPageFetcher.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError } from '@/shared/api/errors/apiError'; import { useScanSessionStore } from '@/shared/store/scanSessionStore'; -import { createResultPageFetcher } from './createResultPageFetcher'; +import { createResultPageFetcher, DETAIL_UNAVAILABLE_MESSAGE } from './createResultPageFetcher'; vi.mock('@/shared/lib/scan-session/ensureScanDetail', () => { return { @@ -15,7 +15,7 @@ vi.mock('@/shared/lib/scan-session/ensureScanDetail', () => { describe('createResultPageFetcher', () => { beforeEach(() => { useScanSessionStore.getState().clearSession(); - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('falls back to session result data when scan detail is not found yet', async () => { @@ -58,10 +58,43 @@ describe('createResultPageFetcher', () => { await expect(fetchResultPageData()).resolves.toMatchObject({ detailUnavailable: true, - siteMeta: '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.', + siteMeta: DETAIL_UNAVAILABLE_MESSAGE, siteName: 'https://www.daum.net/', siteUrl: 'https://www.daum.net/', trustScore: 0, }); }); + + it('propagates non-404 API errors from scan detail request', async () => { + const { ensureScanDetail } = await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(ensureScanDetail).mockRejectedValue( + new ApiError({ + message: 'Request failed with status code 500', + statusCode: 500, + }), + ); + + useScanSessionStore.getState().setHistorySelection({ + isUrl: true, + riskLevel: 'safe', + scannedAt: null, + schemeType: 'WEB', + url: 'https://www.daum.net/', + }); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).rejects.toThrow('Request failed with status code 500'); + }); + + it('throws SCAN_SESSION_REQUIRED when there is no session data and no recoverable URL', async () => { + const { ensureScanDetail, resolveRequestedUrlFromSearch } = + await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(resolveRequestedUrlFromSearch).mockReturnValue(null); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).rejects.toThrow('SCAN_SESSION_REQUIRED'); + expect(ensureScanDetail).not.toHaveBeenCalled(); + }); }); diff --git a/src/pages/Result/api/createResultPageFetcher.ts b/src/pages/Result/api/createResultPageFetcher.ts index 7ce5130..c6bc123 100644 --- a/src/pages/Result/api/createResultPageFetcher.ts +++ b/src/pages/Result/api/createResultPageFetcher.ts @@ -16,6 +16,9 @@ type ResultPageFetcher = { getInitialResultPageData: () => ResultPageData | null; }; +export const DETAIL_UNAVAILABLE_MESSAGE = + '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.'; + function hasSessionResult(session: ScanSessionSnapshot): boolean { return Boolean( session.analysisDetail || @@ -33,7 +36,7 @@ function createDetailUnavailableResultPageData(url: string): ResultPageData { return { detailUnavailable: true, previewUrl: url, - siteMeta: '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.', + siteMeta: DETAIL_UNAVAILABLE_MESSAGE, siteName: url, siteUrl: url, trustScore: 0, @@ -45,9 +48,8 @@ export function createResultPageFetcher(tone: ResultTone): ResultPageFetcher { async function fetchResultPageData(): Promise { const session = getScanSessionSnapshot(); const recoverableUrl = resolveRecoverableUrl(session); - const hasRecoverableUrl = Boolean(recoverableUrl); - if (!session.analysisDetail && hasRecoverableUrl) { + if (!session.analysisDetail && recoverableUrl) { try { const detailSession = await ensureScanDetail(); return toResultPageData(detailSession, tone); @@ -57,7 +59,7 @@ export function createResultPageFetcher(tone: ResultTone): ResultPageFetcher { return toResultPageData(session, tone); } - return createDetailUnavailableResultPageData(recoverableUrl ?? ''); + return createDetailUnavailableResultPageData(recoverableUrl); } throw error;