diff --git a/frontend-new/src/_test_utilities/envServiceMock.ts b/frontend-new/src/_test_utilities/envServiceMock.ts index 1e7d013f1..84f1b99ff 100644 --- a/frontend-new/src/_test_utilities/envServiceMock.ts +++ b/frontend-new/src/_test_utilities/envServiceMock.ts @@ -23,4 +23,8 @@ jest.mock("src/envService", () => ({ getAppIconUrl: jest.fn(() => "mock-app-icon-url"), getThemeCssVariables: jest.fn(() => "{}"), getRegistrationCodeDisabled: jest.fn(() => "false"), + getTargetEnvironmentName: jest.fn(), + getSentryDSN: jest.fn(), + getSentryEnabled: jest.fn(), + getSentryConfig: jest.fn(), })); diff --git a/frontend-new/src/_test_utilities/i18nMock.ts b/frontend-new/src/_test_utilities/i18nMock.ts index 148bea29c..e9c13aae4 100644 --- a/frontend-new/src/_test_utilities/i18nMock.ts +++ b/frontend-new/src/_test_utilities/i18nMock.ts @@ -116,8 +116,12 @@ jest.mock("react-i18next", () => { // Mock the initialized instance module so app code can import it without side effects jest.mock("src/i18n/i18n", () => { + const { Locale } = require("src/i18n/constants"); + const mock = { + language: Locale.EN_GB, t: stableT, + changeLanguage: jest.fn(), use: jest.fn().mockReturnThis(), init: jest.fn().mockReturnThis(), }; diff --git a/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx b/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx index 525c7128f..e32bcf771 100644 --- a/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx +++ b/frontend-new/src/app/ProtectedRoute/ProtectedRoute.test.tsx @@ -9,10 +9,10 @@ import { routerPaths } from "src/app/routerPaths"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; // mock the FirebaseSocialAuthentication service jest.mock("src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service", () => { @@ -74,7 +74,7 @@ const getUserPreferences = ( ) => { const givenPreferences: UserPreference = { user_id: "user1", - language: Language.en, + language: Locale.EN_GB, accepted_tc: acceptedTC, has_sensitive_personal_data: hasSensitiveData, sensitive_personal_data_requirement: sensitiveDataRequirement, diff --git a/frontend-new/src/app/ProtectedRoute/util.test.ts b/frontend-new/src/app/ProtectedRoute/util.test.ts index 237ab0d54..472fbd878 100644 --- a/frontend-new/src/app/ProtectedRoute/util.test.ts +++ b/frontend-new/src/app/ProtectedRoute/util.test.ts @@ -1,8 +1,6 @@ import { isAcceptedTCValid, isSensitiveDataValid } from "src/app/ProtectedRoute/util"; -import { - Language, - SensitivePersonalDataRequirement, -} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; describe("protected route util", () => { describe("isSensitiveDataValid", () => { @@ -10,7 +8,7 @@ describe("protected route util", () => { // GIVEN a user required to provide sensitive personal data but does not have it const userPreferences = { user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -30,7 +28,7 @@ describe("protected route util", () => { // GIVEN a user required to provide sensitive personal data and has it const userPreferences = { user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: true, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -50,7 +48,7 @@ describe("protected route util", () => { // GIVEN a user not required to provide sensitive personal data const userPreferences = { user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE, @@ -72,7 +70,7 @@ describe("protected route util", () => { // GIVEN a user without accepted_tc const userPreferences = { user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: undefined, has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE, @@ -92,7 +90,7 @@ describe("protected route util", () => { // GIVEN a user with an invalid accepted_tc const userPreferences = { user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: "invalid date", has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE, @@ -112,7 +110,7 @@ describe("protected route util", () => { // GIVEN a user with a valid accepted_tc const userPreferences = { user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE, diff --git a/frontend-new/src/app/index.test.tsx b/frontend-new/src/app/index.test.tsx index 207243209..7dac779bf 100644 --- a/frontend-new/src/app/index.test.tsx +++ b/frontend-new/src/app/index.test.tsx @@ -9,15 +9,16 @@ import * as AuthenticationFactoryModule from "src/auth/services/Authentication.s import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import { PersistentStorageService } from "./PersistentStorageService/PersistentStorageService"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { DATA_TEST_ID as BACKDROP_DATA_TEST_ID } from "src/theme/Backdrop/Backdrop"; import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; import { routerPaths } from "src/app/routerPaths"; import { AuthBroadcastChannel, AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel"; +import LocaleSyncService from "src/i18n/LocaleSyncService"; // mock the snackbar jest.mock("src/theme/SnackbarProvider/SnackbarProvider", () => { @@ -93,6 +94,7 @@ describe("index", () => { // As a good practice, we should the mock*Once() methods to avoid side effects between tests // As a precaution, we reset all method mocks to ensure that no side effects are carried over between tests resetAllMethodMocks(UserPreferencesService.getInstance()); + resetAllMethodMocks(LocaleSyncService.getInstance()); unmockBrowserIsOnLine(); // Mock the authenticationServiceFactory to return a mock instance of the authentication service @@ -221,7 +223,7 @@ describe("index", () => { const mockPreferences = { user_id: "foo", - language: Language.en, + language: Locale.EN_GB, sessions: [123], accepted_tc: new Date(), has_sensitive_personal_data: false, @@ -257,7 +259,7 @@ describe("index", () => { const mockPreferences = { user_id: "foo", - language: Language.en, + language: Locale.EN_GB, sessions: [123], user_feedback_answered_questions: {}, accepted_tc: new Date(), @@ -291,6 +293,44 @@ describe("index", () => { expect(console.error).not.toHaveBeenCalled(); expect(console.warn).not.toHaveBeenCalled(); }); + + test("should apply backend locale on refresh", async () => { + // GIVEN a user with preferences containing a specific locale + jest.spyOn(AuthenticationStateService.getInstance(), "getToken").mockReturnValue("valid-token"); + + const applyBackendLocaleFn = jest + .spyOn(LocaleSyncService.getInstance(), "applyBackendLocale") + .mockResolvedValue(); + + const mockPreferences = { + user_id: "foo", + language: Locale.EN_US, + sessions: [123], + accepted_tc: new Date(), + has_sensitive_personal_data: false, + getActiveSessionId: jest.fn().mockReturnValue(123), + user_feedback_answered_questions: {}, + sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, + experiments: {}, + }; + + jest.spyOn(UserPreferencesService.getInstance(), "getUserPreferences").mockResolvedValue(mockPreferences); + + // WHEN the app is rendered + render(); + + // THEN wait for the app to finish loading + await waitFor(() => { + expect(screen.queryByTestId(BACKDROP_DATA_TEST_ID.BACKDROP_CONTAINER)).not.toBeInTheDocument(); + }); + + // AND expect no errors or warnings + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + + // AND expect the backend locale to be applied to the frontend + expect(applyBackendLocaleFn).toHaveBeenCalledWith(mockPreferences.language); + }); }); describe("register listeners", () => { diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx index 90cd4a6ed..309bec363 100644 --- a/frontend-new/src/app/index.tsx +++ b/frontend-new/src/app/index.tsx @@ -19,6 +19,7 @@ import UserPreferencesService from "src/userPreferences/UserPreferencesService/u import { AuthenticationError } from "src/error/commonErrors"; import { RestAPIError } from "src/error/restAPIError/RestAPIError"; import { StatusCodes } from "http-status-codes"; +import LocaleSyncService from "src/i18n/LocaleSyncService"; import { lazyWithPreload } from "src/utils/preloadableComponent/PreloadableComponent"; import { TokenValidationFailureCause } from "src/auth/services/Authentication.service"; import { AuthBroadcastChannel, AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel"; @@ -148,6 +149,10 @@ const App = () => { } UserPreferencesStateService.getInstance().setUserPreferences(preferences); + + // On page refresh, apply backend locale to frontend i18n + // This ensures the UI language matches the user's stored preference + await LocaleSyncService.getInstance().applyBackendLocale(preferences.language); } catch (error) { console.error(new AuthenticationError("Error initializing authentication and user preferences state", error)); await AuthenticationServiceFactory.resetAuthenticationState(); diff --git a/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx b/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx index da0520b2b..682794ffa 100644 --- a/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx +++ b/frontend-new/src/auth/components/SocialAuth/SocialAuth.test.tsx @@ -8,10 +8,8 @@ import { mockBrowserIsOnLine, unmockBrowserIsOnLine } from "src/_test_utilities/ import FirebaseSocialAuthenticationService from "src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service"; import authStateService from "src/auth/services/AuthenticationState.service"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; -import { - SensitivePersonalDataRequirement, - Language, -} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { TabiyaUser } from "src/auth/auth.types"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; import * as EnvServiceModule from "src/envService"; @@ -111,7 +109,7 @@ describe("SocialAuth tests", () => { // AND the user preferences exist for the user jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: givenUser.id, - language: Language.en, + language: Locale.EN_GB, sessions: [1], user_feedback_answered_questions: {}, accepted_tc: tc, @@ -617,7 +615,7 @@ describe("SocialAuth tests", () => { const createUserPreferencesSpy = jest.spyOn(UserPreferencesService.getInstance(), "createUserPreferences"); const mockPreferences = { user_id: givenUser.id, - language: Language.en, + language: Locale.EN_GB, invitation_code: "valid-code", sessions: [], user_feedback_answered_questions: {}, @@ -660,7 +658,7 @@ describe("SocialAuth tests", () => { expect(createUserPreferencesSpy).toHaveBeenCalledWith({ user_id: givenUser.id, invitation_code: "valid-code", - language: Language.en, + language: Locale.EN_GB, }); }); @@ -697,7 +695,7 @@ describe("SocialAuth tests", () => { require("src/userPreferences/UserPreferencesService/userPreferences.service").default; const mockPreferences = { user_id: givenUser.id, - language: Language.en, + language: Locale.EN_GB, invitation_code: "modal-code", sessions: [], user_feedback_answered_questions: {}, diff --git a/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx b/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx index d6b9a10cf..77c9d626d 100644 --- a/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx +++ b/frontend-new/src/auth/components/SocialAuth/SocialAuth.tsx @@ -15,8 +15,10 @@ import RegistrationCodeFormModal, { import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import { invitationsService } from "src/auth/services/invitationsService/invitations.service"; import { InvitationStatus, InvitationType } from "src/auth/services/invitationsService/invitations.types"; -import { Language, UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import { getRegistrationCodeDisabled, getRegistrationDisabled } from "src/envService"; +import { validateLocale } from "src/i18n/constants"; +import i18n from "src/i18n/i18n"; const uniqueId = "f0324e97-83fd-49e6-95c3-1043751fa1db"; export const DATA_TEST_ID = { @@ -96,10 +98,13 @@ const SocialAuth: React.FC> = ({ throw new Error("Something went wrong: No user found"); } + // Use the current i18n locale instead of a hardcoded default + const currentLocale = validateLocale(i18n.language); + if (!registrationCode) { prefs = await UserPreferencesService.getInstance().createUserPreferences({ user_id: _user.id, - language: Language.en, + language: currentLocale, }); } else { const invitation = await invitationsService.checkInvitationCodeStatus(registrationCode); @@ -115,7 +120,7 @@ const SocialAuth: React.FC> = ({ prefs = await UserPreferencesService.getInstance().createUserPreferences({ user_id: _user.id, invitation_code: invitation.invitation_code, - language: Language.en, + language: currentLocale, }); } diff --git a/frontend-new/src/auth/pages/Register/Register.test.tsx b/frontend-new/src/auth/pages/Register/Register.test.tsx index a75b72a3e..8beeda87e 100644 --- a/frontend-new/src/auth/pages/Register/Register.test.tsx +++ b/frontend-new/src/auth/pages/Register/Register.test.tsx @@ -13,9 +13,9 @@ import { InvitationStatus, InvitationType } from "src/auth/services/invitationsS import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import { SensitivePersonalDataRequirement, - Language, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import authStateService from "src/auth/services/AuthenticationState.service"; import { INVITATIONS_PARAM_NAME, TabiyaUser } from "src/auth/auth.types"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; @@ -301,7 +301,7 @@ describe("Testing Register component", () => { // AND the user preferences service is mocked to succeed jest.spyOn(UserPreferencesService.getInstance(), "createUserPreferences").mockResolvedValueOnce({ user_id: "foo-bar-id", - language: Language.en, + language: Locale.EN_GB, sessions: [], user_feedback_answered_questions: {}, accepted_tc: new Date(), diff --git a/frontend-new/src/auth/services/Authentication.service.test.ts b/frontend-new/src/auth/services/Authentication.service.test.ts index 7cbaea068..261c323e8 100644 --- a/frontend-new/src/auth/services/Authentication.service.test.ts +++ b/frontend-new/src/auth/services/Authentication.service.test.ts @@ -3,7 +3,7 @@ import AuthenticationService, { CLOCK_TOLERANCE, TokenValidationFailureCause } f import { jwtDecode } from "jwt-decode"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; -import { Language, UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { UserPreference } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import { TabiyaUser, Token, TokenHeader } from "src/auth/auth.types"; import AuthenticationStateService from "./AuthenticationState.service"; @@ -12,6 +12,17 @@ import { RestAPIError } from "src/error/restAPIError/RestAPIError"; import { StatusCodes } from "http-status-codes"; import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; import { nanoid } from "nanoid"; +import { Locale } from "src/i18n/constants"; + +// Mock LocaleSyncService to prevent actual backend calls +jest.mock("src/i18n/LocaleSyncService", () => ({ + __esModule: true, + default: { + getInstance: jest.fn().mockReturnValue({ + syncOnLogin: jest.fn().mockResolvedValue(undefined), + }), + }, +})); // Mock jwt-decode jest.mock("jwt-decode", () => ({ @@ -123,6 +134,7 @@ describe("AuthenticationService", () => { const givenUserPreferences: UserPreference = { user_id: "foo-id", sessions: [], + language: Locale.EN_GB, } as unknown as UserPreference; jest .spyOn(UserPreferencesService.getInstance(), "getUserPreferences") @@ -236,6 +248,7 @@ describe("AuthenticationService", () => { const givenReturnedPrefs: UserPreference = { user_id: "foo-id", sessions: [], + language: Locale.EN_GB, } as unknown as UserPreference; jest .spyOn(UserPreferencesService.getInstance(), "createUserPreferences") @@ -254,7 +267,7 @@ describe("AuthenticationService", () => { expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({ user_id: givenUser.id, invitation_code: givenRegistrationCode, - language: Language.en, + language: Locale.EN_GB, }); // AND the preferences return from the service should be set in the state @@ -294,6 +307,7 @@ describe("AuthenticationService", () => { const givenReturnedPrefs: UserPreference = { user_id: "foo-id", sessions: [], + language: Locale.EN_GB, } as unknown as UserPreference; jest .spyOn(UserPreferencesService.getInstance(), "createUserPreferences") @@ -312,7 +326,7 @@ describe("AuthenticationService", () => { expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({ user_id: givenUser.id, invitation_code: undefined, - language: Language.en, + language: Locale.EN_GB, }); // AND the preferences returned from the service should be set in the state @@ -359,7 +373,7 @@ describe("AuthenticationService", () => { expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({ user_id: givenUser.id, invitation_code: givenRegistrationCode, - language: Language.en, + language: Locale.EN_GB, }); // AND the preferences returned from the service should be set in the state diff --git a/frontend-new/src/auth/services/Authentication.service.ts b/frontend-new/src/auth/services/Authentication.service.ts index 170d45fd2..4f9134cca 100644 --- a/frontend-new/src/auth/services/Authentication.service.ts +++ b/frontend-new/src/auth/services/Authentication.service.ts @@ -1,12 +1,14 @@ import AuthenticationStateService from "./AuthenticationState.service"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; -import { Language } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import { TabiyaUser, Token, TokenHeader } from "src/auth/auth.types"; +import { validateLocale } from "src/i18n/constants"; +import i18n from "src/i18n/i18n"; import { jwtDecode } from "jwt-decode"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { RestAPIError } from "src/error/restAPIError/RestAPIError"; import { StatusCodes } from "http-status-codes"; +import LocaleSyncService from "src/i18n/LocaleSyncService"; export const CLOCK_TOLERANCE = 10; // 10 second buffer for expiration and issuance time, in case of clock skew @@ -121,8 +123,14 @@ abstract class AuthenticationService { throw error; } if (prefs !== null) { - // set the local preferences "state" ( for lack of a better word ) + // set the local preferences "state" (for lack of a better word) UserPreferencesStateService.getInstance().setUserPreferences(prefs); + + // Sync locale on login: if the frontend locale differs from the backend, update backend + // If a user logs in, we need to update the backend with the language they are using, so that it follows. + // It might be a bad idea to start on compass in English and realize you are having a conversation in Spanish + const frontendLocale = validateLocale(i18n.language); + await LocaleSyncService.getInstance().syncOnLogin(prefs.language, frontendLocale); } } @@ -139,10 +147,12 @@ abstract class AuthenticationService { this.authenticationStateService.setToken(token); // create user preferences for the first time. // in order to do this, there needs to be a logged-in user in the persistent storage + // Use the current i18n locale instead of a hardcoded default + const currentLocale = validateLocale(i18n.language); const prefs = await UserPreferencesService.getInstance().createUserPreferences({ user_id: user.id, invitation_code: registrationCode, - language: Language.en, + language: currentLocale, }); UserPreferencesStateService.getInstance().setUserPreferences(prefs); } diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts index 45302d6e5..0d31ceb02 100644 --- a/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts +++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/emailAuth/FirebaseEmailAuthentication.service.test.ts @@ -19,6 +19,16 @@ import { PersistentStorageService } from "src/app/PersistentStorageService/Persi import { TokenValidationFailureCause } from "src/auth/services/Authentication.service"; import { jwtDecode } from "jwt-decode"; +// Mock LocaleSyncService to prevent actual backend calls +jest.mock("src/i18n/LocaleSyncService", () => ({ + __esModule: true, + default: { + getInstance: jest.fn().mockReturnValue({ + syncOnLogin: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + jest.mock("jwt-decode", () => ({ jwtDecode: jest.fn(), })); @@ -588,6 +598,19 @@ describe("AuthService class tests", () => { // AND the user is currently logged in jest.spyOn(firebase.auth(), "currentUser", "get").mockReturnValue(mockAnonymousUser as any); + // AND getUser returns a valid user + const givenUser = { id: "123", name: givenUserName, email: givenEmail }; + jest.spyOn(authService, "getUser").mockReturnValue(givenUser); + + // AND the user has some preferences + const givenUserPreferences: UserPreference = { + user_id: "foo-id", + sessions: [], + } as unknown as UserPreference; + jest + .spyOn(UserPreferencesService.getInstance(), "getUserPreferences") + .mockResolvedValueOnce(givenUserPreferences); + // WHEN linking the anonymous account const actualToken = await authService.linkAnonymousAccount(givenEmail, givenPassword, givenUserName); diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts index 976dfc964..7325c1259 100644 --- a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts +++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthentication.service.test.ts @@ -6,10 +6,10 @@ import { invitationsService } from "src/auth/services/invitationsService/invitat import { Invitation, InvitationStatus, InvitationType } from "src/auth/services/invitationsService/invitations.types"; import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; @@ -92,6 +92,9 @@ describe("AuthService class tests", () => { jest .spyOn(UserPreferencesService.getInstance(), "getUserPreferences") .mockResolvedValueOnce(givenReturnedPreferences); + jest + .spyOn(UserPreferencesService.getInstance(), "updateUserPreferences") + .mockResolvedValueOnce(givenReturnedPreferences); // AND the token is decoded into a user jest.spyOn(authService, "getUser").mockReturnValue(givenUser); @@ -118,7 +121,7 @@ describe("AuthService class tests", () => { expect(UserPreferencesService.getInstance().createUserPreferences).toHaveBeenCalledWith({ user_id: givenUser.id, invitation_code: givenInvitationCode, - language: Language.en, + language: Locale.EN_GB, }); // AND the user preferences should be set in the user preferences state diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts index 8cf9ba070..5bb8c60ab 100644 --- a/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts +++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/invitationCodeAuth/FirebaseInvitationCodeAuthenticationService.ts @@ -10,11 +10,12 @@ import { AuthenticationServices, TabiyaUser } from "src/auth/auth.types"; import { invitationsService } from "src/auth/services/invitationsService/invitations.service"; import { InvitationStatus, InvitationType } from "src/auth/services/invitationsService/invitations.types"; import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; -import { Language } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import AuthenticationService from "src/auth/services/Authentication.service"; import { formatTokenForLogging } from "src/auth/utils/formatTokenForLogging"; import { TokenError } from "src/error/commonErrors"; +import { validateLocale } from "src/i18n/constants"; +import i18n from "src/i18n/i18n"; class FirebaseInvitationCodeAuthenticationService extends AuthenticationService { private static instance: FirebaseInvitationCodeAuthenticationService; @@ -92,10 +93,12 @@ class FirebaseInvitationCodeAuthenticationService extends AuthenticationService this.authenticationStateService.setToken(token); // create user preferences for the first time. + // Use the current i18n locale instead of a hardcoded default + const currentLocale = validateLocale(i18n.language); const prefs = await UserPreferencesService.getInstance().createUserPreferences({ user_id: _user.id, invitation_code: invitation.invitation_code, - language: Language.en, + language: currentLocale, }); UserPreferencesStateService.getInstance().setUserPreferences(prefs); diff --git a/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts b/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts index 5f6d65ca2..66f19fb89 100644 --- a/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts +++ b/frontend-new/src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSocialAuthentication.service.test.ts @@ -11,6 +11,16 @@ import StdFirebaseAuthenticationService from "src/auth/services/FirebaseAuthenti import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel"; +// Mock LocaleSyncService to prevent actual backend calls +jest.mock("src/i18n/LocaleSyncService", () => ({ + __esModule: true, + default: { + getInstance: jest.fn().mockReturnValue({ + syncOnLogin: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + jest.mock("firebase/compat/app", () => { const mockAuth = { initializeApp: jest.fn(), diff --git a/frontend-new/src/chat/Chat.stories.tsx b/frontend-new/src/chat/Chat.stories.tsx index 9fc3d1a0c..f32f00778 100644 --- a/frontend-new/src/chat/Chat.stories.tsx +++ b/frontend-new/src/chat/Chat.stories.tsx @@ -11,10 +11,10 @@ import ExperienceService from "src/experiences/experienceService/experienceServi import { SkillsRankingService } from "src/features/skillsRanking/skillsRankingService/skillsRankingService"; import cvService from "src/CV/CVService/CVService"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { ConversationPhase } from "src/chat/chatProgressbar/types"; import { ConversationMessageSender, ConversationResponse } from "./ChatService/ChatService.types"; import { nanoid } from "nanoid"; @@ -36,7 +36,7 @@ const meta: Meta = { status: 201, response: { user_id: nanoid(), - language: Language.en, + language: Locale.EN_GB, sessions: [1], user_feedback_answered_questions: {}, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, @@ -124,7 +124,7 @@ const meta: Meta = { const mockUserPreferencesStateService = UserPreferencesStateService.getInstance(); const defaultUserPreferences: UserPreference = { user_id: "1", - language: Language.en, + language: Locale.EN_GB, sessions: [123], user_feedback_answered_questions: {}, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, @@ -146,7 +146,7 @@ const meta: Meta = { // @ts-ignore mockUserPreferencesService.getNewSession = async (userId: string) => ({ user_id: userId, - language: Language.en, + language: Locale.EN_GB, sessions: [123], user_feedback_answered_questions: {}, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx index 6f9c49655..3aa3cf328 100644 --- a/frontend-new/src/chat/Chat.test.tsx +++ b/frontend-new/src/chat/Chat.test.tsx @@ -27,10 +27,10 @@ import ExperiencesDrawer, { } from "src/experiences/experiencesDrawer/ExperiencesDrawer"; import { DiveInPhase, WorkType } from "src/experiences/experienceService/experiences.types"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import ConfirmModalDialog, { DATA_TEST_ID as CONFIRM_MODAL_DIALOG_DATA_TEST_ID, } from "src/theme/confirmModalDialog/ConfirmModalDialog"; @@ -263,7 +263,7 @@ describe("Chat", () => { user_id: givenUser.id, accepted_tc: new Date(), has_sensitive_personal_data: false, - language: Language.en, + language: Locale.EN_GB, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, sessions: givenSessionId !== null ? [givenSessionId] : [], user_feedback_answered_questions: {}, @@ -804,7 +804,7 @@ describe("Chat", () => { user_id: givenUser.id, accepted_tc: new Date(), has_sensitive_personal_data: false, - language: Language.en, + language: Locale.EN_GB, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, sessions: [givenNewSessionId], user_feedback_answered_questions: {}, @@ -929,7 +929,7 @@ describe("Chat", () => { sessions: [givenNewSessionId], user_feedback_answered_questions: {}, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, - language: Language.en, + language: Locale.EN_GB, experiments: {}, }; jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockResolvedValueOnce(givenUserPreferences); @@ -988,7 +988,7 @@ describe("Chat", () => { const givenNewSessionId = 123; const givenUserPreferences: UserPreference = { user_id: givenUser.id, - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sessions: [givenNewSessionId], @@ -1201,7 +1201,9 @@ describe("Chat", () => { }); // THEN expect the send message method to be called with the user's message - expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage); + await waitFor(() => { + expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage); + }); // AND expect the user's message and a typing indicator to be shown in the chat await waitFor(() => { assertMessagesAreShown( @@ -1428,7 +1430,9 @@ describe("Chat", () => { }); // THEN expect the send message method to be called with the user's message - expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage); + await waitFor(() => { + expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenActiveSessionId, givenMessage); + }); // AND expect the user's message and a typing indicator to be shown in the chat await waitFor(() => { assertMessagesAreShown( @@ -1628,8 +1632,14 @@ describe("Chat", () => { }); // THEN expect the send message method to be called - expect(ChatService.getInstance().sendMessage).toHaveBeenCalledTimes(1); - expect(ChatService.getInstance().sendMessage).toHaveBeenLastCalledWith(givenActiveSessionId, givenMessage); + await waitFor(() => { + expect(ChatService.getInstance().sendMessage).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(ChatService.getInstance().sendMessage).toHaveBeenLastCalledWith(givenActiveSessionId, givenMessage); + }); + // AND expect an error to have been logged await waitFor(() => { expect(console.error).toHaveBeenCalledWith(new ChatError("Failed to send message:", givenError)); @@ -1840,7 +1850,7 @@ describe("Chat", () => { user_id: givenUser.id, accepted_tc: new Date(), has_sensitive_personal_data: false, - language: Language.en, + language: Locale.EN_GB, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, sessions: [givenNewSessionId, givenActiveSessionId], user_feedback_answered_questions: {}, diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index d9caa3115..b059a4d53 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -21,6 +21,7 @@ import ChatMessageField from "./ChatMessageField/ChatMessageField"; import { useNavigate } from "react-router-dom"; import { routerPaths } from "src/app/routerPaths"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import LocaleSyncService from "src/i18n/LocaleSyncService"; import { ConversationMessage, ConversationMessageSender } from "./ChatService/ChatService.types"; import { Backdrop } from "src/theme/Backdrop/Backdrop"; import ExperiencesDrawer from "src/experiences/experiencesDrawer/ExperiencesDrawer"; @@ -222,7 +223,11 @@ export const Chat: React.FC> = ({ } finally { setIsLoading(false); } - }, [enqueueSnackbar, activeSessionId, t]); + // Intentionally omitting `t` from dependencies: + // - This effect should only run when activeSessionId changes (conversation switching) + // - Including `t` would cause unnecessary conversation reloads on language change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enqueueSnackbar, activeSessionId]); // Opens the experiences drawer and get experiences if needed const handleOpenExperiencesDrawer = useCallback(async () => { @@ -567,6 +572,11 @@ export const Chat: React.FC> = ({ addMessageToChat(message); } + // Ensure the locale is synced before sending a message + // If there is a pending update of the locale, wait for that first. + // This is that we have real-time like when they change language and how it is applied at the backend. + await LocaleSyncService.getInstance().waitForPendingSync(); + try { // Send the user's message const response = await ChatService.getInstance().sendMessage(sessionId, userMessage); diff --git a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx index 5cc0f4c08..3822eb630 100644 --- a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx +++ b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx @@ -9,10 +9,8 @@ import { import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; import { TabiyaUser } from "src/auth/auth.types"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; -import { - SensitivePersonalDataRequirement, - Language, -} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { getBackendUrl } from "src/envService"; import { FeedbackStatus } from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; @@ -75,7 +73,7 @@ const StorybookWrapper = ({ UserPreferencesStateService.getInstance().setUserPreferences({ sessions: [1], user_id: "test-user", - language: Language.en, + language: Locale.EN_GB, user_feedback_answered_questions: { 1: answeredQuestions, }, diff --git a/frontend-new/src/chat/issueNewSession.test.ts b/frontend-new/src/chat/issueNewSession.test.ts index b00f48abe..02092d348 100644 --- a/frontend-new/src/chat/issueNewSession.test.ts +++ b/frontend-new/src/chat/issueNewSession.test.ts @@ -3,10 +3,10 @@ import "src/_test_utilities/consoleMock"; import { issueNewSession } from "src/chat/issueNewSession"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import { SessionError } from "src/error/commonErrors"; @@ -23,7 +23,7 @@ describe("issueNewSession", () => { // AND the user preferences service instance will return a new session for the given user ID and new session ID const givenUserPreferences: UserPreference = { user_id: givenUserId, - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), sessions: [givenNewSessionId], user_feedback_answered_questions: {}, @@ -54,7 +54,7 @@ describe("issueNewSession", () => { // GIVEN some user preferences const givenUserPreferences: UserPreference = { user_id: "foo", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), sessions: [123], user_feedback_answered_questions: {}, diff --git a/frontend-new/src/consent/components/consentPage/Consent.test.tsx b/frontend-new/src/consent/components/consentPage/Consent.test.tsx index 233752e2a..a42e3aa3c 100644 --- a/frontend-new/src/consent/components/consentPage/Consent.test.tsx +++ b/frontend-new/src/consent/components/consentPage/Consent.test.tsx @@ -16,9 +16,9 @@ import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline"; import { routerPaths } from "src/app/routerPaths"; import { SensitivePersonalDataRequirement, - Language, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { RestAPIError } from "src/error/restAPIError/RestAPIError"; import { DATA_TEST_ID as BACKDROP_TEST_ID } from "src/theme/Backdrop/Backdrop"; import { AuthenticationError } from "src/error/commonErrors"; @@ -151,7 +151,7 @@ describe("Testing Consent Page", () => { // GIVEN the user preferences state service is mocked to set the user preferences const givenUserPreferences: UserPreference = { user_id: "foo-id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), sessions: [], user_feedback_answered_questions: {}, @@ -478,7 +478,7 @@ describe("Testing Consent Page", () => { // GIVEN the user preferences state service is mocked to set the user preferences jest.spyOn(UserPreferencesService.getInstance(), "updateUserPreferences").mockResolvedValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_AVAILABLE, @@ -527,7 +527,7 @@ describe("Testing Consent Page", () => { // GIVEN the user preferences state service is mocked to set the user preferences. jest.spyOn(UserPreferencesService.getInstance(), "updateUserPreferences").mockResolvedValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, @@ -546,7 +546,7 @@ describe("Testing Consent Page", () => { // AND the user requires sensitive personal data. jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, diff --git a/frontend-new/src/consent/components/consentPage/Consent.tsx b/frontend-new/src/consent/components/consentPage/Consent.tsx index f969f5e13..6ca4a54e3 100644 --- a/frontend-new/src/consent/components/consentPage/Consent.tsx +++ b/frontend-new/src/consent/components/consentPage/Consent.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useState } from "react"; import { useTranslation, Trans } from "react-i18next"; import { Box, Container, Checkbox, styled, Typography, useTheme, FormControlLabel, useMediaQuery } from "@mui/material"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; -import { Language, UpdateUserPreferencesSpec } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { UpdateUserPreferencesSpec } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import { getUserFriendlyErrorMessage, RestAPIError } from "src/error/restAPIError/RestAPIError"; import PrimaryButton from "src/theme/PrimaryButton/PrimaryButton"; import { useNavigate } from "react-router-dom"; @@ -122,7 +122,6 @@ const Consent: React.FC = () => { const newUserPreferenceSpecs: UpdateUserPreferencesSpec = { user_id: user.id, - language: Language.en, accepted_tc: new Date(), }; setIsAccepting(true); diff --git a/frontend-new/src/experiences/report/config/getConfig.test.ts b/frontend-new/src/experiences/report/config/getConfig.test.ts index 348d374d5..924e67c19 100644 --- a/frontend-new/src/experiences/report/config/getConfig.test.ts +++ b/frontend-new/src/experiences/report/config/getConfig.test.ts @@ -1,14 +1,11 @@ import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; import * as envService from "src/envService"; import { defaultSkillsReportOutputConfig } from "./default"; import { DownloadFormat } from "./types"; import { getSkillsReportOutputConfig } from "./getConfig"; -jest.mock("src/envService", () => ({ - getSkillsReportOutputConfigEnvVar: jest.fn(), -})); - const defaultValue = defaultSkillsReportOutputConfig; describe("getConfig", () => { diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx index 9132aafa9..1177d7a76 100644 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx +++ b/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx @@ -8,10 +8,8 @@ import { act, fireEvent, waitFor } from "@testing-library/react"; import FeedbackFormContent from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; -import { - SensitivePersonalDataRequirement, - Language, -} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service"; import { FeedbackError } from "src/error/commonErrors"; import { FeedbackResponse } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; @@ -159,7 +157,7 @@ describe("FeedbackForm", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ accepted_tc: new Date(), user_id: "0001", - language: Language.en, + language: Locale.EN_GB, sessions: [], user_feedback_answered_questions: {}, has_sensitive_personal_data: false, diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx index bb076e8c1..392c9e815 100644 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx +++ b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.test.tsx @@ -14,10 +14,10 @@ import UserPreferencesStateService from "src/userPreferences/UserPreferencesStat import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; import { - Language, SensitivePersonalDataRequirement, UserPreference, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline"; import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; import { @@ -53,7 +53,7 @@ jest.mock("src/feedback/overallFeedback/feedbackForm/components/customRating/Cus const mockUserPreferences: UserPreference = { sessions: [123], user_id: "test-user", - language: Language.en, + language: Locale.EN_GB, user_feedback_answered_questions: {}, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, has_sensitive_personal_data: false, diff --git a/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.test.ts b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.test.ts new file mode 100644 index 000000000..9d7eec070 --- /dev/null +++ b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.test.ts @@ -0,0 +1,403 @@ +// mock the console logs +import "src/_test_utilities/consoleMock"; + +import LocaleSyncService from "./LocaleSyncService"; +import { Locale, FALL_BACK_LOCALE, SupportedLocales } from "src/i18n/constants"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; +import { + SensitivePersonalDataRequirement, + UserPreference, +} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import i18n from "src/i18n/i18n"; + +// Mock UserPreferencesService +jest.mock("src/userPreferences/UserPreferencesService/userPreferences.service"); + +function getMockUserPreference(overrides: Partial = {}): UserPreference { + return { + user_id: "test-user-id", + language: Locale.EN_GB, + sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, + has_sensitive_personal_data: true, + accepted_tc: new Date(), + sessions: [1, 2, 3], + user_feedback_answered_questions: {}, + experiments: {}, + ...overrides, + }; +} + +describe("LocaleSyncService", () => { + let service: LocaleSyncService; + let mockUpdateUserPreferences: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset i18n mock state + (i18n as any).language = "en-GB"; + + // Clear the UserPreferencesStateService state + UserPreferencesStateService.getInstance().clearUserPreferences(); + + // Setup UserPreferencesService mock + mockUpdateUserPreferences = jest.fn().mockResolvedValue(getMockUserPreference()); + (UserPreferencesService.getInstance as jest.Mock).mockReturnValue({ + updateUserPreferences: mockUpdateUserPreferences, + }); + + // Get fresh instance reference (singleton persists, but we reset mocks) + service = LocaleSyncService.getInstance(); + }); + + describe("getInstance", () => { + test("should return a singleton instance", () => { + // WHEN getInstance is called multiple times + const firstInstance = LocaleSyncService.getInstance(); + const secondInstance = LocaleSyncService.getInstance(); + + // THEN both should be the same instance + expect(firstInstance).toBe(secondInstance); + }); + }); + + describe("syncLocaleToBackend", () => { + test("should skip sync when user is not authenticated", async () => { + // GIVEN the user is not authenticated (no preferences) + expect(UserPreferencesStateService.getInstance().getUserPreferences()).toBeNull(); + + // WHEN syncLocaleToBackend is called + await service.syncLocaleToBackend(Locale.ES_ES); + + // THEN no API call should be made + expect(mockUpdateUserPreferences).not.toHaveBeenCalled(); + }); + + test("should sync locale to backend when user is authenticated", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + const updatedPrefs = getMockUserPreference({ language: Locale.ES_ES }); + mockUpdateUserPreferences.mockResolvedValue(updatedPrefs); + + // WHEN syncLocaleToBackend is called + await service.syncLocaleToBackend(Locale.ES_ES); + + // THEN the API should be called with the correct parameters + expect(mockUpdateUserPreferences).toHaveBeenCalledWith({ + user_id: mockPrefs.user_id, + language: Locale.ES_ES, + }); + }); + + test("should update local state with response from backend", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + const updatedPrefs = getMockUserPreference({ language: Locale.ES_ES }); + mockUpdateUserPreferences.mockResolvedValue(updatedPrefs); + + // WHEN syncLocaleToBackend is called + await service.syncLocaleToBackend(Locale.ES_ES); + + // THEN the local state should be updated + const currentPrefs = UserPreferencesStateService.getInstance().getUserPreferences(); + expect(currentPrefs?.language).toBe(Locale.ES_ES); + }); + + test("should handle API errors gracefully without throwing", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + // AND the API will fail + const apiError = new Error("API error"); + mockUpdateUserPreferences.mockRejectedValue(apiError); + + // WHEN syncLocaleToBackend is called + // THEN it should not throw + await expect(service.syncLocaleToBackend(Locale.ES_ES)).resolves.not.toThrow(); + + // AND the error should be logged (console is mocked) + expect(console.error).toHaveBeenCalled(); + }); + + describe("race condition handling", () => { + test("should queue subsequent requests while one is pending", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + // AND the API has a delay + let resolveFirst: () => void; + const firstCallPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + let callCount = 0; + mockUpdateUserPreferences.mockImplementation(async (params: { language: Locale }) => { + callCount++; + if (callCount === 1) { + await firstCallPromise; + } + return getMockUserPreference({ language: params.language }); + }); + + // WHEN multiple sync calls are made rapidly + const promise1 = service.syncLocaleToBackend(Locale.ES_ES); + const promise2 = service.syncLocaleToBackend(Locale.EN_US); + const promise3 = service.syncLocaleToBackend(Locale.ES_AR); + + // Resolve the first call + resolveFirst!(); + + await Promise.all([promise1, promise2, promise3]); + + // THEN only 2 API calls should be made (first + last queued) + // The intermediate locale (EN_US) should be skipped + expect(mockUpdateUserPreferences).toHaveBeenCalledTimes(2); + expect(mockUpdateUserPreferences).toHaveBeenNthCalledWith(1, { + user_id: mockPrefs.user_id, + language: Locale.ES_ES, + }); + expect(mockUpdateUserPreferences).toHaveBeenNthCalledWith(2, { + user_id: mockPrefs.user_id, + language: Locale.ES_AR, + }); + }); + }); + }); + + describe("syncOnLogin", () => { + test("should not sync when locales match", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference({ language: Locale.EN_GB }); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + // WHEN syncOnLogin is called with matching locales + await service.syncOnLogin(Locale.EN_GB, Locale.EN_GB); + + // THEN no API call should be made + expect(mockUpdateUserPreferences).not.toHaveBeenCalled(); + }); + + test("should sync to backend when locales differ", async () => { + // GIVEN the user is authenticated with EN_GB locale + const mockPrefs = getMockUserPreference({ language: Locale.EN_GB }); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + mockUpdateUserPreferences.mockResolvedValue(getMockUserPreference({ language: Locale.ES_ES })); + + // WHEN syncOnLogin is called with different locales + await service.syncOnLogin(Locale.EN_GB, Locale.ES_ES); + + // THEN the API should be called with the frontend locale + expect(mockUpdateUserPreferences).toHaveBeenCalledWith({ + user_id: mockPrefs.user_id, + language: Locale.ES_ES, + }); + }); + + test("should handle backend locale as string", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference({ language: Locale.EN_GB }); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + mockUpdateUserPreferences.mockResolvedValue(getMockUserPreference({ language: Locale.ES_ES })); + + // WHEN syncOnLogin is called with backend locale as string + await service.syncOnLogin("en-GB", Locale.ES_ES); + + // THEN the API should be called + expect(mockUpdateUserPreferences).toHaveBeenCalledWith({ + user_id: mockPrefs.user_id, + language: Locale.ES_ES, + }); + }); + }); + + describe("applyBackendLocale", () => { + test("should change i18n language when backend locale differs from current", async () => { + // GIVEN the current i18n language is EN_GB + (i18n as any).language = Locale.EN_GB; + + // WHEN applyBackendLocale is called with a different locale + await service.applyBackendLocale(Locale.ES_ES); + + // THEN i18n.changeLanguage should be called + expect(i18n.changeLanguage).toHaveBeenCalledWith(Locale.ES_ES); + }); + + test("should not change i18n language when backend locale matches current", async () => { + // GIVEN the current i18n language is EN_GB + (i18n as any).language = Locale.EN_GB; + + // WHEN applyBackendLocale is called with the same locale + await service.applyBackendLocale(Locale.EN_GB); + + // THEN i18n.changeLanguage should not be called + expect(i18n.changeLanguage).not.toHaveBeenCalled(); + }); + + test("should fall back to FALL_BACK_LOCALE when backend locale is not supported", async () => { + // GIVEN the current i18n language is EN_GB + (i18n as any).language = Locale.EN_GB; + + // WHEN applyBackendLocale is called with an unsupported locale + await service.applyBackendLocale("invalid-locale"); + + // THEN i18n.changeLanguage should be called with the fallback locale + expect(i18n.changeLanguage).toHaveBeenCalledWith(FALL_BACK_LOCALE); + + // AND an warning should be logged + expect(console.warn).toHaveBeenCalled(); + }); + + test("should validate all supported locales correctly", async () => { + // Test each supported locale to ensure they're all accepted + for (const locale of SupportedLocales) { + jest.clearAllMocks(); + (i18n as any).language = "different-locale"; + + // WHEN applyBackendLocale is called with a supported locale + await service.applyBackendLocale(locale); + + // THEN i18n.changeLanguage should be called with that locale + expect(i18n.changeLanguage).toHaveBeenCalledWith(locale); + + // AND no error should be logged + expect(console.error).not.toHaveBeenCalled(); + } + }); + }); + + describe("waitForPendingSync", () => { + test("should resolve immediately if no sync is pending", async () => { + // WHEN waitForPendingSync is called + const promise = service.waitForPendingSync(); + + // THEN it should resolve + await expect(promise).resolves.not.toThrow(); + }); + + test("should wait for pending sync to complete", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + // AND a sync is pending (delayed) + let resolveSync: () => void; + const syncPromise = new Promise((resolve) => { + resolveSync = resolve; + }); + + mockUpdateUserPreferences.mockImplementation(async () => { + await syncPromise; + return getMockUserPreference(); + }); + + // Start sync + const syncCall = service.syncLocaleToBackend(Locale.ES_ES); + + // WHEN waitForPendingSync is called + let waitResolved = false; + const waitPromise = service.waitForPendingSync().then(() => { + waitResolved = true; + }); + + // THEN it should not resolve yet + await new Promise((r) => setTimeout(r, 0)); + expect(waitResolved).toBe(false); + + // WHEN sync completes + resolveSync!(); + await syncCall; + + // THEN waitForPendingSync should resolve + await waitPromise; + expect(waitResolved).toBe(true); + }); + + test("should wait for queued sync to complete", async () => { + // GIVEN the user is authenticated + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + // AND a sync is pending (delayed) + let resolveFirstSync: () => void; + const firstSyncPromise = new Promise((resolve) => { + resolveFirstSync = resolve; + }); + + let resolveSecondSync: () => void; + const secondSyncPromise = new Promise((resolve) => { + resolveSecondSync = resolve; + }); + + let callCount = 0; + mockUpdateUserPreferences.mockImplementation(async (params) => { + callCount++; + if (callCount === 1) { + await firstSyncPromise; + } else if (callCount === 2) { + await secondSyncPromise; + } + return getMockUserPreference({ language: params.language }); + }); + + // Start first sync + const syncCall1 = service.syncLocaleToBackend(Locale.ES_ES); + + // Queue second sync + const syncCall2 = service.syncLocaleToBackend(Locale.ES_AR); + + // WHEN waitForPendingSync is called + let waitResolved = false; + const waitPromise = service.waitForPendingSync().then(() => { + waitResolved = true; + }); + + // Resolve first sync + resolveFirstSync!(); + + // Wait a bit for microtasks + await new Promise((r) => setTimeout(r, 0)); + + // Resolve second sync + resolveSecondSync!(); + await Promise.all([syncCall1, syncCall2]); + + // THEN waitForPendingSync should resolve + await waitPromise; + expect(waitResolved).toBe(true); + expect(callCount).toBe(2); + }); + }); + + describe("isAuthenticated", () => { + test("should return false when user preferences are not set", () => { + // GIVEN no user preferences are set + UserPreferencesStateService.getInstance().clearUserPreferences(); + + // WHEN isAuthenticated is called + const result = service.isAuthenticated(); + + // THEN it should return false + expect(result).toBe(false); + }); + + test("should return true when user preferences are set", () => { + // GIVEN user preferences are set + const mockPrefs = getMockUserPreference(); + UserPreferencesStateService.getInstance().setUserPreferences(mockPrefs); + + // WHEN isAuthenticated is called + const result = service.isAuthenticated(); + + // THEN it should return true + expect(result).toBe(true); + }); + }); +}); diff --git a/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.ts b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.ts new file mode 100644 index 000000000..8aa074b50 --- /dev/null +++ b/frontend-new/src/i18n/LocaleSyncService/LocaleSyncService.ts @@ -0,0 +1,181 @@ +import { Locale, SupportedLocales, FALL_BACK_LOCALE } from "src/i18n/constants"; +import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import i18n from "src/i18n/i18n"; + +/** + * LocaleSyncService is responsible for synchronizing the frontend i18n locale + * with the backend user preferences. + * + * Responsibilities: + * - Sync locale changes to backend when a user changes language in UI + * - Apply backend locale to frontend on page refresh (for authenticated users) + * - Sync locale to backend on login if frontend and backend differ + * - Handle race conditions when rapid locale changes occur + * + * This service is a singleton to ensure consistent state management across the app. + */ +export default class LocaleSyncService { + private static instance: LocaleSyncService; + private pendingSync: Promise | null = null; + private queuedLocale: Locale | null = null; + + private constructor() {} + + /** + * Get the singleton instance of LocaleSyncService. + * @returns {LocaleSyncService} The singleton instance. + */ + public static getInstance(): LocaleSyncService { + if (!LocaleSyncService.instance) { + LocaleSyncService.instance = new LocaleSyncService(); + } + return LocaleSyncService.instance; + } + + /** + * Sync the current locale to the backend. + * Handles race conditions by tracking pending requests and queuing the latest locale. + * + * @param {Locale} locale - The locale to sync to backend. + * @returns {Promise} + */ + public async syncLocaleToBackend(locale: Locale): Promise { + // If there's already a pending sync, queue this locale and wait + if (this.pendingSync) { + this.queuedLocale = locale; + await this.pendingSync; + // After pending completing, if this was the queued locale, process it + if (this.queuedLocale === locale) { + this.queuedLocale = null; + return this.executeSyncToBackend(locale); + } + // Otherwise, a newer locale was queued, so skip this one + return; + } + + return this.executeSyncToBackend(locale); + } + + /** + * Execute the actual sync to backend. + * @param {Locale} locale - The locale to sync. + * @returns {Promise} + */ + private async executeSyncToBackend(locale: Locale): Promise { + const userPreferences = UserPreferencesStateService.getInstance().getUserPreferences(); + + // Guard: Don't sync if not authenticated + if (!userPreferences) { + console.debug("LocaleSyncService: Skipping sync, user not authenticated"); + return; + } + + this.pendingSync = this.performBackendUpdate(userPreferences.user_id, locale); + + try { + await this.pendingSync; + } finally { + this.pendingSync = null; + } + } + + /** + * Perform the actual PATCH request to update locale in backend. + * @param {string} userId - The user ID. + * @param {Locale} locale - The locale to update. + * @returns {Promise} + */ + private async performBackendUpdate(userId: string, locale: Locale): Promise { + try { + const updatedPreferences = await UserPreferencesService.getInstance().updateUserPreferences({ + user_id: userId, + language: locale, + }); + + // Update local state with the response + UserPreferencesStateService.getInstance().setUserPreferences(updatedPreferences); + console.debug(`LocaleSyncService: Successfully synced locale ${locale} to backend`); + } catch (error) { + // Log error but don't throw - locale is already updated in i18n (optimistic update) + console.error(new Error(`LocaleSyncService: Failed to sync locale ${locale} to backend`, { cause: error })); + } + } + + /** + * Handle locale synchronization on login. + * If the frontend locale differs from the backend locale, update the backend. + * + * @param {string} backendLocale - The locale stored in backend preferences. + * @param {Locale} frontendLocale - The current i18n locale. + * @returns {Promise} + */ + public async syncOnLogin(backendLocale: string, frontendLocale: Locale): Promise { + // Validate that the comparison makes sense + if (backendLocale === frontendLocale) { + console.debug("LocaleSyncService: Locales match, no sync needed"); + return; + } + + console.debug( + `LocaleSyncService: Frontend locale (${frontendLocale}) differs from backend (${backendLocale}), syncing to backend` + ); + await this.syncLocaleToBackend(frontendLocale); + } + + /** + * Wait for any pending sync to complete. + * This ensures that the locale is synced to the backend before proceeding. + * Useful when performing actions that depend on the correct backend locale (e.g., sending a message). + * + * Note: Failures in the underlying sync are logged but do not prevent the wait + * from completing. This is intentional - we use optimistic updates for i18n, + * so even if backend sync fails, the frontend locale has already been updated. + * Backend sync failures will be retried on next login. + * + * @returns {Promise} + */ + public async waitForPendingSync(): Promise { + try { + if (this.pendingSync) { + await this.pendingSync; + } + } catch (e) { + // Log error but don't propagate - see method documentation for rationale + console.error("LocaleSyncService: Failed to wait for pending sync", e); + } + } + + /** + * Apply the backend locale to the frontend i18n. + * Used on page refresh for authenticated users. + * Validates that the backend locale is supported before applying. + * + * @param {string} backendLocale - The locale from backend preferences. + * @returns {Promise} + */ + public async applyBackendLocale(backendLocale: string): Promise { + // Validate the backend locale is supported + if (!SupportedLocales.includes(backendLocale as Locale)) { + console.warn( + `LocaleSyncService: Backend locale "${backendLocale}" is not supported. Supported: ${SupportedLocales.join(", ")}. Falling back to ${FALL_BACK_LOCALE}` + ); + await i18n.changeLanguage(FALL_BACK_LOCALE); + return; + } + + // Only change if different from the current + if (i18n.language !== backendLocale) { + console.debug(`LocaleSyncService: Applying backend locale ${backendLocale} to frontend`); + await i18n.changeLanguage(backendLocale); + } + } + + /** + * Check if a user is currently authenticated (has user preferences). + * @returns {boolean} True if authenticated, false otherwise. + */ + public isAuthenticated(): boolean { + return UserPreferencesStateService.getInstance().getUserPreferences() !== null; + } +} diff --git a/frontend-new/src/i18n/LocaleSyncService/index.ts b/frontend-new/src/i18n/LocaleSyncService/index.ts new file mode 100644 index 000000000..db9f812e7 --- /dev/null +++ b/frontend-new/src/i18n/LocaleSyncService/index.ts @@ -0,0 +1 @@ +export { default } from "./LocaleSyncService"; diff --git a/frontend-new/src/i18n/constants.ts b/frontend-new/src/i18n/constants.ts index 8098ee128..a4dc088ac 100644 --- a/frontend-new/src/i18n/constants.ts +++ b/frontend-new/src/i18n/constants.ts @@ -19,3 +19,19 @@ export const LocalesLabels = { export const SupportedLocales: Locale[] = [Locale.EN_GB, Locale.EN_US, Locale.ES_ES, Locale.ES_AR]; export const FALL_BACK_LOCALE = Locale.EN_GB; + +/** + * Validates and safely converts a string to a supported Locale. + * If the locale is not supported, falls back to FALL_BACK_LOCALE and logs a warning. + * + * @param locale - The locale string to validate (typically from i18n.language) + * @returns A valid Locale enum value + */ +export function validateLocale(locale: string): Locale { + const localeValue = locale as Locale; + if (SupportedLocales.includes(localeValue)) { + return localeValue; + } + console.warn(`[validateLocale] Invalid locale: ${locale}, falling back to ${FALL_BACK_LOCALE}`); + return FALL_BACK_LOCALE; +} diff --git a/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx b/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx index f56cb9c7f..b326b3329 100644 --- a/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx +++ b/frontend-new/src/i18n/languageContextMenu/LanguageContextMenu.tsx @@ -5,7 +5,10 @@ import { useTheme } from "@mui/material"; import ContextMenu from "src/theme/ContextMenu/ContextMenu"; import { useTranslation } from "react-i18next"; import { Locale, LocalesLabels } from "src/i18n/constants"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { parseEnvSupportedLocales } from "src/i18n/languageContextMenu/parseEnvSupportedLocales"; +import LocaleSyncService from "src/i18n/LocaleSyncService"; +import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; const uniqueId = "f4d06e4b-0e0c-49c7-ad93-924c5ac89070"; @@ -24,6 +27,7 @@ const LanguageContextMenu: React.FC = ({ removeMargin const theme = useTheme(); const { t, i18n } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); const handleLocaleChange = useCallback( (locale: Locale) => () => { @@ -31,12 +35,26 @@ const LanguageContextMenu: React.FC = ({ removeMargin .changeLanguage(locale) .then(() => { console.debug(`Language changed to ${locale}`); + // Sync locale to backend if user is authenticated + const isAuthenticated = UserPreferencesStateService.getInstance().getUserPreferences() !== null; + if (!isAuthenticated) return; + + LocaleSyncService.getInstance() + .syncLocaleToBackend(locale) + .catch((e) => { + // Notify the user that preference may not persist + enqueueSnackbar(t("i18n.sync.errors.syncFailedWarning", { locale: LocalesLabels[locale] }), { + variant: "warning", + }); + console.debug("Locale sync to backend failed, will retry on next login"); + }); }) .catch((e) => { console.error(`Failed to change language to ${locale}`, e); + enqueueSnackbar(t("i18n.sync.errors.changeFailed"), { variant: "error" }); }); }, - [i18n] + [i18n, enqueueSnackbar, t] ); const supportedLocales = useMemo(() => parseEnvSupportedLocales(), []); diff --git a/frontend-new/src/i18n/locales/en-GB/translation.json b/frontend-new/src/i18n/locales/en-GB/translation.json index c1136b22a..b69530abc 100644 --- a/frontend-new/src/i18n/locales/en-GB/translation.json +++ b/frontend-new/src/i18n/locales/en-GB/translation.json @@ -733,6 +733,12 @@ "i18n": { "languageContextMenu": { "selector": "Language Selector" + }, + "sync": { + "errors": { + "syncFailedWarning": "Language changed to {{locale}}, but we couldn't save your preference. It will revert on next login.", + "changeFailed": "Failed to change language. Please try again." + } } }, "_deprecated": { diff --git a/frontend-new/src/i18n/locales/en-US/translation.json b/frontend-new/src/i18n/locales/en-US/translation.json index f48745dc8..e38f98d6d 100644 --- a/frontend-new/src/i18n/locales/en-US/translation.json +++ b/frontend-new/src/i18n/locales/en-US/translation.json @@ -733,6 +733,12 @@ "i18n": { "languageContextMenu": { "selector": "Language Selector" + }, + "sync": { + "errors": { + "syncFailedWarning": "Language changed to {{locale}}, but we couldn't save your preference. It will revert on next login.", + "changeFailed": "Failed to change language. Please try again." + } } }, "_deprecated": { diff --git a/frontend-new/src/i18n/locales/es-AR/translation.json b/frontend-new/src/i18n/locales/es-AR/translation.json index 064b3106f..21b3c83dd 100644 --- a/frontend-new/src/i18n/locales/es-AR/translation.json +++ b/frontend-new/src/i18n/locales/es-AR/translation.json @@ -733,6 +733,12 @@ "i18n": { "languageContextMenu": { "selector": "Selector de idioma" + }, + "sync": { + "errors": { + "syncFailedWarning": "Idioma cambiado a {{locale}}, pero no pudimos guardar tu preferencia. Se revertirá en el próximo inicio de sesión.", + "changeFailed": "Error al cambiar el idioma. Por favor, intentá de nuevo." + } } }, "_deprecated": { diff --git a/frontend-new/src/i18n/locales/es-ES/translation.json b/frontend-new/src/i18n/locales/es-ES/translation.json index becf02fcc..06b8d69bd 100644 --- a/frontend-new/src/i18n/locales/es-ES/translation.json +++ b/frontend-new/src/i18n/locales/es-ES/translation.json @@ -733,6 +733,12 @@ "i18n": { "languageContextMenu": { "selector": "Selector de idioma" + }, + "sync": { + "errors": { + "syncFailedWarning": "Idioma cambiado a {{locale}}, pero no pudimos guardar tu preferencia. Se revertirá en el próximo inicio de sesión.", + "changeFailed": "Error al cambiar el idioma. Por favor, inténtalo de nuevo." + } } }, "_deprecated": { diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx index a24112c88..c8db0ecf5 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.stories.tsx @@ -2,10 +2,8 @@ import { Meta, StoryObj } from "@storybook/react"; import SensitiveDataForm from "src/sensitiveData/components/sensitiveDataForm/SensitiveDataForm"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; -import { - Language, - SensitivePersonalDataRequirement, -} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { getBackendUrl } from "src/envService"; const meta: Meta = { @@ -37,7 +35,7 @@ export const Shown: StoryObj = { sessions: [], sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, user_feedback_answered_questions: {}, - language: Language.en, + language: Locale.EN_GB, experiments: {}, }); return () => { @@ -56,7 +54,7 @@ export const ShownWhenSkipping: StoryObj = { sessions: [], sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, user_feedback_answered_questions: {}, - language: Language.en, + language: Locale.EN_GB, experiments: {}, }); return () => { diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx index 804170f24..6774772cd 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/sensitiveDataForm.test.tsx @@ -16,10 +16,8 @@ import * as RestAPIErrorModule from "src/error/restAPIError/RestAPIError"; import { RestAPIError } from "src/error/restAPIError/RestAPIError"; import AuthenticationServiceFactory from "src/auth/services/Authentication.service.factory"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; -import { - Language, - SensitivePersonalDataRequirement, -} from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { SensitivePersonalDataRequirement } from "src/userPreferences/UserPreferencesService/userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { sensitivePersonalDataService } from "src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service"; import { EncryptedDataTooLarge } from "src/sensitiveData/services/sensitivePersonalDataService/errors"; import { FieldDefinition, FieldType } from "./config/types"; @@ -153,7 +151,7 @@ const givenUserId = getTestString(10); const SAMPLE_USER_PREFERENCES = { user_id: givenUserId, - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), sessions: [], user_feedback_answered_questions: {}, @@ -206,7 +204,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -259,7 +257,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -312,7 +310,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -365,7 +363,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -419,7 +417,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -472,7 +470,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -508,7 +506,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -542,7 +540,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -811,7 +809,7 @@ describe("Sensitive Data Form", () => { // AND user preferences are set to show reject button jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: givenUserId, - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -931,7 +929,7 @@ describe("Sensitive Data Form", () => { // GIVEN the user preferences state service is mocked to return user preferences with PII required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, @@ -977,7 +975,7 @@ describe("Sensitive Data Form", () => { // GIVEN the user preferences state service is mocked to return user preferences with PII not required jest.spyOn(UserPreferencesStateService.getInstance(), "getUserPreferences").mockReturnValue({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, @@ -1008,7 +1006,7 @@ describe("Sensitive Data Form", () => { // AND the user preferences should be updated expect(UserPreferencesStateService.getInstance().setUserPreferences).toHaveBeenCalledWith({ user_id: "given user id", - language: Language.en, + language: Locale.EN_GB, accepted_tc: expect.any(Date), experiments: {}, has_sensitive_personal_data: true, @@ -1031,7 +1029,7 @@ describe("Sensitive Data Form", () => { // GIVEN sensitive personal data is not required const givenUserPreferences = { user_id: givenUserId, - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, @@ -1069,7 +1067,7 @@ describe("Sensitive Data Form", () => { // AND the sensitive personal data is not required const givenUserPreferences = { user_id: givenUserId, - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), has_sensitive_personal_data: false, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, diff --git a/frontend-new/src/sentryInit.test.ts b/frontend-new/src/sentryInit.test.ts index eacdd6d88..0228e6629 100644 --- a/frontend-new/src/sentryInit.test.ts +++ b/frontend-new/src/sentryInit.test.ts @@ -1,4 +1,5 @@ import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; import * as Sentry from "@sentry/react"; import { CompressionError, initSentry, SENTRY_CONFIG_DEFAULT, SentryConfig, sentryTransport } from "./sentryInit"; @@ -29,14 +30,6 @@ jest.mock("@sentry/core", () => ({ createTransport: jest.fn(), })); -jest.mock("./envService", () => ({ - getBackendUrl: jest.fn(), - getTargetEnvironmentName: jest.fn(), - getSentryDSN: jest.fn(), - getSentryEnabled: jest.fn(), - getSentryConfig: jest.fn(), -})); - jest.mock("brotli-wasm", () => ({ __esModule: true, default: Promise.resolve({ diff --git a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts index ed61f34aa..51d33b76c 100644 --- a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts +++ b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.test.ts @@ -9,16 +9,16 @@ import ErrorConstants from "src/error/restAPIError/RestAPIError.constants"; import { CreateUserPreferencesSpec, SensitivePersonalDataRequirement, - Language, UpdateUserPreferencesSpec, UserPreference, } from "./userPreferences.types"; +import { Locale } from "src/i18n/constants"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; function getTestUserPreferences(): UserPreference { return { user_id: "1", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), sessions: [1234], client_id: getRandomString(10), @@ -210,7 +210,7 @@ describe("UserPreferencesService", () => { // AND the PATCH models REST API will respond with OK and some models const givenUserPreferences: UpdateUserPreferencesSpec = { user_id: "1", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), }; @@ -250,7 +250,7 @@ describe("UserPreferencesService", () => { let updateUserPreferencesCallback = async () => await service.updateUserPreferences({ user_id: "1", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), }); @@ -285,7 +285,7 @@ describe("UserPreferencesService", () => { const updateUserPreferencesCallback = async () => await service.updateUserPreferences({ user_id: "1", - language: Language.en, + language: Locale.EN_GB, accepted_tc: new Date(), }); @@ -300,7 +300,7 @@ describe("UserPreferencesService", () => { // AND the POST models REST API will respond with OK and some models const givenUserPreferences: CreateUserPreferencesSpec = { user_id: "1", - language: Language.en, + language: Locale.EN_GB, invitation_code: "1234", }; @@ -374,7 +374,7 @@ describe("UserPreferencesService", () => { let createUserPreferencesCallback = async () => await service.createUserPreferences({ user_id: "1", - language: Language.en, + language: Locale.EN_GB, invitation_code: "1234", }); @@ -411,7 +411,7 @@ describe("UserPreferencesService", () => { const createUserPreferencesCallback = async () => await service.createUserPreferences({ user_id: "1", - language: Language.en, + language: Locale.EN_GB, invitation_code: "1234", }); diff --git a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts index 1ee99e493..d5c84360b 100644 --- a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts +++ b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.types.ts @@ -1,3 +1,5 @@ +import { Locale } from "src/i18n/constants"; + export enum SensitivePersonalDataRequirement { REQUIRED = "REQUIRED", NOT_REQUIRED = "NOT_REQUIRED", @@ -14,7 +16,7 @@ export type UserPreferencesExperiments = Partial random + index); return { user_id: nanoid(), // ensure a unique user id - language: Language.en, + language: Locale.EN_GB, sensitive_personal_data_requirement: SensitivePersonalDataRequirement.REQUIRED, has_sensitive_personal_data: true, accepted_tc: new Date(), diff --git a/frontend-new/src/userPreferences/UserPreferencesStateService.ts b/frontend-new/src/userPreferences/UserPreferencesStateService.ts index 626708342..d00d6ec30 100644 --- a/frontend-new/src/userPreferences/UserPreferencesStateService.ts +++ b/frontend-new/src/userPreferences/UserPreferencesStateService.ts @@ -1,9 +1,9 @@ import { SensitivePersonalDataRequirement, UserPreference, - Language, } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import { QUESTION_KEYS } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; +import { Locale, SupportedLocales, FALL_BACK_LOCALE } from "src/i18n/constants"; export default class UserPreferencesStateService { private static instance: UserPreferencesStateService; @@ -103,9 +103,18 @@ export default class UserPreferencesStateService { if (key === "sensitive_personal_data_requirement") { return SensitivePersonalDataRequirement[value as keyof typeof SensitivePersonalDataRequirement]; } + if (key === "language") { - return Language[value as keyof typeof Language]; + // Validate that the locale is supported, fall back to default if not + const locale = value as Locale; + if (SupportedLocales.includes(locale)) { + return locale; + } + + console.warn(`Unsupported locale "${value}" received from backend, falling back to ${FALL_BACK_LOCALE}`); + return FALL_BACK_LOCALE; } + return value; }); }