feat(ADM-018): completed feature
This commit is contained in:
151
project/src/modules/categories/api/categories.routes.ts
Normal file
151
project/src/modules/categories/api/categories.routes.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateCategory,
|
||||
DeleteCategory,
|
||||
GetCategoryBySlug,
|
||||
ListCategoryTree,
|
||||
UpdateCategory,
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryTreeNode } from '../domain/category.js';
|
||||
import {
|
||||
CategoryParentNotFoundError,
|
||||
CategorySlugAlreadyExistsError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
|
||||
|
||||
export interface CategoriesRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const slugSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
const slugParamSchema = z.object({ slug: slugSchema });
|
||||
const idParamSchema = z.object({ id: z.uuid() });
|
||||
|
||||
const newCategorySchema = z.object({
|
||||
parentId: z.uuid().optional().nullable(),
|
||||
name: z.string().min(1).max(200),
|
||||
slug: slugSchema,
|
||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
const categoryPatchSchema = newCategorySchema
|
||||
.partial()
|
||||
.refine((value) => Object.values(value).some((field) => field !== undefined), {
|
||||
message: 'At least one category field is required',
|
||||
});
|
||||
|
||||
export async function registerCategoriesRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: CategoriesRoutesDeps,
|
||||
): Promise<void> {
|
||||
const repository = new PgCategoryRepository(deps.pool);
|
||||
const getBySlug = new GetCategoryBySlug(repository);
|
||||
const listTree = new ListCategoryTree(repository);
|
||||
const createCategory = new CreateCategory(repository);
|
||||
const updateCategory = new UpdateCategory(repository);
|
||||
const deleteCategory = new DeleteCategory(repository);
|
||||
|
||||
app.get('/categories/tree', async (_request, reply) => {
|
||||
const items = await listTree.execute();
|
||||
return reply.send({ items: items.map(serializeTreeNode) });
|
||||
});
|
||||
|
||||
app.get('/categoria/:slug', async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const category = await getBySlug.execute(slug);
|
||||
if (!category) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Category not found');
|
||||
}
|
||||
return reply.send(serializeCategory(category));
|
||||
});
|
||||
|
||||
app.post('/categories', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newCategorySchema, request.body);
|
||||
try {
|
||||
const category = await createCategory.execute(input);
|
||||
return reply.code(201).send(serializeCategory(category));
|
||||
} catch (error) {
|
||||
throw mapCategoryError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/categories/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const patch = parseJson(categoryPatchSchema, request.body);
|
||||
try {
|
||||
const category = await updateCategory.execute(id, patch);
|
||||
if (!category) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Category not found');
|
||||
}
|
||||
return reply.send(serializeCategory(category));
|
||||
} catch (error) {
|
||||
throw mapCategoryError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/categories/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const result = await deleteCategory.execute(id);
|
||||
if (result === 'not_found') {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Category not found');
|
||||
}
|
||||
if (result === 'has_children') {
|
||||
throw new AppError(409, 'CATEGORY_HAS_CHILDREN', 'Category has child categories');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
function mapCategoryError(error: unknown): Error {
|
||||
if (error instanceof CategorySlugAlreadyExistsError) {
|
||||
return new AppError(409, 'CATEGORY_SLUG_EXISTS', error.message);
|
||||
}
|
||||
if (error instanceof CategoryParentNotFoundError) {
|
||||
return new AppError(422, 'CATEGORY_PARENT_NOT_FOUND', error.message);
|
||||
}
|
||||
if (error instanceof CategoryTreeCycleError) {
|
||||
return new AppError(422, 'CATEGORY_TREE_CYCLE', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown category error');
|
||||
}
|
||||
|
||||
function serializeCategory(category: Category) {
|
||||
return {
|
||||
id: category.id,
|
||||
parentId: category.parentId,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
url: `/categoria/${category.slug}`,
|
||||
seoTitle: category.seoTitle,
|
||||
seoDescription: category.seoDescription,
|
||||
createdAt: category.createdAt.toISOString(),
|
||||
updatedAt: category.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeTreeNode(category: CategoryTreeNode): ReturnType<typeof serializeCategory> & {
|
||||
children: ReturnType<typeof serializeTreeNode>[];
|
||||
} {
|
||||
return {
|
||||
...serializeCategory(category),
|
||||
children: category.children.map(serializeTreeNode),
|
||||
};
|
||||
}
|
||||
105
project/src/modules/categories/application/category-use-cases.ts
Normal file
105
project/src/modules/categories/application/category-use-cases.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Category, CategoryPatch, CategoryTreeNode, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
export class GetCategoryBySlug {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(slug: string): Promise<Category | undefined> {
|
||||
return this.categories.findBySlug(slug);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListCategoryTree {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(): Promise<CategoryTreeNode[]> {
|
||||
return buildTree(await this.categories.list());
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateCategory {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(input: NewCategory): Promise<Category> {
|
||||
await this.assertParentExists(input.parentId);
|
||||
return this.categories.create(input);
|
||||
}
|
||||
|
||||
private async assertParentExists(parentId: string | null | undefined): Promise<void> {
|
||||
if (parentId === undefined || parentId === null) {
|
||||
return;
|
||||
}
|
||||
const parent = await this.categories.findById(parentId);
|
||||
if (!parent) {
|
||||
throw new CategoryParentNotFoundError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateCategory {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(id: string, patch: CategoryPatch): Promise<Category | undefined> {
|
||||
if (patch.parentId !== undefined) {
|
||||
await this.assertValidParent(id, patch.parentId);
|
||||
}
|
||||
return this.categories.update(id, patch);
|
||||
}
|
||||
|
||||
private async assertValidParent(id: string, parentId: string | null): Promise<void> {
|
||||
if (parentId === null) {
|
||||
return;
|
||||
}
|
||||
if (parentId === id) {
|
||||
throw new CategoryTreeCycleError();
|
||||
}
|
||||
const parent = await this.categories.findById(parentId);
|
||||
if (!parent) {
|
||||
throw new CategoryParentNotFoundError();
|
||||
}
|
||||
const parentIsDescendant = await this.categories.isDescendant(id, parentId);
|
||||
if (parentIsDescendant) {
|
||||
throw new CategoryTreeCycleError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteCategoryResult = 'deleted' | 'not_found' | 'has_children';
|
||||
|
||||
export class DeleteCategory {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(id: string): Promise<DeleteCategoryResult> {
|
||||
const category = await this.categories.findById(id);
|
||||
if (!category) {
|
||||
return 'not_found';
|
||||
}
|
||||
if (await this.categories.hasChildren(id)) {
|
||||
return 'has_children';
|
||||
}
|
||||
return (await this.categories.delete(id)) ? 'deleted' : 'not_found';
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const nodes = new Map<string, CategoryTreeNode>();
|
||||
for (const category of categories) {
|
||||
nodes.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parentId === null) {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
const parent = nodes.get(node.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
28
project/src/modules/categories/domain/category.ts
Normal file
28
project/src/modules/categories/domain/category.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Category domain model. Public storefront URLs use slug; id is internal.
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NewCategory {
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
}
|
||||
|
||||
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
|
||||
export type CategoryPatch = Partial<NewCategory>;
|
||||
|
||||
export interface CategoryTreeNode extends Category {
|
||||
children: CategoryTreeNode[];
|
||||
}
|
||||
20
project/src/modules/categories/domain/errors.ts
Normal file
20
project/src/modules/categories/domain/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class CategorySlugAlreadyExistsError extends Error {
|
||||
constructor() {
|
||||
super('Category slug already exists');
|
||||
this.name = 'CategorySlugAlreadyExistsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CategoryParentNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Category parent not found');
|
||||
this.name = 'CategoryParentNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CategoryTreeCycleError extends Error {
|
||||
constructor() {
|
||||
super('Category parent would create a cycle');
|
||||
this.name = 'CategoryTreeCycleError';
|
||||
}
|
||||
}
|
||||
12
project/src/modules/categories/domain/ports.ts
Normal file
12
project/src/modules/categories/domain/ports.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Category, CategoryPatch, NewCategory } from './category.js';
|
||||
|
||||
export interface CategoryRepository {
|
||||
list(): Promise<Category[]>;
|
||||
findById(id: string): Promise<Category | undefined>;
|
||||
findBySlug(slug: string): Promise<Category | undefined>;
|
||||
create(input: NewCategory): Promise<Category>;
|
||||
update(id: string, patch: CategoryPatch): Promise<Category | undefined>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
hasChildren(id: string): Promise<boolean>;
|
||||
isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean>;
|
||||
}
|
||||
2
project/src/modules/categories/index.ts
Normal file
2
project/src/modules/categories/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the categories module. */
|
||||
export { registerCategoriesRoutes, type CategoriesRoutesDeps } from './api/categories.routes.js';
|
||||
@@ -0,0 +1,156 @@
|
||||
import type pg from 'pg';
|
||||
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
|
||||
import { CategorySlugAlreadyExistsError } from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
interface CategoryRow {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
seo_title: string | null;
|
||||
seo_description: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
|
||||
['parentId', 'parent_id'],
|
||||
['name', 'name'],
|
||||
['slug', 'slug'],
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
];
|
||||
|
||||
export class PgCategoryRepository implements CategoryRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async list(): Promise<Category[]> {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
`SELECT * FROM categories_categories ORDER BY parent_id NULLS FIRST, name, created_at`,
|
||||
);
|
||||
return result.rows.map(toCategory);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Category | undefined> {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
'SELECT * FROM categories_categories WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toCategory(row) : undefined;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<Category | undefined> {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
'SELECT * FROM categories_categories WHERE slug = $1',
|
||||
[slug],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toCategory(row) : undefined;
|
||||
}
|
||||
|
||||
async create(input: NewCategory): Promise<Category> {
|
||||
try {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.parentId ?? null,
|
||||
input.name,
|
||||
input.slug,
|
||||
input.seoTitle ?? null,
|
||||
input.seoDescription ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('categories_categories INSERT returned no row');
|
||||
}
|
||||
return toCategory(row);
|
||||
} catch (error) {
|
||||
if (isPgError(error, UNIQUE_VIOLATION)) {
|
||||
throw new CategorySlugAlreadyExistsError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, patch: CategoryPatch): Promise<Category | undefined> {
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, column] of UPDATABLE) {
|
||||
if (key in patch) {
|
||||
values.push(patch[key]);
|
||||
setClauses.push(`${column} = $${values.length}`);
|
||||
}
|
||||
}
|
||||
if (setClauses.length === 0) {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
try {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
`UPDATE categories_categories SET ${setClauses.join(', ')}, updated_at = now()
|
||||
WHERE id = $${values.length}
|
||||
RETURNING *`,
|
||||
values,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toCategory(row) : undefined;
|
||||
} catch (error) {
|
||||
if (isPgError(error, UNIQUE_VIOLATION)) {
|
||||
throw new CategorySlugAlreadyExistsError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.pool.query('DELETE FROM categories_categories WHERE id = $1', [id]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async hasChildren(id: string): Promise<boolean> {
|
||||
const result = await this.pool.query<{ exists: boolean }>(
|
||||
'SELECT EXISTS (SELECT 1 FROM categories_categories WHERE parent_id = $1)',
|
||||
[id],
|
||||
);
|
||||
return result.rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
async isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean> {
|
||||
const result = await this.pool.query<{ exists: boolean }>(
|
||||
`WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM categories_categories WHERE parent_id = $1
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories_categories c
|
||||
INNER JOIN descendants d ON c.parent_id = d.id
|
||||
)
|
||||
SELECT EXISTS (SELECT 1 FROM descendants WHERE id = $2)`,
|
||||
[candidateAncestorId, candidateDescendantId],
|
||||
);
|
||||
return result.rows[0]?.exists ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
function toCategory(row: CategoryRow): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
seoTitle: row.seo_title,
|
||||
seoDescription: row.seo_description,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function isPgError(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
121
project/src/modules/categories/tests/category-use-cases.test.ts
Normal file
121
project/src/modules/categories/tests/category-use-cases.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateCategory,
|
||||
ListCategoryTree,
|
||||
UpdateCategory,
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slug'>): Category {
|
||||
return {
|
||||
parentId: null,
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeCategoryRepository implements CategoryRepository {
|
||||
constructor(private readonly categories: Category[]) {}
|
||||
|
||||
async list(): Promise<Category[]> {
|
||||
return this.categories;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Category | undefined> {
|
||||
return this.categories.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<Category | undefined> {
|
||||
return this.categories.find((item) => item.slug === slug);
|
||||
}
|
||||
|
||||
async create(input: NewCategory): Promise<Category> {
|
||||
const created = category({
|
||||
id: `cat-${this.categories.length + 1}`,
|
||||
parentId: input.parentId ?? null,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
seoTitle: input.seoTitle ?? null,
|
||||
seoDescription: input.seoDescription ?? null,
|
||||
});
|
||||
this.categories.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, patch: CategoryPatch): Promise<Category | undefined> {
|
||||
const current = await this.findById(id);
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
Object.assign(current, patch);
|
||||
return current;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const index = this.categories.findIndex((item) => item.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
this.categories.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async hasChildren(id: string): Promise<boolean> {
|
||||
return this.categories.some((item) => item.parentId === id);
|
||||
}
|
||||
|
||||
async isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean> {
|
||||
let current = await this.findById(candidateDescendantId);
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === candidateAncestorId) {
|
||||
return true;
|
||||
}
|
||||
current = await this.findById(current.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('category use cases', () => {
|
||||
it('builds a parent/child tree', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
|
||||
category({ id: 'child', parentId: 'root', name: 'Aceites', slug: 'aceites' }),
|
||||
]);
|
||||
|
||||
const tree = await new ListCategoryTree(repo).execute();
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.slug).toBe('alimentacion');
|
||||
expect(tree[0]?.children[0]?.slug).toBe('aceites');
|
||||
});
|
||||
|
||||
it('rejects unknown parent on create', async () => {
|
||||
const repo = new FakeCategoryRepository([]);
|
||||
|
||||
await expect(
|
||||
new CreateCategory(repo).execute({ parentId: 'missing', name: 'Aceites', slug: 'aceites' }),
|
||||
).rejects.toBeInstanceOf(CategoryParentNotFoundError);
|
||||
});
|
||||
|
||||
it('rejects self-parent and descendant-as-parent updates', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
|
||||
category({ id: 'child', parentId: 'root', name: 'Aceites', slug: 'aceites' }),
|
||||
category({ id: 'grandchild', parentId: 'child', name: 'Oliva', slug: 'oliva' }),
|
||||
]);
|
||||
const update = new UpdateCategory(repo);
|
||||
|
||||
await expect(update.execute('root', { parentId: 'root' })).rejects.toBeInstanceOf(
|
||||
CategoryTreeCycleError,
|
||||
);
|
||||
await expect(update.execute('root', { parentId: 'grandchild' })).rejects.toBeInstanceOf(
|
||||
CategoryTreeCycleError,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user