Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down
2 changes: 2 additions & 0 deletions backend/tests/test_flashcards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/app/(auth)/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)
Expand Down Expand Up @@ -64,6 +68,11 @@ export default function OnboardingPage() {
useState<BillingInterval | null>(null)
const [checkoutError, setCheckoutError] = useState('')

const targetLanguageName = tLang(
getLanguageByCode(targetLanguage)?.iso639 ??
DEFAULT_TARGET_LANGUAGE.split('-')[0]
)

useEffect(() => {
loadConfig()
fetchLanguages().then(() => {
Expand Down Expand Up @@ -266,7 +275,7 @@ export default function OnboardingPage() {
{step === 2 && (
<div className="space-y-5">
<p className="text-fl-fg font-mono text-sm">
{t('goals.subtitle')}
{t('goals.subtitle', { language: targetLanguageName })}
</p>
<div className="grid grid-cols-2 gap-2">
{LEARNING_GOALS.map((goal) => {
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/store/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ export const useConfigStore = create<ConfigStore>((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.
}
},
}))
181 changes: 181 additions & 0 deletions frontend/tests/app/onboarding-goals-subtitle.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Locale, string> = {
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(
<NextIntlClientProvider
locale={locale}
messages={catalogs[locale]}
timeZone="UTC"
onError={onIntlError}
>
<OnboardingPage />
</NextIntlClientProvider>
)
}

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<Response>((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()
}
)
})
33 changes: 31 additions & 2 deletions frontend/tests/store/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,46 @@ 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()

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 })
Expand Down
2 changes: 1 addition & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion messages/ro.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ă",
Expand Down
Loading
Loading