From d92bd1162e979fb842d0393fc0c1748c447c5e17 Mon Sep 17 00:00:00 2001 From: Kirill Hramov Date: Wed, 12 Aug 2026 17:52:06 +0300 Subject: [PATCH 1/3] Introduce `Bitrix24AccountReadModel` for active accounts retrieval with pagination - Added a new read model `Bitrix24AccountReadModel` to fetch all active Bitrix24 accounts (`status = active`) with pagination and stable sorting by `createdAt`. - Implemented functional tests to verify correct filtering, pagination metadata, and sorting behavior. - Throws `InvalidArgumentException` for invalid sort directions. --- .tasks/110/active-accounts-read-model-plan.md | 81 +++++++++++++++++ .../Doctrine/Bitrix24AccountReadModel.php | 56 ++++++++++++ .../Doctrine/Bitrix24AccountReadModelTest.php | 88 +++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 .tasks/110/active-accounts-read-model-plan.md create mode 100644 src/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModel.php create mode 100644 tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php diff --git a/.tasks/110/active-accounts-read-model-plan.md b/.tasks/110/active-accounts-read-model-plan.md new file mode 100644 index 00000000..c31ef195 --- /dev/null +++ b/.tasks/110/active-accounts-read-model-plan.md @@ -0,0 +1,81 @@ +# #110 — Read model для списка активных аккаунтов Bitrix24 + +## Summary + +Нужен read-only способ получить все активные аккаунты Bitrix24 +(`status = Bitrix24AccountStatus::active`) с пагинацией и стабильной сортировкой. + +Решение согласовано в issue #110: reviewer (camaxtly) явно сказал — интерфейс +не нужен, достаточно маленькой конкретной реализации (read model). Сигнатуру +метода оставили из предложения KarlsonComplete. + +## Scope + +В рамках задачи: + +- новый конкретный класс read model в `src/Bitrix24Accounts/Infrastructure/Doctrine/` +- функциональный тест на него + +Вне задачи: + +- интерфейс для read model (отвергнуто reviewer) +- CLI-команда в этой библиотеке (ответственность consumer-приложения) +- изменения CHANGELOG.md (по запросу пользователя) +- открытие PR (пользователь открывает самостоятельно) + +## Target contract + +Метод (точная сигнатура из предложения KarlsonComplete, без интерфейса): + +```php +public function findAllActive(string $sort = 'desc', int $page = 1, int $limit = 50): PaginationInterface +``` + +Поведение: + +- `$sort` ∈ `{'asc','desc'}` — направление сортировки по полю `createdAt`. + При невалидном значении бросаем + `Bitrix24\SDK\Core\Exceptions\InvalidArgumentException` + (тот же тип исключения, что используется в bounded context). +- Фильтр: `a.status = Bitrix24AccountStatus::active` +- Возврат: `PaginationInterface` через Knp paginator + (`knplabs/knp-paginator-bundle ^6` уже в `composer.json`). +- Образец wiring/стиля: `src/Journal/Infrastructure/Doctrine/DoctrineDbalJournalItemRepository.php`. + +## Implementation changes + +1. `src/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModel.php` + + - `final` класс, без интерфейса + - конструктор: `EntityManagerInterface` + `PaginatorInterface` + - метод `findAllActive(...)` см. выше + - `QueryBuilder` по alias `a`: `where a.status = :status` (= `active->name`), + `orderBy a.createdAt` в направлении `$sort` + - пагинация: `$this->paginator->paginate($qb, $page, $limit)` + (без sort-опций Knp — сортировка зафиксирована в QueryBuilder) + +2. `tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php` + + - `setUp` по образцу `tests/Functional/Journal/Services/HandlerTest.php` + (`Paginator` с `TraceableEventDispatcher` + stub `ArgumentAccessInterface`), + изоляция через transactional `setUp`/`tearDown` (`beginTransaction` / `rollback`) + - наполнение через `Bitrix24AccountBuilder` + `Bitrix24AccountRepository::save()` + `Flusher::flush()` + +## Test cases and scenarios + +1. Функциональные: + - `findAllActive()` возвращает только active-аккаунты (есть new/blocked/deleted — они отсеиваются) + - метаданные пагинации (`getTotalItemCount`, размер текущей страницы, `limit`) + - направление сортировки: `desc` (default) = новые первыми, `asc` = старые первыми + (для детерминизма между созданием аккаунтов делаем `usleep`, чтобы миллисекундные + `createdAt` различались при precision=3) + - невалидный `$sort` бросает `InvalidArgumentException` + +## Assumptions and defaults + +- Базовая ветка: `dev` (локальная) +- Ветка работы: `feature/110-active-accounts-read-model` +- `CHANGELOG.md` не редактируем +- PR открывает пользователь самостоятельно +- Функциональные тесты требуют запущенный Docker + Postgres (`make test-functional`) +- Контроль качества только через `Makefile`: `make lint-all`, `make test-unit`, `make test-functional` diff --git a/src/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModel.php b/src/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModel.php new file mode 100644 index 00000000..286818da --- /dev/null +++ b/src/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModel.php @@ -0,0 +1,56 @@ + + * + * @throws InvalidArgumentException if $sort is neither "asc" nor "desc" + */ + public function findAllActive(string $sort = 'desc', int $page = 1, int $limit = 50): PaginationInterface + { + if ('asc' !== $sort && 'desc' !== $sort) { + throw new InvalidArgumentException( + sprintf('sort direction must be "asc" or "desc", got "%s"', $sort) + ); + } + + $queryBuilder = $this->entityManager->getRepository(Bitrix24Account::class) + ->createQueryBuilder('a') + ->where('a.status = :status') + ->setParameter('status', Bitrix24AccountStatus::active->name) + ->orderBy('a.createdAt', $sort) + ; + + return $this->paginator->paginate( + $queryBuilder, + $page, + $limit + ); + } +} diff --git a/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php new file mode 100644 index 00000000..1f5c68ac --- /dev/null +++ b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php @@ -0,0 +1,88 @@ +addSubscriber(new PaginationSubscriber()); + + $this->readModel = new Bitrix24AccountReadModel( + $entityManager, + new Paginator($traceableEventDispatcher, $this->createStub(ArgumentAccessInterface::class)) + ); + $this->repository = new Bitrix24AccountRepository($entityManager); + $this->flusher = new Flusher($entityManager, $traceableEventDispatcher); + } + + #[Test] + public function testFindAllActiveReturnsOnlyActiveAccounts(): void + { + $active = (new Bitrix24AccountBuilder())->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $new = (new Bitrix24AccountBuilder())->build(); + $blocked = (new Bitrix24AccountBuilder())->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $blocked->markAsBlocked(null); + + $deleted = (new Bitrix24AccountBuilder())->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $deleted->applicationUninstalled(null); + + $this->repository->save($active); + $this->repository->save($new); + $this->repository->save($blocked); + $this->repository->save($deleted); + + $this->flusher->flush($active, $new, $blocked, $deleted); + + $found = []; + $pagination = $this->readModel->findAllActive('desc', 1, 1000); + foreach ($pagination as $account) { + self::assertSame(Bitrix24AccountStatus::active, $account->getStatus()); + $found[] = $account->getId()->toRfc4122(); + } + + self::assertContains($active->getId()->toRfc4122(), $found); + self::assertNotContains($new->getId()->toRfc4122(), $found); + self::assertNotContains($blocked->getId()->toRfc4122(), $found); + self::assertNotContains($deleted->getId()->toRfc4122(), $found); + } + + #[Test] + public function testFindAllActiveThrowsOnInvalidSortDirection(): void + { + $this->expectException(InvalidArgumentException::class); + $this->readModel->findAllActive('random'); + } +} From 8cc8d3791b7e4876b57fc57fe9ec2a2f50b2c796 Mon Sep 17 00:00:00 2001 From: Kirill Hramov Date: Thu, 13 Aug 2026 12:53:25 +0300 Subject: [PATCH 2/3] Refactor test setup in `Bitrix24AccountReadModelTest` to simplify builder usage --- .../Doctrine/Bitrix24AccountReadModelTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php index 1f5c68ac..c2ab0361 100644 --- a/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php +++ b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php @@ -51,12 +51,12 @@ protected function setUp(): void #[Test] public function testFindAllActiveReturnsOnlyActiveAccounts(): void { - $active = (new Bitrix24AccountBuilder())->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); - $new = (new Bitrix24AccountBuilder())->build(); - $blocked = (new Bitrix24AccountBuilder())->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $active = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $new = new Bitrix24AccountBuilder()->build(); + $blocked = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); $blocked->markAsBlocked(null); - $deleted = (new Bitrix24AccountBuilder())->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $deleted = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); $deleted->applicationUninstalled(null); $this->repository->save($active); From 45c99b1f4079d8edcdbef65160a0b14bf00c23ef Mon Sep 17 00:00:00 2001 From: Kirill Hramov Date: Mon, 17 Aug 2026 12:06:47 +0300 Subject: [PATCH 3/3] Enhance `Bitrix24AccountReadModelTest` to validate active accounts retrieval with multiple member IDs --- .../Doctrine/Bitrix24AccountReadModelTest.php | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php index c2ab0361..8903a0e7 100644 --- a/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php +++ b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php @@ -20,6 +20,7 @@ use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Uid\Uuid; /** * @internal @@ -51,7 +52,24 @@ protected function setUp(): void #[Test] public function testFindAllActiveReturnsOnlyActiveAccounts(): void { - $active = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $activeMemberIds = [ + Uuid::v4()->toRfc4122(), + Uuid::v4()->toRfc4122(), + Uuid::v4()->toRfc4122(), + ]; + + foreach ($activeMemberIds as $activeMemberId) { + $activeAccount = new Bitrix24AccountBuilder() + ->withMemberId($activeMemberId) + ->withStatus(Bitrix24AccountStatus::new) + ->withInstalled() + ->build(); + + $this->repository->save($activeAccount); + + $this->flusher->flush($activeAccount); + } + $new = new Bitrix24AccountBuilder()->build(); $blocked = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); $blocked->markAsBlocked(null); @@ -59,24 +77,23 @@ public function testFindAllActiveReturnsOnlyActiveAccounts(): void $deleted = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); $deleted->applicationUninstalled(null); - $this->repository->save($active); $this->repository->save($new); $this->repository->save($blocked); $this->repository->save($deleted); - $this->flusher->flush($active, $new, $blocked, $deleted); + $this->flusher->flush($new, $blocked, $deleted); - $found = []; + $foundActiveAccounts = 0; $pagination = $this->readModel->findAllActive('desc', 1, 1000); foreach ($pagination as $account) { self::assertSame(Bitrix24AccountStatus::active, $account->getStatus()); - $found[] = $account->getId()->toRfc4122(); + + if (in_array($account->getMemberId(), $activeMemberIds, true)) { + $foundActiveAccounts++; + } } - self::assertContains($active->getId()->toRfc4122(), $found); - self::assertNotContains($new->getId()->toRfc4122(), $found); - self::assertNotContains($blocked->getId()->toRfc4122(), $found); - self::assertNotContains($deleted->getId()->toRfc4122(), $found); + self::assertSame(3, $foundActiveAccounts, 'findAllActive must return exactly all created active accounts'); } #[Test]