Skip to content
Merged
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 @@ -9,6 +9,7 @@
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\Helper\Currency;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
Expand Down Expand Up @@ -43,7 +44,7 @@ public function validateRequestData(int $eventId, array $data = []): void
$this->validateTypes($data);

$event = $this->eventRepository->findById($eventId);
$this->validatePromoCode($eventId, $data);
$promoCode = $this->validatePromoCode($eventId, $data);
$this->validateProductSelection($data);

$this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService
Expand All @@ -53,26 +54,30 @@ public function validateRequestData(int $eventId, array $data = []): void
);

$this->validateOverallCapacity($data);
$this->validateProductDetails($event, $data);
$this->validateProductDetails($event, $data, $promoCode);
}

/**
* @throws ValidationException
*/
private function validatePromoCode(int $eventId, array $data): void
private function validatePromoCode(int $eventId, array $data): ?PromoCodeDomainObject
{
if (isset($data['promo_code'])) {
$promoCode = $this->promoCodeRepository->findFirstWhere([
PromoCodeDomainObjectAbstract::CODE => strtolower(trim($data['promo_code'])),
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);
if (!isset($data['promo_code'])) {
return null;
}

if (!$promoCode) {
throw ValidationException::withMessages([
'promo_code' => __('This promo code is invalid'),
]);
}
$promoCode = $this->promoCodeRepository->findFirstWhere([
PromoCodeDomainObjectAbstract::CODE => strtolower(trim($data['promo_code'])),
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);

if (!$promoCode) {
throw ValidationException::withMessages([
'promo_code' => __('This promo code is invalid'),
]);
}

return $promoCode->isValid() ? $promoCode : null;
}

/**
Expand Down Expand Up @@ -122,19 +127,19 @@ private function getProducts(array $data): Collection
* @throws ValidationException
* @throws Exception
*/
private function validateProductDetails(EventDomainObject $event, array $data): void
private function validateProductDetails(EventDomainObject $event, array $data, ?PromoCodeDomainObject $promoCode): void
{
$products = $this->getProducts($data);

foreach ($data['products'] as $productIndex => $productAndQuantities) {
$this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products);
$this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products, $promoCode);
}
}

/**
* @throws ValidationException
*/
private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products): void
private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode): void
{
$productId = $productAndQuantities['product_id'];
$totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity');
Expand All @@ -155,6 +160,11 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
product: $product
);

$this->validateProductVisibility(
product: $product,
promoCode: $promoCode
);

$this->validateProductQuantity(
productIndex: $productIndex,
productAndQuantities: $productAndQuantities,
Expand Down Expand Up @@ -238,6 +248,23 @@ private function validateProductEvent(EventDomainObject $event, int $productId,
}
}

/**
* Products the organiser has hidden must not be purchasable through the public checkout, even when the
* product ID is known. Products hidden behind a promo code are only purchasable when a valid promo code
* that applies to the product is supplied. This mirrors the display filtering in ProductFilterService.
*/
private function validateProductVisibility(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): void
{
if ($product->getIsHidden()) {
throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
}

if ($product->getIsHiddenWithoutPromoCode()
&& !($promoCode && $promoCode->appliesToProduct($product))) {
throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
}
}

/**
* @throws ValidationException
*/
Expand Down Expand Up @@ -292,9 +319,16 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd
]);
}

$validPriceIds = $product->getProductPrices()?->map(fn(ProductPriceDomainObject $price) => $price->getId());
$productPrices = $product->getProductPrices();
$validPriceIds = $productPrices?->map(fn(ProductPriceDomainObject $price) => $price->getId());
if (!in_array($priceId, $validPriceIds->toArray(), true)) {
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
continue;
}

$selectedPrice = $productPrices?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $priceId);
if ((int)$quantity > 0 && $selectedPrice?->getIsHidden()) {
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
Expand All @@ -19,6 +20,7 @@
use Illuminate\Validation\ValidationException;
use Mockery;
use Mockery\MockInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;

class OrderCreateRequestValidationServiceTest extends TestCase
Expand Down Expand Up @@ -235,6 +237,180 @@ public function testUnrelatedOverReservedCapacityDoesNotBlockSelectedProduct():
$this->assertTrue(true);
}

public function testHiddenProductIsRejected(): void
{
$eventId = 1;
$productId = 10;
$priceId = 101;

$this->setupMocks(
eventId: $eventId,
productId: $productId,
priceIds: [$priceId],
priceLabels: ['Hidden VIP'],
availabilities: [
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
],
isHidden: true,
);

$data = [
'products' => [
[
'product_id' => $productId,
'quantities' => [
['price_id' => $priceId, 'quantity' => 1],
],
],
],
];

$this->expectException(NotFoundHttpException::class);
$this->service->validateRequestData($eventId, $data);
}

