diff --git a/CHANGELOG.md b/CHANGELOG.md index 5224edaf1..5b239e895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Onboarding learning goals now name the selected language in all ten interface languages, with natural wording and a safe display fallback for unrecognized language codes. - Public signup controls now reflect `ALLOW_REGISTRATION`: closed registration shows a localized invite-only message and Login action, while invitation links retain access to the registration form. The existing flag is exposed through `/api/config`. +- Configuration loading can retry after temporary network or invalid JSON failures, so registration and billing controls recover on subsequent navigation without requiring a full page reload. +- Blocked email-domain registration uses the current HTTP 422 status constant, avoiding the deprecated Starlette alias while preserving the response code and message. +- Flashcard concurrent-deletion tests detach the deleted object before promotion, avoiding SQLAlchemy identity-map collisions when SQLite reuses its ID while preserving the simulated deletion scenario. ## [1.9.15] - 2026-09-22 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 779fa9845..e38492534 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -79,7 +79,7 @@ async def register( email_domain = data.email.split("@")[-1].lower() if email_domain in [d.lower() for d in settings.BLOCKED_EMAIL_DOMAINS]: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Email domain not allowed", ) diff --git a/backend/tests/test_flashcards.py b/backend/tests/test_flashcards.py index 3a752d203..af60fa3f7 100644 --- a/backend/tests/test_flashcards.py +++ b/backend/tests/test_flashcards.py @@ -795,6 +795,8 @@ async def delete_then_promote(db, existing): .execution_options(synchronize_session=False) ) await db.commit() + # Keep the stale reference without an identity-map collision if SQLite reuses its ID. + db.expunge(existing) return await respond(db, existing) async def lookup(**kwargs): diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx index a0c44a993..ba841744a 100644 --- a/frontend/src/app/(auth)/onboarding/page.tsx +++ b/frontend/src/app/(auth)/onboarding/page.tsx @@ -12,7 +12,10 @@ import { useAuthStore, isSubscribed, isFreemiumTrialActive } from '@/store/auth' import { useConfigStore } from '@/store/config' import { useLanguageStore } from '@/store/language' import TargetLanguageSelector from '@/components/TargetLanguageSelector' -import { DEFAULT_TARGET_LANGUAGE } from '@/lib/target-languages' +import { + DEFAULT_TARGET_LANGUAGE, + getLanguageByCode, +} from '@/lib/target-languages' const LEARNING_GOALS = [ 'travel', @@ -34,6 +37,7 @@ function getSelectedPlan(plan: string | null): BillingInterval | null { export default function OnboardingPage() { const t = useTranslations('onboarding') const tCommon = useTranslations('common') + const tLang = useTranslations('targetLanguages') const router = useRouter() const searchParams = useSearchParams() const setUser = useAuthStore((s) => s.setUser) @@ -64,6 +68,11 @@ export default function OnboardingPage() { useState(null) const [checkoutError, setCheckoutError] = useState('') + const targetLanguageName = tLang( + getLanguageByCode(targetLanguage)?.iso639 ?? + DEFAULT_TARGET_LANGUAGE.split('-')[0] + ) + useEffect(() => { loadConfig() fetchLanguages().then(() => { @@ -266,7 +275,7 @@ export default function OnboardingPage() { {step === 2 && (

- {t('goals.subtitle')} + {t('goals.subtitle', { language: targetLanguageName })}

