diff --git a/src/Shared/Helpers/Pagination.php b/src/Shared/Helpers/Pagination.php deleted file mode 100644 index 4e0468c..0000000 --- a/src/Shared/Helpers/Pagination.php +++ /dev/null @@ -1,122 +0,0 @@ -from, - $this->to, - ); - } - - /** - * Get the from value of the pagination. - */ - public function from(): int - { - return $this->from; - } - - /** - * Get the to value of the pagination. - */ - public function to(): int - { - return $this->to; - } - - /** - * Calculate and get the page size of the pagination. - */ - public function pageSize(): int - { - return $this->to - $this->from + 1; - } - - /** - * Calculate and get the page number of the pagination. - */ - public function pageNumber(): int - { - return (int) ($this->from / $this->pageSize()) + 1; - } - - /** - * Calculate and get the indexes of the pagination. - */ - public static function ofIndexes(int $from, int $to): self - { - $from = max(0, $from); - - $to = min( - max($to, $from), - $from + self::MAX_PAGE_SIZE - 1, - ); - - return new self($from, $to); - } - - /** - * Calculate and get the indexes of the pagination. - */ - public static function ofPage(int $pageNumber, int $pageSize): self - { - $pageNumber = max(1, $pageNumber); - - $pageSize = min( - max(1, $pageSize), - self::MAX_PAGE_SIZE, - ); - - $fromIndex = ($pageNumber - 1) * $pageSize; - $toIndex = $fromIndex + $pageSize - 1; - - return new self($fromIndex, $toIndex); - } - - /** - * Calculate and get the offset/limit of the pagination. - */ - public static function ofOffsetAndLimit(int $offset, int $limit): self - { - $offset = max(0, $offset); - - $limit = min( - max(1, $limit), - self::MAX_PAGE_SIZE, - ); - - $fromIndex = $offset; - $toIndex = $offset + $limit - 1; - - return new self($fromIndex, $toIndex); - } - - /** - * Get the first page of the pagination. - */ - public static function firstPage(int $pageSize = 25): self - { - return self::ofPage(1, $pageSize); - } -} diff --git a/src/Shared/ValueObjects/Pagination/PageNumber.php b/src/Shared/ValueObjects/Pagination/PageNumber.php new file mode 100644 index 0000000..aeead21 --- /dev/null +++ b/src/Shared/ValueObjects/Pagination/PageNumber.php @@ -0,0 +1,53 @@ +value <= 0) { + throw new \InvalidArgumentException('Page number must be greater than 0'); + } + } + + public static function of(int $value): self + { + return new self($value); + } + + public static function default(): self + { + return new self(self::DEFAULT_PAGE); + } + + public function value(): int + { + return $this->value; + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } + + public function __toString(): string + { + return (string) $this->value; + } + + public function jsonSerialize(): int + { + return $this->value; + } +} diff --git a/src/Shared/ValueObjects/Pagination/PageSize.php b/src/Shared/ValueObjects/Pagination/PageSize.php new file mode 100644 index 0000000..83c61d1 --- /dev/null +++ b/src/Shared/ValueObjects/Pagination/PageSize.php @@ -0,0 +1,60 @@ +value <= 0) { + throw new \InvalidArgumentException('Page size must be greater than 0'); + } + + if ($this->value > self::MAX_PAGE_SIZE) { + throw new \InvalidArgumentException('Page size must be less than or equal to ' . self::MAX_PAGE_SIZE); + } + } + + public static function of(int $value): self + { + return new self($value); + } + + public static function default(): self + { + return new self(self::DEFAULT_SIZE); + } + + public function value(): int + { + return $this->value; + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } + + public function __toString(): string + { + return (string) $this->value; + } + + public function jsonSerialize(): int + { + return $this->value; + } +} diff --git a/src/Shared/Helpers/Paginated.php b/src/Shared/ValueObjects/Pagination/Paginated.php similarity index 71% rename from src/Shared/Helpers/Paginated.php rename to src/Shared/ValueObjects/Pagination/Paginated.php index 1845650..84d4a09 100644 --- a/src/Shared/Helpers/Paginated.php +++ b/src/Shared/ValueObjects/Pagination/Paginated.php @@ -2,24 +2,20 @@ declare(strict_types=1); -namespace Shared\Helpers; +namespace Shared\ValueObjects\Pagination; /** @template T of object */ final readonly class Paginated { /** @param T[] $items */ public function __construct( - private Pagination $pagination, + private readonly Pagination $pagination, private array $items, private int $total ) { } - /** - * Get items of the list. - * - * @return T[] - */ + /** @return T[] */ public function getItems(): array { $items = []; @@ -32,17 +28,11 @@ final readonly class Paginated return $items; } - /** - * Get the pagination of the list. - */ public function getPagination(): Pagination { return $this->pagination; } - /** - * Get the total of the list. - */ public function getTotal(): int { return $this->total; diff --git a/src/Shared/ValueObjects/Pagination/Pagination.php b/src/Shared/ValueObjects/Pagination/Pagination.php new file mode 100644 index 0000000..5b38a80 --- /dev/null +++ b/src/Shared/ValueObjects/Pagination/Pagination.php @@ -0,0 +1,94 @@ +from; + } + + public function to(): int + { + return $this->to; + } + + public function pageSize(): int + { + return $this->to - $this->from + 1; + } + + public function pageNumber(): int + { + return (int) ($this->from / $this->pageSize()) + 1; + } + + public function pageSizeVo(): PageSize + { + return new PageSize($this->pageSize()); + } + + public function pageNumberVo(): PageNumber + { + return new PageNumber($this->pageNumber()); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $this->from === $other->from && $this->to === $other->to; + } + + public static function ofIndexes(int $from, int $to): self + { + $from = max(0, $from); + $to = clamp($to, $from + 1, $from + self::MAX_PAGE_SIZE); + + return new self($from, $to); + } + + public static function ofPage(PageNumber $pageNumber, PageSize $pageSize = new PageSize()): self + { + $fromIndex = ($pageNumber->value - 1) * $pageSize->value; + $toIndex = $fromIndex + $pageSize->value - 1; + + return new self($fromIndex, $toIndex); + } + + public static function ofOffsetAndLimit(int $offset, int $limit): self + { + $offset = max(0, $offset); + $limit = clamp($limit, 1, self::MAX_PAGE_SIZE); + + $fromIndex = $offset; + $toIndex = $offset + $limit - 1; + + return new self($fromIndex, $toIndex); + } + + public static function firstPage(PageSize $pageSize = new PageSize()): self + { + return self::ofPage(new PageNumber(1), $pageSize); + } + + public function __toString(): string + { + return sprintf('Pagination{from=%d, to=%d}', $this->from, $this->to); + } +} diff --git a/tests/Unit/Shared/Helpers/PaginatedTest.php b/tests/Unit/Shared/Helpers/PaginatedTest.php deleted file mode 100644 index 103a1cf..0000000 --- a/tests/Unit/Shared/Helpers/PaginatedTest.php +++ /dev/null @@ -1,45 +0,0 @@ -getPagination()) - ->toBe($pagination); - }); - - it('returns its total', function (): void { - // GIVEN - $pagination = Pagination::firstPage(12); - - // WHEN - $paginated = new Paginated($pagination, [], 42); - - // THEN - expect($paginated->getTotal())->toBe(42); - }); - - it('returns an empty list when there are no items', function (): void { - // GIVEN - $pagination = Pagination::firstPage(12); - - // WHEN - $paginated = new Paginated($pagination, [], 0); - - // THEN - expect($paginated->getItems())->toBe([]) - ->and($paginated->getTotal())->toBe(0); - }); -}); diff --git a/tests/Unit/Shared/Helpers/PaginationTest.php b/tests/Unit/Shared/Helpers/PaginationTest.php deleted file mode 100644 index c87292f..0000000 --- a/tests/Unit/Shared/Helpers/PaginationTest.php +++ /dev/null @@ -1,198 +0,0 @@ -from())->toBe(10) - ->and($pagination->to())->toBe(19) - ->and($pagination->pageSize())->toBe(10); - }); - - it('normalizes a negative from index', function (): void { - // GIVEN - $from = -10; - $to = 19; - - // WHEN - $pagination = Pagination::ofIndexes($from, $to); - - // THEN - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(19); - }); - - it('creates a pagination from page number and page size', function (): void { - // GIVEN - $pageNumber = 1; - $pageSize = 25; - - // WHEN - $pagination = Pagination::ofPage($pageNumber, $pageSize); - - // THEN - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(24) - ->and($pagination->pageSize())->toBe(25) - ->and($pagination->pageNumber())->toBe(1); - }); - - it('creates the second page correctly', function (): void { - // GIVEN - $pageNumber = 2; - $pageSize = 25; - - // WHEN - $pagination = Pagination::ofPage($pageNumber, $pageSize); - - // THEN - expect($pagination->from())->toBe(25) - ->and($pagination->to())->toBe(49) - ->and($pagination->pageSize())->toBe(25) - ->and($pagination->pageNumber())->toBe(2); - }); - - it('normalizes a page number below one', function (): void { - // GIVEN - $pageNumber = 0; - $pageSize = 25; - - // WHEN - $pagination = Pagination::ofPage($pageNumber, $pageSize); - - // THEN - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(24) - ->and($pagination->pageNumber())->toBe(1); - }); - - it('normalizes a negative page number', function (): void { - // GIVEN - $pageNumber = -5; - $pageSize = 25; - - // WHEN - $pagination = Pagination::ofPage($pageNumber, $pageSize); - - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(24) - ->and($pagination->pageNumber())->toBe(1); - }); - - it('normalizes a page size below one', function (): void { - // GIVEN - $pageNumber = 1; - $pageSize = 0; - - // WHEN - $pagination = Pagination::ofPage($pageNumber, $pageSize); - - // THEN - expect($pagination->pageSize())->toBe(1) - ->and($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(0); - }); - - it('limits the page size to the maximum page size', function (): void { - // GIVEN - $pageNumber = 1; - $pageSize = Pagination::MAX_PAGE_SIZE + 100; - - // WHEN - $pagination = Pagination::ofPage($pageNumber, $pageSize); - - // THEN - expect($pagination->pageSize()) - ->toBe(Pagination::MAX_PAGE_SIZE); - }); - - it('creates a pagination from offset and limit', function (): void { - // GIVEN - $pageNumber = 20; - $pageSize = 10; - - // WHEN - $pagination = Pagination::ofOffsetAndLimit($pageNumber, $pageSize); - - // THEN - expect($pagination->from())->toBe(20) - ->and($pagination->to())->toBe(29) - ->and($pagination->pageSize())->toBe(10); - }); - - it('normalizes a negative offset', function (): void { - // GIVEN - $pageNumber = -10; - $pageSize = 10; - - // WHEN - $pagination = Pagination::ofOffsetAndLimit($pageNumber, $pageSize); - - // THEN - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(9); - }); - - it('normalizes a limit below one', function (): void { - // GIVEN - $pageNumber = 10; - $pageSize = 0; - - // WHEN - $pagination = Pagination::ofOffsetAndLimit($pageNumber, $pageSize); - - // THEN - expect($pagination->from())->toBe(10) - ->and($pagination->to())->toBe(10) - ->and($pagination->pageSize())->toBe(1); - }); - - it('creates the first page with the default page size', function (): void { - // GIVEN & WHEN - $pagination = Pagination::firstPage(); - - // THEN - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(24) - ->and($pagination->pageSize())->toBe(25) - ->and($pagination->pageNumber())->toBe(1); - }); - - it('creates the first page with a custom page size', function (): void { - // GIVEN - $pageSize = 50; - - // WHEN - $pagination = Pagination::firstPage($pageSize); - - // THEN - expect($pagination->from())->toBe(0) - ->and($pagination->to())->toBe(49) - ->and($pagination->pageSize())->toBe(50); - }); - - it('returns its string representation', function (): void { - // GIVEN - $from = 10; - $to = 19; - - // WHEN - $pagination = Pagination::ofIndexes($from, $to); - - // THEN - expect((string) $pagination) - ->toBe('Pagination{from=10, to=19}'); - }); -}); diff --git a/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php b/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php new file mode 100644 index 0000000..f23a423 --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php @@ -0,0 +1,87 @@ +value)->toBe(PageNumber::DEFAULT_PAGE) + ->and($page->value())->toBe(PageNumber::DEFAULT_PAGE); + }); + + it('creates page number with explicit value', function (): void { + // WHEN + $page = new PageNumber(5); + + // THEN + expect($page->value)->toBe(5) + ->and($page->value())->toBe(5); + }); + + it('creates page number via of factory', function (): void { + // WHEN + $page = PageNumber::of(3); + + // THEN + expect($page->value)->toBe(3) + ->and($page->value())->toBe(3); + }); + + it('creates page number via default factory', function (): void { + // WHEN + $page = PageNumber::default(); + + // THEN + expect($page->value)->toBe(PageNumber::DEFAULT_PAGE); + }); + + it('rejects page number less than or equal to zero', function (int $invalidValue): void { + // WHEN / THEN + expect(fn () => new PageNumber($invalidValue)) + ->toThrow(\InvalidArgumentException::class, 'Page number must be greater than 0'); + })->with([ + 'zero' => [0], + 'negative' => [-1], + 'large negative' => [-99], + ]); + + it('implements equals correctly', function (): void { + // GIVEN + $page1 = new PageNumber(2); + $page2 = new PageNumber(2); + $page3 = new PageNumber(3); + + // THEN + expect($page1->equals($page2))->toBeTrue() + ->and($page1->equals($page3))->toBeFalse() + ->and($page1->equals(2))->toBeFalse() + ->and($page1->equals('2'))->toBeFalse() + ->and($page1->equals(null))->toBeFalse(); + }); + + it('converts to string via __toString', function (): void { + // GIVEN + $page = new PageNumber(4); + + // THEN + expect((string) $page)->toBe('4'); + }); + + it('serializes to json as integer', function (): void { + // GIVEN + $page = new PageNumber(7); + + // THEN + expect($page->jsonSerialize())->toBe(7) + ->and(json_encode($page))->toBe('7'); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php b/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php new file mode 100644 index 0000000..fa96ee3 --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php @@ -0,0 +1,101 @@ +value)->toBe(PageSize::DEFAULT_SIZE) + ->and($size->value())->toBe(PageSize::DEFAULT_SIZE); + }); + + it('creates page size with explicit value', function (): void { + // WHEN + $size = new PageSize(50); + + // THEN + expect($size->value)->toBe(50) + ->and($size->value())->toBe(50); + }); + + it('creates page size via of factory', function (): void { + // WHEN + $size = PageSize::of(10); + + // THEN + expect($size->value)->toBe(10) + ->and($size->value())->toBe(10); + }); + + it('creates page size via default factory', function (): void { + // WHEN + $size = PageSize::default(); + + // THEN + expect($size->value)->toBe(PageSize::DEFAULT_SIZE); + }); + + it('allows maximum page size', function (): void { + // WHEN + $size = new PageSize(PageSize::MAX_PAGE_SIZE); + + // THEN + expect($size->value)->toBe(PageSize::MAX_PAGE_SIZE); + }); + + it('rejects page size less than or equal to zero', function (int $invalidValue): void { + // WHEN / THEN + expect(fn () => new PageSize($invalidValue)) + ->toThrow(\InvalidArgumentException::class, 'Page size must be greater than 0'); + })->with([ + 'zero' => [0], + 'negative' => [-1], + 'large negative' => [-100], + ]); + + it('rejects page size exceeding maximum page size', function (): void { + // WHEN / THEN + expect(fn () => new PageSize(PageSize::MAX_PAGE_SIZE + 1)) + ->toThrow(\InvalidArgumentException::class, 'Page size must be less than or equal to 10000'); + }); + + it('implements equals correctly', function (): void { + // GIVEN + $size1 = new PageSize(25); + $size2 = new PageSize(25); + $size3 = new PageSize(50); + + // THEN + expect($size1->equals($size2))->toBeTrue() + ->and($size1->equals($size3))->toBeFalse() + ->and($size1->equals(25))->toBeFalse() + ->and($size1->equals('25'))->toBeFalse() + ->and($size1->equals(null))->toBeFalse(); + }); + + it('converts to string via __toString', function (): void { + // GIVEN + $size = new PageSize(15); + + // THEN + expect((string) $size)->toBe('15'); + }); + + it('serializes to json as integer', function (): void { + // GIVEN + $size = new PageSize(30); + + // THEN + expect($size->jsonSerialize())->toBe(30) + ->and(json_encode($size))->toBe('30'); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/Pagination/PaginatedTest.php b/tests/Unit/Shared/ValueObjects/Pagination/PaginatedTest.php new file mode 100644 index 0000000..2c8f6bc --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Pagination/PaginatedTest.php @@ -0,0 +1,59 @@ + 1, 'name' => 'First']; + $item2 = (object) ['id' => 2, 'name' => 'Second']; + $items = [$item1, $item2]; + $total = 42; + + // WHEN + $paginated = new Paginated($pagination, $items, $total); + + // THEN + expect($paginated->getPagination())->toBe($pagination) + ->and($paginated->getTotal())->toBe(42) + ->and($paginated->getItems())->toHaveCount(2) + ->and($paginated->getItems()[0]->id)->toBe(1) + ->and($paginated->getItems()[1]->id)->toBe(2); + }); + + it('returns cloned items so original objects are not mutated externally', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + $original = (object) ['name' => 'Original']; + $paginated = new Paginated($pagination, [$original], 1); + + // WHEN + $retrieved = $paginated->getItems(); + $retrieved[0]->name = 'Mutated'; + + // THEN + expect($paginated->getItems()[0]->name)->toBe('Original') + ->and($retrieved[0])->not->toBe($paginated->getItems()[0]); + }); + + it('handles empty items array', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + + // WHEN + $paginated = new Paginated($pagination, [], 0); + + // THEN + expect($paginated->getItems())->toBeEmpty() + ->and($paginated->getTotal())->toBe(0) + ->and($paginated->getPagination())->toBe($pagination); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/Pagination/PaginationTest.php b/tests/Unit/Shared/ValueObjects/Pagination/PaginationTest.php new file mode 100644 index 0000000..469cb7d --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Pagination/PaginationTest.php @@ -0,0 +1,151 @@ +from())->toBe(20) + ->and($pagination->to())->toBe(39) + ->and($pagination->pageSize())->toBe(20) + ->and($pagination->pageNumber())->toBe(2); + }); + + it('can be created from offset and limit', function (): void { + // GIVEN + $offset = 20; + $limit = 10; + + // WHEN + $pagination = Pagination::ofOffsetAndLimit($offset, $limit); + + // THEN + expect($pagination->from())->toBe(20) + ->and($pagination->to())->toBe(29) + ->and($pagination->pageSize())->toBe(10) + ->and($pagination->pageNumber())->toBe(3); + }); + + it('can be created from explicit indexes using ofIndexes', function (): void { + // GIVEN + $from = 5; + $to = 14; + + // WHEN + $pagination = Pagination::ofIndexes($from, $to); + + // THEN + expect($pagination->from())->toBe(5) + ->and($pagination->to())->toBe(14) + ->and($pagination->pageSize())->toBe(10); + }); + + it('can be created using first page helper with default size', function (): void { + // WHEN + $pagination = Pagination::firstPage(); + + // THEN + expect($pagination->from())->toBe(0) + ->and($pagination->to())->toBe(24) + ->and($pagination->pageSize())->toBe(25) + ->and($pagination->pageNumber())->toBe(1); + }); + + it('can be created using first page helper with explicit size', function (): void { + // GIVEN + $pageSize = new PageSize(15); + + // WHEN + $pagination = Pagination::firstPage($pageSize); + + // THEN + expect($pagination->from())->toBe(0) + ->and($pagination->to())->toBe(14) + ->and($pagination->pageSize())->toBe(15) + ->and($pagination->pageNumber())->toBe(1); + }); + + it('formats pagination as string representation via __toString', function (): void { + // GIVEN + $pagination = Pagination::ofIndexes(0, 9); + + // WHEN + $stringRepresentation = (string) $pagination; + + // THEN + expect($stringRepresentation)->toBe('Pagination{from=0, to=9}'); + }); + + it('clamps invalid constructor-level values via ofIndexes', function (int $from, int $to, int $expectedFrom, int $expectedTo): void { + // GIVEN / WHEN + $pagination = Pagination::ofIndexes($from, $to); + + // THEN + expect($pagination->from())->toBe($expectedFrom) + ->and($pagination->to())->toBe($expectedTo); + })->with([ + 'negative from is clamped to 0' => [-1, 10, 0, 10], + 'to below from+1 is clamped to from+1' => [10, 5, 10, 11], + 'to equal from is clamped to from+1' => [10, 10, 10, 11], + 'oversized range is clamped to max' => [0, 10001, 0, 10000], + ]); + + it('clamps invalid ofOffsetAndLimit parameters', function (int $offset, int $limit, int $expectedFrom, int $expectedSize): void { + // GIVEN / WHEN + $pagination = Pagination::ofOffsetAndLimit($offset, $limit); + + // THEN + expect($pagination->from())->toBe($expectedFrom) + ->and($pagination->pageSize())->toBe($expectedSize); + })->with([ + 'negative offset clamped to 0' => [-1, 10, 0, 10], + 'limit 0 clamped to 1' => [0, 0, 0, 1], + 'negative limit clamped to 1' => [0, -5, 0, 1], + 'oversized limit clamped' => [0, 20000, 0, 10000], + ]); + + it('creates pagination using ofPage with Value Objects', function (): void { + // GIVEN + $pageNumber = new PageNumber(3); + $pageSize = new PageSize(15); + + // WHEN + $pagination = Pagination::ofPage($pageNumber, $pageSize); + + // THEN + expect($pagination->from())->toBe(30) + ->and($pagination->to())->toBe(44) + ->and($pagination->pageSize())->toBe(15) + ->and($pagination->pageNumber())->toBe(3) + ->and($pagination->pageNumberVo())->toEqual($pageNumber) + ->and($pagination->pageSizeVo())->toEqual($pageSize); + }); + + it('checks equality with another Pagination instance', function (): void { + // GIVEN + $p1 = Pagination::ofPage(new PageNumber(1), new PageSize(20)); + $p2 = Pagination::ofPage(new PageNumber(1), new PageSize(20)); + $p3 = Pagination::ofPage(new PageNumber(2), new PageSize(20)); + + // THEN + expect($p1->equals($p2))->toBeTrue() + ->and($p1->equals($p3))->toBeFalse() + ->and($p1->equals(null))->toBeFalse() + ->and($p1->equals('pagination'))->toBeFalse(); + }); +});