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 & Pick): Product { return { description: null, state: 'draft', channels: 'all', featured: false, attributes: [], seoTitle: null, seoDescription: null, categoryIds: [], brandId: null, expirationDate: null, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), ...input, }; } function image( input: Partial & Pick, ): 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 { return this.products.find((item) => item.id === id); } async findActiveBySlug(slug: string): Promise { return this.products.find((item) => item.slug === slug && item.state === 'active'); } async create(input: NewProduct): Promise { 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 { const current = await this.findById(id); if (!current) return undefined; Object.assign(current, patch); return current; } async categoriesExist(_categoryIds: readonly string[]): Promise { return true; } async brandExists(_brandId: string): Promise { return true; } async delete(_id: string): Promise { // 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 { 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 { 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 { 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 { 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 { 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 { 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); }); });