From 21c2c2a364853b1bb54ab05a0521c10ef8e05fec Mon Sep 17 00:00:00 2001 From: chodak166 Date: Sun, 31 Aug 2025 18:59:11 +0200 Subject: [PATCH] PHP8 implementation clean-up and tests --- php8/.devcontainer/devcontainer.json | 1 + php8/src/Application.php | 28 +- php8/src/Application/Commands/AddItem.php | 11 +- .../Commands/HandleExpiredItems.php | 10 +- php8/src/DiContainer.php | 13 +- php8/src/Domain/Entities/Item.php | 141 +---- .../Domain/Policies/ItemExpirationPolicy.php | 9 +- .../Adapters/SystemTimeProvider.php | 2 +- .../Infrastructure/Auth/JwtAuthService.php | 2 +- .../Infrastructure/Http/HttpOrderService.php | 2 +- .../src/Infrastructure/Http/JwtMiddleware.php | 2 +- .../src/WebApi/Controllers/AuthController.php | 2 +- .../src/WebApi/Controllers/BaseController.php | 2 +- .../WebApi/Controllers/StoreController.php | 2 +- php8/src/WebApi/Router.php | 52 ++ php8/tests/Unit/AddItemTest.php | 510 ++++++++++++++---- 16 files changed, 500 insertions(+), 289 deletions(-) create mode 100644 php8/src/WebApi/Router.php diff --git a/php8/.devcontainer/devcontainer.json b/php8/.devcontainer/devcontainer.json index 1e00e99..22f2a86 100755 --- a/php8/.devcontainer/devcontainer.json +++ b/php8/.devcontainer/devcontainer.json @@ -12,6 +12,7 @@ }, "extensions": [ "xdebug.php-debug", + "devsense.phptools-vscode", "bmewburn.vscode-intelephense-client", "ms-vscode.vscode-json", "mrmlnc.vscode-json5", diff --git a/php8/src/Application.php b/php8/src/Application.php index 17d1d10..ebbd80d 100755 --- a/php8/src/Application.php +++ b/php8/src/Application.php @@ -4,11 +4,8 @@ declare(strict_types=1); namespace AutoStore; -use AutoStore\Infrastructure\Http\JwtMiddleware; -use AutoStore\WebApi\Controllers\AuthController; -use AutoStore\WebApi\Controllers\StoreController; +use AutoStore\WebApi\Router; use Slim\Factory\AppFactory; -use Slim\Routing\RouteCollectorProxy; class Application { @@ -33,29 +30,12 @@ class Application private function setupRoutes(): void { - $jwtMiddleware = $this->di->get(JwtMiddleware::class); - $authController = $this->di->get(AuthController::class); - $storeController = $this->di->get(StoreController::class); - - // Public routes - $this->app->group('/api/v1', function (RouteCollectorProxy $group) use ($authController) { - $group->post('/login', [$authController, 'login']); - }); - - // Protected routes - $this->app->group('/api/v1', function (RouteCollectorProxy $group) use ($storeController, $jwtMiddleware) { - $group->group('/items', function (RouteCollectorProxy $itemGroup) use ($storeController) { - $itemGroup->get('', [$storeController, 'listItems']); - $itemGroup->post('', [$storeController, 'addItem']); - $itemGroup->get('/{id}', [$storeController, 'getItem']); - $itemGroup->delete('/{id}', [$storeController, 'deleteItem']); - })->add($jwtMiddleware); - }); + $router = new Router($this->app, $this->di); + $router->setupRoutes(); } public function run(): void { $this->app->run(); } - -} \ No newline at end of file +} diff --git a/php8/src/Application/Commands/AddItem.php b/php8/src/Application/Commands/AddItem.php index 6db631f..3739e2b 100755 --- a/php8/src/Application/Commands/AddItem.php +++ b/php8/src/Application/Commands/AddItem.php @@ -34,7 +34,7 @@ class AddItem $this->logger = $logger; } - public function execute(string $name, string $expirationDate, string $orderUrl, string $userId): string + public function execute(string $name, string $expirationDate, string $orderUrl, string $userId): ?string { try { $item = new Item( @@ -46,14 +46,13 @@ class AddItem ); $currentTime = $this->timeProvider->now(); - $this->expirationPolicy->checkExpiration($item, $currentTime); - - if ($item->isExpired()) { + if ($this->expirationPolicy->isExpired($item, $currentTime)) { try { $this->orderService->orderItem($item); - $item->markAsOrdered(); + return null; } catch (\Exception $e) { $this->logger->error('Failed to place order for expired item: ' . $e->getMessage()); + return null; } } @@ -64,4 +63,4 @@ class AddItem throw new ApplicationException('Failed to add item: ' . $e->getMessage(), 0, $e); } } -} \ No newline at end of file +} diff --git a/php8/src/Application/Commands/HandleExpiredItems.php b/php8/src/Application/Commands/HandleExpiredItems.php index 110eb5e..07f5022 100755 --- a/php8/src/Application/Commands/HandleExpiredItems.php +++ b/php8/src/Application/Commands/HandleExpiredItems.php @@ -40,13 +40,11 @@ class HandleExpiredItems $items = $this->itemRepository->findExpired(); foreach ($items as $item) { - $this->expirationPolicy->checkExpiration($item, $currentTime); - - if ($item->isExpired() && !$item->isOrdered()) { + $isExpired = $this->expirationPolicy->isExpired($item, $currentTime); + if ($isExpired) { try { $this->orderService->orderItem($item); - $item->markAsOrdered(); - $this->itemRepository->save($item); + $this->itemRepository->delete($item); } catch (\Exception $e) { $this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage()); } @@ -56,4 +54,4 @@ class HandleExpiredItems throw new ApplicationException('Failed to handle expired items: ' . $e->getMessage(), 0, $e); } } -} \ No newline at end of file +} diff --git a/php8/src/DiContainer.php b/php8/src/DiContainer.php index c5e1120..dd34f17 100755 --- a/php8/src/DiContainer.php +++ b/php8/src/DiContainer.php @@ -10,12 +10,10 @@ use AutoStore\Application\Interfaces\IItemRepository; use AutoStore\Application\Interfaces\IOrderService; use AutoStore\Application\Interfaces\ITimeProvider; use AutoStore\Application\Interfaces\IUserRepository; -use AutoStore\Application\Services\TaskScheduler; use AutoStore\Application\Commands\AddItem; use AutoStore\Application\Commands\DeleteItem; use AutoStore\Application\Commands\HandleExpiredItems; use AutoStore\Application\Commands\LoginUser; -use AutoStore\Application\Commands\UpdateItem; use AutoStore\Application\Queries\GetItem; use AutoStore\Application\Queries\ListItems; use AutoStore\Infrastructure\Adapters\SystemTimeProvider; @@ -43,7 +41,7 @@ class DiContainer // Simplified app config, for real app use settings repository (env variables, database, etc.) $configJson = file_get_contents(self::ROOT_DIR . '/configuration.json'); $config = json_decode($configJson, true); - $this->storagePath = $config['storage_directory'] ?? self::ROOT_DIR. '/storage'; + $this->storagePath = $config['storage_directory'] ?? self::ROOT_DIR . '/storage'; $this->jwtSecret = $config['jwt_secret'] ?? 'secret-key'; $this->diContainer = new Container(); @@ -147,13 +145,6 @@ class DiContainer ->addArgument(IItemRepository::class) ->setShared(true); - $di->add(UpdateItem::class) - ->addArgument(IItemRepository::class) - ->addArgument(IOrderService::class) - ->addArgument(ITimeProvider::class) - ->addArgument(LoggerInterface::class) - ->setShared(true); - $di->add(DeleteItem::class) ->addArgument(IItemRepository::class) ->setShared(true); @@ -177,4 +168,4 @@ class DiContainer ->addArgument(DeleteItem::class) ->setShared(true); } -} \ No newline at end of file +} diff --git a/php8/src/Domain/Entities/Item.php b/php8/src/Domain/Entities/Item.php index 5669b58..f83b003 100755 --- a/php8/src/Domain/Entities/Item.php +++ b/php8/src/Domain/Entities/Item.php @@ -15,43 +15,37 @@ class Item private DateTimeImmutable $expirationDate; private string $orderUrl; private string $userId; - private bool $expired; - private bool $ordered; private DateTimeImmutable $createdAt; - private ?DateTimeImmutable $updatedAt; -public function __construct( - string $id, - string $name, - DateTimeImmutable $expirationDate, - string $orderUrl, - string $userId -) { - if (empty($id)) { - throw InvalidItemDataException::create('Item ID cannot be empty'); - } + public function __construct( + string $id, + string $name, + DateTimeImmutable $expirationDate, + string $orderUrl, + string $userId + ) { + if (empty($id)) { + throw InvalidItemDataException::create('Item ID cannot be empty'); + } - if (empty($name)) { - throw InvalidItemDataException::create('Item name cannot be empty'); - } + if (empty($name)) { + throw InvalidItemDataException::create('Item name cannot be empty'); + } - if (empty($orderUrl)) { - throw InvalidItemDataException::create('Order URL cannot be empty'); - } + if (empty($orderUrl)) { + throw InvalidItemDataException::create('Order URL cannot be empty'); + } - if (empty($userId)) { - throw InvalidItemDataException::create('User ID cannot be empty'); - } + if (empty($userId)) { + throw InvalidItemDataException::create('User ID cannot be empty'); + } - $this->id = $id; - $this->name = $name; - $this->expirationDate = $expirationDate; - $this->orderUrl = $orderUrl; - $this->userId = $userId; - $this->expired = false; - $this->ordered = false; - $this->createdAt = new DateTimeImmutable(); - $this->updatedAt = null; -} + $this->id = $id; + $this->name = $name; + $this->expirationDate = $expirationDate; + $this->orderUrl = $orderUrl; + $this->userId = $userId; + $this->createdAt = new DateTimeImmutable(); + } public function getId(): string { @@ -78,75 +72,11 @@ public function __construct( return $this->userId; } - public function isExpired(): bool - { - return $this->expired; - } - public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } - public function getUpdatedAt(): ?DateTimeImmutable - { - return $this->updatedAt; - } - - public function markAsExpired(): void - { - if ($this->expired) { - return; - } - - $this->expired = true; - $this->updatedAt = new DateTimeImmutable(); - } - - public function isOrdered(): bool - { - return $this->ordered; - } - - public function markAsOrdered(): void - { - if ($this->ordered) { - return; - } - - $this->ordered = true; - $this->updatedAt = new DateTimeImmutable(); - } - - public function updateName(string $name): void - { - if ($this->name === $name) { - return; - } - - $this->name = $name; - $this->updatedAt = new DateTimeImmutable(); - } - - public function updateExpirationDate(DateTimeImmutable $expirationDate): void - { - if ($this->expirationDate == $expirationDate) { - return; - } - - $this->expirationDate = $expirationDate; - $this->updatedAt = new DateTimeImmutable(); - } - - public function updateOrderUrl(string $orderUrl): void - { - if ($this->orderUrl === $orderUrl) { - return; - } - - $this->orderUrl = $orderUrl; - $this->updatedAt = new DateTimeImmutable(); - } public function toArray(): array { @@ -156,10 +86,7 @@ public function __construct( 'expirationDate' => $this->expirationDate->format('Y-m-d\TH:i:s.uP'), 'orderUrl' => $this->orderUrl, 'userId' => $this->userId, - 'expired' => $this->expired, - 'is_ordered' => $this->ordered, - 'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s.uP'), - 'updatedAt' => $this->updatedAt?->format('Y-m-d\TH:i:s.uP'), + 'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s.uP') ]; } @@ -177,18 +104,6 @@ public function __construct( $data['userId'] ); - if (isset($data['expired']) && is_bool($data['expired'])) { - if ($data['expired']) { - $item->markAsExpired(); - } - } - - if (isset($data['is_ordered']) && is_bool($data['is_ordered'])) { - if ($data['is_ordered']) { - $item->markAsOrdered(); - } - } - return $item; } -} \ No newline at end of file +} diff --git a/php8/src/Domain/Policies/ItemExpirationPolicy.php b/php8/src/Domain/Policies/ItemExpirationPolicy.php index 11eb2bb..e3e8dce 100755 --- a/php8/src/Domain/Policies/ItemExpirationPolicy.php +++ b/php8/src/Domain/Policies/ItemExpirationPolicy.php @@ -13,11 +13,4 @@ class ItemExpirationPolicy { return $item->getExpirationDate() <= $currentTime; } - - public function checkExpiration(Item $item, DateTimeImmutable $currentTime): void - { - if ($this->isExpired($item, $currentTime) && !$item->isExpired()) { - $item->markAsExpired(); - } - } -} \ No newline at end of file +} diff --git a/php8/src/Infrastructure/Adapters/SystemTimeProvider.php b/php8/src/Infrastructure/Adapters/SystemTimeProvider.php index 4455197..a6d85ae 100755 --- a/php8/src/Infrastructure/Adapters/SystemTimeProvider.php +++ b/php8/src/Infrastructure/Adapters/SystemTimeProvider.php @@ -13,4 +13,4 @@ class SystemTimeProvider implements ITimeProvider { return new DateTimeImmutable(); } -} \ No newline at end of file +} diff --git a/php8/src/Infrastructure/Auth/JwtAuthService.php b/php8/src/Infrastructure/Auth/JwtAuthService.php index 3c58de7..8330ae5 100755 --- a/php8/src/Infrastructure/Auth/JwtAuthService.php +++ b/php8/src/Infrastructure/Auth/JwtAuthService.php @@ -85,4 +85,4 @@ class JwtAuthService implements IAuthService return null; } } -} \ No newline at end of file +} diff --git a/php8/src/Infrastructure/Http/HttpOrderService.php b/php8/src/Infrastructure/Http/HttpOrderService.php index 0feae95..3f1137d 100755 --- a/php8/src/Infrastructure/Http/HttpOrderService.php +++ b/php8/src/Infrastructure/Http/HttpOrderService.php @@ -60,4 +60,4 @@ class HttpOrderService implements IOrderService throw OrderException::create($item->getOrderUrl(), $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/php8/src/Infrastructure/Http/JwtMiddleware.php b/php8/src/Infrastructure/Http/JwtMiddleware.php index b0856bc..0386754 100755 --- a/php8/src/Infrastructure/Http/JwtMiddleware.php +++ b/php8/src/Infrastructure/Http/JwtMiddleware.php @@ -62,4 +62,4 @@ class JwtMiddleware implements MiddlewareInterface 'message' => $message ])); } -} \ No newline at end of file +} diff --git a/php8/src/WebApi/Controllers/AuthController.php b/php8/src/WebApi/Controllers/AuthController.php index c1f4219..34e669b 100755 --- a/php8/src/WebApi/Controllers/AuthController.php +++ b/php8/src/WebApi/Controllers/AuthController.php @@ -40,4 +40,4 @@ class AuthController extends BaseController return $this->createErrorResponse($response, 'Internal server error', 500); } } -} \ No newline at end of file +} diff --git a/php8/src/WebApi/Controllers/BaseController.php b/php8/src/WebApi/Controllers/BaseController.php index c8b95ac..fe355fb 100755 --- a/php8/src/WebApi/Controllers/BaseController.php +++ b/php8/src/WebApi/Controllers/BaseController.php @@ -56,4 +56,4 @@ abstract class BaseController return null; } -} \ No newline at end of file +} diff --git a/php8/src/WebApi/Controllers/StoreController.php b/php8/src/WebApi/Controllers/StoreController.php index 7ae6987..73e97c3 100755 --- a/php8/src/WebApi/Controllers/StoreController.php +++ b/php8/src/WebApi/Controllers/StoreController.php @@ -109,4 +109,4 @@ class StoreController extends BaseController return $this->createErrorResponse($response, 'Internal server error', 500); } } -} \ No newline at end of file +} diff --git a/php8/src/WebApi/Router.php b/php8/src/WebApi/Router.php new file mode 100644 index 0000000..d019424 --- /dev/null +++ b/php8/src/WebApi/Router.php @@ -0,0 +1,52 @@ +app = $app; + $this->container = $container; + } + + public function setupRoutes(): void + { + $jwtMiddleware = $this->container->get(JwtMiddleware::class); + $authController = $this->container->get(AuthController::class); + $storeController = $this->container->get(StoreController::class); + + $this->setupPublicRoutes($authController); + $this->setupProtectedRoutes($storeController, $jwtMiddleware); + } + + private function setupPublicRoutes(AuthController $authController): void + { + $this->app->group('/api/v1', function (RouteCollectorProxy $group) use ($authController) { + $group->post('/login', [$authController, 'login']); + }); + } + + private function setupProtectedRoutes(StoreController $storeController, JwtMiddleware $jwtMiddleware): void + { + $this->app->group('/api/v1', function (RouteCollectorProxy $group) use ($storeController, $jwtMiddleware) { + $group->group('/items', function (RouteCollectorProxy $itemGroup) use ($storeController) { + $itemGroup->get('', [$storeController, 'listItems']); + $itemGroup->post('', [$storeController, 'addItem']); + $itemGroup->get('/{id}', [$storeController, 'getItem']); + $itemGroup->delete('/{id}', [$storeController, 'deleteItem']); + })->add($jwtMiddleware); + }); + } +} diff --git a/php8/tests/Unit/AddItemTest.php b/php8/tests/Unit/AddItemTest.php index d6fef51..c501406 100755 --- a/php8/tests/Unit/AddItemTest.php +++ b/php8/tests/Unit/AddItemTest.php @@ -15,11 +15,21 @@ use Psr\Log\LoggerInterface; class AddItemTest extends TestCase { + private const MOCKED_NOW = '2023-01-01 12:00:00'; + private const NOT_EXPIRED_DATE = '2023-01-02 12:00:00'; + private const EXPIRED_DATE = '2022-12-31 12:00:00'; + private const ITEM_ID = 'test-item-id'; + private const ITEM_NAME = 'Test Item'; + private const ORDER_URL = 'http://example.com/order'; + private const USER_ID = 'test-user-id'; + private const DATE_FORMAT = 'Y-m-d H:i:s'; + private AddItem $addItem; 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 { @@ -28,6 +38,9 @@ class AddItemTest extends TestCase $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->addItem = new AddItem( $this->itemRepository, $this->orderService, @@ -36,69 +49,104 @@ class AddItemTest extends TestCase ); } - public function testExecuteShouldSaveItemWhenNotExpired(): void + private function createTestItem(): array { - $userId = 'test-user-id'; - $itemName = 'Test Item'; - $expirationDate = new DateTimeImmutable('+1 day'); - $orderUrl = 'http://example.com/order'; + return [ + 'name' => self::ITEM_NAME, + 'expirationDate' => new DateTimeImmutable(self::NOT_EXPIRED_DATE), // 1 day in the future + 'orderUrl' => self::ORDER_URL, + 'userId' => self::USER_ID + ]; + } - $this->timeProvider->method('now') - ->willReturn(new DateTimeImmutable()); + private function createExpiredTestItem(): array + { + return [ + 'name' => self::ITEM_NAME, + 'expirationDate' => new DateTimeImmutable(self::EXPIRED_DATE), // 1 day in the past + 'orderUrl' => self::ORDER_URL, + 'userId' => self::USER_ID + ]; + } - // Capture the saved item - $savedItem = null; - $this->itemRepository->expects($this->once()) - ->method('save') - ->with($this->callback(function (Item $item) use ($itemName, $orderUrl, $userId, &$savedItem) { - $savedItem = $item; - return $item->getName() === $itemName && - $item->getOrderUrl() === $orderUrl && - $item->getUserId() === $userId && - !$item->isOrdered(); - })); + private function createItemWithExpiration(string $expiration): array + { + return [ + 'name' => self::ITEM_NAME, + 'expirationDate' => new DateTimeImmutable($expiration), + 'orderUrl' => self::ORDER_URL, + 'userId' => self::USER_ID + ]; + } - $this->orderService->expects($this->never()) - ->method('orderItem'); + private function getItemMatcher(array $expectedItem): callable + { + return function (Item $item) use ($expectedItem) { + return $item->getName() === $expectedItem['name'] && + $item->getOrderUrl() === $expectedItem['orderUrl'] && + $item->getUserId() === $expectedItem['userId']; + }; + } - // Mock findById to return the saved item + public function testWhenItemNotExpiredThenItemSaved(): void + { + // Given + $testItem = $this->createTestItem(); $this->itemRepository->expects($this->once()) - ->method('findById') - ->willReturnCallback(function ($id) use (&$savedItem) { - return $savedItem; - }); + ->method('save') + ->with($this->callback($this->getItemMatcher($testItem))); + + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } - $resultId = $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); + public function testWhenItemNotExpiredThenOrderIsNotPlaced(): void + { + // Given + $testItem = $this->createTestItem(); - // Retrieve the saved item to verify its properties - $result = $this->itemRepository->findById($resultId); + $this->orderService->expects($this->never())->method('orderItem'); - $this->assertSame($itemName, $result->getName()); - // Compare DateTime objects without microseconds - $this->assertEquals($expirationDate->format('Y-m-d H:i:s'), $result->getExpirationDate()->format('Y-m-d H:i:s')); - $this->assertSame($orderUrl, $result->getOrderUrl()); - $this->assertSame($userId, $result->getUserId()); - $this->assertFalse($result->isOrdered()); + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); } - public function testExecuteShouldPlaceOrderWhenItemIsExpired(): void + public function testWhenItemNotExpiredThenNewItemIdIsReturned(): void { - $userId = 'test-user-id'; - $itemName = 'Test Item'; - $expirationDate = new DateTimeImmutable('-1 day'); - $orderUrl = 'http://example.com/order'; + // Given + $testItem = $this->createTestItem(); + $this->orderService->expects($this->never())->method('orderItem'); + + // When + $resultId = $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); - $this->timeProvider->method('now') - ->willReturn(new DateTimeImmutable()); + // Then + $this->assertNotNull($resultId); + $this->assertNotEmpty($resultId); + } + + public function testWhenItemIsExpiredThenOrderPlaced(): void + { + // Given + $testItem = $this->createExpiredTestItem(); - $savedItem = null; $orderedItem = null; - $this->itemRepository->expects($this->once()) - ->method('save') - ->with($this->callback(function (Item $item) use (&$savedItem) { - $savedItem = $item; - return true; - })); + $this->itemRepository->expects($this->never())->method('save'); $this->orderService->expects($this->once()) ->method('orderItem') @@ -107,106 +155,340 @@ class AddItemTest extends TestCase return true; })); - // Mock findById to return the ordered item - $this->itemRepository->expects($this->once()) - ->method('findById') - ->willReturnCallback(function ($id) use (&$orderedItem) { - // Mark the item as ordered before returning it - if ($orderedItem) { - $orderedItem->markAsOrdered(); - } - return $orderedItem; - }); - - $resultId = $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); - // Retrieve the saved item to verify its properties - $result = $this->itemRepository->findById($resultId); + // When + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); - $this->assertTrue($result->isOrdered()); + // Then + $this->assertNotNull($orderedItem); + $this->assertEquals($testItem['name'], $orderedItem?->getName()); } - public function testExecuteShouldThrowExceptionWhenItemNameIsEmpty(): void + public function testWhenItemNameIsEmptyThenExceptionThrown(): void { - $userId = 'test-user-id'; - $itemName = ''; - $expirationDate = new DateTimeImmutable('+1 day'); - $orderUrl = 'http://example.com/order'; + // Given + $testItem = $this->createTestItem(); + $testItem['name'] = ''; $this->expectException(\AutoStore\Application\Exceptions\ApplicationException::class); - $this->expectExceptionMessage('Failed to add item: Item name cannot be empty'); - $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); } - public function testExecuteShouldThrowExceptionWhenOrderUrlIsEmpty(): void + public function testWhenOrderUrlIsEmptyThenExceptionThrown(): void { - $userId = 'test-user-id'; - $itemName = 'Test Item'; - $expirationDate = new DateTimeImmutable('+1 day'); - $orderUrl = ''; + // Given + $testItem = $this->createTestItem(); + $testItem['orderUrl'] = ''; $this->expectException(\AutoStore\Application\Exceptions\ApplicationException::class); - $this->expectExceptionMessage('Failed to add item: Order URL cannot be empty'); - $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); } - public function testExecuteShouldThrowExceptionWhenUserIdIsEmpty(): void + public function testWhenUserIdIsEmptyThenExceptionThrown(): void { - $userId = ''; - $itemName = 'Test Item'; - $expirationDate = new DateTimeImmutable('+1 day'); - $orderUrl = 'http://example.com/order'; + // Given + $testItem = $this->createTestItem(); + $testItem['userId'] = ''; $this->expectException(\AutoStore\Application\Exceptions\ApplicationException::class); - $this->expectExceptionMessage('Failed to add item: User ID cannot be empty'); - $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); } - public function testExecuteShouldLogErrorWhenOrderServiceFails(): void + public function testWhenOrderServiceFailsThenErrorLogged(): void { - $userId = 'test-user-id'; - $itemName = 'Test Item'; - $expirationDate = new DateTimeImmutable('-1 day'); - $orderUrl = 'http://example.com/order'; - - $this->timeProvider->method('now') - ->willReturn(new DateTimeImmutable()); + $userId = self::USER_ID; + $itemName = self::ITEM_NAME; + $expirationDate = new DateTimeImmutable(self::EXPIRED_DATE); + $orderUrl = self::ORDER_URL; // Mock the repository to return a saved item - $savedItem = null; - $this->itemRepository->expects($this->once()) - ->method('save') - ->with($this->callback(function (Item $item) use (&$savedItem) { - $savedItem = $item; - return true; - })); + $this->itemRepository->expects($this->never())->method('save'); // Mock the order service to throw an exception $this->orderService->expects($this->once()) ->method('orderItem') ->willThrowException(new \RuntimeException('Order service failed')); - $this->logger->expects($this->once()) - ->method('error') - ->with($this->stringContains('Failed to place order for expired item')); - - // Mock findById to return the saved item - $this->itemRepository->expects($this->once()) - ->method('findById') - ->willReturnCallback(function ($id) use (&$savedItem) { - return $savedItem; - }); + $this->logger->expects($this->once())->method('error'); // The handler should not throw an exception when the order service fails // It should log the error and continue - $resultId = $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); + $this->addItem->execute($itemName, $expirationDate->format(self::DATE_FORMAT), $orderUrl, $userId); + } + + public function testWhenItemIsExpiredThenOrderIsPlaced(): void + { + // Given + $testItem = $this->createExpiredTestItem(); + + $this->orderService->expects($this->once())->method('orderItem'); + + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenItemIsExpiredThenItemIsNotSaved(): void + { + // Given + $testItem = $this->createExpiredTestItem(); + + $this->itemRepository->expects($this->never())->method('save'); + + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenItemIsExpiredThenNullIdIsReturned(): void + { + // Given + $testItem = $this->createExpiredTestItem(); + + // When + $resultId = $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + + // Then + $this->assertNull($resultId); + } + + public function testWhenItemExpirationDateIsExactlyCurrentTimeThenItemIsSaved(): void + { + // Given + $testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT)); + + $this->itemRepository->expects($this->never())->method('save'); + + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenItemExpirationDateIsExactlyCurrentTimeThenOrderIsPlaced(): void + { + // Given + $testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT)); - // Retrieve the saved item to verify its properties - $result = $this->itemRepository->findById($resultId); + $this->orderService->expects($this->once())->method('orderItem'); - $this->assertFalse($result->isOrdered()); + // When & Then + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenItemExpirationDateIsExactlyCurrentTimeThenNullIdIsReturned(): void + { + // Given + $testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT)); + + // When + $resultId = $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + + // Then + $this->assertNull($resultId); + } + + public function testWhenItemExpirationDateIsInFutureThenItemSaved(): void + { + // Given + $testItem = $this->createTestItem(); + + $this->itemRepository->expects($this->once())->method('save'); + + // When + $resultId = $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + + // Then + $this->assertNotEmpty($resultId); + } + + public function testWhenRepositorySaveThrowsExceptionThenRuntimeExceptionThrown(): void + { + // Given + $testItem = $this->createTestItem(); + $expectedException = new \RuntimeException('Repository error'); + + $this->itemRepository->expects($this->once()) + ->method('save') + ->willThrowException($expectedException); + + // When & Then + $this->expectException(\RuntimeException::class); + + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenRepositorySaveThrowsExceptionThenOrderIsNotPlaced(): void + { + // Given + $testItem = $this->createTestItem(); + $expectedException = new \RuntimeException('Repository error'); + + $this->itemRepository->expects($this->once()) + ->method('save') + ->willThrowException($expectedException); + + $this->orderService->expects($this->never())->method('orderItem'); + + // When & Then + $this->expectException(\RuntimeException::class); + + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenOrderServiceThrowsExceptionThenRuntimeExceptionThrown(): void + { + // Given + $testItem = $this->createExpiredTestItem(); + $expectedException = new \RuntimeException('Order service error'); + + $this->itemRepository->expects($this->never())->method('save'); + + $this->orderService->expects($this->once()) + ->method('orderItem') + ->willThrowException($expectedException); + + // When & Then + // The implementation logs the exception and does not throw, so we just call execute + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenClockThrowsExceptionThenRuntimeExceptionThrown(): void + { + // Given + $testItem = $this->createTestItem(); + $expectedException = new \RuntimeException('Clock error'); + + $this->timeProvider->method('now') + ->willThrowException($expectedException); + + $this->itemRepository->expects($this->never())->method('save'); + $this->orderService->expects($this->never())->method('orderItem'); + + // When & Then + $this->expectException(\RuntimeException::class); + + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenClockThrowsExceptionThenItemIsNotSaved(): void + { + // Given + $testItem = $this->createTestItem(); + $expectedException = new \RuntimeException('Clock error'); + + $this->timeProvider->method('now') + ->willThrowException($expectedException); + + $this->itemRepository->expects($this->never())->method('save'); + + // When & Then + $this->expectException(\RuntimeException::class); + + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); + } + + public function testWhenClockThrowsExceptionThenOrderIsNotPlaced(): void + { + // Given + $testItem = $this->createTestItem(); + $expectedException = new \RuntimeException('Clock error'); + + $this->timeProvider->method('now') + ->willThrowException($expectedException); + + $this->orderService->expects($this->never())->method('orderItem'); + + // When & Then + $this->expectException(\RuntimeException::class); + + $this->addItem->execute( + $testItem['name'], + $testItem['expirationDate']->format(self::DATE_FORMAT), + $testItem['orderUrl'], + $testItem['userId'] + ); } -} \ No newline at end of file +}