201 lines
6.2 KiB
TypeScript
201 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,
|
|
expirationDate: 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);
|
|
});
|
|
});
|