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]/products/[id]/page.tsx b/app/[locale]/products/[id]/page.tsx index 107391b8..bf9ddc33 100644 --- a/app/[locale]/products/[id]/page.tsx +++ b/app/[locale]/products/[id]/page.tsx @@ -8,6 +8,7 @@ import { APP_BASE_URL } from '@/shared/kernel/config'; import { BackLink } from '@/shared/ui/back-link'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; +import { NotFoundError } from '@/shared/kernel/app-error'; import { CustomizationExperience, type CustomizationExperienceLabels, @@ -34,8 +35,9 @@ export async function generateMetadata({ try { product = await getPublicProduct(id, locale); - } catch { - notFound(); + } catch (error) { + if (error instanceof NotFoundError) notFound(); + throw error; } const canonical = `${APP_BASE_URL}/${locale}/products/${id}`; @@ -89,8 +91,9 @@ export default async function ProductDetailPage({ try { product = await getPublicProduct(id, locale); - } catch { - notFound(); + } catch (error) { + if (error instanceof NotFoundError) notFound(); + throw error; } const viewerContext = await resolveProductViewerContext( diff --git a/app/[locale]/profile/page.tsx b/app/[locale]/profile/page.tsx index ff9e944f..fdf951a0 100644 --- a/app/[locale]/profile/page.tsx +++ b/app/[locale]/profile/page.tsx @@ -1,255 +1,37 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useSession } from 'next-auth/react'; -import { useRouter, useParams } from 'next/navigation'; -import { TextField } from '@/shared/ui/text-field'; -import { BackLink } from '@/shared/ui/back-link'; -import { Card } from '@/shared/ui/card'; -import { Button } from '@/shared/ui/button'; -import { ErrorMessage } from '@/shared/ui/error-message'; -import { DeleteConfirmModal } from '@/shared/ui/delete-confirm-modal'; -import { useDictionary } from '@/shared/i18n/dictionary-context'; -import styles from './page.module.css'; -import { - AddressAutocompleteFields, - type AddressValue, -} from '@/modules/users/presentation/components/address-autocomplete-fields'; - -type AddressFields = AddressValue; - -interface ProfileData { - firstName: string; - lastName: string; - email: string; - address: AddressFields; -} - -interface ProfileForm extends ProfileData { - address: AddressFields; -} - -export default function ProfilePage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const { locale } = useParams<{ locale: string }>(); - const dict = useDictionary(); - const role = session?.user?.role; - const isShowAddress = role === 'CUSTOMER'; - const [form, setForm] = useState({ - firstName: '', - lastName: '', - email: '', - address: {}, - }); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - useEffect(() => { - if (status === 'unauthenticated') { - router.push(`/${locale}/auth/signin`); - return; - } - if (status !== 'authenticated') return; - - let isCancelled = false; - (async () => { - setLoading(true); - setError(null); - try { - const res = await fetch('/api/users/me'); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || 'Failed to load profile'); - } - if (!isCancelled) { - setForm({ - firstName: data.firstName || '', - lastName: data.lastName || '', - email: data.email || '', - address: data.address || {}, - }); - } - } catch (error_: unknown) { - if (!isCancelled) { - setError( - error_ instanceof Error ? error_.message : 'Failed to load profile', - ); - } - } finally { - if (!isCancelled) { - setLoading(false); - } - } - })(); - return () => { - isCancelled = true; - }; - }, [status, locale, router]); - - if (status === 'loading' || loading) { - return
{dict.common.loading}
; +import { getServerSession } from 'next-auth'; +import { redirect } from 'next/navigation'; +import { container } from '@/composition-root/container'; +import { authOptions } from '@/shared/infrastructure/auth-options'; +import { ProfileForm } from './profile-form'; + +export default async function ProfilePage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + + if (!userId) { + return redirect(`/${locale}/auth/signin`); } - const handleSave = async (e: React.FormEvent) => { - e.preventDefault(); - setSaving(true); - setError(null); - setSuccess(null); - - const userFields = [ - 'street', - 'houseNumber', - 'postalCode', - 'city', - 'floor', - 'door', - 'instructions', - ] as const; - const hasAddress = userFields.some((f) => form.address[f]?.trim()); - const body: Record = {}; - if (form.firstName) body.firstName = form.firstName; - if (form.lastName) body.lastName = form.lastName; - if (isShowAddress && hasAddress) body.address = form.address; - - try { - const res = await fetch('/api/users/me', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || 'Failed to update profile'); - } - setSuccess(dict.profile.updateSuccess); - } catch (error_: unknown) { - setError( - error_ instanceof Error ? error_.message : 'Failed to update profile', - ); - } finally { - setSaving(false); - } - }; - - const handleDelete = async () => { - setSaving(true); - setError(null); - try { - const res = await fetch('/api/users/me', { method: 'DELETE' }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || 'Failed to delete account'); - } - // Redirect to home after soft-delete - globalThis.location.assign('/'); - } catch (error_: unknown) { - setError( - error_ instanceof Error ? error_.message : 'Failed to delete account', - ); - } finally { - setSaving(false); - setShowDeleteModal(false); - } - }; + const user = await container.getUserProfileUseCase().execute(userId); + if (!user || user.deletedAt) { + return redirect(`/${locale}/auth/signin`); + } return ( -
- {dict.common.backToHome} - - -

{dict.profile.title}

- - {error && } - {success && ( -
- {success} -
- )} - - - setForm((prev) => ({ ...prev, firstName: v }))} - required - /> - setForm((prev) => ({ ...prev, lastName: v }))} - required - /> - {}} - disabled - /> - - {isShowAddress && ( -
-

{dict.auth.address}

-
- - setForm((prev) => ({ ...prev, address })) - } - locale={locale === 'cat' ? 'cat' : 'es'} - labels={{ - street: dict.auth.street, - houseNumber: dict.auth.houseNumber, - postalCode: dict.auth.postalCode, - city: dict.auth.city, - floor: dict.auth.floor, - door: dict.auth.door, - instructions: dict.auth.instructions, - countryLabel: dict.auth.countryLabel, - searchPlaceholder: dict.auth.searchPlaceholder, - noResults: dict.auth.noResults, - retry: dict.auth.retry, - providerError: dict.auth.providerError, - listboxLabel: dict.auth.listboxLabel, - composedLabel: dict.auth.composedLabel, - }} - /> -
-
- )} - -
- -
- - -
- -
- - setShowDeleteModal(false)} - /> -
-
+ ); } diff --git a/app/[locale]/profile/profile-form.tsx b/app/[locale]/profile/profile-form.tsx new file mode 100644 index 00000000..f509a0ac --- /dev/null +++ b/app/[locale]/profile/profile-form.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState } from 'react'; +import { TextField } from '@/shared/ui/text-field'; +import { BackLink } from '@/shared/ui/back-link'; +import { Card } from '@/shared/ui/card'; +import { Button } from '@/shared/ui/button'; +import { ErrorMessage } from '@/shared/ui/error-message'; +import { DeleteConfirmModal } from '@/shared/ui/delete-confirm-modal'; +import { useDictionary } from '@/shared/i18n/dictionary-context'; +import { + AddressAutocompleteFields, + type AddressValue, +} from '@/modules/users/presentation/components/address-autocomplete-fields'; +import styles from './page.module.css'; + +export interface ProfileData { + firstName: string; + lastName: string; + email: string; + address: AddressValue; +} + +interface ProfileFormProps { + locale: string; + profile: ProfileData; + role?: string; +} + +export function ProfileForm({ locale, profile, role }: ProfileFormProps) { + const dict = useDictionary(); + const isShowAddress = role === 'CUSTOMER'; + const [form, setForm] = useState(profile); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + setSuccess(null); + + const hasAddress = [ + form.address.street, + form.address.houseNumber, + form.address.postalCode, + form.address.city, + form.address.floor, + form.address.door, + form.address.instructions, + ].some((value) => value?.trim()); + const hasAddressChanged = + JSON.stringify(form.address) !== JSON.stringify(profile.address); + const body: Record = {}; + if (form.firstName) body.firstName = form.firstName; + if (form.lastName) body.lastName = form.lastName; + if (isShowAddress && hasAddressChanged) { + body.address = hasAddress ? form.address : null; + } + + try { + const res = await fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to update profile'); + } + setSuccess(dict.profile.updateSuccess); + } catch (error_: unknown) { + setError( + error_ instanceof Error ? error_.message : 'Failed to update profile', + ); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + setSaving(true); + setError(null); + try { + const res = await fetch('/api/users/me', { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to delete account'); + } + globalThis.location.assign('/'); + } catch (error_: unknown) { + setError( + error_ instanceof Error ? error_.message : 'Failed to delete account', + ); + } finally { + setSaving(false); + setShowDeleteModal(false); + } + }; + + return ( +
+ {dict.common.backToHome} + + +

{dict.profile.title}

+ + {error && } + {success && ( +
+ {success} +
+ )} + +
+ setForm((prev) => ({ ...prev, firstName: v }))} + required + /> + setForm((prev) => ({ ...prev, lastName: v }))} + required + /> + {}} + disabled + /> + + {isShowAddress && ( +
+

{dict.auth.address}

+
+ + setForm((prev) => ({ ...prev, address })) + } + locale={locale === 'cat' ? 'cat' : 'es'} + labels={{ + street: dict.auth.street, + houseNumber: dict.auth.houseNumber, + postalCode: dict.auth.postalCode, + city: dict.auth.city, + floor: dict.auth.floor, + door: dict.auth.door, + instructions: dict.auth.instructions, + countryLabel: dict.auth.countryLabel, + searchPlaceholder: dict.auth.searchPlaceholder, + noResults: dict.auth.noResults, + retry: dict.auth.retry, + providerError: dict.auth.providerError, + listboxLabel: dict.auth.listboxLabel, + composedLabel: dict.auth.composedLabel, + }} + /> +
+
+ )} + +
+ +
+ + +
+ +
+ + setShowDeleteModal(false)} + /> +
+
+ ); +} diff --git a/app/[locale]/seller/orders/page.tsx b/app/[locale]/seller/orders/page.tsx index 1b872952..d05a7462 100644 --- a/app/[locale]/seller/orders/page.tsx +++ b/app/[locale]/seller/orders/page.tsx @@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/shared/infrastructure/auth-options'; import { container } from '@/composition-root/container'; import { redirect, notFound } from 'next/navigation'; +import Link from 'next/link'; import { getDictionary } from '@/shared/i18n/get-dictionary'; import { orderListQuerySchema } from '@/modules/orders/presentation/schemas/order-schemas'; import { ListSellerOrdersUseCase } from '@/modules/orders/application/list-seller-orders-use-case'; @@ -97,12 +98,12 @@ export default async function SellerOrdersPage({ header: dict.sellerDashboard?.actions ?? 'Actions', render: (order) => (
- {dict.orders?.viewOrder ?? 'Ver pedido'} - + {order.status === 'new' && (

{dict.sellerDashboard.title}

- + {dict.sellerDashboard.createProduct} - +

{labels.emptyTitle}

{labels.emptyDescription}

- + {labels.browseProducts} - +
); } @@ -309,7 +311,7 @@ export function CartView({ item.customization, item.id, ) && ( - {labels.customizationEditFromCart} - + )}
@@ -382,9 +384,9 @@ export function CartView({
{isAuthenticated && ( - + {labels.checkout} - + )}
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 () => {