13 changed files with 1001 additions and 104 deletions
@ -0,0 +1,19 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Filters; |
||||||
|
|
||||||
|
class FilterCondition extends FilterExpr |
||||||
|
{ |
||||||
|
public string $field; |
||||||
|
public string $operator; |
||||||
|
public $value; |
||||||
|
|
||||||
|
public function __construct(string $field, string $operator, $value = null) |
||||||
|
{ |
||||||
|
$this->field = $field; |
||||||
|
$this->operator = $operator; |
||||||
|
$this->value = $value; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Filters; |
||||||
|
|
||||||
|
abstract class FilterExpr |
||||||
|
{ |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Filters; |
||||||
|
|
||||||
|
class FilterGroup extends FilterExpr |
||||||
|
{ |
||||||
|
public string $type; |
||||||
|
public array $children = []; |
||||||
|
|
||||||
|
public function __construct(string $type = 'AND') |
||||||
|
{ |
||||||
|
$this->type = strtoupper($type); |
||||||
|
} |
||||||
|
|
||||||
|
public function add(FilterExpr $expr): self |
||||||
|
{ |
||||||
|
$this->children[] = $expr; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Filters; |
||||||
|
|
||||||
|
class FilterOperator |
||||||
|
{ |
||||||
|
public const EQUALS = 'equals'; |
||||||
|
public const NOT_EQUALS = 'not_equals'; |
||||||
|
public const GREATER_THAN = 'greater_than'; |
||||||
|
public const LESS_THAN = 'less_than'; |
||||||
|
public const GREATER_EQ = 'greater_eq'; |
||||||
|
public const LESS_EQ = 'less_eq'; |
||||||
|
public const IN = 'in'; |
||||||
|
public const NOT_IN = 'not_in'; |
||||||
|
public const LIKE = 'like'; |
||||||
|
public const IS_NULL = 'is_null'; |
||||||
|
public const IS_NOT_NULL = 'is_not_null'; |
||||||
|
} |
||||||
@ -0,0 +1,236 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Filters; |
||||||
|
|
||||||
|
class FilterSpecification |
||||||
|
{ |
||||||
|
private FilterGroup $rootGroup; |
||||||
|
private FilterGroup $currentGroup; |
||||||
|
private array $groupStack = []; |
||||||
|
private ?string $lastField = null; |
||||||
|
|
||||||
|
public function __construct(?string $field = null) |
||||||
|
{ |
||||||
|
$this->rootGroup = new FilterGroup('AND'); |
||||||
|
$this->currentGroup = $this->rootGroup; |
||||||
|
if ($field !== null) { |
||||||
|
$this->field($field); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function field(string $field): self |
||||||
|
{ |
||||||
|
$this->lastField = $field; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function equals($value): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::EQUALS, $value)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function notEquals($value): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::NOT_EQUALS, $value)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function greaterThan($value): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::GREATER_THAN, $value)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function lessThan($value): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LESS_THAN, $value)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function greaterEq($value): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::GREATER_EQ, $value)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function lessEq($value): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LESS_EQ, $value)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function in(array $values): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IN, $values)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function notIn(array $values): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::NOT_IN, $values)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function like(string $pattern): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, $pattern)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function contains(string $needle): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, "%$needle%")); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function startsWith(string $prefix): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, $prefix . '%')); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function endsWith(string $suffix): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, '%' . $suffix)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function isNull(): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IS_NULL)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function isNotNull(): self |
||||||
|
{ |
||||||
|
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IS_NOT_NULL)); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function or(?string $field = null): self |
||||||
|
{ |
||||||
|
$newGroup = new FilterGroup('OR'); |
||||||
|
if (!empty($this->currentGroup->children)) { |
||||||
|
$lastExpr = array_pop($this->currentGroup->children); |
||||||
|
$newGroup->add($lastExpr); |
||||||
|
} |
||||||
|
$this->currentGroup->add($newGroup); |
||||||
|
$this->groupStack[] = $this->currentGroup; |
||||||
|
$this->currentGroup = $newGroup; |
||||||
|
if ($field !== null) { |
||||||
|
$this->field($field); |
||||||
|
} |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function and(?string $field = null): self |
||||||
|
{ |
||||||
|
$newGroup = new FilterGroup('AND'); |
||||||
|
if (!empty($this->currentGroup->children)) { |
||||||
|
$lastExpr = array_pop($this->currentGroup->children); |
||||||
|
$newGroup->add($lastExpr); |
||||||
|
} |
||||||
|
$this->currentGroup->add($newGroup); |
||||||
|
$this->groupStack[] = $this->currentGroup; |
||||||
|
$this->currentGroup = $newGroup; |
||||||
|
if ($field !== null) { |
||||||
|
$this->field($field); |
||||||
|
} |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function close(): self |
||||||
|
{ |
||||||
|
if (empty($this->groupStack)) { |
||||||
|
throw new \LogicException('No group to close.'); |
||||||
|
} |
||||||
|
$this->currentGroup = array_pop($this->groupStack); |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getExpression(): FilterExpr |
||||||
|
{ |
||||||
|
return $this->rootGroup; |
||||||
|
} |
||||||
|
|
||||||
|
public function matches(array $data): bool |
||||||
|
{ |
||||||
|
return $this->evaluateExpression($this->rootGroup, $data); |
||||||
|
} |
||||||
|
|
||||||
|
private function evaluateExpression(FilterExpr $expr, array $data): bool |
||||||
|
{ |
||||||
|
if ($expr instanceof FilterCondition) { |
||||||
|
return $this->evaluateCondition($expr, $data); |
||||||
|
} |
||||||
|
|
||||||
|
if ($expr instanceof FilterGroup) { |
||||||
|
return $this->evaluateGroup($expr, $data); |
||||||
|
} |
||||||
|
|
||||||
|
throw new \LogicException('Unknown filter expression type.'); |
||||||
|
} |
||||||
|
|
||||||
|
private function evaluateCondition(FilterCondition $condition, array $data): bool |
||||||
|
{ |
||||||
|
if (!array_key_exists($condition->field, $data)) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
$value = $data[$condition->field]; |
||||||
|
|
||||||
|
switch ($condition->operator) { |
||||||
|
case FilterOperator::EQUALS: |
||||||
|
return $value == $condition->value; |
||||||
|
case FilterOperator::NOT_EQUALS: |
||||||
|
return $value != $condition->value; |
||||||
|
case FilterOperator::GREATER_THAN: |
||||||
|
return $value > $condition->value; |
||||||
|
case FilterOperator::LESS_THAN: |
||||||
|
return $value < $condition->value; |
||||||
|
case FilterOperator::GREATER_EQ: |
||||||
|
return $value >= $condition->value; |
||||||
|
case FilterOperator::LESS_EQ: |
||||||
|
return $value <= $condition->value; |
||||||
|
case FilterOperator::IN: |
||||||
|
return in_array($value, $condition->value); |
||||||
|
case FilterOperator::NOT_IN: |
||||||
|
return !in_array($value, $condition->value); |
||||||
|
case FilterOperator::LIKE: |
||||||
|
return str_contains((string)$value, str_replace('%', '', $condition->value)); |
||||||
|
case FilterOperator::IS_NULL: |
||||||
|
return $value === null; |
||||||
|
case FilterOperator::IS_NOT_NULL: |
||||||
|
return $value !== null; |
||||||
|
default: |
||||||
|
throw new \LogicException("Unknown operator: {$condition->operator}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function evaluateGroup(FilterGroup $group, array $data): bool |
||||||
|
{ |
||||||
|
if (empty($group->children)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
$results = []; |
||||||
|
foreach ($group->children as $child) { |
||||||
|
$results[] = $this->evaluateExpression($child, $data); |
||||||
|
} |
||||||
|
|
||||||
|
return $group->type === 'AND' ? !in_array(false, $results) : in_array(true, $results); |
||||||
|
} |
||||||
|
|
||||||
|
private function consumeField(): string |
||||||
|
{ |
||||||
|
if ($this->lastField === null) { |
||||||
|
throw new \LogicException("No field specified."); |
||||||
|
} |
||||||
|
$field = $this->lastField; |
||||||
|
$this->lastField = null; |
||||||
|
return $field; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,313 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Tests\Unit; |
||||||
|
|
||||||
|
use AutoStore\Application\Commands\HandleExpiredItems; |
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Interfaces\IOrderService; |
||||||
|
use AutoStore\Application\Interfaces\ITimeProvider; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use DateTimeImmutable; |
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class HandleExpiredItemsTest extends TestCase |
||||||
|
{ |
||||||
|
private const MOCKED_NOW = '2023-01-01 12:00:00'; |
||||||
|
private const EXPIRED_DATE = '2022-12-31 12:00:00'; |
||||||
|
private const NOT_EXPIRED_DATE = '2023-01-02 12:00:00'; |
||||||
|
private const ITEM_ID_1 = 'expired-item-id-1'; |
||||||
|
private const ITEM_ID_2 = 'expired-item-id-2'; |
||||||
|
private const ITEM_NAME_1 = 'Expired Item 1'; |
||||||
|
private const ITEM_NAME_2 = 'Expired Item 2'; |
||||||
|
private const ORDER_URL_1 = 'http://example.com/order1'; |
||||||
|
private const ORDER_URL_2 = 'http://example.com/order2'; |
||||||
|
private const USER_ID_1 = 'test-user-id-1'; |
||||||
|
private const USER_ID_2 = 'test-user-id-2'; |
||||||
|
|
||||||
|
private HandleExpiredItems $handleExpiredItems; |
||||||
|
private IItemRepository&\PHPUnit\Framework\MockObject\MockObject $itemRepository; |
||||||
|
private IOrderService&\PHPUnit\Framework\MockObject\MockObject $orderService; |
||||||
|
private ITimeProvider&\PHPUnit\Framework\MockObject\MockObject $timeProvider; |
||||||
|
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; |
||||||
|
private DateTimeImmutable $fixedCurrentTime; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->itemRepository = $this->createMock(IItemRepository::class); |
||||||
|
$this->orderService = $this->createMock(IOrderService::class); |
||||||
|
$this->timeProvider = $this->createMock(ITimeProvider::class); |
||||||
|
$this->logger = $this->createMock(LoggerInterface::class); |
||||||
|
|
||||||
|
$this->fixedCurrentTime = new DateTimeImmutable(self::MOCKED_NOW); |
||||||
|
$this->timeProvider->method('now')->willReturn($this->fixedCurrentTime); |
||||||
|
|
||||||
|
$this->handleExpiredItems = new HandleExpiredItems( |
||||||
|
$this->itemRepository, |
||||||
|
$this->orderService, |
||||||
|
$this->timeProvider, |
||||||
|
$this->logger |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
private function createExpiredItem1(): Item |
||||||
|
{ |
||||||
|
return new Item( |
||||||
|
self::ITEM_ID_1, |
||||||
|
self::ITEM_NAME_1, |
||||||
|
new DateTimeImmutable(self::EXPIRED_DATE), |
||||||
|
self::ORDER_URL_1, |
||||||
|
self::USER_ID_1 |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
private function createExpiredItem2(): Item |
||||||
|
{ |
||||||
|
return new Item( |
||||||
|
self::ITEM_ID_2, |
||||||
|
self::ITEM_NAME_2, |
||||||
|
new DateTimeImmutable(self::EXPIRED_DATE), |
||||||
|
self::ORDER_URL_2, |
||||||
|
self::USER_ID_2 |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenNoExpiredItemsExistThenNoOrdersPlaced(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn([]); |
||||||
|
|
||||||
|
$this->orderService->expects($this->never())->method('orderItem'); |
||||||
|
$this->itemRepository->expects($this->never())->method('delete'); |
||||||
|
$this->logger->expects($this->never())->method('error'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenExpiredItemsExistThenOrdersPlacedAndItemsDeleted(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$expiredItem1 = $this->createExpiredItem1(); |
||||||
|
$expiredItem2 = $this->createExpiredItem2(); |
||||||
|
$expiredItems = [$expiredItem1, $expiredItem2]; |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn($expiredItems); |
||||||
|
$this->orderService->expects($this->exactly(2)) |
||||||
|
->method('orderItem'); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->exactly(2)) |
||||||
|
->method('delete'); |
||||||
|
|
||||||
|
|
||||||
|
$this->logger->expects($this->never())->method('error'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenOrderServiceFailsForOneItemThenErrorLoggedAndOtherItemProcessed(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$expiredItem1 = $this->createExpiredItem1(); |
||||||
|
$expiredItem2 = $this->createExpiredItem2(); |
||||||
|
$expiredItems = [$expiredItem1, $expiredItem2]; |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn($expiredItems); |
||||||
|
$callCount = 0; |
||||||
|
$this->orderService->expects($this->exactly(2)) |
||||||
|
->method('orderItem') |
||||||
|
->willReturnCallback(function () use (&$callCount) { |
||||||
|
$callCount++; |
||||||
|
if ($callCount === 1) { |
||||||
|
throw new \RuntimeException('Order service failed'); |
||||||
|
} |
||||||
|
return null; |
||||||
|
}); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('delete') |
||||||
|
->with(self::ITEM_ID_2); |
||||||
|
|
||||||
|
$this->logger->expects($this->once()) |
||||||
|
->method('error') |
||||||
|
->with('Failed to place order for expired item ' . self::ITEM_ID_1 . ': Order service failed'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenRepositoryFindThrowsDomainExceptionThenApplicationExceptionThrown(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$domainException = new DomainException('Repository error'); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willThrowException($domainException); |
||||||
|
|
||||||
|
$this->orderService->expects($this->never())->method('orderItem'); |
||||||
|
$this->itemRepository->expects($this->never())->method('delete'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->expectException(ApplicationException::class); |
||||||
|
$this->expectExceptionMessage('Failed to handle expired items: Repository error'); |
||||||
|
|
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenRepositoryDeleteThrowsExceptionThenErrorLogged(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$expiredItem = $this->createExpiredItem1(); |
||||||
|
$expiredItems = [$expiredItem]; |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn($expiredItems); |
||||||
|
|
||||||
|
$this->orderService->expects($this->once()) |
||||||
|
->method('orderItem') |
||||||
|
->with($expiredItem); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('delete') |
||||||
|
->with(self::ITEM_ID_1) |
||||||
|
->willThrowException(new \RuntimeException('Delete failed')); |
||||||
|
|
||||||
|
$this->logger->expects($this->once()) |
||||||
|
->method('error') |
||||||
|
->with($this->stringContains('Failed to place order for expired item')); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenTimeProviderThrowsDomainExceptionThenApplicationExceptionThrown(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$domainException = new DomainException('Time provider error'); |
||||||
|
|
||||||
|
$this->timeProvider->method('now') |
||||||
|
->willThrowException($domainException); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->never())->method('findWhere'); |
||||||
|
$this->orderService->expects($this->never())->method('orderItem'); |
||||||
|
$this->itemRepository->expects($this->never())->method('delete'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->expectException(ApplicationException::class); |
||||||
|
$this->expectExceptionMessage('Failed to handle expired items: Time provider error'); |
||||||
|
|
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenItemIsNotExpiredThenOrderNotPlacedAndItemNotDeleted(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
// Create an item that's not actually expired despite being returned by the repository |
||||||
|
// This tests the double-check with isExpired() |
||||||
|
$notExpiredItem = new Item( |
||||||
|
self::ITEM_ID_1, |
||||||
|
self::ITEM_NAME_1, |
||||||
|
new DateTimeImmutable(self::NOT_EXPIRED_DATE), |
||||||
|
self::ORDER_URL_1, |
||||||
|
self::USER_ID_1 |
||||||
|
); |
||||||
|
|
||||||
|
$items = [$notExpiredItem]; |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn($items); |
||||||
|
|
||||||
|
$this->orderService->expects($this->never())->method('orderItem'); |
||||||
|
$this->itemRepository->expects($this->never())->method('delete'); |
||||||
|
$this->logger->expects($this->never())->method('error'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenMixedExpiredAndNonExpiredItemsThenOnlyExpiredItemsProcessed(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$expiredItem = $this->createExpiredItem1(); |
||||||
|
$notExpiredItem = new Item( |
||||||
|
self::ITEM_ID_2, |
||||||
|
self::ITEM_NAME_2, |
||||||
|
new DateTimeImmutable(self::NOT_EXPIRED_DATE), |
||||||
|
self::ORDER_URL_2, |
||||||
|
self::USER_ID_2 |
||||||
|
); |
||||||
|
|
||||||
|
$items = [$expiredItem, $notExpiredItem]; |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn($items); |
||||||
|
|
||||||
|
$this->orderService->expects($this->once()) |
||||||
|
->method('orderItem') |
||||||
|
->with($expiredItem); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('delete') |
||||||
|
->with(self::ITEM_ID_1); |
||||||
|
|
||||||
|
$this->logger->expects($this->never())->method('error'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenAllOrderServicesFailThenAllErrorsLogged(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$expiredItem1 = $this->createExpiredItem1(); |
||||||
|
$expiredItem2 = $this->createExpiredItem2(); |
||||||
|
$expiredItems = [$expiredItem1, $expiredItem2]; |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn($expiredItems); |
||||||
|
|
||||||
|
$this->orderService->expects($this->exactly(2)) |
||||||
|
->method('orderItem') |
||||||
|
->willThrowException(new \RuntimeException('Order service failed')); |
||||||
|
|
||||||
|
$this->itemRepository->expects($this->never())->method('delete'); |
||||||
|
|
||||||
|
$this->logger->expects($this->exactly(2)) |
||||||
|
->method('error'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenRepositoryFindReturnsEmptyArrayThenNoProcessingOccurs(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findWhere') |
||||||
|
->willReturn([]); |
||||||
|
|
||||||
|
$this->orderService->expects($this->never())->method('orderItem'); |
||||||
|
$this->itemRepository->expects($this->never())->method('delete'); |
||||||
|
$this->logger->expects($this->never())->method('error'); |
||||||
|
|
||||||
|
// When & Then |
||||||
|
$this->handleExpiredItems->execute(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,155 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Tests\Unit\Domain\Policies; |
||||||
|
|
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Domain\Filters\FilterSpecification; |
||||||
|
use AutoStore\Domain\Policies\ItemExpirationPolicy; |
||||||
|
use DateTimeImmutable; |
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
|
||||||
|
class ItemExpirationPolicyTest extends TestCase |
||||||
|
{ |
||||||
|
private const ITEM_ID = 'test-id'; |
||||||
|
private const ITEM_ID_2 = 'test-id-2'; |
||||||
|
private const ITEM_NAME = 'Test Item'; |
||||||
|
private const ITEM_NAME_2 = 'Test Item 2'; |
||||||
|
private const ORDER_URL = 'http://example.com/order'; |
||||||
|
private const ORDER_URL_2 = 'http://example.com/order2'; |
||||||
|
private const USER_ID = 'user-id'; |
||||||
|
private const USER_ID_2 = 'user-id-2'; |
||||||
|
private const CURRENT_TIME = '2023-01-01 12:00:00'; |
||||||
|
private const EXPIRED_TIME = '2023-01-01 11:00:00'; |
||||||
|
private const VALID_TIME = '2023-01-01 13:00:00'; |
||||||
|
|
||||||
|
private ItemExpirationPolicy $policy; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->policy = new ItemExpirationPolicy(); |
||||||
|
} |
||||||
|
|
||||||
|
private function createExpiredItem(): Item |
||||||
|
{ |
||||||
|
return new Item( |
||||||
|
self::ITEM_ID, |
||||||
|
self::ITEM_NAME, |
||||||
|
new DateTimeImmutable('-1 day'), |
||||||
|
self::ORDER_URL, |
||||||
|
self::USER_ID |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
private function createValidItem(): Item |
||||||
|
{ |
||||||
|
return new Item( |
||||||
|
self::ITEM_ID, |
||||||
|
self::ITEM_NAME, |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
self::ORDER_URL, |
||||||
|
self::USER_ID |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
private function createItemWithExpiration(string $expirationDate): Item |
||||||
|
{ |
||||||
|
return new Item( |
||||||
|
self::ITEM_ID, |
||||||
|
self::ITEM_NAME, |
||||||
|
new DateTimeImmutable($expirationDate), |
||||||
|
self::ORDER_URL, |
||||||
|
self::USER_ID |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
private function createSecondItemWithExpiration(string $expirationDate): Item |
||||||
|
{ |
||||||
|
return new Item( |
||||||
|
self::ITEM_ID_2, |
||||||
|
self::ITEM_NAME_2, |
||||||
|
new DateTimeImmutable($expirationDate), |
||||||
|
self::ORDER_URL_2, |
||||||
|
self::USER_ID_2 |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenItemIsExpiredThenIsExpiredReturnsTrue(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$item = $this->createExpiredItem(); |
||||||
|
$currentTime = new DateTimeImmutable(); |
||||||
|
|
||||||
|
// When |
||||||
|
$result = $this->policy->isExpired($item, $currentTime); |
||||||
|
|
||||||
|
// Then |
||||||
|
$this->assertTrue($result); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenItemIsNotExpiredThenIsExpiredReturnsFalse(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$item = $this->createValidItem(); |
||||||
|
$currentTime = new DateTimeImmutable(); |
||||||
|
|
||||||
|
// When |
||||||
|
$result = $this->policy->isExpired($item, $currentTime); |
||||||
|
|
||||||
|
// Then |
||||||
|
$this->assertFalse($result); |
||||||
|
} |
||||||
|
|
||||||
|
public function testGetExpirationSpecShouldReturnCorrectFilter(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$currentTime = new DateTimeImmutable(self::CURRENT_TIME); |
||||||
|
|
||||||
|
// When |
||||||
|
$specification = $this->policy->getExpirationSpec($currentTime); |
||||||
|
|
||||||
|
// Then |
||||||
|
$this->assertInstanceOf(FilterSpecification::class, $specification); |
||||||
|
|
||||||
|
// Test the filter with mock data using the same format as Item::toArray() |
||||||
|
$expiredData = [ |
||||||
|
'id' => self::ITEM_ID, |
||||||
|
'name' => self::ITEM_NAME, |
||||||
|
'expirationDate' => self::EXPIRED_TIME, |
||||||
|
'orderUrl' => self::ORDER_URL, |
||||||
|
'userId' => self::USER_ID |
||||||
|
]; |
||||||
|
|
||||||
|
$validData = [ |
||||||
|
'id' => self::ITEM_ID_2, |
||||||
|
'name' => self::ITEM_NAME_2, |
||||||
|
'expirationDate' => self::VALID_TIME, |
||||||
|
'orderUrl' => self::ORDER_URL_2, |
||||||
|
'userId' => self::USER_ID_2 |
||||||
|
]; |
||||||
|
|
||||||
|
$this->assertTrue($specification->matches($expiredData)); |
||||||
|
$this->assertFalse($specification->matches($validData)); |
||||||
|
} |
||||||
|
|
||||||
|
public function testGetExpirationSpecShouldWorkWithItemObjects(): void |
||||||
|
{ |
||||||
|
// Given |
||||||
|
$currentTime = new DateTimeImmutable(self::CURRENT_TIME); |
||||||
|
$expiredItem = $this->createItemWithExpiration(self::EXPIRED_TIME); |
||||||
|
$validItem = $this->createSecondItemWithExpiration(self::VALID_TIME); |
||||||
|
|
||||||
|
// When |
||||||
|
$specification = $this->policy->getExpirationSpec($currentTime); |
||||||
|
|
||||||
|
// Then |
||||||
|
// Test with array representation |
||||||
|
$expiredArray = $expiredItem->toArray(); |
||||||
|
$validArray = $validItem->toArray(); |
||||||
|
|
||||||
|
// Check that the specification matches the array representation |
||||||
|
$this->assertTrue($specification->matches($expiredArray)); |
||||||
|
$this->assertFalse($specification->matches($validArray)); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue