33 changed files with 672 additions and 105 deletions
-
4src/app/questions-list/page.tsx
-
100src/app/terms/page.test.tsx
-
59src/app/terms/page.tsx
-
24src/components/Componentes/question-date.tsx
-
18src/components/Componentes/question-exit-navigation-button.tsx
-
12src/components/Componentes/question-section-flow.tsx
-
265src/components/Componentes/slider-page.test.tsx
-
85src/components/Componentes/slider-page.tsx
-
88src/lib/get-submit-path.test.ts
-
5src/translations/locales/ar.json
-
5src/translations/locales/az.json
-
5src/translations/locales/bn.json
-
5src/translations/locales/da.json
-
5src/translations/locales/de.json
-
6src/translations/locales/en.json
-
5src/translations/locales/es.json
-
6src/translations/locales/fa.json
-
5src/translations/locales/fr.json
-
5src/translations/locales/gu.json
-
5src/translations/locales/ha.json
-
5src/translations/locales/he.json
-
5src/translations/locales/hi.json
-
5src/translations/locales/id.json
-
5src/translations/locales/ks.json
-
5src/translations/locales/pt.json
-
5src/translations/locales/ru.json
-
5src/translations/locales/sw.json
-
5src/translations/locales/tg.json
-
5src/translations/locales/tr.json
-
5src/translations/locales/ul.json
-
5src/translations/locales/ur.json
-
5src/translations/locales/uz.json
-
5src/translations/locales/zh.json
@ -0,0 +1,100 @@ |
|||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
|||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; |
|||
import TermsRoute from './page'; |
|||
|
|||
const mockUseMarriageProfileQuery = vi.fn(); |
|||
const mockReplace = vi.fn(); |
|||
|
|||
vi.mock('@/hooks/marriage/use-profile-main', () => ({ |
|||
useMarriageProfileQuery: () => mockUseMarriageProfileQuery(), |
|||
})); |
|||
|
|||
vi.mock('next/navigation', () => ({ |
|||
useRouter: () => ({ |
|||
replace: mockReplace, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/provider', () => ({ |
|||
useI18n: () => ({ |
|||
locale: 'en', |
|||
dictionary: { |
|||
"Failed to load profile. Please try again.": "Failed to load profile. Please try again.", |
|||
"Retry": "Retry", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/config', () => ({ |
|||
localizePath: (path: string) => path, |
|||
})); |
|||
|
|||
vi.mock('@/components/Componentes/slider-page', () => ({ |
|||
default: () => <div data-testid="slider-page">SliderPage</div>, |
|||
})); |
|||
|
|||
describe('TermsRoute Guard', () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it('renders loading state when isLoading is true', () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ isLoading: true }); |
|||
render(<TermsRoute />); |
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
// The spinner should be visible, maybe just check for a div. We can check slider is absent.
|
|||
}); |
|||
|
|||
it('renders Error/Retry when isError is true', () => { |
|||
const mockRefetch = vi.fn(); |
|||
mockUseMarriageProfileQuery.mockReturnValue({ isError: true, refetch: mockRefetch }); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.getByText('Failed to load profile. Please try again.')).toBeInTheDocument(); |
|||
const retryBtn = screen.getByText('Retry'); |
|||
fireEvent.click(retryBtn); |
|||
expect(mockRefetch).toHaveBeenCalled(); |
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('pending_onboarding => SliderPage renders', () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ |
|||
isLoading: false, |
|||
data: { status: 'pending_onboarding' } |
|||
}); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.getByTestId('slider-page')).toBeInTheDocument(); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('pending_info => redirects to Questions and SliderPage is NOT rendered', async () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ |
|||
isLoading: false, |
|||
data: { status: 'pending_info' } |
|||
}); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalled(); // Should redirect to /questions-list or /questions-list/personal_info
|
|||
}); |
|||
}); |
|||
|
|||
it('waiting => redirects to Finding/Waiting Page and SliderPage is NOT rendered', async () => { |
|||
mockUseMarriageProfileQuery.mockReturnValue({ |
|||
isLoading: false, |
|||
data: { status: 'waiting' } |
|||
}); |
|||
render(<TermsRoute />); |
|||
|
|||
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalledWith('/finding-match'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,5 +1,64 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect } from "react"; |
|||
import { useRouter } from "next/navigation"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|||
import SliderPage from "@/components/Componentes/slider-page"; |
|||
import Button from "@/components/Componentes/button"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
import { localizePath } from "@/translations/config"; |
|||
|
|||
export default function TermsRoute() { |
|||
const { data: profile, isLoading, isError, refetch } = useMarriageProfileQuery(); |
|||
const router = useRouter(); |
|||
const { locale, dictionary: t } = useI18n(); |
|||
|
|||
useEffect(() => { |
|||
if (!isLoading && profile) { |
|||
if (profile.status !== "pending_onboarding") { |
|||
const target = getSubmitPath(profile); |
|||
if (target !== "/terms") { |
|||
router.replace(localizePath(target, locale)); |
|||
} |
|||
} |
|||
} |
|||
}, [profile, isLoading, router, locale]); |
|||
|
|||
if (isLoading) { |
|||
return ( |
|||
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]"> |
|||
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" /> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (isError) { |
|||
return ( |
|||
<div className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-[#F5F5F5]"> |
|||
<p className="mb-4 text-center font-medium text-[#F14B46]"> |
|||
{/* @ts-expect-error - missing key */} |
|||
{t["Failed to load profile. Please try again."] || "Failed to load profile. Please try again."} |
|||
</p> |
|||
<div className="w-full max-w-[200px]"> |
|||
<Button onClick={() => refetch()}> |
|||
{/* @ts-expect-error - missing key */} |
|||
{t["Retry"] || "Retry"} |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (profile && profile.status !== "pending_onboarding") { |
|||
// If target is /terms but status is not pending_onboarding, we should still not render SliderPage
|
|||
// to prevent showing Onboarding to users who already finished it but have an unknown status.
|
|||
return ( |
|||
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]"> |
|||
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" /> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return <SliderPage />; |
|||
} |
|||
@ -0,0 +1,265 @@ |
|||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
|||
import userEvent from '@testing-library/user-event'; |
|||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; |
|||
import SliderPage from './slider-page'; |
|||
|
|||
const mockMutateAsync = vi.fn(); |
|||
const mockGetMarriageProfile = vi.fn(); |
|||
const mockSetQueryData = vi.fn(); |
|||
const mockReplace = vi.fn(); |
|||
const mockRemoveItem = vi.spyOn(Storage.prototype, 'removeItem'); |
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; |
|||
|
|||
vi.mock('@/hooks/marriage/use-profile-basic', () => ({ |
|||
useUpdateMarriageProfileBasicMutation: () => ({ |
|||
mutateAsync: mockMutateAsync, |
|||
isPending: false, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/hooks/marriage/use-profile-main', () => ({ |
|||
getMarriageProfile: (...args: any[]) => mockGetMarriageProfile(...args), |
|||
useMarriageProfileQuery: () => ({ data: undefined, refetch: vi.fn() }), |
|||
})); |
|||
|
|||
vi.mock('@/hooks/marriage/query-keys', () => ({ |
|||
marriageQueryKeys: { |
|||
profile: () => ['marriage', 'profile'], |
|||
}, |
|||
})); |
|||
|
|||
vi.mock('next/navigation', () => ({ |
|||
useRouter: () => ({ |
|||
replace: mockReplace, |
|||
back: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/provider', () => ({ |
|||
useI18n: () => ({ |
|||
locale: 'en', |
|||
dictionary: { |
|||
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.", |
|||
"Accept & Continue": "Accept & Continue", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@/translations/config', () => ({ |
|||
localizePath: (path: string, locale: string) => path, |
|||
})); |
|||
|
|||
describe('SliderPage', () => { |
|||
let queryClient: QueryClient; |
|||
|
|||
beforeEach(() => { |
|||
vi.resetAllMocks(); |
|||
queryClient = new QueryClient(); |
|||
vi.spyOn(queryClient, 'setQueryData'); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers(); |
|||
cleanup(); |
|||
}); |
|||
|
|||
const navigateToFinalSlide = async () => { |
|||
render( |
|||
<QueryClientProvider client={queryClient}> |
|||
<SliderPage /> |
|||
</QueryClientProvider> |
|||
); |
|||
const dots = screen.getAllByRole('button', { name: /go to slide/i }); |
|||
fireEvent.click(dots[4]); // Go directly to slide 5
|
|||
return await screen.findByText('Finish'); |
|||
}; |
|||
|
|||
it('a. Success: PATCH ok, GET ok -> replace', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
|
|||
let resolveGet: any; |
|||
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
mockGetMarriageProfile.mockReturnValueOnce(new Promise(resolve => { |
|||
resolveGet = resolve; |
|||
})); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
// Wait for PATCH to resolve and GET to be called
|
|||
await waitFor(() => { |
|||
expect(mockMutateAsync).toHaveBeenCalled(); |
|||
expect(mockGetMarriageProfile).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
// Assert navigation and localStorage clear did not happen before GET resolves
|
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(currentRemoveCount).toBe(initialRemoveCount); |
|||
|
|||
// Resolve GET
|
|||
resolveGet({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
await waitFor(() => { |
|||
expect(queryClient.setQueryData).toHaveBeenCalled(); |
|||
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(finalRemoveCount).toBe(initialRemoveCount + 1); |
|||
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
|||
}); |
|||
}); |
|||
|
|||
it('b. PATCH failure: no nav, Error UI, Retry works', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
|
|||
mockMutateAsync.mockRejectedValueOnce(new Error('Network Error')); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
|||
}); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
|
|||
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(currentRemoveCount).toBe(initialRemoveCount); |
|||
|
|||
// Retry succeeds
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
const retryBtn = screen.getByText('Finish'); |
|||
fireEvent.click(retryBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
|||
}); |
|||
|
|||
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
|||
expect(finalRemoveCount).toBe(initialRemoveCount + 1); |
|||
}); |
|||
|
|||
it('c. Slow request: Promise pending -> no redirect', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); |
|||
|
|||
let resolvePatch: any; |
|||
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => { |
|||
resolvePatch = resolve; |
|||
})); |
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await vi.advanceTimersByTimeAsync(6000); |
|||
|
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
|
|||
resolvePatch({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
vi.useRealTimers(); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
|||
}); |
|||
}); |
|||
|
|||
it('d. Double-check failure: GET returns pending_onboarding -> Error UI', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_onboarding', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
|||
}); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('e. Double click: only 1 request', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
let resolvePatch: any; |
|||
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => { |
|||
resolvePatch = resolve; |
|||
})); |
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
fireEvent.click(finishBtn); |
|||
fireEvent.click(finishBtn); |
|||
|
|||
expect(mockMutateAsync).toHaveBeenCalledTimes(1); |
|||
|
|||
resolvePatch({ status: 'pending_info', gender: 'female', is_registering_for_self: true }); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockReplace).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
it('f. Double-check failure: GET returns waiting -> Error UI', async () => { |
|||
const finishBtn = await navigateToFinalSlide(); |
|||
|
|||
mockMutateAsync.mockResolvedValueOnce({ |
|||
status: 'pending_info', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
mockGetMarriageProfile.mockResolvedValueOnce({ |
|||
status: 'waiting', |
|||
gender: 'female', |
|||
is_registering_for_self: true |
|||
}); |
|||
|
|||
fireEvent.click(finishBtn); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
|||
}); |
|||
expect(mockReplace).not.toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,88 @@ |
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|||
import { getSubmitPath } from './get-submit-path'; |
|||
import * as firstEntryHelper from './first-entry-helper'; |
|||
import * as matchStartGrace from './match-start-grace'; |
|||
import type { MarriageProfileResponse } from '@/hooks/marriage/types'; |
|||
|
|||
vi.mock('./first-entry-helper', () => ({ |
|||
isFirstEntryCompleted: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock('./match-start-grace', () => ({ |
|||
clearLegacyMatchSubmittedFlag: vi.fn(), |
|||
isWithinMatchStartGrace: vi.fn(), |
|||
})); |
|||
|
|||
describe('getSubmitPath', () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(true); |
|||
vi.mocked(matchStartGrace.isWithinMatchStartGrace).mockReturnValue(false); |
|||
}); |
|||
|
|||
const baseProfile: MarriageProfileResponse = { |
|||
status: 'pending_onboarding', |
|||
id: 1, |
|||
gender: 'male', |
|||
is_registering_for_self: true, |
|||
}; |
|||
|
|||
it('pending_onboarding returns /terms', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'pending_onboarding' })).toBe('/terms'); |
|||
}); |
|||
|
|||
it('waiting returns /finding-match', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'waiting' })).toBe('/finding-match'); |
|||
}); |
|||
|
|||
it('pending_info returns questions path', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list'); |
|||
|
|||
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(false); |
|||
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list/personal_info'); |
|||
}); |
|||
|
|||
it('matched returns /request-accepted', () => { |
|||
expect(getSubmitPath({ ...baseProfile, status: 'matched' })).toBe('/request-accepted'); |
|||
}); |
|||
|
|||
describe('active_case priority', () => { |
|||
it('returns /new-match when status="pending_info" but active_case is introduced and action pending', () => { |
|||
const profileWithCase = { |
|||
...baseProfile, |
|||
status: 'pending_info' as const, |
|||
active_case: { |
|||
status: 'introduced' as const, |
|||
my_action: 'pending' as const, |
|||
}, |
|||
}; |
|||
// Type assertion added because the mock profile object is lacking many properties of the full MarriageProfileResponse,
|
|||
// but it contains everything needed for the logic being tested.
|
|||
expect(getSubmitPath(profileWithCase as any)).toBe('/new-match'); |
|||
}); |
|||
|
|||
it('returns /request-accepted when status="pending_info" but active_case is payment_done', () => { |
|||
const profileWithCase = { |
|||
...baseProfile, |
|||
status: 'pending_info' as const, |
|||
active_case: { |
|||
status: 'payment_done' as const, |
|||
my_action: 'pending' as const, |
|||
}, |
|||
}; |
|||
expect(getSubmitPath(profileWithCase as any)).toBe('/request-accepted'); |
|||
}); |
|||
|
|||
it('returns /request-sent when status="waiting" but active_case is male_accepted and action done', () => { |
|||
const profileWithCase = { |
|||
...baseProfile, |
|||
status: 'waiting' as const, |
|||
active_case: { |
|||
status: 'male_accepted' as const, |
|||
my_action: 'done' as const, |
|||
}, |
|||
}; |
|||
expect(getSubmitPath(profileWithCase as any)).toBe('/request-sent'); |
|||
}); |
|||
}); |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue