Browse Source

Added spec-based item filtering in PHP implementation

php8-fixes
chodak166 4 months ago
parent
commit
77eab34b5d
  1. 5
      php8/src/Application/Commands/HandleExpiredItems.php
  2. 3
      php8/src/Application/Interfaces/IItemRepository.php
  3. 4
      php8/src/Domain/Entities/Item.php
  4. 19
      php8/src/Domain/Filters/FilterCondition.php
  5. 9
      php8/src/Domain/Filters/FilterExpr.php
  6. 22
      php8/src/Domain/Filters/FilterGroup.php
  7. 20
      php8/src/Domain/Filters/FilterOperator.php
  8. 236
      php8/src/Domain/Filters/FilterSpecification.php
  9. 9
      php8/src/Domain/Policies/ItemExpirationPolicy.php
  10. 12
      php8/src/Infrastructure/Repositories/FileItemRepository.php
  11. 298
      php8/tests/Integration/FileItemRepositoryTest.php
  12. 313
      php8/tests/Unit/HandleExpiredItemsTest.php
  13. 155
      php8/tests/Unit/ItemExpirationPolicyTest.php

5
php8/src/Application/Commands/HandleExpiredItems.php

@ -37,14 +37,15 @@ class HandleExpiredItems
{ {
try { try {
$currentTime = $this->timeProvider->now(); $currentTime = $this->timeProvider->now();
$items = $this->itemRepository->findExpired(); $specification = $this->expirationPolicy->getExpirationSpec($currentTime);
$items = $this->itemRepository->findWhere($specification);
foreach ($items as $item) { foreach ($items as $item) {
$isExpired = $this->expirationPolicy->isExpired($item, $currentTime); $isExpired = $this->expirationPolicy->isExpired($item, $currentTime);
if ($isExpired) { if ($isExpired) {
try { try {
$this->orderService->orderItem($item); $this->orderService->orderItem($item);
$this->itemRepository->delete($item); $this->itemRepository->delete($item->getId());
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage()); $this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage());
} }

3
php8/src/Application/Interfaces/IItemRepository.php

@ -6,6 +6,7 @@ namespace AutoStore\Application\Interfaces;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use AutoStore\Domain\Filters\FilterSpecification;
interface IItemRepository interface IItemRepository
{ {
@ -17,7 +18,7 @@ interface IItemRepository
public function delete(string $id): void; public function delete(string $id): void;
public function findExpired(): array; public function findWhere(FilterSpecification $specification): array;
public function exists(string $id): bool; public function exists(string $id): bool;
} }

4
php8/src/Domain/Entities/Item.php

@ -83,10 +83,10 @@ class Item
return [ return [
'id' => $this->id, 'id' => $this->id,
'name' => $this->name, '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, 'orderUrl' => $this->orderUrl,
'userId' => $this->userId, 'userId' => $this->userId,
'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s.uP') 'createdAt' => $this->createdAt->format('Y-m-d H:i:s')
]; ];
} }

19
php8/src/Domain/Filters/FilterCondition.php

@ -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;
}
}

9
php8/src/Domain/Filters/FilterExpr.php

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Filters;
abstract class FilterExpr
{
}

22
php8/src/Domain/Filters/FilterGroup.php

@ -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;
}
}

20
php8/src/Domain/Filters/FilterOperator.php

@ -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';
}

236
php8/src/Domain/Filters/FilterSpecification.php

@ -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;
}
}

9
php8/src/Domain/Policies/ItemExpirationPolicy.php

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace AutoStore\Domain\Policies; namespace AutoStore\Domain\Policies;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Filters\FilterSpecification;
use DateTimeImmutable; use DateTimeImmutable;
class ItemExpirationPolicy class ItemExpirationPolicy
@ -13,4 +14,12 @@ class ItemExpirationPolicy
{ {
return $item->getExpirationDate() <= $currentTime; 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;
}
} }

12
php8/src/Infrastructure/Repositories/FileItemRepository.php

