Skip to content

Commit ba1e078

Browse files
DC-187: Fix Spanish translations not rendering in client components
COLoginPage and CallbackPage are 'use client' components that were calling getTranslations(), a server-side utility hardcoded to load only English resources. Language switching happens entirely client-side via i18next, so these components must use useTranslation() from react-i18next to re-render when the user changes language. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 74ab7e3 commit ba1e078

5 files changed

Lines changed: 152 additions & 36 deletions

File tree

src/SEBT.Portal.Web/src/app/(public)/callback/page.test.tsx

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,26 @@
1010
*/
1111
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1212
import { render, screen, waitFor } from '@testing-library/react'
13+
import i18n from 'i18next'
1314
import { http, HttpResponse } from 'msw'
14-
import { beforeEach, describe, expect, it, vi } from 'vitest'
15+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
1516

1617
import { server } from '@/mocks/server'
1718

19+
// These callback-specific keys are not yet in the locale CSV files (content gap).
20+
// They are injected here as a test-only bundle until the content team adds them
21+
// to co.csv and the locale JSON files are regenerated.
22+
const callbackTranslations = {
23+
callbackSigningIn: 'Signing you in…',
24+
callbackSignInIssue: 'Sign-in issue',
25+
callbackErrorMissingParams: 'Missing sign-in information.',
26+
callbackErrorSessionExpired: 'Session expired.',
27+
callbackErrorStateMismatch: 'State mismatch.',
28+
callbackErrorGeneric: 'Something went wrong.',
29+
callbackErrorStepUpFailed: 'Step-up verification did not finish.',
30+
callbackErrorIdpRedirect: 'Primary MyColorado sign-in did not finish.'
31+
}
32+
1833
// Mock router
1934
const mockReplace = vi.fn()
2035
vi.mock('next/navigation', () => ({
@@ -44,28 +59,6 @@ vi.mock('@/features/auth', async () => {
4459
}
4560
})
4661

47-
// Mock translations
48-
vi.mock('@/lib/translations', () => ({
49-
getTranslations: vi.fn().mockImplementation((namespace: string) => {
50-
const namespaces: Record<string, Record<string, string>> = {
51-
login: {
52-
callbackSigningIn: 'Signing you in…',
53-
callbackSignInIssue: 'Sign-in issue',
54-
callbackErrorMissingParams: 'Missing sign-in information.',
55-
callbackErrorSessionExpired: 'Session expired.',
56-
callbackErrorStateMismatch: 'State mismatch.',
57-
callbackErrorGeneric: 'Something went wrong.',
58-
callbackErrorStepUpFailed: 'Step-up verification did not finish.',
59-
callbackErrorIdpRedirect: 'Primary MyColorado sign-in did not finish.'
60-
}
61-
}
62-
/* eslint-disable security/detect-object-injection -- test mock */
63-
const translations = namespaces[namespace] ?? {}
64-
return (key: string, defaultValue?: string) => translations[key] ?? defaultValue ?? key
65-
/* eslint-enable security/detect-object-injection */
66-
})
67-
}))
68-
6962
// Mock state
7063
vi.mock('@sebt/design-system', async (importOriginal) => {
7164
const actual = await importOriginal<typeof import('@sebt/design-system')>()
@@ -97,6 +90,14 @@ function renderCallbackPage() {
9790
}
9891

9992
describe('CallbackPage', () => {
93+
beforeAll(() => {
94+
i18n.addResourceBundle('en', 'login', callbackTranslations, true, true)
95+
})
96+
97+
afterAll(() => {
98+
i18n.removeResourceBundle('en', 'login')
99+
})
100+
100101
beforeEach(() => {
101102
vi.clearAllMocks()
102103
// Default: URL has code and state

src/SEBT.Portal.Web/src/app/(public)/callback/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import {
77
OidcCompleteLoginResponseSchema
88
} from '@/features/auth/api/oidc/schema'
99
import { clearPkceStorage, getPkceFromStorage } from '@/lib/oidc-pkce'
10-
import { getTranslations } from '@/lib/translations'
1110
import { Alert, getState } from '@sebt/design-system'
1211
import { useRouter } from 'next/navigation'
1312
import { useEffect, useRef, useState } from 'react'
13+
import { useTranslation } from 'react-i18next'
1414

1515
type CallbackStep = 'loading' | 'have_code_state' | 'have_flow_data' | 'exchanging' | 'error'
1616

@@ -23,7 +23,7 @@ type CallbackStep = 'loading' | 'have_code_state' | 'have_flow_data' | 'exchangi
2323
export default function CallbackPage() {
2424
const router = useRouter()
2525
const { login } = useAuth()
26-
const t = getTranslations('login')
26+
const { t } = useTranslation('login')
2727
const [status, setStatus] = useState<'loading' | 'error'>('loading')
2828
const [step, setStep] = useState<CallbackStep>('loading')
2929
const [errorDetail, setErrorDetail] = useState<string | null>(null)
@@ -141,7 +141,7 @@ export default function CallbackPage() {
141141
// otherwise ref stays true and the retried effect bails while the aborted run skipped navigation.
142142
exchangeStartedRef.current = false
143143
}
144-
// eslint-disable-next-line react-hooks/exhaustive-deps -- t (getTranslations) is a static lookup
144+
// eslint-disable-next-line react-hooks/exhaustive-deps -- t intentionally omitted: adding it would retrigger the OIDC exchange on language change
145145
}, [login, router])
146146

147147
useEffect(() => {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* COLoginPage Unit Tests
3+
*
4+
* Includes a regression test for language switching:
5+
* COLoginPage must use useTranslation (react-i18next), not getTranslations
6+
* (server-side utility), so it re-renders when the user switches language.
7+
*/
8+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
9+
import { act, render, screen, waitFor } from '@testing-library/react'
10+
import i18n from 'i18next'
11+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
12+
13+
import enCOCommon from '@/content/locales/en/co/common.json'
14+
import enCOLogin from '@/content/locales/en/co/login.json'
15+
import esCOCommon from '@/content/locales/es/co/common.json'
16+
import esCOLogin from '@/content/locales/es/co/login.json'
17+
18+
vi.mock('@/api', () => ({ apiFetch: vi.fn() }))
19+
20+
vi.mock('@sebt/design-system', async (importOriginal) => {
21+
const actual = await importOriginal<typeof import('@sebt/design-system')>()
22+
return {
23+
...actual,
24+
getStateLinks: vi.fn().mockReturnValue({ external: { contactUsAssistance: '/help' } })
25+
}
26+
})
27+
28+
import { COLoginPage } from './COLoginPage'
29+
30+
function renderCOLoginPage() {
31+
const queryClient = new QueryClient({
32+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } }
33+
})
34+
return render(
35+
<QueryClientProvider client={queryClient}>
36+
<COLoginPage state="co" />
37+
</QueryClientProvider>
38+
)
39+
}
40+
41+
describe('COLoginPage', () => {
42+
beforeAll(() => {
43+
i18n.addResourceBundle('en', 'login', enCOLogin, true, true)
44+
i18n.addResourceBundle('en', 'common', enCOCommon, true, true)
45+
i18n.addResourceBundle('es', 'login', esCOLogin, true, true)
46+
i18n.addResourceBundle('es', 'common', esCOCommon, true, true)
47+
})
48+
49+
afterAll(() => {
50+
i18n.removeResourceBundle('en', 'login')
51+
i18n.removeResourceBundle('en', 'common')
52+
i18n.removeResourceBundle('es', 'login')
53+
i18n.removeResourceBundle('es', 'common')
54+
})
55+
56+
beforeEach(async () => {
57+
// Reset to English between tests so language-switch test doesn't bleed over
58+
await i18n.changeLanguage('en')
59+
})
60+
61+
it('renders the login title in English', () => {
62+
renderCOLoginPage()
63+
expect(
64+
screen.getByRole('heading', { name: /Access the Summer EBT portal/i })
65+
).toBeInTheDocument()
66+
})
67+
68+
it('renders the Log in button', () => {
69+
renderCOLoginPage()
70+
expect(screen.getByRole('button', { name: /Log in with myColorado/i })).toBeInTheDocument()
71+
})
72+
73+
it('renders the Iniciar sesión button with lang="es"', () => {
74+
renderCOLoginPage()
75+
const espButton = screen.getByRole('button', { name: /Iniciar sesión/i })
76+
expect(espButton).toHaveAttribute('lang', 'es')
77+
})
78+
79+
/**
80+
* Regression test: COLoginPage must use useTranslation, not getTranslations.
81+
* getTranslations is a server-side utility that only ever returns English —
82+
* it does not respond to i18next language changes. If this test fails, it
83+
* means the component was switched back to getTranslations.
84+
*/
85+
it('re-renders with Spanish translations when language is switched to Spanish', async () => {
86+
renderCOLoginPage()
87+
88+
// Confirm English renders initially
89+
expect(
90+
screen.getByRole('heading', { name: /Access the Summer EBT portal/i })
91+
).toBeInTheDocument()
92+
93+
// Switch language — only useTranslation responds to this; getTranslations does not
94+
await act(async () => {
95+
await i18n.changeLanguage('es')
96+
})
97+
98+
await waitFor(() => {
99+
expect(screen.getByRole('heading', { name: /cuidador principal/i })).toBeInTheDocument()
100+
})
101+
})
102+
})

src/SEBT.Portal.Web/src/app/(public)/login/COLoginPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import {
77
getOidcRedirectUriForCurrentOrigin,
88
savePkceForCallback
99
} from '@/lib/oidc-pkce'
10-
import { getTranslations } from '@/lib/translations'
1110
import type { StateCode } from '@sebt/design-system'
1211
import { Alert, TextLink, getStateLinks } from '@sebt/design-system'
1312
import { useMutation } from '@tanstack/react-query'
13+
import { useTranslation } from 'react-i18next'
1414

1515
async function fetchOidcConfig(state: StateCode): Promise<OidcConfigResponse> {
1616
return apiFetch<OidcConfigResponse>(`/auth/oidc/${state}/config`, {
@@ -20,8 +20,8 @@ async function fetchOidcConfig(state: StateCode): Promise<OidcConfigResponse> {
2020

2121
export function COLoginPage({ state }: { state: StateCode }) {
2222
const links = getStateLinks(state)
23-
const t = getTranslations('login')
24-
const tCommon = getTranslations('common')
23+
const { t } = useTranslation('login')
24+
const { t: tCommon } = useTranslation('common')
2525

2626
const oidcConfig = useMutation({
2727
mutationFn: () => fetchOidcConfig(state),

src/SEBT.Portal.Web/src/app/(public)/login/page.test.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
*/
88
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
99
import { render, screen } from '@testing-library/react'
10-
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
import i18n from 'i18next'
11+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
12+
13+
import enCOCommon from '@/content/locales/en/co/common.json'
14+
import enCOLogin from '@/content/locales/en/co/login.json'
15+
1116
import LoginPage from './page'
1217

1318
vi.mock('next/navigation', () => ({
@@ -73,6 +78,16 @@ describe('LoginPage', () => {
7378
})
7479

7580
describe('CO state', () => {
81+
beforeAll(() => {
82+
i18n.addResourceBundle('en', 'login', enCOLogin, true, true)
83+
i18n.addResourceBundle('en', 'common', enCOCommon, true, true)
84+
})
85+
86+
afterAll(() => {
87+
i18n.removeResourceBundle('en', 'login')
88+
i18n.removeResourceBundle('en', 'common')
89+
})
90+
7691
beforeEach(() => {
7792
mockGetState.mockReturnValue('co')
7893
})
@@ -81,15 +96,15 @@ describe('LoginPage', () => {
8196
renderWithQueryClient(<LoginPage />)
8297
expect(
8398
screen.getByRole('heading', {
84-
name: /Access your Summer EBT account/i
99+
name: /Access the Summer EBT portal/i
85100
})
86101
).toBeInTheDocument()
87102
})
88103

89104
it('applies text-primary-dark class to the title', () => {
90105
renderWithQueryClient(<LoginPage />)
91106
const heading = screen.getByRole('heading', {
92-
name: /Access your Summer EBT account/i
107+
name: /Access the Summer EBT portal/i
93108
})
94109
expect(heading).toHaveClass('text-primary-dark')
95110
})
@@ -110,17 +125,15 @@ describe('LoginPage', () => {
110125

111126
it('renders the Iniciar sesión outline button', () => {
112127
renderWithQueryClient(<LoginPage />)
113-
const espButton = screen.getByRole('button', { name: /Iniciar sesión con myColorado/i })
128+
const espButton = screen.getByRole('button', { name: /Iniciar sesión myColorado/i })
114129
expect(espButton).toHaveAttribute('lang', 'es')
115130
expect(espButton).toHaveClass('usa-button--outline')
116131
expect(espButton).toHaveClass('border-primary')
117132
})
118133

119134
it('renders the contact assistance link', () => {
120135
renderWithQueryClient(<LoginPage />)
121-
expect(
122-
screen.getByText('Contact us if you need assistance logging into your account.')
123-
).toBeInTheDocument()
136+
expect(screen.getByText('Contact us if you need assistance logging in.')).toBeInTheDocument()
124137
})
125138

126139
it('does not render LoginForm', () => {

0 commit comments

Comments
 (0)