feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -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(),
};

View File

@@ -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();
}
}
}

View File

@@ -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. */

View File

@@ -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';
}
}

View File

@@ -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,
};

View File

@@ -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' }),