diff --git a/tests/Feature/ProductCatalogServiceTest.php b/tests/Feature/ProductCatalogServiceTest.php new file mode 100644 index 0000000..93d9fe8 --- /dev/null +++ b/tests/Feature/ProductCatalogServiceTest.php @@ -0,0 +1,1077 @@ +service = app(ProductCatalogService::class); + + // Create entity hierarchy: M1 -> M2 -> M3 + $this->m1 = Entity::createMaster('ACME', 'Acme Corporation'); + $this->m2 = $this->m1->createFacade('SHOP', 'Acme Shop'); + $this->m3 = $this->m2->createDropshipper('DROP', 'Dropship Partner'); +}); + +// ────────────────────────────────────────────────────────────────── +// Product CRUD +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → createProduct', function () { + it('creates a product owned by an M1 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'WIDGET-100', + 'name' => 'Super Widget', + 'description' => 'A very fine widget.', + 'price' => 2500, + 'currency' => 'GBP', + 'type' => Product::TYPE_SIMPLE, + ]); + + expect($product)->toBeInstanceOf(Product::class) + ->and($product->exists)->toBeTrue() + ->and($product->owner_entity_id)->toBe($this->m1->id) + ->and($product->sku)->toBe('WIDGET-100') + ->and($product->name)->toBe('Super Widget') + ->and($product->price)->toBe(2500); + }); + + it('upper-cases the SKU on creation', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'lower-sku', + 'name' => 'Lowercase SKU Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + expect($product->sku)->toBe('LOWER-SKU'); + }); + + it('generates a SKU when none is provided', function () { + $product = $this->service->createProduct($this->m1, [ + 'name' => 'Auto SKU Product', + 'price' => 500, + 'currency' => 'GBP', + ]); + + expect($product->sku)->not->toBeEmpty() + ->and($product->sku)->toStartWith('ACME-'); + }); + + it('throws when a non-M1 entity tries to own a product', function () { + $this->service->createProduct($this->m2, [ + 'sku' => 'NOPE', + 'name' => 'Forbidden', + 'price' => 100, + ]); + })->throws(InvalidArgumentException::class, 'Only M1 (Master) entities can own products'); + + it('throws when an M3 entity tries to own a product', function () { + $this->service->createProduct($this->m3, [ + 'sku' => 'NOPE', + 'name' => 'Forbidden', + 'price' => 100, + ]); + })->throws(InvalidArgumentException::class, 'Only M1 (Master) entities can own products'); +}); + +describe('ProductCatalogService → updateProduct', function () { + it('updates mutable product fields', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'UPD-001', + 'name' => 'Original Name', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $updated = $this->service->updateProduct($product, [ + 'name' => 'Updated Name', + 'price' => 1500, + 'description' => 'Now with a description.', + ]); + + expect($updated->name)->toBe('Updated Name') + ->and($updated->price)->toBe(1500) + ->and($updated->description)->toBe('Now with a description.'); + }); + + it('prevents SKU from being changed', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'IMMUTABLE', + 'name' => 'Immutable SKU Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $updated = $this->service->updateProduct($product, [ + 'sku' => 'CHANGED', + 'name' => 'New Name', + ]); + + expect($updated->sku)->toBe('IMMUTABLE') + ->and($updated->name)->toBe('New Name'); + }); + + it('prevents owner_entity_id from being changed', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'OWNER-LOCK', + 'name' => 'Owner Lock Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $otherM1 = Entity::createMaster('OTHER', 'Other Corp'); + + $updated = $this->service->updateProduct($product, [ + 'owner_entity_id' => $otherM1->id, + ]); + + expect($updated->owner_entity_id)->toBe($this->m1->id); + }); +}); + +describe('ProductCatalogService → deleteProduct', function () { + it('soft-deletes a product', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'DEL-001', + 'name' => 'Deletable Product', + 'price' => 999, + 'currency' => 'GBP', + ]); + + $result = $this->service->deleteProduct($product); + + expect($result)->toBeTrue() + ->and(Product::find($product->id))->toBeNull() + ->and(Product::withTrashed()->find($product->id))->not->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// SKU management +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → SKU management', function () { + it('builds full SKU with entity lineage for M1', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PROD500', + 'name' => 'SKU Lineage Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $effective = $this->service->getEffectiveProduct($this->m1, $product); + + expect($effective['sku'])->toBe('ACME-PROD500'); + }); + + it('builds full SKU with entity lineage for M2 assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PROD500', + 'name' => 'SKU Lineage Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['sku'])->toContain('SHOP'); + }); + + it('uses sku_suffix override when set on assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PROD500', + 'name' => 'SKU Override Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'sku_suffix' => 'CUSTOM', + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['sku'])->toContain('CUSTOM'); + }); + + it('resolves a valid SKU to its product and entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'RESOLVE', + 'name' => 'Resolvable Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $result = $this->service->resolveSku('ACME-RESOLVE'); + + expect($result)->not->toBeNull() + ->and($result['product']->id)->toBe($product->id) + ->and($result['entity']->id)->toBe($this->m1->id) + ->and($result['assignment'])->toBeNull(); // M1 owns directly + }); + + it('returns null for an unresolvable SKU', function () { + $result = $this->service->resolveSku('UNKNOWN-DOESNOTEXIST'); + + expect($result)->toBeNull(); + }); + + it('returns null for a SKU with fewer than two parts', function () { + $result = $this->service->resolveSku('SINGLE'); + + expect($result)->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Pricing +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → pricing', function () { + it('returns base price for M1 effective product', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-001', + 'name' => 'Base Price Product', + 'price' => 4999, + 'currency' => 'GBP', + ]); + + $effective = $this->service->getEffectiveProduct($this->m1, $product); + + expect($effective['price'])->toBe(4999); + }); + + it('returns base price when no price override on assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-002', + 'name' => 'Inherited Price Product', + 'price' => 3000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['price'])->toBe(3000); + }); + + it('returns overridden price when price_override is set', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-003', + 'name' => 'Price Override Product', + 'price' => 3000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 3500, + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['price'])->toBe(3500); + }); + + it('returns null effective product when M2 has no assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-004', + 'name' => 'Unassigned Product', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective)->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Product assignment +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → assignProduct', function () { + it('assigns a product to an M2 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-001', + 'name' => 'Assignable Product', + 'price' => 1500, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product); + + expect($assignment)->toBeInstanceOf(ProductAssignment::class) + ->and($assignment->entity_id)->toBe($this->m2->id) + ->and($assignment->product_id)->toBe($product->id); + }); + + it('assigns a product to an M3 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-002', + 'name' => 'M3 Product', + 'price' => 1500, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m3, $product); + + expect($assignment)->toBeInstanceOf(ProductAssignment::class) + ->and($assignment->entity_id)->toBe($this->m3->id); + }); + + it('throws when assigning to an M1 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-003', + 'name' => 'M1 No-Assign Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m1, $product); + })->throws(InvalidArgumentException::class, 'M1 entities own products directly'); + + it('updates existing assignment instead of creating duplicate', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-004', + 'name' => 'Duplicate Guard Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $first = $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1200, + ]); + + $second = $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1500, + ]); + + expect($first->id)->toBe($second->id) + ->and($second->price_override)->toBe(1500); + + // Only one assignment row should exist + $count = ProductAssignment::where('entity_id', $this->m2->id) + ->where('product_id', $product->id) + ->count(); + expect($count)->toBe(1); + }); + + it('accepts overrides on assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-005', + 'name' => 'Override Product', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 2500, + 'name_override' => 'Custom Shop Name', + 'is_featured' => true, + ]); + + expect($assignment->price_override)->toBe(2500) + ->and($assignment->name_override)->toBe('Custom Shop Name') + ->and($assignment->is_featured)->toBeTrue(); + }); +}); + +describe('ProductCatalogService → updateAssignment', function () { + it('updates overrides on an existing assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'UPASSIGN-001', + 'name' => 'Updatable Assignment Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product); + + $updated = $this->service->updateAssignment($assignment, [ + 'price_override' => 1200, + 'description_override' => 'Custom description', + ]); + + expect($updated->price_override)->toBe(1200) + ->and($updated->description_override)->toBe('Custom description'); + }); +}); + +describe('ProductCatalogService → removeAssignment', function () { + it('removes a product assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'RMASSIGN-001', + 'name' => 'Removable Assignment Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product); + $assignmentId = $assignment->id; + + $result = $this->service->removeAssignment($assignment); + + expect($result)->toBeTrue() + ->and(ProductAssignment::find($assignmentId))->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Bulk operations +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → bulkAssign', function () { + it('assigns multiple products to an entity at once', function () { + $products = collect(['BULK-A', 'BULK-B', 'BULK-C'])->map( + fn (string $sku) => $this->service->createProduct($this->m1, [ + 'sku' => $sku, + 'name' => "Bulk Product {$sku}", + 'price' => 1000, + 'currency' => 'GBP', + ]) + ); + + $count = $this->service->bulkAssign( + $this->m2, + $products->pluck('id')->toArray() + ); + + expect($count)->toBe(3); + + $assignments = ProductAssignment::where('entity_id', $this->m2->id)->count(); + expect($assignments)->toBe(3); + }); + + it('applies default overrides to all bulk assignments', function () { + $products = collect(['BULKD-A', 'BULKD-B'])->map( + fn (string $sku) => $this->service->createProduct($this->m1, [ + 'sku' => $sku, + 'name' => "Bulk Default {$sku}", + 'price' => 1000, + 'currency' => 'GBP', + ]) + ); + + $this->service->bulkAssign( + $this->m2, + $products->pluck('id')->toArray(), + ['is_featured' => true] + ); + + $featured = ProductAssignment::where('entity_id', $this->m2->id) + ->where('is_featured', true) + ->count(); + + expect($featured)->toBe(2); + }); + + it('skips non-existent product IDs without error', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'BULK-REAL', + 'name' => 'Real Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $count = $this->service->bulkAssign( + $this->m2, + [$product->id, 99999, 88888] + ); + + expect($count)->toBe(1); + }); +}); + +describe('ProductCatalogService → copyAssignments', function () { + it('copies all assignments from source to target entity', function () { + $productA = $this->service->createProduct($this->m1, [ + 'sku' => 'COPY-A', + 'name' => 'Copy Product A', + 'price' => 1000, + 'currency' => 'GBP', + ]); + $productB = $this->service->createProduct($this->m1, [ + 'sku' => 'COPY-B', + 'name' => 'Copy Product B', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $productA, [ + 'price_override' => 1200, + 'name_override' => 'Custom A', + ]); + $this->service->assignProduct($this->m2, $productB, [ + 'is_featured' => true, + ]); + + $m3b = $this->m2->createDropshipper('DRP2', 'Dropship Partner 2'); + $count = $this->service->copyAssignments($this->m2, $m3b); + + expect($count)->toBe(2); + + $targetAssignments = ProductAssignment::where('entity_id', $m3b->id)->get(); + expect($targetAssignments)->toHaveCount(2); + }); + + it('includes overrides when flag is true', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'COPYOV-A', + 'name' => 'Copy Override Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1500, + 'name_override' => 'Overridden Name', + 'is_featured' => true, + ]); + + $m3b = $this->m2->createDropshipper('DRPC', 'Dropship Copy'); + $this->service->copyAssignments($this->m2, $m3b, includeOverrides: true); + + $copied = ProductAssignment::where('entity_id', $m3b->id) + ->where('product_id', $product->id) + ->first(); + + expect($copied->price_override)->toBe(1500) + ->and($copied->name_override)->toBe('Overridden Name') + ->and($copied->is_featured)->toBeTrue(); + }); + + it('excludes overrides when flag is false', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'COPYNO-A', + 'name' => 'Copy No Override Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1500, + 'name_override' => 'Overridden Name', + ]); + + $m3b = $this->m2->createDropshipper('DRPN', 'Dropship No Overrides'); + $this->service->copyAssignments($this->m2, $m3b, includeOverrides: false); + + $copied = ProductAssignment::where('entity_id', $m3b->id) + ->where('product_id', $product->id) + ->first(); + + expect($copied->price_override)->toBeNull() + ->and($copied->name_override)->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Category and catalogue queries +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getProductsForEntity', function () { + it('returns owned products for M1', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'CAT-A', + 'name' => 'Product A', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'CAT-B', + 'name' => 'Product B', + 'price' => 2000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $products = $this->service->getProductsForEntity($this->m1); + + expect($products)->toHaveCount(2); + }); + + it('excludes inactive products for M1 when activeOnly is true', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'ACT-A', + 'name' => 'Active Product', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'ACT-B', + 'name' => 'Inactive Product', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => false, + 'is_visible' => true, + ]); + + $products = $this->service->getProductsForEntity($this->m1, activeOnly: true); + + expect($products)->toHaveCount(1) + ->and($products->first()->name)->toBe('Active Product'); + }); + + it('includes inactive products when activeOnly is false', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'ALL-A', + 'name' => 'Active', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'ALL-B', + 'name' => 'Inactive', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => false, + 'is_visible' => true, + ]); + + $products = $this->service->getProductsForEntity($this->m1, activeOnly: false); + + expect($products)->toHaveCount(2); + }); + + it('returns assigned products for M2', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'M2-A', + 'name' => 'M2 Visible Product', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->service->assignProduct($this->m2, $product, ['is_active' => true]); + + $assignments = $this->service->getProductsForEntity($this->m2); + + expect($assignments)->toHaveCount(1); + }); +}); + +describe('ProductCatalogService → getByCategory', function () { + it('returns M1 products filtered by category', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'ELEC-001', + 'name' => 'Laptop', + 'price' => 99900, + 'currency' => 'GBP', + 'category' => 'electronics', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'FURN-001', + 'name' => 'Desk', + 'price' => 29900, + 'currency' => 'GBP', + 'category' => 'furniture', + 'is_active' => true, + 'is_visible' => true, + ]); + + $electronics = $this->service->getByCategory($this->m1, 'electronics'); + + expect($electronics)->toHaveCount(1) + ->and($electronics->first()->name)->toBe('Laptop'); + }); + + it('returns M2 assigned products filtered by category', function () { + $laptop = $this->service->createProduct($this->m1, [ + 'sku' => 'ELEC-002', + 'name' => 'Tablet', + 'price' => 49900, + 'currency' => 'GBP', + 'category' => 'electronics', + 'is_active' => true, + 'is_visible' => true, + ]); + $desk = $this->service->createProduct($this->m1, [ + 'sku' => 'FURN-002', + 'name' => 'Chair', + 'price' => 19900, + 'currency' => 'GBP', + 'category' => 'furniture', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->service->assignProduct($this->m2, $laptop, ['is_active' => true]); + $this->service->assignProduct($this->m2, $desk, ['is_active' => true]); + + $electronics = $this->service->getByCategory($this->m2, 'electronics'); + + expect($electronics)->toHaveCount(1); + }); + + it('returns empty collection when no products match category', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'NOCAT-001', + 'name' => 'Widget', + 'price' => 500, + 'currency' => 'GBP', + 'category' => 'widgets', + 'is_active' => true, + 'is_visible' => true, + ]); + + $result = $this->service->getByCategory($this->m1, 'nonexistent'); + + expect($result)->toHaveCount(0); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Search +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → searchProducts', function () { + it('finds products by name', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-001', + 'name' => 'Wireless Headphones', + 'price' => 7999, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-002', + 'name' => 'Wired Mouse', + 'price' => 1999, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'Wireless'); + + expect($results)->toHaveCount(1) + ->and($results->first()->name)->toBe('Wireless Headphones'); + }); + + it('finds products by SKU', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-ABC', + 'name' => 'SKU Search Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'SRCH-ABC'); + + expect($results)->toHaveCount(1) + ->and($results->first()->sku)->toBe('SRCH-ABC'); + }); + + it('respects the limit parameter', function () { + for ($i = 1; $i <= 5; $i++) { + $this->service->createProduct($this->m1, [ + 'sku' => "LIM-{$i}", + 'name' => "Limited Product {$i}", + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + } + + $results = $this->service->searchProducts($this->m1, 'Limited', limit: 3); + + expect($results)->toHaveCount(3); + }); + + it('returns empty collection for non-matching query', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-NOM', + 'name' => 'Real Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'xyznonexistent'); + + expect($results)->toHaveCount(0); + }); + + it('only returns products owned by the given entity', function () { + $otherM1 = Entity::createMaster('OTHER', 'Other Corp'); + + $this->service->createProduct($this->m1, [ + 'sku' => 'MINE-001', + 'name' => 'Shared Name Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($otherM1, [ + 'sku' => 'THEIRS-001', + 'name' => 'Shared Name Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'Shared Name'); + + expect($results)->toHaveCount(1) + ->and($results->first()->sku)->toBe('MINE-001'); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Featured products +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getFeaturedProducts', function () { + it('returns featured products for M1', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'FEAT-A', + 'name' => 'Featured Product', + 'price' => 2000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + 'is_featured' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'FEAT-B', + 'name' => 'Normal Product', + 'price' => 1500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + 'is_featured' => false, + ]); + + $featured = $this->service->getFeaturedProducts($this->m1); + + expect($featured)->toHaveCount(1) + ->and($featured->first()->name)->toBe('Featured Product'); + }); + + it('returns featured assignments for M2', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'FEAT-M2A', + 'name' => 'Featured M2 Product', + 'price' => 2000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'is_active' => true, + 'is_featured' => true, + ]); + + $featured = $this->service->getFeaturedProducts($this->m2); + + expect($featured)->toHaveCount(1); + }); + + it('respects limit parameter', function () { + for ($i = 1; $i <= 5; $i++) { + $this->service->createProduct($this->m1, [ + 'sku' => "FLIM-{$i}", + 'name' => "Featured Limited {$i}", + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + 'is_featured' => true, + ]); + } + + $featured = $this->service->getFeaturedProducts($this->m1, limit: 3); + + expect($featured)->toHaveCount(3); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Product statistics +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getProductStats', function () { + it('returns correct stats for M1 entity', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'STAT-A', + 'name' => 'Active Featured In-Stock', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_featured' => true, + 'track_stock' => true, + 'stock_quantity' => 50, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'STAT-B', + 'name' => 'Active Not-Featured In-Stock', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_featured' => false, + 'track_stock' => true, + 'stock_quantity' => 10, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'STAT-C', + 'name' => 'Inactive Out-of-Stock', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => false, + 'track_stock' => true, + 'stock_quantity' => 0, + 'stock_status' => Product::STOCK_OUT, + ]); + + $stats = $this->service->getProductStats($this->m1); + + expect($stats['total'])->toBe(3) + ->and($stats['active'])->toBe(2) + ->and($stats['featured'])->toBe(1) + ->and($stats['out_of_stock'])->toBe(1); + }); + + it('returns correct stats for M2 entity', function () { + $productA = $this->service->createProduct($this->m1, [ + 'sku' => 'STAM2-A', + 'name' => 'M2 Stat Product A', + 'price' => 1000, + 'currency' => 'GBP', + ]); + $productB = $this->service->createProduct($this->m1, [ + 'sku' => 'STAM2-B', + 'name' => 'M2 Stat Product B', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $productA, [ + 'is_active' => true, + 'is_featured' => true, + ]); + $this->service->assignProduct($this->m2, $productB, [ + 'is_active' => false, + 'is_featured' => false, + ]); + + $stats = $this->service->getProductStats($this->m2); + + expect($stats['total'])->toBe(2) + ->and($stats['active'])->toBe(1) + ->and($stats['featured'])->toBe(1); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Effective product data +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getEffectiveProduct', function () { + it('returns full effective data for M1 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'EFF-001', + 'name' => 'Effective Product', + 'description' => 'A product with all fields.', + 'price' => 4500, + 'currency' => 'GBP', + 'image_url' => 'https://example.com/image.jpg', + 'track_stock' => true, + 'stock_quantity' => 25, + ]); + + $effective = $this->service->getEffectiveProduct($this->m1, $product); + + expect($effective)->toBeArray() + ->and($effective['product']->id)->toBe($product->id) + ->and($effective['assignment'])->toBeNull() + ->and($effective['price'])->toBe(4500) + ->and($effective['name'])->toBe('Effective Product') + ->and($effective['description'])->toBe('A product with all fields.') + ->and($effective['image'])->toBe('https://example.com/image.jpg') + ->and($effective['available_stock'])->toBe(25); + }); + + it('returns effective data with overrides for M2 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'EFF-002', + 'name' => 'Master Product', + 'description' => 'Master description.', + 'price' => 3000, + 'currency' => 'GBP', + 'image_url' => 'https://example.com/master.jpg', + 'track_stock' => true, + 'stock_quantity' => 100, + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 3500, + 'name_override' => 'Shop Product', + 'description_override' => 'Shop description.', + 'image_override' => 'https://example.com/shop.jpg', + 'allocated_stock' => 20, + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective)->toBeArray() + ->and($effective['product']->id)->toBe($product->id) + ->and($effective['assignment'])->not->toBeNull() + ->and($effective['price'])->toBe(3500) + ->and($effective['name'])->toBe('Shop Product') + ->and($effective['description'])->toBe('Shop description.') + ->and($effective['image'])->toBe('https://example.com/shop.jpg') + ->and($effective['available_stock'])->toBe(20); + }); + + it('falls back to master values when no overrides set', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'EFF-003', + 'name' => 'Fallback Product', + 'description' => 'Fallback description.', + 'price' => 2000, + 'currency' => 'GBP', + 'image_url' => 'https://example.com/fallback.jpg', + 'track_stock' => true, + 'stock_quantity' => 50, + ]); + + $this->service->assignProduct($this->m2, $product); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['price'])->toBe(2000) + ->and($effective['name'])->toBe('Fallback Product') + ->and($effective['description'])->toBe('Fallback description.') + ->and($effective['image'])->toBe('https://example.com/fallback.jpg') + ->and($effective['available_stock'])->toBe(50); + }); +});