public function testProductHiddenWithoutPromoCodeIsRejectedWhenNoPromoCodeSupplied(): void
{
$eventId = 1;
$productId = 10;
$priceId = 101;

$this->setupMocks(
eventId: $eventId,
productId: $productId,
priceIds: [$priceId],
priceLabels: ['Promo Only'],
availabilities: [
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
],
isHiddenWithoutPromoCode: true,
);

$data = [
'products' => [
[
'product_id' => $productId,
'quantities' => [
['price_id' => $priceId, 'quantity' => 1],
],
],
],
];

$this->expectException(NotFoundHttpException::class);
$this->service->validateRequestData($eventId, $data);
}

public function testProductHiddenWithoutPromoCodeIsAllowedWithMatchingPromoCode(): void
{
$eventId = 1;
$productId = 10;
$priceId = 101;

$this->setupMocks(
eventId: $eventId,
productId: $productId,
priceIds: [$priceId],
priceLabels: ['Promo Only'],
availabilities: [
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
],
isHiddenWithoutPromoCode: true,
);

$promoCode = Mockery::mock(PromoCodeDomainObject::class);
$promoCode->shouldReceive('isValid')->andReturn(true);
$promoCode->shouldReceive('appliesToProduct')->andReturn(true);
$this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode);

$data = [
'promo_code' => 'UNLOCK',
'products' => [
[
'product_id' => $productId,
'quantities' => [
['price_id' => $priceId, 'quantity' => 1],
],
],
],
];

$this->service->validateRequestData($eventId, $data);
$this->assertTrue(true);
}

public function testProductHiddenWithoutPromoCodeIsRejectedWhenPromoCodeDoesNotApply(): void
{
$eventId = 1;
$productId = 10;
$priceId = 101;

$this->setupMocks(
eventId: $eventId,
productId: $productId,
priceIds: [$priceId],
priceLabels: ['Promo Only'],
availabilities: [
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
],
isHiddenWithoutPromoCode: true,
);

$promoCode = Mockery::mock(PromoCodeDomainObject::class);
$promoCode->shouldReceive('isValid')->andReturn(true);
$promoCode->shouldReceive('appliesToProduct')->andReturn(false);
$this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode);

$data = [
'promo_code' => 'WRONGPRODUCT',
'products' => [
[
'product_id' => $productId,
'quantities' => [
['price_id' => $priceId, 'quantity' => 1],
],
],
],
];

$this->expectException(NotFoundHttpException::class);
$this->service->validateRequestData($eventId, $data);
}

public function testHiddenPriceTierIsRejected(): void
{
$eventId = 1;
$productId = 10;
$visiblePriceId = 101;
$hiddenPriceId = 102;

$this->setupMocks(
eventId: $eventId,
productId: $productId,
priceIds: [$visiblePriceId, $hiddenPriceId],
priceLabels: ['General', 'Hidden VIP'],
availabilities: [
['price_id' => $visiblePriceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
['price_id' => $hiddenPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
],
hiddenPriceIds: [$hiddenPriceId],
);

$data = [
'products' => [
[
'product_id' => $productId,
'quantities' => [
['price_id' => $hiddenPriceId, 'quantity' => 1],
],
],
],
];

$this->expectException(ValidationException::class);
$this->service->validateRequestData($eventId, $data);
}

private function setupMocks(
int $eventId,
int $productId,
Expand All @@ -243,6 +419,9 @@ private function setupMocks(
array $availabilities,
?Collection $capacities = null,
array $extraAvailabilities = [],
bool $isHidden = false,
bool $isHiddenWithoutPromoCode = false,
array $hiddenPriceIds = [],
): void
{
$event = Mockery::mock(EventDomainObject::class);
Expand All @@ -257,6 +436,7 @@ private function setupMocks(
$price = Mockery::mock(ProductPriceDomainObject::class);
$price->shouldReceive('getId')->andReturn($priceId);
$price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null);
$price->shouldReceive('getIsHidden')->andReturn(in_array($priceId, $hiddenPriceIds, true));
$productPrices->push($price);
}

Expand All @@ -269,6 +449,8 @@ private function setupMocks(
$product->shouldReceive('isSoldOut')->andReturn(false);
$product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name);
$product->shouldReceive('getProductPrices')->andReturn($productPrices);
$product->shouldReceive('getIsHidden')->andReturn($isHidden);
$product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn($isHiddenWithoutPromoCode);

$this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product]));
Expand Down
Loading