- Implement tests for UpdateProfile use case to validate language persistence and behavior when lang_code is omitted.

- Create a new client asset file for future use.
This commit is contained in:
David Arranz 2026-10-05 12:56:33 +02:00
parent 839e1a5222
commit 988790f904
17 changed files with 597 additions and 350 deletions

View File

@ -19,6 +19,11 @@ import {
Form, Form,
Input, Input,
Label, Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch, Switch,
} from "@/ui"; } from "@/ui";
@ -27,6 +32,7 @@ import { AlertCircleIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useUnsavedChangesNotifier } from "@/lib/hooks"; import { useUnsavedChangesNotifier } from "@/lib/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -40,11 +46,13 @@ export const SettingsEditor = () => {
const [activeSection, setActiveSection] = useState("profile"); const [activeSection, setActiveSection] = useState("profile");
const { useOne, useUpdate, useUploadLogo } = useSettings(); const { useOne, useUpdate, useUploadLogo } = useSettings();
const { toast } = useToast(); const { toast } = useToast();
const { i18n } = useTranslation();
const { data, status, error: queryError } = useOne(); const { data, status, error: queryError } = useOne();
const defaultValues = useMemo( const defaultValues = useMemo(
() => ({ () => ({
lang_code: "es",
contact_information: "", contact_information: "",
default_payment_method: "", default_payment_method: "",
default_notes: "", default_notes: "",
@ -65,7 +73,7 @@ export const SettingsEditor = () => {
const form = useForm<SettingsDataForm>({ const form = useForm<SettingsDataForm>({
mode: "onBlur", mode: "onBlur",
values: data?.dealer, values: data ? { ...data.dealer, lang_code: data.lang_code } : undefined,
defaultValues, defaultValues,
/*resolver: joiResolver( /*resolver: joiResolver(
Joi.object({ Joi.object({
@ -130,6 +138,9 @@ export const SettingsEditor = () => {
}, },
//onSettled: () => {}, //onSettled: () => {},
onSuccess: () => { onSuccess: () => {
if (i18n.language !== data.lang_code) {
void i18n.changeLanguage(data.lang_code ?? "es");
}
reset(getValues()); reset(getValues());
toast({ description: "Ajustes guardados", variant: "success" }); toast({ description: "Ajustes guardados", variant: "success" });
}, },
@ -189,6 +200,38 @@ export const SettingsEditor = () => {
</a> </a>
</nav> </nav>
<div className={cn("grid gap-6", activeSection === "profile" ? "visible" : "hidden")}> <div className={cn("grid gap-6", activeSection === "profile" ? "visible" : "hidden")}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey='settings.form_fields.language.label' />
</CardTitle>
<CardDescription>
<Trans i18nKey='settings.form_fields.language.desc' />
</CardDescription>
</CardHeader>
<CardContent>
<Controller
control={form.control}
name='lang_code'
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger aria-label={t("settings.form_fields.language.label")}>
<SelectValue placeholder={t("settings.form_fields.language.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value='es'>Español</SelectItem>
<SelectItem value='en'>English</SelectItem>
</SelectContent>
</Select>
)}
/>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>
<Button>
<Trans i18nKey='common.save' />
</Button>
</CardFooter>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>

View File

@ -17,11 +17,13 @@ export type UseSettingsGetParamsType = {
export const useSettings = (params?: UseSettingsGetParamsType) => { export const useSettings = (params?: UseSettingsGetParamsType) => {
const dataSource = useDataSource(); const dataSource = useDataSource();
const keys = useQueryKey(); const keys = useQueryKey();
const settingsQueryKey = keys().data().resource("settings").action("one").id("me").params().get();
const profileQueryKey = keys().auth().action("profile").get();
return { return {
useOne: () => useOne: () =>
useOne<IGetProfileResponse_DTO>({ useOne<IGetProfileResponse_DTO>({
queryKey: keys().data().resource("settings").action("one").id("me").params().get(), queryKey: settingsQueryKey,
queryFn: () => queryFn: () =>
dataSource.getOne({ dataSource.getOne({
resource: "profile", resource: "profile",
@ -34,7 +36,7 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<IUpdateProfileResponse_DTO, TDataSourceError, IUpdateProfile_Request_DTO>({ return useMutation<IUpdateProfileResponse_DTO, TDataSourceError, IUpdateProfile_Request_DTO>({
mutationKey: keys().data().resource("settings").action("one").id("me").params().get(), mutationKey: settingsQueryKey,
mutationFn: (data) => { mutationFn: (data) => {
return dataSource.updateOne({ return dataSource.updateOne({
resource: "profile", resource: "profile",
@ -42,10 +44,23 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
id: "", id: "",
}); });
}, },
onSuccess: () => { onSuccess: (updatedDealer, variables) => {
queryClient.invalidateQueries({ const updateCachedProfile = (profile: IGetProfileResponse_DTO | undefined) =>
queryKey: ["data", "default", "settings"], profile
}); ? {
...profile,
lang_code: variables.lang_code ?? profile.lang_code,
dealer: { ...profile.dealer, ...updatedDealer },
}
: profile;
queryClient.setQueryData<IGetProfileResponse_DTO>(settingsQueryKey, updateCachedProfile);
queryClient.setQueryData<IGetProfileResponse_DTO>(profileQueryKey, updateCachedProfile);
return Promise.all([
queryClient.invalidateQueries({ queryKey: settingsQueryKey }),
queryClient.invalidateQueries({ queryKey: profileQueryKey }),
]);
}, },
}); });
}, },

View File

@ -5,6 +5,7 @@ import { ProtectedRoute } from "./ProtectedRoute";
const useIsLoggedIn = jest.fn(); const useIsLoggedIn = jest.fn();
const useGetProfile = jest.fn(); const useGetProfile = jest.fn();
const changeLanguage = jest.fn();
jest.mock("@/lib/hooks", () => ({ jest.mock("@/lib/hooks", () => ({
useIsLoggedIn: () => useIsLoggedIn(), useIsLoggedIn: () => useIsLoggedIn(),
@ -16,6 +17,10 @@ jest.mock("@/components", () => ({
LoadingOverlay: () => <div data-testid='loading' />, LoadingOverlay: () => <div data-testid='loading' />,
})); }));
jest.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "en", changeLanguage } }),
}));
const renderProtectedRoute = () => { const renderProtectedRoute = () => {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@ -48,6 +53,7 @@ describe("ProtectedRoute", () => {
}); });
beforeEach(() => { beforeEach(() => {
changeLanguage.mockClear();
useIsLoggedIn.mockReturnValue({ useIsLoggedIn.mockReturnValue({
isSuccess: true, isSuccess: true,
isLoading: false, isLoading: false,
@ -80,6 +86,7 @@ describe("ProtectedRoute", () => {
const container = renderProtectedRoute(); const container = renderProtectedRoute();
expect(container.querySelector("[data-testid='quotes']")).not.toBeNull(); expect(container.querySelector("[data-testid='quotes']")).not.toBeNull();
expect(changeLanguage).toHaveBeenCalledWith("es");
}); });
it("renders an error instead of treating a profile request failure as a logout", () => { it("renders an error instead of treating a profile request failure as a logout", () => {

View File

@ -1,6 +1,7 @@
import { ErrorOverlay, LoadingOverlay } from "@/components"; import { ErrorOverlay, LoadingOverlay } from "@/components";
import { useGetProfile, useIsLoggedIn } from "@/lib/hooks"; import { useGetProfile, useIsLoggedIn } from "@/lib/hooks";
import React, { useId } from "react"; import React, { useId } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
type ProctectRouteProps = { type ProctectRouteProps = {
@ -9,7 +10,7 @@ type ProctectRouteProps = {
export const ProtectedRoute = ({ children }: ProctectRouteProps) => { export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
const location = useLocation(); const location = useLocation();
//const { i18n } = useTranslation(); const { i18n } = useTranslation();
const id = useId(); const id = useId();
const { const {
@ -28,12 +29,11 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
enabled: isLoggedInSuccess && authenticated, enabled: isLoggedInSuccess && authenticated,
}); });
// Cambiamos el idioma de la aplicación si es necesario React.useEffect(() => {
/*React.useEffect(() => {
if (isProfileSuccess && profile?.lang_code && i18n.language !== profile.lang_code) { if (isProfileSuccess && profile?.lang_code && i18n.language !== profile.lang_code) {
i18n.changeLanguage(profile.lang_code); void i18n.changeLanguage(profile.lang_code);
} }
}, [isProfileSuccess, profile?.lang_code, i18n]);*/ }, [isProfileSuccess, profile?.lang_code, i18n]);
if (isLoggedInLoading || isProfileLoading || (isProfileSuccess && profile === null && isProfileFetching)) { if (isLoggedInLoading || isProfileLoading || (isProfileSuccess && profile === null && isProfileFetching)) {
return <LoadingOverlay />; return <LoadingOverlay />;

View File

@ -438,6 +438,11 @@
} }
}, },
"form_fields": { "form_fields": {
"language": {
"label": "Language",
"placeholder": "Select a language",
"desc": "Preferred language for the interface"
},
"image": { "image": {
"label": "Logotype", "label": "Logotype",
"placeholder": "", "placeholder": "",

View File

@ -440,6 +440,11 @@
} }
}, },
"form_fields": { "form_fields": {
"language": {
"label": "Idioma",
"placeholder": "Selecciona un idioma",
"desc": "Idioma preferido para la interfaz"
},
"logo": { "logo": {
"label": "Logotipo", "label": "Logotipo",
"placeholder": "", "placeholder": "",

254
dist/client/assets/index-BVZyG6kc.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" /> <link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>Presupuestador Uecko</title> <title>Presupuestador Uecko</title>
<script type="module" crossorigin src="/assets/index-CJmQk4ZY.js"></script> <script type="module" crossorigin src="/assets/index-QDyDWtz5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBtV2-Es.css"> <link rel="stylesheet" crossorigin href="/assets/index-CBtV2-Es.css">
</head> </head>

View File

@ -0,0 +1,104 @@
import { Language, UniqueID } from "@shared/contexts";
import { createProfileMapper } from "../infrastructure/mappers/profile.mapper";
import { createUserMapper } from "@/contexts/users/infrastructure/mappers/user.mapper";
import { UpdateProfileUseCase } from "./UpdateProfile.useCase";
const userId = "770672b9-5a87-4e8e-a8af-8b44a7432e12";
const profileSource = {
default_show_line_discount_column: true,
dataValues: {
id: "9b3cb7f7-3747-4eb4-bf9e-82f2f1ad03d6",
name: "Dealer",
status: "actived",
lang_code: "es",
currency_code: "EUR",
logo: "",
contact_information: "",
default_payment_method: "",
default_notes: "",
default_legal_terms: "",
default_quote_validity: "",
default_tax: 2100,
default_tax_label: "% IVA",
},
} as any;
const userSource = {
roles: ["ROLE_USER"],
dataValues: {
id: userId,
name: "User",
email: "user@example.test",
password: "$2b$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu",
lang_code: "es",
},
} as any;
const profileDTO = {
contact_information: "Contact",
default_payment_method: "Transfer",
default_notes: "Notes",
default_legal_terms: "Terms",
default_quote_validity: "30 days",
default_tax: { amount: 2100, scale: 2 },
default_tax_label: "% IVA",
default_show_line_discount_column: true,
logo: "",
};
describe("UpdateProfileUseCase language", () => {
const profile = createProfileMapper({} as any).mapToDomain(profileSource);
const user = createUserMapper({} as any).mapToDomain(userSource);
const profileRepo = {
getByUserId: jest.fn(),
update: jest.fn(),
};
const userRepo = {
getById: jest.fn(),
update: jest.fn(),
};
const adapter = {
startTransaction: () => ({ complete: async (callback: (transaction: any) => Promise<void>) => callback({}) }),
};
const repositoryManager = {
getRepository: (name: string) => () => (name === "Profile" ? profileRepo : userRepo),
};
beforeEach(() => {
jest.clearAllMocks();
profileRepo.getByUserId.mockResolvedValue(profile);
userRepo.getById.mockResolvedValue(user);
profileRepo.update.mockResolvedValue(undefined);
userRepo.update.mockResolvedValue(undefined);
});
it("persists the selected language on the user", async () => {
const useCase = new UpdateProfileUseCase({ adapter: adapter as any, repositoryManager: repositoryManager as any });
const result = await useCase.execute({
userId: UniqueID.create(userId).object,
profileDTO: { ...profileDTO, lang_code: "en" },
});
expect(result.isSuccess).toBe(true);
expect(user.language.code).toBe("en");
expect(userRepo.update).toHaveBeenCalledWith(user);
});
it("does not change the language when lang_code is omitted", async () => {
user.language = Language.createFromCode("en").object;
const useCase = new UpdateProfileUseCase({ adapter: adapter as any, repositoryManager: repositoryManager as any });
const result = await useCase.execute({
userId: UniqueID.create(userId).object,
profileDTO,
});
expect(result.isSuccess).toBe(true);
expect(user.language.code).toBe("en");
expect(userRepo.getById).not.toHaveBeenCalled();
expect(userRepo.update).not.toHaveBeenCalled();
});
});

View File

@ -9,12 +9,14 @@ import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { import {
IUpdateProfile_Request_DTO, IUpdateProfile_Request_DTO,
Language,
Percentage, Percentage,
Result, Result,
TextValueObject, TextValueObject,
UniqueID, UniqueID,
} from "@shared/contexts"; } from "@shared/contexts";
import { IProfileRepository, Profile } from "../domain"; import { IProfileRepository, Profile } from "../domain";
import { IUserRepository, User } from "@/contexts/users/domain";
export interface IUpdateProfileUseCaseRequest extends IUseCaseRequest { export interface IUpdateProfileUseCaseRequest extends IUseCaseRequest {
userId: UniqueID; userId: UniqueID;
@ -52,6 +54,22 @@ export class UpdateProfileUseCase
const profile = exitsOrError.object; const profile = exitsOrError.object;
let user: User | null = null;
if (profileDTO.lang_code !== undefined) {
const languageOrError = Language.createFromCode(profileDTO.lang_code);
if (languageOrError.isFailure) {
return Result.fail(languageOrError.error);
}
const userOrError = await this._getUser(userId);
if (userOrError.isFailure) {
return Result.fail(userOrError.error);
}
user = userOrError.object;
user.language = languageOrError.object;
}
// Actualizar el perfil con datos actualizados // Actualizar el perfil con datos actualizados
profile.contactInformation = TextValueObject.create(profileDTO.contact_information).object; profile.contactInformation = TextValueObject.create(profileDTO.contact_information).object;
profile.defaultPaymentMethod = TextValueObject.create(profileDTO.default_payment_method).object; profile.defaultPaymentMethod = TextValueObject.create(profileDTO.default_payment_method).object;
@ -78,18 +96,24 @@ export class UpdateProfileUseCase
profile.defaultTax = taxOrError.object; profile.defaultTax = taxOrError.object;
// Guardar los cambios // Guardar los cambios
return this._saveProfile(profile); return this._saveProfile(profile, user);
} }
private async _saveProfile(updatedProfile: Profile) { private async _saveProfile(updatedProfile: Profile, updatedUser: User | null) {
// Guardar el contacto // Guardar el contacto
const transaction = this._adapter.startTransaction(); const transaction = this._adapter.startTransaction();
const profileRepository = this._getProfileRepository(); const profileRepository = this._getProfileRepository();
const userRepository = this._getUserRepository();
try { try {
await transaction.complete(async (t) => { await transaction.complete(async (t) => {
const profileRepo = profileRepository({ transaction: t }); const profileRepo = profileRepository({ transaction: t });
await profileRepo.update(updatedProfile); await profileRepo.update(updatedProfile);
if (updatedUser) {
const userRepo = userRepository({ transaction: t });
await userRepo.update(updatedUser);
}
}); });
return Result.ok<Profile>(updatedProfile); return Result.ok<Profile>(updatedProfile);
@ -127,4 +151,26 @@ export class UpdateProfileUseCase
private _getProfileRepository() { private _getProfileRepository() {
return this._repositoryManager.getRepository<IProfileRepository>("Profile"); return this._repositoryManager.getRepository<IProfileRepository>("Profile");
} }
private async _getUser(userId: UniqueID) {
const userRepository = this._getUserRepository();
try {
const user = await userRepository().getById(userId);
if (!user) {
return Result.fail(UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, "User not found"));
}
return Result.ok<User>(user);
} catch (error: unknown) {
const infrastructureError = error as IInfrastructureError;
return Result.fail(
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, infrastructureError.message)
);
}
}
private _getUserRepository() {
return this._repositoryManager.getRepository<IUserRepository>("User");
}
} }

View File

@ -1,11 +1,13 @@
import { UpdateProfileUseCase } from "@/contexts/profile/application"; import { UpdateProfileUseCase } from "@/contexts/profile/application";
import { IProfileContext } from "../../../Profile.context"; import { IProfileContext } from "../../../Profile.context";
import { registerProfileRepository } from "../../../Profile.repository"; import { registerProfileRepository } from "../../../Profile.repository";
import { registerUserRepository } from "@/contexts/users/infrastructure/User.repository";
import { UpdateProfileController } from "./UpdateProfile.controller"; import { UpdateProfileController } from "./UpdateProfile.controller";
import { UpdateProfilePresenter } from "./presenter"; import { UpdateProfilePresenter } from "./presenter";
export const createUpdateProfileController = (context: IProfileContext) => { export const createUpdateProfileController = (context: IProfileContext) => {
registerProfileRepository(context); registerProfileRepository(context);
registerUserRepository(context);
return new UpdateProfileController( return new UpdateProfileController(
{ {

View File

@ -76,6 +76,10 @@ export class User extends AggregateRoot<IUserProps> implements IUser {
return this.props.language; return this.props.language;
} }
set language(language: Language) {
this.props.language = language;
}
get isUser(): boolean { get isUser(): boolean {
return this.hasRole(UserRole.ROLE_USER); return this.hasRole(UserRole.ROLE_USER);
} }

View File

@ -21,7 +21,7 @@ class UserMapper
email: this.mapsValue(source, "email", Email.create), email: this.mapsValue(source, "email", Email.create),
password: this.mapsValue(source, "password", Password.createFromHashedTextPassword), password: this.mapsValue(source, "password", Password.createFromHashedTextPassword),
roles: source.roles.map((rol) => UserRole.create(rol).object), roles: source.roles.map((rol) => UserRole.create(rol).object),
language: this.mapsValue(source, "language", Language.createFromCode), language: this.mapsValue(source, "lang_code", Language.createFromCode),
}; };
const id = this.mapsValue(source, "id", UniqueID.create); const id = this.mapsValue(source, "id", UniqueID.create);

View File

@ -25,4 +25,16 @@ describe("ensureUpdateProfile_Request_DTOIsValid default_tax_label", () => {
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, default_tax_label } as any) ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, default_tax_label } as any)
).toHaveProperty("isFailure", true); ).toHaveProperty("isFailure", true);
}); });
it.each([undefined, "es", "en"])('accepts interface language %p', (lang_code) => {
expect(
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, lang_code })
).toHaveProperty("isSuccess", true);
});
it.each(["ca", "fr", "zz"])('rejects unsupported interface language %p', (lang_code) => {
expect(
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, lang_code })
).toHaveProperty("isFailure", true);
});
}); });