{LEARNING_GOALS.map((goal) => { diff --git a/frontend/src/store/config.ts b/frontend/src/store/config.ts index bdd733781..812df6556 100644 --- a/frontend/src/store/config.ts +++ b/frontend/src/store/config.ts @@ -64,8 +64,7 @@ export const useConfigStore = create((set, get) => ({ loaded: true, }) } catch { - // Non-fatal: keep conservative presentation defaults - set({ loaded: true }) + // Non-fatal: keep conservative defaults and allow the next load to retry. } }, })) diff --git a/frontend/tests/app/onboarding-goals-subtitle.test.tsx b/frontend/tests/app/onboarding-goals-subtitle.test.tsx new file mode 100644 index 000000000..1a25a1370 --- /dev/null +++ b/frontend/tests/app/onboarding-goals-subtitle.test.tsx @@ -0,0 +1,181 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { TARGET_LANGUAGE_CATALOG } from '@/lib/target-languages' +import { useAuthStore } from '@/store/auth' +import { useConfigStore } from '@/store/config' +import { useLanguageStore } from '@/store/language' +import de from '../../../messages/de.json' +import en from '../../../messages/en.json' +import es from '../../../messages/es.json' +import fr from '../../../messages/fr.json' +import itMessages from '../../../messages/it.json' +import nl from '../../../messages/nl.json' +import pl from '../../../messages/pl.json' +import pt from '../../../messages/pt.json' +import ro from '../../../messages/ro.json' +import ru from '../../../messages/ru.json' + +const { searchParams, apiFetch } = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), + apiFetch: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => searchParams, +})) + +vi.mock('@/lib/api', () => ({ apiFetch })) + +import OnboardingPage from '@/app/(auth)/onboarding/page' + +const catalogs = { de, en, es, fr, it: itMessages, nl, pl, pt, ro, ru } +type Locale = keyof typeof catalogs + +const spanishSubtitles: Record = { + de: 'Sprache: Spanisch. Wofür möchtest du sie nutzen? Wähle alle zutreffenden Ziele aus.', + en: 'Language: Spanish. What do you want to use it for? Select all that apply.', + es: 'Idioma: español. ¿Para qué quieres utilizarlo? Selecciona todos los objetivos que correspondan.', + fr: "Langue : espagnol. Dans quel but souhaitez-vous l'utiliser ? Sélectionnez tous les objectifs qui vous correspondent.", + it: 'Lingua: spagnolo. Per cosa vuoi usarla? Seleziona tutti gli obiettivi che fanno al caso tuo.', + nl: 'Taal: Spaans. Waarvoor wil je deze taal gebruiken? Selecteer alle doelen die van toepassing zijn.', + pl: 'Język: hiszpański. Do czego chcesz go używać? Zaznacz wszystkie cele, które Ci odpowiadają.', + pt: 'Idioma: espanhol. Para que você quer usá-lo? Selecione todos os objetivos que se aplicam.', + ro: 'Limbă: spaniolă. Pentru ce vrei să o folosești? Selectează toate obiectivele care ți se potrivesc.', + ru: 'Язык: испанский. Для чего ты хочешь его использовать? Выбери все подходящие цели.', +} + +const onIntlError = vi.fn() +const englishSubtitle = + 'Language: English. What do you want to use it for? Select all that apply.' + +function languageResponse( + codes = TARGET_LANGUAGE_CATALOG.map((language) => language.code) +) { + return new Response( + JSON.stringify({ languages: [], all_supported_languages: codes }) + ) +} + +function renderOnboarding(locale: Locale = 'en') { + return render( + + + + ) +} + +beforeEach(() => { + for (const key of Array.from(searchParams.keys())) searchParams.delete(key) + onIntlError.mockReset() + apiFetch.mockReset() + apiFetch.mockImplementation(async () => languageResponse()) + useAuthStore.setState({ ...useAuthStore.getInitialState() }, true) + useConfigStore.setState( + { ...useConfigStore.getInitialState(), loaded: true }, + true + ) + useLanguageStore.setState({ ...useLanguageStore.getInitialState() }, true) +}) + +afterEach(() => { + cleanup() + expect(onIntlError).not.toHaveBeenCalled() +}) + +describe('onboarding goals subtitle', () => { + it.each(Object.keys(catalogs) as Locale[])( + 'names the selected language with natural wording in the %s UI', + async (locale) => { + const messages = catalogs[locale] + renderOnboarding(locale) + + fireEvent.click( + await screen.findByRole('button', { + name: new RegExp(messages.targetLanguages['es-ES']), + }) + ) + fireEvent.click( + screen.getByRole('button', { name: messages.common.next }) + ) + + expect(screen.getByText(spanishSubtitles[locale])).toBeInTheDocument() + } + ) + + it.each([ + [null, 'English'], + ['en-GB', 'English'], + ['en-US', 'English'], + ['de-DE', 'German'], + ['fr-FR', 'French'], + ['it-IT', 'Italian'], + ['pt-PT', 'Portuguese'], + ['ja-JP', 'Japanese'], + ['ko-KR', 'Korean'], + ['zh-CN', 'Chinese'], + ])( + 'uses the language-level name for query language=%s', + async (code, name) => { + if (code) searchParams.set('language', code) + renderOnboarding() + await screen.findByRole('button', { name: /Spanish/ }) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + expect( + screen.getByText( + `Language: ${name}. What do you want to use it for? Select all that apply.` + ) + ).toBeInTheDocument() + } + ) + + it.each(['', 'unknown-language'])( + 'safely displays the default for language=%s while languages load', + async (code) => { + searchParams.set('language', code) + let resolveLanguages!: (response: Response) => void + apiFetch.mockReturnValueOnce( + new Promise((resolve) => { + resolveLanguages = resolve + }) + ) + renderOnboarding() + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + expect(screen.getByText(englishSubtitle)).toBeInTheDocument() + + await act(async () => resolveLanguages(languageResponse(['fr-FR']))) + + expect( + screen.getByText( + 'Language: French. What do you want to use it for? Select all that apply.' + ) + ).toBeInTheDocument() + } + ) + + it.each(['HTTP', 'network'])( + 'safely displays the default for an invalid query after a %s failure', + async (failure) => { + searchParams.set('language', 'unknown-language') + if (failure === 'HTTP') { + apiFetch.mockResolvedValueOnce(new Response('', { status: 503 })) + } else { + apiFetch.mockRejectedValueOnce(new Error('offline')) + } + renderOnboarding() + await screen.findByText(en.onboarding.saveFailed) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + expect(screen.getByText(englishSubtitle)).toBeInTheDocument() + } + ) +}) diff --git a/frontend/tests/store/config.test.ts b/frontend/tests/store/config.test.ts index 9a70f0d5a..ba6e20c5e 100644 --- a/frontend/tests/store/config.test.ts +++ b/frontend/tests/store/config.test.ts @@ -96,7 +96,7 @@ describe('useConfigStore', () => { expect(fetch).toHaveBeenCalledTimes(1) }) - it('keeps defaults when fetch fails', async () => { + it('keeps defaults and allows retry when fetch fails', async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error('network error')) await useConfigStore.getState().load() @@ -104,9 +104,38 @@ describe('useConfigStore', () => { expect(useConfigStore.getState().allowRegistration).toBe(false) expect(useConfigStore.getState().stripeEnabled).toBe(false) expect(useConfigStore.getState().ttsProvider).toBe('local') - expect(useConfigStore.getState().loaded).toBe(true) + expect(useConfigStore.getState().loaded).toBe(false) }) + it.each(['network error', 'invalid JSON'])( + 'recovers registration and billing configuration after %s', + async (failure) => { + if (failure === 'network error') { + vi.mocked(fetch).mockRejectedValueOnce(new Error('offline')) + } else { + vi.mocked(fetch).mockResolvedValueOnce(new Response('invalid JSON')) + } + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ allow_registration: true, stripe_enabled: true }) + ) + ) + + await useConfigStore.getState().load() + + expect(useConfigStore.getState().allowRegistration).toBe(false) + expect(useConfigStore.getState().stripeEnabled).toBe(false) + expect(useConfigStore.getState().loaded).toBe(false) + + await useConfigStore.getState().load() + + expect(fetch).toHaveBeenCalledTimes(2) + expect(useConfigStore.getState().allowRegistration).toBe(true) + expect(useConfigStore.getState().stripeEnabled).toBe(true) + expect(useConfigStore.getState().loaded).toBe(true) + } + ) + it('does not mark loaded when response is not ok (allows retry)', async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response('error', { status: 500 }) diff --git a/messages/de.json b/messages/de.json index b9e1c0de3..6829ed328 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1295,7 +1295,7 @@ "saveFailed": "Einstellungen konnten nicht gespeichert werden", "goals": { "title": "Deine Lernziele", - "subtitle": "Wozu möchtest du Englisch nutzen? Wähle alles Zutreffende.", + "subtitle": "Sprache: {language}. Wofür möchtest du sie nutzen? Wähle alle zutreffenden Ziele aus.", "skip": "Überspringen", "travel": "Reisen & Tourismus", "work": "Beruf & Karriere", diff --git a/messages/en.json b/messages/en.json index cbf8fe81a..4d17ca93d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1295,7 +1295,7 @@ "saveFailed": "Failed to save preferences", "goals": { "title": "Your Learning Goals", - "subtitle": "What do you want to use English for? Select all that apply.", + "subtitle": "Language: {language}. What do you want to use it for? Select all that apply.", "skip": "Skip", "travel": "Travel & Tourism", "work": "Work & Professional", diff --git a/messages/es.json b/messages/es.json index a7cda5d2e..0dadce61d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1295,7 +1295,7 @@ "saveFailed": "Error al guardar las preferencias", "goals": { "title": "Tus objetivos de aprendizaje", - "subtitle": "¿Para qué quieres usar el inglés? Selecciona todos los que apliquen.", + "subtitle": "Idioma: {language}. ¿Para qué quieres utilizarlo? Selecciona todos los objetivos que correspondan.", "skip": "Omitir", "travel": "Viajes y turismo", "work": "Trabajo y carrera", diff --git a/messages/fr.json b/messages/fr.json index aac8dcc4b..2c5c0dae3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1295,7 +1295,7 @@ "saveFailed": "Échec de l'enregistrement des préférences", "goals": { "title": "Vos objectifs d'apprentissage", - "subtitle": "Pour quoi voulez-vous utiliser l'anglais ? Sélectionnez tout ce qui s'applique.", + "subtitle": "Langue : {language}. Dans quel but souhaitez-vous l'utiliser ? Sélectionnez tous les objectifs qui vous correspondent.", "skip": "Passer", "travel": "Voyages et tourisme", "work": "Travail et carrière", diff --git a/messages/it.json b/messages/it.json index 382326ea8..0754c2b94 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1295,7 +1295,7 @@ "saveFailed": "Impossibile salvare le preferenze", "goals": { "title": "I tuoi obiettivi", - "subtitle": "Per cosa vuoi usare l'inglese? Seleziona tutto ciò che si applica.", + "subtitle": "Lingua: {language}. Per cosa vuoi usarla? Seleziona tutti gli obiettivi che fanno al caso tuo.", "skip": "Salta", "travel": "Viaggi e turismo", "work": "Lavoro e carriera", diff --git a/messages/nl.json b/messages/nl.json index 3a4fbb3f7..afc32d746 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1295,7 +1295,7 @@ "saveFailed": "Opslaan van voorkeuren mislukt", "goals": { "title": "Jouw leerdoelen", - "subtitle": "Waarvoor wil je Engels gebruiken? Selecteer alles wat van toepassing is.", + "subtitle": "Taal: {language}. Waarvoor wil je deze taal gebruiken? Selecteer alle doelen die van toepassing zijn.", "skip": "Overslaan", "travel": "Reizen en toerisme", "work": "Werk en carrière", diff --git a/messages/pl.json b/messages/pl.json index bcf7ca834..f04fb339f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1295,7 +1295,7 @@ "saveFailed": "Nie udało się zapisać preferencji", "goals": { "title": "Twoje cele nauki", - "subtitle": "Do czego chcesz używać angielskiego? Zaznacz wszystkie, które dotyczą Ciebie.", + "subtitle": "Język: {language}. Do czego chcesz go używać? Zaznacz wszystkie cele, które Ci odpowiadają.", "skip": "Pominąć", "travel": "Podróże i turystyka", "work": "Praca i kariera", diff --git a/messages/pt.json b/messages/pt.json index f82d1be2e..6cb871681 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -1295,7 +1295,7 @@ "saveFailed": "Falha ao salvar as preferências", "goals": { "title": "Seus objetivos de aprendizado", - "subtitle": "Para quê você quer usar o inglês? Selecione tudo que se aplica.", + "subtitle": "Idioma: {language}. Para que você quer usá-lo? Selecione todos os objetivos que se aplicam.", "skip": "Pular", "travel": "Viagens e turismo", "work": "Trabalho e carreira", diff --git a/messages/ro.json b/messages/ro.json index de8b09251..bd6435d00 100644 --- a/messages/ro.json +++ b/messages/ro.json @@ -1295,7 +1295,7 @@ "saveFailed": "Salvarea preferințelor a eșuat", "goals": { "title": "Obiectivele tale de învățare", - "subtitle": "Pentru ce vrei să folosești engleza? Selectează tot ce ți se potrivește.", + "subtitle": "Limbă: {language}. Pentru ce vrei să o folosești? Selectează toate obiectivele care ți se potrivesc.", "skip": "Sari", "travel": "Călătorii și turism", "work": "Muncă și carieră", diff --git a/messages/ru.json b/messages/ru.json index d590c07ed..77ff8941e 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1295,7 +1295,7 @@ "saveFailed": "Не удалось сохранить настройки", "goals": { "title": "Твои цели обучения", - "subtitle": "Для чего ты хочешь использовать английский? Выбери всё, что подходит.", + "subtitle": "Язык: {language}. Для чего ты хочешь его использовать? Выбери все подходящие цели.", "skip": "Пропустить", "travel": "Путешествия и туризм", "work": "Работа и карьера", diff --git a/specs/platform.instructions.md b/specs/platform.instructions.md index 749e7be01..2085b0f2a 100644 --- a/specs/platform.instructions.md +++ b/specs/platform.instructions.md @@ -116,6 +116,11 @@ Client state is split across auth, config, freemium, language, loading, progress The loading store uses a request counter and completion state; API calls through `apiFetch` participate automatically. +The shared config store caches a successful `/api/config` response. Network, HTTP, and JSON parsing +failures preserve the current values without marking the configuration as loaded, allowing the next +`load()` call to retry. Public registration remains closed by default until configuration enables it; +invitation forms remain available independently of configuration loading. + Theme supports system, dark, and light modes and is applied before first paint from persisted preference. diff --git a/specs/target-language.instructions.md b/specs/target-language.instructions.md index 33393e745..f17deb08e 100644 --- a/specs/target-language.instructions.md +++ b/specs/target-language.instructions.md @@ -74,6 +74,13 @@ The current frontend registration form does not send target language. Onboarding 6. Creates or activates the initial `UserLanguage`. 7. Navigates to Dashboard; assessment remains a separate next action. +The learning-goals subtitle identifies the selected language using the localized `targetLanguages` +ISO 639-1 entry rather than the regional selector label. The name appears as a standalone language +label, so translations do not require language-specific articles or inflections. Unrecognized +selection codes use the language of `DEFAULT_TARGET_LANGUAGE` (`en-GB`) for display, including while +the available-language request is pending or has failed. This display fallback does not change the +selected code or replace backend validation. + ## Compatibility field `users.target_language` remains part of registration and profile responses for defaults and older