diff --git a/src/hooks/useEnsureGuestTable.ts b/src/hooks/useEnsureGuestTable.ts new file mode 100644 index 00000000..e18f84b8 --- /dev/null +++ b/src/hooks/useEnsureGuestTable.ts @@ -0,0 +1,23 @@ +import { SAMPLE_TABLE_DATA } from '../constants/sample_table'; +import { + isGuestFlow, + setSessionCustomizeTableData, +} from '../util/sessionStorage'; + +export const GUEST_TABLE_ID = 'guest'; + +/** + * 게스트 경로(`/:id === 'guest'`)인데 세션에 테이블 데이터가 없으면 + * 샘플 테이블을 세션에 채워 게스트 플로우를 복구한다. + * + * 홈 버튼으로 세션이 비워진 뒤 뒤로가기로 돌아오면 게스트 판별이 풀려 + * API 저장소로 `Number('guest')`(NaN) 조회가 나가므로, 쿼리가 저장소를 + * 고르기 전에 동기적으로 실행되도록 렌더 중에 처리한다. + * 같은 페이지 인스턴스에서 id만 `guest`로 바뀌는 경우도 막기 위해 매 렌더마다 + * 확인하며, 세션 데이터가 있으면 아무것도 하지 않으므로 반복 실행해도 안전하다. + */ +export function useEnsureGuestTable(id: string | undefined) { + if (id === GUEST_TABLE_ID && !isGuestFlow()) { + setSessionCustomizeTableData(SAMPLE_TABLE_DATA); + } +} diff --git a/src/page/TableOverviewPage/TableOverviewPage.tsx b/src/page/TableOverviewPage/TableOverviewPage.tsx index 4da88f9e..fd921181 100644 --- a/src/page/TableOverviewPage/TableOverviewPage.tsx +++ b/src/page/TableOverviewPage/TableOverviewPage.tsx @@ -26,11 +26,13 @@ import { isSupportedLang, } from '../../util/languageRouting'; import useAnalytics from '../../hooks/useAnalytics'; +import { useEnsureGuestTable } from '../../hooks/useEnsureGuestTable'; // 토론 개요를 보여주고 공유, 수정, 시작 액션을 제공하는 페이지다. export default function TableOverviewPage() { const { t, i18n } = useTranslation(); const { id } = useParams(); + useEnsureGuestTable(id); const tableId = Number(id); const navigate = useNavigate(); const { openModal, closeModal, ModalWrapper } = useModal(); diff --git a/src/page/TableOverviewPage/guestBackNavigation.test.tsx b/src/page/TableOverviewPage/guestBackNavigation.test.tsx new file mode 100644 index 00000000..dd99ac3e --- /dev/null +++ b/src/page/TableOverviewPage/guestBackNavigation.test.tsx @@ -0,0 +1,127 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { GlobalPortal } from '../../util/GlobalPortal'; +import TableOverviewPage from './TableOverviewPage'; +import TableSharingPage from '../TableSharingPage/TableSharingPage'; +import { encodeDebateTableData } from '../../util/arrayEncoding'; +import { server } from '../../mocks/server'; +import { DebateTableData } from '../../types/type'; +import { SAMPLE_TABLE_DATA } from '../../constants/sample_table'; + +const sharedTable: DebateTableData = { + info: { + name: '공유받은 테이블', + agenda: '공유 주제', + prosTeamName: '찬성', + consTeamName: '반대', + }, + table: [ + { + stance: 'PROS', + speechType: '입론', + boxType: 'NORMAL', + time: 60, + timePerTeam: null, + timePerSpeaking: null, + speaker: '1', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], + }, + ], +}; + +function renderWithRouter(initialEntry: string) { + const router = createMemoryRouter( + [ + { path: '/share', element: }, + { path: '/overview/:type/:id', element: }, + { path: '/home', element:
HOME
}, + ], + { initialEntries: [initialEntry] }, + ); + + render( + + + + + , + ); + + return router; +} + +describe('비회원 테이블 개요 페이지 - 세션 데이터 유실', () => { + const requestedUrls: string[] = []; + const onRequestStart = ({ request }: { request: Request }) => { + requestedUrls.push(request.url); + }; + + beforeEach(() => { + sessionStorage.clear(); + requestedUrls.length = 0; + server.events.on('request:start', onRequestStart); + }); + + afterEach(() => { + server.events.removeListener('request:start', onRequestStart); + }); + + it('공유 링크 -> 개요 -> 홈 -> 뒤로가기 시 NaN 조회 없이 샘플 테이블로 복구한다', async () => { + const router = renderWithRouter( + `/share?data=${encodeURIComponent(encodeDebateTableData(sharedTable))}`, + ); + + await waitFor(() => + expect(router.state.location.pathname).toBe('/overview/customize/guest'), + ); + await screen.findByText('공유받은 테이블'); + + // 헤더 홈 버튼은 게스트 세션 데이터를 삭제한다. + await userEvent.click(screen.getAllByLabelText('홈으로 이동')[0]); + await waitFor(() => expect(router.state.location.pathname).toBe('/home')); + expect(sessionStorage.getItem('DebateTableData')).toBeNull(); + + await act(async () => { + await router.navigate(-1); + }); + expect(router.state.location.pathname).toBe('/overview/customize/guest'); + + await screen.findByText(SAMPLE_TABLE_DATA.info.name); + expect(sessionStorage.getItem('DebateTableData')).not.toBeNull(); + expect(requestedUrls.filter((url) => url.includes('NaN'))).toEqual([]); + }); + + it('세션 데이터 없이 게스트 경로로 직접 진입해도 샘플 테이블을 보여준다', async () => { + renderWithRouter('/overview/customize/guest'); + + await screen.findByText(SAMPLE_TABLE_DATA.info.name); + expect(requestedUrls.filter((url) => url.includes('NaN'))).toEqual([]); + }); + + it('같은 페이지에서 회원 테이블 id가 게스트로 바뀌어도 샘플 테이블로 복구한다', async () => { + const router = renderWithRouter('/overview/customize/1'); + await screen.findByText('나의 자유토론 테이블'); + + await act(async () => { + await router.navigate('/overview/customize/guest'); + }); + + await screen.findByText(SAMPLE_TABLE_DATA.info.name); + expect(sessionStorage.getItem('DebateTableData')).not.toBeNull(); + expect(requestedUrls.filter((url) => url.includes('NaN'))).toEqual([]); + }); + + it('세션에 게스트 데이터가 있으면 덮어쓰지 않는다', async () => { + sessionStorage.setItem( + 'DebateTableData', + JSON.stringify({ id: -1, ...sharedTable }), + ); + renderWithRouter('/overview/customize/guest'); + + await screen.findByText('공유받은 테이블'); + expect(screen.queryByText(SAMPLE_TABLE_DATA.info.name)).toBeNull(); + }); +}); diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 02c7a0c5..2bad5a36 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -20,6 +20,7 @@ import clsx from 'clsx'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; import { isGuestFlow } from '../../util/sessionStorage'; +import { useEnsureGuestTable } from '../../hooks/useEnsureGuestTable'; import useAnalytics from '../../hooks/useAnalytics'; import { consumeTemplateOrigin } from '../../util/analytics/templateOrigin'; import { @@ -51,6 +52,7 @@ export default function TimerPage() { const { t } = useTranslation(); const [answerTime, setAnswerTime] = useState(30); const pathParams = useParams(); + useEnsureGuestTable(pathParams.id); const tableId = Number(pathParams.id); const { openUseTooltipModal,