@ -8,6 +8,7 @@ use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Application\Interfaces\IItemRepository; use AutoStore\Application\Interfaces\IItemRepository;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use AutoStore\Domain\Filters\FilterSpecification;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class FileItemRepository implements IItemRepository class FileItemRepository implements IItemRepository
@ -79,10 +80,6 @@ class FileItemRepository implements IItemRepository
return $result; return $result;
} }
public function findExpiredItems(): array
{
return $this->findExpired();
}
public function delete(string $id): void public function delete(string $id): void
{ {
@ -96,15 +93,12 @@ class FileItemRepository implements IItemRepository
$this->logger->info("Item deleted: {$id}"); $this->logger->info("Item deleted: {$id}");
} }
public function findExpired(): array public function findWhere(FilterSpecification $specification): array
{ {
$result = []; $result = [];
$now = new \DateTimeImmutable();
foreach ($this->items as $itemData) { foreach ($this->items as $itemData) {
$expirationDate = new \DateTimeImmutable($itemData['expirationDate']); if ($specification->matches($itemData)) {
if ($expirationDate <= $now) {
try { try {
$result[] = Item::fromArray($itemData); $result[] = Item::fromArray($itemData);
} catch (DomainException $e) { } catch (DomainException $e) {

298
php8/tests/Integration/FileItemRepositoryTest.php

@ -6,6 +6,7 @@ namespace AutoStore\Tests\Infrastructure\Repositories;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Filters\FilterSpecification;
use AutoStore\Infrastructure\Repositories\FileItemRepository; use AutoStore\Infrastructure\Repositories\FileItemRepository;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -13,6 +14,27 @@ use Psr\Log\LoggerInterface;
class FileItemRepositoryTest extends TestCase 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 FileItemRepository $repository;
private string $testStoragePath; private string $testStoragePath;
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger;
@ -34,18 +56,103 @@ class FileItemRepositoryTest extends TestCase
array_map('unlink', glob("$this->testStoragePath/*.json")); 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( return new Item(
'test-id', self::ITEM_ID_1,
'Test Item', 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'), new DateTimeImmutable('+1 day'),
'http://example.com/order', self::ORDER_URL_2,
'user-id' 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); $this->repository->save($item);
// Then
$filePath = $this->testStoragePath . '/items.json'; $filePath = $this->testStoragePath . '/items.json';
$this->assertFileExists($filePath); $this->assertFileExists($filePath);
@ -62,146 +169,157 @@ class FileItemRepositoryTest extends TestCase
$this->assertEquals($item->getUserId(), $data[$item->getId()]['userId']); $this->assertEquals($item->getUserId(), $data[$item->getId()]['userId']);
} }
public function testFindByIdShouldReturnItemIfExists(): void public function testWhenItemExistsThenFindByIdReturnsItem(): void
{ {
$item = new Item( // Given
'test-id', $item = $this->createTestItem1();
'Test Item',
new DateTimeImmutable('+1 day'),
'http://example.com/order',
'user-id'
);
$this->repository->save($item); $this->repository->save($item);
$foundItem = $this->repository->findById('test-id'); // When
$foundItem = $this->repository->findById(self::ITEM_ID_1);
// Then
$this->assertNotNull($foundItem); $this->assertNotNull($foundItem);
$this->assertSame($item->getId(), $foundItem->getId()); $this->assertSame($item->getId(), $foundItem->getId());
$this->assertSame($item->getName(), $foundItem->getName()); $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); $this->assertNull($foundItem);
} }
public function testFindByUserIdShouldReturnItemsForUser(): void public function testWhenUserHasMultipleItemsThenFindByUserIdReturnsAllUserItems(): void
{ {
$item1 = new Item( // Given
'test-id-1', $item1 = $this->createTestItem1();
'Test Item 1', $item2 = $this->createTestItem2();
new DateTimeImmutable('+1 day'), $item3 = $this->createTestItem3();
'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'
);
$this->repository->save($item1); $this->repository->save($item1);
$this->repository->save($item2); $this->repository->save($item2);
$this->repository->save($item3); $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->assertCount(2, $userItems);
$this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $userItems)); $this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $userItems));
$this->assertContainsEquals($item3->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( // Given
'test-id-1', $item1 = $this->createTestItem1();
'Test Item 1', $item2 = $this->createTestItem2();
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'
);
$this->repository->save($item1); $this->repository->save($item1);
$this->repository->save($item2); $this->repository->save($item2);
// When
$allItems = $this->repository->findAll(); $allItems = $this->repository->findAll();
// Then
$this->assertCount(2, $allItems); $this->assertCount(2, $allItems);
$this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $allItems)); $this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $allItems));
$this->assertContainsEquals($item2->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( // Given
'test-id', $item = $this->createTestItem1();
'Test Item',
new DateTimeImmutable('+1 day'),
'http://example.com/order',
'user-id'
);
$this->repository->save($item); $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); $this->assertNull($foundItem);
} }
public function testDeleteShouldThrowExceptionForNonExistentItem(): void public function testWhenNonExistentItemIsDeletedThenExceptionIsThrown(): void
{ {
// Given & When & Then
$this->expectException(ApplicationException::class); $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( // Given
'expired-id', $expiredItem = $this->createExpiredItem();
'Expired Item', $validItem = $this->createValidItem();
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'
);
$this->repository->save($expiredItem); $this->repository->save($expiredItem);
$this->repository->save($validItem); $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->assertCount(1, $expiredItems);
$this->assertSame($expiredItem->getId(), $expiredItems[0]->getId()); $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());
}
} }

313
php8/tests/Unit/HandleExpiredItemsTest.php

@ -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();
}
}

155
php8/tests/Unit/ItemExpirationPolicyTest.php

@ -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…
Cancel
Save