) => {
+ event.preventDefault();
+ setLoading(true);
+ setError(null);
+ setSaved(null);
+
+ try {
+ const response = await fetch(`/api/sellers/${sellerId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(),
+ description: description.trim(),
+ }),
+ });
+
+ const responseText = await response.text().catch(() => '');
+
+ if (!response.ok) {
+ let errorMessage = '';
+
+ if (responseText.trim()) {
+ try {
+ const data = JSON.parse(responseText) as {
+ error?: string;
+ message?: string;
+ };
+ errorMessage = data.error ?? data.message ?? responseText.trim();
+ } catch {
+ errorMessage = responseText.trim();
+ }
+ }
+
+ throw new Error(errorMessage || errorLabel);
+ }
+
+ setSaved(savedLabel);
+ } catch (submitError: unknown) {
+ setError(submitError instanceof Error ? submitError.message : errorLabel);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/app/[locale]/admin/sellers/page.module.css b/app/[locale]/admin/sellers/page.module.css
index e5e10c9a..7f0ab118 100644
--- a/app/[locale]/admin/sellers/page.module.css
+++ b/app/[locale]/admin/sellers/page.module.css
@@ -188,6 +188,26 @@
color: var(--color-white);
}
+.editLink {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: 0.35rem 0.8rem;
+ border: 1px solid var(--color-green-dark);
+ border-radius: 4px;
+ color: var(--color-green-dark);
+ font-size: 0.8rem;
+ font-weight: var(--font-weight-medium);
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+}
+
+.editLink:hover {
+ background: var(--color-green-dark);
+ color: var(--color-white);
+}
+
.noSellers {
text-align: center;
padding: var(--spacing-xl);
diff --git a/app/[locale]/admin/sellers/page.tsx b/app/[locale]/admin/sellers/page.tsx
index 28fb2fd3..83948c0a 100644
--- a/app/[locale]/admin/sellers/page.tsx
+++ b/app/[locale]/admin/sellers/page.tsx
@@ -70,6 +70,19 @@ export default async function AdminSellersPage({
const result = await useCase.execute(filter);
const { items: sellers, page: currentPage, pageSize, totalPages } = result;
+ function getStatusLabel(status: string): string {
+ switch (status) {
+ case 'active':
+ return dict.admin.status_active;
+ case 'suspended':
+ return dict.admin.status_suspended;
+ case 'banned':
+ return dict.admin.status_banned;
+ default:
+ return status;
+ }
+ }
+
// Redirect to valid page if current page is out of range
if (currentPage > totalPages && totalPages > 0) {
redirect(
@@ -145,9 +158,7 @@ export default async function AdminSellersPage({
data-testid={`status-badge-${seller.sellerId.value}`}
data-status={seller.status}
>
- {dict.admin[
- `status_${seller.status}` as keyof typeof dict.admin
- ] ?? seller.status}
+ {getStatusLabel(seller.status)}
@@ -161,6 +172,12 @@ export default async function AdminSellersPage({
>
{dict.admin.viewProducts}
+
+ {dict.admin.edit}
+
@@ -133,6 +149,7 @@ export default async function SellerProductsPage({
{dict.admin.productStatus} |
{dict.admin.productPrice} |
{dict.admin.productUpdated} |
+ {dict.admin.actions} |
@@ -143,7 +160,14 @@ export default async function SellerProductsPage({
(translation) => translation.locale === locale,
)?.name ?? dict.admin.untranslatedProduct}
- {product.status} |
+
+
+ {getStatusLabel(product.status)}
+
+ |
{product.basePrice.format()} |
{LocalizedDate.create(
@@ -151,51 +175,50 @@ export default async function SellerProductsPage({
locale,
).toString()}
|
+
+
+ |
))}
- {result.totalPages > 1 ? (
-
>
) : (
{dict.sellerDashboard.noProducts}
diff --git a/app/[locale]/seller/products/product-actions.module.css b/app/[locale]/seller/products/product-actions.module.css
new file mode 100644
index 00000000..93bc33d2
--- /dev/null
+++ b/app/[locale]/seller/products/product-actions.module.css
@@ -0,0 +1,38 @@
+.actions {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.button {
+ padding: 0.35rem 0.8rem;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+}
+
+.button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.activate {
+ background: var(--color-green-dark);
+ color: var(--color-white);
+}
+
+.activate:hover:not(:disabled) {
+ opacity: 0.85;
+}
+
+.suspend {
+ background: var(--color-lila);
+ color: var(--color-white);
+}
+
+.suspend:hover:not(:disabled) {
+ opacity: 0.85;
+}
diff --git a/app/[locale]/seller/products/product-actions.tsx b/app/[locale]/seller/products/product-actions.tsx
new file mode 100644
index 00000000..ec1d821a
--- /dev/null
+++ b/app/[locale]/seller/products/product-actions.tsx
@@ -0,0 +1,162 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useDictionary } from '@/shared/i18n/dictionary-context';
+import { ConfirmModal } from '@/shared/presentation/components/confirm-modal';
+import styles from './product-actions.module.css';
+
+interface ProductActionsProps {
+ productId: string;
+ currentStatus: string;
+}
+
+const ACTIVE_STATUS = 'ACTIVE';
+const ARCHIVED_STATUS = 'ARCHIVED';
+const DRAFT_STATUS = 'DRAFT';
+const ELIMINATED_STATUS = 'ELIMINATED';
+
+export function ProductActions({
+ productId,
+ currentStatus,
+}: ProductActionsProps) {
+ const dict = useDictionary();
+ const router = useRouter();
+ const [status, setStatus] = useState(currentStatus);
+ const [loading, setLoading] = useState(false);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [pendingStatus, setPendingStatus] = useState(null);
+
+ const handleStatusChange = async (newStatus: string) => {
+ if (newStatus === ELIMINATED_STATUS) {
+ setPendingStatus(newStatus);
+ setShowConfirmModal(true);
+ return;
+ }
+
+ await executeStatusChange(newStatus);
+ };
+
+ const executeStatusChange = async (newStatus: string) => {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/products/${productId}/status`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: newStatus }),
+ });
+
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ throw new Error(errorData?.error || `HTTP ${res.status}`);
+ }
+
+ const data = await res.json();
+ setStatus(data.status);
+ router.refresh();
+ } catch (error) {
+ console.error('Failed to change product status:', error);
+ } finally {
+ setLoading(false);
+ setShowConfirmModal(false);
+ setPendingStatus(null);
+ }
+ };
+
+ const handleConfirm = () => {
+ if (pendingStatus) {
+ executeStatusChange(pendingStatus);
+ }
+ };
+
+ const handleCancel = () => {
+ setShowConfirmModal(false);
+ setPendingStatus(null);
+ };
+
+ const isActive = status === ACTIVE_STATUS;
+ const isDraft = status === DRAFT_STATUS;
+ const isArchived = status === ARCHIVED_STATUS;
+ const isEliminated = status === ELIMINATED_STATUS;
+
+ if (isEliminated) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {isDraft && (
+ <>
+
+
+ >
+ )}
+ {isActive && (
+ <>
+
+
+ >
+ )}
+ {isArchived && (
+ <>
+
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/app/api/products/[id]/status/route.ts b/app/api/products/[id]/status/route.ts
new file mode 100644
index 00000000..0d64d8f6
--- /dev/null
+++ b/app/api/products/[id]/status/route.ts
@@ -0,0 +1,88 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { container } from '@/composition-root/container';
+import { requireRole } from '@/shared/authorization/authorization';
+import { handleApiError } from '@/shared/presentation/error-handler';
+import { changeProductStatusSchema } from '@/modules/products/presentation/schemas/change-product-status-schema';
+import { ChangeProductStatusUseCase } from '@/modules/products/application/change-product-status-use-case';
+
+async function patchHandler(
+ request: NextRequest,
+ context: { params: Promise<{ id: string }> },
+): Promise {
+ try {
+ const { id } = await context.params;
+ const body = changeProductStatusSchema.parse(await request.json());
+
+ const session = await container.getSession().getSession();
+ if (!session?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const user = await container.getUserLookup().findById(session.id);
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ const isAdmin = user.role === 'ADMIN';
+ const sellerId = await getCurrentSellerId();
+
+ // ADMIN can manage any product; DESIGNER can only manage their own
+ if (!isAdmin && !sellerId) {
+ return NextResponse.json(
+ { error: 'No seller account found for this user' },
+ { status: 403 },
+ );
+ }
+
+ const product = await container.getProductRepository().findById(id, 'es');
+ if (!product) {
+ return NextResponse.json({ error: 'Product not found' }, { status: 404 });
+ }
+
+ // DESIGNER must own the product; ADMIN can manage any
+ if (!isAdmin && product.sellerId !== sellerId) {
+ return NextResponse.json(
+ { error: 'You do not own this product' },
+ { status: 403 },
+ );
+ }
+
+ const useCase = new ChangeProductStatusUseCase(
+ container.getProductRepository(),
+ );
+ const updated = await useCase.execute({
+ productId: id,
+ sellerId: product.sellerId, // Use the product's sellerId, not the current user's
+ status: body.status,
+ });
+
+ return NextResponse.json(
+ {
+ id: updated.id,
+ status: updated.status,
+ updatedAt: updated.updatedAt.toISOString(),
+ },
+ { status: 200 },
+ );
+ } catch (error: unknown) {
+ return handleApiError(error);
+ }
+}
+
+export const PATCH = requireRole(
+ 'DESIGNER',
+ 'ADMIN',
+)(
+ patchHandler as unknown as (
+ req: NextRequest,
+ context?: unknown,
+ ) => Promise,
+);
+
+async function getCurrentSellerId(): Promise {
+ const session = await container.getSession().getSession();
+ if (!session?.id) return null;
+
+ const seller = await container.getSellerRepository().findByUserId(session.id);
+ return seller?.sellerId.value ?? null;
+}
diff --git a/modules/products/application/change-product-status-use-case.ts b/modules/products/application/change-product-status-use-case.ts
new file mode 100644
index 00000000..bb270019
--- /dev/null
+++ b/modules/products/application/change-product-status-use-case.ts
@@ -0,0 +1,51 @@
+import { NotFoundError, ValidationError } from '@/shared/kernel/app-error';
+import type {
+ ProductEntity,
+ ProductRepository,
+} from '../domain/product-repository';
+import {
+ ProductStatus,
+ VALID_TRANSITIONS,
+} from '../domain/value-objects/product-status';
+
+export interface ChangeProductStatusDTO {
+ productId: string;
+ status: ProductStatus;
+ sellerId: string;
+}
+
+export class ChangeProductStatusUseCase {
+ constructor(private readonly productRepository: ProductRepository) {}
+
+ async execute(dto: ChangeProductStatusDTO): Promise {
+ const product = await this.productRepository.findById(dto.productId, 'es');
+
+ if (!product || product.sellerId !== dto.sellerId) {
+ throw new NotFoundError('Product not found');
+ }
+
+ if (product.status === dto.status) {
+ throw new ValidationError(`Product is already ${dto.status}`);
+ }
+
+ const allowed = VALID_TRANSITIONS[product.status];
+ if (!allowed || !allowed.includes(dto.status)) {
+ throw new ValidationError(
+ `Cannot transition from ${product.status} to ${dto.status}`,
+ );
+ }
+
+ const updated: ProductEntity = {
+ ...product,
+ status: dto.status,
+ updatedAt: new Date(),
+ };
+
+ const persisted = await this.productRepository.update(updated);
+ if (!persisted) {
+ throw new NotFoundError('Product not found');
+ }
+
+ return updated;
+ }
+}
diff --git a/modules/products/domain/value-objects/product-status.ts b/modules/products/domain/value-objects/product-status.ts
index beeb74d3..1fac3ed4 100644
--- a/modules/products/domain/value-objects/product-status.ts
+++ b/modules/products/domain/value-objects/product-status.ts
@@ -4,13 +4,14 @@
* Follows the SellerStatus pattern exactly:
* - Enum with string values
* - VALID_TRANSITIONS map for state machine enforcement
- * - ARCHIVED is terminal (no outgoing transitions)
+ * - ELIMINATED is terminal (no outgoing transitions)
* - Same-status transitions are no-ops (handled by use case)
*/
export enum ProductStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
ARCHIVED = 'ARCHIVED',
+ ELIMINATED = 'ELIMINATED',
}
/**
@@ -18,13 +19,24 @@ export enum ProductStatus {
* A missing key means the status is terminal (no outgoing transitions).
*
* Rules:
- * - DRAFT → [ACTIVE] (must publish before archiving)
- * - ACTIVE → [ARCHIVED] (can archive directly)
- * - ARCHIVED → (none — terminal) (no further transitions)
+ * - DRAFT → [ACTIVE, ELIMINATED] (can publish or delete)
+ * - ACTIVE → [ARCHIVED, ELIMINATED] (can suspend or delete)
+ * - ARCHIVED → [ACTIVE, ELIMINATED] (can reactivate or delete)
+ * - ELIMINATED → (none — terminal) (no further transitions)
*/
export const VALID_TRANSITIONS: Readonly<
Partial>
> = Object.freeze({
- [ProductStatus.DRAFT]: Object.freeze([ProductStatus.ACTIVE]),
- [ProductStatus.ACTIVE]: Object.freeze([ProductStatus.ARCHIVED]),
+ [ProductStatus.DRAFT]: Object.freeze([
+ ProductStatus.ACTIVE,
+ ProductStatus.ELIMINATED,
+ ]),
+ [ProductStatus.ACTIVE]: Object.freeze([
+ ProductStatus.ARCHIVED,
+ ProductStatus.ELIMINATED,
+ ]),
+ [ProductStatus.ARCHIVED]: Object.freeze([
+ ProductStatus.ACTIVE,
+ ProductStatus.ELIMINATED,
+ ]),
});
diff --git a/modules/products/presentation/schemas/change-product-status-schema.ts b/modules/products/presentation/schemas/change-product-status-schema.ts
new file mode 100644
index 00000000..524ccfb7
--- /dev/null
+++ b/modules/products/presentation/schemas/change-product-status-schema.ts
@@ -0,0 +1,6 @@
+import { z } from 'zod';
+import { ProductStatus } from '../../domain/value-objects/product-status';
+
+export const changeProductStatusSchema = z.object({
+ status: z.nativeEnum(ProductStatus),
+});
diff --git a/prisma/migrations/20260623140000_restore_category_slug_index/migration.sql b/prisma/migrations/20260623140000_restore_category_slug_index/migration.sql
index 54d34f85..f5af2e1b 100644
--- a/prisma/migrations/20260623140000_restore_category_slug_index/migration.sql
+++ b/prisma/migrations/20260623140000_restore_category_slug_index/migration.sql
@@ -1,4 +1,4 @@
-- RestoreIndex
-- The Category_slug_idx was inadvertently dropped in the Upload model migration.
-- This migration restores it as a separate, focused change.
-CREATE INDEX "Category_slug_idx" ON "Category"("slug");
+CREATE INDEX IF NOT EXISTS "Category_slug_idx" ON "Category"("slug");
diff --git a/prisma/seed.ts b/prisma/seed.ts
index d9226842..06d85e0f 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -77,13 +77,30 @@ async function main() {
` ✓ Admin user created: ${adminUser.firstName} ${adminUser.lastName} (${adminUser.email})`,
);
- // 3. Create Seller (the company/store) linked to admin user
+ // 3. Create designer user (DESIGNER role, verified email)
+ const designerPasswordHash = await bcrypt.hash('Designer123!', BCRYPT_COST);
+ const designerUser = await prisma.user.create({
+ data: {
+ firstName: 'Designer',
+ lastName: 'User',
+ email: 'designer@test.com',
+ passwordHash: designerPasswordHash,
+ role: 'DESIGNER',
+ emailVerified: new Date(),
+ preferredLanguage: 'es',
+ },
+ });
+ console.log(
+ ` ✓ Designer user created: ${designerUser.firstName} ${designerUser.lastName} (${designerUser.email})`,
+ );
+
+ // 4. Create Seller (the company/store) linked to designer user
const seller = await prisma.seller.create({
data: {
name: '728 Store',
description:
'Tienda oficial de 728 Store — productos personalizados de calidad.',
- userId: adminUser.id,
+ userId: designerUser.id,
status: 'active',
},
});
@@ -91,7 +108,7 @@ async function main() {
` ✓ Seller created: ${seller.name} (${seller.id}, linked to user: ${seller.userId})`,
);
- // 3. Create Products with i18n translations
+ // 5. Create Products with i18n translations
const productsData = [
{
basePrice: 25.0,
@@ -157,7 +174,7 @@ async function main() {
console.log(` ✓ Product created: ${product.id}`);
}
- // 4. Create test user (customer role, verified email)
+ // 6. Create test user (customer role, verified email)
const passwordHash = await bcrypt.hash('Test123!', BCRYPT_COST);
const user = await prisma.user.create({
data: {
@@ -176,23 +193,6 @@ async function main() {
console.log(` → Email: test@test.com`);
console.log(` → Password: Test123!`);
- // 5. Create designer user (DESIGNER role, verified email)
- const designerPasswordHash = await bcrypt.hash('Designer123!', BCRYPT_COST);
- const designerUser = await prisma.user.create({
- data: {
- firstName: 'Designer',
- lastName: 'User',
- email: 'designer@test.com',
- passwordHash: designerPasswordHash,
- role: 'DESIGNER',
- emailVerified: new Date(),
- preferredLanguage: 'es',
- },
- });
- console.log(
- ` ✓ Designer user created: ${designerUser.firstName} ${designerUser.lastName} (***@test.com)`,
- );
-
console.log('\n✅ Seed complete!');
}
diff --git a/shared/i18n/dictionary-context.tsx b/shared/i18n/dictionary-context.tsx
index 43a54c2c..6e515bc6 100644
--- a/shared/i18n/dictionary-context.tsx
+++ b/shared/i18n/dictionary-context.tsx
@@ -4,11 +4,78 @@ import { createContext, use } from 'react';
import type { ReactNode } from 'react';
import es from '@/shared/i18n/locales/es.json';
-/**
- * Dictionary type: top-level keys are sections ("common", "auth", "profile"),
- * each containing string key-value pairs.
- */
-export type Dictionary = Record>;
+export interface SellerDetailDictionary {
+ editTitle: string;
+ nameLabel: string;
+ descriptionLabel: string;
+ save: string;
+ saved: string;
+ error: string;
+}
+
+export interface AdminDictionary {
+ sellersTitle: string;
+ noSellers: string;
+ sellerName: string;
+ sellerStatus: string;
+ sellerCreated: string;
+ actions: string;
+ edit: string;
+ viewProducts: string;
+ suspend: string;
+ activate: string;
+ ban: string;
+ status_draft: string;
+ status_active: string;
+ status_archived: string;
+ status_suspended: string;
+ status_banned: string;
+ status_eliminated: string;
+ createSeller: string;
+ createSellerTitle: string;
+ sellerBusinessName: string;
+ sellerDescription: string;
+ sellerDescriptionList: string;
+ delete: string;
+ deleteConfirm: string;
+ deleteConfirmMessage: string;
+ createSellerError: string;
+ deleteSellerError: string;
+ deletedSuccess: string;
+ pagePrev: string;
+ pageNext: string;
+ pageXofY: string;
+ createSellerSuccess: string;
+ backToSellers: string;
+ sellerProductsTitle: string;
+ noProducts: string;
+ productName: string;
+ productStatus: string;
+ productPrice: string;
+ productUpdated: string;
+ searchSellers: string;
+ searchSellersPlaceholder: string;
+ searchProductsPlaceholder: string;
+ searchProducts: string;
+ productCount: string;
+ untranslatedProduct: string;
+ paginationAriaLabel: string;
+ suspendProduct: string;
+ activateProduct: string;
+ eliminateProduct: string;
+ eliminateProductConfirm: string;
+ sellerDetail: SellerDetailDictionary;
+}
+
+export interface Dictionary {
+ common: Record;
+ auth: Record;
+ profile: Record;
+ userMenu: Record;
+ sellerDashboard: Record;
+ passwordStrength: Record;
+ admin: AdminDictionary;
+}
const DictionaryContext = createContext(null);
diff --git a/shared/i18n/locales/cat.json b/shared/i18n/locales/cat.json
index 852fce1b..a6b23d1d 100644
--- a/shared/i18n/locales/cat.json
+++ b/shared/i18n/locales/cat.json
@@ -134,9 +134,17 @@
"sellerStatus": "Estat",
"sellerCreated": "Creat",
"actions": "Accions",
+ "edit": "Editar",
"viewProducts": "Veure productes",
"suspend": "Suspendre",
"activate": "Activar",
+ "status_draft": "Esborrany",
+ "status_archived": "Arxivat",
+ "status_eliminated": "Eliminat",
+ "suspendProduct": "Suspendre",
+ "activateProduct": "Activar",
+ "eliminateProduct": "Eliminar",
+ "eliminateProductConfirm": "Estàs segur d'eliminar aquest producte? Aquesta acció no es pot desfer.",
"ban": "Banejar",
"status_active": "Actiu",
"status_suspended": "Sospès",
@@ -169,6 +177,14 @@
"searchProducts": "Cercar productes",
"productCount": "{total} productes",
"untranslatedProduct": "Sense traducció",
- "paginationAriaLabel": "Paginació"
+ "paginationAriaLabel": "Paginació",
+ "sellerDetail": {
+ "editTitle": "Editar venedor",
+ "nameLabel": "Nom del negoci",
+ "descriptionLabel": "Descripció",
+ "save": "Desar",
+ "saved": "Desat",
+ "error": "Error en desar el venedor"
+ }
}
}
diff --git a/shared/i18n/locales/es.json b/shared/i18n/locales/es.json
index b3749ba6..7e6b919a 100644
--- a/shared/i18n/locales/es.json
+++ b/shared/i18n/locales/es.json
@@ -134,9 +134,17 @@
"sellerStatus": "Estado",
"sellerCreated": "Creado",
"actions": "Acciones",
+ "edit": "Editar",
"viewProducts": "Ver productos",
"suspend": "Suspender",
"activate": "Activar",
+ "status_draft": "Borrador",
+ "status_archived": "Archivado",
+ "status_eliminated": "Eliminado",
+ "suspendProduct": "Suspender",
+ "activateProduct": "Activar",
+ "eliminateProduct": "Eliminar",
+ "eliminateProductConfirm": "¿Estás seguro de eliminar este producto? Esta acción no se puede deshacer.",
"ban": "Banear",
"status_active": "Activo",
"status_suspended": "Suspendido",
@@ -169,6 +177,14 @@
"searchProducts": "Buscar productos",
"productCount": "Total de productos: {total}",
"untranslatedProduct": "Sin traducción",
- "paginationAriaLabel": "Paginación"
+ "paginationAriaLabel": "Paginación",
+ "sellerDetail": {
+ "editTitle": "Editar vendedor",
+ "nameLabel": "Nombre del negocio",
+ "descriptionLabel": "Descripción",
+ "save": "Guardar",
+ "saved": "Guardado",
+ "error": "Error al guardar el vendedor"
+ }
}
}
diff --git a/shared/presentation/components/confirm-modal.module.css b/shared/presentation/components/confirm-modal.module.css
new file mode 100644
index 00000000..2599320f
--- /dev/null
+++ b/shared/presentation/components/confirm-modal.module.css
@@ -0,0 +1,76 @@
+.dialog {
+ border: none;
+ border-radius: 8px;
+ padding: 0;
+ max-width: 400px;
+ width: 90%;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+
+.dialog::backdrop {
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.content {
+ padding: 24px;
+}
+
+.title {
+ margin: 0 0 12px;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--color-text-primary, #1a1a1a);
+}
+
+.message {
+ margin: 0 0 24px;
+ color: var(--color-text-secondary, #666);
+ line-height: 1.5;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.cancelButton,
+.confirmButton {
+ padding: 8px 16px;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background-color 0.15s,
+ border-color 0.15s;
+}
+
+.cancelButton {
+ background: transparent;
+ border: 1px solid var(--color-border, #ddd);
+ color: var(--color-text-primary, #333);
+}
+
+.cancelButton:hover {
+ background: var(--color-bg-hover, #f5f5f5);
+}
+
+.confirmButton {
+ background: var(--color-primary, #0066cc);
+ border: 1px solid var(--color-primary, #0066cc);
+ color: white;
+}
+
+.confirmButton:hover {
+ background: var(--color-primary-hover, #0052a3);
+}
+
+.confirmButton.danger {
+ background: var(--color-danger, #dc3545);
+ border-color: var(--color-danger, #dc3545);
+}
+
+.confirmButton.danger:hover {
+ background: var(--color-danger-hover, #c82333);
+}
diff --git a/shared/presentation/components/confirm-modal.tsx b/shared/presentation/components/confirm-modal.tsx
new file mode 100644
index 00000000..d19267fe
--- /dev/null
+++ b/shared/presentation/components/confirm-modal.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import styles from './confirm-modal.module.css';
+
+interface ConfirmModalProps {
+ open: boolean;
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ variant?: 'danger' | 'default';
+}
+
+export function ConfirmModal({
+ open,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ onConfirm,
+ onCancel,
+ variant = 'default',
+}: ConfirmModalProps) {
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+
+ if (open && !dialog.open) {
+ dialog.showModal();
+ } else if (!open && dialog.open) {
+ dialog.close();
+ }
+ }, [open]);
+
+ return (
+
+ );
+}
diff --git a/tests/unit/app/[locale]/admin/sellers/[sellerId]/page.test.tsx b/tests/unit/app/[locale]/admin/sellers/[sellerId]/page.test.tsx
new file mode 100644
index 00000000..d7b3a23b
--- /dev/null
+++ b/tests/unit/app/[locale]/admin/sellers/[sellerId]/page.test.tsx
@@ -0,0 +1,168 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+const fetchMock = vi.fn();
+
+const mocks = vi.hoisted(() => {
+ const assertRoleMock = vi.fn(async () => undefined);
+ const getDictionaryMock = vi.fn();
+ const getSellerRepositoryMock = vi.fn();
+ const redirectMock = vi.fn(() => {
+ throw new Error('NEXT_REDIRECT');
+ });
+ const notFoundMock = vi.fn(() => {
+ throw new Error('NEXT_NOT_FOUND');
+ });
+
+ return {
+ assertRoleMock,
+ getDictionaryMock,
+ getSellerRepositoryMock,
+ redirectMock,
+ notFoundMock,
+ };
+});
+
+vi.mock('next/navigation', () => ({
+ redirect: mocks.redirectMock,
+ notFound: mocks.notFoundMock,
+}));
+
+vi.mock('@/shared/authorization/authorization', () => ({
+ assertRole: mocks.assertRoleMock,
+}));
+
+vi.mock('@/shared/i18n/get-dictionary', () => ({
+ getDictionary: mocks.getDictionaryMock,
+}));
+
+vi.mock('@/composition-root/container', () => ({
+ container: {
+ getSellerRepository: mocks.getSellerRepositoryMock,
+ },
+}));
+
+import AdminSellerDetailPage from '@/app/[locale]/admin/sellers/[sellerId]/page';
+import { MemorySellerRepository } from '@/tests/doubles/memory-seller-repository';
+import { SellerId } from '@/shared/kernel/domain/value-objects/seller-id';
+import { SellerStatus } from '@/modules/sellers/domain/seller-status';
+import type { SellerEntity } from '@/modules/sellers/domain/seller';
+
+function makeSeller(overrides: Partial = {}): SellerEntity {
+ return {
+ sellerId: SellerId.create('seller-1'),
+ name: 'Test Shop',
+ description: 'A test shop',
+ userId: 'user-1',
+ status: SellerStatus.ACTIVE,
+ deletedAt: null,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+ ...overrides,
+ };
+}
+
+function makeDict() {
+ return {
+ admin: {
+ backToSellers: 'Back to sellers',
+ sellerDetail: {
+ editTitle: 'Edit seller',
+ nameLabel: 'Business name',
+ descriptionLabel: 'Description',
+ save: 'Save',
+ saved: 'Saved',
+ },
+ },
+ } as unknown as Awaited<
+ ReturnType
+ >;
+}
+
+describe('AdminSellerDetailPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getDictionaryMock.mockResolvedValue(makeDict());
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('renders commercial seller fields only and submits a PATCH request', async () => {
+ const repo = new MemorySellerRepository();
+ repo.seed(makeSeller());
+ mocks.getSellerRepositoryMock.mockReturnValue(repo);
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ text: async () => '',
+ } as Response);
+
+ const element = await AdminSellerDetailPage({
+ params: Promise.resolve({ locale: 'es', sellerId: 'seller-1' }),
+ });
+ render(element);
+
+ expect(
+ screen.getByRole('link', { name: 'Back to sellers' }),
+ ).toHaveAttribute('href', '/es/admin/sellers');
+ expect(
+ screen.getByRole('heading', { name: 'Edit seller' }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Business name')).toHaveValue('Test Shop');
+ expect(screen.getByLabelText('Description')).toHaveValue('A test shop');
+ expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/first name/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/last name/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/address/i)).not.toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText('Business name'), {
+ target: { value: 'Updated Shop' },
+ });
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: 'Updated description' },
+ });
+ fireEvent.submit(
+ (screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form!,
+ );
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith('/api/sellers/seller-1', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: 'Updated Shop',
+ description: 'Updated description',
+ }),
+ });
+ });
+
+ expect(screen.getByRole('status')).toHaveTextContent('Saved');
+ });
+
+ it('redirects non-admin users to the locale root', async () => {
+ mocks.assertRoleMock.mockRejectedValueOnce(new Error('FORBIDDEN'));
+
+ await expect(
+ AdminSellerDetailPage({
+ params: Promise.resolve({ locale: 'es', sellerId: 'seller-1' }),
+ }),
+ ).rejects.toThrow('NEXT_REDIRECT');
+
+ expect(mocks.redirectMock).toHaveBeenCalledWith('/es');
+ });
+
+ it('returns 404 for a missing seller', async () => {
+ const repo = new MemorySellerRepository();
+ mocks.getSellerRepositoryMock.mockReturnValue(repo);
+
+ await expect(
+ AdminSellerDetailPage({
+ params: Promise.resolve({ locale: 'es', sellerId: 'missing' }),
+ }),
+ ).rejects.toThrow('NEXT_NOT_FOUND');
+
+ expect(mocks.notFoundMock).toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/app/[locale]/admin/sellers/[sellerId]/products/page.test.tsx b/tests/unit/app/[locale]/admin/sellers/[sellerId]/products/page.test.tsx
index d2a5f543..f416c600 100644
--- a/tests/unit/app/[locale]/admin/sellers/[sellerId]/products/page.test.tsx
+++ b/tests/unit/app/[locale]/admin/sellers/[sellerId]/products/page.test.tsx
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => {
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
+ useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock('@/shared/authorization/authorization', () => ({
@@ -98,6 +99,9 @@ function makeDict() {
productCount: '{total} items',
untranslatedProduct: 'No translation',
paginationAriaLabel: 'Page navigation',
+ status_draft: 'Draft',
+ status_active: 'Active',
+ status_archived: 'Archived',
},
} as unknown as Awaited<
ReturnType
@@ -159,7 +163,7 @@ describe('AdminSellerProductsPage', () => {
const repo = new MemoryProductRepository();
repo.seed([
makeProduct({ id: 'p-1' }),
- makeProduct({ id: 'p-2', translations: [] }),
+ makeProduct({ id: 'p-2', status: ProductStatus.DRAFT, translations: [] }),
makeProduct({ id: 'p-3' }),
]);
mocks.getProductRepositoryMock.mockReturnValue(repo);
@@ -185,8 +189,8 @@ describe('AdminSellerProductsPage', () => {
expect(screen.getByText('Tassa')).toBeInTheDocument();
expect(screen.getByText('No translation')).toBeInTheDocument();
- expect(screen.getByText('Page 1 of 2')).toBeInTheDocument();
- expect(screen.getByText('3 items')).toBeInTheDocument();
+ expect(screen.getAllByText('Active')).toHaveLength(1);
+ expect(screen.getByText('Draft')).toBeInTheDocument();
expect(
screen.getByRole('navigation', { name: 'Page navigation' }),
).toBeInTheDocument();
@@ -223,7 +227,6 @@ describe('AdminSellerProductsPage', () => {
});
render(element);
- expect(screen.getByText('Page 2 of 2')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '← Previous' })).toHaveAttribute(
'href',
'/es/admin/sellers/seller-1/products?pageSize=2',
diff --git a/tests/unit/app/[locale]/admin/sellers/[sellerId]/seller-detail-form.test.tsx b/tests/unit/app/[locale]/admin/sellers/[sellerId]/seller-detail-form.test.tsx
new file mode 100644
index 00000000..06fb7045
--- /dev/null
+++ b/tests/unit/app/[locale]/admin/sellers/[sellerId]/seller-detail-form.test.tsx
@@ -0,0 +1,154 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { SellerDetailForm } from '@/app/[locale]/admin/sellers/[sellerId]/seller-detail-form';
+
+const fetchMock = vi.fn();
+
+describe('SellerDetailForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('shows the network error when submission fails before reaching the API', async () => {
+ fetchMock.mockRejectedValueOnce(new Error('Network error'));
+
+ render(
+ ,
+ );
+
+ fireEvent.submit(
+ (screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form!,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Network error');
+ });
+ });
+
+ it('shows the API error text when the response is not ok', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: false,
+ text: async () => JSON.stringify({ error: 'Validation failed' }),
+ } as Response);
+
+ render(
+ ,
+ );
+
+ fireEvent.submit(
+ (screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form!,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
+ });
+ });
+
+ it('clears the previous error after a successful resubmit', async () => {
+ fetchMock
+ .mockResolvedValueOnce({
+ ok: false,
+ text: async () => JSON.stringify({ error: 'Validation failed' }),
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => '',
+ } as Response);
+
+ render(
+ ,
+ );
+
+ fireEvent.submit(
+ (screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form!,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
+ });
+
+ fireEvent.submit(
+ (screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form!,
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).toBeNull();
+ expect(screen.getByRole('status')).toHaveTextContent('Saved');
+ });
+ });
+
+ it('keeps the submit button in loading state until the request settles', async () => {
+ let resolveFetch!: (value: Response) => void;
+ const pendingFetch = new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+
+ fetchMock.mockReturnValueOnce(pendingFetch as Promise);
+
+ render(
+ ,
+ );
+
+ fireEvent.submit(
+ (screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form!,
+ );
+
+ expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled();
+
+ await act(async () => {
+ resolveFetch({
+ ok: true,
+ text: async () => '',
+ } as Response);
+ });
+
+ expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
+ });
+});
diff --git a/tests/unit/app/[locale]/admin/sellers/page.test.tsx b/tests/unit/app/[locale]/admin/sellers/page.test.tsx
index 47a33084..c7b43ee8 100644
--- a/tests/unit/app/[locale]/admin/sellers/page.test.tsx
+++ b/tests/unit/app/[locale]/admin/sellers/page.test.tsx
@@ -3,16 +3,24 @@ import { render, screen } from '@testing-library/react';
const mocks = vi.hoisted(() => {
const assertRoleMock = vi.fn(async () => undefined);
+ const redirectMock = vi.fn(() => {
+ throw new Error('NEXT_REDIRECT');
+ });
const getDictionaryMock = vi.fn();
const getSellerRepositoryMock = vi.fn();
return {
assertRoleMock,
+ redirectMock,
getDictionaryMock,
getSellerRepositoryMock,
};
});
+vi.mock('next/navigation', () => ({
+ redirect: mocks.redirectMock,
+}));
+
vi.mock('@/shared/authorization/authorization', () => ({
assertRole: mocks.assertRoleMock,
}));
@@ -74,6 +82,7 @@ function makeDict() {
sellerStatus: 'Estado',
sellerCreated: 'Creado',
actions: 'Acciones',
+ edit: 'Edit',
viewProducts: 'Ver productos',
suspend: 'Suspender',
activate: 'Activar',
@@ -235,4 +244,39 @@ describe('AdminSellersPage', () => {
'suspended',
);
});
+
+ it('renders an edit link for each seller row', async () => {
+ const repo = new MemorySellerRepository();
+ repo.seed(
+ makeSeller({
+ sellerId: SellerId.create('seller-1'),
+ name: 'Edit Me',
+ }),
+ );
+ mocks.getSellerRepositoryMock.mockReturnValue(repo);
+
+ const element = await AdminSellersPage({
+ params: Promise.resolve({ locale: 'es' }),
+ searchParams: Promise.resolve({}),
+ });
+ render(element);
+
+ expect(screen.getByRole('link', { name: 'Edit' })).toHaveAttribute(
+ 'href',
+ '/es/admin/sellers/seller-1',
+ );
+ });
+
+ it('redirects non-admin users to the locale root', async () => {
+ mocks.assertRoleMock.mockRejectedValueOnce(new Error('FORBIDDEN'));
+
+ await expect(
+ AdminSellersPage({
+ params: Promise.resolve({ locale: 'es' }),
+ searchParams: Promise.resolve({}),
+ }),
+ ).rejects.toThrow('NEXT_REDIRECT');
+
+ expect(mocks.redirectMock).toHaveBeenCalledWith('/es');
+ });
});
diff --git a/tests/unit/app/[locale]/seller/products/page.test.tsx b/tests/unit/app/[locale]/seller/products/page.test.tsx
index a6da318c..4b3d2471 100644
--- a/tests/unit/app/[locale]/seller/products/page.test.tsx
+++ b/tests/unit/app/[locale]/seller/products/page.test.tsx
@@ -15,6 +15,11 @@ const mocks = vi.hoisted(() => {
};
});
+vi.mock('next/navigation', () => ({
+ redirect: vi.fn(),
+ useRouter: () => ({ refresh: vi.fn() }),
+}));
+
vi.mock('@/shared/i18n/get-dictionary', () => ({
getDictionary: mocks.getDictionaryMock,
}));
@@ -77,17 +82,26 @@ function makeSeller(overrides: Partial = {}): SellerEntity {
function makeDict() {
return {
+ common: {
+ loading: 'Loading...',
+ },
admin: {
productName: 'Product',
productStatus: 'Status',
productPrice: 'Price',
productUpdated: 'Updated',
+ actions: 'Actions',
untranslatedProduct: 'Untranslated',
paginationAriaLabel: 'Page navigation',
pagePrev: '← Previous',
pageNext: 'Next →',
pageXofY: 'Page {current} of {total}',
searchProductsPlaceholder: 'Search products...',
+ status_draft: 'Draft',
+ status_active: 'Active',
+ status_archived: 'Archived',
+ suspendProduct: 'Suspend',
+ activateProduct: 'Activate',
},
sellerDashboard: {
title: 'Seller products',
@@ -144,13 +158,16 @@ describe('SellerProductsPage', () => {
screen.getByRole('searchbox', { name: 'Search products' }),
).toHaveValue('taza');
expect(screen.getByText('Taza')).toBeInTheDocument();
- expect(screen.getByText('ACTIVE')).toBeInTheDocument();
+ expect(screen.getByText('Active')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Suspender' }),
+ ).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Search products' }),
).toBeInTheDocument();
});
- it('renders pagination controls and page info when multiple pages exist', async () => {
+ it('renders pagination controls when products exist', async () => {
const repo = new MemoryProductRepository();
repo.seed([
makeProduct({ id: 'p-1' }),
@@ -177,7 +194,6 @@ describe('SellerProductsPage', () => {
expect(
screen.getByRole('navigation', { name: 'Page navigation' }),
).toBeInTheDocument();
- expect(screen.getByText('Page 1 of 2')).toBeInTheDocument();
expect(screen.getByText('← Previous')).toHaveTextContent('← Previous');
expect(screen.getByText('← Previous').tagName).toBe('SPAN');
expect(screen.getByRole('link', { name: 'Next →' })).toHaveAttribute(
@@ -213,7 +229,6 @@ describe('SellerProductsPage', () => {
});
render(element);
- expect(screen.getByText('Page 2 of 2')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '← Previous' })).toHaveAttribute(
'href',
'/cat/seller/products?pageSize=2',
diff --git a/tests/unit/app/[locale]/seller/products/product-actions.test.tsx b/tests/unit/app/[locale]/seller/products/product-actions.test.tsx
new file mode 100644
index 00000000..88b1b1d3
--- /dev/null
+++ b/tests/unit/app/[locale]/seller/products/product-actions.test.tsx
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+const mocks = vi.hoisted(() => {
+ const fetchMock = vi.fn();
+ const useDictionaryMock = vi.fn();
+ const refreshMock = vi.fn();
+
+ return { fetchMock, useDictionaryMock, refreshMock };
+});
+
+vi.mock('@/shared/i18n/dictionary-context', () => ({
+ useDictionary: mocks.useDictionaryMock,
+}));
+
+vi.mock('next/navigation', () => {
+ return {
+ useRouter: () => ({ refresh: mocks.refreshMock }),
+ };
+});
+
+globalThis.fetch = mocks.fetchMock as typeof fetch;
+
+import { ProductActions } from '@/app/[locale]/seller/products/product-actions';
+
+function makeDict() {
+ return {
+ common: {
+ loading: 'Loading...',
+ },
+ admin: {
+ suspendProduct: 'Suspend',
+ activateProduct: 'Activate',
+ eliminateProduct: 'Eliminate',
+ },
+ } as unknown as Awaited<
+ ReturnType
+ >;
+}
+
+describe('ProductActions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.useDictionaryMock.mockReturnValue(makeDict());
+ });
+
+ it('shows Suspend for ACTIVE products and archives on click', async () => {
+ mocks.fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ status: 'ARCHIVED' }),
+ });
+
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Suspend' }));
+
+ expect(mocks.fetchMock).toHaveBeenCalledWith('/api/products/p-1/status', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'ARCHIVED' }),
+ });
+ });
+
+ it('shows Activate for DRAFT products', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: 'Activate' }),
+ ).toBeInTheDocument();
+ });
+
+ it('shows no actions for ELIMINATED products', () => {
+ render();
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('shows Activate and Eliminate for ARCHIVED products', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: 'Activate' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Eliminate' }),
+ ).toBeInTheDocument();
+ });
+
+ it('disables the action while loading', async () => {
+ let resolveFetch: ((value: Response) => void) | undefined;
+ mocks.fetchMock.mockReturnValue(
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ );
+
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Suspend' }));
+
+ expect(screen.getAllByRole('button', { name: 'Loading...' })).toHaveLength(
+ 2,
+ );
+
+ await act(async () => {
+ resolveFetch?.(
+ new Response(JSON.stringify({ status: 'ARCHIVED' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ });
+ });
+});
diff --git a/tests/unit/app/api/products/[id]/status/route.test.ts b/tests/unit/app/api/products/[id]/status/route.test.ts
new file mode 100644
index 00000000..e22aee59
--- /dev/null
+++ b/tests/unit/app/api/products/[id]/status/route.test.ts
@@ -0,0 +1,157 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+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 { SellerStatus } from '@/modules/sellers/domain/seller-status';
+import { SellerId } from '@/shared/kernel/domain/value-objects/seller-id';
+
+const mocks = vi.hoisted(() => {
+ const requireRoleMock = vi.fn(
+ () => (handler: (req: NextRequest, context?: unknown) => unknown) =>
+ handler,
+ );
+ const getSessionMock = vi.fn();
+ const findByUserIdMock = vi.fn();
+ const findByIdMock = vi.fn();
+ const updateMock = vi.fn();
+ const getUserLookupFindByIdMock = vi.fn();
+
+ return {
+ requireRoleMock,
+ getSessionMock,
+ findByUserIdMock,
+ findByIdMock,
+ updateMock,
+ getUserLookupFindByIdMock,
+ };
+});
+
+vi.mock('@/shared/authorization/authorization', () => ({
+ requireRole: mocks.requireRoleMock,
+}));
+
+vi.mock('@/composition-root/container', () => ({
+ container: {
+ getSession: () => ({
+ getSession: mocks.getSessionMock,
+ }),
+ getUserLookup: () => ({
+ findById: mocks.getUserLookupFindByIdMock,
+ }),
+ getSellerRepository: () => ({
+ findByUserId: mocks.findByUserIdMock,
+ }),
+ getProductRepository: () => ({
+ findById: mocks.findByIdMock,
+ update: mocks.updateMock,
+ }),
+ },
+}));
+
+import { PATCH } from '@/app/api/products/[id]/status/route';
+
+const PARAMS = { params: Promise.resolve({ id: 'p-1' }) };
+
+function makeRequest(body: unknown): NextRequest {
+ return new NextRequest('http://localhost:3000/api/products/p-1/status', {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+function makeProduct(
+ overrides: Partial<{
+ status: ProductStatus;
+ sellerId: string;
+ }> = {},
+) {
+ return {
+ id: 'p-1',
+ basePrice: ProductPrice.create(10, Currency.EUR),
+ sellerId: overrides.sellerId ?? 'seller-1',
+ sellerName: 'Test Shop',
+ status: overrides.status ?? ProductStatus.ACTIVE,
+ categoryId: null,
+ category: null,
+ createdAt: new Date('2025-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2025-01-02T00:00:00.000Z'),
+ translations: [{ locale: 'es', name: 'Taza', description: 'Una taza' }],
+ images: [],
+ tags: [],
+ };
+}
+
+function makeSeller() {
+ return {
+ sellerId: SellerId.create('seller-1'),
+ name: 'Test Shop',
+ description: null,
+ userId: 'user-1',
+ status: SellerStatus.ACTIVE,
+ deletedAt: null,
+ createdAt: new Date('2025-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2025-01-01T00:00:00.000Z'),
+ };
+}
+
+describe('route authorization (module-load wiring)', () => {
+ it('wires PATCH through requireRole("DESIGNER", "ADMIN")', () => {
+ const calls = mocks.requireRoleMock.mock.calls as unknown as Array<
+ [string, ...unknown[]]
+ >;
+ expect(calls.length).toBeGreaterThanOrEqual(1);
+ for (const call of calls) {
+ expect(call).toContain('DESIGNER');
+ expect(call).toContain('ADMIN');
+ }
+ });
+});
+
+describe('PATCH /api/products/[id]/status', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getSessionMock.mockResolvedValue({ id: 'user-1' });
+ mocks.getUserLookupFindByIdMock.mockResolvedValue({
+ id: 'user-1',
+ role: 'DESIGNER',
+ });
+ mocks.findByUserIdMock.mockResolvedValue(makeSeller());
+ });
+
+ it('returns 400 when body is invalid', async () => {
+ const res = await PATCH(makeRequest({}), PARAMS);
+
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 404 when the product is missing', async () => {
+ mocks.findByIdMock.mockResolvedValue(null);
+
+ const res = await PATCH(makeRequest({ status: 'ARCHIVED' }), PARAMS);
+
+ expect(res.status).toBe(404);
+ });
+
+ it('returns 403 when the authenticated seller does not own the product', async () => {
+ mocks.findByIdMock.mockResolvedValue(makeProduct({ sellerId: 'seller-2' }));
+
+ const res = await PATCH(makeRequest({ status: 'ARCHIVED' }), PARAMS);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('returns 200 with the updated status for the owner', async () => {
+ const updated = makeProduct({ status: ProductStatus.ARCHIVED });
+ mocks.findByIdMock.mockResolvedValue(makeProduct());
+ mocks.updateMock.mockResolvedValue(updated);
+
+ const res = await PATCH(makeRequest({ status: 'ARCHIVED' }), PARAMS);
+
+ expect(res.status).toBe(200);
+ expect(mocks.updateMock).toHaveBeenCalledTimes(1);
+ const body = await res.json();
+ expect(body.status).toBe('ARCHIVED');
+ });
+});
diff --git a/tests/unit/components/cart/guest-cart-badge.test.tsx b/tests/unit/components/cart/guest-cart-badge.test.tsx
index 57422ae5..1a58bf10 100644
--- a/tests/unit/components/cart/guest-cart-badge.test.tsx
+++ b/tests/unit/components/cart/guest-cart-badge.test.tsx
@@ -15,6 +15,7 @@ vi.mock('@/modules/cart/presentation/guest-cart-context', () => ({
// Mock next/navigation for usePathname
vi.mock('next/navigation', () => ({
usePathname: vi.fn(() => '/es/some-page'),
+ useRouter: () => ({ refresh: vi.fn() }),
}));
import { useSession } from 'next-auth/react';
diff --git a/tests/unit/modules/presentation/components/role-nav-links.test.tsx b/tests/unit/modules/presentation/components/role-nav-links.test.tsx
index f2f94f20..ca07bfb5 100644
--- a/tests/unit/modules/presentation/components/role-nav-links.test.tsx
+++ b/tests/unit/modules/presentation/components/role-nav-links.test.tsx
@@ -5,6 +5,7 @@ import { RoleNavLinks } from '@/modules/presentation/components/role-nav-links';
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({ locale: 'es' }),
+ useRouter: () => ({ refresh: vi.fn() }),
}));
describe('RoleNavLinks component', () => {
diff --git a/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx b/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx
index 7f38a4e8..84177e5d 100644
--- a/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx
+++ b/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx
@@ -5,6 +5,7 @@ import { UserMenuDropdown } from '@/modules/presentation/components/user-menu-dr
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({ locale: 'es' }),
+ useRouter: () => ({ refresh: vi.fn() }),
}));
// Mock next-auth/react
diff --git a/tests/unit/modules/products/application/change-product-status-use-case.test.ts b/tests/unit/modules/products/application/change-product-status-use-case.test.ts
new file mode 100644
index 00000000..aa6a98c8
--- /dev/null
+++ b/tests/unit/modules/products/application/change-product-status-use-case.test.ts
@@ -0,0 +1,190 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MemoryProductRepository } from '@/tests/doubles/memory-product-repository';
+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 { ChangeProductStatusUseCase } from '@/modules/products/application/change-product-status-use-case';
+import { NotFoundError, ValidationError } from '@/shared/kernel/app-error';
+import type { ProductEntity } from '@/modules/products/domain/product-repository';
+
+function makeProduct(overrides: Partial = {}): ProductEntity {
+ return {
+ id: 'p-1',
+ basePrice: ProductPrice.create(10, Currency.EUR),
+ sellerId: 'seller-1',
+ sellerName: 'Test Shop',
+ status: ProductStatus.ACTIVE,
+ categoryId: null,
+ category: null,
+ createdAt: new Date('2025-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2025-01-02T00:00:00.000Z'),
+ translations: [{ locale: 'es', name: 'Taza', description: 'Una taza' }],
+ images: [],
+ tags: [],
+ ...overrides,
+ };
+}
+
+describe('ChangeProductStatusUseCase', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2025-02-01T12:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('updates ACTIVE to ARCHIVED for the owning seller', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeProduct()]);
+ const updateSpy = vi.spyOn(repository, 'update');
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ const updated = await useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ARCHIVED,
+ });
+
+ expect(updateSpy).toHaveBeenCalledTimes(1);
+ expect(updateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'p-1',
+ status: ProductStatus.ARCHIVED,
+ updatedAt: new Date('2025-02-01T12:00:00.000Z'),
+ }),
+ );
+ expect(updated.status).toBe(ProductStatus.ARCHIVED);
+ expect(updated.updatedAt.toISOString()).toBe('2025-02-01T12:00:00.000Z');
+ });
+
+ it('rejects DRAFT to ARCHIVED because it must pass through ACTIVE', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeDraftProduct()]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ await expect(
+ useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ARCHIVED,
+ }),
+ ).rejects.toBeInstanceOf(ValidationError);
+ });
+
+ it('updates DRAFT to ACTIVE', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeDraftProduct()]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ const updated = await useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ACTIVE,
+ });
+
+ expect(updated.status).toBe(ProductStatus.ACTIVE);
+ });
+
+ it('updates DRAFT to ELIMINATED', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeDraftProduct()]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ const updated = await useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ELIMINATED,
+ });
+
+ expect(updated.status).toBe(ProductStatus.ELIMINATED);
+ });
+
+ it('updates ACTIVE to ELIMINATED', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeProduct()]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ const updated = await useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ELIMINATED,
+ });
+
+ expect(updated.status).toBe(ProductStatus.ELIMINATED);
+ });
+
+ it('updates ARCHIVED to ACTIVE', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeProduct({ status: ProductStatus.ARCHIVED })]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ const updated = await useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ACTIVE,
+ });
+
+ expect(updated.status).toBe(ProductStatus.ACTIVE);
+ });
+
+ it('updates ARCHIVED to ELIMINATED', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeProduct({ status: ProductStatus.ARCHIVED })]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ const updated = await useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ELIMINATED,
+ });
+
+ expect(updated.status).toBe(ProductStatus.ELIMINATED);
+ });
+
+ it('rejects same-status transitions as a no-op', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeProduct()]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ await expect(
+ useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ACTIVE,
+ }),
+ ).rejects.toBeInstanceOf(ValidationError);
+ });
+
+ it('throws when the product does not exist', async () => {
+ const repository = new MemoryProductRepository();
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ await expect(
+ useCase.execute({
+ productId: 'missing',
+ sellerId: 'seller-1',
+ status: ProductStatus.ACTIVE,
+ }),
+ ).rejects.toBeInstanceOf(NotFoundError);
+ });
+
+ it('throws when the product belongs to another seller', async () => {
+ const repository = new MemoryProductRepository();
+ repository.seed([makeProduct({ sellerId: 'seller-2' })]);
+ const useCase = new ChangeProductStatusUseCase(repository);
+
+ await expect(
+ useCase.execute({
+ productId: 'p-1',
+ sellerId: 'seller-1',
+ status: ProductStatus.ARCHIVED,
+ }),
+ ).rejects.toBeInstanceOf(NotFoundError);
+ });
+});
+
+function makeDraftProduct(): ProductEntity {
+ return makeProduct({ status: ProductStatus.DRAFT });
+}
diff --git a/tests/unit/modules/products/domain/value-objects/product-status.test.ts b/tests/unit/modules/products/domain/value-objects/product-status.test.ts
index 26bb55b5..712a7909 100644
--- a/tests/unit/modules/products/domain/value-objects/product-status.test.ts
+++ b/tests/unit/modules/products/domain/value-objects/product-status.test.ts
@@ -5,15 +5,16 @@ import {
} from '@/modules/products/domain/value-objects/product-status';
describe('ProductStatus', () => {
- it('should define the three valid status values', () => {
+ it('should define the four valid status values', () => {
expect(ProductStatus.DRAFT).toBe('DRAFT');
expect(ProductStatus.ACTIVE).toBe('ACTIVE');
expect(ProductStatus.ARCHIVED).toBe('ARCHIVED');
+ expect(ProductStatus.ELIMINATED).toBe('ELIMINATED');
});
- it('should have exactly 3 members', () => {
+ it('should have exactly 4 members', () => {
const keys = Object.values(ProductStatus);
- expect(keys).toHaveLength(3);
+ expect(keys).toHaveLength(4);
});
});
@@ -24,27 +25,52 @@ describe('VALID_TRANSITIONS', () => {
);
});
+ it('should allow DRAFT → ELIMINATED', () => {
+ expect(VALID_TRANSITIONS[ProductStatus.DRAFT]).toContain(
+ ProductStatus.ELIMINATED,
+ );
+ });
+
it('should allow ACTIVE → ARCHIVED', () => {
expect(VALID_TRANSITIONS[ProductStatus.ACTIVE]).toContain(
ProductStatus.ARCHIVED,
);
});
+ it('should allow ACTIVE → ELIMINATED', () => {
+ expect(VALID_TRANSITIONS[ProductStatus.ACTIVE]).toContain(
+ ProductStatus.ELIMINATED,
+ );
+ });
+
+ it('should allow ARCHIVED → ACTIVE', () => {
+ expect(VALID_TRANSITIONS[ProductStatus.ARCHIVED]).toContain(
+ ProductStatus.ACTIVE,
+ );
+ });
+
+ it('should allow ARCHIVED → ELIMINATED', () => {
+ expect(VALID_TRANSITIONS[ProductStatus.ARCHIVED]).toContain(
+ ProductStatus.ELIMINATED,
+ );
+ });
+
it('should NOT allow DRAFT → ARCHIVED (must go through ACTIVE)', () => {
expect(VALID_TRANSITIONS[ProductStatus.DRAFT]).not.toContain(
ProductStatus.ARCHIVED,
);
});
- it('should NOT allow ARCHIVED → any status (terminal state)', () => {
- expect(VALID_TRANSITIONS[ProductStatus.ARCHIVED]).toBeUndefined();
+ it('should NOT allow ELIMINATED → any status (terminal state)', () => {
+ expect(VALID_TRANSITIONS[ProductStatus.ELIMINATED]).toBeUndefined();
});
- it('should define transitions for exactly 2 statuses (DRAFT, ACTIVE)', () => {
+ it('should define transitions for exactly 3 statuses (DRAFT, ACTIVE, ARCHIVED)', () => {
const transitionKeys = Object.keys(VALID_TRANSITIONS);
- expect(transitionKeys).toHaveLength(2);
+ expect(transitionKeys).toHaveLength(3);
expect(transitionKeys).toContain(ProductStatus.DRAFT);
expect(transitionKeys).toContain(ProductStatus.ACTIVE);
+ expect(transitionKeys).toContain(ProductStatus.ARCHIVED);
});
it('should NOT include same-status in transitions (no-op handled by use case)', () => {
@@ -54,6 +80,9 @@ describe('VALID_TRANSITIONS', () => {
expect(VALID_TRANSITIONS[ProductStatus.ACTIVE]).not.toContain(
ProductStatus.ACTIVE,
);
+ expect(VALID_TRANSITIONS[ProductStatus.ARCHIVED]).not.toContain(
+ ProductStatus.ARCHIVED,
+ );
});
});
@@ -64,23 +93,47 @@ describe('canTransitionTo()', () => {
);
});
+ it('should return true for valid transition DRAFT → ELIMINATED', () => {
+ expect(canTransitionTo(ProductStatus.DRAFT, ProductStatus.ELIMINATED)).toBe(
+ true,
+ );
+ });
+
it('should return true for valid transition ACTIVE → ARCHIVED', () => {
expect(canTransitionTo(ProductStatus.ACTIVE, ProductStatus.ARCHIVED)).toBe(
true,
);
});
+ it('should return true for valid transition ACTIVE → ELIMINATED', () => {
+ expect(
+ canTransitionTo(ProductStatus.ACTIVE, ProductStatus.ELIMINATED),
+ ).toBe(true);
+ });
+
+ it('should return true for valid transition ARCHIVED → ACTIVE', () => {
+ expect(canTransitionTo(ProductStatus.ARCHIVED, ProductStatus.ACTIVE)).toBe(
+ true,
+ );
+ });
+
+ it('should return true for valid transition ARCHIVED → ELIMINATED', () => {
+ expect(
+ canTransitionTo(ProductStatus.ARCHIVED, ProductStatus.ELIMINATED),
+ ).toBe(true);
+ });
+
it('should return false for invalid transition DRAFT → ARCHIVED', () => {
expect(canTransitionTo(ProductStatus.DRAFT, ProductStatus.ARCHIVED)).toBe(
false,
);
});
- it('should return false for ARCHIVED → any (terminal)', () => {
- expect(canTransitionTo(ProductStatus.ARCHIVED, ProductStatus.ACTIVE)).toBe(
- false,
- );
- expect(canTransitionTo(ProductStatus.ARCHIVED, ProductStatus.DRAFT)).toBe(
+ it('should return false for ELIMINATED → any (terminal)', () => {
+ expect(
+ canTransitionTo(ProductStatus.ELIMINATED, ProductStatus.ACTIVE),
+ ).toBe(false);
+ expect(canTransitionTo(ProductStatus.ELIMINATED, ProductStatus.DRAFT)).toBe(
false,
);
});
@@ -95,6 +148,9 @@ describe('canTransitionTo()', () => {
expect(
canTransitionTo(ProductStatus.ARCHIVED, ProductStatus.ARCHIVED),
).toBe(false);
+ expect(
+ canTransitionTo(ProductStatus.ELIMINATED, ProductStatus.ELIMINATED),
+ ).toBe(false);
});
});
|