feat(ADM-018): completed feature
This commit is contained in:
187
project/src/modules/catalog/tests/image-use-cases.test.ts
Normal file
187
project/src/modules/catalog/tests/image-use-cases.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user