Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ class GetProductsHandler
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly ProductFilterService $productFilterService,
)
{
}
private readonly ProductFilterService $productFilterService,
) {}

public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwarePaginator
{
Expand All @@ -25,10 +23,9 @@ public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwar
->loadRelation(TaxAndFeesDomainObject::class)
->findByEventId($eventId, $queryParamsDTO);

$filteredProducts = $this->productFilterService->filter(
productsCategories: $productPaginator->getCollection(),
$filteredProducts = $this->productFilterService->filterProducts(
products: $productPaginator->getCollection(),
hideSoldOutProducts: false,
hideHiddenCategories: false,
);

$productPaginator->setCollection($filteredProducts);
Expand Down
144 changes: 75 additions & 69 deletions backend/app/Services/Domain/Product/ProductFilterService.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,71 +23,83 @@
class ProductFilterService
{
private ?AccountConfigurationDomainObject $accountConfiguration = null;

private ?EventSettingDomainObject $eventSettings = null;

private ?string $eventCurrency = null;

public function __construct(
private readonly TaxAndFeeCalculationService $taxCalculationService,
private readonly ProductPriceService $productPriceService,
private readonly TaxAndFeeCalculationService $taxCalculationService,
private readonly ProductPriceService $productPriceService,
private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
private readonly OrderPlatformFeePassThroughService $platformFeeService,
private readonly AccountRepositoryInterface $accountRepository,
private readonly EventRepositoryInterface $eventRepository,
)
{
}
private readonly OrderPlatformFeePassThroughService $platformFeeService,
private readonly AccountRepositoryInterface $accountRepository,
private readonly EventRepositoryInterface $eventRepository,
) {}

/**
* @param Collection<ProductCategoryDomainObject> $productsCategories
* @param PromoCodeDomainObject|null $promoCode
* @param bool $hideSoldOutProducts
* @param bool $hideHiddenCategories
* @param Collection<ProductCategoryDomainObject> $productsCategories
* @return Collection<ProductCategoryDomainObject>
*/
public function filter(
Collection $productsCategories,
Collection $productsCategories,
?PromoCodeDomainObject $promoCode = null,
bool $hideSoldOutProducts = true,
bool $hideHiddenCategories = true,
): Collection
{
bool $hideSoldOutProducts = true,
bool $hideHiddenCategories = true,
): Collection {
if ($productsCategories->isEmpty()) {
return $productsCategories;
}

$products = $productsCategories
->flatMap(fn(ProductCategoryDomainObject $category) => $category->getProducts());
->flatMap(fn (ProductCategoryDomainObject $category) => $category->getProducts());

if ($products->isEmpty()) {
return $hideHiddenCategories
? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden())
? $productsCategories->reject(fn (ProductCategoryDomainObject $category) => $category->getIsHidden())
: $productsCategories;
}

$eventId = $products->first()->getEventId();
$this->loadAccountConfiguration($eventId);

$productQuantities = $this
->fetchAvailableProductQuantitiesService
->getAvailableProductQuantities($eventId);

$filteredProducts = $products
->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode))
->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts))
->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts));
$filteredProducts = $this->filterProducts($products, $promoCode, $hideSoldOutProducts);

$filteredCategories = $hideHiddenCategories
? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden())
? $productsCategories->reject(fn (ProductCategoryDomainObject $category) => $category->getIsHidden())
: $productsCategories;

