- 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,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from "@/ui";
@ -27,6 +32,7 @@ import { AlertCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useUnsavedChangesNotifier } from "@/lib/hooks";
import { cn } from "@/lib/utils";
@ -40,11 +46,13 @@ export const SettingsEditor = () => {
const [activeSection, setActiveSection] = useState("profile");
const { useOne, useUpdate, useUploadLogo } = useSettings();
const { toast } = useToast();
const { i18n } = useTranslation();
const { data, status, error: queryError } = useOne();
const defaultValues = useMemo(
() => ({
lang_code: "es",
contact_information: "",
default_payment_method: "",
default_notes: "",
@ -65,7 +73,7 @@ export const SettingsEditor = () => {
const form = useForm<SettingsDataForm>({
mode: "onBlur",
values: data?.dealer,
values: data ? { ...data.dealer, lang_code: data.lang_code } : undefined,
defaultValues,
/*resolver: joiResolver(
Joi.object({
@ -130,6 +138,9 @@ export const SettingsEditor = () => {
},
//onSettled: () => {},
onSuccess: () => {
if (i18n.language !== data.lang_code) {
void i18n.changeLanguage(data.lang_code ?? "es");
}
reset(getValues());
toast({ description: "Ajustes guardados", variant: "success" });
},
@ -189,6 +200,38 @@ export const SettingsEditor = () => {
</a>
</nav>
<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>
<CardHeader>
<CardTitle>

View File

@ -17,11 +17,13 @@ export type UseSettingsGetParamsType = {
export const useSettings = (params?: UseSettingsGetParamsType) => {
const dataSource = useDataSource();
const keys = useQueryKey();
const settingsQueryKey = keys().data().resource("settings").action("one").id("me").params().get();
const profileQueryKey = keys().auth().action("profile").get();
return {
useOne: () =>
useOne<IGetProfileResponse_DTO>({
queryKey: keys().data().resource("settings").action("one").id("me").params().get(),
queryKey: settingsQueryKey,
queryFn: () =>
dataSource.getOne({
resource: "profile",
@ -34,7 +36,7 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
const queryClient = useQueryClient();
return useMutation<IUpdateProfileResponse_DTO, TDataSourceError, IUpdateProfile_Request_DTO>({
mutationKey: keys().data().resource("settings").action("one").id("me").params().get(),
mutationKey: settingsQueryKey,
mutationFn: (data) => {
return dataSource.updateOne({
resource: "profile",
@ -42,10 +44,23 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
id: "",
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["data", "default", "settings"],
});
onSuccess: (updatedDealer, variables) => {
const updateCachedProfile = (profile: IGetProfileResponse_DTO | undefined) =>
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 useGetProfile = jest.fn();
const changeLanguage = jest.fn();
jest.mock("@/lib/hooks", () => ({
useIsLoggedIn: () => useIsLoggedIn(),
@ -16,6 +17,10 @@ jest.mock("@/components", () => ({
LoadingOverlay: () => <div data-testid='loading' />,
}));
jest.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "en", changeLanguage } }),
}));
const renderProtectedRoute = () => {
const container = document.createElement("div");
document.body.appendChild(container);
@ -48,6 +53,7 @@ describe("ProtectedRoute", () => {
});
beforeEach(() => {
changeLanguage.mockClear();
useIsLoggedIn.mockReturnValue({
isSuccess: true,
isLoading: false,
@ -80,6 +86,7 @@ describe("ProtectedRoute", () => {
const container = renderProtectedRoute();
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", () => {

View File

@ -1,6 +1,7 @@
import { ErrorOverlay, LoadingOverlay } from "@/components";
import { useGetProfile, useIsLoggedIn } from "@/lib/hooks";
import React, { useId } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router-dom";
type ProctectRouteProps = {
@ -9,7 +10,7 @@ type ProctectRouteProps = {
export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
const location = useLocation();
//const { i18n } = useTranslation();
const { i18n } = useTranslation();
const id = useId();
const {
@ -28,12 +29,11 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
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) {
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)) {
return <LoadingOverlay />;

View File

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

View File

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

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" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<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">
</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 {
IUpdateProfile_Request_DTO,
Language,
Percentage,
Result,
TextValueObject,
UniqueID,
} from "@shared/contexts";
import { IProfileRepository, Profile } from "../domain";
import { IUserRepository, User } from "@/contexts/users/domain";
export interface IUpdateProfileUseCaseRequest extends IUseCaseRequest {
userId: UniqueID;
@ -52,6 +54,22 @@ export class UpdateProfileUseCase
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
profile.contactInformation = TextValueObject.create(profileDTO.contact_information).object;
profile.defaultPaymentMethod = TextValueObject.create(profileDTO.default_payment_method).object;
@ -78,18 +96,24 @@ export class UpdateProfileUseCase
profile.defaultTax = taxOrError.object;
// 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
const transaction = this._adapter.startTransaction();
const profileRepository = this._getProfileRepository();
const userRepository = this._getUserRepository();
try {
await transaction.complete(async (t) => {
const profileRepo = profileRepository({ transaction: t });
await profileRepo.update(updatedProfile);
if (updatedUser) {
const userRepo = userRepository({ transaction: t });
await userRepo.update(updatedUser);
}
});
return Result.ok<Profile>(updatedProfile);
@ -127,4 +151,26 @@ export class UpdateProfileUseCase
private _getProfileRepository() {
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 { IProfileContext } from "../../../Profile.context";
import { registerProfileRepository } from "../../../Profile.repository";
import { registerUserRepository } from "@/contexts/users/infrastructure/User.repository";
import { UpdateProfileController } from "./UpdateProfile.controller";
import { UpdateProfilePresenter } from "./presenter";
export const createUpdateProfileController = (context: IProfileContext) => {
registerProfileRepository(context);
registerUserRepository(context);
return new UpdateProfileController(
{

View File

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

View File

@ -21,7 +21,7 @@ class UserMapper
email: this.mapsValue(source, "email", Email.create),
password: this.mapsValue(source, "password", Password.createFromHashedTextPassword),
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);

View File

@ -25,4 +25,16 @@ describe("ensureUpdateProfile_Request_DTOIsValid default_tax_label", () => {
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, default_tax_label } as any)
).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 { IPercentage_DTO, Result, RuleValidator } from "../../../../common";
export const SUPPORTED_INTERFACE_LANGUAGE_CODES = ["es", "en"] as const;
export interface IUpdateProfile_Request_DTO {
lang_code?: string;
contact_information: string;
default_payment_method: string;
default_notes: string;
@ -15,6 +18,7 @@ export interface IUpdateProfile_Request_DTO {
export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) {
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(""),
default_payment_method: Joi.string().optional().allow(null).allow("").default(""),
default_notes: Joi.string().optional().allow(null).allow("").default(""),