Files
mercadodevida/project/src/modules/catalog/tests/image-use-cases.test.ts
2026-08-19 15:17:51 +02:00

200 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AttachProductImage, ListProductImages } from '../application/image-use-cases.js';
import { ProductImageVariantMismatchError } from '../domain/errors.js';
import type { NewProductImage, ProductImage, ProductImageOrderItem } from '../domain/image.js';
import type { NewProduct, Product, ProductPatch } from '../domain/product.js';
import type { ProductImageRepository, ProductRepository } from '../domain/ports.js';
function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>): Product {
return {
description: null,
state: 'draft',
channels: 'all',
featured: false,
attributes: [],
seoTitle: null,
seoDescription: null,
categoryIds: [],
brandId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
function image(
input: Partial<ProductImage> & Pick<ProductImage, 'id' | 'productId' | 'url'>,
): ProductImage {
return {
variantId: null,
altText: 'Alt text',
position: 0,
role: 'gallery',
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeProductRepository implements ProductRepository {
constructor(private readonly products: Product[]) {}
async findById(id: string): Promise<Product | undefined> {
return this.products.find((item) => item.id === id);
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
return this.products.find((item) => item.slug === slug && item.state === 'active');
}
async create(input: NewProduct): Promise<Product> {
const created = product({
id: `prod-${this.products.length + 1}`,
name: input.name,
slug: input.slug,
});
this.products.push(created);
return created;
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const current = await this.findById(id);
if (!current) return undefined;
Object.assign(current, patch);
return current;
}
async categoriesExist(_categoryIds: readonly string[]): Promise<boolean> {
return true;
}
async brandExists(_brandId: string): Promise<boolean> {
return true;
}
async delete(_id: string): Promise<void> {
// noop for tests
}
async listAll(options?: {
limit?: number;
offset?: number;
q?: string;
}): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
let items = [...this.products];
if (options?.q)
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}
class FakeImageRepository implements ProductImageRepository {
constructor(
private readonly images: ProductImage[],
private readonly validVariants: readonly string[] = [],
) {}
async listByProductId(
productId: string,
variantId: string | null = null,
): Promise<ProductImage[]> {
return this.images
.filter((item) => item.productId === productId && item.variantId === variantId)
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
}
async listByProductIds(productIds: readonly string[]): Promise<ProductImage[]> {
const set = new Set(productIds);
return this.images
.filter((item) => set.has(item.productId))
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
}
async attach(productId: string, input: NewProductImage): Promise<ProductImage> {
const created = image({
id: `img-${this.images.length + 1}`,
productId,
variantId: input.variantId ?? null,
url: input.url,
altText: input.altText,
position: input.position ?? 0,
role: input.role,
});
this.images.push(created);
return created;
}
async detach(productId: string, imageId: string): Promise<boolean> {
const index = this.images.findIndex(
(item) => item.productId === productId && item.id === imageId,
);
if (index < 0) return false;
this.images.splice(index, 1);
return true;
}
async reorder(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
for (const item of items) {
const current = this.images.find(
(imageItem) => imageItem.productId === productId && imageItem.id === item.imageId,
);
if (current) current.position = item.position;
}
return this.listByProductId(productId);
}
async variantBelongsToProduct(_productId: string, variantId: string): Promise<boolean> {
return this.validVariants.includes(variantId);
}
}
describe('product image use cases', () => {
it('lists product images in stable order with alt text', async () => {
const images = new FakeImageRepository([
image({ id: 'b', productId: 'prod', url: '/b.jpg', altText: 'Second', position: 2 }),
image({ id: 'a', productId: 'prod', url: '/a.jpg', altText: 'First', position: 1 }),
]);
const result = await new ListProductImages(images).execute('prod');
expect(result.map((item) => ({ url: item.url, altText: item.altText }))).toEqual([
{ url: '/a.jpg', altText: 'First' },
{ url: '/b.jpg', altText: 'Second' },
]);
});
it('returns undefined when attaching an image to a missing product', async () => {
const products = new FakeProductRepository([]);
const images = new FakeImageRepository([]);
const result = await new AttachProductImage(products, images).execute('missing', {
url: '/image.jpg',
altText: 'Image',
role: 'main',
});
expect(result).toBeUndefined();
});
it('rejects a variant image when the variant does not belong to the product', async () => {
const products = new FakeProductRepository([
product({ id: 'prod', name: 'Aceite', slug: 'aceite' }),
]);
const images = new FakeImageRepository([], ['variant-owned-by-prod']);
await expect(
new AttachProductImage(products, images).execute('prod', {
url: '/variant.jpg',
altText: 'Variant',
role: 'gallery',
variantId: 'other-variant',
}),
).rejects.toBeInstanceOf(ProductImageVariantMismatchError);
});
});