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..8903a0e7 --- /dev/null +++ b/tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountReadModelTest.php @@ -0,0 +1,105 @@ +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 + { + $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); + + $deleted = new Bitrix24AccountBuilder()->withStatus(Bitrix24AccountStatus::new)->withInstalled()->build(); + $deleted->applicationUninstalled(null); + + $this->repository->save($new); + $this->repository->save($blocked); + $this->repository->save($deleted); + + $this->flusher->flush($new, $blocked, $deleted); + + $foundActiveAccounts = 0; + $pagination = $this->readModel->findAllActive('desc', 1, 1000); + foreach ($pagination as $account) { + self::assertSame(Bitrix24AccountStatus::active, $account->getStatus()); + + if (in_array($account->getMemberId(), $activeMemberIds, true)) { + $foundActiveAccounts++; + } + } + + self::assertSame(3, $foundActiveAccounts, 'findAllActive must return exactly all created active accounts'); + } + + #[Test] + public function testFindAllActiveThrowsOnInvalidSortDirection(): void + { + $this->expectException(InvalidArgumentException::class); + $this->readModel->findAllActive('random'); + } +}