From 77eab34b5d1e3f8ff682047917b44159ae78af67 Mon Sep 17 00:00:00 2001 From: chodak166 Date: Sun, 7 Sep 2025 17:31:06 +0200 Subject: [PATCH] Added spec-based item filtering in PHP implementation --- .../Commands/HandleExpiredItems.php | 5 +- .../Interfaces/IItemRepository.php | 3 +- php8/src/Domain/Entities/Item.php | 4 +- php8/src/Domain/Filters/FilterCondition.php | 19 ++ php8/src/Domain/Filters/FilterExpr.php | 9 + php8/src/Domain/Filters/FilterGroup.php | 22 ++ php8/src/Domain/Filters/FilterOperator.php | 20 ++ .../Domain/Filters/FilterSpecification.php | 236 +++++++++++++ .../Domain/Policies/ItemExpirationPolicy.php | 9 + .../Repositories/FileItemRepository.php | 12 +- .../Integration/FileItemRepositoryTest.php | 298 ++++++++++++----- php8/tests/Unit/HandleExpiredItemsTest.php | 313 ++++++++++++++++++ php8/tests/Unit/ItemExpirationPolicyTest.php | 155 +++++++++ 13 files changed, 1001 insertions(+), 104 deletions(-) create mode 100644 php8/src/Domain/Filters/FilterCondition.php create mode 100644 php8/src/Domain/Filters/FilterExpr.php create mode 100644 php8/src/Domain/Filters/FilterGroup.php create mode 100644 php8/src/Domain/Filters/FilterOperator.php create mode 100644 php8/src/Domain/Filters/FilterSpecification.php create mode 100644 php8/tests/Unit/HandleExpiredItemsTest.php create mode 100644 php8/tests/Unit/ItemExpirationPolicyTest.php diff --git a/php8/src/Application/Commands/HandleExpiredItems.php b/php8/src/Application/Commands/HandleExpiredItems.php index 07f5022..07e1a3f 100755 --- a/php8/src/Application/Commands/HandleExpiredItems.php +++ b/php8/src/Application/Commands/HandleExpiredItems.php @@ -37,14 +37,15 @@ class HandleExpiredItems { try { $currentTime = $this->timeProvider->now(); - $items = $this->itemRepository->findExpired(); + $specification = $this->expirationPolicy->getExpirationSpec($currentTime); + $items = $this->itemRepository->findWhere($specification); foreach ($items as $item) { $isExpired = $this->expirationPolicy->isExpired($item, $currentTime); if ($isExpired) { try { $this->orderService->orderItem($item); - $this->itemRepository->delete($item); + $this->itemRepository->delete($item->getId()); } catch (\Exception $e) { $this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage()); } diff --git a/php8/src/Application/Interfaces/IItemRepository.php b/php8/src/Application/Interfaces/IItemRepository.php index 9939f73..63536ab 100755 --- a/php8/src/Application/Interfaces/IItemRepository.php +++ b/php8/src/Application/Interfaces/IItemRepository.php @@ -6,6 +6,7 @@ namespace AutoStore\Application\Interfaces; use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Exceptions\DomainException; +use AutoStore\Domain\Filters\FilterSpecification; interface IItemRepository { @@ -17,7 +18,7 @@ interface IItemRepository public function delete(string $id): void; - public function findExpired(): array; + public function findWhere(FilterSpecification $specification): array; public function exists(string $id): bool; } \ No newline at end of file diff --git a/php8/src/Domain/Entities/Item.php b/php8/src/Domain/Entities/Item.php index f83b003..6bfa05f 100755 --- a/php8/src/Domain/Entities/Item.php +++ b/php8/src/Domain/Entities/Item.php @@ -83,10 +83,10 @@ class Item return [ 'id' => $this->id, 'name' => $this->name, - 'expirationDate' => $this->expirationDate->format('Y-m-d\TH:i:s.uP'), + 'expirationDate' => $this->expirationDate->format('Y-m-d H:i:s'), 'orderUrl' => $this->orderUrl, 'userId' => $this->userId, - 'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s.uP') + 'createdAt' => $this->createdAt->format('Y-m-d H:i:s') ]; } diff --git a/php8/src/Domain/Filters/FilterCondition.php b/php8/src/Domain/Filters/FilterCondition.php new file mode 100644 index 0000000..f4a5d79 --- /dev/null +++ b/php8/src/Domain/Filters/FilterCondition.php @@ -0,0 +1,19 @@ +field = $field; + $this->operator = $operator; + $this->value = $value; + } +} \ No newline at end of file diff --git a/php8/src/Domain/Filters/FilterExpr.php b/php8/src/Domain/Filters/FilterExpr.php new file mode 100644 index 0000000..d64ea48 --- /dev/null +++ b/php8/src/Domain/Filters/FilterExpr.php @@ -0,0 +1,9 @@ +type = strtoupper($type); + } + + public function add(FilterExpr $expr): self + { + $this->children[] = $expr; + return $this; + } +} \ No newline at end of file diff --git a/php8/src/Domain/Filters/FilterOperator.php b/php8/src/Domain/Filters/FilterOperator.php new file mode 100644 index 0000000..f7f9afb --- /dev/null +++ b/php8/src/Domain/Filters/FilterOperator.php @@ -0,0 +1,20 @@ +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; + } +} \ No newline at end of file diff --git a/php8/src/Domain/Policies/ItemExpirationPolicy.php b/php8/src/Domain/Policies/ItemExpirationPolicy.php index e3e8dce..5f747e1 100755 --- a/php8/src/Domain/Policies/ItemExpirationPolicy.php +++ b/php8/src/Domain/Policies/ItemExpirationPolicy.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace AutoStore\Domain\Policies; use AutoStore\Domain\Entities\Item; +use AutoStore\Domain\Filters\FilterSpecification; use DateTimeImmutable; class ItemExpirationPolicy @@ -13,4 +14,12 @@ class ItemExpirationPolicy { return $item->getExpirationDate() <= $currentTime; } + + public function getExpirationSpec(DateTimeImmutable $currentTime): FilterSpecification + { + $specification = new FilterSpecification('expirationDate'); + $specification->lessEq($currentTime->format('Y-m-d H:i:s')); + + return $specification; + } } diff --git a/php8/src/Infrastructure/Repositories/FileItemRepository.php b/php8/src/Infrastructure/Repositories/FileItemRepository.php index b5079e5..c42dfcb 100755 --- a/php8/src/Infrastructure/Repositories/FileItemRepository.php +++ b/php8/src/Infrastructure/Repositories/FileItemRepository.php @@ -8,6 +8,7 @@ use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Interfaces\IItemRepository; use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Exceptions\DomainException; +use AutoStore\Domain\Filters\FilterSpecification; use Psr\Log\LoggerInterface; class FileItemRepository implements IItemRepository @@ -79,10 +80,6 @@ class FileItemRepository implements IItemRepository return $result; } - public function findExpiredItems(): array - { - return $this->findExpired(); - } public function delete(string $id): void { @@ -96,15 +93,12 @@ class FileItemRepository implements IItemRepository $this->logger->info("Item deleted: {$id}"); } - public function findExpired(): array + public function findWhere(FilterSpecification $specification): array { $result = []; - $now = new \DateTimeImmutable(); foreach ($this->items as $itemData) { - $expirationDate = new \DateTimeImmutable($itemData['expirationDate']); - - if ($expirationDate <= $now) { + if ($specification->matches($itemData)) { try { $result[] = Item::fromArray($itemData); } catch (DomainException $e) { diff --git a/php8/tests/Integration/FileItemRepositoryTest.php b/php8/tests/Integration/FileItemRepositoryTest.php index 3849891..6e66aab 100755 --- a/php8/tests/Integration/FileItemRepositoryTest.php +++ b/php8/tests/Integration/FileItemRepositoryTest.php @@ -6,6 +6,7 @@ namespace AutoStore\Tests\Infrastructure\Repositories; use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Domain\Entities\Item; +use AutoStore\Domain\Filters\FilterSpecification; use AutoStore\Infrastructure\Repositories\FileItemRepository; use DateTimeImmutable; use PHPUnit\Framework\TestCase; @@ -13,6 +14,27 @@ use Psr\Log\LoggerInterface; class FileItemRepositoryTest extends TestCase { + private const ITEM_ID_1 = 'test-id-1'; + private const ITEM_ID_2 = 'test-id-2'; + private const ITEM_ID_3 = 'test-id-3'; + private const EXPIRED_ID = 'expired-id'; + private const VALID_ID = 'valid-id'; + private const NON_EXISTENT_ID = 'non-existent-id'; + private const ITEM_NAME_1 = 'Test Item 1'; + private const ITEM_NAME_2 = 'Test Item 2'; + private const ITEM_NAME_3 = 'Test Item 3'; + private const EXPIRED_NAME = 'Expired Item'; + private const VALID_NAME = 'Valid Item'; + private const ORDER_URL_1 = 'http://example.com/order1'; + private const ORDER_URL_2 = 'http://example.com/order2'; + private const ORDER_URL_3 = 'http://example.com/order3'; + private const EXPIRED_ORDER_URL = 'http://example.com/expired-order'; + private const VALID_ORDER_URL = 'http://example.com/valid-order'; + private const USER_ID_1 = 'user-id-1'; + private const USER_ID_2 = 'user-id-2'; + private const USER_ID = 'user-id'; + private const DATE_FORMAT = 'Y-m-d H:i:s'; + private FileItemRepository $repository; private string $testStoragePath; private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; @@ -34,18 +56,103 @@ class FileItemRepositoryTest extends TestCase array_map('unlink', glob("$this->testStoragePath/*.json")); } - public function testSaveShouldCreateFileForNewItem(): void + private function createTestItem1(): Item + { + return new Item( + self::ITEM_ID_1, + self::ITEM_NAME_1, + new DateTimeImmutable('+1 day'), + self::ORDER_URL_1, + self::USER_ID_1 + ); + } + + private function createTestItem2(): Item + { + return new Item( + self::ITEM_ID_2, + self::ITEM_NAME_2, + new DateTimeImmutable('+2 days'), + self::ORDER_URL_2, + self::USER_ID_2 + ); + } + + private function createTestItem3(): Item + { + return new Item( + self::ITEM_ID_3, + self::ITEM_NAME_3, + new DateTimeImmutable('+3 days'), + self::ORDER_URL_3, + self::USER_ID_1 + ); + } + + private function createExpiredItem(): Item + { + return new Item( + self::EXPIRED_ID, + self::EXPIRED_NAME, + new DateTimeImmutable('-1 day'), + self::EXPIRED_ORDER_URL, + self::USER_ID + ); + } + + private function createValidItem(): Item + { + return new Item( + self::VALID_ID, + self::VALID_NAME, + new DateTimeImmutable('+1 day'), + self::VALID_ORDER_URL, + self::USER_ID + ); + } + + private function createExpiredItemForUser1(): Item { - $item = new Item( - 'test-id', - 'Test Item', + return new Item( + self::ITEM_ID_1, + self::ITEM_NAME_1, + new DateTimeImmutable('-1 day'), + self::ORDER_URL_1, + self::USER_ID_1 + ); + } + + private function createValidItemForUser1(): Item + { + return new Item( + self::ITEM_ID_2, + self::ITEM_NAME_2, new DateTimeImmutable('+1 day'), - 'http://example.com/order', - 'user-id' + self::ORDER_URL_2, + self::USER_ID_1 + ); + } + + private function createExpiredItemForUser2(): Item + { + return new Item( + self::ITEM_ID_3, + self::ITEM_NAME_3, + new DateTimeImmutable('-1 day'), + self::ORDER_URL_3, + self::USER_ID_2 ); + } + + public function testWhenItemIsSavedThenFileIsCreated(): void + { + // Given + $item = $this->createTestItem1(); + // When $this->repository->save($item); + // Then $filePath = $this->testStoragePath . '/items.json'; $this->assertFileExists($filePath); @@ -62,146 +169,157 @@ class FileItemRepositoryTest extends TestCase $this->assertEquals($item->getUserId(), $data[$item->getId()]['userId']); } - public function testFindByIdShouldReturnItemIfExists(): void + public function testWhenItemExistsThenFindByIdReturnsItem(): void { - $item = new Item( - 'test-id', - 'Test Item', - new DateTimeImmutable('+1 day'), - 'http://example.com/order', - 'user-id' - ); - + // Given + $item = $this->createTestItem1(); $this->repository->save($item); - $foundItem = $this->repository->findById('test-id'); + // When + $foundItem = $this->repository->findById(self::ITEM_ID_1); + // Then $this->assertNotNull($foundItem); $this->assertSame($item->getId(), $foundItem->getId()); $this->assertSame($item->getName(), $foundItem->getName()); } - public function testFindByIdShouldReturnNullIfNotExists(): void + public function testWhenItemDoesNotExistThenFindByIdReturnsNull(): void { - $foundItem = $this->repository->findById('non-existent-id'); + // When + $foundItem = $this->repository->findById(self::NON_EXISTENT_ID); + + // Then $this->assertNull($foundItem); } - public function testFindByUserIdShouldReturnItemsForUser(): void + public function testWhenUserHasMultipleItemsThenFindByUserIdReturnsAllUserItems(): void { - $item1 = new Item( - 'test-id-1', - 'Test Item 1', - new DateTimeImmutable('+1 day'), - 'http://example.com/order1', - 'user-id-1' - ); - - $item2 = new Item( - 'test-id-2', - 'Test Item 2', - new DateTimeImmutable('+2 days'), - 'http://example.com/order2', - 'user-id-2' - ); - - $item3 = new Item( - 'test-id-3', - 'Test Item 3', - new DateTimeImmutable('+3 days'), - 'http://example.com/order3', - 'user-id-1' - ); + // Given + $item1 = $this->createTestItem1(); + $item2 = $this->createTestItem2(); + $item3 = $this->createTestItem3(); $this->repository->save($item1); $this->repository->save($item2); $this->repository->save($item3); - $userItems = $this->repository->findByUserId('user-id-1'); + // When + $userItems = $this->repository->findByUserId(self::USER_ID_1); + // Then $this->assertCount(2, $userItems); $this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $userItems)); $this->assertContainsEquals($item3->getId(), array_map(fn($i) => $i->getId(), $userItems)); } - public function testFindAllShouldReturnAllItems(): void + public function testWhenMultipleItemsExistThenFindAllReturnsAllItems(): void { - $item1 = new Item( - 'test-id-1', - 'Test Item 1', - new DateTimeImmutable('+1 day'), - 'http://example.com/order1', - 'user-id-1' - ); - - $item2 = new Item( - 'test-id-2', - 'Test Item 2', - new DateTimeImmutable('+2 days'), - 'http://example.com/order2', - 'user-id-2' - ); + // Given + $item1 = $this->createTestItem1(); + $item2 = $this->createTestItem2(); $this->repository->save($item1); $this->repository->save($item2); + // When $allItems = $this->repository->findAll(); + // Then $this->assertCount(2, $allItems); $this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $allItems)); $this->assertContainsEquals($item2->getId(), array_map(fn($i) => $i->getId(), $allItems)); } - public function testDeleteShouldRemoveItem(): void + public function testWhenItemIsDeletedThenItIsNoLongerFound(): void { - $item = new Item( - 'test-id', - 'Test Item', - new DateTimeImmutable('+1 day'), - 'http://example.com/order', - 'user-id' - ); - + // Given + $item = $this->createTestItem1(); $this->repository->save($item); - $this->repository->delete('test-id'); + // When + $this->repository->delete(self::ITEM_ID_1); - $foundItem = $this->repository->findById('test-id'); + // Then + $foundItem = $this->repository->findById(self::ITEM_ID_1); $this->assertNull($foundItem); } - public function testDeleteShouldThrowExceptionForNonExistentItem(): void + public function testWhenNonExistentItemIsDeletedThenExceptionIsThrown(): void { + // Given & When & Then $this->expectException(ApplicationException::class); - $this->expectExceptionMessage("Item 'non-existent-id' not found"); + $this->expectExceptionMessage("Item '" . self::NON_EXISTENT_ID . "' not found"); - $this->repository->delete('non-existent-id'); + $this->repository->delete(self::NON_EXISTENT_ID); } - public function testFindExpiredItemsShouldReturnOnlyExpiredItems(): void + public function testWhenFilteringByExpirationThenOnlyExpiredItemsAreReturned(): void { - $expiredItem = new Item( - 'expired-id', - 'Expired Item', - new DateTimeImmutable('-1 day'), - 'http://example.com/expired-order', - 'user-id' - ); - - $validItem = new Item( - 'valid-id', - 'Valid Item', - new DateTimeImmutable('+1 day'), - 'http://example.com/valid-order', - 'user-id' - ); + // Given + $expiredItem = $this->createExpiredItem(); + $validItem = $this->createValidItem(); $this->repository->save($expiredItem); $this->repository->save($validItem); - $expiredItems = $this->repository->findExpiredItems(); + // When + $now = new DateTimeImmutable(); + $specification = new FilterSpecification('expirationDate'); + $specification->lessEq($now->format(self::DATE_FORMAT)); + $expiredItems = $this->repository->findWhere($specification); + + // Then $this->assertCount(1, $expiredItems); $this->assertSame($expiredItem->getId(), $expiredItems[0]->getId()); } + + public function testWhenFilteringByUserIdThenOnlyUserItemsAreReturned(): void + { + // Given + $item1 = $this->createTestItem1(); + $item2 = $this->createTestItem2(); + $item3 = $this->createTestItem3(); + + $this->repository->save($item1); + $this->repository->save($item2); + $this->repository->save($item3); + + // When + $specification = new FilterSpecification('userId'); + $specification->equals(self::USER_ID_1); + + $userItems = $this->repository->findWhere($specification); + + // Then + $this->assertCount(2, $userItems); + $this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $userItems)); + $this->assertContainsEquals($item3->getId(), array_map(fn($i) => $i->getId(), $userItems)); + } + + public function testWhenUsingComplexFilterThenOnlyMatchingItemsAreReturned(): void + { + // Given + $item1 = $this->createExpiredItemForUser1(); + $item2 = $this->createValidItemForUser1(); + $item3 = $this->createExpiredItemForUser2(); + + $this->repository->save($item1); + $this->repository->save($item2); + $this->repository->save($item3); + + // When + $now = new DateTimeImmutable(); + $specification = new FilterSpecification('userId'); + $specification->equals(self::USER_ID_1) + ->and('expirationDate') + ->lessEq($now->format(self::DATE_FORMAT)); + + $filteredItems = $this->repository->findWhere($specification); + + // Then + $this->assertCount(1, $filteredItems); + $this->assertSame($item1->getId(), $filteredItems[0]->getId()); + } } \ No newline at end of file diff --git a/php8/tests/Unit/HandleExpiredItemsTest.php b/php8/tests/Unit/HandleExpiredItemsTest.php new file mode 100644 index 0000000..91b36a0 --- /dev/null +++ b/php8/tests/Unit/HandleExpiredItemsTest.php @@ -0,0 +1,313 @@ +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(); + } +} + diff --git a/php8/tests/Unit/ItemExpirationPolicyTest.php b/php8/tests/Unit/ItemExpirationPolicyTest.php new file mode 100644 index 0000000..54bf384 --- /dev/null +++ b/php8/tests/Unit/ItemExpirationPolicyTest.php @@ -0,0 +1,155 @@ +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)); + } +}