diff --git a/app/[locale]/admin/sellers/page.tsx b/app/[locale]/admin/sellers/page.tsx
index 3bfc1743..83ae5e80 100644
--- a/app/[locale]/admin/sellers/page.tsx
+++ b/app/[locale]/admin/sellers/page.tsx
@@ -1,4 +1,5 @@
import { redirect } from 'next/navigation';
+import Link from 'next/link';
import { container } from '@/composition-root/container';
import { ListSellersUseCase } from '@/modules/sellers/application/use-cases/list-sellers-use-case';
import { listSellersQuerySchema } from '@/modules/sellers/presentation/schemas/seller-schemas';
@@ -123,18 +124,18 @@ export default async function AdminSellersPage({
header: dict.admin.actions,
render: (seller) => (
-
{dict.admin.viewProducts}
-
-
+
{dict.admin.edit}
-
+
{dict.admin.sellersTitle}
-
+ {dict.admin.createSeller}
-
+
(null);
const dict = useDictionary();
const redirectByRole = (role: string) => {
@@ -30,26 +33,37 @@ export default function SignInPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
+ setError(null);
setLoading(true);
- const result = await signIn('credentials', {
- email,
- password,
- redirect: false,
- });
+ try {
+ const result = await signIn('credentials', {
+ email,
+ password,
+ redirect: false,
+ });
- if (result?.ok) {
- const res = await fetch('/api/auth/session');
- const session = await res.json();
- const role = session?.user?.role ?? 'CUSTOMER';
- redirectByRole(role);
- }
+ if (result?.error) {
+ setError(dict.auth.invalidCredentials);
+ return;
+ }
- setLoading(false);
+ if (result?.ok) {
+ const res = await fetch('/api/auth/session');
+ const session = await res.json();
+ const role = session?.user?.role ?? 'CUSTOMER';
+ redirectByRole(role);
+ }
+ } catch {
+ setError(dict.auth.invalidCredentials);
+ } finally {
+ setLoading(false);
+ }
};
return (
{dict.auth.signInTitle}
+ {error && }
);
diff --git a/app/[locale]/auth/signup/page.tsx b/app/[locale]/auth/signup/page.tsx
index e85397d0..b9bed908 100644
--- a/app/[locale]/auth/signup/page.tsx
+++ b/app/[locale]/auth/signup/page.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
+import Link from 'next/link';
import { signIn, useSession } from 'next-auth/react';
import type { ZodIssue } from 'zod';
import { TextField } from '@/shared/ui/text-field';
@@ -287,9 +288,9 @@ export default function SignUpPage() {
{dict.auth.alreadyHaveAccount}{' '}
-
+
{dict.auth.loginButton}
-
+
);
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index 19ba22d7..e1845975 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -1,6 +1,7 @@
import { getServerSession } from 'next-auth';
import type { Metadata, Viewport } from 'next';
import Image from 'next/image';
+import Link from 'next/link';
import { authOptions } from '@/shared/infrastructure/auth-options';
import { prisma } from '@/shared/infrastructure/prisma';
import LanguageSelector from '@/shared/layout/language-selector';
@@ -131,7 +132,7 @@ export default async function RootLayout({
)}
diff --git a/modules/products/application/get-product-by-id-use-case.ts b/modules/products/application/get-product-by-id-use-case.ts
index c4054863..9c4cf47c 100644
--- a/modules/products/application/get-product-by-id-use-case.ts
+++ b/modules/products/application/get-product-by-id-use-case.ts
@@ -3,6 +3,7 @@ import {
ProductRepository,
} from '../domain/product-repository';
import { resolveDisplay } from '../domain/entities/product-translation';
+import { NotFoundError } from '@/shared/kernel/app-error';
export class GetProductByIdUseCase {
constructor(private productRepository: ProductRepository) {}
@@ -11,7 +12,7 @@ export class GetProductByIdUseCase {
const product = await this.productRepository.findById(id, locale, audience);
if (!product) {
- throw new Error('Product not found');
+ throw new NotFoundError('Product not found');
}
const translation = resolveDisplay(product.translations, locale);
diff --git a/modules/users/application/dto/update-user.dto.ts b/modules/users/application/dto/update-user.dto.ts
index 6a98d71a..76f5966d 100644
--- a/modules/users/application/dto/update-user.dto.ts
+++ b/modules/users/application/dto/update-user.dto.ts
@@ -7,5 +7,5 @@ export interface UpdateUserDTO {
city: string;
postalCode: string;
country: string;
- };
+ } | null;
}
diff --git a/modules/users/application/use-cases/get-user-profile-use-case.ts b/modules/users/application/use-cases/get-user-profile-use-case.ts
new file mode 100644
index 00000000..0dc9fec7
--- /dev/null
+++ b/modules/users/application/use-cases/get-user-profile-use-case.ts
@@ -0,0 +1,9 @@
+import type { UserProfilePort } from '../../domain/user-profile-port';
+
+export class GetUserProfileUseCase {
+ constructor(private readonly profilePort: UserProfilePort) {}
+
+ execute(userId: string) {
+ return this.profilePort.findById(userId);
+ }
+}
diff --git a/modules/users/application/use-cases/update-user-use-case.ts b/modules/users/application/use-cases/update-user-use-case.ts
index dc97615d..85809282 100644
--- a/modules/users/application/use-cases/update-user-use-case.ts
+++ b/modules/users/application/use-cases/update-user-use-case.ts
@@ -6,6 +6,29 @@ import { Address } from '@/shared/kernel/domain/value-objects/address';
import type { UpdateUserDTO } from '../dto/update-user.dto';
import { validateName } from '@/shared/validation/name-validator';
+function resolveAddressChange(
+ existingAddress: Address | null,
+ incomingAddress: UpdateUserDTO['address'],
+) {
+ if (incomingAddress === undefined) {
+ return { address: existingAddress, changed: false };
+ }
+ if (incomingAddress === null) {
+ return { address: null, changed: existingAddress !== null };
+ }
+
+ const address = Address.create(
+ incomingAddress.street,
+ incomingAddress.city,
+ incomingAddress.postalCode,
+ incomingAddress.country,
+ );
+ return {
+ address,
+ changed: !existingAddress || !existingAddress.equals(address),
+ };
+}
+
export class UpdateUserUseCase {
constructor(
private readonly userRepository: UserRepository,
@@ -47,19 +70,9 @@ export class UpdateUserUseCase {
}
// 4. Apply address if provided and different
- let address = existing.address;
- if (dto.address !== undefined) {
- const newAddress = Address.create(
- dto.address.street,
- dto.address.city,
- dto.address.postalCode,
- dto.address.country,
- );
- if (!existing.address || !existing.address.equals(newAddress)) {
- address = newAddress;
- changedFields.push('address');
- }
- }
+ const addressChange = resolveAddressChange(existing.address, dto.address);
+ const address = addressChange.address;
+ if (addressChange.changed) changedFields.push('address');
// 5. Persist updated user
const now = new Date();
diff --git a/modules/users/domain/user-profile-port.ts b/modules/users/domain/user-profile-port.ts
new file mode 100644
index 00000000..bdd40bea
--- /dev/null
+++ b/modules/users/domain/user-profile-port.ts
@@ -0,0 +1,5 @@
+import type { UserEntity } from './user-repository';
+
+export interface UserProfilePort {
+ findById(userId: string): Promise;
+}
diff --git a/modules/users/infrastructure/prisma-user-repository.ts b/modules/users/infrastructure/prisma-user-repository.ts
index 06d4d038..0b3f7762 100644
--- a/modules/users/infrastructure/prisma-user-repository.ts
+++ b/modules/users/infrastructure/prisma-user-repository.ts
@@ -67,6 +67,10 @@ export class PrismaUserRepository implements UserRepository {
create: { userId, ...address },
});
}
+
+ async clearAddress(userId: string): Promise {
+ await prisma.userAddress.deleteMany({ where: { userId } });
+ }
async save(user: UserEntity, tx: PrismaClient = prisma): Promise {
const savedUser = await tx.user.upsert({
where: { id: user.userId.value },
diff --git a/modules/users/infrastructure/user-profile-adapter.ts b/modules/users/infrastructure/user-profile-adapter.ts
new file mode 100644
index 00000000..5643e3d2
--- /dev/null
+++ b/modules/users/infrastructure/user-profile-adapter.ts
@@ -0,0 +1,10 @@
+import type { UserProfilePort } from '../domain/user-profile-port';
+import type { UserRepository } from '../domain/user-repository';
+
+export class UserProfileAdapter implements UserProfilePort {
+ constructor(private readonly userRepository: UserRepository) {}
+
+ findById(userId: string) {
+ return this.userRepository.findById(userId);
+ }
+}
diff --git a/shared/i18n/locales/cat.json b/shared/i18n/locales/cat.json
index de0bb42a..74956a42 100644
--- a/shared/i18n/locales/cat.json
+++ b/shared/i18n/locales/cat.json
@@ -19,6 +19,10 @@
"products": "Els Nostres Productes",
"viewDetails": "Veure Detalls",
"productDetailsError": "No s'han pogut carregar els detalls del producte. Revisa els logs del servidor.",
+ "unexpectedErrorStatus": "Estat temporal",
+ "unexpectedErrorTitle": "Alguna cosa no funciona",
+ "unexpectedErrorDescription": "Ho estem intentant arreglar. Torna-ho a provar d'aquí a uns instants.",
+ "retry": "Tornar-ho a provar",
"noImageAvailable": "Imatge no disponible",
"customizable": "Personalitzable",
"customizeProduct": "Personalitza",
diff --git a/shared/i18n/locales/es.json b/shared/i18n/locales/es.json
index 818bc912..1779b993 100644
--- a/shared/i18n/locales/es.json
+++ b/shared/i18n/locales/es.json
@@ -19,6 +19,10 @@
"products": "Nuestros Productos",
"viewDetails": "Ver Detalles",
"productDetailsError": "No se han podido cargar los detalles del producto. Revisa los logs del servidor.",
+ "unexpectedErrorStatus": "Estado temporal",
+ "unexpectedErrorTitle": "Algo no funciona",
+ "unexpectedErrorDescription": "Estamos tratando de arreglarlo. Intentá de nuevo en unos instantes.",
+ "retry": "Reintentar",
"noImageAvailable": "Imagen no disponible",
"customizable": "Personalizable",
"customizeProduct": "Personaliza",
diff --git a/shared/layout/header-nav.tsx b/shared/layout/header-nav.tsx
index 84946b6e..49e74836 100644
--- a/shared/layout/header-nav.tsx
+++ b/shared/layout/header-nav.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { usePathname } from 'next/navigation';
+import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { LoginModal } from '@/shared/layout/login-modal';
import { UserMenuDropdown } from '@/shared/layout/user-menu-dropdown';
@@ -23,9 +24,9 @@ export function HeaderNav({ loginLabel, cartAlt, aboutLabel }: HeaderNavProps) {
const isAuthPage = pathname.includes('/auth/');
const locale = pathname.split('/', 2)[1] ?? 'es';
const aboutLink = (
-
+
{aboutLabel}
-
+
);
if (status === 'authenticated' && session?.user) {
diff --git a/shared/layout/login-modal.tsx b/shared/layout/login-modal.tsx
index 3a71dcd3..4c94979c 100644
--- a/shared/layout/login-modal.tsx
+++ b/shared/layout/login-modal.tsx
@@ -50,13 +50,8 @@ export function LoginModal({ isOpen, onClose }: LoginModalProps) {
globalThis.location.assign(`/${locale}/seller/products`);
}
}
- } catch (error_: unknown) {
- const message = error_ instanceof Error ? error_.message : '';
- if (message.includes('CredentialsSignin')) {
- setError(dict.auth.invalidCredentials);
- } else {
- setError(message || dict.auth.invalidCredentials);
- }
+ } catch {
+ setError(dict.auth.invalidCredentials);
} finally {
setLoading(false);
}
diff --git a/shared/ui/pagination.tsx b/shared/ui/pagination.tsx
index c833a241..b3d77170 100644
--- a/shared/ui/pagination.tsx
+++ b/shared/ui/pagination.tsx
@@ -1,4 +1,5 @@
import styles from './pagination.module.css';
+import Link from 'next/link';
interface PaginationProps {
currentPage: number;
@@ -29,9 +30,12 @@ export function Pagination({
{prevLabel}
) : (
-
+
{prevLabel}
-
+
)}
{pageInfo && {pageInfo}}
{currentPage >= totalPages ? (
@@ -39,9 +43,12 @@ export function Pagination({
{nextLabel}
) : (
-
+
{nextLabel}
-
+
)}
);
diff --git a/tests/unit/app/[locale]/auth/signin/signin-page.test.tsx b/tests/unit/app/[locale]/auth/signin/signin-page.test.tsx
index 94bf7053..a587e3a6 100644
--- a/tests/unit/app/[locale]/auth/signin/signin-page.test.tsx
+++ b/tests/unit/app/[locale]/auth/signin/signin-page.test.tsx
@@ -135,6 +135,54 @@ describe('SignInPage', () => {
});
});
+ it('shows the authentication error and clears loading when credentials fail', async () => {
+ vi.mocked(signIn).mockResolvedValue({
+ ok: false,
+ error: 'CredentialsSignin',
+ status: 401,
+ url: null,
+ });
+
+ render();
+ fireEvent.change(screen.getByLabelText('Correo electrónico'), {
+ target: { value: 'user@test.com' },
+ });
+ fireEvent.change(screen.getByLabelText('Contraseña'), {
+ target: { value: 'pass123' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Iniciar sesión' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Credenciales incorrectas')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Iniciar sesión' }),
+ ).toBeEnabled();
+ });
+ });
+
+ it('clears loading without exposing an unexpected sign-in error', async () => {
+ vi.mocked(signIn).mockRejectedValue(
+ new Error('Authentication unavailable'),
+ );
+
+ render();
+ fireEvent.change(screen.getByLabelText('Correo electrónico'), {
+ target: { value: 'user@test.com' },
+ });
+ fireEvent.change(screen.getByLabelText('Contraseña'), {
+ target: { value: 'pass123' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Iniciar sesión' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Credenciales incorrectas')).toBeInTheDocument();
+ expect(screen.queryByText('Authentication unavailable')).toBeNull();
+ expect(
+ screen.getByRole('button', { name: 'Iniciar sesión' }),
+ ).toBeEnabled();
+ });
+ });
+
it('renders link to signup page', () => {
render();
diff --git a/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx b/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx
index 9ee5a39e..95af42c2 100644
--- a/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx
+++ b/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx
@@ -27,10 +27,17 @@ vi.mock('@/modules/cart/presentation/components/add-to-cart-button', () => ({
addToCartButtonMock(props),
}));
-vi.mock('@/app/[locale]/products/[id]/mockup-canvas-control', () => ({
- MockupCanvasControl: ({ productImageUrl }: { productImageUrl: string }) => (
+const mockupCanvasControlMock = vi.fn(
+ ({ productImageUrl }: { productImageUrl: string; onUpload?: unknown }) => (
{productImageUrl}
),
+);
+
+vi.mock('@/app/[locale]/products/[id]/mockup-canvas-control', () => ({
+ MockupCanvasControl: (props: {
+ productImageUrl: string;
+ onUpload?: unknown;
+ }) => mockupCanvasControlMock(props),
}));
vi.mock('@/app/[locale]/products/[id]/similar-products', () => ({
@@ -387,4 +394,40 @@ describe('CustomizationExperience', () => {
expect(screen.getByTestId('mock-add-to-cart')).toBeTruthy();
});
+
+ it('does not persist a design when storage rejects the upload', async () => {
+ const config = ProductCustomizationConfig.fromJson({ mode: 'text_photo' });
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ id: 'upload-1',
+ uploadUrl: 'https://storage.example/upload-1',
+ storageKey: 'upload-1',
+ publicUrl: '/uploads/upload-1.png',
+ }),
+ })
+ .mockResolvedValueOnce({ ok: false });
+ vi.stubGlobal('fetch', mockFetch);
+
+ render(
+ ,
+ );
+
+ const canvasProps = mockupCanvasControlMock.mock.calls.at(-1)?.[0];
+ if (!canvasProps?.onUpload || typeof canvasProps.onUpload !== 'function') {
+ throw new Error('Expected the mockup canvas upload callback');
+ }
+ await expect(
+ canvasProps.onUpload(
+ new File(['image'], 'design.png', { type: 'image/png' }),
+ ),
+ ).rejects.toThrow('File storage failed');
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/tests/unit/app/[locale]/products/[id]/error.test.tsx b/tests/unit/app/[locale]/products/[id]/error.test.tsx
new file mode 100644
index 00000000..f972db7e
--- /dev/null
+++ b/tests/unit/app/[locale]/products/[id]/error.test.tsx
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import ProductDetailError from '@/app/[locale]/products/[id]/error';
+
+describe('ProductDetailError', () => {
+ it('shows a safe recovery message without exposing the underlying error', () => {
+ const reset = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole('heading', { name: 'Algo no funciona' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Estamos tratando de arreglarlo. Intentá de nuevo en unos instantes.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/PostgreSQL connection refused/i)).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reintentar' }));
+ expect(reset).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/unit/app/[locale]/products/[id]/page.test.tsx b/tests/unit/app/[locale]/products/[id]/page.test.tsx
index 818b43ba..63905637 100644
--- a/tests/unit/app/[locale]/products/[id]/page.test.tsx
+++ b/tests/unit/app/[locale]/products/[id]/page.test.tsx
@@ -498,10 +498,7 @@ describe('ProductDetailPage', () => {
expect(mocks.notFoundMock).toHaveBeenCalledOnce();
});
- it('calls notFound when loading the public product fails', async () => {
- mocks.notFoundMock.mockImplementation(() => {
- throw new Error('NEXT_NOT_FOUND');
- });
+ it('propagates infrastructure errors while loading the public product', async () => {
mocks.getProductRepositoryMock.mockReturnValue({
findById: vi.fn().mockRejectedValue(new Error('Database unavailable')),
});
@@ -510,8 +507,8 @@ describe('ProductDetailPage', () => {
ProductDetailPage({
params: Promise.resolve({ locale: 'es', id: 'unavailable-product' }),
}),
- ).rejects.toThrow('NEXT_NOT_FOUND');
+ ).rejects.toThrow('Database unavailable');
- expect(mocks.notFoundMock).toHaveBeenCalledOnce();
+ expect(mocks.notFoundMock).not.toHaveBeenCalled();
});
});
diff --git a/tests/unit/app/[locale]/profile/profile-form.test.tsx b/tests/unit/app/[locale]/profile/profile-form.test.tsx
new file mode 100644
index 00000000..f053ba2f
--- /dev/null
+++ b/tests/unit/app/[locale]/profile/profile-form.test.tsx
@@ -0,0 +1,90 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ProfileForm } from '@/app/[locale]/profile/profile-form';
+
+const mockFetch = vi.fn();
+vi.stubGlobal('fetch', mockFetch);
+
+const profile = {
+ email: 'test@example.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ address: {
+ street: '123 Main St',
+ city: 'Barcelona',
+ postalCode: '08001',
+ country: 'Spain',
+ },
+};
+
+describe('ProfileForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the server-loaded customer profile', () => {
+ render();
+
+ expect(screen.getByLabelText('Nombre')).toHaveValue('John');
+ expect(screen.getByLabelText('Apellido')).toHaveValue('Doe');
+ expect(screen.getByLabelText('Calle')).toHaveValue('123 Main St');
+ });
+
+ it('hides address fields for non-customer roles', () => {
+ render();
+
+ expect(screen.queryByLabelText('Calle')).toBeNull();
+ });
+
+ it('preserves profile PATCH mutations', async () => {
+ const user = userEvent.setup();
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
+ render();
+
+ await user.clear(screen.getByLabelText('Nombre'));
+ await user.type(screen.getByLabelText('Nombre'), 'Jane');
+ await user.click(screen.getByRole('button', { name: 'Enviar' }));
+
+ expect(mockFetch).toHaveBeenCalledWith('/api/users/me', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: expect.stringContaining('"firstName":"Jane"'),
+ });
+ });
+
+ it('sends an explicit address deletion after clearing an existing address', async () => {
+ const user = userEvent.setup();
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
+ render();
+
+ await user.clear(screen.getByLabelText('Calle'));
+ await user.clear(screen.getByLabelText('Código postal'));
+ await user.clear(screen.getByLabelText('Ciudad'));
+ await user.click(screen.getByRole('button', { name: 'Enviar' }));
+
+ expect(mockFetch).toHaveBeenCalledWith('/api/users/me', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: expect.stringContaining('"address":null'),
+ });
+ });
+
+ it('preserves profile DELETE mutations', async () => {
+ const user = userEvent.setup();
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ json: async () => ({ error: 'Failed to delete account' }),
+ });
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Eliminar cuenta' }));
+ await user.click(
+ screen.getAllByRole('button', { name: 'Eliminar cuenta' })[1],
+ );
+
+ expect(mockFetch).toHaveBeenCalledWith('/api/users/me', {
+ method: 'DELETE',
+ });
+ });
+});
diff --git a/tests/unit/app/[locale]/profile/profile-page.test.tsx b/tests/unit/app/[locale]/profile/profile-page.test.tsx
index 83987388..76de2e1c 100644
--- a/tests/unit/app/[locale]/profile/profile-page.test.tsx
+++ b/tests/unit/app/[locale]/profile/profile-page.test.tsx
@@ -1,223 +1,116 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
-import ProfilePage from '@/app/[locale]/profile/page';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+const mocks = vi.hoisted(() => {
+ const getSessionMock = vi.fn();
+ const getProfileMock = vi.fn();
+ const redirectMock = vi.fn();
+ const profileFormMock = vi.fn();
+
+ return {
+ getSessionMock,
+ getProfileMock,
+ redirectMock,
+ profileFormMock,
+ };
+});
-// Mock next/navigation
-const mockPush = vi.fn();
-vi.mock('next/navigation', () => ({
- useRouter: () => ({ push: mockPush }),
- useParams: () => ({ locale: 'es' }),
-}));
+vi.mock('server-only', () => ({}));
-// Mock fetch globally
-const mockFetch = vi.fn();
-vi.stubGlobal('fetch', mockFetch);
+vi.mock('next-auth', () => ({
+ getServerSession: mocks.getSessionMock,
+}));
-// Mock next-auth
-vi.mock('next-auth/react', () => ({
- useSession: vi.fn(),
+vi.mock('next/navigation', () => ({
+ redirect: mocks.redirectMock,
}));
-import { useSession } from 'next-auth/react';
+vi.mock('@/shared/infrastructure/auth-options', () => ({
+ authOptions: {},
+}));
-const mockUseSession = vi.mocked(useSession);
+vi.mock('@/composition-root/container', () => ({
+ container: {
+ getUserProfileUseCase: () => ({ execute: mocks.getProfileMock }),
+ },
+}));
-const baseProfileResponse = {
- id: '1',
- email: 'test@example.com',
- firstName: 'John',
- lastName: 'Doe',
- address: {
- street: '123 Main St',
- city: 'Barcelona',
- postalCode: '08001',
- country: 'Spain',
+vi.mock('@/app/[locale]/profile/profile-form', () => ({
+ ProfileForm: (props: unknown) => {
+ mocks.profileFormMock(props);
+ return ;
},
- emailVerified: null,
- createdAt: '2025-01-01T00:00:00.000Z',
-};
-
-type ProfileResponse = Omit & {
- address: typeof baseProfileResponse.address | null;
-};
-
-const mockAuthenticatedSession = (role: string) => {
- mockUseSession.mockReturnValue({
- data: {
- user: {
- id: '1',
- email: 'test@example.com',
- name: 'John Doe',
- role,
- emailVerified: null,
- },
- expires: '2099-01-01T00:00:00.000Z',
- },
- status: 'authenticated',
- update: vi.fn(),
- });
-};
-
-const mockProfileFetch = (profile: Partial = {}) => {
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- ...baseProfileResponse,
- ...profile,
- }),
- });
-};
+}));
+
+import ProfilePage from '@/app/[locale]/profile/page';
describe('ProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockAuthenticatedSession('user');
- });
-
- it('renders loading state initially', () => {
- mockFetch.mockImplementation(() => new Promise(() => {})); // never resolves
- render();
- expect(screen.getByText('Cargando...')).toBeInTheDocument();
- });
-
- it('fetches and displays user profile data', async () => {
- mockProfileFetch();
- mockAuthenticatedSession('CUSTOMER');
-
- render();
-
- await waitFor(() => {
- expect(screen.getByLabelText('Nombre')).toHaveValue('John');
- expect(screen.getByLabelText('Apellido')).toHaveValue('Doe');
- expect(screen.getByLabelText('Calle')).toHaveValue('123 Main St');
- expect(screen.getByLabelText('Ciudad')).toHaveValue('Barcelona');
- });
- });
-
- it('shows error message when GET fails', async () => {
- mockFetch.mockResolvedValueOnce({
- ok: false,
- json: async () => ({ error: 'Unauthorized' }),
- });
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText('Unauthorized')).toBeInTheDocument();
+ mocks.getSessionMock.mockResolvedValue({
+ user: { id: 'user-1', role: 'CUSTOMER' },
});
- });
-
- it('displays delete account button', async () => {
- mockProfileFetch({ address: null });
-
- render();
-
- await waitFor(() => {
- expect(
- screen.getByRole('button', { name: 'Eliminar cuenta' }),
- ).toBeInTheDocument();
+ mocks.getProfileMock.mockResolvedValue({
+ email: { value: 'test@example.com' },
+ firstName: 'John',
+ lastName: 'Doe',
+ address: {
+ street: '123 Main St',
+ city: 'Barcelona',
+ postalCode: '08001',
+ country: 'Spain',
+ },
});
});
- // ── Auth guard tests ──
-
- it('redirects to signin when unauthenticated', async () => {
- mockUseSession.mockReturnValue({
- data: null,
- status: 'unauthenticated',
- update: vi.fn(),
+ it('loads the authenticated user through the profile use case and passes it to the client form', async () => {
+ const element = await ProfilePage({
+ params: Promise.resolve({ locale: 'es' }),
});
- render();
+ render(element as never);
- await waitFor(() => {
- expect(mockPush).toHaveBeenCalledWith('/es/auth/signin');
+ expect(mocks.getProfileMock).toHaveBeenCalledWith('user-1');
+ expect(mocks.profileFormMock).toHaveBeenCalledWith({
+ locale: 'es',
+ profile: {
+ email: 'test@example.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ address: {
+ street: '123 Main St',
+ city: 'Barcelona',
+ postalCode: '08001',
+ country: 'Spain',
+ },
+ },
+ role: 'CUSTOMER',
});
+ expect(screen.getByTestId('profile-form')).toBeInTheDocument();
});
- it('shows loading state when session status is loading', () => {
- mockUseSession.mockReturnValue({
- data: null,
- status: 'loading',
- update: vi.fn(),
- });
+ it('redirects unauthenticated visitors to sign in', async () => {
+ mocks.getSessionMock.mockResolvedValue(null);
- render();
+ await ProfilePage({ params: Promise.resolve({ locale: 'es' }) });
- expect(screen.getByText('Cargando...')).toBeInTheDocument();
+ expect(mocks.redirectMock).toHaveBeenCalledWith('/es/auth/signin');
+ expect(mocks.getProfileMock).not.toHaveBeenCalled();
});
- it('shows profile form when authenticated', async () => {
- mockProfileFetch();
- mockAuthenticatedSession('CUSTOMER');
+ it('redirects when the authenticated user no longer exists', async () => {
+ mocks.getProfileMock.mockResolvedValue(null);
- render();
+ await ProfilePage({ params: Promise.resolve({ locale: 'cat' }) });
- await waitFor(() => {
- expect(screen.getByLabelText('Nombre')).toHaveValue('John');
- });
+ expect(mocks.redirectMock).toHaveBeenCalledWith('/cat/auth/signin');
});
- // ── Address guard tests ──
+ it('redirects when the authenticated account is deleted', async () => {
+ mocks.getProfileMock.mockResolvedValue({ deletedAt: new Date() });
- it('shows address section for CUSTOMER role', async () => {
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- ...baseProfileResponse,
- email: 'customer@test.com',
- firstName: 'Jane',
- address: {
- street: '456 Oak Ave',
- city: 'Madrid',
- postalCode: '28001',
- country: 'Spain',
- },
- }),
- });
+ await ProfilePage({ params: Promise.resolve({ locale: 'es' }) });
- mockUseSession.mockReturnValue({
- data: {
- user: {
- id: '1',
- email: 'customer@test.com',
- name: 'Jane Doe',
- role: 'CUSTOMER',
- emailVerified: null,
- },
- expires: '2099-01-01T00:00:00.000Z',
- },
- status: 'authenticated',
- update: vi.fn(),
- });
-
- render();
-
- await waitFor(() => {
- expect(screen.getByLabelText('Calle')).toHaveValue('456 Oak Ave');
- expect(screen.getByLabelText('Ciudad')).toHaveValue('Madrid');
- });
+ expect(mocks.redirectMock).toHaveBeenCalledWith('/es/auth/signin');
});
-
- it.each(['SUPPORT', 'DESIGNER'] as const)(
- 'hides address section for %s role even when API returns address data',
- async (role) => {
- mockProfileFetch({
- email: `${role.toLowerCase()}@test.com`,
- firstName: role,
- lastName: 'User',
- });
- mockAuthenticatedSession(role);
-
- render();
-
- await waitFor(() => {
- expect(screen.getByLabelText('Nombre')).toHaveValue(role);
- });
-
- for (const label of ['Calle', 'Ciudad', 'Código postal', 'País']) {
- expect(screen.queryByLabelText(label)).toBeNull();
- }
- },
- );
});
diff --git a/tests/unit/app/cart/cart-view.test.tsx b/tests/unit/app/cart/cart-view.test.tsx
index 55e73f2a..3ef457cc 100644
--- a/tests/unit/app/cart/cart-view.test.tsx
+++ b/tests/unit/app/cart/cart-view.test.tsx
@@ -119,7 +119,7 @@ describe('CartView', () => {
expect(screen.getByText(labels.emptyTitle)).toBeTruthy();
const link = screen.getByRole('link');
- expect(link.getAttribute('href')).toBe('/es/');
+ expect(link.getAttribute('href')).toBe('/es');
});
it('renders subtotal', () => {
@@ -277,6 +277,26 @@ describe('CartView', () => {
});
});
+ it('restores the item when DELETE fails', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: false });
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getAllByRole('button', { name: labels.remove })[0]);
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Product')).toBeTruthy();
+ expect(screen.getByText('Another Product')).toBeTruthy();
+ });
+ });
+
it('reverts optimistic update on PATCH failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
diff --git a/tests/unit/modules/presentation/components/login-modal.test.tsx b/tests/unit/modules/presentation/components/login-modal.test.tsx
index 9df38f7c..22824266 100644
--- a/tests/unit/modules/presentation/components/login-modal.test.tsx
+++ b/tests/unit/modules/presentation/components/login-modal.test.tsx
@@ -84,6 +84,29 @@ describe('LoginModal component', () => {
});
});
+ it('does not expose an unexpected sign-in error', async () => {
+ vi.mocked(signIn).mockRejectedValue(
+ new Error('Authentication unavailable'),
+ );
+
+ render();
+ fireEvent.change(screen.getByLabelText('Correo electrónico'), {
+ target: { value: 'test@example.com' },
+ });
+ fireEvent.change(screen.getByLabelText('Contraseña'), {
+ target: { value: 'password123' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Iniciar sesión' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Credenciales incorrectas')).toBeInTheDocument();
+ expect(screen.queryByText('Authentication unavailable')).toBeNull();
+ expect(
+ screen.getByRole('button', { name: 'Iniciar sesión' }),
+ ).toBeEnabled();
+ });
+ });
+
it('calls onClose when close button is clicked', () => {
render();
diff --git a/tests/unit/modules/products/application/get-product-by-id-use-case.test.ts b/tests/unit/modules/products/application/get-product-by-id-use-case.test.ts
index 5958ab92..df90b21c 100644
--- a/tests/unit/modules/products/application/get-product-by-id-use-case.test.ts
+++ b/tests/unit/modules/products/application/get-product-by-id-use-case.test.ts
@@ -4,8 +4,18 @@ import { MemoryProductRepository } from '@/tests/doubles/memory-product-reposito
import { ProductPrice } from '@/modules/products/domain/value-objects/product-price';
import { Currency } from '@/shared/kernel/domain/value-objects/currency';
import { ProductStatus } from '@/modules/products/domain/value-objects/product-status';
+import { NotFoundError } from '@/shared/kernel/app-error';
describe('GetProductByIdUseCase', () => {
+ it('throws NotFoundError when the product is absent', async () => {
+ const repository = new MemoryProductRepository();
+ const useCase = new GetProductByIdUseCase(repository);
+
+ await expect(useCase.execute('missing', 'es')).rejects.toBeInstanceOf(
+ NotFoundError,
+ );
+ });
+
it('returns the resolved translation with fallback sizes and metadata', async () => {
const repository = new MemoryProductRepository();
repository.seed([
diff --git a/tests/unit/modules/users/application/get-user-profile-use-case.test.ts b/tests/unit/modules/users/application/get-user-profile-use-case.test.ts
new file mode 100644
index 00000000..c310fc89
--- /dev/null
+++ b/tests/unit/modules/users/application/get-user-profile-use-case.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it, vi } from 'vitest';
+import { GetUserProfileUseCase } from '@/modules/users/application/use-cases/get-user-profile-use-case';
+
+describe('GetUserProfileUseCase', () => {
+ it('loads a profile through its port', async () => {
+ const profile = { firstName: 'John' } as never;
+ const port = { findById: vi.fn().mockResolvedValue(profile) };
+
+ const result = await new GetUserProfileUseCase(port).execute('user-1');
+
+ expect(port.findById).toHaveBeenCalledWith('user-1');
+ expect(result).toBe(profile);
+ });
+});
diff --git a/tests/unit/modules/users/application/update-user.test.ts b/tests/unit/modules/users/application/update-user.test.ts
index 5118a397..b7f83a85 100644
--- a/tests/unit/modules/users/application/update-user.test.ts
+++ b/tests/unit/modules/users/application/update-user.test.ts
@@ -6,6 +6,7 @@ import { UserId } from '@/shared/kernel/domain/value-objects/user-id';
import { Email } from '@/shared/kernel/domain/value-objects/email';
import { RoleId } from '@/shared/kernel/domain/identifiers/role-id';
import { PasswordHash } from '@/shared/kernel/domain/value-objects/password-hash';
+import { Address } from '@/shared/kernel/domain/value-objects/address';
describe('UpdateUserUseCase', () => {
let userRepository: MemoryUserRepository;
@@ -137,6 +138,34 @@ describe('UpdateUserUseCase', () => {
expect(payload3.changedFields).toContain('address');
});
+ it('should remove address when explicitly set to null', async () => {
+ const userId = UserId.create('user-remove-address');
+ await userRepository.save({
+ userId,
+ email: Email.create('remove-address@example.com'),
+ firstName: 'Address',
+ lastName: 'Owner',
+ address: Address.create('Calle 1', 'Madrid', '28001', 'ES'),
+ roleId: RoleId.create('CUSTOMER'),
+ passwordHash: PasswordHash.create('hashedpassword123'),
+ emailVerified: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ const { UpdateUserUseCase } =
+ await import('@/modules/users/application/use-cases/update-user-use-case');
+ const result = await new UpdateUserUseCase(
+ userRepository,
+ outboxRepository,
+ ).execute({ userId: 'user-remove-address', address: null });
+
+ expect(result.address).toBeNull();
+ expect(outboxRepository.events[0].payload).toMatchObject({
+ changedFields: ['address'],
+ });
+ });
+
// ── Error Cases ─────────────────────────────────────────────
it('should throw NotFoundError when user does not exist', async () => {