diff --git a/src/lib/services/brand.service.ts b/src/lib/services/brand.service.ts index fe15e946..6e4707a6 100644 --- a/src/lib/services/brand.service.ts +++ b/src/lib/services/brand.service.ts @@ -325,15 +325,39 @@ export class BrandService { throw new Error('Brand not found'); } + // Filter out duplicates and invalid IDs + const uniqueProductIds = [...new Set(productIds.filter(Boolean))]; + if (uniqueProductIds.length === 0) { + return { updated: 0, errors: [] }; + } + + // Step 1: Find all accessible products from the provided list + const existingProducts = await prisma.product.findMany({ + where: { + id: { in: uniqueProductIds }, + storeId, + deletedAt: null, + }, + select: { id: true }, + }); + + const foundIds = new Set(existingProducts.map((p) => p.id)); const errors: string[] = []; - let updated = 0; - // Process each product - for (const productId of productIds) { + // Identify missing products for error reporting + for (const id of uniqueProductIds) { + if (!foundIds.has(id)) { + errors.push(`Product ${id} not found or not accessible`); + } + } + + // Step 2: Update all found products in a single operation + let updated = 0; + if (foundIds.size > 0) { try { const result = await prisma.product.updateMany({ where: { - id: productId, + id: { in: Array.from(foundIds) }, storeId, deletedAt: null, }, @@ -341,14 +365,14 @@ export class BrandService { brandId, }, }); - - if (result.count === 0) { - errors.push(`Product ${productId} not found or not accessible`); - } else { - updated += result.count; - } + updated = result.count; } catch (error) { - errors.push(`Failed to update product ${productId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + // In case of bulk update failure, we report it once or per product depending on preferred behavior + // Since the original code processed each product, if one failed, others might succeed. + // With updateMany, if it fails, it fails for all. + // For consistency with original error format, we'll add it once but referencing the batch. + errors.push(`Failed to update product batch: ${errorMessage}`); } } @@ -363,15 +387,40 @@ export class BrandService { productIds: string[], storeId: string ): Promise<{ updated: number; errors: string[] }> { + // Filter out duplicates and invalid IDs + const uniqueProductIds = [...new Set(productIds.filter(Boolean))]; + if (uniqueProductIds.length === 0) { + return { updated: 0, errors: [] }; + } + + // Step 1: Find all accessible products assigned to this brand + const existingProducts = await prisma.product.findMany({ + where: { + id: { in: uniqueProductIds }, + brandId, + storeId, + deletedAt: null, + }, + select: { id: true }, + }); + + const foundIds = new Set(existingProducts.map((p) => p.id)); const errors: string[] = []; - let updated = 0; - // Process each product - for (const productId of productIds) { + // Identify missing products for error reporting + for (const id of uniqueProductIds) { + if (!foundIds.has(id)) { + errors.push(`Product ${id} not found, not assigned to this brand, or not accessible`); + } + } + + // Step 2: Update all found products in a single operation + let updated = 0; + if (foundIds.size > 0) { try { const result = await prisma.product.updateMany({ where: { - id: productId, + id: { in: Array.from(foundIds) }, brandId, storeId, deletedAt: null, @@ -380,14 +429,10 @@ export class BrandService { brandId: null, }, }); - - if (result.count === 0) { - errors.push(`Product ${productId} not found, not assigned to this brand, or not accessible`); - } else { - updated += result.count; - } + updated = result.count; } catch (error) { - errors.push(`Failed to update product ${productId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + errors.push(`Failed to update product batch: ${errorMessage}`); } } diff --git a/src/test/services/brand.service.test.ts b/src/test/services/brand.service.test.ts new file mode 100644 index 00000000..df232643 --- /dev/null +++ b/src/test/services/brand.service.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { brandService } from '@/lib/services/brand.service'; +import { prismaMock } from '@/test/mocks/prisma'; + +describe('BrandService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('assignProductsToBrand', () => { + it('should assign products to a brand efficiently', async () => { + const brandId = 'brand_123'; + const storeId = 'store_123'; + const productIds = ['prod_1', 'prod_2', 'prod_3']; + + // Mock brand check + prismaMock.brand.findFirst.mockResolvedValue({ id: brandId } as any); + + // Mock findMany to return all products + prismaMock.product.findMany.mockResolvedValue( + productIds.map(id => ({ id })) as any + ); + + // Mock updateMany result + prismaMock.product.updateMany.mockResolvedValue({ count: 3 }); + + const result = await brandService.assignProductsToBrand(brandId, productIds, storeId); + + expect(result.updated).toBe(3); + expect(result.errors).toHaveLength(0); + + // Verify single call to findMany and single call to updateMany (Efficient) + expect(prismaMock.product.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.product.updateMany).toHaveBeenCalledTimes(1); + + // Verify query parameters + expect(prismaMock.product.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + id: { in: expect.arrayContaining(productIds) } + }) + })); + }); + + it('should handle missing products and still update others', async () => { + const brandId = 'brand_123'; + const storeId = 'store_123'; + const productIds = ['prod_1', 'prod_2', 'prod_3']; + + prismaMock.brand.findFirst.mockResolvedValue({ id: brandId } as any); + + // Mock findMany to return only 2 products + prismaMock.product.findMany.mockResolvedValue([ + { id: 'prod_1' }, + { id: 'prod_2' } + ] as any); + + prismaMock.product.updateMany.mockResolvedValue({ count: 2 }); + + const result = await brandService.assignProductsToBrand(brandId, productIds, storeId); + + expect(result.updated).toBe(2); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Product prod_3 not found or not accessible'); + + expect(prismaMock.product.updateMany).toHaveBeenCalledTimes(1); + expect(prismaMock.product.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + id: { in: ['prod_1', 'prod_2'] } + }) + })); + }); + }); + + describe('removeProductsFromBrand', () => { + it('should remove products from a brand efficiently', async () => { + const brandId = 'brand_123'; + const storeId = 'store_123'; + const productIds = ['prod_1', 'prod_2', 'prod_3']; + + // Mock findMany to return all products + prismaMock.product.findMany.mockResolvedValue( + productIds.map(id => ({ id })) as any + ); + + // Mock updateMany results + prismaMock.product.updateMany.mockResolvedValue({ count: 3 }); + + const result = await brandService.removeProductsFromBrand(brandId, productIds, storeId); + + expect(result.updated).toBe(3); + expect(result.errors).toHaveLength(0); + + // Verify efficient calls + expect(prismaMock.product.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.product.updateMany).toHaveBeenCalledTimes(1); + }); + + it('should handle products not assigned to brand', async () => { + const brandId = 'brand_123'; + const storeId = 'store_123'; + const productIds = ['prod_1', 'prod_2', 'prod_3']; + + // Mock findMany to return only 1 product + prismaMock.product.findMany.mockResolvedValue([ + { id: 'prod_1' } + ] as any); + + prismaMock.product.updateMany.mockResolvedValue({ count: 1 }); + + const result = await brandService.removeProductsFromBrand(brandId, productIds, storeId); + + expect(result.updated).toBe(1); + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain('Product prod_2 not found, not assigned to this brand, or not accessible'); + expect(result.errors[1]).toContain('Product prod_3 not found, not assigned to this brand, or not accessible'); + }); + }); +});