diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 00000000..09c13ef2 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,58 @@ +name: "Build Docker image" + +on: + push: + paths: + - "docker/php-cli/Dockerfile" + workflow_dispatch: + +concurrency: + group: docker-build + cancel-in-progress: true + +jobs: + build: + name: "Build & push php-cli" + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: "Get metadata" + id: meta + run: | + echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + echo "rfc3339=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + + - name: "Checkout" + uses: actions/checkout@v4 + + - name: "Login to GitHub Container Registry" + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: "Setup Docker QEMU" + uses: docker/setup-qemu-action@v3 + + - name: "Setup Docker Buildx" + uses: docker/setup-buildx-action@v3 + + - name: "Build & push image" + uses: docker/build-push-action@v6 + with: + context: ./docker/php-cli + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/mesilov/bitrix24-php-lib:php-cli + ghcr.io/mesilov/bitrix24-php-lib:php-cli-${{ steps.meta.outputs.short_sha }} + labels: | + org.opencontainers.image.source=${{ github.event.repository.html_url }} + org.opencontainers.image.created=${{ steps.meta.outputs.rfc3339 }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 621a34b4..730b7ce5 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -1,4 +1,5 @@ name: "Allowed licenses checks" + on: push: pull_request: @@ -6,38 +7,25 @@ on: jobs: static-analysis: name: "composer-license-checker" - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.4" - dependencies: [ highest ] - operating-system: [ ubuntu-latest] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + container: + image: ghcr.io/mesilov/bitrix24-php-lib:php-cli + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - extensions: json, bcmath, curl, intl, mbstring - tools: composer:v2 - - - name: "Install lowest dependencies" - if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" + uses: actions/checkout@v4 - - name: "Install highest dependencies" - if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" + - name: "Install dependencies" + run: composer update --no-interaction --no-progress --no-suggest - name: "composer-license-checker" - run: "php vendor/bin/composer-license-checker" + run: php vendor/bin/composer-license-checker - name: "is allowed licenses check succeeded" if: ${{ success() }} @@ -47,4 +35,4 @@ jobs: - name: "is allowed licenses check failed" if: ${{ failure() }} run: | - echo '::error:: ❗️ allowed licenses check failed (╯°益°)╯彡┻━┻' \ No newline at end of file + echo '::error:: ❗️ allowed licenses check failed (╯°益°)╯彡┻━┻' diff --git a/.github/workflows/lint-cs-fixer.yml b/.github/workflows/lint-cs-fixer.yml index d49006ad..1695f517 100644 --- a/.github/workflows/lint-cs-fixer.yml +++ b/.github/workflows/lint-cs-fixer.yml @@ -1,44 +1,31 @@ +name: Lint CS-Fixer + on: push: pull_request: -name: Lint CS-Fixer - jobs: static-analysis: name: "CS-Fixer" - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.4" - dependencies: [ highest ] - operating-system: [ ubuntu-latest] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + container: + image: ghcr.io/mesilov/bitrix24-php-lib:php-cli + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - extensions: json, bcmath, curl, intl, mbstring - tools: composer:v2 - - - name: "Install lowest dependencies" - if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" + uses: actions/checkout@v4 - - name: "Install highest dependencies" - if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" + - name: "Install dependencies" + run: composer update --no-interaction --no-progress --no-suggest - name: "CS-Fixer" - run: "vendor/bin/php-cs-fixer fix --dry-run --diff --verbose" + run: vendor/bin/php-cs-fixer fix --dry-run --diff --verbose - name: "is CS-Fixer check succeeded" if: ${{ success() }} @@ -48,4 +35,4 @@ jobs: - name: "is CS-Fixer check failed" if: ${{ failure() }} run: | - echo '::error:: ❗️ CS-Fixer check failed (╯°益°)╯彡┻━┻' \ No newline at end of file + echo '::error:: ❗️ CS-Fixer check failed (╯°益°)╯彡┻━┻' diff --git a/.github/workflows/lint-phpstan.yml b/.github/workflows/lint-phpstan.yml index 165a4911..13f85190 100644 --- a/.github/workflows/lint-phpstan.yml +++ b/.github/workflows/lint-phpstan.yml @@ -1,44 +1,31 @@ +name: PHPStan lint checks + on: push: pull_request: -name: PHPStan lint checks - jobs: static-analysis: name: "PHPStan" - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.4" - dependencies: [ highest ] - operating-system: [ ubuntu-latest] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + container: + image: ghcr.io/mesilov/bitrix24-php-lib:php-cli + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - extensions: json, bcmath, curl, intl, mbstring - tools: composer:v2 - - - name: "Install lowest dependencies" - if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" + uses: actions/checkout@v4 - - name: "Install highest dependencies" - if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" + - name: "Install dependencies" + run: composer update --no-interaction --no-progress --no-suggest - name: "PHPStan" - run: "vendor/bin/phpstan --memory-limit=2G analyse" + run: vendor/bin/phpstan --memory-limit=2G analyse - name: "is PHPStan check succeeded" if: ${{ success() }} @@ -48,4 +35,4 @@ jobs: - name: "is PHPStan check failed" if: ${{ failure() }} run: | - echo '::error:: ❗️ PHPStan check failed (╯°益°)╯彡┻━┻' \ No newline at end of file + echo '::error:: ❗️ PHPStan check failed (╯°益°)╯彡┻━┻' diff --git a/.github/workflows/lint-rector.yml b/.github/workflows/lint-rector.yml index 6572ef68..16c23087 100644 --- a/.github/workflows/lint-rector.yml +++ b/.github/workflows/lint-rector.yml @@ -1,44 +1,31 @@ +name: Rector lint checks + on: push: pull_request: -name: Rector lint checks - jobs: static-analysis: name: "Rector" - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.4" - dependencies: [ highest ] - operating-system: [ ubuntu-latest] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + container: + image: ghcr.io/mesilov/bitrix24-php-lib:php-cli + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - extensions: json, bcmath, curl, intl, mbstring - tools: composer:v2 - - - name: "Install lowest dependencies" - if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" + uses: actions/checkout@v4 - - name: "Install highest dependencies" - if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" + - name: "Install dependencies" + run: composer update --no-interaction --no-progress --no-suggest - name: "Rector" - run: "vendor/bin/rector process --dry-run" + run: vendor/bin/rector process --dry-run - name: "is Rector check succeeded" if: ${{ success() }} @@ -48,4 +35,4 @@ jobs: - name: "is PHPStan check failed" if: ${{ failure() }} run: | - echo '::error:: ❗️ Rector check failed (╯°益°)╯彡┻━┻' \ No newline at end of file + echo '::error:: ❗️ Rector check failed (╯°益°)╯彡┻━┻' diff --git a/.github/workflows/tests-functional.yml b/.github/workflows/tests-functional.yml index d96ab988..854fe52e 100644 --- a/.github/workflows/tests-functional.yml +++ b/.github/workflows/tests-functional.yml @@ -6,7 +6,7 @@ on: env: COMPOSER_FLAGS: "--ansi --no-interaction --no-progress" - DATABASE_HOST: localhost + DATABASE_HOST: postgres DATABASE_USER: b24phpLibTest DATABASE_PASSWORD: b24phpLibTest DATABASE_NAME: b24phpLibTest @@ -14,19 +14,18 @@ env: jobs: tests: name: "Functional tests" + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + container: + image: ghcr.io/mesilov/bitrix24-php-lib:php-cli + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.3" - - "8.4" - dependencies: [ highest ] - operating-system: [ ubuntu-latest ] services: - bitrix24-php-lib-test-database: + postgres: image: postgres:16-alpine ports: - 5432:5432 @@ -42,14 +41,7 @@ jobs: steps: - name: "Checkout code" - uses: "actions/checkout@v2" - - - name: "Setup PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - extensions: json, bcmath, curl, intl, mbstring, pdo_pgsql, pdo + uses: actions/checkout@v4 - name: "Install dependencies with Composer" run: | @@ -57,12 +49,11 @@ jobs: - name: "Install PostgreSQL client" run: | - sudo apt-get update - sudo apt-get install -y postgresql-client + apk add --no-cache postgresql-client - name: "Wait for PostgreSQL to be ready" run: | - until pg_isready -h localhost -p 5432 -U b24phpLibTest; do + until pg_isready -h postgres -p 5432 -U b24phpLibTest; do echo "Waiting for PostgreSQL to start..." sleep 2 done @@ -73,7 +64,6 @@ jobs: php bin/doctrine orm:schema-tool:create --dump-sql php bin/doctrine orm:schema-tool:update --force php bin/doctrine orm:info - # Запуск тестов с очисткой состояния между тестами php vendor/bin/phpunit --testsuite=functional_tests --display-warnings --testdox --process-isolation - name: "is functional tests succeeded" diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 873aaad3..b45b58f9 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -10,35 +10,26 @@ env: jobs: tests: name: "PHPUnit tests" - - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.3" - - "8.4" - dependencies: [ highest ] - operating-system: [ ubuntu-latest] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + container: + image: ghcr.io/mesilov/bitrix24-php-lib:php-cli + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - extensions: json, bcmath, curl, intl, mbstring + uses: actions/checkout@v4 - name: "Install dependencies" run: | composer update ${{ env.COMPOSER_FLAGS }} - name: "run unit tests" - run: "php vendor/bin/phpunit --testsuite=unit_tests --display-warnings --testdox" + run: php vendor/bin/phpunit --testsuite=unit_tests --display-warnings --testdox - name: "is unit tests tests succeeded" if: ${{ success() }} @@ -48,4 +39,4 @@ jobs: - name: "is unit tests tests failed" if: ${{ failure() }} run: | - echo '::error:: ❗️ unit tests tests failed (╯°益°)╯彡┻━┻' \ No newline at end of file + echo '::error:: ❗️ unit tests tests failed (╯°益°)╯彡┻━┻' diff --git a/.tasks/77/unit-tests-fix-plan.md b/.tasks/77/unit-tests-fix-plan.md new file mode 100644 index 00000000..7d8a4e4c --- /dev/null +++ b/.tasks/77/unit-tests-fix-plan.md @@ -0,0 +1,127 @@ +## Исправление падений тестов после обновления SDK (Task #77) + +### Причина + +После обновления зависимостей SDK (`bitrix24/b24phpsdk`) изменился контракт `ContactPersonInterface`: + +- **`getBitrix24UserId()`** теперь возвращает `int` (было `?int`) +- **`createContactPersonImplementation()`** в обоих абстрактных тест-классах SDK (`ContactPersonInterfaceTest`, `ContactPersonRepositoryInterfaceTest`) изменила порядок параметров: `int $bitrix24UserId` переместился на **позицию 5** (после `$contactPersonStatus`), тип стал ненулевым + +**Результат:** 58 падений `TypeError` в `make test-unit`. +**Потенциально:** аналогичные ошибки в `make test-functional` в `ContactPersonRepositoryTest`. + +--- + +### Изменяемые файлы (4 файла) + +--- + +#### 1. `src/ContactPersons/Entity/ContactPerson.php` + +**Проблема:** `getBitrix24UserId()` возвращает `?int`, но `ContactPersonInterface` теперь требует `int`. +Конструктор уже корректен (`private readonly int $bitrix24UserId`). + +**Исправление:** изменить возвращаемый тип с `?int` на `int`. + +```php +// ДО +public function getBitrix24UserId(): ?int + +// ПОСЛЕ +public function getBitrix24UserId(): int +``` + +--- + +#### 2. `tests/Unit/ContactPersons/Entity/ContactPersonTest.php` + +**Проблема:** `createContactPersonImplementation()` — старый порядок параметров и тип `?int $bitrix24UserId`. + +**Новая сигнатура** (из `vendor/.../ContactPersonInterfaceTest.php`, строки 35–54): +``` +pos 1: Uuid $uuid +pos 2: CarbonImmutable $createdAt +pos 3: CarbonImmutable $updatedAt +pos 4: ContactPersonStatus $contactPersonStatus +pos 5: int $bitrix24UserId ← ПЕРЕМЕЩЁН сюда, ненулевой +pos 6: string $name +pos 7: ?string $surname +pos 8: ?string $patronymic +pos 9: ?string $email +pos 10: ?CarbonImmutable $emailVerifiedAt +pos 11: ?string $comment +pos 12: ?PhoneNumber $phoneNumber +pos 13: ?CarbonImmutable $mobilePhoneVerifiedAt +pos 14: ?string $externalId +pos 15: ?Uuid $bitrix24PartnerUuid +pos 16: ?string $userAgent +pos 17: ?string $userAgentReferer +pos 18: ?IP $userAgentIp +``` + +**Исправление:** привести сигнатуру метода к новому контракту. +Тело метода: `$bitrix24UserId` передаётся в `new ContactPerson(...)` на позицию 10 (аргумент #10 конструктора) — правильно. + +--- + +#### 3. `tests/Functional/ContactPersons/Infrastructure/Doctrine/ContactPersonRepositoryTest.php` + +**Проблема:** `createContactPersonImplementation()` — та же самая старая сигнатура (строки 27–62). +Наследует от `ContactPersonRepositoryInterfaceTest`, которая также обновила сигнатуру (см. `vendor/.../Repository/ContactPersonRepositoryInterfaceTest.php`, строки 37–55): +``` +pos 5: int $bitrix24UserId ← ненулевой, на позиции 5 +pos 6: string $name +... +``` + +**Исправление:** привести сигнатуру и тело метода к новому контракту — аналогично п.2. + +--- + +#### 4. `tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php` + +**Проблема:** поле `private ?int $bitrix24UserId = null;` (строка 33) — тип `?int`, который передаётся в `ContactPerson::__construct()` (требует `int`). PHPStan будет ругаться. + +**Исправление:** изменить тип поля на `int` (значение по умолчанию убрать, инициализация уже в `__construct()`). + +```php +// ДО +private ?int $bitrix24UserId = null; + +// ПОСЛЕ +private int $bitrix24UserId; +``` + +--- + +### Шаги реализации + +1. `src/ContactPersons/Entity/ContactPerson.php` — изменить тип возврата `getBitrix24UserId()`. +2. `tests/Unit/ContactPersons/Entity/ContactPersonTest.php` — обновить сигнатуру `createContactPersonImplementation()`. +3. `tests/Functional/ContactPersons/Infrastructure/Doctrine/ContactPersonRepositoryTest.php` — обновить сигнатуру `createContactPersonImplementation()`. +4. `tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php` — исправить тип поля `$bitrix24UserId`. + +--- + +### Проверка + +```bash +# Unit-тесты (должны пройти все 170) +make test-unit + +# Линтеры +make lint-phpstan +make lint-cs-fixer +make lint-rector + +# Функциональные тесты (требуют БД) +make test-functional +``` + +--- + +### Примечания + +- `InstallContactPerson\Command` и `Handler` не требуют изменений. +- Doctrine-маппинг (`config/xml/ContactPerson.xml`) не требует изменений. +- `CHANGELOG.md` — обновить после внесения правок. diff --git a/.tasks/84/functional-tests-fix-plan.md b/.tasks/84/functional-tests-fix-plan.md new file mode 100644 index 00000000..46f0d8af --- /dev/null +++ b/.tasks/84/functional-tests-fix-plan.md @@ -0,0 +1,50 @@ +## План устранения падения `make test-functional` (совместимость `ContactPerson` с SDK interface) + +### Summary +Диагностический запуск `make test-functional` завершился до старта тестов с `Fatal error` при загрузке классов Doctrine/Entity: +- Команда упала на шаге `php bin/doctrine orm:schema-tool:drop --force`. +- Причина: несовместимая сигнатура метода в `ContactPerson` с контрактом SDK. + +Подтверждённая ошибка: +- `ContactPerson::markEmailAsVerified(): void` +- требует соответствия `ContactPersonInterface::markEmailAsVerified(?CarbonImmutable $verifiedAt = null): void` + +Файлы: +- `src/ContactPersons/Entity/ContactPerson.php:173` +- `vendor/bitrix24/b24phpsdk/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php:83` + +### Important Interface Changes Needed +1. Привести сигнатуры методов сущности к актуальному SDK контракту: +- `markEmailAsVerified(?CarbonImmutable $verifiedAt = null): void` +- `markMobilePhoneAsVerified(?CarbonImmutable $verifiedAt = null): void` + +2. Добавить отсутствующий метод интерфейса: +- `isPartner(): bool` + +Дополнительно выявлено по статическому сравнению: +- В классе сейчас `markMobilePhoneAsVerified(): void` без параметра. +- В классе отсутствует `isPartner()`, хотя он обязателен в интерфейсе. + +### Implementation Plan +1. Обновить `ContactPerson` сигнатуры обоих `mark*Verified` методов под интерфейс. +2. Внутри методов использовать переданный `$verifiedAt`, а при `null` ставить `new CarbonImmutable()`. +3. Добавить реализацию `isPartner(): bool` с семантикой контракта (true при наличии `bitrix24PartnerId`). +4. Проверить, что атрибуты `#[\Override]` остаются валидными после правок. +5. Перезапустить: +- `make test-functional` +- при успехе дополнительно `make test-unit` как регрессия по доменной модели. + +### Test Cases and Scenarios +1. Инфраструктурный smoke: +- `php bin/doctrine orm:schema-tool:drop --force` больше не падает с `Fatal error`. + +2. Основной сценарий: +- `make test-functional` проходит стадию bootstrap и выполняет тесты (или падает уже на реальных assertions, а не на загрузке класса). + +3. Регрессия: +- `make test-unit` остаётся зелёным после изменения сигнатур и добавления `isPartner()`. + +### Assumptions and Defaults +- Источник истины по контрактам: установленная версия `bitrix24/b24phpsdk` в `vendor`. +- Поведение `mark*Verified` должно поддерживать опциональный timestamp из интерфейса. +- `isPartner()` реализуется как проверка `null !== $this->bitrix24PartnerId`. diff --git a/.tasks/84/ghcr-dev-images-ci-plan.md b/.tasks/84/ghcr-dev-images-ci-plan.md new file mode 100644 index 00000000..84b67ed9 --- /dev/null +++ b/.tasks/84/ghcr-dev-images-ci-plan.md @@ -0,0 +1,97 @@ +## План внедрения GHCR-образов для dev/CI (`php-cli`) + +### Summary +Цель: чтобы dev-образ `php-cli` собирался в CI и публиковался в GitHub Container Registry, а CI-тесты использовали pull этого образа из GHCR вместо локальной установки PHP. + +Опорный референс из `bitrix24/b24phpsdk` (ветка `v3`): +- Workflow сборки: https://raw.githubusercontent.com/bitrix24/b24phpsdk/v3/.github/workflows/docker-build.yml +- `docker-compose` с `image + build`: https://raw.githubusercontent.com/bitrix24/b24phpsdk/v3/docker-compose.yaml + +Выбранные решения: +- Теги: `:php-cli` + immutable `:php-cli-`. +- Триггер сборки: изменения `docker/php-cli/Dockerfile` + `workflow_dispatch`. +- CI потребление: unit/functional тесты запускаются в GHCR image. + +### Important Changes / Interfaces +1. Новый CI workflow публикации образа +- Файл: `.github/workflows/docker-build.yml` +- Права job: `packages: write`, `contents: read` +- Buildx multi-arch: `linux/amd64,linux/arm64` +- Публикация тегов: + - `ghcr.io/mesilov/bitrix24-php-lib:php-cli` + - `ghcr.io/mesilov/bitrix24-php-lib:php-cli-` +- Кэш: `cache-from/to: type=gha` + +2. Контракт образа для compose/dev +- Файл: `docker-compose.yaml` +- `php-cli` получает: + - `image: ${PHP_CLI_IMAGE:-ghcr.io/mesilov/bitrix24-php-lib:php-cli}` + - `build: { context: ./docker/php-cli }` (fallback для локальной пересборки) +- Поведение: + - `make docker-pull` подтягивает GHCR image + - `make docker-up --build` при необходимости пересобирает локально + +3. Перевод тестовых workflow на GHCR image +- Файлы: + - `.github/workflows/tests-unit.yml` + - `.github/workflows/tests-functional.yml` +- Убрать `shivammathur/setup-php` (образ уже содержит PHP/extensions/composer) +- Добавить job-level container: + - `container.image: ghcr.io/mesilov/bitrix24-php-lib:php-cli` + - `container.credentials` через `${{ github.actor }}` + `${{ secrets.GITHUB_TOKEN }}` +- Добавить `permissions: packages: read` в тестовых job. + +4. Корректировка functional env под container+services +- Сейчас `DATABASE_HOST=localhost`; в container job это неверно. +- Изменить на hostname service-контейнера (например `bitrix24-php-lib-test-database`), чтобы подключение к Postgres было стабильным. +- Шаг установки `postgresql-client`/`pg_isready` убрать (или оставить только если реально нужен CLI-инструмент в job). + +### Implementation Steps (Decision Complete) +1. Создать `.github/workflows/docker-build.yml` по шаблону `b24phpsdk`, адаптировав: +- image path на `ghcr.io/mesilov/bitrix24-php-lib` +- два тега (`php-cli`, `php-cli-${short_sha}`) +- события: `push.paths: docker/php-cli/Dockerfile`, `workflow_dispatch`. + +2. Обновить `docker-compose.yaml`: +- добавить `image` для `php-cli` с env-override +- сохранить `build.context` для fallback +- не менять `database` сервис. + +3. Обновить `tests-unit.yml`: +- `permissions: packages: read` +- добавить `container.image` + `container.credentials` +- удалить setup-php step +- оставить `composer update` + `phpunit` как есть. + +4. Обновить `tests-functional.yml`: +- `permissions: packages: read` +- добавить `container.image` + credentials +- сменить `DATABASE_HOST` на service name +- удалить setup-php step и apt/pg_isready шаги +- оставить schema-tool + phpunit шаги. + +5. Проверить Makefile/локальный DX: +- Убедиться, что `docker-pull` реально тянет GHCR образ. +- При необходимости добавить короткую подсказку в `help` про переменную `PHP_CLI_IMAGE`. + +### Test Cases and Scenarios +1. Публикация образа +- Изменить `docker/php-cli/Dockerfile` в ветке. +- Проверить, что `docker-build` workflow публикует оба тега в GHCR. + +2. Pull в CI +- `tests-unit` и `tests-functional` стартуют в container image из GHCR без шага setup-php. +- Workflow не падают на pull/auth. + +3. Functional DB connectivity +- `DATABASE_HOST` резолвится на service container. +- schema-tool команды проходят стабильно. + +4. Локальный dev +- `make docker-pull` подтягивает `ghcr.io/mesilov/bitrix24-php-lib:php-cli`. +- `make docker-up` и `make test-*` остаются рабочими. + +### Assumptions and Defaults +- GHCR package для репозитория доступен для чтения в Actions через `GITHUB_TOKEN`. +- Основной registry-путь фиксируем как `ghcr.io/mesilov/bitrix24-php-lib`. +- Для локальной разработки build fallback сохраняется (`build.context`) и не ломает текущий поток. diff --git a/.tasks/84/makefile-parity-plan.md b/.tasks/84/makefile-parity-plan.md new file mode 100644 index 00000000..aa7c2dd4 --- /dev/null +++ b/.tasks/84/makefile-parity-plan.md @@ -0,0 +1,101 @@ +## Makefile Parity Plan: `bitrix24-php-lib` in `b24phpsdk v3` Style + +### Summary +Rebuild local `Makefile` in the structural and naming style of `b24phpsdk` `v3`: +- source style reference: https://github.com/bitrix24/b24phpsdk/blob/v3/Makefile +- target file: `Makefile` + +Chosen decisions: +- Use only new target names (no backward-compat aliases). +- Keep only targets relevant to this repository (no copied integration matrix from SDK). + +### Public Interface Changes (Make Targets) +Replace current target names with this final target set: + +1. Core behavior and scaffolding +- `.DEFAULT_GOAL := help` +- `%: @: # silence` +- `help` with grouped sections (docker/composer/lint/tests/dev/db/debug) +- `ENV := $(PWD)/.env`, `ENV_LOCAL := $(PWD)/.env.local` (keep repo-local env model) + +2. Docker targets +- `docker-init` (down -> build/pull as needed -> composer install -> up) +- `docker-up` +- `docker-down` +- `docker-down-clear` +- `docker-pull` +- `docker-restart` (depends on `docker-down docker-up`) + +3. Composer targets +- `composer-install` +- `composer-update` +- `composer-dumpautoload` +- `composer-clear-cache` (from old `clear`) +- `composer` (pass-through arguments via `$(filter-out ...)`) + +4. Lint/quality targets +- `lint-allowed-licenses` +- `lint-cs-fixer` +- `lint-cs-fixer-fix` +- `lint-phpstan` +- `lint-rector` +- `lint-rector-fix` +- `lint-all` (aggregator) + +5. Test targets +- `test-unit` (old `test-run-unit`) +- `test-functional` (old `test-run-functional`, including doctrine schema reset steps) +- `test-functional-one` (old `run-one-functional-test`; keep same default filter/path unless changed later) + +6. Utility/dev targets +- `php-cli-bash` +- `debug-show-env` (old `debug-print-env`) +- `doctrine-schema-drop` (old `schema-drop`) +- `doctrine-schema-create` (old `schema-create`) + +7. Phony declarations +- Add `.PHONY` for each non-file target, matching `b24phpsdk` style. + +### Implementation Details (Decision-Complete) +1. Rewrite file header and baseline structure to mirror `b24phpsdk` style: +- shebang placement, exported timeout vars, `.DEFAULT_GOAL`, wildcard silence, env includes, help block first. + +2. Standardize all docker invocations to `docker compose` (space form), not `docker-compose`. + +3. Replace old names entirely: +- remove `default`, `init`, `up`, `down`, `down-clear`, `restart`, `clear`, `test-run-unit`, `test-run-functional`, `run-one-functional-test`, `debug-print-env`, `schema-drop`, `schema-create`, `start-rector`, `coding-standards`. + +4. Preserve command semantics for this repo: +- functional tests still run doctrine schema drop/create/update before phpunit. +- lint commands still use installed vendor binaries from current project. +- composer pass-through remains unchanged behavior-wise. + +5. Ensure tab indentation for all recipe lines (fix current mixed-space recipe issue). + +### Test Cases and Scenarios +Run after rewrite: + +1. Structural/syntax checks +- `make help` prints grouped menu and exits 0. +- `make -n docker-up`, `make -n test-unit`, `make -n lint-all` produce expected command chains. + +2. Target behavior smoke checks +- `make docker-up` +- `make composer-install` +- `make lint-cs-fixer` +- `make lint-phpstan` +- `make test-unit` +- `make test-functional` (with DB env loaded) + +3. Pass-through checks +- `make composer "install --dry-run"` +- `make php-cli-bash` + +4. Regression checks +- Confirm removed legacy target names now fail (expected), because compatibility aliases were explicitly not requested. + +### Assumptions and Defaults +- Keep env file location at project root (`.env`, `.env.local`), not `tests/.env` from SDK. +- Keep only targets backed by tools/tests present in this repo (`phpunit` suites: `unit_tests`, `functional_tests`). +- Do not introduce SDK-specific dev/documentation/ngrok/integration-scope targets that have no local implementation. +- English target naming and help text preserved in SDK style. diff --git a/.tasks/84/unit-tests-fix-plan.md b/.tasks/84/unit-tests-fix-plan.md new file mode 100644 index 00000000..613d9b20 --- /dev/null +++ b/.tasks/84/unit-tests-fix-plan.md @@ -0,0 +1,51 @@ +## План исправления падений `make test-unit` (18 ошибок Serializer/ObjectNormalizer) + +### Summary +По результату запуска `make test-unit`: +- Всего: `97` тестов, `145` assertions +- Ошибки: `18` +- Все 18 ошибок однотипны и приходят из `SettingsFetcherTest` с `LogicException`: + `ObjectNormalizer requires symfony/property-access`. + +Источник падений: +- `tests/Unit/ApplicationSettings/Services/SettingsFetcherTest.php` +- Инициализация `ObjectNormalizer()` в `setUp()`. + +Выбранная стратегия: добавить `symfony/property-access` в `require-dev`. + +### Important Changes (Public Interfaces / Dependencies) +1. Обновить dev-зависимости проекта: +- `composer.json`: добавить `symfony/property-access` в `require-dev` (версия в линии Symfony 7, например `^7`). +- `composer.lock`: обновить lock-файл после установки зависимости. + +2. Код бизнес-логики не менять: +- `src/ApplicationSettings/Services/SettingsFetcher.php` остаётся без изменений. +- Поведение API `SettingsFetcher::getItem()` и `SettingsFetcher::getValue()` не меняется. + +### Implementation Steps +1. Добавить пакет: +- `docker compose run --rm php-cli composer require --dev symfony/property-access:^7` + +2. Проверить, что dependency корректно зафиксирована: +- Убедиться, что в `composer.json` и `composer.lock` добавлен `symfony/property-access`. + +3. Перезапустить юнит-тесты: +- `make test-unit` + +4. Если останутся новые ошибки после этого фикса: +- Разобрать их как отдельную волну (ожидается, что текущие 18 ошибок исчезнут полностью). + +### Test Cases and Scenarios +1. Основной сценарий: +- `make test-unit` должен завершиться с `exit code 0`. + +2. Точечная проверка проблемного класса: +- Запустить только `SettingsFetcherTest` и убедиться, что тесты `getItem`/`getValue` больше не падают на `LogicException`. + +3. Регрессия: +- Повторный запуск полного `make test-unit` для проверки, что добавление зависимости не вызвало побочных падений в остальных unit-тестах. + +### Assumptions and Defaults +- Используемая версия Symfony в проекте остаётся в линии `^7`, поэтому `symfony/property-access:^7` совместим. +- Проблема инфраструктурная (отсутствующая dev-зависимость), а не дефект алгоритма `SettingsFetcher`. +- В рамках этого фикса не меняем структуру тестов и не переписываем сериализацию в `SettingsFetcherTest`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ab3581..8a15d611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,67 @@ +## Unreleased + +## 0.4.0 + +### Added + +- **ContactPersons support (main feature of 0.4.0)** + - Added `ApplicationInstallations\UseCase\InstallContactPerson\Command` / `Handler` to create and link a `ContactPerson` to an `ApplicationInstallation` + - Added `ApplicationInstallations\UseCase\UnlinkContactPerson\Command` / `Handler` to unlink a contact person from an installation + - Added `ContactPersons\UseCase\ChangeProfile\Command` / `Handler` to update `FullName`, email, and mobile phone + - Added `ContactPersons\UseCase\MarkEmailAsVerified\Command` / `Handler` to confirm email ownership + - Added `ContactPersons\UseCase\MarkMobilePhoneAsVerified\Command` / `Handler` to confirm mobile phone ownership +- **`ContactPersonType` enum** (`personal` | `partner`) in `Bitrix24\Lib\ContactPersons\Enum` + +### Changed + +- **`ContactPerson` entity** + - Constructor accepts optional `$createdAt` / `$updatedAt` parameters so SDK contract tests can assert stable timestamps + - `$isEmailVerified` and `$isMobilePhoneVerified` are initialized from `$emailVerifiedAt` / `$mobilePhoneVerifiedAt` in constructor + - `getBitrix24UserId()` return type narrowed from `?int` to `int` to match `ContactPersonInterface` + - `markAsDeleted()` now throws `InvalidArgumentException` (was `LogicException`) to satisfy the SDK contract +- **`ApplicationInstallation` entity** + - `unlinkContactPerson()` and `unlinkBitrix24PartnerContactPerson()` now return early when the respective ID is already `null` to avoid unnecessary `updatedAt` mutation +- **`OnAppInstall\Handler`** + - Now throws `ApplicationInstallationNotFoundException` when installation cannot be found by member ID (instead of silent no-op) + +### Fixed + +- **SDK contract compatibility after `bitrix24/b24phpsdk` update** + - Updated `createContactPersonImplementation()` signatures in `ContactPersonTest` and `ContactPersonRepositoryTest` (`int $bitrix24UserId` moved to position 5 and made non-nullable) + - Narrowed `ContactPersonBuilder::$bitrix24UserId` from `?int` to `int` + - Restored green unit test suite (`170` tests) + +## 0.3.1 + +### Changed + +- **Makefile aligned with b24phpsdk v3 style** + - Set `help` as default target and added grouped help output + - Switched Docker commands from `docker-compose` to `docker compose` + - Renamed targets to SDK-style naming (`docker-*`, `test-unit`, `test-functional`, `debug-show-env`, `doctrine-schema-*`) + - Added explicit `.PHONY` declarations for operational targets + - Added `lint-all` aggregate target +- **Dependency update for PHP 8.4 compatibility** + - Updated `darsyn/ip` from `^5` to `^6` + - Removed runtime deprecation warnings from functional test runs +- **CI pipelines moved to dev Docker image from GHCR** + - Added workflow to build and publish `php-cli` image to `ghcr.io/mesilov/bitrix24-php-lib` (`php-cli` and `php-cli-` tags) + - Switched lint, unit, functional, and license-check workflows to run inside `ghcr.io/mesilov/bitrix24-php-lib:php-cli` + - Added GitHub Actions package permissions for pulling private GHCR images in jobs +- **Docker Compose image source updated for dev workflow** + - Added `image: ${PHP_CLI_IMAGE:-ghcr.io/mesilov/bitrix24-php-lib:php-cli}` to `php-cli` service + - Kept local `build` section as fallback when registry tag is unavailable + +### Fixed + +- **Unit tests failing in `SettingsFetcherTest` due to missing serializer dependency** + - Added `symfony/property-access` to `require-dev` + - Restored successful run of `make test-unit` (`97 tests, 190 assertions`) +- **Functional tests bootstrap failure due to SDK contract mismatch** + - Updated `ContactPerson::markEmailAsVerified()` and `ContactPerson::markMobilePhoneAsVerified()` signatures to match `ContactPersonInterface` + - Added missing `ContactPerson::isPartner()` method implementation + - Restored successful run of `make test-functional` (`62 tests, 127 assertions, 1 skipped`) + ## 0.3.0 ### Added @@ -92,6 +156,12 @@ - Created `BaseException` class in `src/Exceptions/` for future custom exceptions - Updated all tests to expect correct SDK exception types - Fixed PHPDoc annotations to reference correct exception types +- **Type safety improvement in OnAppInstall Command** — [#64](https://github.com/mesilov/bitrix24-php-lib/issues/64) + - Changed `$applicationStatus` parameter type from `string` to `ApplicationStatus` object + - Improved type safety by enforcing proper value object usage + - Removed unnecessary string validation in Command constructor + - Eliminated redundant ApplicationStatus instantiation in Handler + - Updated all related tests to use ApplicationStatus objects ### Removed diff --git a/Makefile b/Makefile index d6663b5f..2bb3c8f1 100644 --- a/Makefile +++ b/Makefile @@ -9,118 +9,171 @@ export COMPOSE_HTTP_TIMEOUT=120 export DOCKER_CLIENT_TIMEOUT=120 +.DEFAULT_GOAL := help + +%: + @: # silence + # load default and personal env-variables ENV := $(PWD)/.env ENV_LOCAL := $(PWD)/.env.local include $(ENV) -include $(ENV_LOCAL) - -start-rector: vendor - vendor/bin/rector process tests --config=rector.php - -coding-standards: vendor - vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --diff --verbose - -default: - @echo "make needs target:" - @egrep -e '^\S+' ./Makefile | grep -v default | sed -r 's/://' | sed -r 's/^/ - /' - -%: - @: # silence - -# Rule to print all environment variables for debugging -debug-print-env: - @echo "DATABASE_HOST=$(DATABASE_HOST)" - @echo "DATABASE_NAME=$(DATABASE_NAME)" - @echo "DATABASE_USER=$(DATABASE_USER)" - @echo "DATABASE_PASSWORD=$(DATABASE_PASSWORD)" - -init: +.PHONY: help +help: + @echo "-------------------------------" + @echo " bitrix24-php-lib Makefile" + @echo "-------------------------------" + @echo "" + @echo "docker-init - first installation" + @echo "docker-up - run docker" + @echo "docker-down - stop docker" + @echo "docker-down-clear - stop docker and remove volumes" + @echo "docker-pull - pull Docker images" + @echo "docker-restart - restart containers" + @echo "" + @echo "composer-install - install dependencies" + @echo "composer-update - update dependencies" + @echo "composer-dumpautoload - regenerate autoload" + @echo "composer-clear-cache - clear composer cache" + @echo "composer - run composer and pass arguments" + @echo "" + @echo "lint-all - run all linters" + @echo "lint-allowed-licenses - validate dependency licenses" + @echo "lint-cs-fixer - run php-cs-fixer in dry-run" + @echo "lint-cs-fixer-fix - run php-cs-fixer fix" + @echo "lint-phpstan - run phpstan" + @echo "lint-rector - run rector dry-run" + @echo "lint-rector-fix - run rector fix" + @echo "" + @echo "test-unit - run unit tests" + @echo "test-functional - run functional tests" + @echo "test-functional-one - run one functional test with debugger" + @echo "" + @echo "doctrine-schema-drop - drop database schema" + @echo "doctrine-schema-create - create database schema" + @echo "php-cli-bash - open shell in php-cli container" + @echo "debug-show-env - print db env variables" + +.PHONY: docker-init +docker-init: @echo "remove all containers" - docker-compose down --remove-orphans + docker compose down --remove-orphans + @echo "pull Docker images" + docker compose pull @echo "build containers" - docker-compose build + docker compose build @echo "install dependencies" - docker-compose run --rm php-cli composer install - @echo "change owner of var folder for access from container" - docker-compose run --rm php-cli chown -R www-data:www-data /var/www/html/var/ - @echo "run application…" - docker-compose up -d - + docker compose run --rm php-cli composer install + @echo "run application..." + docker compose up -d -clear: - docker-compose run --rm php-cli composer clear-cache +.PHONY: docker-up +docker-up: + @echo "run application..." + docker compose up --build -d -up: - @echo "run application…" - docker-compose up --build -d - -down: +.PHONY: docker-down +docker-down: @echo "stop application and remove containers" - docker-compose down --remove-orphans + docker compose down --remove-orphans -down-clear: +.PHONY: docker-down-clear +docker-down-clear: @echo "stop application and remove containers with volumes" - docker-compose down -v --remove-orphans + docker compose down -v --remove-orphans -restart: down up +.PHONY: docker-pull +docker-pull: + @echo "pull Docker images..." + docker compose pull -# container operations -php-cli-bash: - docker-compose run --rm php-cli sh $(filter-out $@,$(MAKECMDGOALS)) +.PHONY: docker-restart +docker-restart: docker-down docker-up -# composer operations +.PHONY: composer-install composer-install: - @echo "install dependencies…" - docker-compose run --rm php-cli composer install + @echo "install dependencies..." + docker compose run --rm php-cli composer install +.PHONY: composer-update composer-update: - @echo "update dependencies…" - docker-compose run --rm php-cli composer update + @echo "update dependencies..." + docker compose run --rm php-cli composer update +.PHONY: composer-dumpautoload composer-dumpautoload: - docker-compose run --rm php-cli composer dumpautoload -# composer call with any parameters -# Examples: + docker compose run --rm php-cli composer dumpautoload + +.PHONY: composer-clear-cache +composer-clear-cache: + docker compose run --rm php-cli composer clear-cache + +.PHONY: composer +# call composer with any parameters # make composer install # make composer "install --no-dev" composer: - docker-compose run --rm php-cli composer $(filter-out $@,$(MAKECMDGOALS)) + docker compose run --rm php-cli composer $(filter-out $@,$(MAKECMDGOALS)) -# check allowed licenses +.PHONY: lint-allowed-licenses lint-allowed-licenses: - docker-compose run --rm php-cli php vendor/bin/composer-license-checker -# linters + docker compose run --rm php-cli vendor/bin/composer-license-checker + +.PHONY: lint-cs-fixer +lint-cs-fixer: + docker compose run --rm php-cli php vendor/bin/php-cs-fixer fix --dry-run --diff --verbose + +.PHONY: lint-cs-fixer-fix +lint-cs-fixer-fix: + docker compose run --rm php-cli php vendor/bin/php-cs-fixer fix --diff --verbose + +.PHONY: lint-phpstan lint-phpstan: - docker-compose run --rm php-cli php vendor/bin/phpstan analyse --memory-limit 2G + docker compose run --rm php-cli php vendor/bin/phpstan analyse --memory-limit 2G + +.PHONY: lint-rector lint-rector: - docker-compose run --rm php-cli php vendor/bin/rector process --dry-run + docker compose run --rm php-cli php vendor/bin/rector process --dry-run + +.PHONY: lint-rector-fix lint-rector-fix: - docker-compose run --rm php-cli php vendor/bin/rector process -lint-cs-fixer: - docker-compose run --rm php-cli php vendor/bin/php-cs-fixer fix --dry-run --diff --verbose -lint-cs-fixer-fix: - docker-compose run --rm php-cli php vendor/bin/php-cs-fixer fix --diff --verbose + docker compose run --rm php-cli php vendor/bin/rector process -# unit-tests -test-run-unit: - docker-compose run --rm php-cli php vendor/bin/phpunit --testsuite=unit_tests --display-warnings --testdox +.PHONY: lint-all +lint-all: lint-allowed-licenses lint-cs-fixer lint-phpstan lint-rector -# functional-tests, work with test database -test-run-functional: debug-print-env - docker-compose run --rm php-cli php bin/doctrine orm:schema-tool:drop --force - docker-compose run --rm php-cli php bin/doctrine orm:schema-tool:create - docker-compose run --rm php-cli php bin/doctrine orm:schema-tool:update --dump-sql - docker-compose run --rm php-cli php vendor/bin/phpunit --testsuite=functional_tests --display-warnings --testdox +.PHONY: test-unit +test-unit: + docker compose run --rm php-cli php vendor/bin/phpunit --testsuite=unit_tests --display-warnings --testdox -# Run one functional test with debugger -run-one-functional-test: debug-print-env - docker-compose run --rm php-cli php -dxdebug.start_with_request=yes vendor/bin/phpunit --filter 'testChangeDomainUrlWithHappyPath' tests/Functional/Bitrix24Accounts/UseCase/ChangeDomainUrl/HandlerTest.php +.PHONY: debug-show-env +debug-show-env: + @echo "DATABASE_HOST=$(DATABASE_HOST)" + @echo "DATABASE_NAME=$(DATABASE_NAME)" + @echo "DATABASE_USER=$(DATABASE_USER)" + @echo "DATABASE_PASSWORD=$(DATABASE_PASSWORD)" + +.PHONY: test-functional +test-functional: debug-show-env + docker compose run --rm php-cli php bin/doctrine orm:schema-tool:drop --force + docker compose run --rm php-cli php bin/doctrine orm:schema-tool:create + docker compose run --rm php-cli php bin/doctrine orm:schema-tool:update --dump-sql + docker compose run --rm php-cli php vendor/bin/phpunit --testsuite=functional_tests --display-warnings --testdox -schema-drop: - docker-compose run --rm php-cli php bin/doctrine orm:schema-tool:drop --force +.PHONY: test-functional-one +test-functional-one: debug-show-env + docker compose run --rm php-cli php -dxdebug.start_with_request=yes vendor/bin/phpunit --filter 'testChangeDomainUrlWithHappyPath' tests/Functional/Bitrix24Accounts/UseCase/ChangeDomainUrl/HandlerTest.php -schema-create: - docker-compose run --rm php-cli php bin/doctrine orm:schema-tool:create +.PHONY: doctrine-schema-drop +doctrine-schema-drop: + docker compose run --rm php-cli php bin/doctrine orm:schema-tool:drop --force +.PHONY: doctrine-schema-create +doctrine-schema-create: + docker compose run --rm php-cli php bin/doctrine orm:schema-tool:create + +.PHONY: php-cli-bash +php-cli-bash: + docker compose run --rm php-cli sh $(filter-out $@,$(MAKECMDGOALS)) diff --git a/composer.json b/composer.json index f77299d4..ce71e702 100644 --- a/composer.json +++ b/composer.json @@ -35,28 +35,30 @@ }, "require": { "php": "8.3.* || 8.4.*", - "ext-json": "*", - "ext-curl": "*", "ext-bcmath": "*", + "ext-curl": "*", "ext-intl": "*", - "psr/log": "^3", + "ext-json": "*", + "bitrix24/b24phpsdk": "dev-v3-dev", + "darsyn/ip": "^6", + "darsyn/ip-doctrine": "^6", + "doctrine/doctrine-bundle": "3.2.2", + "doctrine/doctrine-migrations-bundle": "4.0.0", + "doctrine/orm": "^3", "fig/http-message-util": "^1", "giggsey/libphonenumber-for-php": "^8", - "darsyn/ip": "^5", - "nesbot/carbon": "^3", - "moneyphp/money": "^4", - "bitrix24/b24phpsdk": "dev-dev", - "doctrine/orm": "^3", - "doctrine/doctrine-bundle": "*", - "doctrine/doctrine-migrations-bundle": "*", "knplabs/knp-paginator-bundle": "^6", - "symfony/event-dispatcher": "^7", - "symfony/serializer": "^7", - "symfony/uid": "^7", - "symfony/yaml": "^7", - "symfony/cache": "^7", - "symfony/console": "^7", - "symfony/dotenv": "^7" + "moneyphp/money": "^4", + "nesbot/carbon": "^3", + "odolbeau/phone-number-bundle": "^4", + "psr/log": "^3", + "symfony/cache": "^7||^8", + "symfony/console": "^7||^8", + "symfony/dotenv": "^7||^8", + "symfony/event-dispatcher": "^7||^8", + "symfony/serializer": "^7||^8", + "symfony/uid": "^7||^8", + "symfony/yaml": "^7||^8" }, "require-dev": { "doctrine/migrations": "^3", @@ -70,8 +72,9 @@ "rector/rector": "^1", "roave/security-advisories": "dev-master", "symfony/debug-bundle": "^7", - "symfony/property-access": "^7.3", - "symfony/stopwatch": "^7" + "symfony/property-access": "^7", + "symfony/stopwatch": "^7", + "symfony/var-exporter": "^7" }, "autoload": { "psr-4": { diff --git a/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml b/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml new file mode 100644 index 00000000..e641458f --- /dev/null +++ b/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.FullName.dcm.xml b/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.FullName.dcm.xml new file mode 100644 index 00000000..a3d65ba7 --- /dev/null +++ b/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.FullName.dcm.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml b/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml new file mode 100644 index 00000000..779b038b --- /dev/null +++ b/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index fbdff876..ea30d7d0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,6 @@ services: php-cli: + image: ${PHP_CLI_IMAGE:-ghcr.io/mesilov/bitrix24-php-lib:php-cli} build: context: ./docker/php-cli depends_on: diff --git a/docker/php-cli/Dockerfile b/docker/php-cli/Dockerfile index 6f1684b9..465aee36 100644 --- a/docker/php-cli/Dockerfile +++ b/docker/php-cli/Dockerfile @@ -1,3 +1,4 @@ +# build DEV image FROM php:8.4-cli-alpine RUN apk add unzip libpq-dev git icu-dev autoconf build-base linux-headers \ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fee6376d..932a5060 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,9 +9,14 @@ ./tests/Unit + ./tests/Unit/ApplicationInstallations/Entity/ApplicationInstallationTest.php + ./tests/Unit/Bitrix24Accounts/Entity/Bitrix24AccountTest.php ./tests/Functional + ./tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php + ./tests/Functional/Bitrix24Accounts/Infrastructure/Doctrine/Bitrix24AccountRepositoryTest.php + ./tests/Functional/FlusherDecorator.php diff --git a/rector.php b/rector.php index 3ce8e423..59026b68 100644 --- a/rector.php +++ b/rector.php @@ -15,6 +15,7 @@ use Rector\Naming\Rector\Class_\RenamePropertyToMatchTypeRector; use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\Set\ValueObject\DowngradeLevelSetList; +use Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector; return RectorConfig::configure() ->withPaths([ @@ -48,5 +49,6 @@ strictBooleans: true ) ->withSkip([ - RenamePropertyToMatchTypeRector::class + RenamePropertyToMatchTypeRector::class, + FlipTypeControlToUseExclusiveTypeRector::class, ]); \ No newline at end of file diff --git a/src/ApplicationInstallations/Entity/ApplicationInstallation.php b/src/ApplicationInstallations/Entity/ApplicationInstallation.php index 99568624..bda0eea2 100644 --- a/src/ApplicationInstallations/Entity/ApplicationInstallation.php +++ b/src/ApplicationInstallations/Entity/ApplicationInstallation.php @@ -347,7 +347,9 @@ public function linkContactPerson(Uuid $uuid): void #[\Override] public function unlinkContactPerson(): void { - $this->updatedAt = new CarbonImmutable(); + if (null === $this->contactPersonId) { + return; + } $this->events[] = new Events\ApplicationInstallationContactPersonUnlinkedEvent( $this->id, @@ -356,13 +358,14 @@ public function unlinkContactPerson(): void ); $this->contactPersonId = null; + $this->updatedAt = new CarbonImmutable(); } #[\Override] public function linkBitrix24PartnerContactPerson(Uuid $uuid): void { - $this->updatedAt = new CarbonImmutable(); $this->bitrix24PartnerContactPersonId = $uuid; + $this->updatedAt = new CarbonImmutable(); $this->events[] = new Events\ApplicationInstallationBitrix24PartnerContactPersonLinkedEvent( $this->id, @@ -374,7 +377,9 @@ public function linkBitrix24PartnerContactPerson(Uuid $uuid): void #[\Override] public function unlinkBitrix24PartnerContactPerson(): void { - $this->updatedAt = new CarbonImmutable(); + if (null === $this->bitrix24PartnerContactPersonId) { + return; + } $this->events[] = new Events\ApplicationInstallationBitrix24PartnerContactPersonUnlinkedEvent( $this->id, @@ -383,13 +388,14 @@ public function unlinkBitrix24PartnerContactPerson(): void ); $this->bitrix24PartnerContactPersonId = null; + $this->updatedAt = new CarbonImmutable(); } #[\Override] public function linkBitrix24Partner(Uuid $uuid): void { - $this->updatedAt = new CarbonImmutable(); $this->bitrix24PartnerId = $uuid; + $this->updatedAt = new CarbonImmutable(); $this->events[] = new Events\ApplicationInstallationBitrix24PartnerLinkedEvent( $this->id, diff --git a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php index d6644c6a..28717da4 100644 --- a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php +++ b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php @@ -102,6 +102,32 @@ public function findByExternalId(string $externalId): array ; } + /** + * Get the current installation on the portal without input parameters. + * The system allows only one active installation per portal, + * therefore, the current one is interpreted as the installation with status active. + * If, for any reason, there are multiple, select the most recent by createdAt. + * + * @throws ApplicationInstallationNotFoundException + */ + public function getCurrent(): ApplicationInstallationInterface + { + $applicationInstallation = $this->getEntityManager()->getRepository(ApplicationInstallation::class) + ->createQueryBuilder('appInstallation') + ->where('appInstallation.status = :status') + ->orderBy('appInstallation.createdAt', 'DESC') + ->setParameter('status', ApplicationInstallationStatus::active) + ->getQuery() + ->getOneOrNullResult() + ; + + if (null === $applicationInstallation) { + throw new ApplicationInstallationNotFoundException('current active application installation not found'); + } + + return $applicationInstallation; + } + /** * Find application installation by application token. * diff --git a/src/ApplicationInstallations/UseCase/InstallContactPerson/Command.php b/src/ApplicationInstallations/UseCase/InstallContactPerson/Command.php new file mode 100644 index 00000000..d3b09b01 --- /dev/null +++ b/src/ApplicationInstallations/UseCase/InstallContactPerson/Command.php @@ -0,0 +1,42 @@ +validate(); + } + + private function validate(): void + { + if (null !== $this->email && !filter_var($this->email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email format.'); + } + + if (null !== $this->externalId && '' === trim($this->externalId)) { + throw new \InvalidArgumentException('External ID cannot be empty if provided.'); + } + + if ($this->bitrix24UserId <= 0) { + throw new \InvalidArgumentException('Bitrix24 User ID must be a positive integer.'); + } + } +} diff --git a/src/ApplicationInstallations/UseCase/InstallContactPerson/Handler.php b/src/ApplicationInstallations/UseCase/InstallContactPerson/Handler.php new file mode 100644 index 00000000..5020a18e --- /dev/null +++ b/src/ApplicationInstallations/UseCase/InstallContactPerson/Handler.php @@ -0,0 +1,122 @@ +logger->info('ContactPerson.InstallContactPerson.start', [ + 'applicationInstallationId' => $command->applicationInstallationId, + 'bitrix24UserId' => $command->bitrix24UserId, + 'bitrix24PartnerId' => $command->bitrix24PartnerId?->toRfc4122() ?? '', + ]); + + $createdContactPersonId = ''; + + try { + if (null !== $command->mobilePhoneNumber) { + try { + $this->guardMobilePhoneNumber($command->mobilePhoneNumber); + } catch (InvalidArgumentException) { + // Ошибка уже залогирована внутри гарда. + // Прерываем создание контакта, но не останавливаем установку приложения. + return; + } + } + + /** @var null|AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $applicationInstallation */ + $applicationInstallation = $this->applicationInstallationRepository->getById($command->applicationInstallationId); + + $uuidV7 = Uuid::v7(); + + $contactPerson = new ContactPerson( + $uuidV7, + ContactPersonStatus::active, + $command->bitrix24UserId, + $command->fullName, + $command->email, + null, + $command->mobilePhoneNumber, + null, + $command->comment, + $command->externalId, + $command->bitrix24PartnerId, + $command->userAgentInfo, + true + ); + + $this->contactPersonRepository->save($contactPerson); + + if ($contactPerson->isPartner()) { + $applicationInstallation->linkBitrix24PartnerContactPerson($uuidV7); + } else { + $applicationInstallation->linkContactPerson($uuidV7); + } + + $this->applicationInstallationRepository->save($applicationInstallation); + + $this->flusher->flush($contactPerson, $applicationInstallation); + + $createdContactPersonId = $uuidV7->toRfc4122(); + } catch (ApplicationInstallationNotFoundException $applicationInstallationNotFoundException) { + $this->logger->warning('ContactPerson.InstallContactPerson.applicationInstallationNotFound', [ + 'applicationInstallationId' => $command->applicationInstallationId, + 'message' => $applicationInstallationNotFoundException->getMessage(), + ]); + + throw $applicationInstallationNotFoundException; + } finally { + $this->logger->info('ContactPerson.InstallContactPerson.finish', [ + 'applicationInstallationId' => $command->applicationInstallationId, + 'bitrix24UserId' => $command->bitrix24UserId, + 'bitrix24PartnerId' => $command->bitrix24PartnerId?->toRfc4122() ?? '', + 'contact_person_id' => $createdContactPersonId, + ]); + } + } + + private function guardMobilePhoneNumber(PhoneNumber $mobilePhoneNumber): void + { + if (!$this->phoneNumberUtil->isValidNumber($mobilePhoneNumber)) { + $this->logger->warning('ContactPerson.InstallContactPerson.InvalidMobilePhoneNumber', [ + 'mobilePhoneNumber' => (string) $mobilePhoneNumber, + ]); + + throw new InvalidArgumentException('Invalid mobile phone number.'); + } + + if (PhoneNumberType::MOBILE !== $this->phoneNumberUtil->getNumberType($mobilePhoneNumber)) { + $this->logger->warning('ContactPerson.InstallContactPerson.MobilePhoneNumberMustBeMobile', [ + 'mobilePhoneNumber' => (string) $mobilePhoneNumber, + ]); + + throw new InvalidArgumentException('Phone number must be mobile.'); + } + } +} diff --git a/src/ApplicationInstallations/UseCase/OnAppInstall/Command.php b/src/ApplicationInstallations/UseCase/OnAppInstall/Command.php index dfb22497..fea531a5 100644 --- a/src/ApplicationInstallations/UseCase/OnAppInstall/Command.php +++ b/src/ApplicationInstallations/UseCase/OnAppInstall/Command.php @@ -5,6 +5,7 @@ namespace Bitrix24\Lib\ApplicationInstallations\UseCase\OnAppInstall; use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\SDK\Application\ApplicationStatus; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; /** @@ -18,7 +19,7 @@ public function __construct( public string $memberId, public Domain $domainUrl, public string $applicationToken, - public string $applicationStatus, + public ApplicationStatus $applicationStatus, ) { $this->validate(); } @@ -35,9 +36,5 @@ private function validate(): void if ('' === $this->applicationToken) { throw new InvalidArgumentException('ApplicationToken must be a non-empty string.'); } - - if ('' === $this->applicationStatus) { - throw new InvalidArgumentException('ApplicationStatus must be a non-empty string.'); - } } } diff --git a/src/ApplicationInstallations/UseCase/OnAppInstall/Handler.php b/src/ApplicationInstallations/UseCase/OnAppInstall/Handler.php index 9123cdf8..6c9b043b 100644 --- a/src/ApplicationInstallations/UseCase/OnAppInstall/Handler.php +++ b/src/ApplicationInstallations/UseCase/OnAppInstall/Handler.php @@ -5,8 +5,8 @@ namespace Bitrix24\Lib\ApplicationInstallations\UseCase\OnAppInstall; use Bitrix24\Lib\Services\Flusher; -use Bitrix24\SDK\Application\ApplicationStatus; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationInterface; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterface; use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountInterface; use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus; @@ -27,7 +27,7 @@ public function __construct( ) {} /** - * @throws InvalidArgumentException|MultipleBitrix24AccountsFoundException + * @throws ApplicationInstallationNotFoundException|InvalidArgumentException|MultipleBitrix24AccountsFoundException */ public function handle(Command $command): void { @@ -42,9 +42,13 @@ public function handle(Command $command): void // todo fix https://github.com/mesilov/bitrix24-php-lib/issues/59 $applicationInstallation = $this->applicationInstallationRepository->findByBitrix24AccountMemberId($command->memberId); - $applicationStatus = new ApplicationStatus($command->applicationStatus); + if (null === $applicationInstallation) { + throw new ApplicationInstallationNotFoundException( + sprintf('Application installation not found for member ID %s', $command->memberId) + ); + } - $applicationInstallation->changeApplicationStatus($applicationStatus); + $applicationInstallation->changeApplicationStatus($command->applicationStatus); $applicationInstallation->setApplicationToken($command->applicationToken); diff --git a/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Command.php b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Command.php new file mode 100644 index 00000000..2059747c --- /dev/null +++ b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Command.php @@ -0,0 +1,23 @@ +validate(); + } + + private function validate(): void + { + // no-op for now, but keep a place for future checks + } +} diff --git a/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Handler.php b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Handler.php new file mode 100644 index 00000000..f2b47c37 --- /dev/null +++ b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Handler.php @@ -0,0 +1,69 @@ +logger->info('ContactPerson.UnlinkContactPerson.start', [ + 'contactPersonId' => $command->contactPersonId, + 'applicationInstallationId' => $command->applicationInstallationId, + ]); + + try { + /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */ + $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId); + + /** @var AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $applicationInstallation */ + $applicationInstallation = $this->applicationInstallationRepository->getById($command->applicationInstallationId); + + $entitiesToFlush = []; + + if ($contactPerson->isPartner()) { + $applicationInstallation->unlinkBitrix24PartnerContactPerson(); + } else { + $applicationInstallation->unlinkContactPerson(); + } + + $this->applicationInstallationRepository->save($applicationInstallation); + $entitiesToFlush[] = $applicationInstallation; + + $contactPerson->markAsDeleted($command->comment); + $this->contactPersonRepository->save($contactPerson); + $entitiesToFlush[] = $contactPerson; + + $this->flusher->flush(...$entitiesToFlush); + } catch (ApplicationInstallationNotFoundException|ContactPersonNotFoundException $e) { + $this->logger->warning('ContactPerson.UnlinkContactPerson.notFound', [ + 'message' => $e->getMessage(), + ]); + + throw $e; + } finally { + $this->logger->info('ContactPerson.UnlinkContactPerson.finish', [ + 'contactPersonId' => $command->contactPersonId, + 'applicationInstallationId' => $command->applicationInstallationId, + ]); + } + } +} diff --git a/src/ContactPersons/Entity/ContactPerson.php b/src/ContactPersons/Entity/ContactPerson.php new file mode 100644 index 00000000..7f261811 --- /dev/null +++ b/src/ContactPersons/Entity/ContactPerson.php @@ -0,0 +1,329 @@ +isEmailVerified = null !== $emailVerifiedAt; + $this->isMobilePhoneVerified = null !== $mobilePhoneVerifiedAt; + $this->addContactPersonCreatedEventIfNeeded($this->isEmitContactPersonCreatedEvent); + } + + #[\Override] + public function getId(): Uuid + { + return $this->id; + } + + #[\Override] + public function getStatus(): ContactPersonStatus + { + return $this->status; + } + + #[\Override] + public function markAsActive(?string $comment): void + { + if (!in_array($this->status, [ContactPersonStatus::blocked, ContactPersonStatus::deleted], true)) { + throw new LogicException(sprintf('you must be in status blocked or deleted , now status is «%s»', $this->status->value)); + } + + $this->status = ContactPersonStatus::active; + $this->updatedAt = new CarbonImmutable(); + if (null !== $comment) { + $this->comment = $comment; + } + } + + #[\Override] + public function markAsBlocked(?string $comment): void + { + if (!in_array($this->status, [ContactPersonStatus::active, ContactPersonStatus::deleted], true)) { + throw new LogicException(sprintf('you must be in status active or deleted, now status is «%s»', $this->status->value)); + } + + $this->status = ContactPersonStatus::blocked; + $this->updatedAt = new CarbonImmutable(); + if (null !== $comment) { + $this->comment = $comment; + } + + $this->events[] = new ContactPersonBlockedEvent( + $this->id, + $this->updatedAt, + ); + } + + #[\Override] + public function markAsDeleted(?string $comment): void + { + if (!in_array($this->status, [ContactPersonStatus::active, ContactPersonStatus::blocked], true)) { + throw new InvalidArgumentException(sprintf('you must be in status active or blocked, now status is «%s»', $this->status->value)); + } + + $this->status = ContactPersonStatus::deleted; + $this->updatedAt = new CarbonImmutable(); + if (null !== $comment) { + $this->comment = $comment; + } + + $this->events[] = new ContactPersonDeletedEvent( + $this->id, + $this->updatedAt, + ); + } + + #[\Override] + public function getFullName(): FullName + { + return $this->fullName; + } + + #[\Override] + public function changeFullName(FullName $fullName): void + { + if ('' === trim($fullName->name)) { + throw new InvalidArgumentException('FullName name cannot be empty.'); + } + + $this->fullName = $fullName; + $this->updatedAt = new CarbonImmutable(); + $this->events[] = new ContactPersonFullNameChangedEvent( + $this->id, + $this->updatedAt, + ); + } + + #[\Override] + public function getCreatedAt(): CarbonImmutable + { + return $this->createdAt; + } + + #[\Override] + public function getUpdatedAt(): CarbonImmutable + { + return $this->updatedAt; + } + + #[\Override] + public function getEmail(): ?string + { + return $this->email; + } + + /** + * Changes the contact person's email address. + * + * If an empty string is provided (including a string containing only whitespace), + * it will be normalized to `null` so that the database stores `NULL` instead of an empty value. + */ + #[\Override] + public function changeEmail(?string $email): void + { + if (null !== $email) { + $email = trim($email); + if ('' === $email) { + $email = null; + } + } + + $this->email = $email; + $this->isEmailVerified = false; + $this->emailVerifiedAt = null; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new ContactPersonEmailChangedEvent( + $this->id, + $this->updatedAt, + ); + } + + #[\Override] + public function markEmailAsVerified(?CarbonImmutable $verifiedAt = null): void + { + $this->isEmailVerified = true; + + $this->emailVerifiedAt = $verifiedAt ?? new CarbonImmutable(); + $this->events[] = new ContactPersonEmailVerifiedEvent( + $this->id, + $this->emailVerifiedAt, + ); + } + + #[\Override] + public function isPartner(): bool + { + return $this->getBitrix24PartnerId() instanceof Uuid; + } + + #[\Override] + public function getEmailVerifiedAt(): ?CarbonImmutable + { + return $this->emailVerifiedAt; + } + + /** + * Changes the contact's mobile phone number. + * + * Note: This method does not validate the phone number. + * Make sure to use it through the appropriate use case, + * where validation is performed. + * + * If you use this method outside a use case, + * ensure that you pass a valid mobile phone number. + */ + #[\Override] + public function changeMobilePhone(?PhoneNumber $phoneNumber): void + { + $this->mobilePhoneNumber = $phoneNumber; + $this->isMobilePhoneVerified = false; + $this->mobilePhoneVerifiedAt = null; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new ContactPersonMobilePhoneChangedEvent( + $this->id, + $this->updatedAt, + ); + } + + #[\Override] + public function getMobilePhone(): ?PhoneNumber + { + return $this->mobilePhoneNumber; + } + + #[\Override] + public function getMobilePhoneVerifiedAt(): ?CarbonImmutable + { + return $this->mobilePhoneVerifiedAt; + } + + #[\Override] + public function markMobilePhoneAsVerified(?CarbonImmutable $verifiedAt = null): void + { + $this->isMobilePhoneVerified = true; + $this->mobilePhoneVerifiedAt = $verifiedAt ?? new CarbonImmutable(); + $this->events[] = new ContactPersonMobilePhoneVerifiedEvent( + $this->id, + $this->mobilePhoneVerifiedAt, + ); + } + + #[\Override] + public function getComment(): ?string + { + return $this->comment; + } + + #[\Override] + public function setExternalId(?string $externalId): void + { + if ('' === $externalId) { + throw new InvalidArgumentException('ExternalId cannot be empty string'); + } + + if ($this->externalId === $externalId) { + return; + } + + $this->externalId = $externalId; + $this->updatedAt = new CarbonImmutable(); + } + + #[\Override] + public function getExternalId(): ?string + { + return $this->externalId; + } + + #[\Override] + public function getBitrix24UserId(): int + { + return $this->bitrix24UserId; + } + + #[\Override] + public function getBitrix24PartnerId(): ?Uuid + { + return $this->bitrix24PartnerId; + } + + #[\Override] + public function setBitrix24PartnerId(?Uuid $uuid): void + { + $this->bitrix24PartnerId = $uuid; + $this->updatedAt = new CarbonImmutable(); + } + + #[\Override] + public function isEmailVerified(): bool + { + return $this->isEmailVerified; + } + + #[\Override] + public function isMobilePhoneVerified(): bool + { + return $this->isMobilePhoneVerified; + } + + #[\Override] + public function getUserAgentInfo(): UserAgentInfo + { + return $this->userAgentInfo; + } + + private function addContactPersonCreatedEventIfNeeded(bool $isEmitCreatedEvent): void + { + if ($isEmitCreatedEvent) { + // Create event and add it to events array + $this->events[] = new ContactPersonCreatedEvent( + $this->id, + $this->createdAt + ); + } + } +} diff --git a/src/ContactPersons/Enum/ContactPersonType.php b/src/ContactPersons/Enum/ContactPersonType.php new file mode 100644 index 00000000..edcf52a3 --- /dev/null +++ b/src/ContactPersons/Enum/ContactPersonType.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\ContactPersons\Enum; + +enum ContactPersonType: string +{ + case personal = 'personal'; + case partner = 'partner'; +} diff --git a/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php b/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php new file mode 100644 index 00000000..4fb9b76f --- /dev/null +++ b/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php @@ -0,0 +1,136 @@ +repository = $this->entityManager->getRepository(ContactPerson::class); + } + + #[\Override] + public function save(ContactPersonInterface $contactPerson): void + { + $this->entityManager->persist($contactPerson); + } + + #[\Override] + public function delete(Uuid $uuid): void + { + $contactPerson = $this->repository->find($uuid); + + if (null === $contactPerson) { + throw new ContactPersonNotFoundException( + sprintf('contactPerson not found by id %s', $uuid->toRfc4122()) + ); + } + + if (ContactPersonStatus::deleted !== $contactPerson->getStatus()) { + throw new InvalidArgumentException( + sprintf( + 'you cannot delete contactPerson «%s», they must be in status «deleted», current status «%s»', + $contactPerson->getId()->toRfc4122(), + $contactPerson->getStatus()->name + ) + ); + } + + $this->save($contactPerson); + } + + /** + * @phpstan-return ContactPersonInterface&AggregateRootEventsEmitterInterface + * + * @throws ContactPersonNotFoundException + */ + #[\Override] + public function getById(Uuid $uuid): ContactPersonInterface + { + $contactPerson = $this->repository + ->createQueryBuilder('contactPerson') + ->where('contactPerson.id = :id') + ->andWhere('contactPerson.status != :status') + ->setParameter('id', $uuid) + ->setParameter('status', ContactPersonStatus::deleted) + ->getQuery() + ->getOneOrNullResult() + ; + + if (null === $contactPerson) { + throw new ContactPersonNotFoundException( + sprintf('contactPerson account not found by id %s', $uuid->toRfc4122()) + ); + } + + return $contactPerson; + } + + #[\Override] + public function findByEmail(string $email, ?ContactPersonStatus $contactPersonStatus = null, ?bool $isEmailVerified = null): array + { + if ('' === trim($email)) { + throw new InvalidArgumentException('email cannot be an empty string'); + } + + $criteria = ['email' => $email]; + + if ($contactPersonStatus instanceof ContactPersonStatus) { + $criteria['status'] = $contactPersonStatus->name; + } + + if (null !== $isEmailVerified) { + $criteria['isEmailVerified'] = $isEmailVerified; + } + + return $this->repository->findBy($criteria); + } + + #[\Override] + public function findByPhone(PhoneNumber $phoneNumber, ?ContactPersonStatus $contactPersonStatus = null, ?bool $isPhoneVerified = null): array + { + $criteria = ['mobilePhoneNumber' => $phoneNumber]; + + if ($contactPersonStatus instanceof ContactPersonStatus) { + $criteria['status'] = $contactPersonStatus->name; + } + + if (null !== $isPhoneVerified) { + $criteria['isMobilePhoneVerified'] = $isPhoneVerified; + } + + return $this->repository->findBy($criteria); + } + + #[\Override] + public function findByExternalId(string $externalId, ?ContactPersonStatus $contactPersonStatus = null): array + { + if ('' === trim($externalId)) { + throw new InvalidArgumentException('external id cannot be empty'); + } + + $criteria = ['externalId' => $externalId]; + + if ($contactPersonStatus instanceof ContactPersonStatus) { + $criteria['status'] = $contactPersonStatus->name; + } + + return $this->repository->findBy($criteria); + } +} diff --git a/src/ContactPersons/UseCase/ChangeProfile/Command.php b/src/ContactPersons/UseCase/ChangeProfile/Command.php new file mode 100644 index 00000000..0dc45ccf --- /dev/null +++ b/src/ContactPersons/UseCase/ChangeProfile/Command.php @@ -0,0 +1,32 @@ +validate(); + } + + private function validate(): void + { + // Note: empty email is allowed for profile changes. + // If you pass an empty string (or whitespace), it will be normalized to `null` + // on the entity level, so the database will store `NULL` instead of an empty string. + if ('' !== trim($this->email) && !filter_var($this->email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException('Invalid email format.'); + } + } +} diff --git a/src/ContactPersons/UseCase/ChangeProfile/Handler.php b/src/ContactPersons/UseCase/ChangeProfile/Handler.php new file mode 100644 index 00000000..54b8333e --- /dev/null +++ b/src/ContactPersons/UseCase/ChangeProfile/Handler.php @@ -0,0 +1,96 @@ +logger->info('ContactPerson.ChangeProfile.start', [ + 'contactPersonId' => $command->contactPersonId, + 'fullName' => (string) $command->fullName, + 'email' => $command->email, + 'mobilePhoneNumber' => (string) $command->mobilePhoneNumber, + ]); + + try { + /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */ + $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId); + + if (!$command->fullName->equal($contactPerson->getFullName())) { + $contactPerson->changeFullName($command->fullName); + } + + if ($command->email !== $contactPerson->getEmail()) { + $contactPerson->changeEmail($command->email); + } + + $this->guardMobilePhoneNumber($command->mobilePhoneNumber); + if (!$command->mobilePhoneNumber->equals($contactPerson->getMobilePhone())) { + $contactPerson->changeMobilePhone($command->mobilePhoneNumber); + } + + $this->contactPersonRepository->save($contactPerson); + $this->flusher->flush($contactPerson); + + $this->logger->info('ContactPerson.ChangeProfile.finish', [ + 'contactPersonId' => $contactPerson->getId()->toRfc4122(), + 'updatedFields' => [ + 'fullName' => (string) $command->fullName, + 'email' => $command->email, + 'mobilePhoneNumber' => (string) $command->mobilePhoneNumber, + ], + ]); + } catch (ContactPersonNotFoundException $contactPersonNotFoundException) { + $this->logger->warning('ContactPerson.ChangeProfile.contactPersonNotFound', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'message' => $contactPersonNotFoundException->getMessage(), + ]); + + throw $contactPersonNotFoundException; + } finally { + $this->logger->info('ContactPerson.ChangeProfile.finish', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + ]); + } + } + + private function guardMobilePhoneNumber(PhoneNumber $mobilePhoneNumber): void + { + if (!$this->phoneNumberUtil->isValidNumber($mobilePhoneNumber)) { + $this->logger->warning('ContactPerson.ChangeProfile.InvalidMobilePhoneNumber', [ + 'mobilePhoneNumber' => (string) $mobilePhoneNumber, + ]); + + throw new InvalidArgumentException('Invalid mobile phone number.'); + } + + if (PhoneNumberType::MOBILE !== $this->phoneNumberUtil->getNumberType($mobilePhoneNumber)) { + $this->logger->warning('ContactPerson.ChangeProfile.MobilePhoneNumberMustBeMobile', [ + 'mobilePhoneNumber' => (string) $mobilePhoneNumber, + ]); + + throw new InvalidArgumentException('Phone number must be mobile.'); + } + } +} diff --git a/src/ContactPersons/UseCase/MarkEmailAsVerified/Command.php b/src/ContactPersons/UseCase/MarkEmailAsVerified/Command.php new file mode 100644 index 00000000..82013e56 --- /dev/null +++ b/src/ContactPersons/UseCase/MarkEmailAsVerified/Command.php @@ -0,0 +1,34 @@ +validate(); + } + + private function validate(): void + { + $email = trim($this->email); + + // Email verification requires a real (non-empty) email address. + // An empty value cannot be confirmed, so we fail fast with a clear error. + if ('' === $email) { + throw new \InvalidArgumentException('Cannot confirm an empty email.'); + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email format.'); + } + } +} diff --git a/src/ContactPersons/UseCase/MarkEmailAsVerified/Handler.php b/src/ContactPersons/UseCase/MarkEmailAsVerified/Handler.php new file mode 100644 index 00000000..40f2fb6b --- /dev/null +++ b/src/ContactPersons/UseCase/MarkEmailAsVerified/Handler.php @@ -0,0 +1,59 @@ +logger->info('ContactPerson.MarkEmailVerification.start', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'email' => $command->email, + ]); + + try { + /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */ + $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId); + + $actualEmail = $contactPerson->getEmail(); + + if (mb_strtolower((string) $actualEmail) === mb_strtolower($command->email)) { + $contactPerson->markEmailAsVerified($command->emailVerifiedAt); + $this->contactPersonRepository->save($contactPerson); + $this->flusher->flush($contactPerson); + } else { + $this->logger->warning('ContactPerson.MarkEmailVerification.emailMismatch', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'actualEmail' => $actualEmail, + 'expectedEmail' => $command->email, + ]); + } + } catch (ContactPersonNotFoundException $contactPersonNotFoundException) { + $this->logger->warning('ContactPerson.MarkEmailVerification.contactPersonNotFound', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'message' => $contactPersonNotFoundException->getMessage(), + ]); + + throw $contactPersonNotFoundException; + } finally { + $this->logger->info('ContactPerson.MarkEmailVerification.finish', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + ]); + } + } +} diff --git a/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Command.php b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Command.php new file mode 100644 index 00000000..6e339c65 --- /dev/null +++ b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Command.php @@ -0,0 +1,22 @@ +validate(); + } + + private function validate(): void {} +} diff --git a/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Handler.php b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Handler.php new file mode 100644 index 00000000..9d45766e --- /dev/null +++ b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Handler.php @@ -0,0 +1,71 @@ +phoneNumberUtil->format($command->phone, PhoneNumberFormat::E164); + + $this->logger->info('ContactPerson.MarkMobilePhoneVerification.start', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'phone' => $expectedMobilePhoneE164, + ]); + + try { + /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */ + $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId); + + $actualPhone = $contactPerson->getMobilePhone(); + + if (null !== $actualPhone && $command->phone->equals($actualPhone)) { + $contactPerson->markMobilePhoneAsVerified($command->phoneVerifiedAt); + + $this->contactPersonRepository->save($contactPerson); + $this->flusher->flush($contactPerson); + } else { + // Format the current mobile phone number to the international E.164 format + $actualMobilePhoneE164 = $this->phoneNumberUtil->format($actualPhone, PhoneNumberFormat::E164); + + $this->logger->warning('ContactPerson.MarkMobilePhoneVerification.phoneMismatch', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'actualPhone' => $actualMobilePhoneE164, + 'expectedPhone' => $expectedMobilePhoneE164, + ]); + + return; + } + } catch (ContactPersonNotFoundException $contactPersonNotFoundException) { + $this->logger->warning('ContactPerson.MarkMobilePhoneVerification.contactPersonNotFound', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + 'message' => $contactPersonNotFoundException->getMessage(), + ]); + + throw $contactPersonNotFoundException; + } finally { + $this->logger->info('ContactPerson.MarkMobilePhoneVerification.finish', [ + 'contactPersonId' => $command->contactPersonId->toRfc4122(), + ]); + } + } +} diff --git a/tests/EntityManagerFactory.php b/tests/EntityManagerFactory.php index e3935cf2..5f9832bb 100644 --- a/tests/EntityManagerFactory.php +++ b/tests/EntityManagerFactory.php @@ -6,6 +6,7 @@ use Bitrix24\SDK\Core\Exceptions\WrongConfigurationException; use Carbon\Doctrine\CarbonImmutableType; +use Darsyn\IP\Doctrine\MultiType; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception; use Doctrine\DBAL\Types\Type; @@ -14,6 +15,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMSetup; +use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Symfony\Bridge\Doctrine\Types\UuidType; class EntityManagerFactory @@ -66,6 +68,14 @@ public static function get(): EntityManagerInterface Type::addType('carbon_immutable', CarbonImmutableType::class); } + if (!Type::hasType('phone_number')) { + Type::addType('phone_number', PhoneNumberType::class); + } + + if (!Type::hasType('ip_address')) { + Type::addType('ip_address', MultiType::class); + } + $configuration = ORMSetup::createXMLMetadataConfiguration($paths, $isDevMode); $connection = DriverManager::getConnection($connectionParams, $configuration); diff --git a/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php b/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php index 264a03a5..b36f97a1 100644 --- a/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php +++ b/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php @@ -17,11 +17,11 @@ class ApplicationInstallationBuilder private Uuid $bitrix24AccountId; - private readonly ?Uuid $contactPersonId; + private ?Uuid $contactPersonId; - private readonly ?Uuid $bitrix24PartnerContactPersonId; + private ?Uuid $bitrix24PartnerContactPersonId; - private readonly ?Uuid $bitrix24PartnerId; + private ?Uuid $bitrix24PartnerId = null; private ?string $externalId = null; @@ -43,7 +43,6 @@ public function __construct() $this->bitrix24AccountId = Uuid::v7(); $this->bitrix24PartnerContactPersonId = Uuid::v7(); $this->contactPersonId = Uuid::v7(); - $this->bitrix24PartnerId = Uuid::v7(); $this->portalUsersCount = random_int(1, 1_000_000); } @@ -61,6 +60,13 @@ public function withApplicationToken(string $applicationToken): self return $this; } + public function withBitrix24PartnerId(?Uuid $uuid): self + { + $this->bitrix24PartnerId = $uuid; + + return $this; + } + public function withApplicationStatusInstallation(ApplicationInstallationStatus $applicationInstallationStatus): self { $this->status = $applicationInstallationStatus; @@ -82,6 +88,20 @@ public function withBitrix24AccountId(Uuid $uuid): self return $this; } + public function withContactPersonId(?Uuid $uuid): self + { + $this->contactPersonId = $uuid; + + return $this; + } + + public function withBitrix24PartnerContactPersonId(?Uuid $uuid): self + { + $this->bitrix24PartnerContactPersonId = $uuid; + + return $this; + } + public function withPortalLicenseFamily(PortalLicenseFamily $portalLicenseFamily): self { $this->portalLicenseFamily = $portalLicenseFamily; diff --git a/tests/Functional/ApplicationInstallations/UseCase/InstallContactPerson/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/InstallContactPerson/HandlerTest.php new file mode 100644 index 00000000..076c62b6 --- /dev/null +++ b/tests/Functional/ApplicationInstallations/UseCase/InstallContactPerson/HandlerTest.php @@ -0,0 +1,296 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\ApplicationInstallations\UseCase\InstallContactPerson; + +use Bitrix24\Lib\ApplicationInstallations\Infrastructure\Doctrine\ApplicationInstallationRepository; +use Bitrix24\Lib\ApplicationInstallations\UseCase\InstallContactPerson\Command; +use Bitrix24\Lib\ApplicationInstallations\UseCase\InstallContactPerson\Handler; +use Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine\Bitrix24AccountRepository; +use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\ApplicationInstallations\Builders\ApplicationInstallationBuilder; +use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder; +use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder; +use Bitrix24\SDK\Application\ApplicationStatus; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationContactPersonLinkedEvent; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException; +use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonCreatedEvent; +use Bitrix24\SDK\Application\PortalLicenseFamily; +use Bitrix24\SDK\Core\Credentials\Scope; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberUtil; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Handler::class)] +class HandlerTest extends TestCase +{ + /** + * @var PhoneNumberUtil + */ + public $phoneNumberUtil; + + private Handler $handler; + + private Flusher $flusher; + + private ContactPersonRepository $repository; + + private ApplicationInstallationRepository $applicationInstallationRepository; + + private Bitrix24AccountRepository $bitrix24accountRepository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new ContactPersonRepository($entityManager); + $this->applicationInstallationRepository = new ApplicationInstallationRepository($entityManager); + $this->bitrix24accountRepository = new Bitrix24AccountRepository($entityManager); + $this->phoneNumberUtil = PhoneNumberUtil::getInstance(); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Handler( + $this->applicationInstallationRepository, + $this->repository, + $this->phoneNumberUtil, + $this->flusher, + new NullLogger() + ); + } + + #[Test] + public function testInstallContactPersonSuccess(): void + { + // Подготовка Bitrix24 аккаунта и установки приложения + $applicationToken = Uuid::v7()->toRfc4122(); + $memberId = Uuid::v4()->toRfc4122(); + $externalId = Uuid::v7()->toRfc4122(); + + $bitrix24Account = (new Bitrix24AccountBuilder()) + ->withApplicationScope(new Scope(['crm'])) + ->withStatus(Bitrix24AccountStatus::new) + ->withApplicationToken($applicationToken) + ->withMemberId($memberId) + ->withMaster(true) + ->withSetToken() + ->withInstalled() + ->build() + ; + + $this->bitrix24accountRepository->save($bitrix24Account); + + $applicationInstallation = (new ApplicationInstallationBuilder()) + ->withApplicationStatus(new ApplicationStatus('F')) + ->withPortalLicenseFamily(PortalLicenseFamily::free) + ->withBitrix24AccountId($bitrix24Account->getId()) + ->withApplicationStatusInstallation(ApplicationInstallationStatus::active) + ->withApplicationToken($applicationToken) + ->withContactPersonId(null) + ->withBitrix24PartnerContactPersonId(null) + ->withExternalId($externalId) + ->build() + ; + + $this->applicationInstallationRepository->save($applicationInstallation); + $this->flusher->flush(); + + // Данные контакта + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId($externalId) + ->withBitrix24UserId($bitrix24Account->getBitrix24UserId()) + ->withBitrix24PartnerId($applicationInstallation->getBitrix24PartnerId()) + ->build() + ; + + // Запуск use-case + $this->handler->handle( + new Command( + $applicationInstallation->getId(), + $contactPerson->getFullName(), + $bitrix24Account->getBitrix24UserId(), + $contactPerson->getUserAgentInfo(), + $contactPerson->getEmail(), + $contactPerson->getMobilePhone(), + $contactPerson->getComment(), + $contactPerson->getExternalId(), + $contactPerson->getBitrix24PartnerId(), + ) + ); + + // Проверки: событие, связь и наличие контакта + $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents(); + $this->assertContains(ContactPersonCreatedEvent::class, $dispatchedEvents); + $this->assertContains(ApplicationInstallationContactPersonLinkedEvent::class, $dispatchedEvents); + + $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId()); + $contactPersonId = $foundInstallation->getContactPersonId(); + $this->assertNotNull($contactPersonId); + + $foundContactPerson = $this->repository->getById($contactPersonId); + $this->assertEquals($contactPersonId, $foundContactPerson->getId()); + $this->assertEquals($contactPerson->getEmail(), $foundContactPerson->getEmail()); + $this->assertEquals($contactPerson->getMobilePhone(), $foundContactPerson->getMobilePhone()); + $this->assertEquals($contactPerson->getFullName(), $foundContactPerson->getFullName()); + $this->assertEquals($contactPerson->getComment(), $foundContactPerson->getComment()); + $this->assertEquals($contactPerson->getExternalId(), $foundContactPerson->getExternalId()); + $this->assertEquals($contactPerson->getBitrix24UserId(), $foundContactPerson->getBitrix24UserId()); + $this->assertEquals($contactPerson->getBitrix24PartnerId(), $foundContactPerson->getBitrix24PartnerId()); + } + + #[Test] + public function testInstallContactPersonWithWrongApplicationInstallationId(): void + { + // Подготовим входные данные контакта (без реальной установки) + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId(Uuid::v7()->toRfc4122()) + ->build() + ; + + $uuidV7 = Uuid::v7(); + + $this->expectException(ApplicationInstallationNotFoundException::class); + + $this->handler->handle( + new Command( + $uuidV7, + $contactPerson->getFullName(), + random_int(1, 1_000_000), + $contactPerson->getUserAgentInfo(), + $contactPerson->getEmail(), + $contactPerson->getMobilePhone(), + $contactPerson->getComment(), + $contactPerson->getExternalId(), + $contactPerson->getBitrix24PartnerId(), + ) + ); + } + + #[Test] + public function testInstallContactPersonWithInvalidEmail(): void + { + // Подготовим входные данные контакта + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('invalid-email') + ->build() + ; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email format.'); + + new Command( + Uuid::v7(), + $contactPerson->getFullName(), + 1, + $contactPerson->getUserAgentInfo(), + $contactPerson->getEmail(), + $contactPerson->getMobilePhone(), + $contactPerson->getComment(), + $contactPerson->getExternalId(), + $contactPerson->getBitrix24PartnerId(), + ); + } + + #[Test] + #[DataProvider('invalidPhoneProvider')] + public function testInstallContactPersonWithInvalidPhone(string $phoneNumber, string $region): void + { + // Подготовка Bitrix24 аккаунта и установки приложения + $applicationToken = Uuid::v7()->toRfc4122(); + $memberId = Uuid::v7()->toRfc4122(); + + $bitrix24Account = (new Bitrix24AccountBuilder()) + ->withApplicationToken($applicationToken) + ->withMemberId($memberId) + ->build() + ; + $this->bitrix24accountRepository->save($bitrix24Account); + + $applicationInstallation = (new ApplicationInstallationBuilder()) + ->withBitrix24AccountId($bitrix24Account->getId()) + ->withApplicationToken($applicationToken) + ->withApplicationStatus(new ApplicationStatus('F')) + ->withPortalLicenseFamily(PortalLicenseFamily::free) + ->build() + ; + $this->applicationInstallationRepository->save($applicationInstallation); + $this->flusher->flush(); + + $invalidPhoneNumber = $this->phoneNumberUtil->parse($phoneNumber, $region); + + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($invalidPhoneNumber) + ->build() + ; + + $this->handler->handle( + new Command( + $applicationInstallation->getId(), + $contactPerson->getFullName(), + $bitrix24Account->getBitrix24UserId(), + $contactPerson->getUserAgentInfo(), + $contactPerson->getEmail(), + $contactPerson->getMobilePhone(), + $contactPerson->getComment(), + $contactPerson->getExternalId(), + $contactPerson->getBitrix24PartnerId(), + ) + ); + + // Проверяем, что контакт не был создан + $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId()); + $this->assertNull($foundInstallation->getBitrix24PartnerId()); + } + + public static function invalidPhoneProvider(): array + { + return [ + 'invalid format' => ['123', 'RU'], + 'not mobile' => ['+74951234567', 'RU'], // Moscow landline + ]; + } + + private function createPhoneNumber(string $number): PhoneNumber + { + $phoneNumberUtil = PhoneNumberUtil::getInstance(); + + return $phoneNumberUtil->parse($number, 'RU'); + } +} diff --git a/tests/Functional/ApplicationInstallations/UseCase/OnAppInstall/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/OnAppInstall/HandlerTest.php index ea179a59..01e8c2f2 100644 --- a/tests/Functional/ApplicationInstallations/UseCase/OnAppInstall/HandlerTest.php +++ b/tests/Functional/ApplicationInstallations/UseCase/OnAppInstall/HandlerTest.php @@ -25,6 +25,7 @@ use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder; use Bitrix24\SDK\Application\ApplicationStatus; use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException; use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus; use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Exceptions\Bitrix24AccountNotFoundException; use Bitrix24\SDK\Application\PortalLicenseFamily; @@ -80,7 +81,7 @@ protected function setUp(): void } /** - * @throws InvalidArgumentException|Bitrix24AccountNotFoundException + * @throws InvalidArgumentException|Bitrix24AccountNotFoundException|ApplicationInstallationNotFoundException */ #[Test] public function testEventOnAppInstall(): void @@ -88,7 +89,7 @@ public function testEventOnAppInstall(): void $memberId = Uuid::v4()->toRfc4122(); $domainUrl = Uuid::v4()->toRfc4122().'-example.com'; $applicationToken = Uuid::v7()->toRfc4122(); - $applicationStatus = 'T'; + $applicationStatus = new ApplicationStatus('T'); $bitrix24Account = (new Bitrix24AccountBuilder()) ->withApplicationScope(new Scope(['crm'])) diff --git a/tests/Functional/ApplicationInstallations/UseCase/UnlinkContactPerson/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/UnlinkContactPerson/HandlerTest.php new file mode 100644 index 00000000..ee82d512 --- /dev/null +++ b/tests/Functional/ApplicationInstallations/UseCase/UnlinkContactPerson/HandlerTest.php @@ -0,0 +1,370 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\ApplicationInstallations\UseCase\UnlinkContactPerson; + +use Bitrix24\Lib\ApplicationInstallations\Infrastructure\Doctrine\ApplicationInstallationRepository; +use Bitrix24\Lib\ApplicationInstallations\UseCase\UnlinkContactPerson\Command; +use Bitrix24\Lib\ApplicationInstallations\UseCase\UnlinkContactPerson\Handler; +use Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine\Bitrix24AccountRepository; +use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\ApplicationInstallations\Builders\ApplicationInstallationBuilder; +use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder; +use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder; +use Bitrix24\SDK\Application\ApplicationStatus; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationBitrix24PartnerContactPersonUnlinkedEvent; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationContactPersonUnlinkedEvent; +use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException; +use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonDeletedEvent; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException; +use Bitrix24\SDK\Application\PortalLicenseFamily; +use Bitrix24\SDK\Core\Credentials\Scope; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberUtil; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Handler::class)] +class HandlerTest extends TestCase +{ + private Handler $handler; + + private Flusher $flusher; + + private ContactPersonRepository $repository; + + private ApplicationInstallationRepository $applicationInstallationRepository; + + private Bitrix24AccountRepository $bitrix24accountRepository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $this->truncateAllTables(); + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new ContactPersonRepository($entityManager); + $this->applicationInstallationRepository = new ApplicationInstallationRepository($entityManager); + $this->bitrix24accountRepository = new Bitrix24AccountRepository($entityManager); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Handler( + $this->applicationInstallationRepository, + $this->repository, + $this->flusher, + new NullLogger() + ); + } + + /** + * @throws InvalidArgumentException|\Random\RandomException + */ + #[Test] + public function testUninstallContactPersonSuccess(): void + { + // Подготовка Bitrix24 аккаунта и установки приложения + $applicationToken = Uuid::v7()->toRfc4122(); + $memberId = Uuid::v4()->toRfc4122(); + $externalId = Uuid::v7()->toRfc4122(); + + $bitrix24Account = (new Bitrix24AccountBuilder()) + ->withApplicationScope(new Scope(['crm'])) + ->withStatus(Bitrix24AccountStatus::new) + ->withApplicationToken($applicationToken) + ->withMemberId($memberId) + ->withMaster(true) + ->withSetToken() + ->withInstalled() + ->build(); + + $this->bitrix24accountRepository->save($bitrix24Account); + + $applicationInstallation = (new ApplicationInstallationBuilder()) + ->withApplicationStatus(new ApplicationStatus('F')) + ->withPortalLicenseFamily(PortalLicenseFamily::free) + ->withBitrix24AccountId($bitrix24Account->getId()) + ->withApplicationStatusInstallation(ApplicationInstallationStatus::active) + ->withApplicationToken($applicationToken) + ->withContactPersonId(null) + ->withBitrix24PartnerContactPersonId(null) + ->withExternalId($externalId) + ->build(); + + $this->applicationInstallationRepository->save($applicationInstallation); + + // Создаём контакт и привязываем к установке + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId($externalId) + ->withBitrix24UserId($bitrix24Account->getBitrix24UserId()) + ->build(); + + $this->repository->save($contactPerson); + $applicationInstallation->linkContactPerson($contactPerson->getId()); + $this->applicationInstallationRepository->save($applicationInstallation); + $this->flusher->flush(); + + // Запуск use-case + $this->handler->handle( + new Command( + $contactPerson->getId(), + $applicationInstallation->getId(), + 'Deleted by test' + ) + ); + + // Проверки: события отвязки и удаления контакта + $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents(); + $this->assertContains(ContactPersonDeletedEvent::class, $dispatchedEvents); + $this->assertContains(ApplicationInstallationContactPersonUnlinkedEvent::class, $dispatchedEvents); + + // Перечитаем установку и проверим, что контакт отвязан + $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId()); + $this->assertNull($foundInstallation->getContactPersonId()); + + // Контакт помечен как удалённый и недоступен через getById + $this->expectException(ContactPersonNotFoundException::class); + $this->repository->getById($contactPerson->getId()); + } + + #[Test] + public function testUninstallContactPersonNotFound(): void + { + // Подготовка Bitrix24 аккаунта и установки приложения (чтобы getCurrent() вернул установку) + $applicationToken = Uuid::v7()->toRfc4122(); + $memberId = Uuid::v4()->toRfc4122(); + $externalId = Uuid::v7()->toRfc4122(); + + $bitrix24Account = (new Bitrix24AccountBuilder()) + ->withApplicationScope(new Scope(['crm'])) + ->withStatus(Bitrix24AccountStatus::new) + ->withApplicationToken($applicationToken) + ->withMemberId($memberId) + ->withMaster(true) + ->withSetToken() + ->withInstalled() + ->build(); + + $this->bitrix24accountRepository->save($bitrix24Account); + + $applicationInstallation = (new ApplicationInstallationBuilder()) + ->withApplicationStatus(new ApplicationStatus('F')) + ->withPortalLicenseFamily(PortalLicenseFamily::free) + ->withBitrix24AccountId($bitrix24Account->getId()) + ->withApplicationStatusInstallation(ApplicationInstallationStatus::active) + ->withApplicationToken($applicationToken) + ->withContactPersonId(null) + ->withBitrix24PartnerContactPersonId(null) + ->withExternalId($externalId) + ->build(); + + $this->applicationInstallationRepository->save($applicationInstallation); + $this->flusher->flush(); + + // Ожидаем исключение, т.к. контактного лица с таким ID нет + $this->expectException(ContactPersonNotFoundException::class); + + $this->handler->handle( + new Command( + Uuid::v7(), + $applicationInstallation->getId(), + 'Deleted by test' + ) + ); + } + + #[Test] + public function testUninstallContactPersonWithWrongApplicationInstallationId(): void + { + // Создадим контактное лицо, но не будем создавать установку приложения, + // чтобы репозиторий вернул ApplicationInstallationNotFoundException при getCurrent() + $externalId = Uuid::v7()->toRfc4122(); + $contactPerson = (new ContactPersonBuilder()) + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId($externalId) + ->build(); + + $this->repository->save($contactPerson); + $this->flusher->flush(); + + $this->expectException(ApplicationInstallationNotFoundException::class); + + $this->handler->handle( + new Command( + $contactPerson->getId(), + Uuid::v7(), + 'Deleted by test' + ) + ); + } + + /** + * @throws InvalidArgumentException|\Random\RandomException + */ + #[Test] + public function testUninstallPartnerContactPersonSuccess(): void + { + // Подготовка Bitrix24 аккаунта и установки приложения + $applicationToken = Uuid::v7()->toRfc4122(); + $memberId = Uuid::v4()->toRfc4122(); + $externalId = Uuid::v7()->toRfc4122(); + + $bitrix24Account = (new Bitrix24AccountBuilder()) + ->withApplicationScope(new Scope(['crm'])) + ->withStatus(Bitrix24AccountStatus::new) + ->withApplicationToken($applicationToken) + ->withMemberId($memberId) + ->withMaster(true) + ->withSetToken() + ->withInstalled() + ->build(); + + $this->bitrix24accountRepository->save($bitrix24Account); + + $applicationInstallation = (new ApplicationInstallationBuilder()) + ->withApplicationStatus(new ApplicationStatus('F')) + ->withPortalLicenseFamily(PortalLicenseFamily::free) + ->withBitrix24AccountId($bitrix24Account->getId()) + ->withApplicationStatusInstallation(ApplicationInstallationStatus::active) + ->withApplicationToken($applicationToken) + ->withContactPersonId(null) + ->withBitrix24PartnerContactPersonId(null) + ->withExternalId($externalId) + ->build(); + + $this->applicationInstallationRepository->save($applicationInstallation); + + // Создаём контакт и привязываем как партнёрский к установке + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId($externalId) + ->withBitrix24UserId($bitrix24Account->getBitrix24UserId()) + ->withBitrix24PartnerId(Uuid::v7()) + ->build(); + + $this->repository->save($contactPerson); + $applicationInstallation->linkBitrix24PartnerContactPerson($contactPerson->getId()); + $this->applicationInstallationRepository->save($applicationInstallation); + $this->flusher->flush(); + + // Запуск use-case + $this->handler->handle( + new Command( + $contactPerson->getId(), + $applicationInstallation->getId(), + 'Deleted by test' + ) + ); + + // Проверки: события отвязки и удаления контакта + $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents(); + $this->assertContains(ContactPersonDeletedEvent::class, $dispatchedEvents); + $this->assertContains(ApplicationInstallationBitrix24PartnerContactPersonUnlinkedEvent::class, $dispatchedEvents); + + // Перечитаем установку и проверим, что партнёрский контакт отвязан + $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId()); + $this->assertNull($foundInstallation->getBitrix24PartnerContactPersonId()); + + $this->expectException(ContactPersonNotFoundException::class); + $this->repository->getById($contactPerson->getId()); + } + + #[Test] + public function testUninstallPartnerContactPersonWithWrongApplicationInstallationId(): void + { + // Создадим контактное лицо, но не будем создавать установку приложения, + // чтобы репозиторий вернул ApplicationInstallationNotFoundException при getCurrent() + $externalId = Uuid::v7()->toRfc4122(); + $contactPerson = (new ContactPersonBuilder()) + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId($externalId) + ->build(); + + $this->repository->save($contactPerson); + $this->flusher->flush(); + + $this->expectException(ApplicationInstallationNotFoundException::class); + + $this->handler->handle( + new Command( + $contactPerson->getId(), + Uuid::v7(), + 'Deleted by test' + ) + ); + } + + private function createPhoneNumber(string $number): PhoneNumber + { + $phoneNumberUtil = PhoneNumberUtil::getInstance(); + return $phoneNumberUtil->parse($number, 'RU'); + } + + private function truncateAllTables(): void + { + $entityManager = EntityManagerFactory::get(); + $connection = $entityManager->getConnection(); + $schemaManager = $connection->createSchemaManager(); + + $names = $schemaManager->introspectTableNames(); + + if ($names === []) { + return; + } + + $quotedTables = []; + + foreach ($names as $name) { + $tableName = $name->toString(); + $quotedTables[] = $tableName; + } + + $sql = 'TRUNCATE ' . implode(', ', $quotedTables) . ' RESTART IDENTITY CASCADE'; + + $connection->beginTransaction(); + try { + $connection->executeStatement($sql); + $connection->commit(); + } catch (\Throwable $throwable) { + $connection->rollBack(); + throw $throwable; + } + + $entityManager->clear(); + } +} diff --git a/tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php b/tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php new file mode 100644 index 00000000..b07fbb82 --- /dev/null +++ b/tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php @@ -0,0 +1,131 @@ +id = Uuid::v7(); + $this->fullName = DemoDataGenerator::getFullName(); + $this->bitrix24UserId = random_int(1, 1_000_000); + } + + public function withStatus(ContactPersonStatus $contactPersonStatus): self + { + $this->status = $contactPersonStatus; + + return $this; + } + + public function withFullName(FullName $fullName): self + { + $this->fullName = $fullName; + + return $this; + } + + public function withEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + public function withMobilePhoneNumber(PhoneNumber $mobilePhoneNumber): self + { + $this->mobilePhoneNumber = $mobilePhoneNumber; + + return $this; + } + + public function withComment(string $comment): self + { + $this->comment = $comment; + + return $this; + } + + public function withExternalId(string $externalId): self + { + $this->externalId = $externalId; + + return $this; + } + + public function withBitrix24UserId(int $bitrix24UserId): self + { + $this->bitrix24UserId = $bitrix24UserId; + + return $this; + } + + public function withBitrix24PartnerId(?Uuid $uuid): self + { + $this->bitrix24PartnerId = $uuid; + + return $this; + } + + public function withUserAgentInfo(UserAgentInfo $userAgentInfo): self + { + $this->userAgentInfo = $userAgentInfo; + + return $this; + } + + public function build(): ContactPerson + { + $userAgentInfo = $this->userAgentInfo ?? new UserAgentInfo( + DemoDataGenerator::getUserAgentIp(), + DemoDataGenerator::getUserAgent() + ); + + return new ContactPerson( + $this->id, + $this->status, + $this->bitrix24UserId, + $this->fullName, + $this->email, + null, + $this->mobilePhoneNumber, + null, + $this->comment, + $this->externalId, + $this->bitrix24PartnerId, + $userAgentInfo + ); + } +} \ No newline at end of file diff --git a/tests/Functional/ContactPersons/Infrastructure/Doctrine/ContactPersonRepositoryTest.php b/tests/Functional/ContactPersons/Infrastructure/Doctrine/ContactPersonRepositoryTest.php new file mode 100644 index 00000000..50b34310 --- /dev/null +++ b/tests/Functional/ContactPersons/Infrastructure/Doctrine/ContactPersonRepositoryTest.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\ContactPersons\UseCase\ChangeProfile; + +use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository; +use Bitrix24\Lib\ContactPersons\UseCase\ChangeProfile\Command; +use Bitrix24\Lib\ContactPersons\UseCase\ChangeProfile\Handler; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\FullName; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonEmailChangedEvent; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonFullNameChangedEvent; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonMobilePhoneChangedEvent; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberFormat; +use libphonenumber\PhoneNumberUtil; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Uid\Uuid; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException; + +/** + * @internal + */ +#[CoversClass(Handler::class)] +class HandlerTest extends TestCase +{ + /** + * @var PhoneNumberUtil + */ + public $phoneNumberUtil; + + private Handler $handler; + + private Flusher $flusher; + + private ContactPersonRepository $repository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new ContactPersonRepository($entityManager); + $this->phoneNumberUtil = PhoneNumberUtil::getInstance(); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Handler( + $this->repository, + $this->phoneNumberUtil, + $this->flusher, + new NullLogger() + ); + } + + #[Test] + public function testUpdateExistingContactPerson(): void + { + // Создаем контактное лицо через билдера + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Initial comment') + ->withExternalId(Uuid::v7()->toRfc4122()) + ->withBitrix24UserId(random_int(1, 1_000_000)) + ->withBitrix24PartnerId(Uuid::v7()) + ->build() + ; + + $this->repository->save($contactPerson); + $this->flusher->flush(); + + // Обновляем контактное лицо через команду + $this->handler->handle( + new Command( + $contactPerson->getId(), + new FullName('Jane Doe'), + 'jane.doe@example.com', + $this->createPhoneNumber('+79997654321') + ) + ); + + // Проверяем, что изменения сохранились + $updatedContactPerson = $this->repository->getById($contactPerson->getId()); + $formattedPhone = $this->phoneNumberUtil->format($updatedContactPerson->getMobilePhone(), PhoneNumberFormat::E164); + + $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents(); + $this->assertContains(ContactPersonEmailChangedEvent::class, $dispatchedEvents); + $this->assertContains(ContactPersonMobilePhoneChangedEvent::class, $dispatchedEvents); + $this->assertContains(ContactPersonFullNameChangedEvent::class, $dispatchedEvents); + $this->assertEquals('Jane Doe', $updatedContactPerson->getFullName()->name); + $this->assertEquals('jane.doe@example.com', $updatedContactPerson->getEmail()); + $this->assertEquals('+79997654321', $formattedPhone); + } + + #[Test] + public function testUpdateWithNonExistentContactPerson(): void + { + $this->expectException(ContactPersonNotFoundException::class); + + $this->handler->handle( + new Command( + Uuid::v7(), + new FullName('Jane Doe'), + 'jane.doe@example.com', + $this->createPhoneNumber('+79997654321') + ) + ); + } + + #[Test] + public function testUpdateWithSameData(): void + { + // Создаем контактное лицо через билдера + $email = 'john.doe@example.com'; + $fullName = new FullName('John Doe'); + $phone = '+79991234567'; + + $contactPersonBuilder = new ContactPersonBuilder(); + $contactPerson = $contactPersonBuilder + ->withEmail($email) + ->withFullName($fullName) + ->withMobilePhoneNumber($this->createPhoneNumber($phone)) + ->withExternalId(Uuid::v7()->toRfc4122()) + ->withBitrix24UserId(random_int(1, 1_000_000)) + ->withBitrix24PartnerId(Uuid::v7()) + ->build() + ; + + $this->repository->save($contactPerson); + $this->flusher->flush(); + + // Обновляем контактное лицо теми же данными + $this->handler->handle( + new Command( + $contactPerson->getId(), + $fullName, + $email, + $this->createPhoneNumber($phone) + ) + ); + + // Проверяем, что события не были отправлены + $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents(); + $this->assertNotContains(ContactPersonEmailChangedEvent::class, $dispatchedEvents); + $this->assertNotContains(ContactPersonMobilePhoneChangedEvent::class, $dispatchedEvents); + $this->assertNotContains(ContactPersonFullNameChangedEvent::class, $dispatchedEvents); + + // Проверяем, что данные не изменились + $updatedContactPerson = $this->repository->getById($contactPerson->getId()); + $this->assertEquals($fullName->name, $updatedContactPerson->getFullName()->name); + $this->assertEquals($email, $updatedContactPerson->getEmail()); + $this->assertEquals($phone, $this->phoneNumberUtil->format($updatedContactPerson->getMobilePhone(), PhoneNumberFormat::E164)); + } + + private function createPhoneNumber(string $number): PhoneNumber + { + $phoneNumberUtil = PhoneNumberUtil::getInstance(); + + return $phoneNumberUtil->parse($number, 'RU'); + } +} diff --git a/tests/Functional/ContactPersons/UseCase/MarkEmailAsVerified/HandlerTest.php b/tests/Functional/ContactPersons/UseCase/MarkEmailAsVerified/HandlerTest.php new file mode 100644 index 00000000..176627fa --- /dev/null +++ b/tests/Functional/ContactPersons/UseCase/MarkEmailAsVerified/HandlerTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\ContactPersons\UseCase\MarkEmailAsVerified; + +use Bitrix24\Lib\ContactPersons\Entity\ContactPerson; +use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository; +use Bitrix24\Lib\ContactPersons\UseCase\MarkEmailAsVerified\Command; +use Bitrix24\Lib\ContactPersons\UseCase\MarkEmailAsVerified\Handler; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException; +use Carbon\CarbonImmutable; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberUtil; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Handler::class)] +class HandlerTest extends TestCase +{ + private Handler $handler; + + private Flusher $flusher; + + private ContactPersonRepository $repository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new ContactPersonRepository($entityManager); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Handler( + $this->repository, + $this->flusher, + new NullLogger() + ); + } + + #[Test] + public function testConfirmEmailVerificationSuccess(): void + { + $contactPerson = $this->createContactPerson('john.doe@example.com'); + + $verifiedAt = new CarbonImmutable('2025-01-01T10:00:00+00:00'); + $this->handler->handle( + new Command($contactPerson->getId(), 'john.doe@example.com', $verifiedAt) + ); + + $updatedContactPerson = $this->repository->getById($contactPerson->getId()); + $this->assertTrue($updatedContactPerson->isEmailVerified()); + $this->assertSame($verifiedAt->toISOString(), $updatedContactPerson->getEmailVerifiedAt()?->toISOString()); + } + + #[Test] + #[DataProvider('invalidMarkEmailVerificationProvider')] + public function testConfirmEmailVerificationFails( + bool $useRealContactId, + string $emailInCommand, + ?string $expectedExceptionClass = null + ): void { + $contactPerson = $this->createContactPerson('john.doe@example.com'); + $contactId = $useRealContactId ? $contactPerson->getId() : Uuid::v7(); + + if (null !== $expectedExceptionClass) { + $this->expectException($expectedExceptionClass); + } + + $this->handler->handle(new Command($contactId, $emailInCommand)); + + if (null === $expectedExceptionClass) { + // Если исключение не ожидалось (например, при несовпадении email), проверяем, что статус не изменился + $reloaded = $this->repository->getById($contactPerson->getId()); + $this->assertFalse($reloaded->isEmailVerified()); + } + } + + public static function invalidMarkEmailVerificationProvider(): array + { + return [ + 'contact person not found' => [ + 'useRealContactId' => false, + 'emailInCommand' => 'john.doe@example.com', + 'expectedExceptionClass' => ContactPersonNotFoundException::class, + ], + 'email mismatch' => [ + 'useRealContactId' => true, + 'emailInCommand' => 'another.email@example.com', + 'expectedExceptionClass' => null, + ], + 'invalid email format' => [ + 'useRealContactId' => true, + 'emailInCommand' => 'not-an-email', + 'expectedExceptionClass' => \InvalidArgumentException::class, + ], + ]; + } + + private function createContactPerson(string $email): ContactPerson + { + $contactPerson = (new ContactPersonBuilder()) + ->withEmail($email) + ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567')) + ->withComment('Test comment') + ->withExternalId(Uuid::v7()->toRfc4122()) + ->withBitrix24UserId(random_int(1, 1_000_000)) + ->withBitrix24PartnerId(Uuid::v7()) + ->build() + ; + + $this->repository->save($contactPerson); + $this->flusher->flush(); + + return $contactPerson; + } + + private function createPhoneNumber(string $number): PhoneNumber + { + $phoneNumberUtil = PhoneNumberUtil::getInstance(); + + return $phoneNumberUtil->parse($number, 'RU'); + } +} diff --git a/tests/Functional/ContactPersons/UseCase/MarkMobilePhoneAsVerified/HandlerTest.php b/tests/Functional/ContactPersons/UseCase/MarkMobilePhoneAsVerified/HandlerTest.php new file mode 100644 index 00000000..dfe01136 --- /dev/null +++ b/tests/Functional/ContactPersons/UseCase/MarkMobilePhoneAsVerified/HandlerTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\ContactPersons\UseCase\MarkMobilePhoneAsVerified; + +use Bitrix24\Lib\ContactPersons\Entity\ContactPerson; +use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository; +use Bitrix24\Lib\ContactPersons\UseCase\MarkMobilePhoneAsVerified\Command; +use Bitrix24\Lib\ContactPersons\UseCase\MarkMobilePhoneAsVerified\Handler; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberUtil; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Handler::class)] +class HandlerTest extends TestCase +{ + /** + * @var PhoneNumberUtil + */ + public $phoneNumberUtil; + + private Handler $handler; + + private Flusher $flusher; + + private ContactPersonRepository $repository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new ContactPersonRepository($entityManager); + $this->phoneNumberUtil = PhoneNumberUtil::getInstance(); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Handler( + $this->repository, + $this->phoneNumberUtil, + $this->flusher, + new NullLogger() + ); + } + + #[Test] + public function testConfirmPhoneVerification(): void + { + $phoneNumber = $this->createPhoneNumber('+79991234567'); + $contactPerson = $this->createContactPerson($phoneNumber); + + $this->assertFalse($contactPerson->isMobilePhoneVerified()); + + $this->handler->handle(new Command($contactPerson->getId(), $phoneNumber)); + + $updatedContactPerson = $this->repository->getById($contactPerson->getId()); + $this->assertTrue($updatedContactPerson->isMobilePhoneVerified()); + } + + #[Test] + #[DataProvider('invalidPhoneVerificationProvider')] + public function testConfirmPhoneVerificationFails( + bool $useRealContactId, + string $phoneNumberInCommand, + ?string $expectedExceptionClass = null + ): void { + $realPhoneNumber = $this->createPhoneNumber('+79991234567'); + $contactPerson = $this->createContactPerson($realPhoneNumber); + + $contactId = $useRealContactId ? $contactPerson->getId() : Uuid::v7(); + + if (null !== $expectedExceptionClass) { + $this->expectException($expectedExceptionClass); + } + + $phoneNumber = $this->createPhoneNumber($phoneNumberInCommand); + $this->handler->handle(new Command($contactId, $phoneNumber)); + + if (null === $expectedExceptionClass) { + // Если исключение не ожидалось (например, при несовпадении телефона), проверяем, что статус не изменился + $reloaded = $this->repository->getById($contactPerson->getId()); + $this->assertFalse($reloaded->isMobilePhoneVerified()); + } + } + + public static function invalidPhoneVerificationProvider(): array + { + return [ + 'contact person not found' => [ + 'useRealContactId' => false, + 'phoneNumberInCommand' => '+79991234567', + 'expectedExceptionClass' => ContactPersonNotFoundException::class, + ], + 'phone mismatch' => [ + 'useRealContactId' => true, + 'phoneNumberInCommand' => '+79990000000', + 'expectedExceptionClass' => null, + ], + 'invalid phone format' => [ + 'useRealContactId' => true, + 'phoneNumberInCommand' => '123', + 'expectedExceptionClass' => null, + // Actually Command doesn't validate phone format in this package, it's a PhoneNumber object. + // In Handler.php there's no guard for phone in MarkMobilePhoneAsVerified, it just compares them. + ], + ]; + } + + private function createContactPerson(PhoneNumber $phoneNumber): ContactPerson + { + $contactPerson = (new ContactPersonBuilder()) + ->withEmail('john.doe@example.com') + ->withMobilePhoneNumber($phoneNumber) + ->withComment('Test comment') + ->withExternalId(Uuid::v7()->toRfc4122()) + ->withBitrix24UserId(random_int(1, 1_000_000)) + ->withBitrix24PartnerId(Uuid::v7()) + ->build() + ; + + $this->repository->save($contactPerson); + $this->flusher->flush(); + + return $contactPerson; + } + + private function createPhoneNumber(string $number): PhoneNumber + { + $phoneNumberUtil = PhoneNumberUtil::getInstance(); + + return $phoneNumberUtil->parse($number, 'RU'); + } +} diff --git a/tests/Unit/ApplicationInstallations/UseCase/InstallContactPerson/CommandTest.php b/tests/Unit/ApplicationInstallations/UseCase/InstallContactPerson/CommandTest.php new file mode 100644 index 00000000..5df7c535 --- /dev/null +++ b/tests/Unit/ApplicationInstallations/UseCase/InstallContactPerson/CommandTest.php @@ -0,0 +1,156 @@ +expectException($expectedException); + } + + $command = new Command( + $applicationInstallationId, + $fullName, + $bitrix24UserId, + $userAgentInfo, + $email, + $mobilePhoneNumber, + $comment, + $externalId, + $bitrix24PartnerId + ); + + self::assertSame($applicationInstallationId, $command->applicationInstallationId); + self::assertSame($fullName, $command->fullName); + self::assertSame($bitrix24UserId, $command->bitrix24UserId); + self::assertSame($userAgentInfo, $command->userAgentInfo); + self::assertSame($email, $command->email); + self::assertSame($mobilePhoneNumber, $command->mobilePhoneNumber); + self::assertSame($comment, $command->comment); + self::assertSame($externalId, $command->externalId); + self::assertSame($bitrix24PartnerId, $command->bitrix24PartnerId); + } + + public static function commandDataProvider(): array + { + $fullName = new FullName('John Doe'); + $userAgentInfo = new UserAgentInfo(null); + + return [ + 'valid data' => [ + Uuid::v7(), + $fullName, + 123, + $userAgentInfo, + 'john.doe@example.com', + new PhoneNumber(), + 'Test comment', + 'ext-123', + Uuid::v7(), + ], + 'invalid email: empty' => [ + Uuid::v7(), + $fullName, + 123, + $userAgentInfo, + '', + null, + null, + null, + null, + \InvalidArgumentException::class, + ], + 'invalid email: spaces' => [ + Uuid::v7(), + $fullName, + 123, + $userAgentInfo, + ' ', + null, + null, + null, + null, + \InvalidArgumentException::class, + ], + 'invalid email: format' => [ + Uuid::v7(), + $fullName, + 123, + $userAgentInfo, + 'not-an-email', + null, + null, + null, + null, + \InvalidArgumentException::class, + ], + 'invalid external id: empty string' => [ + Uuid::v7(), + $fullName, + 123, + $userAgentInfo, + null, + null, + null, + ' ', + null, + \InvalidArgumentException::class, + ], + 'invalid user id: zero' => [ + Uuid::v7(), + $fullName, + 0, + $userAgentInfo, + null, + null, + null, + null, + null, + \InvalidArgumentException::class, + ], + 'invalid user id: negative' => [ + Uuid::v7(), + $fullName, + -1, + $userAgentInfo, + null, + null, + null, + null, + null, + \InvalidArgumentException::class, + ], + ]; + } +} diff --git a/tests/Unit/ApplicationInstallations/UseCase/OnAppInstall/CommandTest.php b/tests/Unit/ApplicationInstallations/UseCase/OnAppInstall/CommandTest.php index 42df18c4..ff6bf9d9 100644 --- a/tests/Unit/ApplicationInstallations/UseCase/OnAppInstall/CommandTest.php +++ b/tests/Unit/ApplicationInstallations/UseCase/OnAppInstall/CommandTest.php @@ -32,7 +32,7 @@ public function testValidCommand( string $memberId, Domain $domain, string $applicationToken, - string $applicationStatus, + ApplicationStatus $applicationStatus, ?string $expectedException, ): void { @@ -55,7 +55,7 @@ public function testValidCommand( public static function dataForCommand(): \Generator { $applicationToken = Uuid::v7()->toRfc4122(); - $applicationStatus = 'T'; + $applicationStatus = new ApplicationStatus('T'); (new ApplicationInstallationBuilder()) ->withApplicationStatus(new ApplicationStatus('F')) @@ -97,14 +97,5 @@ public static function dataForCommand(): \Generator $applicationStatus, InvalidArgumentException::class, ]; - - // Empty applicationStatus - yield 'emptyApplicationStatus' => [ - $bitrix24AccountBuilder->getMemberId(), - new Domain($bitrix24AccountBuilder->getDomainUrl()), - $applicationToken, - '', - InvalidArgumentException::class, - ]; } -} \ No newline at end of file +} diff --git a/tests/Unit/ContactPersons/Entity/ContactPersonTest.php b/tests/Unit/ContactPersons/Entity/ContactPersonTest.php new file mode 100644 index 00000000..d1a040f3 --- /dev/null +++ b/tests/Unit/ContactPersons/Entity/ContactPersonTest.php @@ -0,0 +1,64 @@ +expectException($expectedException); + } + + $command = new Command( + $uuid, + $fullName, + $email, + $mobilePhoneNumber + ); + + self::assertEquals($uuid, $command->contactPersonId); + self::assertEquals($fullName, $command->fullName); + self::assertSame($email, $command->email); + self::assertEquals($mobilePhoneNumber, $command->mobilePhoneNumber); + } + + public static function commandDataProvider(): array + { + $fullName = new FullName('John Doe'); + + return [ + 'valid data' => [ + Uuid::v7(), + $fullName, + 'john.doe@example.com', + new PhoneNumber(), + ], + 'empty email is valid' => [ + Uuid::v7(), + $fullName, + '', + new PhoneNumber(), + ], + 'invalid email format' => [ + Uuid::v7(), + $fullName, + 'not-an-email', + new PhoneNumber(), + InvalidArgumentException::class, + ], + ]; + } +} diff --git a/tests/Unit/ContactPersons/UseCase/MarkEmailAsVerified/CommandTest.php b/tests/Unit/ContactPersons/UseCase/MarkEmailAsVerified/CommandTest.php new file mode 100644 index 00000000..61c9c2b1 --- /dev/null +++ b/tests/Unit/ContactPersons/UseCase/MarkEmailAsVerified/CommandTest.php @@ -0,0 +1,77 @@ +expectException($expectedException); + } + + $command = new Command( + $uuid, + $email, + $emailVerifiedAt + ); + + self::assertEquals($uuid, $command->contactPersonId); + self::assertSame($email, $command->email); + self::assertEquals($emailVerifiedAt, $command->emailVerifiedAt); + } + + public static function commandDataProvider(): array + { + return [ + 'valid data' => [ + Uuid::v7(), + 'john.doe@example.com', + new CarbonImmutable(), + ], + 'valid data without date' => [ + Uuid::v7(), + 'john.doe@example.com', + null, + ], + 'invalid email: empty' => [ + Uuid::v7(), + '', + null, + \InvalidArgumentException::class, + ], + 'invalid email: spaces' => [ + Uuid::v7(), + ' ', + null, + \InvalidArgumentException::class, + ], + 'invalid email: format' => [ + Uuid::v7(), + 'not-an-email', + null, + \InvalidArgumentException::class, + ], + ]; + } +}