Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace hiqdev\php\billing\product\Domain\Model\Price\Exception;

use InvalidArgumentException;

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
));
}
}
61 changes: 61 additions & 0 deletions src/product/Domain/Model/Price/PriceTypeCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace hiqdev\php\billing\product\Domain\Model\Price;

use Countable;
use hiqdev\php\billing\product\Domain\Model\Price\Exception\InvalidPriceTypeCollectionException;
use IteratorAggregate;
use Traversable;

class PriceTypeCollection implements IteratorAggregate, Countable
{
/**
* @var string[] - flipped type names for fast search
*/
private array $flippedTypeNames;

public function __construct(private readonly array $types = [])
{
$this->assertAllPriceTypes($types);
$this->flippedTypeNames = array_flip($this->names());
}

private function assertAllPriceTypes(array $types): void
{
foreach ($types as $type) {
if (!$type instanceof PriceTypeInterface) {
throw InvalidPriceTypeCollectionException::becauseContainsNonPriceType($type);
}
}
}

public function names(): array
{
return array_map(fn(PriceTypeInterface $t) => $t->name(), $this->types);
}

/**
* @return Traversable<int, PriceTypeInterface>
*/
public function getIterator(): Traversable
{
return new \ArrayIterator($this->types);
}

public function has(string $priceType): bool
{
return array_key_exists($priceType, $this->flippedTypeNames);
}

public function count(): int
{
return count($this->types);
}

public function hasItems(): bool
{
return $this->count() > 0;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php declare(strict_types=1);

namespace hiqdev\php\billing\product\price;
namespace hiqdev\php\billing\product\Domain\Model\Price;

interface PriceTypeInterface
{
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/product/Domain/Model/Price/PriceTypeCollectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

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;

class PriceTypeCollectionTest extends TestCase
{
public function testEmptyCollection(): void
{
$collection = new PriceTypeCollection([]);

$this->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'));
}

public function testNames(): void
{
$type = $this->createPriceType('hourly');
$monthly = $this->createPriceType('monthly');

$collection = new PriceTypeCollection([$type, $monthly]);

$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]);
}
}
Loading