diff --git a/.tasks/92/mark-old-installations-plan.md b/.tasks/92/mark-old-installations-plan.md new file mode 100644 index 00000000..8b745f8b --- /dev/null +++ b/.tasks/92/mark-old-installations-plan.md @@ -0,0 +1,174 @@ +## План по issue #92: фоновая очистка зависших установок — статус needReinstall + +### Summary + +Issue #92 — фоновая очистка зависших установок в статусе `new`, до которых не дошёл `ONAPPINSTALL`. +Реализуется в два этапа: сначала добавление нового статуса `needReinstall` в SDK, затем использование его в этой библиотеке. + +### Scope + +**В scope:** +- Новый статус `needReinstall` в SDK (`ApplicationInstallationStatus`) +- Метод `markAsNeedReinstall()` в SDK entity и интерфейсе +- Обновление guard в `applicationUninstalled()` для `needReinstall` +- Новое доменное событие `ApplicationInstallationMarkedNeedReinstallEvent` +- UseCase `MarkOldInstallations` (Workflow → Handler, поиск + перевод в `needReinstall`) +- Repository метод `findStaleInstallations()` в существующем `ApplicationInstallationRepository` +- Console-команда `bitrix24:installations:mark-old` +- Unit + Functional тесты +- Документация и CHANGELOG + +**Out of scope:** +- Фильтр по `memberId` — ищем все зависшие установки независимо от портала +- `dry-run` режим — убрали, не нужен +- Проверка реального состояния портала через SDK (`--verify-portal`) +- Автоматическое планирование (cron, worker) — задача потребителя +- Изменение `Bitrix24AccountStatus` — статус `needReinstall` только у `ApplicationInstallation` +- UseCase переустановки — достаточно существующего `Install`, который через `applicationUninstalled` удалит `needReinstall` установку + +### Target Contract + +1. **Машина состояний ApplicationInstallation после изменений:** +``` +new → active (applicationInstalled) +new → blocked (markAsBlocked) +new → needReinstall (markAsNeedReinstall) ← НОВОЕ +blocked → active (markAsActive) +blocked → deleted (applicationUninstalled) +active → blocked (markAsBlocked) +active → deleted (applicationUninstalled) +needReinstall → deleted (applicationUninstalled) ← НОВОЕ +``` + +2. **markOldInstallations flow (после рефакторинга по ревью):** +``` +Console command (ttl=3600 по дефолту) + → валидация ttl inline (>= 0) + → repository.findStaleInstallations(status=new, olderThan=NOW()-ttl) // query side, CQRS read + → foreach stale: + → MarkAsNeedReinstall\Handler::handle(Command(installationId, comment)) // command side + → getById(installationId) // identity map: без доп. SQL, ре-проверка guard от race с ONAPPINSTALL + → installation.markAsNeedReinstall(comment) + → repository.save + flusher.flush → событие ApplicationInstallationMarkedNeedReinstallEvent + → catch LogicException → skip (race condition: статус изменился параллельно) + → рендер: помеченные ID + count + пропущенные ID +``` + +> Рефакторинг по итогам ревью: use case — единоразовая операция над одним агрегатом (без foreach), +> батч-оркестрация вынесена в Console command. Workflow/Config/Result/Collector удалены. + +3. **Переустановка после markOldInstallations:** +``` +Install\Command (повторная установка) + → Install\Handler::handle() + → findByBitrix24AccountMemberId находит installation в needReinstall + → deactivateCurrentInstallation() + → markAsBlocked пропускается (только для status=new) + → applicationUninstalled(null) // needReinstall → deleted (guard обновлён) + → удаляются ВСЕ аккаунты портала (не только master), см. Install/Handler.php:125-138 + → создание новой пары +``` + +### Implementation Changes + +#### PR 1: SDK (bitrix24/b24phpsdk) — разбит на issues #576–#580 + +**Файлы SDK:** + +| Файл | Изменение | +|---|---| +| `src/Application/Contracts/ApplicationInstallations/Entity/ApplicationInstallationStatus.php` | Добавить `case needReinstall = 'needReinstall'` | +| `src/Application/Contracts/ApplicationInstallations/Entity/ApplicationInstallationInterface.php` | Добавить метод `markAsNeedReinstall(?string $comment): void` + обновить docblock `applicationUninstalled` | +| `src/Application/Contracts/ApplicationInstallations/Events/ApplicationInstallationMarkedNeedReinstallEvent.php` | Новое событие (`applicationInstallationId: Uuid`, `timestamp: CarbonImmutable`, `comment: ?string`) | + +> Временно изменения применены прямо в `vendor/bitrix24/b24phpsdk/`, будут формализованы в SDK PR. + +#### PR 2: bitrix24-php-lib (этот репозиторий) + +**Домен — `src/ApplicationInstallations/Entity/ApplicationInstallation.php`:** + +| Изменение | Детали | +|---|---| +| `markAsNeedReinstall(?string $comment)` | Переход `new → needReinstall`, emit `ApplicationInstallationMarkedNeedReinstallEvent` | +| Guard в `applicationUninstalled()` | Добавить `needReinstall` в допустимые статусы (прямой переход `needReinstall → deleted`, без `blocked`) | + +**Repository — `src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php`:** + +Метод `findStaleInstallations(ApplicationInstallationStatus $status, CarbonImmutable $olderThan): array`: +- Без `memberId` фильтра +- Без JOIN к `Bitrix24Account` +- WHERE `status = :status AND createdAt < :olderThan` +- ORDER BY `createdAt ASC` + +**UseCase — `src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/`** (единоразовая операция над одним агрегатом): + +| Файл | Содержание | +|---|---| +| `Command.php` | `installationId: Uuid`, `comment: ?string` | +| `Handler.php` | `handle(Command): void` — `getById` → `markAsNeedReinstall(comment)` → save → flush → лог. Комментарий у `getById` про identity map | + +**Удалено (после рефакторинга по ревью):** весь `UseCase/MarkOldInstallations/` — Workflow, Handler, Command, MarkOldInstallationsConfig, MarkOldInstallationsResult, MarkOldInstallationsCollector. + +**Console — `src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php`** (батч-оркестратор): + +```php +#[AsCommand(name: 'bitrix24:installations:mark-old')] +class MarkOldInstallationsCommand extends Command +{ + public const DEFAULT_TTL = 3600; + // аргумент: ttl (опциональный, дефолт DEFAULT_TTL), валидация inline + // конструктор: ApplicationInstallationRepository + MarkAsNeedReinstall\Handler + // findStaleInstallations(new, NOW()-ttl) → foreach → handler->handle(new Command(id, comment)) + // catch LogicException → skip (race condition с ONAPPINSTALL) + // рендер: помеченные ID + count, пропущенные ID +} +``` + +**Документация — `src/ApplicationInstallations/Docs/application-installations.md`:** +- Добавить секцию «Stale Installation Cleanup» с описанием flow +- Sequence diagram для markOldInstallations +- Sequence diagram для reinstall после needReinstall +- Обновить state machine — новые переходы +- Обновить секцию Follow-Up — отметить что issue #92 реализован + +**CHANGELOG.md:** +- Новая запись: feature — mark old installations as needReinstall via background command + +### Test Cases + +#### Unit Tests — удалены после рефакторинга + +> ConfigTest удалён вместе с MarkOldInstallationsConfig (валидация TTL теперь inline в Console command, консоль не тестируем unit-тестами). +> Entity-тесты (`markAsNeedReinstall` transitions) — идут в SDK, не в этот репо. + +#### Functional Tests — ✅ реализованы + +**MarkAsNeedReinstall/HandlerTest** (`tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php`): +1. Pending installation в `new` → handle → статус `needReinstall`, comment сохранён, событие `ApplicationInstallationMarkedNeedReinstallEvent` диспатчнуто +2. Active installation → `LogicException` +3. Неизвестный installationId → `ApplicationInstallationNotFoundException` + +**ApplicationInstallationRepositoryTest** — добавлен `testFindStaleInstallationsReturnsOnlyOldEnoughNewOnes`: +- Old `new`-installation (createdAt=NOW()-2h, TTL=3600) → найдена +- Fresh `new`-installation → не найдена +- Old `active`-installation (createdAt=NOW()-2h) → не найдена (статус не тот) +- `backdateCreatedAt` через DQL UPDATE (createdAt readonly в конструкторе) + +**Install/HandlerTest** — добавлен тест `testReinstallOverNeedReinstallInstallationDeletesOldEntitiesAndCreatesNewPendingPair`: +- Reinstall поверх `needReinstall` → старая `deleted`, новая пара создана +- `markAsBlocked` пропущен (срабатывает только для `new`), сразу `applicationUninstalled(null)` → `deleted` +- Событие `ApplicationInstallationBlockedEvent` НЕ диспатчнуто + +> Удалены после рефакторинга: MarkOldInstallations/ConfigTest, HandlerTest, WorkflowTest (unit + functional). + +### Assumptions + +- `needReinstall` добавляется только в `ApplicationInstallationStatus`, не в `Bitrix24AccountStatus` +- Мастер-аккаунт при markOld не меняет статус — остаётся в `new` +- `applicationUninstalled(null)` из `needReinstall → deleted` не требует блокировки +- Метод `findStaleInstallations` временно живёт в локальном репозитории; в SDK-интерфейс пойдёт отдельным issue ([b24phpsdk#579](https://github.com/bitrix24/b24phpsdk/issues/579)) +- Scheduling (cron/worker) — ответственность потребителя библиотеки +- Константа TTL по умолчанию = 3600 секунд, находится в Console command (`DEFAULT_TTL`) +- При reinstall удаляются ВСЕ аккаунты портала (master + child), а не только master — см. `Install/Handler.php:125-138` +- Use case обрабатывает ровно один агрегат; повторная загрузка через `getById` не даёт доп. SQL (Doctrine identity map) и защищает guard от race с ONAPPINSTALL +- SDK issues: [#576](https://github.com/bitrix24/b24phpsdk/issues/576) (enum), [#577](https://github.com/bitrix24/b24phpsdk/issues/577) (interface method), [#578](https://github.com/bitrix24/b24phpsdk/issues/578) (event), [#579](https://github.com/bitrix24/b24phpsdk/issues/579) (repo interface), [#580](https://github.com/bitrix24/b24phpsdk/issues/580) (reference impl + tests) diff --git a/src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php b/src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php new file mode 100644 index 00000000..79379709 --- /dev/null +++ b/src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php @@ -0,0 +1,139 @@ +addArgument( + 'ttl', + InputArgument::OPTIONAL, + sprintf('Time to live in seconds (default: %d)', self::DEFAULT_TTL), + (string) self::DEFAULT_TTL + ) + ->setHelp( + <<<'HELP' +The bitrix24:installations:mark-old command finds pending installations +in status "new" older than the given TTL and marks them as "needReinstall". + +Mark installations older than 1 hour (default): + php bin/console bitrix24:installations:mark-old + +Mark installations older than 30 minutes: + php bin/console bitrix24:installations:mark-old 1800 +HELP + ) + ; + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io = new SymfonyStyle($input, $output); + + $ttl = (int) $input->getArgument('ttl'); + if ($ttl < 0) { + $this->io->error('TTL in seconds must be a non-negative integer.'); + + return Command::FAILURE; + } + + $olderThan = new CarbonImmutable(); + $olderThan = $olderThan->subSeconds($ttl); + + $staleInstallations = $this->applicationInstallationRepository->findStaleInstallations( + ApplicationInstallationStatus::new, + $olderThan + ); + + return $this->processStaleInstallations($staleInstallations, $ttl); + } + + private function processStaleInstallations(array $staleInstallations, int $ttl): int + { + if ([] === $staleInstallations) { + $this->io->success('No stale installations found.'); + + return Command::SUCCESS; + } + + $comment = sprintf('installation timed out without ONAPPINSTALL, TTL = %d seconds', $ttl); + + $markedIds = []; + $failedIds = []; + + foreach ($staleInstallations as $staleInstallation) { + $installationId = $staleInstallation->getId(); + + try { + $this->markAsNeedReinstallHandler->handle( + new MarkAsNeedReinstallCommand($installationId, $comment) + ); + + $markedIds[] = $installationId; + } catch (LogicException) { + // Installation changed status concurrently (e.g. ONAPPINSTALL arrived) — skip it. + $failedIds[] = $installationId; + } + } + + return $this->renderResult($markedIds, $failedIds); + } + + /** + * @param Uuid[] $markedIds + * @param Uuid[] $failedIds + */ + private function renderResult(array $markedIds, array $failedIds): int + { + $this->io->success(sprintf('Marked %d installation(s) as needReinstall:', count($markedIds))); + + foreach ($markedIds as $installationId) { + $this->io->text(sprintf(' - installation %s', $installationId->toRfc4122())); + } + + if ([] !== $failedIds) { + $this->io->warning(sprintf('Skipped %d installation(s) changed status concurrently:', count($failedIds))); + + foreach ($failedIds as $installationId) { + $this->io->text(sprintf(' - installation %s', $installationId->toRfc4122())); + } + } + + return Command::SUCCESS; + } +} diff --git a/src/ApplicationInstallations/Entity/ApplicationInstallation.php b/src/ApplicationInstallations/Entity/ApplicationInstallation.php index bda0eea2..6463c170 100644 --- a/src/ApplicationInstallations/Entity/ApplicationInstallation.php +++ b/src/ApplicationInstallations/Entity/ApplicationInstallation.php @@ -13,6 +13,7 @@ use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationContactPersonLinkedEvent; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationCreatedEvent; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationFinishedEvent; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationMarkedNeedReinstallEvent; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationUnblockedEvent; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationUninstalledEvent; use Bitrix24\SDK\Application\PortalLicenseFamily; @@ -166,10 +167,11 @@ public function applicationUninstalled(?string $applicationToken = null): void if ( ApplicationInstallationStatus::active !== $this->status && ApplicationInstallationStatus::blocked !== $this->status + && ApplicationInstallationStatus::needReinstall !== $this->status ) { throw new LogicException( sprintf( - 'installation was interrupted because status must be in active or blocked, but your status is %s', + 'installation was interrupted because status must be in active, blocked or needReinstall, but your status is %s', $this->status->value ) ); @@ -241,6 +243,26 @@ public function markAsBlocked(?string $comment): void ); } + public function markAsNeedReinstall(?string $comment): void + { + if (ApplicationInstallationStatus::new !== $this->status) { + throw new LogicException(sprintf( + 'you can mark application installation as need reinstall only in status new, but your status is «%s»', + $this->status->value + )); + } + + $this->status = ApplicationInstallationStatus::needReinstall; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new ApplicationInstallationMarkedNeedReinstallEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + #[\Override] public function getApplicationStatus(): ApplicationStatus { diff --git a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php index 28717da4..066ea233 100644 --- a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php +++ b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php @@ -11,6 +11,7 @@ use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterface; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Carbon\CarbonImmutable; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\Expr\Join; @@ -184,4 +185,26 @@ public function findByBitrix24AccountMemberId(string $memberId): ?ApplicationIns ->getOneOrNullResult() ; } + + public function findStaleInstallations( + ApplicationInstallationStatus $status, + CarbonImmutable $olderThan + ): array { + $queryBuilder = $this->getEntityManager()->getRepository(ApplicationInstallation::class) + ->createQueryBuilder('ai') + ; + + $queryBuilder + ->where('ai.status = :status') + ->andWhere('ai.createdAt < :olderThan') + ->setParameter('status', $status) + ->setParameter('olderThan', $olderThan) + ; + + return $queryBuilder + ->orderBy('ai.createdAt', 'ASC') + ->getQuery() + ->getResult() + ; + } } diff --git a/src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/Command.php b/src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/Command.php new file mode 100644 index 00000000..5429631f --- /dev/null +++ b/src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/Command.php @@ -0,0 +1,15 @@ +logger->info('ApplicationInstallations.MarkAsNeedReinstall.start', [ + 'installationId' => $command->installationId->toRfc4122(), + ]); + + // getById does not produce an extra SQL query when the entity is already + // managed by Doctrine's identity map (e.g. loaded by findStaleInstallations + // in the same session). Re-loading here also re-checks the status guard + // against a race with a concurrently arriving ONAPPINSTALL. + $applicationInstallation = $this->applicationInstallationRepository->getById($command->installationId); + assert($applicationInstallation instanceof AggregateRootEventsEmitterInterface); + + $applicationInstallation->markAsNeedReinstall($command->comment); + + $this->applicationInstallationRepository->save($applicationInstallation); + $this->flusher->flush($applicationInstallation); + + $this->logger->info('ApplicationInstallations.MarkAsNeedReinstall.finish', [ + 'installationId' => $command->installationId->toRfc4122(), + 'createdAt' => $applicationInstallation->getCreatedAt()->toAtomString(), + ]); + } +} diff --git a/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php b/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php index 925d0aca..5d0b2e2b 100644 --- a/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php +++ b/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php @@ -6,14 +6,19 @@ use Bitrix24\Lib\ApplicationInstallations\Entity\ApplicationInstallation; use Bitrix24\Lib\ApplicationInstallations\Infrastructure\Doctrine\ApplicationInstallationRepository; +use Bitrix24\Lib\Bitrix24Accounts\Entity\Bitrix24Account; use Bitrix24\SDK\Application\ApplicationStatus; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationInterface; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterface; use Bitrix24\SDK\Application\PortalLicenseFamily; +use Bitrix24\SDK\Core\Credentials\AuthToken; +use Bitrix24\SDK\Core\Credentials\Scope; use Bitrix24\SDK\Tests\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterfaceTest; use Bitrix24\SDK\Tests\Application\Contracts\TestRepositoryFlusherInterface; use Carbon\CarbonImmutable; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\Test; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Uid\Uuid; use PHPUnit\Framework\Attributes\CoversClass; @@ -72,5 +77,83 @@ protected function createRepositoryFlusherImplementation(): TestRepositoryFlushe return new FlusherDecorator(new Flusher($entityManager, $eventDispatcher)); } + #[Test] + public function testFindStaleInstallationsReturnsOnlyOldEnoughNewOnes(): void + { + $entityManager = EntityManagerFactory::get(); + $repository = new ApplicationInstallationRepository($entityManager); + + $oldInstallation = $this->persistInstallation($entityManager); + $this->backdateCreatedAt($entityManager, $oldInstallation->getId(), new CarbonImmutable('-2 hours')); + + $freshInstallation = $this->persistInstallation($entityManager); + $activeInstallation = $this->persistInstallation($entityManager); + $activeInstallation->applicationInstalled(Uuid::v7()->toRfc4122()); + $this->backdateCreatedAt($entityManager, $activeInstallation->getId(), new CarbonImmutable('-2 hours')); + $entityManager->flush(); + $entityManager->clear(); + + $found = $repository->findStaleInstallations( + ApplicationInstallationStatus::new, + (new CarbonImmutable())->subSeconds(3600) + ); + + $foundIds = array_map( + static fn (ApplicationInstallationInterface $installation): string => $installation->getId()->toRfc4122(), + $found + ); + + self::assertContains($oldInstallation->getId()->toRfc4122(), $foundIds); + self::assertNotContains($freshInstallation->getId()->toRfc4122(), $foundIds); + self::assertNotContains($activeInstallation->getId()->toRfc4122(), $foundIds); + } + + private function persistInstallation(EntityManagerInterface $entityManager): ApplicationInstallation + { + $bitrix24Account = new Bitrix24Account( + Uuid::v7(), + 1, + true, + Uuid::v4()->toRfc4122(), + 'example.bitrix24.test', + new AuthToken('access', 'refresh', 3600, time() + 3600), + 1, + new Scope(['crm']), + true + ); + + $applicationInstallation = new ApplicationInstallation( + Uuid::v7(), + $bitrix24Account->getId(), + new ApplicationStatus('F'), + PortalLicenseFamily::free, + 10, + null, + null, + null, + 'lead-1', + 'install' + ); + + $entityManager->persist($bitrix24Account); + $entityManager->persist($applicationInstallation); + $entityManager->flush(); + + return $applicationInstallation; + } + + private function backdateCreatedAt( + EntityManagerInterface $entityManager, + Uuid $installationId, + CarbonImmutable $createdAt + ): void { + $entityManager->createQuery( + 'UPDATE ' . ApplicationInstallation::class . ' ai SET ai.createdAt = :createdAt WHERE ai.id = :id' + ) + ->setParameter('createdAt', $createdAt) + ->setParameter('id', $installationId, 'uuid') + ->execute(); + } + } diff --git a/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php index e3b6103b..e1eb3944 100644 --- a/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php +++ b/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php @@ -152,6 +152,36 @@ public function testReinstallOverPendingInstallationDeletesOldEntitiesAndCreates self::assertNotContains(ApplicationInstallationFinishedEvent::class, $events); } + #[Test] + public function testReinstallOverNeedReinstallInstallationDeletesOldEntitiesAndCreatesNewPendingPair(): void + { + $memberId = Uuid::v4()->toRfc4122(); + $bitrix24Account = $this->createAccount($memberId); + $applicationInstallation = $this->createInstallation($bitrix24Account->getId()); + $applicationInstallation->markAsNeedReinstall('installation timed out without ONAPPINSTALL'); + + $this->entityManager->persist($bitrix24Account); + $this->entityManager->persist($applicationInstallation); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $this->handler->handle($this->createCommand(null, $memberId)); + $this->entityManager->clear(); + + /** @var ApplicationInstallation $deletedInstallation */ + $deletedInstallation = $this->entityManager->find(ApplicationInstallation::class, $applicationInstallation->getId()); + $currentInstallation = $this->installationRepository->findByBitrix24AccountMemberId($memberId); + + self::assertNotNull($currentInstallation); + self::assertSame(ApplicationInstallationStatus::deleted, $deletedInstallation->getStatus()); + self::assertSame(ApplicationInstallationStatus::new, $currentInstallation->getStatus()); + self::assertNotSame($applicationInstallation->getId()->toRfc4122(), $currentInstallation->getId()->toRfc4122()); + + $events = $this->eventDispatcher->getOrphanedEvents(); + self::assertContains(ApplicationInstallationUninstalledEvent::class, $events); + self::assertNotContains(ApplicationInstallationBlockedEvent::class, $events); + } + private function createCommand(?string $applicationToken = null, ?string $memberId = null): Command { return new Command( diff --git a/tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php new file mode 100644 index 00000000..930c24eb --- /dev/null +++ b/tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php @@ -0,0 +1,130 @@ +entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->installationRepository = new ApplicationInstallationRepository($this->entityManager); + + $this->handler = new Handler( + $this->installationRepository, + new Flusher($this->entityManager, $this->eventDispatcher), + new NullLogger() + ); + } + + #[Test] + public function testPendingInstallationIsMarkedAsNeedReinstall(): void + { + $installation = $this->persistInstallation(); + + $this->handler->handle(new Command($installation->getId(), 'installation timed out without ONAPPINSTALL')); + $this->entityManager->clear(); + + $updated = $this->installationRepository->getById($installation->getId()); + + self::assertSame(ApplicationInstallationStatus::needReinstall, $updated->getStatus()); + self::assertSame('installation timed out without ONAPPINSTALL', $updated->getComment()); + + $events = $this->eventDispatcher->getOrphanedEvents(); + self::assertContains(ApplicationInstallationMarkedNeedReinstallEvent::class, $events); + } + + #[Test] + public function testActiveInstallationThrowsLogicException(): void + { + $installation = $this->persistInstallation(); + $installation->applicationInstalled(Uuid::v7()->toRfc4122()); + $this->entityManager->flush(); + + $this->expectException(LogicException::class); + + $this->handler->handle(new Command($installation->getId(), 'installation timed out without ONAPPINSTALL')); + } + + #[Test] + public function testUnknownInstallationIdThrowsNotFoundException(): void + { + $this->expectException(ApplicationInstallationNotFoundException::class); + + $this->handler->handle(new Command(Uuid::v7(), 'installation timed out without ONAPPINSTALL')); + } + + private function persistInstallation(): ApplicationInstallation + { + $bitrix24Account = new Bitrix24Account( + Uuid::v7(), + 1, + true, + Uuid::v4()->toRfc4122(), + 'example.bitrix24.test', + new AuthToken('access', 'refresh', 3600, time() + 3600), + 1, + new Scope(['crm']), + true + ); + + $installation = new ApplicationInstallation( + Uuid::v7(), + $bitrix24Account->getId(), + new ApplicationStatus('F'), + PortalLicenseFamily::free, + 10, + null, + null, + null, + 'lead-1', + 'install' + ); + + $this->entityManager->persist($bitrix24Account); + $this->entityManager->persist($installation); + $this->entityManager->flush(); + + return $installation; + } +}