From 29be10ddd0a25d76ce47dedefc7217bde10570c8 Mon Sep 17 00:00:00 2001 From: Vadym Hrechukha Date: Thu, 6 Nov 2025 04:20:47 +0200 Subject: [PATCH 1/7] HP-2557: Created PriceTypeCollection for simplified work with PriceTypeInterface --- src/product/price/PriceTypeCollection.php | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/product/price/PriceTypeCollection.php diff --git a/src/product/price/PriceTypeCollection.php b/src/product/price/PriceTypeCollection.php new file mode 100644 index 00000000..9da0214c --- /dev/null +++ b/src/product/price/PriceTypeCollection.php @@ -0,0 +1,45 @@ +flippedTypes = array_flip(array_map(fn(PriceTypeInterface $t) => $t->name(), $types)); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new \ArrayIterator($this->types); + } + + public function has(string $priceType): bool + { + return array_key_exists($priceType, $this->flippedTypes); + } + + public function count(): int + { + return count($this->types); + } + + public function hasItems(): bool + { + return $this->count() > 0; + } +} From 0e563ffeaa3553820cdd2e2d3dc553d979828a43 Mon Sep 17 00:00:00 2001 From: Vadym Hrechukha Date: Thu, 6 Nov 2025 13:09:08 +0200 Subject: [PATCH 2/7] HP-2557: moved PriceTypeCollection class into appropriate directory in accordance with DDD architecture --- .../{price => Domain/Model/Price}/PriceTypeCollection.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename src/product/{price => Domain/Model/Price}/PriceTypeCollection.php (88%) diff --git a/src/product/price/PriceTypeCollection.php b/src/product/Domain/Model/Price/PriceTypeCollection.php similarity index 88% rename from src/product/price/PriceTypeCollection.php rename to src/product/Domain/Model/Price/PriceTypeCollection.php index 9da0214c..ab865786 100644 --- a/src/product/price/PriceTypeCollection.php +++ b/src/product/Domain/Model/Price/PriceTypeCollection.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace hiqdev\php\billing\product\price; +namespace hiqdev\php\billing\product\Domain\Model\Price; use Countable; +use hiqdev\php\billing\product\price\PriceTypeInterface; use IteratorAggregate; use Traversable; From 70cb8c58932f24a5c2a1cd88215ce02774ff6894 Mon Sep 17 00:00:00 2001 From: Vadym Hrechukha Date: Thu, 6 Nov 2025 13:16:19 +0200 Subject: [PATCH 3/7] HP-2557: moved PriceTypeInterface into appropriate directory in accordance with DDD architecture --- src/product/Domain/Model/Price/PriceTypeCollection.php | 1 - .../{price => Domain/Model/Price}/PriceTypeInterface.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename src/product/{price => Domain/Model/Price}/PriceTypeInterface.php (63%) diff --git a/src/product/Domain/Model/Price/PriceTypeCollection.php b/src/product/Domain/Model/Price/PriceTypeCollection.php index ab865786..ba50770e 100644 --- a/src/product/Domain/Model/Price/PriceTypeCollection.php +++ b/src/product/Domain/Model/Price/PriceTypeCollection.php @@ -5,7 +5,6 @@ namespace hiqdev\php\billing\product\Domain\Model\Price; use Countable; -use hiqdev\php\billing\product\price\PriceTypeInterface; use IteratorAggregate; use Traversable; diff --git a/src/product/price/PriceTypeInterface.php b/src/product/Domain/Model/Price/PriceTypeInterface.php similarity index 63% rename from src/product/price/PriceTypeInterface.php rename to src/product/Domain/Model/Price/PriceTypeInterface.php index 5fddfc52..38301775 100644 --- a/src/product/price/PriceTypeInterface.php +++ b/src/product/Domain/Model/Price/PriceTypeInterface.php @@ -1,6 +1,6 @@ Date: Thu, 6 Nov 2025 13:21:46 +0200 Subject: [PATCH 4/7] HP-2557: covered PriceTypeCollection with PHPUnit --- .../Model/Price/PriceTypeCollectionTest.php | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php diff --git a/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php b/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php new file mode 100644 index 00000000..2bc38705 --- /dev/null +++ b/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php @@ -0,0 +1,81 @@ +assertCount(0, $collection); + $this->assertFalse($collection->hasItems()); + $this->assertFalse($collection->has('any')); + } + + public function testCountAndHasItems(): void + { + $type = $this->createPriceType('hourly'); + $collection = new PriceTypeCollection([$type]); + + $this->assertCount(1, $collection); + $this->assertTrue($collection->hasItems()); + } + + private function createPriceType(string $name): PriceTypeInterface + { + return new class($name) implements PriceTypeInterface { + public function __construct(private string $name) {} + public function name(): string { return $this->name; } + }; + } + + public function testHasReturnsTrueForExistingType(): void + { + $hourly = $this->createPriceType('hourly'); + $monthly = $this->createPriceType('monthly'); + + $collection = new PriceTypeCollection([$hourly, $monthly]); + + $this->assertTrue($collection->has('hourly')); + $this->assertTrue($collection->has('monthly')); + $this->assertFalse($collection->has('discount')); + } + + public function testIteratorReturnsAllTypes(): void + { + $types = [ + $this->createPriceType('hourly'), + $this->createPriceType('fixed'), + ]; + + $collection = new PriceTypeCollection($types); + $collectedNames = []; + + foreach ($collection as $type) { + $this->assertInstanceOf(PriceTypeInterface::class, $type); + $collectedNames[] = $type->name(); + } + + $this->assertSame(['hourly', 'fixed'], $collectedNames); + } + + public function testHandlesDuplicateNamesGracefully(): void + { + // Duplicates in the array should still work for iteration, though flipped array will only store last + $hourly1 = $this->createPriceType('hourly'); + $hourly2 = $this->createPriceType('hourly'); + $collection = new PriceTypeCollection([$hourly1, $hourly2]); + + // Both objects exist in types + $this->assertCount(2, $collection); + // But "has" should still return true for 'hourly' + $this->assertTrue($collection->has('hourly')); + } +} From a36d665d976b025ee627fd078bade03392e48395 Mon Sep 17 00:00:00 2001 From: Vadym Hrechukha Date: Thu, 6 Nov 2025 13:46:44 +0200 Subject: [PATCH 5/7] HP-2557: created PriceTypeCollection::names() to avoid code duplicate --- .../Domain/Model/Price/PriceTypeCollection.php | 13 +++++++++---- .../Domain/Model/Price/PriceTypeCollectionTest.php | 10 ++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/product/Domain/Model/Price/PriceTypeCollection.php b/src/product/Domain/Model/Price/PriceTypeCollection.php index ba50770e..37a7e293 100644 --- a/src/product/Domain/Model/Price/PriceTypeCollection.php +++ b/src/product/Domain/Model/Price/PriceTypeCollection.php @@ -11,13 +11,18 @@ class PriceTypeCollection implements IteratorAggregate, Countable { /** - * @var string[] - flipped types for fast search + * @var string[] - flipped type names for fast search */ - private array $flippedTypes; + private array $flippedTypeNames; public function __construct(private readonly array $types = []) { - $this->flippedTypes = array_flip(array_map(fn(PriceTypeInterface $t) => $t->name(), $types)); + $this->flippedTypeNames = array_flip($this->names()); + } + + public function names(): array + { + return array_map(fn(PriceTypeInterface $t) => $t->name(), $this->types); } /** @@ -30,7 +35,7 @@ public function getIterator(): Traversable public function has(string $priceType): bool { - return array_key_exists($priceType, $this->flippedTypes); + return array_key_exists($priceType, $this->flippedTypeNames); } public function count(): int diff --git a/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php b/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php index 2bc38705..e755aa2c 100644 --- a/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php +++ b/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php @@ -78,4 +78,14 @@ public function testHandlesDuplicateNamesGracefully(): void // But "has" should still return true for 'hourly' $this->assertTrue($collection->has('hourly')); } + + public function testNames(): void + { + $type = $this->createPriceType('hourly'); + $monthly = $this->createPriceType('monthly'); + + $collection = new PriceTypeCollection([$type, $monthly]); + + $this->assertSame(['hourly', 'monthly'], $collection->names()); + } } From ed0e403d846294c74fea5b3127e0ff381f381147 Mon Sep 17 00:00:00 2001 From: Vadym Hrechukha Date: Thu, 6 Nov 2025 18:17:29 +0200 Subject: [PATCH 6/7] HP-2557: ensure PriceTypeCollection contains only instances of PriceTypeInterface --- .../InvalidPriceTypeCollectionException.php | 11 +++++++++++ .../Domain/Model/Price/PriceTypeCollection.php | 14 ++++++++++++++ .../Domain/Model/Price/PriceTypeCollectionTest.php | 11 +++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php diff --git a/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php b/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php new file mode 100644 index 00000000..e18acfbe --- /dev/null +++ b/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php @@ -0,0 +1,11 @@ +assertAllPriceTypes($types); $this->flippedTypeNames = array_flip($this->names()); } + private function assertAllPriceTypes(array $types): void + { + foreach ($types as $type) { + if (!$type instanceof PriceTypeInterface) { + throw new InvalidPriceTypeCollectionException(sprintf( + 'PriceTypeCollection can only contain instances of PriceTypeInterface. Got: %s', + is_object($type) ? get_class($type) : gettype($type) + )); + } + } + } + public function names(): array { return array_map(fn(PriceTypeInterface $t) => $t->name(), $this->types); diff --git a/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php b/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php index e755aa2c..3c5ed5e6 100644 --- a/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php +++ b/tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php @@ -4,6 +4,7 @@ namespace hiqdev\php\billing\tests\unit\product\Domain\Model\Price; +use hiqdev\php\billing\product\Domain\Model\Price\Exception\InvalidPriceTypeCollectionException; use hiqdev\php\billing\product\Domain\Model\Price\PriceTypeCollection; use hiqdev\php\billing\product\Domain\Model\Price\PriceTypeInterface; use PHPUnit\Framework\TestCase; @@ -88,4 +89,14 @@ public function testNames(): void $this->assertSame(['hourly', 'monthly'], $collection->names()); } + + public function testThrowsExceptionWhenInvalidItemProvided(): void + { + $invalidItem = new \stdClass(); // not a PriceTypeInterface instance + + $this->expectException(InvalidPriceTypeCollectionException::class); + $this->expectExceptionMessage('PriceTypeCollection can only contain instances of PriceTypeInterface. Got: stdClas'); + + new PriceTypeCollection([$invalidItem]); + } } From b7a462459bfb947bf7f6ff8b32296bea70485a07 Mon Sep 17 00:00:00 2001 From: Vadym Hrechukha Date: Thu, 6 Nov 2025 18:22:43 +0200 Subject: [PATCH 7/7] HP-2557: created `InvalidPriceTypeCollectionException::becauseContainsNonPriceType()` method because it makes the domain reason for the exception explicit and self-documenting --- .../Exception/InvalidPriceTypeCollectionException.php | 9 +++++++++ src/product/Domain/Model/Price/PriceTypeCollection.php | 5 +---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php b/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php index e18acfbe..c3bc435e 100644 --- a/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php +++ b/src/product/Domain/Model/Price/Exception/InvalidPriceTypeCollectionException.php @@ -8,4 +8,13 @@ class InvalidPriceTypeCollectionException extends InvalidArgumentException { + public static function becauseContainsNonPriceType(mixed $value): self + { + $given = is_object($value) ? get_class($value) : gettype($value); + + return new self(sprintf( + 'PriceTypeCollection can only contain instances of PriceTypeInterface. Got: %s', + $given + )); + } } diff --git a/src/product/Domain/Model/Price/PriceTypeCollection.php b/src/product/Domain/Model/Price/PriceTypeCollection.php index 0e94aba0..8b262199 100644 --- a/src/product/Domain/Model/Price/PriceTypeCollection.php +++ b/src/product/Domain/Model/Price/PriceTypeCollection.php @@ -26,10 +26,7 @@ private function assertAllPriceTypes(array $types): void { foreach ($types as $type) { if (!$type instanceof PriceTypeInterface) { - throw new InvalidPriceTypeCollectionException(sprintf( - 'PriceTypeCollection can only contain instances of PriceTypeInterface. Got: %s', - is_object($type) ? get_class($type) : gettype($type) - )); + throw InvalidPriceTypeCollectionException::becauseContainsNonPriceType($type); } } }