return $filteredCategories
->each(fn(ProductCategoryDomainObject $category) => $category->setProducts(
->each(fn (ProductCategoryDomainObject $category) => $category->setProducts(
$filteredProducts->where(
static fn(ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId()
static fn (ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId()
)
));
}

/**
* @param Collection<ProductDomainObject> $products
* @return Collection<ProductDomainObject>
*/
public function filterProducts(
Collection $products,
?PromoCodeDomainObject $promoCode = null,
bool $hideSoldOutProducts = true,
): Collection {
if ($products->isEmpty()) {
return $products;
}

$eventId = $products->first()->getEventId();
$this->loadAccountConfiguration($eventId);

$productQuantities = $this
->fetchAvailableProductQuantitiesService
->getAvailableProductQuantities($eventId);

return $products
->map(fn (ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode))
->reject(fn (ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts))
->each(fn (ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts));
}

private function loadAccountConfiguration(int $eventId): void
{
$account = $this->accountRepository
Expand All @@ -109,10 +121,10 @@ private function loadAccountConfiguration(int $eventId): void

private function isHiddenByPromoCode(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): bool
{
return $product->getIsHiddenWithoutPromoCode() && !(
$promoCode
&& $promoCode->appliesToProduct($product)
);
return $product->getIsHiddenWithoutPromoCode() && ! (
$promoCode
&& $promoCode->appliesToProduct($product)
);
}

private function shouldProductBeDiscounted(?PromoCodeDomainObject $promoCode, ProductDomainObject $product): bool
Expand All @@ -127,17 +139,13 @@ private function shouldProductBeDiscounted(?PromoCodeDomainObject $promoCode, Pr
}

/**
* @param PromoCodeDomainObject|null $promoCode
* @param ProductDomainObject $product
* @param Collection<AvailableProductQuantitiesDTO> $productQuantities
* @return ProductDomainObject
* @param Collection<AvailableProductQuantitiesDTO> $productQuantities
*/
private function processProduct(
ProductDomainObject $product,
Collection $productQuantities,
ProductDomainObject $product,
Collection $productQuantities,
?PromoCodeDomainObject $promoCode = null,
): ProductDomainObject
{
): ProductDomainObject {
if ($this->shouldProductBeDiscounted($promoCode, $product)) {
$product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode) {
$price->setPriceBeforeDiscount($price->getPrice());
Expand All @@ -156,7 +164,7 @@ private function processProduct(
$productQuantities->each(function (AvailableProductQuantitiesDTO $quantity) use ($product) {
if ($quantity->capacities !== null && $quantity->capacities->isNotEmpty() && $quantity->product_id === $product->getId()) {
$product->setQuantityAvailable(
$quantity->capacities->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getAvailableCapacity())
$quantity->capacities->min(fn (CapacityAssignmentDomainObject $capacity) => $capacity->getAvailableCapacity())
);
}
});
Expand All @@ -165,11 +173,10 @@ private function processProduct(
}

private function filterProduct(
ProductDomainObject $product,
ProductDomainObject $product,
?PromoCodeDomainObject $promoCode = null,
bool $hideSoldOutProducts = true,
): bool
{
bool $hideSoldOutProducts = true,
): bool {
$hidden = false;

if ($this->isHiddenByPromoCode($product, $promoCode)) {
Expand Down Expand Up @@ -202,7 +209,7 @@ private function filterProduct(

private function processProductPrice(ProductDomainObject $product, ProductPriceDomainObject $price): void
{
if (!$price->isFree()) {
if (! $price->isFree()) {
$taxAndFees = $this->taxCalculationService
->calculateTaxAndFeesForProductPrice($product, $price);

Expand Down Expand Up @@ -244,11 +251,11 @@ private function addPlatformFeeToProduct(ProductDomainObject $product): void
$existingTaxesAndFees = $product->getTaxAndFees() ?? collect();

$hasPlatformFee = $existingTaxesAndFees->contains(
fn(TaxAndFeesDomainObject $fee) => $fee->getId() === OrderPlatformFeePassThroughService::PLATFORM_FEE_ID
fn (TaxAndFeesDomainObject $fee) => $fee->getId() === OrderPlatformFeePassThroughService::PLATFORM_FEE_ID
);

if (!$hasPlatformFee) {
$platformFeeDomainObject = (new TaxAndFeesDomainObject())
if (! $hasPlatformFee) {
$platformFeeDomainObject = (new TaxAndFeesDomainObject)
->setId(OrderPlatformFeePassThroughService::PLATFORM_FEE_ID)
->setAccountId(0)
->setName(OrderPlatformFeePassThroughService::getPlatformFeeName())
Expand All @@ -261,14 +268,13 @@ private function addPlatformFeeToProduct(ProductDomainObject $product): void
}

private function filterProductPrice(
ProductDomainObject $product,
ProductDomainObject $product,
ProductPriceDomainObject $price,
bool $hideSoldOutProducts = true
): bool
{
bool $hideSoldOutProducts = true
): bool {
$hidden = false;

if (!$product->isTieredType()) {
if (! $product->isTieredType()) {
return false;
}

Expand Down Expand Up @@ -299,23 +305,23 @@ private function processProductPrices(ProductDomainObject $product, bool $hideSo
{
$product->setProductPrices(
$product->getProductPrices()
?->each(fn(ProductPriceDomainObject $price) => $this->processProductPrice($product, $price))
->reject(fn(ProductPriceDomainObject $price) => $this->filterProductPrice($product, $price, $hideSoldOutProducts))
?->each(fn (ProductPriceDomainObject $price) => $this->processProductPrice($product, $price))
->reject(fn (ProductPriceDomainObject $price) => $this->filterProductPrice($product, $price, $hideSoldOutProducts))
);
}

private function getPriceAvailability(ProductPriceDomainObject $price, ProductDomainObject $product): bool
{
if ($product->isTieredType()) {
return !$price->isSoldOut()
&& !$price->isBeforeSaleStartDate()
&& !$price->isAfterSaleEndDate()
&& !$price->getIsHidden();
return ! $price->isSoldOut()
&& ! $price->isBeforeSaleStartDate()
&& ! $price->isAfterSaleEndDate()
&& ! $price->getIsHidden();
}

return !$product->isSoldOut()
&& !$product->isBeforeSaleStartDate()
&& !$product->isAfterSaleEndDate()
&& !$product->getIsHidden();
return ! $product->isSoldOut()
&& ! $product->isBeforeSaleStartDate()
&& ! $product->isAfterSaleEndDate()
&& ! $product->getIsHidden();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Tests\Unit\Services\Application\Handlers\Product;

use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\Product\GetProductsHandler;
use HiEvents\Services\Domain\Product\ProductFilterService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;

class GetProductsHandlerTest extends TestCase
{
private ProductRepositoryInterface|MockInterface $productRepository;

private ProductFilterService|MockInterface $productFilterService;

private GetProductsHandler $handler;

protected function setUp(): void
{
parent::setUp();

$this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
$this->productFilterService = Mockery::mock(ProductFilterService::class);

$this->handler = new GetProductsHandler(
$this->productRepository,
$this->productFilterService
);
}

public function test_handle_calls_filter_and_returns_paginator(): void
{
$eventId = 1;
$queryParams = QueryParamsDTO::fromArray([]);

$product1 = new ProductDomainObject;
$product1->setId(10);
$product1->setEventId($eventId);

$productsCollection = collect([$product1]);

$paginator = new LengthAwarePaginator($productsCollection, 1, 15);

$this->productRepository
->shouldReceive('loadRelation')
->with(ProductPriceDomainObject::class)
->andReturnSelf();

$this->productRepository
->shouldReceive('loadRelation')
->with(TaxAndFeesDomainObject::class)
->andReturnSelf();

$this->productRepository
->shouldReceive('findByEventId')
->with($eventId, $queryParams)
->andReturn($paginator);

// Here we mock the behavior of ProductFilterService::filterProducts.
$this->productFilterService
->shouldReceive('filterProducts')
->with(
Mockery::on(function ($collection) {
return $collection instanceof Collection && $collection->first() instanceof ProductDomainObject;
}),
null,
false
)
->andReturn($productsCollection);

$result = $this->handler->handle($eventId, $queryParams);

$this->assertSame($paginator, $result);
$this->assertSame($productsCollection, $result->getCollection());
}

public function test_real_filter_throws_on_product_collection(): void
{
$taxService = Mockery::mock(\HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService::class);
$priceService = Mockery::mock(\HiEvents\Services\Domain\Product\ProductPriceService::class);
$fetchService = Mockery::mock(\HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService::class);
$feeService = Mockery::mock(\HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService::class);
$accountRepo = Mockery::mock(\HiEvents\Repository\Interfaces\AccountRepositoryInterface::class);
$eventRepo = Mockery::mock(\HiEvents\Repository\Interfaces\EventRepositoryInterface::class);

$filterService = new ProductFilterService(
$taxService,
$priceService,
$fetchService,
$feeService,
$accountRepo,
$eventRepo
);

$product = new ProductDomainObject;

$this->expectException(\TypeError::class);
$this->expectExceptionMessageMatches('/must be of type.*ProductCategoryDomainObject.*ProductDomainObject given/');

$filterService->filter(collect([$product]));
}
}
Loading