feat(F-048): completed feature
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateCategory,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryTreeNode } from '../domain/category.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategorySlugAlreadyExistsError,
|
||||
CategoryTreeCycleError,
|
||||
@@ -38,6 +41,7 @@ const newCategorySchema = z.object({
|
||||
slug: slugSchema,
|
||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
isParent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const categoryPatchSchema = newCategorySchema
|
||||
@@ -57,12 +61,23 @@ export async function registerCategoriesRoutes(
|
||||
const updateCategory = new UpdateCategory(repository);
|
||||
const deleteCategory = new DeleteCategory(repository);
|
||||
|
||||
app.get('/categories/tree', async (_request, reply) => {
|
||||
const treeSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Category tree (público)',
|
||||
description: 'Devuelve el árbol completo de categorías.',
|
||||
};
|
||||
app.get('/categories/tree', { schema: treeSchema }, async (_request, reply) => {
|
||||
const items = await listTree.execute();
|
||||
return reply.send({ items: items.map(serializeTreeNode) });
|
||||
});
|
||||
|
||||
app.get('/categoria/:slug', async (request, reply) => {
|
||||
const publicCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Get category by slug (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/categoria/:slug', { schema: publicCatSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const category = await getBySlug.execute(slug);
|
||||
if (!category) {
|
||||
@@ -71,7 +86,13 @@ export async function registerCategoriesRoutes(
|
||||
return reply.send(serializeCategory(category));
|
||||
});
|
||||
|
||||
app.post('/categories', async (request, reply) => {
|
||||
const createCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Create category (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/categories', { schema: createCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newCategorySchema, request.body);
|
||||
@@ -83,7 +104,18 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/categories/:id', async (request, reply) => {
|
||||
const updateCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Update category (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/categories/:id', { schema: updateCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -99,7 +131,17 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/categories/:id', async (request, reply) => {
|
||||
const deleteCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Delete category (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete('/categories/:id', { schema: deleteCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -124,6 +166,9 @@ function mapCategoryError(error: unknown): Error {
|
||||
if (error instanceof CategoryTreeCycleError) {
|
||||
return new AppError(422, 'CATEGORY_TREE_CYCLE', error.message);
|
||||
}
|
||||
if (error instanceof CategoryParentNotContainerError) {
|
||||
return new AppError(422, 'CATEGORY_PARENT_NOT_CONTAINER', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown category error');
|
||||
}
|
||||
|
||||
@@ -136,6 +181,7 @@ function serializeCategory(category: Category) {
|
||||
url: `/categoria/${category.slug}`,
|
||||
seoTitle: category.seoTitle,
|
||||
seoDescription: category.seoDescription,
|
||||
isParent: category.isParent,
|
||||
createdAt: category.createdAt.toISOString(),
|
||||
updatedAt: category.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Category, CategoryPatch, CategoryTreeNode, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
export class GetCategoryBySlug {
|
||||
@@ -22,11 +26,11 @@ export class CreateCategory {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(input: NewCategory): Promise<Category> {
|
||||
await this.assertParentExists(input.parentId);
|
||||
await this.assertValidParent(input.parentId);
|
||||
return this.categories.create(input);
|
||||
}
|
||||
|
||||
private async assertParentExists(parentId: string | null | undefined): Promise<void> {
|
||||
private async assertValidParent(parentId: string | null | undefined): Promise<void> {
|
||||
if (parentId === undefined || parentId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -34,6 +38,9 @@ export class CreateCategory {
|
||||
if (!parent) {
|
||||
throw new CategoryParentNotFoundError();
|
||||
}
|
||||
if (!parent.isParent) {
|
||||
throw new CategoryParentNotContainerError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +69,9 @@ export class UpdateCategory {
|
||||
if (parentIsDescendant) {
|
||||
throw new CategoryTreeCycleError();
|
||||
}
|
||||
if (!parent.isParent) {
|
||||
throw new CategoryParentNotContainerError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Category {
|
||||
slug: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
|
||||
isParent: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -18,6 +20,7 @@ export interface NewCategory {
|
||||
slug: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
isParent?: boolean;
|
||||
}
|
||||
|
||||
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
|
||||
|
||||
@@ -18,3 +18,12 @@ export class CategoryTreeCycleError extends Error {
|
||||
this.name = 'CategoryTreeCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CategoryParentNotContainerError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Category parent must be a parent (container) category; a child cannot contain categories',
|
||||
);
|
||||
this.name = 'CategoryParentNotContainerError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface CategoryRow {
|
||||
slug: string;
|
||||
seo_title: string | null;
|
||||
seo_description: string | null;
|
||||
is_parent: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
|
||||
['slug', 'slug'],
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
['isParent', 'is_parent'],
|
||||
];
|
||||
|
||||
export class PgCategoryRepository implements CategoryRepository {
|
||||
@@ -55,8 +57,8 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
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)
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.parentId ?? null,
|
||||
@@ -64,6 +66,7 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
input.slug,
|
||||
input.seoTitle ?? null,
|
||||
input.seoDescription ?? null,
|
||||
input.isParent ?? false,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -146,6 +149,7 @@ function toCategory(row: CategoryRow): Category {
|
||||
slug: row.slug,
|
||||
seoTitle: row.seo_title,
|
||||
seoDescription: row.seo_description,
|
||||
isParent: row.is_parent,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
UpdateCategory,
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slug'>): Category {
|
||||
@@ -13,6 +17,7 @@ function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slu
|
||||
parentId: null,
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
isParent: false,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
@@ -42,6 +47,7 @@ class FakeCategoryRepository implements CategoryRepository {
|
||||
slug: input.slug,
|
||||
seoTitle: input.seoTitle ?? null,
|
||||
seoDescription: input.seoDescription ?? null,
|
||||
isParent: input.isParent ?? false,
|
||||
});
|
||||
this.categories.push(created);
|
||||
return created;
|
||||
@@ -103,6 +109,16 @@ describe('category use cases', () => {
|
||||
).rejects.toBeInstanceOf(CategoryParentNotFoundError);
|
||||
});
|
||||
|
||||
it('rejects a leaf category as parent', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'leaf', name: 'Aceites', slug: 'aceites', isParent: false }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
new CreateCategory(repo).execute({ parentId: 'leaf', name: 'Oliva', slug: 'oliva' }),
|
||||
).rejects.toBeInstanceOf(CategoryParentNotContainerError);
|
||||
});
|
||||
|
||||
it('rejects self-parent and descendant-as-parent updates', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
|
||||
|
||||
Reference in New Issue
Block a user