Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .tasks/110/active-accounts-read-model-plan.md
Original file line number Diff line number Diff line change
@@ -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<Bitrix24Account>` через 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`
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine;

use Bitrix24\Lib\Bitrix24Accounts\Entity\Bitrix24Account;
use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus;
use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\Pagination\PaginationInterface;
use Knp\Component\Pager\PaginatorInterface;

/**
* Read-only access to Bitrix24 accounts for listing / reporting use cases.
*/
final readonly class Bitrix24AccountReadModel
{
public function __construct(
private EntityManagerInterface $entityManager,
private PaginatorInterface $paginator
) {}

/**
* Returns all active Bitrix24 accounts with pagination and stable ordering by createdAt.
*
* @param string $sort sort direction by account createdAt, 'asc' or 'desc'
* @param int $page 1-based page number
* @param int $limit number of items per page
*
* @return PaginationInterface<Bitrix24Account>
*
* @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
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Infrastructure\Doctrine;

use Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine\Bitrix24AccountReadModel;
use Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine\Bitrix24AccountRepository;
use Bitrix24\Lib\Services\Flusher;
use Bitrix24\Lib\Tests\EntityManagerFactory;
use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder;
use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus;
use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
use Knp\Component\Pager\ArgumentAccess\ArgumentAccessInterface;
use Knp\Component\Pager\Event\Subscriber\Paginate\PaginationSubscriber;
use Knp\Component\Pager\Paginator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\Uid\Uuid;

/**
* @internal
*/
#[CoversClass(Bitrix24AccountReadModel::class)]
class Bitrix24AccountReadModelTest extends TestCase
{
private Bitrix24AccountReadModel $readModel;

private Bitrix24AccountRepository $repository;

private Flusher $flusher;

#[\Override]
protected function setUp(): void
{
$entityManager = EntityManagerFactory::get();
$traceableEventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
$traceableEventDispatcher->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
Comment thread
KarlsonComplete marked this conversation as resolved.
{
$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');
}
}
Loading