View File

@ -1,7 +1,10 @@
import Joi from "joi"; import Joi from "joi";
import { IPercentage_DTO, Result, RuleValidator } from "../../../../common"; import { IPercentage_DTO, Result, RuleValidator } from "../../../../common";
export const SUPPORTED_INTERFACE_LANGUAGE_CODES = ["es", "en"] as const;
export interface IUpdateProfile_Request_DTO { export interface IUpdateProfile_Request_DTO {
lang_code?: string;
contact_information: string; contact_information: string;
default_payment_method: string; default_payment_method: string;
default_notes: string; default_notes: string;
@ -15,6 +18,7 @@ export interface IUpdateProfile_Request_DTO {
export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) { export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) {
const schema = Joi.object({ const schema = Joi.object({
lang_code: Joi.string().trim().lowercase().valid(...SUPPORTED_INTERFACE_LANGUAGE_CODES).optional(),
contact_information: Joi.string().optional().allow(null).allow("").default(""), contact_information: Joi.string().optional().allow(null).allow("").default(""),
default_payment_method: Joi.string().optional().allow(null).allow("").default(""), default_payment_method: Joi.string().optional().allow(null).allow("").default(""),
default_notes: Joi.string().optional().allow(null).allow("").default(""), default_notes: Joi.string().optional().allow(null).allow("").default(""),