diff --git a/apps/api/src/contexts/supplies/application/list-categories.spec.ts b/apps/api/src/contexts/supplies/application/list-categories.spec.ts index 6ce2723..8728b09 100644 --- a/apps/api/src/contexts/supplies/application/list-categories.spec.ts +++ b/apps/api/src/contexts/supplies/application/list-categories.spec.ts @@ -3,34 +3,55 @@ import { Category } from '../domain/category'; import { CategoryDefinition } from '../domain/category-definition'; import { CategoryRepository } from '../domain/ports/category.repository'; +const FOOD: CategoryDefinition = { + slug: Category.Food, + labelEs: 'Alimentos', + labelEn: 'Food', + parentSlug: null, + vertical: 'general', + sort: 10, + codePrefix: 'FOD', + archivedAt: null, + translations: [ + { locale: 'es', label: 'Alimentos' }, + { locale: 'en', label: 'Food' }, + ], +}; + +function makeRepo(listCategoriesFn: jest.Mock): CategoryRepository { + return { + loadAliasMap: () => Promise.resolve(new Map()), + listCategories: listCategoriesFn, + findBySlug: () => Promise.resolve(null), + createCategory: () => Promise.resolve(FOOD), + updateCategory: () => Promise.resolve(FOOD), + }; +} + describe('ListCategories', () => { - it('returns the category taxonomy from the repository', async () => { - const categories: CategoryDefinition[] = [ - { - slug: Category.Food, - labelEs: 'Alimentos', - labelEn: 'Food', - parentSlug: null, - vertical: 'general', - sort: 10, - codePrefix: 'FOD', - archivedAt: null, - translations: [ - { locale: 'es', label: 'Alimentos' }, - { locale: 'en', label: 'Food' }, - ], - }, - ]; - const repo: CategoryRepository = { - loadAliasMap: () => Promise.resolve(new Map()), - listCategories: () => Promise.resolve(categories), - findBySlug: () => Promise.resolve(categories[0] ?? null), - createCategory: () => Promise.resolve(), - updateCategory: () => Promise.resolve(), - }; - - const result = await new ListCategories(repo).execute(); - - expect(result).toEqual(categories); + it('devuelve la taxonomía de categorías del repositorio', async () => { + const listCategories = jest.fn().mockResolvedValue([FOOD]); + const result = await new ListCategories(makeRepo(listCategories)).execute(); + + expect(result).toEqual([FOOD]); + }); + + it('llama al repositorio sin includeArchived por defecto (cara pública)', async () => { + const listCategories = jest.fn().mockResolvedValue([FOOD]); + + await new ListCategories(makeRepo(listCategories)).execute(); + + expect(listCategories).toHaveBeenCalledWith(undefined); + }); + + it('pasa includeArchived: true cuando se solicita (cara admin)', async () => { + const archived: CategoryDefinition = { ...FOOD, archivedAt: new Date() }; + const listCategories = jest.fn().mockResolvedValue([FOOD, archived]); + + await new ListCategories(makeRepo(listCategories)).execute({ + includeArchived: true, + }); + + expect(listCategories).toHaveBeenCalledWith({ includeArchived: true }); }); }); diff --git a/apps/api/src/contexts/supplies/application/update-category.spec.ts b/apps/api/src/contexts/supplies/application/update-category.spec.ts new file mode 100644 index 0000000..41a4140 --- /dev/null +++ b/apps/api/src/contexts/supplies/application/update-category.spec.ts @@ -0,0 +1,82 @@ +import { UpdateCategory } from './update-category'; +import { CategoryValidationError } from './category-admin.errors'; +import { Category } from '../domain/category'; +import { CategoryDefinition } from '../domain/category-definition'; +import { CategoryRepository } from '../domain/ports/category.repository'; + +const BASE: CategoryDefinition = { + slug: 'baby_food', + labelEs: 'Alimentos para bebé', + labelEn: 'Baby food', + parentSlug: 'food', + vertical: 'general', + sort: 140, + archivedAt: null, + translations: [], +}; + +function makeRepo( + override: Partial = {}, +): CategoryRepository { + return { + loadAliasMap: () => Promise.resolve(new Map()), + listCategories: () => Promise.resolve([]), + findBySlug: () => Promise.resolve(BASE), + createCategory: () => Promise.resolve(BASE), + updateCategory: (_slug, input) => + Promise.resolve({ ...BASE, archivedAt: input.archivedAt ?? null }), + ...override, + }; +} + +describe('UpdateCategory — archive / restore', () => { + it('archiva una categoría no-núcleo fijando archivedAt', async () => { + const repo = makeRepo(); + const result = await new UpdateCategory(repo).execute('baby_food', { + archived: true, + }); + expect(result.archivedAt).toBeInstanceOf(Date); + }); + + it('restaura una categoría archivada pasando archived: false', async () => { + const archived: CategoryDefinition = { ...BASE, archivedAt: new Date() }; + const repo = makeRepo({ + findBySlug: () => Promise.resolve(archived), + updateCategory: (_slug, input) => + Promise.resolve({ ...archived, archivedAt: input.archivedAt ?? null }), + }); + + const result = await new UpdateCategory(repo).execute('baby_food', { + archived: false, + }); + expect(result.archivedAt).toBeNull(); + }); + + it('preserva archivedAt cuando archived no se pasa', async () => { + const ts = new Date('2026-01-01T00:00:00Z'); + const archived: CategoryDefinition = { ...BASE, archivedAt: ts }; + const repo = makeRepo({ + findBySlug: () => Promise.resolve(archived), + updateCategory: (_slug, input) => + Promise.resolve({ ...archived, archivedAt: input.archivedAt ?? null }), + }); + + const result = await new UpdateCategory(repo).execute('baby_food', { + labelEs: 'Otro nombre', + }); + expect(result.archivedAt).toEqual(ts); + }); + + it('rechaza archivar una categoría núcleo', async () => { + const core: CategoryDefinition = { + ...BASE, + slug: Category.Food, + parentSlug: null, + }; + const repo = makeRepo({ findBySlug: () => Promise.resolve(core) }); + + await expect( + new UpdateCategory(repo).execute(Category.Food, { archived: true }), + ).rejects.toBeInstanceOf(CategoryValidationError); + }); +}); diff --git a/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts b/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts index f6ba2f9..9b44e46 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts @@ -80,4 +80,43 @@ describe('CategoriesAdminController', () => { BadRequestException, ); }); + + it('archiva una categoría no-núcleo vía DELETE', async () => { + const archived = { ...category, archivedAt: new Date().toISOString() }; + const updateCategory = { execute: jest.fn().mockResolvedValue(archived) }; + const controller = new CategoriesAdminController( + { execute: jest.fn() } as never, + { execute: jest.fn() } as never, + updateCategory as never, + ); + + await controller.delete('baby_food'); + + expect(updateCategory.execute).toHaveBeenCalledWith('baby_food', { + archived: true, + }); + }); + + it('restaura una categoría archivada vía PATCH con archived: false', async () => { + const restored: CategoryDefinition = { ...category, archivedAt: null }; + const updateCategory = { execute: jest.fn().mockResolvedValue(restored) }; + const controller = new CategoriesAdminController( + { execute: jest.fn() } as never, + { execute: jest.fn() } as never, + updateCategory as never, + ); + + const result = await controller.update('baby_food', { archived: false }); + + expect(updateCategory.execute).toHaveBeenCalledWith('baby_food', { + labelEs: undefined, + labelEn: undefined, + parentSlug: undefined, + vertical: undefined, + sort: undefined, + archived: false, + translations: undefined, + }); + expect(result.archivedAt).toBeNull(); + }); }); diff --git a/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts b/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts index 193b90f..702d0b6 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts @@ -35,4 +35,23 @@ describe('CategoriesController', () => { expect(es[0]?.label).toBe('Alimentos'); expect(fr[0]?.label).toBe('Nourriture'); }); + + it('no expone archivedAt ni campos internos en la proyección pública', async () => { + const controller = new CategoriesController({ + execute: () => Promise.resolve(categories), + }); + + const result = await controller.list('es', {}); + + expect(result[0]).not.toHaveProperty('archivedAt'); + expect(result[0]).toEqual({ + slug: 'food', + label: 'Alimentos', + labelEs: 'Alimentos', + labelEn: 'Food', + parentSlug: null, + vertical: 'general', + sort: 1, + }); + }); });