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 f628913a..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.3" - 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: "make lint-allowed-licenses" + 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 6d2fd84f..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.3" - 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 9cf8ab24..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.3" - 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 ec35b524..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.3" - 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 b17a851c..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,18 +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" - dependencies: [ highest ] - operating-system: [ ubuntu-latest ] services: - bitrix24-php-lib-test-database: + postgres: image: postgres:16-alpine ports: - 5432:5432 @@ -41,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: | @@ -56,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 @@ -72,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 c714207b..b45b58f9 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -10,34 +10,26 @@ env: jobs: tests: name: "PHPUnit tests" - - runs-on: ${{ matrix.operating-system }} - - strategy: - fail-fast: false - matrix: - php-version: - - "8.3" - 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() }} @@ -47,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 f026b3d5..8a15d611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,200 @@ +## 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 + +- **ApplicationSettings bounded context** for application configuration management — [#67](https://github.com/mesilov/bitrix24-php-lib/issues/67) + - Full CRUD functionality with CQRS pattern (Create, Update, Delete use cases) + - Multi-scope support: Global, Departmental, and Personal settings with cascading resolution + - **SettingsFetcher service** with automatic deserialization support + - Cascading resolution logic (Personal → Departmental → Global) + - JSON deserialization to objects using Symfony Serializer + - Comprehensive logging with LoggerInterface + - **DefaultSettingsInstaller service** for bulk creation of default settings + - Soft-delete support with `ApplicationSettingStatus` enum (Active/Deleted) + - Event system with `ApplicationSettingsItemChangedEvent` for change tracking + - CLI command `app:settings:list` for viewing settings with scope filtering + - InMemory repository implementation for fast unit testing + - Unique constraint on (installation_id, key, user_id, department_id) + - Tracking fields: `changedByBitrix24UserId`, `isRequired` +- Database schema updates + - Table `application_settings` with UUID v7 IDs + - Scope fields: `b24_user_id`, `b24_department_id` + - Status field with index for query optimization + - Timestamp tracking: `created_at_utc`, `updated_at_utc` +- Comprehensive test coverage + - Unit tests for entity validation and business logic + - Functional tests for repository operations and use case handlers + - Tests for all scope types and soft-delete behavior + +### Changed + +- **Refactored ApplicationSettings entity naming** + - Renamed `ApplicationSetting` → `ApplicationSettingsItem` + - Renamed all interfaces and events accordingly + - Updated table name from `application_setting` → `application_settings` +- **Renamed service class for clarity** — [#67](https://github.com/mesilov/bitrix24-php-lib/issues/67) + - Renamed `InstallSettings` → `DefaultSettingsInstaller` for better semantic clarity + - Updated all references in documentation and tests + - Updated log message prefixes to use new class name +- **Separated Create/Update use cases** + - Create UseCase now only creates new settings (throws exception if exists) + - Update UseCase for modifying existing settings (throws exception if not found) + - Update automatically emits `ApplicationSettingsItemChangedEvent` +- **Simplified repository API** + - Removed 6 redundant methods, kept only `findAllForInstallation()` + - Renamed `findAll()` → `findAllForInstallationByKey()` to avoid conflicts + - All find methods now filter by `status=Active` by default + - Added optimized `findAllForInstallationByKey()` method +- **Enhanced SettingsFetcher** + - Renamed `getSetting()` → `getItem()` + - Renamed `getSettingValue()` → `getValue()` + - Added automatic deserialization with type-safe generics + - Non-nullable return types with exception throwing +- **ApplicationSettingsItem improvements** + - UUID v7 generation moved inside entity constructor + - Key validation: only lowercase latin letters and dots + - Scope methods: `isGlobal()`, `isPersonal()`, `isDepartmental()` + - `updateValue()` method emits change events +- **Makefile improvements** + - Updated to use Docker for `composer-license-checker` + - Aligns with other linting and analysis workflows +- **Code quality improvements** + - Applied Rector automatic refactoring (arrow functions, type hints, naming) + - Added `#[\Override]` attributes to overridden methods + - Applied PHP-CS-Fixer formatting consistently + - Added symfony/property-access dependency for ObjectNormalizer +- **Documentation improvements** + - Translated ApplicationSettings documentation to English + - Updated all code examples to reflect current codebase + - Updated exception references to use SDK standard exceptions + - Improved best practices and security sections +- **Test infrastructure improvements** + - Created contract tests for ApplicationSettingsItemRepositoryInterface + - Moved ApplicationSettingsItemInMemoryRepository from src to tests/Helpers + - Added contract test implementations for both InMemory and Doctrine repositories + - Refactored existing repository tests to focus on implementation-specific behavior + +### Fixed + +- **PHPStan level 5 errors related to SDK interface compatibility** — [#67](https://github.com/mesilov/bitrix24-php-lib/issues/67) + - Removed invalid `#[\Override]` attributes from extension methods in `ApplicationInstallationRepository` + - Fixed `findByMemberId()` call with incorrect parameter count in `OnAppInstall\Handler` + - Added `@phpstan-ignore-next-line` comments for methods not yet available in SDK interface + - Added TODO comments to track SDK interface extension requirements +- **Doctrine XML mapping** + - Fixed `enumType` → `enum-type` syntax for Doctrine ORM 3 compatibility +- **Repository method naming conflicts** + - Renamed methods to avoid conflicts with EntityRepository base class +- **Exception handling standardization** — [#67](https://github.com/mesilov/bitrix24-php-lib/issues/67) + - Replaced custom exceptions with SDK standard exceptions for consistency + - Removed `SettingsItemAlreadyExistsException` → using `Bitrix24\SDK\Core\Exceptions\InvalidArgumentException` + - Removed `SettingsItemNotFoundException` → using `Bitrix24\SDK\Core\Exceptions\ItemNotFoundException` + - 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 + +- **Get UseCase** - replaced with `SettingsFetcher` service (UseCases now only for data modification) +- **Redundant repository methods** + - `findGlobalByKey()`, `findPersonalByKey()`, `findDepartmentalByKey()` + - `findAllGlobal()`, `findAllPersonal()`, `findAllDepartmental()` + - `deleteByApplicationInstallationId()` + - `softDeleteByApplicationInstallationId()` +- **Hard delete from Delete UseCase** - replaced with soft-delete pattern +- **Entity getStatus() method** - use `isActive()` instead for better encapsulation +- **Static getRecommendedDefaults()** - developers should define their own defaults +- **Custom exception classes** — [#67](https://github.com/mesilov/bitrix24-php-lib/issues/67) + - `ApplicationSettings\Services\Exception\SettingsItemNotFoundException` + - `ApplicationSettings\UseCase\Create\Exception\SettingsItemAlreadyExistsException` + +## 0.2.0 + +### Changed + +Updated application contracts +fix minor errors + ## 0.1.1 + ### Added + - Change php version requirements — [#44](https://github.com/mesilov/bitrix24-php-lib/pull/44) ## 0.1.0 ### By [@mesilov](https://github.com/mesilov) + - Add initial project setup with CI configuration — [#2](https://github.com/mesilov/bitrix24-php-lib/pull/2) - Fix incorrect annotation syntax from `#[\Override]` to `#[Override]` — [#3](https://github.com/mesilov/bitrix24-php-lib/pull/3) - Rename package and namespaces to `bitrix24-php-lib` — [#4](https://github.com/mesilov/bitrix24-php-lib/pull/4) @@ -14,8 +204,10 @@ --- ### By [@KarlsonComplete](https://github.com/KarlsonComplete) + - Add docker containers — [#12](https://github.com/mesilov/bitrix24-php-lib/pull/12) -- Add docker structure — [#14](https://github.com/mesilov/bitrix24-php-lib/pull/14), [#15](https://github.com/mesilov/bitrix24-php-lib/pull/15), [#16](https://github.com/mesilov/bitrix24-php-lib/pull/16), [#17](https://github.com/mesilov/bitrix24-php-lib/pull/17), [#19](https://github.com/mesilov/bitrix24-php-lib/pull/19), [#27](https://github.com/mesilov/bitrix24-php-lib/pull/27), [#29](https://github.com/mesilov/bitrix24-php-lib/pull/29), [#32](https://github.com/mesilov/bitrix24-php-lib/pull/32), [#34](https://github.com/mesilov/bitrix24-php-lib/pull/34), [#36](https://github.com/mesilov/bitrix24-php-lib/pull/36), [#37](https://github.com/mesilov/bitrix24-php-lib/pull/37), [#38](https://github.com/mesilov/bitrix24-php-lib/pull/38) +- Add docker + structure — [#14](https://github.com/mesilov/bitrix24-php-lib/pull/14), [#15](https://github.com/mesilov/bitrix24-php-lib/pull/15), [#16](https://github.com/mesilov/bitrix24-php-lib/pull/16), [#17](https://github.com/mesilov/bitrix24-php-lib/pull/17), [#19](https://github.com/mesilov/bitrix24-php-lib/pull/19), [#27](https://github.com/mesilov/bitrix24-php-lib/pull/27), [#29](https://github.com/mesilov/bitrix24-php-lib/pull/29), [#32](https://github.com/mesilov/bitrix24-php-lib/pull/32), [#34](https://github.com/mesilov/bitrix24-php-lib/pull/34), [#36](https://github.com/mesilov/bitrix24-php-lib/pull/36), [#37](https://github.com/mesilov/bitrix24-php-lib/pull/37), [#38](https://github.com/mesilov/bitrix24-php-lib/pull/38) - Added mapping, fixing functional tests — [#18](https://github.com/mesilov/bitrix24-php-lib/pull/18) - Removed attributes in the account — [#20](https://github.com/mesilov/bitrix24-php-lib/pull/20) - Fixed some errors in functional tests — [#21](https://github.com/mesilov/bitrix24-php-lib/pull/21) diff --git a/CLAUDE.md b/CLAUDE.md index d78b8143..46add905 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,11 +93,17 @@ src/ 3. Follow DDD principles 4. Use CQRS for write operations 5. Validate all inputs in command constructors +6. **After each refactoring task, automatically run linters and tests:** + - Run all linters: `make lint-phpstan && make lint-cs-fixer && make lint-rector` + - Run unit tests: `make test-run-unit` + - Run functional tests: `make test-run-functional` + - Fix any errors before proceeding to the next task +7. After refactoring, summarize changes in `changelog.md` +8. Check and actualize documentation in related files and README ## Git Workflow - Main branch: `main` - Feature branches: `feature/issue-number-description` -- Current branch: `feature/46-fix-errors` ## Docker Setup - PHP CLI container for development @@ -121,4 +127,5 @@ The `.env` file contains default values that work out-of-the-box with Docker Com - `DATABASE_NAME=b24phpLibTest` - `POSTGRES_VERSION=16` -These defaults allow running functional tests immediately after `make up` without additional configuration. \ No newline at end of file +These defaults allow running functional tests immediately after `make up` without additional configuration. +- Always update changelog.md \ No newline at end of file diff --git a/Makefile b/Makefile index f09443d3..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: - 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/README.md b/README.md index 12fc2ca4..ff5444bd 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,15 @@ PHP lib for Bitrix24 application development ## Build status -| CI\CD [status](https://github.com/mesilov/bitrix24-php-lib/actions) on `master` | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [![allowed licenses check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/license-check.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/license-check.yml) | -| [![php-cs-fixer check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-cs-fixer.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-cs-fixer.yml) | -| [![phpstan check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-phpstan.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-phpstan.yml) | -| [![rector check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-rector.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-rector.yml) | -| [![unit-tests status](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/tests-unit.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/tests-unit.yml) | +| CI\CD [status](https://github.com/mesilov/bitrix24-php-lib/actions) on `master` | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [![allowed licenses check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/license-check.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/license-check.yml) | +| [![php-cs-fixer check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-cs-fixer.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-cs-fixer.yml) | +| [![phpstan check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-phpstan.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-phpstan.yml) | +| [![rector check](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-rector.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/lint-rector.yml) | +| [![unit-tests status](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/tests-unit.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/tests-unit.yml) | | [![functional-tests status](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/tests-functional.yml/badge.svg)](https://github.com/mesilov/bitrix24-php-lib/actions/workflows/tests-functional.yml) | - ## Application Domain The library is designed for rapid development of Bitrix24 applications. Provides data storage layer in @@ -45,11 +44,19 @@ who performed application installation ### Bitrix24Partners — ⏳ work in progress Responsible for -storing [Bitrix24 partners](https://github.com/bitrix24/b24phpsdk/tree/main/src/Application/Contracts/Bitrix24Partners) who performed installation or service the portal +storing [Bitrix24 partners](https://github.com/bitrix24/b24phpsdk/tree/main/src/Application/Contracts/Bitrix24Partners) who performed installation or service +the portal + +### ApplicationSettings — ✅ + +Responsible for +storing [application settings](https://github.com/bitrix24/b24phpsdk/tree/main/src/Application/Contracts/ApplicationSettings) +for specific Bitrix24 portal ## Architecture ### Layers and Abstraction Levels + ``` bitrix24-app-laravel-skeleton – Laravel application template bitrix24-app-symfony-skeleton – Symfony application template @@ -58,6 +65,7 @@ bitrix24-php-sdk – transport layer + transport events (expired token, portal r ``` ### Bounded Context Folder Structure + ``` src/ Bitrix24Accounts @@ -77,14 +85,15 @@ src/ Tests ``` - ## Quick Start ### Prerequisites + - Docker and Docker Compose - Make ### Running Tests + ```bash # Initialize and start services make up @@ -99,7 +108,9 @@ make lint-rector ``` ### Database Configuration + Default database credentials are pre-configured in `.env`: + - Host: `database` (Docker service) - Database: `b24phpLibTest` - User: `b24phpLibTest` @@ -108,10 +119,11 @@ Default database credentials are pre-configured in `.env`: No additional configuration needed for running tests. ## Infrastructure -- library is made cloud-agnostic +- library is made cloud-agnostic ## Development Rules + 1. We use linters 2. Library is covered with tests 3. All work is organized through issues diff --git a/composer.json b/composer.json index 3ac2b55d..ce71e702 100644 --- a/composer.json +++ b/composer.json @@ -34,47 +34,47 @@ } }, "require": { - "php": "^8.3", - "ext-json": "*", - "ext-curl": "*", + "php": "8.3.* || 8.4.*", "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", - "symfony/twig-bundle": "^7", - "symfony/http-foundation": "^7", - "symfony/routing": "^7", - "twig/twig": "^3" + "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": { - "lendable/composer-license-checker": "^1.2", + "doctrine/migrations": "^3", + "fakerphp/faker": "^1", "friendsofphp/php-cs-fixer": "^3.64", + "lendable/composer-license-checker": "^1.2", "monolog/monolog": "^3", - "fakerphp/faker": "^1", "phpstan/phpstan": "^1", "phpunit/phpunit": "^11", - "doctrine/migrations": "^3", "psalm/phar": "^5", "rector/rector": "^1", "roave/security-advisories": "dev-master", "symfony/debug-bundle": "^7", - "symfony/stopwatch": "^7" + "symfony/property-access": "^7", + "symfony/stopwatch": "^7", + "symfony/var-exporter": "^7" }, "autoload": { "psr-4": { diff --git a/config/xml/Bitrix24.Lib.ApplicationSettings.Entity.ApplicationSettingsItem.dcm.xml b/config/xml/Bitrix24.Lib.ApplicationSettings.Entity.ApplicationSettingsItem.dcm.xml new file mode 100644 index 00000000..4ed0c3d4 --- /dev/null +++ b/config/xml/Bitrix24.Lib.ApplicationSettings.Entity.ApplicationSettingsItem.dcm.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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.Lib.Journal.Entity.JournalItem.dcm.xml b/config/xml/Bitrix24.Lib.Journal.Entity.JournalItem.dcm.xml index fd2ac067..99fb5c34 100644 --- a/config/xml/Bitrix24.Lib.Journal.Entity.JournalItem.dcm.xml +++ b/config/xml/Bitrix24.Lib.Journal.Entity.JournalItem.dcm.xml @@ -6,6 +6,8 @@ + + @@ -13,12 +15,17 @@ + + + + - + - + + diff --git a/config/xml/Bitrix24.Lib.Journal.ValueObjects.JournalContext.dcm.xml b/config/xml/Bitrix24.Lib.Journal.Entity.ValueObjects.Context.dcm.xml similarity index 67% rename from config/xml/Bitrix24.Lib.Journal.ValueObjects.JournalContext.dcm.xml rename to config/xml/Bitrix24.Lib.Journal.Entity.ValueObjects.Context.dcm.xml index 97fdee38..fff5a506 100644 --- a/config/xml/Bitrix24.Lib.Journal.ValueObjects.JournalContext.dcm.xml +++ b/config/xml/Bitrix24.Lib.Journal.Entity.ValueObjects.Context.dcm.xml @@ -1,13 +1,11 @@ - - - + - + 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/config/xml/Bitrix24.SDK.Core.Credentials.AuthToken.dcm.xml b/config/xml/Bitrix24.SDK.Core.Credentials.AuthToken.dcm.xml index b229a888..6650964b 100644 --- a/config/xml/Bitrix24.SDK.Core.Credentials.AuthToken.dcm.xml +++ b/config/xml/Bitrix24.SDK.Core.Credentials.AuthToken.dcm.xml @@ -4,7 +4,11 @@ - + + + + + \ 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..c957618a 100644 --- a/rector.php +++ b/rector.php @@ -14,7 +14,8 @@ use Rector\Config\RectorConfig; use Rector\Naming\Rector\Class_\RenamePropertyToMatchTypeRector; use Rector\PHPUnit\Set\PHPUnitSetList; -use Rector\Set\ValueObject\DowngradeLevelSetList; +use Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector; +use Rector\Naming\Rector\ClassMethod\RenameParamToMatchTypeRector; return RectorConfig::configure() ->withPaths([ @@ -48,5 +49,7 @@ strictBooleans: true ) ->withSkip([ - RenamePropertyToMatchTypeRector::class + RenamePropertyToMatchTypeRector::class, + RenameParamToMatchTypeRector::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 dca76b02..28717da4 100644 --- a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php +++ b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php @@ -102,9 +102,37 @@ 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. * + * TODO: Create issue in b24-php-sdk to add this method to ApplicationInstallationRepositoryInterface + * * @param non-empty-string $applicationToken * * @throws InvalidArgumentException diff --git a/src/ApplicationInstallations/UseCase/Install/Command.php b/src/ApplicationInstallations/UseCase/Install/Command.php index 224f9bae..fee1e5cd 100644 --- a/src/ApplicationInstallations/UseCase/Install/Command.php +++ b/src/ApplicationInstallations/UseCase/Install/Command.php @@ -4,7 +4,7 @@ namespace Bitrix24\Lib\ApplicationInstallations\UseCase\Install; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\SDK\Application\ApplicationStatus; use Bitrix24\SDK\Application\PortalLicenseFamily; use Bitrix24\SDK\Core\Credentials\AuthToken; diff --git a/src/ApplicationInstallations/UseCase/Install/Handler.php b/src/ApplicationInstallations/UseCase/Install/Handler.php index 3c53d809..3025bc4c 100644 --- a/src/ApplicationInstallations/UseCase/Install/Handler.php +++ b/src/ApplicationInstallations/UseCase/Install/Handler.php @@ -44,6 +44,7 @@ public function handle(Command $command): void ]); /** @var null|AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $activeInstallation */ + // todo fix https://github.com/mesilov/bitrix24-php-lib/issues/59 $activeInstallation = $this->applicationInstallationRepository->findByBitrix24AccountMemberId($command->memberId); if (null !== $activeInstallation) { 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 5ce5b06b..cc302dab 100644 --- a/src/ApplicationInstallations/UseCase/OnAppInstall/Command.php +++ b/src/ApplicationInstallations/UseCase/OnAppInstall/Command.php @@ -4,7 +4,8 @@ namespace Bitrix24\Lib\ApplicationInstallations\UseCase\OnAppInstall; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\SDK\Application\ApplicationStatus; +use Bitrix24\Lib\Common\ValueObjects\Domain; /** * Command is called when installation occurs through UI. @@ -17,7 +18,7 @@ public function __construct( public string $memberId, public Domain $domainUrl, public string $applicationToken, - public string $applicationStatus, + public ApplicationStatus $applicationStatus, ) { $this->validate(); } @@ -31,9 +32,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 55121e88..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 { @@ -39,11 +39,16 @@ public function handle(Command $command): void ]); /** @var null|AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $applicationInstallation */ + // 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); @@ -67,18 +72,23 @@ private function findMasterAccountByMemberId(string $memberId): Bitrix24AccountI $memberId, Bitrix24AccountStatus::active, null, - null, - true + null + ); + + // Filter for master accounts only + $masterAccounts = array_filter( + $bitrix24Accounts, + fn (Bitrix24AccountInterface $bitrix24Account): bool => $bitrix24Account->isMasterAccount() ); - if ([] === $bitrix24Accounts) { + if ([] === $masterAccounts) { throw new Bitrix24AccountNotFoundException('Bitrix24 account not found for member ID '.$memberId); } - if (1 !== count($bitrix24Accounts)) { + if (1 !== count($masterAccounts)) { throw new MultipleBitrix24AccountsFoundException('Multiple Bitrix24 accounts found for member ID '.$memberId); } - return reset($bitrix24Accounts); + return reset($masterAccounts); } } diff --git a/src/ApplicationInstallations/UseCase/Uninstall/Command.php b/src/ApplicationInstallations/UseCase/Uninstall/Command.php index 84debaaf..5c1d933b 100644 --- a/src/ApplicationInstallations/UseCase/Uninstall/Command.php +++ b/src/ApplicationInstallations/UseCase/Uninstall/Command.php @@ -4,7 +4,8 @@ namespace Bitrix24\Lib\ApplicationInstallations\UseCase\Uninstall; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; + readonly class Command { @@ -16,6 +17,7 @@ public function __construct( $this->validate(); } + private function validate(): void { if ('' === $this->applicationToken) { diff --git a/src/ApplicationInstallations/UseCase/Uninstall/Handler.php b/src/ApplicationInstallations/UseCase/Uninstall/Handler.php index 7e9722cd..110c7abd 100644 --- a/src/ApplicationInstallations/UseCase/Uninstall/Handler.php +++ b/src/ApplicationInstallations/UseCase/Uninstall/Handler.php @@ -43,6 +43,7 @@ public function handle(Command $command): void ]); /** @var AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $activeInstallation */ + // todo fix https://github.com/mesilov/bitrix24-php-lib/issues/60 $activeInstallation = $this->applicationInstallationRepository->findByApplicationToken($command->applicationToken); if (null !== $activeInstallation) { 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/ApplicationSettings/Docs/application-settings.md b/src/ApplicationSettings/Docs/application-settings.md new file mode 100644 index 00000000..2259a649 --- /dev/null +++ b/src/ApplicationSettings/Docs/application-settings.md @@ -0,0 +1,728 @@ +# ApplicationSettings - Application Configuration Management + +## Overview + +ApplicationSettings is a bounded context designed for storing and managing Bitrix24 application settings using Domain-Driven Design and CQRS patterns. + +## Core Concepts + +### 1. Bounded Context + +ApplicationSettings is a separate bounded context that encapsulates all application settings management logic. + +### 2. Setting Scopes + +The system supports three levels of settings: + +#### Global Settings +Applied to the entire application installation, available to all users. + +```php +use Bitrix24\Lib\ApplicationSettings\UseCase\Create\Command as CreateCommand; +use Bitrix24\Lib\ApplicationSettings\UseCase\Create\Handler as CreateHandler; +use Symfony\Component\Uid\Uuid; + +// Create global setting +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'app.language', + value: 'en', + isRequired: true // Required setting +); + +$handler->handle($command); +``` + +#### Personal Settings +Tied to a specific Bitrix24 user. + +```php +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'user.theme', + value: 'dark', + isRequired: false, + b24UserId: 123 // User ID +); + +$handler->handle($command); +``` + +#### Departmental Settings +Tied to a specific department. + +```php +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'department.workingHours', + value: '9:00-18:00', + isRequired: false, + b24DepartmentId: 456 // Department ID +); + +$handler->handle($command); +``` + +### 3. Setting Status + +Each setting has a status (enum `ApplicationSettingStatus`): + +- **Active** - active setting, available for use +- **Deleted** - soft-deleted setting + +### 4. Soft Delete + +The system uses the soft-delete pattern: +- Settings are not physically deleted from the database +- When deleted, status changes to `Deleted` +- This allows preserving history and restoring data if needed + +### 5. Invariants (Constraints) + +**Key Uniqueness:** The combination of `applicationInstallationId + key + b24UserId + b24DepartmentId` must be unique. + +This means: +- ✅ You can have a global setting `app.theme` +- ✅ You can have a personal setting `app.theme` for user 123 +- ✅ You can have a personal setting `app.theme` for user 456 +- ✅ You can have a departmental setting `app.theme` for department 789 +- ❌ You cannot create two global settings with key `app.theme` for one installation +- ❌ You cannot create two personal settings with key `app.theme` for one user + +This constraint is enforced: +- At the database level through UNIQUE INDEX +- At the application level through validation in UseCase\Create\Handler and UseCase\Update\Handler + +## Data Structure + +### ApplicationSettingsItem Entity Fields + +```php +class ApplicationSettingsItem +{ + private Uuid $id; // UUID v7 + private Uuid $applicationInstallationId; // Link to installation + private string $key; // Key (only a-z and dots) + private string $value; // Value (any string, JSON) + private bool $isRequired; // Is setting required + private ?int $b24UserId; // User ID (for personal) + private ?int $b24DepartmentId; // Department ID (for departmental) + private ?int $changedByBitrix24UserId; // Who last modified + private ApplicationSettingStatus $status; // Status (active/deleted) + private CarbonImmutable $createdAt; // Creation date + private CarbonImmutable $updatedAt; // Update date +} +``` + +### Database Table + +Table: `application_settings` + +### Key Validation Rules + +- Only lowercase latin letters (a-z) and dots +- Maximum length 255 characters +- Recommended format: `category.subcategory.name` + +Valid key examples: +```php +'app.version' +'user.interface.theme' +'notification.email.enabled' +'integration.api.timeout' +``` + +## Use Cases (Commands) + +### Create - Creating New Setting + +Creates a new setting. If a setting with the same key and scope already exists, throws an exception. + +```php +use Bitrix24\Lib\ApplicationSettings\UseCase\Create\Command; +use Bitrix24\Lib\ApplicationSettings\UseCase\Create\Handler; + +$command = new Command( + applicationInstallationId: $installationId, + key: 'feature.analytics', + value: 'enabled', + isRequired: true, + b24UserId: null, + b24DepartmentId: null, + changedByBitrix24UserId: 100 // Who creates the setting +); + +$handler->handle($command); +``` + +**Important:** Create will throw `SettingsItemAlreadyExistsException` if the setting already exists for the given scope. + +### Update - Updating Existing Setting + +Updates the value of an existing setting. If the setting is not found, throws an exception. + +```php +use Bitrix24\Lib\ApplicationSettings\UseCase\Update\Command; +use Bitrix24\Lib\ApplicationSettings\UseCase\Update\Handler; + +$command = new Command( + applicationInstallationId: $installationId, + key: 'feature.analytics', + value: 'disabled', + b24UserId: null, + b24DepartmentId: null, + changedByBitrix24UserId: 100 // Who makes the change +); + +$handler->handle($command); +``` + +**Important:** Update automatically emits `ApplicationSettingsItemChangedEvent` when the value changes. + +### Delete - Soft Delete Setting + +```php +use Bitrix24\Lib\ApplicationSettings\UseCase\Delete\Command; +use Bitrix24\Lib\ApplicationSettings\UseCase\Delete\Handler; + +$command = new Command( + applicationInstallationId: $installationId, + key: 'deprecated.setting', + b24UserId: null, // Optional + b24DepartmentId: null // Optional +); + +$handler->handle($command); +// Setting is marked as deleted, but remains in DB +``` + +### OnApplicationDelete - Delete All Settings on Uninstall + +```php +use Bitrix24\Lib\ApplicationSettings\UseCase\OnApplicationDelete\Command; +use Bitrix24\Lib\ApplicationSettings\UseCase\OnApplicationDelete\Handler; + +// When application is uninstalled +$command = new Command( + applicationInstallationId: $installationId +); + +$handler->handle($command); +// All settings marked as deleted +``` + +## Working with Repository + +### Finding Settings + +```php +use Bitrix24\Lib\ApplicationSettings\Infrastructure\Doctrine\ApplicationSettingsItemRepository; + +/** @var ApplicationSettingsItemRepository $repository */ + +// Get all active settings for installation +$allSettings = $repository->findAllForInstallation($installationId); + +// Find global setting by key +$globalSetting = null; +foreach ($allSettings as $s) { + if ($s->getKey() === 'app.version' && $s->isGlobal()) { + $globalSetting = $s; + break; + } +} + +// Find user's personal setting +$personalSetting = null; +foreach ($allSettings as $s) { + if ($s->getKey() === 'user.theme' && $s->isPersonal() && $s->getB24UserId() === $userId) { + $personalSetting = $s; + break; + } +} + +// Filter all global settings +$globalSettings = array_filter( + $allSettings, + fn($s): bool => $s->isGlobal() +); + +// Filter user's personal settings +$personalSettings = array_filter( + $allSettings, + fn($s): bool => $s->isPersonal() && $s->getB24UserId() === $userId +); + +// Filter department settings +$deptSettings = array_filter( + $allSettings, + fn($s): bool => $s->isDepartmental() && $s->getB24DepartmentId() === $deptId +); +``` + +**Important:** All find* methods return only settings with `Active` status. Deleted settings are not returned. + +## SettingsFetcher Service + +Utility for retrieving settings with cascading resolution (Personal → Departmental → Global) and automatic deserialization to objects. + +### Key Features + +1. **Cascading resolution**: Personal → Departmental → Global +2. **Automatic deserialization** of JSON to objects via Symfony Serializer +3. **Logging** of all operations for debugging + +### Getting String Value + +```php +use Bitrix24\Lib\ApplicationSettings\Services\SettingsFetcher; + +/** @var SettingsFetcher $fetcher */ + +// Get value with priority resolution +try { + $value = $fetcher->getValue( + uuid: $installationId, + key: 'app.theme', + userId: 123, // Optional + departmentId: 456 // Optional + ); + // Returns personal setting if exists + // Otherwise departmental if exists + // Otherwise global +} catch (SettingsItemNotFoundException $e) { + // Setting not found at any level +} +``` + +### Deserialization to Object + +The `getValue` method supports automatic JSON deserialization to objects: + +```php +// Define DTO class +class ApiConfig +{ + public function __construct( + public string $endpoint, + public int $timeout, + public int $maxRetries + ) {} +} + +// Deserialize setting to object +try { + $config = $fetcher->getValue( + uuid: $installationId, + key: 'api.config', + class: ApiConfig::class // Specify class for deserialization + ); + + // $config is now an instance of ApiConfig + echo $config->endpoint; // https://api.example.com + echo $config->timeout; // 30 +} catch (SettingsItemNotFoundException $e) { + // Setting not found +} +``` + +### Getting Full Setting Object + +If you need access to metadata (id, createdAt, updatedAt, scope, etc.): + +```php +$item = $fetcher->getItem( + uuid: $installationId, + key: 'app.theme', + userId: 123, + departmentId: 456 +); + +// Access metadata +$settingId = $item->getId(); +$createdAt = $item->getCreatedAt(); +$isPersonal = $item->isPersonal(); +$value = $item->getValue(); +``` + +## Events + +### ApplicationSettingsItemChangedEvent + +Emitted when a setting value changes (via Update use case or updateValue() method on entity): + +```php +class ApplicationSettingsItemChangedEvent +{ + public Uuid $settingId; + public string $key; + public string $oldValue; + public string $newValue; + public ?int $changedByBitrix24UserId; + public CarbonImmutable $changedAt; +} +``` + +Events can be captured for logging, auditing, or triggering other actions: + +```php +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class SettingChangeLogger implements EventSubscriberInterface +{ + public function onSettingChanged(ApplicationSettingsItemChangedEvent $event): void + { + $this->logger->info('Setting changed', [ + 'key' => $event->key, + 'old' => $event->oldValue, + 'new' => $event->newValue, + 'changedBy' => $event->changedByBitrix24UserId, + ]); + } +} +``` + +## DefaultSettingsInstaller Service + +Utility for creating a set of default settings during application installation: + +```php +use Bitrix24\Lib\ApplicationSettings\Services\DefaultSettingsInstaller; + +// Create all settings for new installation +$installer = new DefaultSettingsInstaller( + $createHandler, + $logger +); + +$installer->createDefaultSettings( + uuid: $installationId, + defaultSettings: [ + 'app.name' => ['value' => 'My App', 'required' => true], + 'app.language' => ['value' => 'en', 'required' => true], + 'features.notifications' => ['value' => 'true', 'required' => false], + ] +); +``` + +**Important:** DefaultSettingsInstaller uses Create use case, so if a setting already exists, an exception will be thrown. + +## CLI Commands + +### Viewing Settings + +```bash +# All installation settings +php bin/console app:settings:list + +# Only global +php bin/console app:settings:list --global-only + +# User's personal +php bin/console app:settings:list --user-id=123 + +# Departmental +php bin/console app:settings:list --department-id=456 +``` + +## Usage Examples + +### Example 1: Creating and Updating Setting + +```php +use Bitrix24\Lib\ApplicationSettings\UseCase\Create\Command as CreateCommand; +use Bitrix24\Lib\ApplicationSettings\UseCase\Update\Command as UpdateCommand; + +// Create new setting +$createCmd = new CreateCommand( + applicationInstallationId: $installationId, + key: 'integration.api.config', + value: json_encode([ + 'endpoint' => 'https://api.example.com', + 'timeout' => 30, + ]), + isRequired: true +); +$createHandler->handle($createCmd); + +// Update existing setting +$updateCmd = new UpdateCommand( + applicationInstallationId: $installationId, + key: 'integration.api.config', + value: json_encode([ + 'endpoint' => 'https://api.example.com', + 'timeout' => 60, // Changed timeout + 'retries' => 3, // Added retries + ]), + changedByBitrix24UserId: 100 +); +$updateHandler->handle($updateCmd); +``` + +### Example 2: Storing and Deserializing JSON Configuration + +```php +// Create setting with JSON value +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'integration.api.config', + value: json_encode([ + 'endpoint' => 'https://api.example.com', + 'timeout' => 30, + 'retries' => 3, + ]), + isRequired: true +); +$handler->handle($command); + +// Read as string +$value = $fetcher->getValue($installationId, 'integration.api.config'); +$config = json_decode($value, true); + +// OR automatic deserialization to object +class ApiConfig +{ + public function __construct( + public string $endpoint, + public int $timeout, + public int $retries + ) {} +} + +$config = $fetcher->getValue( + uuid: $installationId, + key: 'integration.api.config', + class: ApiConfig::class +); + +// Use typed object +echo $config->endpoint; // https://api.example.com +echo $config->timeout; // 30 +``` + +### Example 3: UI Personalization + +```php +// Save user preferences +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'ui.preferences', + value: json_encode([ + 'theme' => 'dark', + 'language' => 'en', + 'dashboard_layout' => 'compact', + ]), + isRequired: false, + b24UserId: $currentUserId, + changedByBitrix24UserId: $currentUserId +); +$handler->handle($command); + +// Get preferences with personal settings priority +try { + $value = $fetcher->getValue( + uuid: $installationId, + key: 'ui.preferences', + userId: $currentUserId + ); + $preferences = json_decode($value, true); +} catch (SettingsItemNotFoundException $e) { + $preferences = []; // Defaults +} +``` + +### Example 4: Cascading Resolution + +```php +use Bitrix24\Lib\ApplicationSettings\Services\SettingsFetcher; + +/** + * SettingsFetcher automatically uses priorities: + * 1. Personal (if userId provided and setting exists) + * 2. Departmental (if departmentId provided and setting exists) + * 3. Global (fallback) + */ + +$value = $fetcher->getValue( + uuid: $installationId, + key: 'notification.email.enabled', + userId: 123, + departmentId: 456 +); + +// If personal setting exists for user 123 - returns it +// Otherwise if departmental exists for dept 456 - returns it +// Otherwise returns global +// If none found - throws SettingsItemNotFoundException +``` + +### Example 5: Change Auditing + +```php +// When creating setting, specify who created it +$createCmd = new CreateCommand( + applicationInstallationId: $installationId, + key: 'security.two_factor', + value: 'disabled', + isRequired: true, + changedByBitrix24UserId: $adminUserId +); +$createHandler->handle($createCmd); + +// When updating setting, specify who changed it +$updateCmd = new UpdateCommand( + applicationInstallationId: $installationId, + key: 'security.two_factor', + value: 'enabled', + changedByBitrix24UserId: $adminUserId +); +$updateHandler->handle($updateCmd); + +// Events are automatically logged with information about who made the change +``` + +## Best Practices + +### 1. Key Naming + +Use clear, hierarchical names: + +```php +// Good +'app.feature.notifications.email' +'user.interface.theme' +'integration.crm.enabled' + +// Bad +'notif' +'th' +'crm1' +``` + +### 2. Value Typing + +Store JSON for complex structures: + +```php +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'feature.limits', + value: json_encode([ + 'users' => 100, + 'storage_gb' => 50, + 'api_calls_per_day' => 10000, + ]), + isRequired: true +); +``` + +### 3. Required Settings + +Mark critical settings as `isRequired`: + +```php +$command = new CreateCommand( + applicationInstallationId: $installationId, + key: 'app.license_key', + value: $licenseKey, + isRequired: true // Application won't work without this +); +``` + +### 4. Separating Create and Update + +Always use the correct use case: + +```php +// ✅ For creating new settings +$createHandler->handle(new CreateCommand(...)); + +// ✅ For modifying existing settings +$updateHandler->handle(new UpdateCommand(...)); + +// ❌ DON'T use Create for updates +// This will throw SettingsItemAlreadyExistsException +``` + +### 5. Soft Delete + +Use soft-delete instead of physical deletion: + +```php +// Use soft delete +$deleteCommand = new DeleteCommand($installationId, 'old.setting'); +$deleteHandler->handle($deleteCommand); +``` + +### 6. Exception Handling + +```php +use Bitrix24\Lib\ApplicationSettings\Services\Exception\SettingsItemNotFoundException; +use Bitrix24\Lib\ApplicationSettings\Services\Exception\SettingsItemAlreadyExistsException; + +// Create may throw SettingsItemAlreadyExistsException if setting exists +try { + $createHandler->handle($createCommand); +} catch (SettingsItemAlreadyExistsException $e) { + // Setting already exists, use Update instead +} + +// Update may throw SettingsItemNotFoundException if setting not found +try { + $updateHandler->handle($updateCommand); +} catch (SettingsItemNotFoundException $e) { + // Setting doesn't exist, use Create instead +} + +// SettingsFetcher may throw SettingsItemNotFoundException +try { + $value = $fetcher->getValue($uuid, $key); +} catch (SettingsItemNotFoundException $e) { + // Use default value +} +``` + +## Security + +1. **Key validation** - automatic, only allowed characters +2. **Data isolation** - settings tied to `applicationInstallationId` +3. **Audit trail** - tracking who and when changed (`changedByBitrix24UserId`) +4. **History** - soft-delete preserves history for investigations +5. **ACID guarantees** - all operations in Doctrine transactions + +## Performance + +1. **Indexes** - all key fields are indexed (installation_id, key, user_id, department_id, status) +2. **Caching** - recommended to cache frequently used settings +3. **Batch operations** - use `DefaultSettingsInstaller` for bulk creation +4. **Optimized queries** - `findAllForInstallationByKey` filters at DB level + +## Database Schema Migration + +After making code changes, update the database schema: + +```bash +# Create schema (first time) +make schema-create + +# Or generate migration +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate +``` + +## Testing + +The system is fully covered by tests: + +```bash +# Unit tests +make test-run-unit + +# Functional tests (requires DB) +make test-run-functional +``` + +--- + +**Additional Resources:** +- [CLAUDE.md](../../../CLAUDE.md) - Main commands and project architecture diff --git a/src/ApplicationSettings/Entity/ApplicationSettingStatus.php b/src/ApplicationSettings/Entity/ApplicationSettingStatus.php new file mode 100644 index 00000000..ad434f36 --- /dev/null +++ b/src/ApplicationSettings/Entity/ApplicationSettingStatus.php @@ -0,0 +1,40 @@ +id = Uuid::v7(); + $this->validateKey($key); + $this->validateValue(); + $this->validateScope($b24UserId, $b24DepartmentId); + $this->createdAt = new CarbonImmutable(); + $this->updatedAt = new CarbonImmutable(); + } + + #[\Override] + public function getId(): Uuid + { + return $this->id; + } + + #[\Override] + public function getApplicationInstallationId(): Uuid + { + return $this->applicationInstallationId; + } + + #[\Override] + public function getKey(): string + { + return $this->key; + } + + #[\Override] + public function getValue(): string + { + return $this->value; + } + + #[\Override] + public function getCreatedAt(): CarbonImmutable + { + return $this->createdAt; + } + + #[\Override] + public function getUpdatedAt(): CarbonImmutable + { + return $this->updatedAt; + } + + #[\Override] + public function getB24UserId(): ?int + { + return $this->b24UserId; + } + + #[\Override] + public function getB24DepartmentId(): ?int + { + return $this->b24DepartmentId; + } + + #[\Override] + public function getChangedByBitrix24UserId(): ?int + { + return $this->changedByBitrix24UserId; + } + + #[\Override] + public function isRequired(): bool + { + return $this->isRequired; + } + + #[\Override] + public function isActive(): bool + { + return $this->status->isActive(); + } + + /** + * Mark setting as deleted (soft delete). + */ + #[\Override] + public function markAsDeleted(): void + { + if (ApplicationSettingStatus::Deleted === $this->status) { + return; // Already deleted + } + + $this->status = ApplicationSettingStatus::Deleted; + $this->updatedAt = new CarbonImmutable(); + } + + /** + * Update setting value. + */ + #[\Override] + public function updateValue(string $value, ?int $changedByBitrix24UserId = null): void + { + $this->validateValue(); + + if ($this->value !== $value) { + $oldValue = $this->value; + $this->value = $value; + $this->changedByBitrix24UserId = $changedByBitrix24UserId; + $this->updatedAt = new CarbonImmutable(); + + // Emit event about setting change + $this->events[] = new ApplicationSettingsItemChangedEvent( + $this->id, + $this->key, + $oldValue, + $value, + $changedByBitrix24UserId, + $this->updatedAt + ); + } + } + + /** + * Check if setting is global (not tied to user or department). + */ + #[\Override] + public function isGlobal(): bool + { + return null === $this->b24UserId && null === $this->b24DepartmentId; + } + + /** + * Check if setting is personal (tied to specific user). + */ + #[\Override] + public function isPersonal(): bool + { + return null !== $this->b24UserId; + } + + /** + * Check if setting is departmental (tied to specific department). + */ + #[\Override] + public function isDepartmental(): bool + { + return null !== $this->b24DepartmentId && null === $this->b24UserId; + } + + /** + * Validate setting key + * Only lowercase latin letters and dots are allowed, max 255 characters. + */ + private function validateKey(string $key): void + { + if ('' === trim($key)) { + throw new InvalidArgumentException('Setting key cannot be empty'); + } + + if (strlen($key) > 255) { + throw new InvalidArgumentException('Setting key cannot exceed 255 characters'); + } + + // Key should contain only lowercase latin letters and dots + if (in_array(preg_match('/^[a-z.]+$/', $key), [0, false], true)) { + throw new InvalidArgumentException( + 'Setting key can only contain lowercase latin letters and dots' + ); + } + } + + /** + * Validate scope parameters. + */ + private function validateScope(?int $b24UserId, ?int $b24DepartmentId): void + { + if (null !== $b24UserId && $b24UserId <= 0) { + throw new InvalidArgumentException('Bitrix24 user ID must be positive integer'); + } + + if (null !== $b24DepartmentId && $b24DepartmentId <= 0) { + throw new InvalidArgumentException('Bitrix24 department ID must be positive integer'); + } + + // User and department cannot be set simultaneously + if (null !== $b24UserId && null !== $b24DepartmentId) { + throw new InvalidArgumentException( + 'Setting cannot be both personal and departmental. Choose one scope.' + ); + } + } + + /** + * Validate setting value. + */ + private function validateValue(): void + { + // Value can be empty but not null (handled by type hint) + // We store value as string, could be JSON or plain text + // No specific validation needed here, can be extended if needed + } +} diff --git a/src/ApplicationSettings/Entity/ApplicationSettingsItemInterface.php b/src/ApplicationSettings/Entity/ApplicationSettingsItemInterface.php new file mode 100644 index 00000000..f4e0b1fd --- /dev/null +++ b/src/ApplicationSettings/Entity/ApplicationSettingsItemInterface.php @@ -0,0 +1,63 @@ +entityManager->persist($applicationSettingsItem); + } + + #[\Override] + public function delete(ApplicationSettingsItemInterface $applicationSettingsItem): void + { + $this->entityManager->remove($applicationSettingsItem); + } + + #[\Override] + public function findById(Uuid $uuid): ?ApplicationSettingsItemInterface + { + return $this->entityManager + ->getRepository(ApplicationSettingsItem::class) + ->createQueryBuilder('s') + ->where('s.id = :id') + ->andWhere('s.status = :status') + ->setParameter('id', $uuid) + ->setParameter('status', ApplicationSettingStatus::Active) + ->getQuery() + ->getOneOrNullResult() + ; + } + + #[\Override] + public function findAllForInstallation(Uuid $uuid): array + { + return $this->entityManager + ->getRepository(ApplicationSettingsItem::class) + ->createQueryBuilder('s') + ->where('s.applicationInstallationId = :applicationInstallationId') + ->andWhere('s.status = :status') + ->setParameter('applicationInstallationId', $uuid) + ->setParameter('status', ApplicationSettingStatus::Active) + ->orderBy('s.key', 'ASC') + ->getQuery() + ->getResult() + ; + } + + #[\Override] + public function findAllForInstallationByKey(Uuid $uuid, string $key): array + { + return $this->entityManager + ->getRepository(ApplicationSettingsItem::class) + ->createQueryBuilder('s') + ->where('s.applicationInstallationId = :applicationInstallationId') + ->andWhere('s.key = :key') + ->andWhere('s.status = :status') + ->setParameter('applicationInstallationId', $uuid) + ->setParameter('key', $key) + ->setParameter('status', ApplicationSettingStatus::Active) + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryInterface.php b/src/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryInterface.php new file mode 100644 index 00000000..c1bb0900 --- /dev/null +++ b/src/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryInterface.php @@ -0,0 +1,45 @@ + $defaultSettings Settings with value and required flag + */ + public function createDefaultSettings( + Uuid $uuid, + array $defaultSettings + ): void { + $this->logger->info('DefaultSettingsInstaller.createDefaultSettings.start', [ + 'applicationInstallationId' => $uuid->toRfc4122(), + 'settingsCount' => count($defaultSettings), + ]); + + foreach ($defaultSettings as $key => $config) { + // Use Create UseCase to create new setting + $command = new Command( + applicationInstallationId: $uuid, + key: $key, + value: $config['value'], + isRequired: $config['required'] + ); + + $this->createHandler->handle($command); + + $this->logger->debug('DefaultSettingsInstaller.settingProcessed', [ + 'key' => $key, + 'isRequired' => $config['required'], + ]); + } + + $this->logger->info('DefaultSettingsInstaller.createDefaultSettings.finish', [ + 'applicationInstallationId' => $uuid->toRfc4122(), + ]); + } +} diff --git a/src/ApplicationSettings/Services/SettingsFetcher.php b/src/ApplicationSettings/Services/SettingsFetcher.php new file mode 100644 index 00000000..83433886 --- /dev/null +++ b/src/ApplicationSettings/Services/SettingsFetcher.php @@ -0,0 +1,167 @@ +logger->debug('SettingsFetcher.getItem.start', [ + 'uuid' => $uuid->toRfc4122(), + 'key' => $key, + 'userId' => $userId, + 'departmentId' => $departmentId, + ]); + + $allSettings = $this->repository->findAllForInstallationByKey($uuid, $key); + + // Try to find personal setting (highest priority) + if (null !== $userId) { + foreach ($allSettings as $allSetting) { + if ($allSetting->isPersonal() + && $allSetting->getB24UserId() === $userId + ) { + $this->logger->debug('SettingsFetcher.getItem.found', [ + 'scope' => 'personal', + 'settingId' => $allSetting->getId()->toRfc4122(), + ]); + + return $allSetting; + } + } + } + + // Try to find departmental setting (medium priority) + if (null !== $departmentId) { + foreach ($allSettings as $allSetting) { + if ($allSetting->isDepartmental() + && $allSetting->getB24DepartmentId() === $departmentId + ) { + $this->logger->debug('SettingsFetcher.getItem.found', [ + 'scope' => 'departmental', + 'settingId' => $allSetting->getId()->toRfc4122(), + ]); + + return $allSetting; + } + } + } + + // Fallback to global setting (lowest priority) + foreach ($allSettings as $allSetting) { + if ($allSetting->isGlobal()) { + $this->logger->debug('SettingsFetcher.getItem.found', [ + 'scope' => 'global', + 'settingId' => $allSetting->getId()->toRfc4122(), + ]); + + return $allSetting; + } + } + + $this->logger->warning('SettingsFetcher.getItem.notFound', [ + 'uuid' => $uuid->toRfc4122(), + 'key' => $key, + ]); + + throw new ItemNotFoundException(sprintf('Settings item with key "%s" not found', $key)); + } + + /** + * Get setting value with optional deserialization to object. + * + * If $class is provided, deserializes JSON value into specified class using Symfony Serializer. + * If $class is null, returns raw string value. + * + * @template T of object + * + * @param null|class-string $class Optional class to deserialize into + * + * @return ($class is null ? string : T) + * + * @throws ItemNotFoundException if setting not found at any level + */ + public function getValue( + Uuid $uuid, + string $key, + ?int $userId = null, + ?int $departmentId = null, + ?string $class = null + ): object|string { + $this->logger->debug('SettingsFetcher.getValue.start', [ + 'uuid' => $uuid->toRfc4122(), + 'key' => $key, + 'class' => $class, + ]); + + $applicationSettingsItem = $this->getItem($uuid, $key, $userId, $departmentId); + $value = $applicationSettingsItem->getValue(); + + // If no class specified, return raw string + if (null === $class) { + $this->logger->debug('SettingsFetcher.getValue.returnRaw', [ + 'key' => $key, + 'valueLength' => strlen($value), + ]); + + return $value; + } + + // Deserialize to object + try { + $object = $this->serializer->deserialize($value, $class, 'json'); + + $this->logger->debug('SettingsFetcher.getValue.deserialized', [ + 'key' => $key, + 'class' => $class, + ]); + + return $object; + } catch (\Throwable $throwable) { + $this->logger->error('SettingsFetcher.getValue.deserializationFailed', [ + 'key' => $key, + 'class' => $class, + 'error' => $throwable->getMessage(), + ]); + + throw $throwable; + } + } +} diff --git a/src/ApplicationSettings/UseCase/Create/Command.php b/src/ApplicationSettings/UseCase/Create/Command.php new file mode 100644 index 00000000..dc5edd12 --- /dev/null +++ b/src/ApplicationSettings/UseCase/Create/Command.php @@ -0,0 +1,66 @@ +validate(); + } + + /** + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ('' === trim($this->key)) { + throw new InvalidArgumentException('Setting key cannot be empty'); + } + + if (strlen($this->key) > 255) { + throw new InvalidArgumentException('Setting key cannot exceed 255 characters'); + } + + // Key should contain only lowercase latin letters and dots + if (in_array(preg_match('/^[a-z.]+$/', $this->key), [0, false], true)) { + throw new InvalidArgumentException( + 'Setting key can only contain lowercase latin letters and dots' + ); + } + + if (null !== $this->b24UserId && $this->b24UserId <= 0) { + throw new InvalidArgumentException('Bitrix24 user ID must be positive integer'); + } + + if (null !== $this->b24DepartmentId && $this->b24DepartmentId <= 0) { + throw new InvalidArgumentException('Bitrix24 department ID must be positive integer'); + } + + if (null !== $this->b24UserId && null !== $this->b24DepartmentId) { + throw new InvalidArgumentException( + 'Setting cannot be both personal and departmental. Choose one scope.' + ); + } + } +} diff --git a/src/ApplicationSettings/UseCase/Create/Handler.php b/src/ApplicationSettings/UseCase/Create/Handler.php new file mode 100644 index 00000000..76d2d76d --- /dev/null +++ b/src/ApplicationSettings/UseCase/Create/Handler.php @@ -0,0 +1,104 @@ +logger->info('ApplicationSettings.Create.start', [ + 'applicationInstallationId' => $command->applicationInstallationId->toRfc4122(), + 'key' => $command->key, + 'b24UserId' => $command->b24UserId, + 'b24DepartmentId' => $command->b24DepartmentId, + ]); + + // Check if setting already exists with the same scope + $allSettings = $this->applicationSettingRepository->findAllForInstallation( + $command->applicationInstallationId + ); + + $existingSetting = $this->findMatchingSetting( + $allSettings, + $command->key, + $command->b24UserId, + $command->b24DepartmentId + ); + + if ($existingSetting instanceof ApplicationSettingsItemInterface) { + throw new InvalidArgumentException(sprintf('Setting with key "%s" already exists.', $command->key)); + } + + // Create new setting + $applicationSettingsItem = new ApplicationSettingsItem( + $command->applicationInstallationId, + $command->key, + $command->value, + $command->isRequired, + $command->b24UserId, + $command->b24DepartmentId, + $command->changedByBitrix24UserId + ); + $this->applicationSettingRepository->save($applicationSettingsItem); + + $this->logger->debug('ApplicationSettings.Create.created', [ + 'settingId' => $applicationSettingsItem->getId()->toRfc4122(), + 'isRequired' => $command->isRequired, + 'changedBy' => $command->changedByBitrix24UserId, + ]); + + /** @var AggregateRootEventsEmitterInterface&ApplicationSettingsItemInterface $applicationSettingsItem */ + $this->flusher->flush($applicationSettingsItem); + + $this->logger->info('ApplicationSettings.Create.finish', [ + 'settingId' => $applicationSettingsItem->getId()->toRfc4122(), + ]); + } + + /** + * Find setting that matches key and scope. + * + * @param ApplicationSettingsItemInterface[] $settings + */ + private function findMatchingSetting( + array $settings, + string $key, + ?int $b24UserId, + ?int $b24DepartmentId + ): ?ApplicationSettingsItemInterface { + foreach ($settings as $setting) { + if ($setting->getKey() === $key + && $setting->getB24UserId() === $b24UserId + && $setting->getB24DepartmentId() === $b24DepartmentId + ) { + return $setting; + } + } + + return null; + } +} diff --git a/src/ApplicationSettings/UseCase/Delete/Command.php b/src/ApplicationSettings/UseCase/Delete/Command.php new file mode 100644 index 00000000..be1c12f6 --- /dev/null +++ b/src/ApplicationSettings/UseCase/Delete/Command.php @@ -0,0 +1,28 @@ +validate(); + } + + private function validate(): void + { + if ('' === trim($this->key)) { + throw new InvalidArgumentException('Setting key cannot be empty'); + } + } +} diff --git a/src/ApplicationSettings/UseCase/Delete/Handler.php b/src/ApplicationSettings/UseCase/Delete/Handler.php new file mode 100644 index 00000000..ed60b6f0 --- /dev/null +++ b/src/ApplicationSettings/UseCase/Delete/Handler.php @@ -0,0 +1,65 @@ +logger->info('ApplicationSettings.Delete.start', [ + 'applicationInstallationId' => $command->applicationInstallationId->toRfc4122(), + 'key' => $command->key, + ]); + + // Find global setting by key + $allSettings = $this->applicationSettingRepository->findAllForInstallation( + $command->applicationInstallationId + ); + + $setting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === $command->key && $allSetting->isGlobal()) { + $setting = $allSetting; + + break; + } + } + + if (!$setting instanceof ApplicationSettingsItemInterface) { + throw new ItemNotFoundException(sprintf('Setting with key "%s" not found.', $command->key)); + } + + $settingId = $setting->getId()->toRfc4122(); + + // Soft-delete: mark as deleted instead of removing + $setting->markAsDeleted(); + $this->flusher->flush(); + + $this->logger->info('ApplicationSettings.Delete.finish', [ + 'settingId' => $settingId, + 'softDeleted' => true, + ]); + } +} diff --git a/src/ApplicationSettings/UseCase/OnApplicationDelete/Command.php b/src/ApplicationSettings/UseCase/OnApplicationDelete/Command.php new file mode 100644 index 00000000..d5413e32 --- /dev/null +++ b/src/ApplicationSettings/UseCase/OnApplicationDelete/Command.php @@ -0,0 +1,20 @@ +logger->info('ApplicationSettings.OnApplicationDelete.start', [ + 'applicationInstallationId' => $command->applicationInstallationId->toRfc4122(), + ]); + + // Get all active settings for this installation + $settings = $this->applicationSettingRepository->findAllForInstallation($command->applicationInstallationId); + + // Mark each setting as deleted + foreach ($settings as $setting) { + $setting->markAsDeleted(); + } + + $this->flusher->flush(); + + $this->logger->info('ApplicationSettings.OnApplicationDelete.finish', [ + 'applicationInstallationId' => $command->applicationInstallationId->toRfc4122(), + 'deletedCount' => count($settings), + ]); + } +} diff --git a/src/ApplicationSettings/UseCase/Update/Command.php b/src/ApplicationSettings/UseCase/Update/Command.php new file mode 100644 index 00000000..5f7c20bd --- /dev/null +++ b/src/ApplicationSettings/UseCase/Update/Command.php @@ -0,0 +1,62 @@ +validate(); + } + + private function validate(): void + { + if ('' === trim($this->key)) { + throw new InvalidArgumentException('Setting key cannot be empty'); + } + + if (strlen($this->key) > 255) { + throw new InvalidArgumentException('Setting key cannot exceed 255 characters'); + } + + // Key should contain only lowercase latin letters and dots + if (in_array(preg_match('/^[a-z.]+$/', $this->key), [0, false], true)) { + throw new InvalidArgumentException( + 'Setting key can only contain lowercase latin letters and dots' + ); + } + + if (null !== $this->b24UserId && $this->b24UserId <= 0) { + throw new InvalidArgumentException('Bitrix24 user ID must be positive integer'); + } + + if (null !== $this->b24DepartmentId && $this->b24DepartmentId <= 0) { + throw new InvalidArgumentException('Bitrix24 department ID must be positive integer'); + } + + if (null !== $this->b24UserId && null !== $this->b24DepartmentId) { + throw new InvalidArgumentException( + 'Setting cannot be both personal and departmental. Choose one scope.' + ); + } + } +} diff --git a/src/ApplicationSettings/UseCase/Update/Handler.php b/src/ApplicationSettings/UseCase/Update/Handler.php new file mode 100644 index 00000000..a0e40b81 --- /dev/null +++ b/src/ApplicationSettings/UseCase/Update/Handler.php @@ -0,0 +1,96 @@ +logger->info('ApplicationSettings.Update.start', [ + 'applicationInstallationId' => $command->applicationInstallationId->toRfc4122(), + 'key' => $command->key, + 'b24UserId' => $command->b24UserId, + 'b24DepartmentId' => $command->b24DepartmentId, + ]); + + // Find existing setting with the same scope + $allSettings = $this->applicationSettingRepository->findAllForInstallation( + $command->applicationInstallationId + ); + + $setting = $this->findMatchingSetting( + $allSettings, + $command->key, + $command->b24UserId, + $command->b24DepartmentId + ); + + if (!$setting instanceof ApplicationSettingsItemInterface) { + throw new InvalidArgumentException( + sprintf( + 'Setting with key "%s" does not exist for this scope. Use Create command to add it.', + $command->key + ) + ); + } + + // Update existing setting (this will emit ApplicationSettingsItemChangedEvent) + $setting->updateValue($command->value, $command->changedByBitrix24UserId); + + $this->logger->debug('ApplicationSettings.Update.updated', [ + 'settingId' => $setting->getId()->toRfc4122(), + 'changedBy' => $command->changedByBitrix24UserId, + ]); + + /** @var AggregateRootEventsEmitterInterface&ApplicationSettingsItemInterface $setting */ + $this->flusher->flush($setting); + + $this->logger->info('ApplicationSettings.Update.finish', [ + 'settingId' => $setting->getId()->toRfc4122(), + ]); + } + + /** + * Find setting that matches key and scope. + * + * @param ApplicationSettingsItemInterface[] $settings + */ + private function findMatchingSetting( + array $settings, + string $key, + ?int $b24UserId, + ?int $b24DepartmentId + ): ?ApplicationSettingsItemInterface { + foreach ($settings as $setting) { + if ($setting->getKey() === $key + && $setting->getB24UserId() === $b24UserId + && $setting->getB24DepartmentId() === $b24DepartmentId + ) { + return $setting; + } + } + + return null; + } +} diff --git a/src/Bitrix24Accounts/UseCase/ChangeDomainUrl/Command.php b/src/Bitrix24Accounts/UseCase/ChangeDomainUrl/Command.php index 0d640a86..b53f6f20 100644 --- a/src/Bitrix24Accounts/UseCase/ChangeDomainUrl/Command.php +++ b/src/Bitrix24Accounts/UseCase/ChangeDomainUrl/Command.php @@ -4,7 +4,7 @@ namespace Bitrix24\Lib\Bitrix24Accounts\UseCase\ChangeDomainUrl; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; readonly class Command { diff --git a/src/Bitrix24Accounts/UseCase/InstallFinish/Command.php b/src/Bitrix24Accounts/UseCase/InstallFinish/Command.php index 979b0e1a..47d162e4 100644 --- a/src/Bitrix24Accounts/UseCase/InstallFinish/Command.php +++ b/src/Bitrix24Accounts/UseCase/InstallFinish/Command.php @@ -4,7 +4,7 @@ namespace Bitrix24\Lib\Bitrix24Accounts\UseCase\InstallFinish; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; readonly class Command { diff --git a/src/Bitrix24Accounts/UseCase/InstallStart/Command.php b/src/Bitrix24Accounts/UseCase/InstallStart/Command.php index e480182d..89fb386f 100644 --- a/src/Bitrix24Accounts/UseCase/InstallStart/Command.php +++ b/src/Bitrix24Accounts/UseCase/InstallStart/Command.php @@ -4,7 +4,7 @@ namespace Bitrix24\Lib\Bitrix24Accounts\UseCase\InstallStart; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\SDK\Core\Credentials\AuthToken; use Bitrix24\SDK\Core\Credentials\Scope; diff --git a/src/Bitrix24Accounts/UseCase/RenewAuthToken/Command.php b/src/Bitrix24Accounts/UseCase/RenewAuthToken/Command.php index e33957de..a71666c0 100644 --- a/src/Bitrix24Accounts/UseCase/RenewAuthToken/Command.php +++ b/src/Bitrix24Accounts/UseCase/RenewAuthToken/Command.php @@ -10,6 +10,6 @@ { public function __construct( public RenewedAuthToken $renewedAuthToken, - public int $bitrix24UserId, + public ?int $bitrix24UserId = null, ) {} } diff --git a/src/Bitrix24Accounts/ValueObjects/Domain.php b/src/Common/ValueObjects/Domain.php similarity index 97% rename from src/Bitrix24Accounts/ValueObjects/Domain.php rename to src/Common/ValueObjects/Domain.php index e172b223..a0842e3c 100644 --- a/src/Bitrix24Accounts/ValueObjects/Domain.php +++ b/src/Common/ValueObjects/Domain.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bitrix24\Lib\Bitrix24Accounts\ValueObjects; +namespace Bitrix24\Lib\Common\ValueObjects; readonly class Domain { diff --git a/src/Console/ApplicationSettingsListCommand.php b/src/Console/ApplicationSettingsListCommand.php new file mode 100644 index 00000000..0f308421 --- /dev/null +++ b/src/Console/ApplicationSettingsListCommand.php @@ -0,0 +1,192 @@ + + * + * - List personal settings for user: + * php bin/console app:settings:list --user-id=123 + * + * - List departmental settings: + * php bin/console app:settings:list --department-id=456 + */ +#[AsCommand( + name: 'app:settings:list', + description: 'List application settings for portal, user, or department' +)] +class ApplicationSettingsListCommand extends Command +{ + public function __construct( + private readonly ApplicationSettingsItemRepositoryInterface $applicationSettingRepository + ) { + parent::__construct(); + } + + #[\Override] + protected function configure(): void + { + $this + ->addArgument( + 'installation-id', + InputArgument::REQUIRED, + 'Application Installation UUID' + ) + ->addOption( + 'user-id', + 'u', + InputOption::VALUE_REQUIRED, + 'Bitrix24 User ID (for personal settings)' + ) + ->addOption( + 'department-id', + 'd', + InputOption::VALUE_REQUIRED, + 'Bitrix24 Department ID (for departmental settings)' + ) + ->addOption( + 'global-only', + 'g', + InputOption::VALUE_NONE, + 'Show only global settings' + ) + ->setHelp( + <<<'HELP' +The app:settings:list command displays application settings. + +List all settings for application installation: + php bin/console app:settings:list 018c1234-5678-7abc-9def-123456789abc + +List global settings only: + php bin/console app:settings:list 018c1234-5678-7abc-9def-123456789abc --global-only + +List personal settings for specific user: + php bin/console app:settings:list 018c1234-5678-7abc-9def-123456789abc --user-id=123 + +List departmental settings: + php bin/console app:settings:list 018c1234-5678-7abc-9def-123456789abc --department-id=456 +HELP + ) + ; + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $symfonyStyle = new SymfonyStyle($input, $output); + + /** @var string $installationIdString */ + $installationIdString = $input->getArgument('installation-id'); + + try { + $installationId = Uuid::fromString($installationIdString); + } catch (\InvalidArgumentException) { + $symfonyStyle->error('Invalid Installation ID format. Expected UUID.'); + + return Command::FAILURE; + } + + /** @var null|string $userIdInput */ + $userIdInput = $input->getOption('user-id'); + $userId = null !== $userIdInput ? (int) $userIdInput : null; + + /** @var null|string $departmentIdInput */ + $departmentIdInput = $input->getOption('department-id'); + $departmentId = null !== $departmentIdInput ? (int) $departmentIdInput : null; + + $globalOnly = $input->getOption('global-only'); + + // Validate options + if ($userId && $departmentId) { + $symfonyStyle->error('Cannot specify both --user-id and --department-id'); + + return Command::FAILURE; + } + + if ($globalOnly && ($userId || $departmentId)) { + $symfonyStyle->error('Cannot use --global-only with --user-id or --department-id'); + + return Command::FAILURE; + } + + // Fetch all settings and filter based on parameters + $allSettings = $this->applicationSettingRepository->findAllForInstallation($installationId); + + if ($globalOnly || (null === $userId && null === $departmentId)) { + $settings = array_filter($allSettings, fn ($setting): bool => $setting->isGlobal()); + $scope = 'Global'; + } elseif (null !== $userId) { + $settings = array_filter($allSettings, fn ($setting): bool => $setting->isPersonal() && $setting->getB24UserId() === $userId); + $scope = sprintf('Personal (User ID: %d)', $userId); + } else { + $settings = array_filter($allSettings, fn ($setting): bool => $setting->isDepartmental() && $setting->getB24DepartmentId() === $departmentId); + $scope = sprintf('Departmental (Department ID: %d)', $departmentId); + } + + // Display results + $symfonyStyle->title(sprintf('Application Settings - %s', $scope)); + $symfonyStyle->text(sprintf('Installation ID: %s', $installationId->toRfc4122())); + + if ([] === $settings) { + $symfonyStyle->warning('No settings found.'); + + return Command::SUCCESS; + } + + // Create table + $table = new Table($output); + $table->setHeaders(['Key', 'Value', 'Scope', 'Created', 'Updated']); + + foreach ($settings as $setting) { + $settingScope = 'Global'; + if ($setting->isPersonal()) { + $settingScope = sprintf('User #%d', $setting->getB24UserId()); + } elseif ($setting->isDepartmental()) { + $settingScope = sprintf('Dept #%d', $setting->getB24DepartmentId()); + } + + $table->addRow([ + $setting->getKey(), + $this->truncateValue($setting->getValue(), 50), + $settingScope, + $setting->getCreatedAt()->format('Y-m-d H:i:s'), + $setting->getUpdatedAt()->format('Y-m-d H:i:s'), + ]); + } + + $table->render(); + + $symfonyStyle->success(sprintf('Found %d setting(s)', count($settings))); + + return Command::SUCCESS; + } + + /** + * Truncate long values for table display. + */ + private function truncateValue(string $value, int $maxLength): string + { + if (strlen($value) <= $maxLength) { + return $value; + } + + return substr($value, 0, $maxLength - 3).'...'; + } +} 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/src/Exceptions/BaseException.php b/src/Exceptions/BaseException.php new file mode 100644 index 00000000..93c6d97e --- /dev/null +++ b/src/Exceptions/BaseException.php @@ -0,0 +1,16 @@ + + * + * 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\Exceptions; + +class BaseException extends \Exception {} diff --git a/src/Journal/Controller/JournalAdminController.php b/src/Journal/Controller/JournalAdminController.php deleted file mode 100644 index 0aab3bb6..00000000 --- a/src/Journal/Controller/JournalAdminController.php +++ /dev/null @@ -1,94 +0,0 @@ - - * - * 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\Journal\Controller; - -use Bitrix24\Lib\Journal\Entity\LogLevel; -use Bitrix24\Lib\Journal\ReadModel\JournalItemReadRepository; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Uid\Uuid; - -/** - * Admin controller for journal management - * Developer should configure routes in their application - */ -class JournalAdminController extends AbstractController -{ - public function __construct( - private readonly JournalItemReadRepository $journalReadRepository - ) { - } - - /** - * List journal items with filters and pagination - */ - public function list(Request $request): Response - { - $page = max(1, $request->query->getInt('page', 1)); - $domainUrl = $request->query->get('domain'); - $levelValue = $request->query->get('level'); - $label = $request->query->get('label'); - - $level = null; - if ($levelValue && in_array($levelValue, array_column(LogLevel::cases(), 'value'), true)) { - $level = LogLevel::from($levelValue); - } - - $pagination = $this->journalReadRepository->findWithFilters( - domainUrl: $domainUrl ?: null, - level: $level, - label: $label ?: null, - page: $page, - limit: 50 - ); - - $availableDomains = $this->journalReadRepository->getAvailableDomains(); - $availableLabels = $this->journalReadRepository->getAvailableLabels(); - - return $this->render('@Journal/admin/list.html.twig', [ - 'pagination' => $pagination, - 'currentFilters' => [ - 'domain' => $domainUrl, - 'level' => $levelValue, - 'label' => $label, - ], - 'availableDomains' => $availableDomains, - 'availableLabels' => $availableLabels, - 'logLevels' => LogLevel::cases(), - ]); - } - - /** - * Show journal item details - */ - public function show(string $id): Response - { - try { - $uuid = Uuid::fromString($id); - } catch (\InvalidArgumentException) { - throw $this->createNotFoundException('Invalid journal item ID'); - } - - $journalItem = $this->journalReadRepository->findById($uuid); - - if (!$journalItem) { - throw $this->createNotFoundException('Journal item not found'); - } - - return $this->render('@Journal/admin/show.html.twig', [ - 'item' => $journalItem, - ]); - } -} diff --git a/src/Journal/Docs/README.md b/src/Journal/Docs/README.md index d30442cf..3205ec58 100644 --- a/src/Journal/Docs/README.md +++ b/src/Journal/Docs/README.md @@ -20,7 +20,8 @@ $factory = $container->get(JournalLoggerFactory::class); // Создаем логгер для конкретной установки приложения $installationId = Uuid::fromString('...'); -$logger = $factory->createLogger($installationId); +$memberId = '...'; +$logger = $factory->createLogger($memberId, $installationId); // Используем как обычный PSR-3 логгер $logger->info('Синхронизация завершена', [ @@ -50,6 +51,7 @@ use Bitrix24\Lib\Journal\Services\JournalLogger; use Bitrix24\Lib\Journal\Infrastructure\Doctrine\DoctrineDbalJournalItemRepository; $logger = new JournalLogger( + memberId: $memberId, applicationInstallationId: $installationId, repository: $repository, entityManager: $entityManager @@ -74,13 +76,14 @@ $logger->debug('Отладочная информация'); use Bitrix24\Lib\Journal\Entity\JournalItem; // Создание через статические методы -$item = JournalItem::info($installationId, 'Сообщение', [ +$item = JournalItem::info($memberId, $installationId, 'Сообщение', [ 'label' => 'custom.label', 'payload' => ['key' => 'value'] ]); // Или через create с явным указанием уровня $item = JournalItem::create( + memberId: $memberId, applicationInstallationId: $installationId, level: LogLevel::error, message: 'Сообщение об ошибке', @@ -95,7 +98,7 @@ $item = JournalItem::create( ```php use Bitrix24\Lib\Journal\Infrastructure\Doctrine\DoctrineDbalJournalItemRepository; -$repository = new DoctrineDbalJournalItemRepository($entityManager); +$repository = new DoctrineDbalJournalItemRepository($entityManager, $paginator); // Сохранение $repository->save($journalItem); @@ -103,10 +106,9 @@ $entityManager->flush(); // Поиск $item = $repository->findById($uuid); -$items = $repository->findByApplicationInstallationId($installationId, LogLevel::error, 50, 0); +$items = $repository->findByApplicationInstallationId($memberId, $installationId, LogLevel::error, 50, 0); // Очистка -$deleted = $repository->deleteByApplicationInstallationId($installationId); $deleted = $repository->deleteOlderThan(new CarbonImmutable('-30 days')); ``` @@ -126,14 +128,15 @@ $repository->clear(); ### 4. Admin UI (ReadModel) ```php -use Bitrix24\Lib\Journal\ReadModel\JournalItemReadRepository; +use Bitrix24\Lib\Journal\Infrastructure\Doctrine\DoctrineDbalJournalItemRepository; -$readRepo = new JournalItemReadRepository($entityManager, $paginator); +$readRepo = new DoctrineDbalJournalItemRepository($entityManager, $paginator); // Получение с фильтрами и пагинацией $pagination = $readRepo->findWithFilters( - domainUrl: 'example.bitrix24.ru', - level: LogLevel::error, + memberId: '66c9893d5f30e6.45265697', + domain: new Domain('example.bitrix24.ru'), + logLevel: LogLevel::error, label: 'b24.api.error', page: 1, limit: 50 @@ -185,6 +188,7 @@ class MyTest extends TestCase $entityManager = $this->createMock(EntityManagerInterface::class); $this->logger = new JournalLogger( + '66c9893d5f30e6.45265697', Uuid::v7(), $this->repository, $entityManager @@ -215,6 +219,7 @@ class MyTest extends TestCase Таблица `journal_item` с полями: - `id` (UUID) - PK +- `member_id` (string) - ID портала Bitrix24 - `application_installation_id` (UUID) - FK к установке приложения - `created_at_utc` (timestamp) - время создания - `level` (string) - уровень логирования @@ -222,6 +227,7 @@ class MyTest extends TestCase - `label`, `payload`, `bitrix24_user_id`, `ip_address` - поля контекста Индексы: -- `application_installation_id` +- `member_id, application_installation_id, level, created_at_utc` (composite) +- `member_id` - `created_at_utc` - `level` diff --git a/src/Journal/Entity/JournalItem.php b/src/Journal/Entity/JournalItem.php index 36a1011c..327b8e68 100644 --- a/src/Journal/Entity/JournalItem.php +++ b/src/Journal/Entity/JournalItem.php @@ -14,14 +14,14 @@ namespace Bitrix24\Lib\Journal\Entity; use Bitrix24\Lib\AggregateRoot; -use Bitrix24\Lib\Journal\ValueObjects\JournalContext; +use Bitrix24\Lib\Journal\Entity\ValueObjects\Context; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use Carbon\CarbonImmutable; use Symfony\Component\Uid\Uuid; /** * Journal item entity - * Each journal record contains domain business events for technical support staff + * Each journal record contains domain business events for technical support staff. */ class JournalItem extends AggregateRoot implements JournalItemInterface { @@ -30,15 +30,26 @@ class JournalItem extends AggregateRoot implements JournalItemInterface private readonly CarbonImmutable $createdAt; public function __construct( - private Uuid $applicationInstallationId, - private LogLevel $level, - private string $message, - private JournalContext $context + private readonly string $memberId, + private readonly Uuid $applicationInstallationId, + private readonly LogLevel $level, + private readonly string $message, + private readonly string $label, + private readonly ?string $userId, + private readonly Context $context ) { + if ('' === trim($this->memberId)) { + throw new InvalidArgumentException('memberId cannot be empty'); + } + if ('' === trim($this->message)) { throw new InvalidArgumentException('Journal message cannot be empty'); } + if ('' === trim($this->label)) { + throw new InvalidArgumentException('Journal label cannot be empty'); + } + $this->id = Uuid::v7(); $this->createdAt = new CarbonImmutable(); } @@ -55,6 +66,12 @@ public function getApplicationInstallationId(): Uuid return $this->applicationInstallationId; } + #[\Override] + public function getMemberId(): string + { + return $this->memberId; + } + #[\Override] public function getCreatedAt(): CarbonImmutable { @@ -74,68 +91,86 @@ public function getMessage(): string } #[\Override] - public function getContext(): JournalContext + public function getLabel(): string + { + return $this->label; + } + + #[\Override] + public function getUserId(): ?string + { + return $this->userId; + } + + #[\Override] + public function getContext(): Context { return $this->context; } /** - * Create journal item with custom log level + * Create journal item with custom log level. */ public static function create( + string $memberId, Uuid $applicationInstallationId, LogLevel $level, string $message, - JournalContext $context + string $label, + ?string $userId, + Context $context ): self { return new self( + memberId: $memberId, applicationInstallationId: $applicationInstallationId, level: $level, message: $message, + label: $label, + userId: $userId, context: $context ); } /** - * PSR-3 compatible factory methods + * PSR-3 compatible factory methods. */ - public static function emergency(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function emergency(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::emergency, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::emergency, $message, $label, $userId, $context); } - public static function alert(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function alert(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::alert, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::alert, $message, $label, $userId, $context); } - public static function critical(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function critical(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::critical, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::critical, $message, $label, $userId, $context); } - public static function error(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function error(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::error, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::error, $message, $label, $userId, $context); } - public static function warning(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function warning(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::warning, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::warning, $message, $label, $userId, $context); } - public static function notice(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function notice(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::notice, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::notice, $message, $label, $userId, $context); } - public static function info(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function info(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::info, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::info, $message, $label, $userId, $context); } - public static function debug(Uuid $applicationInstallationId, string $message, JournalContext $context): self + public static function debug(string $memberId, Uuid $applicationInstallationId, string $message, string $label, ?string $userId, Context $context): self { - return self::create($applicationInstallationId, LogLevel::debug, $message, $context); + return self::create($memberId, $applicationInstallationId, LogLevel::debug, $message, $label, $userId, $context); } } diff --git a/src/Journal/Entity/JournalItemInterface.php b/src/Journal/Entity/JournalItemInterface.php index bccbddb6..b87edd65 100644 --- a/src/Journal/Entity/JournalItemInterface.php +++ b/src/Journal/Entity/JournalItemInterface.php @@ -13,12 +13,12 @@ namespace Bitrix24\Lib\Journal\Entity; -use Bitrix24\Lib\Journal\ValueObjects\JournalContext; +use Bitrix24\Lib\Journal\Entity\ValueObjects\Context; use Carbon\CarbonImmutable; use Symfony\Component\Uid\Uuid; /** - * Journal item interface for SDK contract extraction + * Journal item interface for SDK contract extraction. */ interface JournalItemInterface { @@ -26,11 +26,17 @@ public function getId(): Uuid; public function getApplicationInstallationId(): Uuid; + public function getMemberId(): string; + public function getCreatedAt(): CarbonImmutable; public function getLevel(): LogLevel; public function getMessage(): string; - public function getContext(): JournalContext; + public function getUserId(): ?string; + + public function getLabel(): string; + + public function getContext(): Context; } diff --git a/src/Journal/Entity/LogLevel.php b/src/Journal/Entity/LogLevel.php index bc4dfbb8..1f8c8918 100644 --- a/src/Journal/Entity/LogLevel.php +++ b/src/Journal/Entity/LogLevel.php @@ -14,7 +14,7 @@ namespace Bitrix24\Lib\Journal\Entity; /** - * PSR-3 compatible log level enum + * PSR-3 compatible log level enum. */ enum LogLevel: string { @@ -28,7 +28,7 @@ enum LogLevel: string case debug = 'debug'; /** - * Creates LogLevel from PSR-3 log level string + * Creates LogLevel from PSR-3 log level string. */ public static function fromPsr3Level(string $level): self { diff --git a/src/Journal/ValueObjects/JournalContext.php b/src/Journal/Entity/ValueObjects/Context.php similarity index 77% rename from src/Journal/ValueObjects/JournalContext.php rename to src/Journal/Entity/ValueObjects/Context.php index 627e5cde..046e9879 100644 --- a/src/Journal/ValueObjects/JournalContext.php +++ b/src/Journal/Entity/ValueObjects/Context.php @@ -11,27 +11,20 @@ declare(strict_types=1); -namespace Bitrix24\Lib\Journal\ValueObjects; +namespace Bitrix24\Lib\Journal\Entity\ValueObjects; use Darsyn\IP\Version\Multi as IP; /** - * Journal context value object + * Journal context value object. */ -readonly class JournalContext +readonly class Context { public function __construct( - private string $label, private ?array $payload = null, private ?int $bitrix24UserId = null, private ?IP $ipAddress = null - ) { - } - - public function getLabel(): string - { - return $this->label; - } + ) {} public function getPayload(): ?array { @@ -49,12 +42,11 @@ public function getIpAddress(): ?IP } /** - * Convert to array + * Convert to array. */ public function toArray(): array { return [ - 'label' => $this->label, 'payload' => $this->payload, 'bitrix24UserId' => $this->bitrix24UserId, 'ipAddress' => $this->ipAddress?->getCompactedAddress(), diff --git a/src/Journal/Infrastructure/Doctrine/DoctrineDbalJournalItemRepository.php b/src/Journal/Infrastructure/Doctrine/DoctrineDbalJournalItemRepository.php index a9bccc78..1bf6b0c1 100644 --- a/src/Journal/Infrastructure/Doctrine/DoctrineDbalJournalItemRepository.php +++ b/src/Journal/Infrastructure/Doctrine/DoctrineDbalJournalItemRepository.php @@ -13,6 +13,9 @@ namespace Bitrix24\Lib\Journal\Infrastructure\Doctrine; +use Bitrix24\Lib\ApplicationInstallations\Entity\ApplicationInstallation; +use Bitrix24\Lib\Bitrix24Accounts\Entity\Bitrix24Account; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Journal\Entity\JournalItem; use Bitrix24\Lib\Journal\Entity\JournalItemInterface; use Bitrix24\Lib\Journal\Entity\LogLevel; @@ -20,26 +23,32 @@ use Carbon\CarbonImmutable; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\QueryBuilder; +use Knp\Component\Pager\Pagination\PaginationInterface; +use Knp\Component\Pager\PaginatorInterface; use Symfony\Component\Uid\Uuid; -class DoctrineDbalJournalItemRepository extends EntityRepository implements JournalItemRepositoryInterface +class DoctrineDbalJournalItemRepository implements JournalItemRepositoryInterface { + private readonly EntityRepository $repository; + public function __construct( - EntityManagerInterface $entityManager + private readonly EntityManagerInterface $entityManager, + private readonly PaginatorInterface $paginator ) { - parent::__construct($entityManager, $entityManager->getClassMetadata(JournalItem::class)); + $this->repository = $this->entityManager->getRepository(JournalItem::class); } #[\Override] public function save(JournalItemInterface $journalItem): void { - $this->getEntityManager()->persist($journalItem); + $this->entityManager->persist($journalItem); } #[\Override] - public function findById(Uuid $id): ?JournalItemInterface + public function findById(Uuid $uuid): ?JournalItemInterface { - return $this->getEntityManager()->getRepository(JournalItem::class)->find($id); + return $this->repository->find($uuid); } /** @@ -47,69 +56,199 @@ public function findById(Uuid $id): ?JournalItemInterface */ #[\Override] public function findByApplicationInstallationId( + string $memberId, Uuid $applicationInstallationId, - ?LogLevel $level = null, + ?LogLevel $logLevel = null, ?int $limit = null, ?int $offset = null ): array { - $qb = $this->getEntityManager()->getRepository(JournalItem::class) + $queryBuilder = $this->repository ->createQueryBuilder('j') - ->where('j.applicationInstallationId = :appId') + ->where('j.memberId = :memberId') + ->setParameter('memberId', $memberId) + ->andWhere('j.applicationInstallationId = :appId') ->setParameter('appId', $applicationInstallationId) - ->orderBy('j.createdAt', 'DESC'); + ->orderBy('j.createdAt', 'DESC') + ; - if (null !== $level) { - $qb->andWhere('j.level = :level') - ->setParameter('level', $level); + if (null !== $logLevel) { + $queryBuilder->andWhere('j.level = :level') + ->setParameter('level', $logLevel) + ; } if (null !== $limit) { - $qb->setMaxResults($limit); + $queryBuilder->setMaxResults($limit); } if (null !== $offset) { - $qb->setFirstResult($offset); + $queryBuilder->setFirstResult($offset); } - return $qb->getQuery()->getResult(); + return $queryBuilder->getQuery()->getResult(); } + /** + * @return JournalItemInterface[] + */ #[\Override] - public function deleteByApplicationInstallationId(Uuid $applicationInstallationId): int - { - return $this->getEntityManager()->createQueryBuilder() - ->delete(JournalItem::class, 'j') - ->where('j.applicationInstallationId = :appId') - ->setParameter('appId', $applicationInstallationId) - ->getQuery() - ->execute(); + public function findByMemberId( + string $memberId, + ?LogLevel $logLevel = null, + ?int $limit = null, + ?int $offset = null + ): array { + $queryBuilder = $this->repository + ->createQueryBuilder('j') + ->where('j.memberId = :memberId') + ->setParameter('memberId', $memberId) + ->orderBy('j.createdAt', 'DESC') + ; + + if (null !== $logLevel) { + $queryBuilder->andWhere('j.level = :level') + ->setParameter('level', $logLevel) + ; + } + + if (null !== $limit) { + $queryBuilder->setMaxResults($limit); + } + + if (null !== $offset) { + $queryBuilder->setFirstResult($offset); + } + + return $queryBuilder->getQuery()->getResult(); } #[\Override] - public function deleteOlderThan(CarbonImmutable $date): int - { - return $this->getEntityManager()->createQueryBuilder() + public function deleteOlderThan( + string $memberId, + Uuid $applicationInstallationId, + CarbonImmutable $date + ): int { + return $this->entityManager->createQueryBuilder() ->delete(JournalItem::class, 'j') - ->where('j.createdAt < :date') + ->where('j.memberId = :memberId') + ->andWhere('j.applicationInstallationId = :appId') + ->andWhere('j.createdAt < :date') + ->setParameter('memberId', $memberId) + ->setParameter('appId', $applicationInstallationId) ->setParameter('date', $date) ->getQuery() - ->execute(); + ->execute() + ; } - #[\Override] - public function countByApplicationInstallationId(Uuid $applicationInstallationId, ?LogLevel $level = null): int + /** + * Find journal items with filters and pagination. + * + * @return PaginationInterface + */ + public function findWithFilters( + ?string $memberId = null, + ?Domain $domain = null, + ?LogLevel $logLevel = null, + ?string $label = null, + int $page = 1, + int $limit = 50 + ): PaginationInterface { + $queryBuilder = $this->createFilteredQueryBuilder($memberId, $domain, $logLevel, $label); + + return $this->paginator->paginate( + $queryBuilder, + $page, + $limit, + [ + 'defaultSortFieldName' => 'j.createdAt', + 'defaultSortDirection' => 'desc', + ] + ); + } + + /** + * Get available domain URLs from journal. + * + * @return string[] + */ + public function getAvailableDomains(): array { - $qb = $this->getEntityManager()->getRepository(JournalItem::class) - ->createQueryBuilder('j') - ->select('COUNT(j.id)') - ->where('j.applicationInstallationId = :appId') - ->setParameter('appId', $applicationInstallationId); + // Join with ApplicationInstallation and then Bitrix24Account to get domain URLs + $queryBuilder = $this->entityManager->createQueryBuilder(); + $queryBuilder->select('DISTINCT b24.domainUrl') + ->from(JournalItem::class, 'j') + ->innerJoin(ApplicationInstallation::class, 'ai', 'WITH', 'ai.id = j.applicationInstallationId') + ->innerJoin(Bitrix24Account::class, 'b24', 'WITH', 'b24.id = ai.bitrix24AccountId') + ->orderBy('b24.domainUrl', 'ASC') + ; + + $results = $queryBuilder->getQuery()->getScalarResult(); + + return array_column($results, 'domainUrl'); + } + + /** + * Get available labels from journal. + * + * @return string[] + */ + public function getAvailableLabels(): array + { + $queryBuilder = $this->entityManager->createQueryBuilder(); + $queryBuilder->select('DISTINCT j.label') + ->from(JournalItem::class, 'j') + ->where('j.label IS NOT NULL') + ->orderBy('j.label', 'ASC') + ; - if (null !== $level) { - $qb->andWhere('j.level = :level') - ->setParameter('level', $level); + $results = $queryBuilder->getQuery()->getScalarResult(); + + return array_filter(array_column($results, 'label')); + } + + /** + * Create query builder with filters. + */ + private function createFilteredQueryBuilder( + ?string $memberId = null, + ?Domain $domain = null, + ?LogLevel $logLevel = null, + ?string $label = null + ): QueryBuilder { + $queryBuilder = $this->entityManager->createQueryBuilder(); + $queryBuilder->select('j') + ->from(JournalItem::class, 'j') + ; + + if (null !== $memberId) { + $queryBuilder->andWhere('j.memberId = :memberId') + ->setParameter('memberId', $memberId) + ; + } + + if (null !== $domain) { + $queryBuilder->innerJoin(ApplicationInstallation::class, 'ai', 'WITH', 'ai.id = j.applicationInstallationId') + ->innerJoin(Bitrix24Account::class, 'b24', 'WITH', 'b24.id = ai.bitrix24AccountId') + ->andWhere('b24.domainUrl = :domainUrl') + ->setParameter('domainUrl', $domain->value) + ; + } + + if (null !== $logLevel) { + $queryBuilder->andWhere('j.level = :level') + ->setParameter('level', $logLevel) + ; } - return (int) $qb->getQuery()->getSingleScalarResult(); + if (null !== $label) { + $queryBuilder->andWhere('j.label = :label') + ->setParameter('label', $label) + ; + } + + $queryBuilder->orderBy('j.createdAt', 'DESC'); + + return $queryBuilder; } } diff --git a/src/Journal/Infrastructure/JournalItemRepositoryInterface.php b/src/Journal/Infrastructure/JournalItemRepositoryInterface.php index eb1fe4d2..60365483 100644 --- a/src/Journal/Infrastructure/JournalItemRepositoryInterface.php +++ b/src/Journal/Infrastructure/JournalItemRepositoryInterface.php @@ -13,50 +13,59 @@ namespace Bitrix24\Lib\Journal\Infrastructure; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Journal\Entity\JournalItemInterface; use Bitrix24\Lib\Journal\Entity\LogLevel; use Carbon\CarbonImmutable; +use Knp\Component\Pager\Pagination\PaginationInterface; use Symfony\Component\Uid\Uuid; /** - * Journal item repository interface for SDK contract extraction + * Journal item repository interface for SDK contract extraction. */ interface JournalItemRepositoryInterface { /** - * Save journal item + * Save journal item. */ public function save(JournalItemInterface $journalItem): void; /** - * Find journal item by ID + * Find journal item by ID. */ - public function findById(Uuid $id): ?JournalItemInterface; + public function findById(Uuid $uuid): ?JournalItemInterface; /** - * Find journal items by application installation ID + * Find journal items by application installation ID. * * @return JournalItemInterface[] */ public function findByApplicationInstallationId( + string $memberId, Uuid $applicationInstallationId, - ?LogLevel $level = null, + ?LogLevel $logLevel = null, ?int $limit = null, ?int $offset = null ): array; /** - * Delete all journal items by application installation ID - */ - public function deleteByApplicationInstallationId(Uuid $applicationInstallationId): int; - - /** - * Delete journal items older than specified date + * Find journal items by member ID. + * + * @return JournalItemInterface[] */ - public function deleteOlderThan(CarbonImmutable $date): int; + public function findByMemberId( + string $memberId, + ?LogLevel $logLevel = null, + ?int $limit = null, + ?int $offset = null + ): array; /** - * Count journal items by application installation ID + * Delete journal items older than specified date. */ - public function countByApplicationInstallationId(Uuid $applicationInstallationId, ?LogLevel $level = null): int; + public function deleteOlderThan( + string $memberId, + Uuid $applicationInstallationId, + CarbonImmutable $date + ): int; } diff --git a/src/Journal/ReadModel/JournalItemReadRepository.php b/src/Journal/ReadModel/JournalItemReadRepository.php deleted file mode 100644 index 6faa9be9..00000000 --- a/src/Journal/ReadModel/JournalItemReadRepository.php +++ /dev/null @@ -1,140 +0,0 @@ - - * - * 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\Journal\ReadModel; - -use Bitrix24\Lib\Journal\Entity\JournalItem; -use Bitrix24\Lib\Journal\Entity\JournalItemInterface; -use Bitrix24\Lib\Journal\Entity\LogLevel; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\QueryBuilder; -use Knp\Component\Pager\PaginatorInterface; -use Knp\Component\Pager\Pagination\PaginationInterface; -use Symfony\Component\Uid\Uuid; - -/** - * Read model repository for journal items with filtering and pagination - */ -readonly class JournalItemReadRepository -{ - public function __construct( - private EntityManagerInterface $entityManager, - private PaginatorInterface $paginator - ) { - } - - /** - * Find journal items with filters and pagination - * - * @return PaginationInterface - */ - public function findWithFilters( - ?string $domainUrl = null, - ?LogLevel $level = null, - ?string $label = null, - int $page = 1, - int $limit = 50 - ): PaginationInterface { - $qb = $this->createFilteredQueryBuilder($domainUrl, $level, $label); - - return $this->paginator->paginate( - $qb, - $page, - $limit, - [ - 'defaultSortFieldName' => 'j.createdAt', - 'defaultSortDirection' => 'desc', - ] - ); - } - - /** - * Find journal item by ID - */ - public function findById(Uuid $id): ?JournalItemInterface - { - return $this->entityManager->getRepository(JournalItem::class)->find($id); - } - - /** - * Get available domain URLs from journal - * - * @return string[] - */ - public function getAvailableDomains(): array - { - // Join with ApplicationInstallation and then Bitrix24Account to get domain URLs - $qb = $this->entityManager->createQueryBuilder(); - $qb->select('DISTINCT b24.domainUrl') - ->from(JournalItem::class, 'j') - ->innerJoin('Bitrix24\Lib\ApplicationInstallations\Entity\ApplicationInstallation', 'ai', 'WITH', 'ai.id = j.applicationInstallationId') - ->innerJoin('Bitrix24\Lib\Bitrix24Accounts\Entity\Bitrix24Account', 'b24', 'WITH', 'b24.id = ai.bitrix24AccountId') - ->orderBy('b24.domainUrl', 'ASC'); - - $results = $qb->getQuery()->getScalarResult(); - - return array_column($results, 'domainUrl'); - } - - /** - * Get available labels from journal - * - * @return string[] - */ - public function getAvailableLabels(): array - { - $qb = $this->entityManager->createQueryBuilder(); - $qb->select('DISTINCT j.context.label') - ->from(JournalItem::class, 'j') - ->where('j.context.label IS NOT NULL') - ->orderBy('j.context.label', 'ASC'); - - $results = $qb->getQuery()->getScalarResult(); - - return array_filter(array_column($results, 'label')); - } - - /** - * Create query builder with filters - */ - private function createFilteredQueryBuilder( - ?string $domainUrl = null, - ?LogLevel $level = null, - ?string $label = null - ): QueryBuilder { - $qb = $this->entityManager->createQueryBuilder(); - $qb->select('j') - ->from(JournalItem::class, 'j'); - - if ($domainUrl) { - $qb->innerJoin('Bitrix24\Lib\ApplicationInstallations\Entity\ApplicationInstallation', 'ai', 'WITH', 'ai.id = j.applicationInstallationId') - ->innerJoin('Bitrix24\Lib\Bitrix24Accounts\Entity\Bitrix24Account', 'b24', 'WITH', 'b24.id = ai.bitrix24AccountId') - ->andWhere('b24.domainUrl = :domainUrl') - ->setParameter('domainUrl', $domainUrl); - } - - if ($level) { - $qb->andWhere('j.level = :level') - ->setParameter('level', $level); - } - - if ($label) { - $qb->andWhere('j.context.label = :label') - ->setParameter('label', $label); - } - - $qb->orderBy('j.createdAt', 'DESC'); - - return $qb; - } -} diff --git a/src/Journal/Services/JournalLogger.php b/src/Journal/Services/JournalLogger.php index 1af06ac8..3a6c024a 100644 --- a/src/Journal/Services/JournalLogger.php +++ b/src/Journal/Services/JournalLogger.php @@ -15,8 +15,8 @@ use Bitrix24\Lib\Journal\Entity\JournalItem; use Bitrix24\Lib\Journal\Entity\LogLevel; +use Bitrix24\Lib\Journal\Entity\ValueObjects\Context; use Bitrix24\Lib\Journal\Infrastructure\JournalItemRepositoryInterface; -use Bitrix24\Lib\Journal\ValueObjects\JournalContext; use Darsyn\IP\Version\Multi as IP; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -25,36 +25,40 @@ /** * PSR-3 compatible journal logger - * Writes log entries to the journal repository + * Writes log entries to the journal repository. */ class JournalLogger implements LoggerInterface { use LoggerTrait; public function __construct( + private readonly string $memberId, private readonly Uuid $applicationInstallationId, private readonly JournalItemRepositoryInterface $repository, private readonly EntityManagerInterface $entityManager - ) { - } + ) {} /** - * Logs with an arbitrary level + * Logs with an arbitrary level. * - * @param mixed $level - * @param string|\Stringable $message + * @param mixed $level * @param array $context */ #[\Override] public function log($level, string|\Stringable $message, array $context = []): void { $logLevel = $this->convertLevel($level); + $label = $context['label'] ?? 'application.log'; + $userId = $context['userId'] ?? null; $journalContext = $this->createContext($context); $journalItem = JournalItem::create( + memberId: $this->memberId, applicationInstallationId: $this->applicationInstallationId, level: $logLevel, message: (string) $message, + label: (string) $label, + userId: $userId, context: $journalContext ); @@ -63,7 +67,7 @@ public function log($level, string|\Stringable $message, array $context = []): v } /** - * Convert PSR-3 log level to LogLevel enum + * Convert PSR-3 log level to LogLevel enum. */ private function convertLevel(mixed $level): LogLevel { @@ -81,12 +85,10 @@ private function convertLevel(mixed $level): LogLevel } /** - * Create JournalContext from PSR-3 context array + * Create Context from PSR-3 context array. */ - private function createContext(array $context): JournalContext + private function createContext(array $context): Context { - $label = $context['label'] ?? 'application.log'; - $ipAddress = null; if (isset($context['ipAddress']) && is_string($context['ipAddress'])) { try { @@ -96,8 +98,7 @@ private function createContext(array $context): JournalContext } } - return new JournalContext( - label: $label, + return new Context( payload: $context['payload'] ?? null, bitrix24UserId: isset($context['bitrix24UserId']) ? (int) $context['bitrix24UserId'] : null, ipAddress: $ipAddress diff --git a/src/Journal/Services/JournalLoggerFactory.php b/src/Journal/Services/JournalLoggerFactory.php index 3b09d5ed..eb4f1e87 100644 --- a/src/Journal/Services/JournalLoggerFactory.php +++ b/src/Journal/Services/JournalLoggerFactory.php @@ -19,22 +19,22 @@ use Symfony\Component\Uid\Uuid; /** - * Factory for creating JournalLogger instances + * Factory for creating JournalLogger instances. */ readonly class JournalLoggerFactory { public function __construct( private JournalItemRepositoryInterface $repository, private EntityManagerInterface $entityManager - ) { - } + ) {} /** - * Create logger for specific application installation + * Create logger for specific application installation. */ - public function createLogger(Uuid $applicationInstallationId): LoggerInterface + public function createLogger(string $memberId, Uuid $applicationInstallationId): LoggerInterface { return new JournalLogger( + memberId: $memberId, applicationInstallationId: $applicationInstallationId, repository: $this->repository, entityManager: $this->entityManager diff --git a/tests/Contract/ApplicationSettings/Infrastructure/ApplicationSettingsItemRepositoryInterfaceContractTest.php b/tests/Contract/ApplicationSettings/Infrastructure/ApplicationSettingsItemRepositoryInterfaceContractTest.php new file mode 100644 index 00000000..1dfdd08f --- /dev/null +++ b/tests/Contract/ApplicationSettings/Infrastructure/ApplicationSettingsItemRepositoryInterfaceContractTest.php @@ -0,0 +1,382 @@ +repository = $this->createRepository(); + $this->clearRepository(); + } + + /** + * Test that save() stores a setting and it can be retrieved by ID. + */ + public function testSaveStoresSettingAndCanBeRetrievedById(): void + { + $uuidV7 = Uuid::v7(); + $applicationSettingsItem = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'test.key', + value: 'test value', + isRequired: true + ); + + $this->repository->save($applicationSettingsItem); + $this->flushChanges(); + + $retrieved = $this->repository->findById($applicationSettingsItem->getId()); + + $this->assertNotNull($retrieved); + $this->assertEquals($applicationSettingsItem->getId()->toRfc4122(), $retrieved->getId()->toRfc4122()); + $this->assertEquals('test.key', $retrieved->getKey()); + $this->assertEquals('test value', $retrieved->getValue()); + $this->assertTrue($retrieved->isRequired()); + } + + /** + * Test that findById() returns null for non-existent ID. + */ + public function testFindByIdReturnsNullForNonExistentId(): void + { + $uuidV7 = Uuid::v7(); + + $result = $this->repository->findById($uuidV7); + + $this->assertNull($result); + } + + /** + * Test that findById() does not return soft-deleted settings. + */ + public function testFindByIdDoesNotReturnDeletedSettings(): void + { + $uuidV7 = Uuid::v7(); + $applicationSettingsItem = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'test.key', + value: 'test value', + isRequired: false + ); + + $this->repository->save($applicationSettingsItem); + $this->flushChanges(); + $applicationSettingsItem->markAsDeleted(); + $this->repository->save($applicationSettingsItem); + $this->flushChanges(); + + $result = $this->repository->findById($applicationSettingsItem->getId()); + + $this->assertNull($result); + } + + /** + * Test that findAllForInstallation() returns all active settings for an installation. + */ + public function testFindAllForInstallationReturnsAllActiveSettings(): void + { + $uuidV7 = Uuid::v7(); + $otherInstallationId = Uuid::v7(); + + $setting1 = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'key.one', + value: 'value1', + isRequired: true + ); + + $setting2 = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'key.two', + value: 'value2', + isRequired: false + ); + + $otherSetting = new ApplicationSettingsItem( + applicationInstallationId: $otherInstallationId, + key: 'other.key', + value: 'other value', + isRequired: false + ); + + $this->repository->save($setting1); + $this->flushChanges(); + $this->repository->save($setting2); + $this->flushChanges(); + $this->repository->save($otherSetting); + $this->flushChanges(); + + $results = $this->repository->findAllForInstallation($uuidV7); + + $this->assertCount(2, $results); + $this->assertContainsOnlyInstancesOf(ApplicationSettingsItemInterface::class, $results); + } + + /** + * Test that findAllForInstallation() excludes soft-deleted settings. + */ + public function testFindAllForInstallationExcludesDeletedSettings(): void + { + $uuidV7 = Uuid::v7(); + + $activeSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'active.key', + value: 'active value', + isRequired: true + ); + + $deletedSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'deleted.key', + value: 'deleted value', + isRequired: false + ); + + $this->repository->save($activeSetting); + $this->flushChanges(); + $this->repository->save($deletedSetting); + $this->flushChanges(); + + $deletedSetting->markAsDeleted(); + $this->repository->save($deletedSetting); + $this->flushChanges(); + + $results = $this->repository->findAllForInstallation($uuidV7); + + $this->assertCount(1, $results); + $this->assertEquals('active.key', $results[0]->getKey()); + } + + /** + * Test that findAllForInstallationByKey() returns settings filtered by key. + */ + public function testFindAllForInstallationByKeyReturnsSettingsFilteredByKey(): void + { + $uuidV7 = Uuid::v7(); + + // Global setting + $globalSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'theme', + value: 'light', + isRequired: false + ); + + // Personal setting for user 123 + $personalSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'theme', + value: 'dark', + isRequired: false, + b24UserId: 123 + ); + + // Different key - should not be returned + $differentKeySetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'language', + value: 'en', + isRequired: true + ); + + $this->repository->save($globalSetting); + $this->flushChanges(); + $this->repository->save($personalSetting); + $this->flushChanges(); + $this->repository->save($differentKeySetting); + $this->flushChanges(); + + $results = $this->repository->findAllForInstallationByKey($uuidV7, 'theme'); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertEquals('theme', $result->getKey()); + } + } + + /** + * Test that findAllForInstallationByKey() excludes soft-deleted settings. + */ + public function testFindAllForInstallationByKeyExcludesDeletedSettings(): void + { + $uuidV7 = Uuid::v7(); + + $activeSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'config', + value: 'active', + isRequired: false + ); + + $deletedSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'config', + value: 'deleted', + isRequired: false, + b24UserId: 456 + ); + + $this->repository->save($activeSetting); + $this->flushChanges(); + $this->repository->save($deletedSetting); + $this->flushChanges(); + + $deletedSetting->markAsDeleted(); + $this->repository->save($deletedSetting); + $this->flushChanges(); + + $results = $this->repository->findAllForInstallationByKey($uuidV7, 'config'); + + $this->assertCount(1, $results); + $this->assertEquals('active', $results[0]->getValue()); + } + + /** + * Test that findAllForInstallationByKey() returns empty array for non-existent key. + */ + public function testFindAllForInstallationByKeyReturnsEmptyArrayForNonExistentKey(): void + { + $uuidV7 = Uuid::v7(); + + $applicationSettingsItem = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'existing.key', + value: 'value', + isRequired: false + ); + + $this->repository->save($applicationSettingsItem); + $this->flushChanges(); + + $results = $this->repository->findAllForInstallationByKey($uuidV7, 'non.existent.key'); + + $this->assertIsArray($results); + $this->assertEmpty($results); + } + + /** + * Test that save() updates an existing setting when called twice. + */ + public function testSaveUpdatesExistingSetting(): void + { + $uuidV7 = Uuid::v7(); + $applicationSettingsItem = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'updateable.key', + value: 'initial value', + isRequired: false + ); + + $this->repository->save($applicationSettingsItem); + $this->flushChanges(); + + $applicationSettingsItem->updateValue('updated value', 100); + $this->repository->save($applicationSettingsItem); + $this->flushChanges(); + + $retrieved = $this->repository->findById($applicationSettingsItem->getId()); + + $this->assertNotNull($retrieved); + $this->assertEquals('updated value', $retrieved->getValue()); + } + + /** + * Test that repository handles different scopes correctly. + */ + public function testRepositoryHandlesDifferentScopes(): void + { + $uuidV7 = Uuid::v7(); + + // Global + $globalSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'multi.scope', + value: 'global', + isRequired: false + ); + + // Personal + $personalSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'multi.scope', + value: 'personal', + isRequired: false, + b24UserId: 123 + ); + + // Departmental + $departmentalSetting = new ApplicationSettingsItem( + applicationInstallationId: $uuidV7, + key: 'multi.scope', + value: 'departmental', + isRequired: false, + b24DepartmentId: 456 + ); + + $this->repository->save($globalSetting); + $this->flushChanges(); + $this->repository->save($personalSetting); + $this->flushChanges(); + $this->repository->save($departmentalSetting); + $this->flushChanges(); + + $results = $this->repository->findAllForInstallationByKey($uuidV7, 'multi.scope'); + + $this->assertCount(3, $results); + + // Verify each scope is present + $values = array_map(fn($s): string => $s->getValue(), $results); + $this->assertContains('global', $values); + $this->assertContains('personal', $values); + $this->assertContains('departmental', $values); + } +} 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/Install/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php index 45846d33..55f35c4d 100644 --- a/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php +++ b/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php @@ -16,7 +16,7 @@ use Bitrix24\Lib\Bitrix24Accounts; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Services\Flusher; use Bitrix24\Lib\ApplicationInstallations; use Bitrix24\Lib\Tests\EntityManagerFactory; 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..cd67f288 100644 --- a/tests/Functional/ApplicationInstallations/UseCase/OnAppInstall/HandlerTest.php +++ b/tests/Functional/ApplicationInstallations/UseCase/OnAppInstall/HandlerTest.php @@ -16,7 +16,7 @@ use Bitrix24\Lib\Bitrix24Accounts; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Services\Flusher; use Bitrix24\Lib\ApplicationInstallations; use Bitrix24\Lib\Tests\EntityManagerFactory; @@ -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/Uninstall/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/Uninstall/HandlerTest.php index 27280532..f46e43ae 100644 --- a/tests/Functional/ApplicationInstallations/UseCase/Uninstall/HandlerTest.php +++ b/tests/Functional/ApplicationInstallations/UseCase/Uninstall/HandlerTest.php @@ -16,7 +16,7 @@ use Bitrix24\Lib\Bitrix24Accounts; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Services\Flusher; use Bitrix24\Lib\ApplicationInstallations; use Bitrix24\Lib\Tests\EntityManagerFactory; 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/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryContractTest.php b/tests/Functional/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryContractTest.php new file mode 100644 index 00000000..74326009 --- /dev/null +++ b/tests/Functional/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryContractTest.php @@ -0,0 +1,49 @@ +flush(); + } + + #[\Override] + protected function clearRepository(): void + { + // Clear entity manager between tests + EntityManagerFactory::get()->clear(); + } + + #[\Override] + protected function tearDown(): void + { + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + parent::tearDown(); + } +} diff --git a/tests/Functional/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryTest.php b/tests/Functional/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryTest.php new file mode 100644 index 00000000..4991dca0 --- /dev/null +++ b/tests/Functional/ApplicationSettings/Infrastructure/Doctrine/ApplicationSettingsItemRepositoryTest.php @@ -0,0 +1,169 @@ +repository = new ApplicationSettingsItemRepository($entityManager); + } + + #[\Override] + protected function tearDown(): void + { + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + } + + /** + * Test Doctrine-specific unique constraint on (installation_id, key, user_id, department_id). + * + * Note: This test verifies that the unique constraint is enforced at the database level. + * PostgreSQL treats NULL as unique values (NULL != NULL), so for global settings + * (where user_id and department_id are NULL) multiple records can exist with the same key. + * This is expected behavior. + */ + public function testUniqueConstraintOnApplicationInstallationIdAndKeyAndScope(): void + { + // This test is intentionally simplified as the unique constraint is primarily + // enforced at the application level in the Create use case handler. + // The database constraint serves as a safety net for personal and departmental settings. + + $this->markTestSkipped( + 'Unique constraint behavior with NULL values in PostgreSQL is complex. ' . + 'Application-level validation is primary, database constraint is secondary. ' . + 'See Create/Handler tests for application-level uniqueness validation.' + ); + } + + /** + * Test that different scopes with same key don't violate unique constraint. + */ + public function testDifferentScopesWithSameKeyAreAllowed(): void + { + $uuidV7 = Uuid::v7(); + + $globalSetting = new ApplicationSettingsItem( + $uuidV7, + 'shared.key', + 'global_value', + false + ); + + $personalSetting = new ApplicationSettingsItem( + $uuidV7, + 'shared.key', + 'personal_value', + false, + b24UserId: 123 + ); + + $departmentalSetting = new ApplicationSettingsItem( + $uuidV7, + 'shared.key', + 'departmental_value', + false, + b24DepartmentId: 456 + ); + + $this->repository->save($globalSetting); + $this->repository->save($personalSetting); + $this->repository->save($departmentalSetting); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // All three should be saved successfully + $allSettings = $this->repository->findAllForInstallationByKey($uuidV7, 'shared.key'); + + $this->assertCount(3, $allSettings); + } + + /** + * Test that entity manager persistence and flushing works correctly. + */ + public function testPersistenceAcrossFlushAndClear(): void + { + $uuidV7 = Uuid::v7(); + + $applicationSettingsItem = new ApplicationSettingsItem( + $uuidV7, + 'persistence.test', + 'test_value', + false + ); + + $uuid = $applicationSettingsItem->getId(); + + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // After clear, entity should still be retrievable from database + $retrieved = $this->repository->findById($uuid); + + $this->assertNotNull($retrieved); + $this->assertEquals('persistence.test', $retrieved->getKey()); + $this->assertEquals('test_value', $retrieved->getValue()); + } + + /** + * Test that soft-deleted settings persist in database but are not returned by queries. + */ + public function testSoftDeletePersistsInDatabase(): void + { + $uuidV7 = Uuid::v7(); + + $applicationSettingsItem = new ApplicationSettingsItem( + $uuidV7, + 'to.soft.delete', + 'value', + false + ); + + $uuid = $applicationSettingsItem->getId(); + + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + + // Soft delete + $applicationSettingsItem->markAsDeleted(); + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // Should not be returned by findById (filters deleted) + $retrieved = $this->repository->findById($uuid); + $this->assertNull($retrieved); + + // Verify it still exists in database using DQL (bypasses soft-delete filtering) + $entityManager = EntityManagerFactory::get(); + $dql = 'SELECT COUNT(s.id) FROM Bitrix24\Lib\ApplicationSettings\Entity\ApplicationSettingsItem s WHERE s.id = :id'; + $query = $entityManager->createQuery($dql); + $query->setParameter('id', $uuid); + + $count = $query->getSingleScalarResult(); + + $this->assertEquals(1, $count, 'Soft-deleted setting should still exist in database'); + } +} diff --git a/tests/Functional/ApplicationSettings/UseCase/Create/HandlerTest.php b/tests/Functional/ApplicationSettings/UseCase/Create/HandlerTest.php new file mode 100644 index 00000000..2479a2ae --- /dev/null +++ b/tests/Functional/ApplicationSettings/UseCase/Create/HandlerTest.php @@ -0,0 +1,165 @@ +repository = new ApplicationSettingsItemRepository($entityManager); + $flusher = new Flusher($entityManager, $eventDispatcher); + + $this->handler = new Handler( + $this->repository, + $flusher, + new NullLogger() + ); + } + + public function testCanCreateNewSetting(): void + { + $uuidV7 = Uuid::v7(); + $command = new Command( + $uuidV7, + 'new.setting', + '{"test":"value"}' + ); + + $this->handler->handle($command); + + EntityManagerFactory::get()->clear(); + + // Find created setting + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $setting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'new.setting' && $allSetting->isGlobal()) { + $setting = $allSetting; + break; + } + } + + $this->assertNotNull($setting); + $this->assertEquals('new.setting', $setting->getKey()); + $this->assertEquals('{"test":"value"}', $setting->getValue()); + } + + public function testThrowsExceptionWhenCreatingDuplicateSetting(): void + { + $uuidV7 = Uuid::v7(); + + // Create initial setting + $createCommand = new Command( + $uuidV7, + 'duplicate.test', + 'initial_value' + ); + $this->handler->handle($createCommand); + EntityManagerFactory::get()->clear(); + + // Attempt to create the same setting again should throw exception + $duplicateCommand = new Command( + $uuidV7, + 'duplicate.test', + 'another_value' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Setting with key "duplicate.test" already exists.'); + + $this->handler->handle($duplicateCommand); + } + + public function testMultipleSettingsForSameInstallation(): void + { + $uuidV7 = Uuid::v7(); + + $command1 = new Command($uuidV7, 'setting.one', 'value1'); + $command2 = new Command($uuidV7, 'setting.two', 'value2'); + + $this->handler->handle($command1); + $this->handler->handle($command2); + EntityManagerFactory::get()->clear(); + + $settings = $this->repository->findAllForInstallation($uuidV7); + + $this->assertCount(2, $settings); + } + + public function testCanCreatePersonalSetting(): void + { + $uuidV7 = Uuid::v7(); + $command = new Command( + applicationInstallationId: $uuidV7, + key: 'personal.setting', + value: 'user_value', + b24UserId: 123 + ); + + $this->handler->handle($command); + EntityManagerFactory::get()->clear(); + + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $setting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'personal.setting' && $allSetting->isPersonal()) { + $setting = $allSetting; + break; + } + } + + $this->assertNotNull($setting); + $this->assertEquals(123, $setting->getB24UserId()); + } + + public function testCanCreateDepartmentalSetting(): void + { + $uuidV7 = Uuid::v7(); + $command = new Command( + applicationInstallationId: $uuidV7, + key: 'dept.setting', + value: 'dept_value', + b24DepartmentId: 456 + ); + + $this->handler->handle($command); + EntityManagerFactory::get()->clear(); + + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $setting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'dept.setting' && $allSetting->isDepartmental()) { + $setting = $allSetting; + break; + } + } + + $this->assertNotNull($setting); + $this->assertEquals(456, $setting->getB24DepartmentId()); + } +} diff --git a/tests/Functional/ApplicationSettings/UseCase/Delete/HandlerTest.php b/tests/Functional/ApplicationSettings/UseCase/Delete/HandlerTest.php new file mode 100644 index 00000000..6b3f0db5 --- /dev/null +++ b/tests/Functional/ApplicationSettings/UseCase/Delete/HandlerTest.php @@ -0,0 +1,101 @@ +repository = new ApplicationSettingsItemRepository($entityManager); + $flusher = new Flusher($entityManager, $eventDispatcher); + + $this->handler = new Handler( + $this->repository, + $flusher, + new NullLogger() + ); + } + + public function testCanDeleteExistingSetting(): void + { + $uuidV7 = Uuid::v7(); + $applicationSettingsItem = new ApplicationSettingsItem( + $uuidV7, + 'delete.test', + 'value', + false + ); + + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + $command = new Command($uuidV7, 'delete.test'); + $this->handler->handle($command); + + EntityManagerFactory::get()->clear(); + + // Setting should not be found by regular find methods (soft-deleted) + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $deletedSetting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'delete.test' && $allSetting->isGlobal()) { + $deletedSetting = $allSetting; + break; + } + } + + $this->assertNull($deletedSetting); + + // But should still exist in database with deleted status + $settingById = EntityManagerFactory::get() + ->createQueryBuilder() + ->select('s') + ->from(\Bitrix24\Lib\ApplicationSettings\Entity\ApplicationSettingsItem::class, 's') + ->where('s.applicationInstallationId = :appId') + ->andWhere('s.key = :key') + ->setParameter('appId', $uuidV7) + ->setParameter('key', 'delete.test') + ->getQuery() + ->getOneOrNullResult(); + + $this->assertNotNull($settingById); + $this->assertFalse($settingById->isActive()); + } + + public function testThrowsExceptionForNonExistentSetting(): void + { + $command = new Command(Uuid::v7(), 'non.existent'); + + $this->expectException(ItemNotFoundException::class); + $this->expectExceptionMessage('Setting with key "non.existent" not found.'); + + $this->handler->handle($command); + } +} diff --git a/tests/Functional/ApplicationSettings/UseCase/OnApplicationDelete/HandlerTest.php b/tests/Functional/ApplicationSettings/UseCase/OnApplicationDelete/HandlerTest.php new file mode 100644 index 00000000..32c6721b --- /dev/null +++ b/tests/Functional/ApplicationSettings/UseCase/OnApplicationDelete/HandlerTest.php @@ -0,0 +1,188 @@ +repository = new ApplicationSettingsItemRepository($entityManager); + $flusher = new Flusher($entityManager, $eventDispatcher); + + $this->handler = new Handler( + $this->repository, + $flusher, + new NullLogger() + ); + } + + public function testCanSoftDeleteAllSettingsForInstallation(): void + { + $uuidV7 = Uuid::v7(); + + // Create multiple settings + $setting1 = new ApplicationSettingsItem( + $uuidV7, + 'setting.one', + 'value1', + false + ); + + $setting2 = new ApplicationSettingsItem( + $uuidV7, + 'setting.two', + 'value2', + false + ); + + $setting3 = new ApplicationSettingsItem( + $uuidV7, + 'setting.three', + 'value3', + true // required + ); + + $this->repository->save($setting1); + $this->repository->save($setting2); + $this->repository->save($setting3); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // Execute soft-delete + $command = new Command($uuidV7); + $this->handler->handle($command); + + EntityManagerFactory::get()->clear(); + + // Settings should not be found by regular find methods + $activeSettings = $this->repository->findAllForInstallation($uuidV7); + $this->assertCount(0, $activeSettings); + + // But should still exist in database with deleted status + $allSettings = EntityManagerFactory::get() + ->createQueryBuilder() + ->select('s') + ->from(ApplicationSettingsItem::class, 's') + ->where('s.applicationInstallationId = :appId') + ->setParameter('appId', $uuidV7) + ->getQuery() + ->getResult(); + + $this->assertCount(3, $allSettings); + + foreach ($allSettings as $allSetting) { + $this->assertFalse($allSetting->isActive()); + } + } + + public function testDoesNotAffectOtherInstallations(): void + { + $uuidV7 = Uuid::v7(); + $installation2 = Uuid::v7(); + + // Create settings for two installations + $setting1 = new ApplicationSettingsItem( + $uuidV7, + 'setting', + 'value1', + false + ); + + $setting2 = new ApplicationSettingsItem( + $installation2, + 'setting', + 'value2', + false + ); + + $this->repository->save($setting1); + $this->repository->save($setting2); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // Delete only first installation settings + $command = new Command($uuidV7); + $this->handler->handle($command); + + EntityManagerFactory::get()->clear(); + + // First installation settings should be soft-deleted + $installation1Settings = $this->repository->findAllForInstallation($uuidV7); + $this->assertCount(0, $installation1Settings); + + // Second installation settings should remain active + $installation2Settings = $this->repository->findAllForInstallation($installation2); + $this->assertCount(1, $installation2Settings); + $this->assertTrue($installation2Settings[0]->isActive()); + } + + public function testOnlyDeletesActiveSettings(): void + { + $uuidV7 = Uuid::v7(); + + // Create active and already deleted settings + $activeSetting = new ApplicationSettingsItem( + $uuidV7, + 'active', + 'value', + false + ); + + $deletedSetting = new ApplicationSettingsItem( + $uuidV7, + 'deleted', + 'value', + false, + null, + null, + null, + ApplicationSettingStatus::Deleted + ); + + $this->repository->save($activeSetting); + $this->repository->save($deletedSetting); + EntityManagerFactory::get()->flush(); + + $initialUpdatedAt = $deletedSetting->getUpdatedAt(); + EntityManagerFactory::get()->clear(); + + // Execute soft-delete + $command = new Command($uuidV7); + $this->handler->handle($command); + + EntityManagerFactory::get()->clear(); + + // Load the already deleted setting + $reloadedDeleted = EntityManagerFactory::get() + ->find(ApplicationSettingsItem::class, $deletedSetting->getId()); + + // updatedAt should not have changed for already deleted setting + $this->assertEquals($initialUpdatedAt->format('Y-m-d H:i:s'), $reloadedDeleted->getUpdatedAt()->format('Y-m-d H:i:s')); + } +} diff --git a/tests/Functional/ApplicationSettings/UseCase/Update/HandlerTest.php b/tests/Functional/ApplicationSettings/UseCase/Update/HandlerTest.php new file mode 100644 index 00000000..01faa279 --- /dev/null +++ b/tests/Functional/ApplicationSettings/UseCase/Update/HandlerTest.php @@ -0,0 +1,192 @@ +repository = new ApplicationSettingsItemRepository($entityManager); + $flusher = new Flusher($entityManager, $eventDispatcher); + + $this->handler = new Handler( + $this->repository, + $flusher, + new NullLogger() + ); + } + + public function testCanUpdateExistingSetting(): void + { + $uuidV7 = Uuid::v7(); + + // Create initial setting + $applicationSettingsItem = new ApplicationSettingsItem( + $uuidV7, + 'update.test', + 'initial_value', + false, + null, + null, + null + ); + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // Update the setting + $updateCommand = new Command( + $uuidV7, + 'update.test', + 'updated_value', + null, + null, + 123 + ); + $this->handler->handle($updateCommand); + EntityManagerFactory::get()->clear(); + + // Verify update + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $updatedSetting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'update.test' && $allSetting->isGlobal()) { + $updatedSetting = $allSetting; + break; + } + } + + $this->assertNotNull($updatedSetting); + $this->assertEquals('updated_value', $updatedSetting->getValue()); + } + + public function testThrowsExceptionWhenUpdatingNonExistentSetting(): void + { + $uuidV7 = Uuid::v7(); + + $updateCommand = new Command( + $uuidV7, + 'non.existent', + 'some_value' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Setting with key "non.existent" does not exist for this scope'); + + $this->handler->handle($updateCommand); + } + + public function testCanUpdatePersonalSetting(): void + { + $uuidV7 = Uuid::v7(); + + // Create initial personal setting + $applicationSettingsItem = new ApplicationSettingsItem( + $uuidV7, + 'personal.test', + 'user_value', + false, + 123, + null, + null + ); + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // Update personal setting + $updateCommand = new Command( + applicationInstallationId: $uuidV7, + key: 'personal.test', + value: 'new_user_value', + b24UserId: 123, + b24DepartmentId: null, + changedByBitrix24UserId: 456 + ); + $this->handler->handle($updateCommand); + EntityManagerFactory::get()->clear(); + + // Verify update + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $updatedSetting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'personal.test' && $allSetting->isPersonal() && $allSetting->getB24UserId() === 123) { + $updatedSetting = $allSetting; + break; + } + } + + $this->assertNotNull($updatedSetting); + $this->assertEquals('new_user_value', $updatedSetting->getValue()); + } + + public function testCanUpdateDepartmentalSetting(): void + { + $uuidV7 = Uuid::v7(); + + // Create initial departmental setting + $applicationSettingsItem = new ApplicationSettingsItem( + $uuidV7, + 'dept.test', + 'dept_value', + false, + null, + 456, + null + ); + $this->repository->save($applicationSettingsItem); + EntityManagerFactory::get()->flush(); + EntityManagerFactory::get()->clear(); + + // Update departmental setting + $updateCommand = new Command( + applicationInstallationId: $uuidV7, + key: 'dept.test', + value: 'new_dept_value', + b24UserId: null, + b24DepartmentId: 456, + changedByBitrix24UserId: 789 + ); + $this->handler->handle($updateCommand); + EntityManagerFactory::get()->clear(); + + // Verify update + $allSettings = $this->repository->findAllForInstallation($uuidV7); + $updatedSetting = null; + foreach ($allSettings as $allSetting) { + if ($allSetting->getKey() === 'dept.test' && $allSetting->isDepartmental() && $allSetting->getB24DepartmentId() === 456) { + $updatedSetting = $allSetting; + break; + } + } + + $this->assertNotNull($updatedSetting); + $this->assertEquals('new_dept_value', $updatedSetting->getValue()); + } +} diff --git a/tests/Functional/Bitrix24Accounts/Builders/Bitrix24AccountBuilder.php b/tests/Functional/Bitrix24Accounts/Builders/Bitrix24AccountBuilder.php index 77b6bed4..a571be6f 100644 --- a/tests/Functional/Bitrix24Accounts/Builders/Bitrix24AccountBuilder.php +++ b/tests/Functional/Bitrix24Accounts/Builders/Bitrix24AccountBuilder.php @@ -56,7 +56,7 @@ public function __construct() $this->isBitrix24UserAdmin = true; $this->memberId = Uuid::v4()->toRfc4122(); $this->domainUrl = Uuid::v4()->toRfc4122().'-example.com'; - $this->authToken = new AuthToken('old_1', 'old_2', 3600); + $this->authToken = new AuthToken('old_1', 'old_2', 3600,time() + 60 * 60 * 24); $this->applicationVersion = 1; $this->applicationScope = new Scope(); } diff --git a/tests/Functional/Bitrix24Accounts/UseCase/ChangeDomainUrl/HandlerTest.php b/tests/Functional/Bitrix24Accounts/UseCase/ChangeDomainUrl/HandlerTest.php index 01e2d5ad..7fca2806 100644 --- a/tests/Functional/Bitrix24Accounts/UseCase/ChangeDomainUrl/HandlerTest.php +++ b/tests/Functional/Bitrix24Accounts/UseCase/ChangeDomainUrl/HandlerTest.php @@ -29,7 +29,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\Uid\Uuid; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; /** * @internal diff --git a/tests/Functional/Bitrix24Accounts/UseCase/InstallFinish/HandlerTest.php b/tests/Functional/Bitrix24Accounts/UseCase/InstallFinish/HandlerTest.php index 5d84d7e2..00af09b4 100644 --- a/tests/Functional/Bitrix24Accounts/UseCase/InstallFinish/HandlerTest.php +++ b/tests/Functional/Bitrix24Accounts/UseCase/InstallFinish/HandlerTest.php @@ -30,7 +30,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\Uid\Uuid; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; /** * @internal diff --git a/tests/Functional/Bitrix24Accounts/UseCase/InstallStart/HandlerTest.php b/tests/Functional/Bitrix24Accounts/UseCase/InstallStart/HandlerTest.php index d179e460..45e86232 100644 --- a/tests/Functional/Bitrix24Accounts/UseCase/InstallStart/HandlerTest.php +++ b/tests/Functional/Bitrix24Accounts/UseCase/InstallStart/HandlerTest.php @@ -33,7 +33,7 @@ use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Stopwatch\Stopwatch; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; /** * @internal 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/Helpers/ApplicationSettings/ApplicationSettingsItemInMemoryRepository.php b/tests/Helpers/ApplicationSettings/ApplicationSettingsItemInMemoryRepository.php new file mode 100644 index 00000000..f6f4ed7a --- /dev/null +++ b/tests/Helpers/ApplicationSettings/ApplicationSettingsItemInMemoryRepository.php @@ -0,0 +1,91 @@ + */ + private array $settings = []; + + #[\Override] + public function save(ApplicationSettingsItemInterface $applicationSettingsItem): void + { + $this->settings[$applicationSettingsItem->getId()->toRfc4122()] = $applicationSettingsItem; + } + + #[\Override] + public function delete(ApplicationSettingsItemInterface $applicationSettingsItem): void + { + unset($this->settings[$applicationSettingsItem->getId()->toRfc4122()]); + } + + #[\Override] + public function findById(Uuid $uuid): ?ApplicationSettingsItemInterface + { + foreach ($this->settings as $setting) { + if ($setting->getId()->toRfc4122() === $uuid->toRfc4122() && $setting->isActive()) { + return $setting; + } + } + + return null; + } + + #[\Override] + public function findAllForInstallation(Uuid $uuid): array + { + $result = []; + foreach ($this->settings as $setting) { + if ($setting->getApplicationInstallationId()->toRfc4122() === $uuid->toRfc4122() + && $setting->isActive() + ) { + $result[] = $setting; + } + } + + return $result; + } + + #[\Override] + public function findAllForInstallationByKey(Uuid $uuid, string $key): array + { + $result = []; + foreach ($this->settings as $setting) { + if ($setting->getApplicationInstallationId()->toRfc4122() === $uuid->toRfc4122() + && $setting->getKey() === $key + && $setting->isActive() + ) { + $result[] = $setting; + } + } + + return $result; + } + + /** + * Clear all settings (for testing). + */ + public function clear(): void + { + $this->settings = []; + } + + /** + * Get all settings including deleted (for testing). + * + * @return ApplicationSettingsItemInterface[] + */ + public function getAllIncludingDeleted(): array + { + return array_values($this->settings); + } +} diff --git a/tests/Unit/ApplicationInstallations/UseCase/Install/CommandTest.php b/tests/Unit/ApplicationInstallations/UseCase/Install/CommandTest.php index 0afee06e..c953274c 100644 --- a/tests/Unit/ApplicationInstallations/UseCase/Install/CommandTest.php +++ b/tests/Unit/ApplicationInstallations/UseCase/Install/CommandTest.php @@ -5,7 +5,7 @@ namespace Bitrix24\Lib\Tests\Unit\ApplicationInstallations\UseCase\Install; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder; use Bitrix24\SDK\Application\ApplicationStatus; use Bitrix24\SDK\Application\PortalLicenseFamily; 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 d5302749..8808d284 100644 --- a/tests/Unit/ApplicationInstallations/UseCase/OnAppInstall/CommandTest.php +++ b/tests/Unit/ApplicationInstallations/UseCase/OnAppInstall/CommandTest.php @@ -5,7 +5,7 @@ namespace Bitrix24\Lib\Tests\Unit\ApplicationInstallations\UseCase\OnAppInstall; use Bitrix24\Lib\ApplicationInstallations\UseCase\OnAppInstall\Command; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Tests\Functional\ApplicationInstallations\Builders\ApplicationInstallationBuilder; use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder; use Bitrix24\SDK\Application\ApplicationStatus; @@ -13,6 +13,7 @@ use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus; use Bitrix24\SDK\Application\PortalLicenseFamily; use Bitrix24\SDK\Core\Credentials\Scope; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -31,7 +32,7 @@ public function testValidCommand( string $memberId, Domain $domain, string $applicationToken, - string $applicationStatus, + ApplicationStatus $applicationStatus, ?string $expectedException, ): void { @@ -54,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')) @@ -85,7 +86,7 @@ public static function dataForCommand(): \Generator new Domain($bitrix24AccountBuilder->getDomainUrl()), $applicationToken, $applicationStatus, - \InvalidArgumentException::class, + InvalidArgumentException::class, ]; // Empty applicationToken @@ -94,16 +95,7 @@ public static function dataForCommand(): \Generator new Domain($bitrix24AccountBuilder->getDomainUrl()), '', $applicationStatus, - \InvalidArgumentException::class, - ]; - - // Empty applicationStatus - yield 'emptyApplicationStatus' => [ - $bitrix24AccountBuilder->getMemberId(), - new Domain($bitrix24AccountBuilder->getDomainUrl()), - $applicationToken, - '', - \InvalidArgumentException::class, + InvalidArgumentException::class, ]; } -} \ No newline at end of file +} diff --git a/tests/Unit/ApplicationInstallations/UseCase/Uninstall/CommandTest.php b/tests/Unit/ApplicationInstallations/UseCase/Uninstall/CommandTest.php index 75cc13f6..4b573a06 100644 --- a/tests/Unit/ApplicationInstallations/UseCase/Uninstall/CommandTest.php +++ b/tests/Unit/ApplicationInstallations/UseCase/Uninstall/CommandTest.php @@ -5,10 +5,11 @@ namespace Bitrix24\Lib\Tests\Unit\ApplicationInstallations\UseCase\Uninstall; use Bitrix24\Lib\ApplicationInstallations\UseCase\Uninstall\Command; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder; use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus; use Bitrix24\SDK\Core\Credentials\Scope; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -69,7 +70,7 @@ public static function dataForCommand(): \Generator '', new Domain($bitrix24AccountBuilder->getDomainUrl()), $applicationToken, - \InvalidArgumentException::class, + InvalidArgumentException::class, ]; // Empty applicationToken @@ -77,7 +78,7 @@ public static function dataForCommand(): \Generator $bitrix24AccountBuilder->getMemberId(), new Domain($bitrix24AccountBuilder->getDomainUrl()), '', - \InvalidArgumentException::class, + InvalidArgumentException::class, ]; } } \ No newline at end of file diff --git a/tests/Unit/ApplicationSettings/Entity/ApplicationSettingsItemTest.php b/tests/Unit/ApplicationSettings/Entity/ApplicationSettingsItemTest.php new file mode 100644 index 00000000..670137ac --- /dev/null +++ b/tests/Unit/ApplicationSettings/Entity/ApplicationSettingsItemTest.php @@ -0,0 +1,291 @@ +assertInstanceOf(Uuid::class, $applicationSettingsItem->getId()); + $this->assertEquals($uuidV7, $applicationSettingsItem->getApplicationInstallationId()); + $this->assertEquals($key, $applicationSettingsItem->getKey()); + $this->assertEquals($value, $applicationSettingsItem->getValue()); + $this->assertNull($applicationSettingsItem->getB24UserId()); + $this->assertNull($applicationSettingsItem->getB24DepartmentId()); + $this->assertTrue($applicationSettingsItem->isGlobal()); + $this->assertFalse($applicationSettingsItem->isPersonal()); + $this->assertFalse($applicationSettingsItem->isDepartmental()); + $this->assertFalse($applicationSettingsItem->isRequired()); + } + + public function testCanCreatePersonalSetting(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'user.preference', + 'dark_mode', + false, // isRequired + 123 // b24UserId + ); + + $this->assertEquals(123, $applicationSettingsItem->getB24UserId()); + $this->assertNull($applicationSettingsItem->getB24DepartmentId()); + $this->assertFalse($applicationSettingsItem->isGlobal()); + $this->assertTrue($applicationSettingsItem->isPersonal()); + $this->assertFalse($applicationSettingsItem->isDepartmental()); + } + + public function testCanCreateDepartmentalSetting(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'dept.config', + 'enabled', + false, // isRequired + null, // No user ID + 456 // b24DepartmentId + ); + + $this->assertNull($applicationSettingsItem->getB24UserId()); + $this->assertEquals(456, $applicationSettingsItem->getB24DepartmentId()); + $this->assertFalse($applicationSettingsItem->isGlobal()); + $this->assertFalse($applicationSettingsItem->isPersonal()); + $this->assertTrue($applicationSettingsItem->isDepartmental()); + } + + public function testCannotCreateSettingWithBothUserAndDepartment(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Setting cannot be both personal and departmental'); + + new ApplicationSettingsItem( + Uuid::v7(), + 'invalid.setting', + 'value', + false, // isRequired + 123, // userId + 456 // departmentId - both set, should fail + ); + } + + public function testCanUpdateValue(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'test.key', + 'initial.value', + false + ); + + $initialUpdatedAt = $applicationSettingsItem->getUpdatedAt(); + usleep(1000); + + $applicationSettingsItem->updateValue('new.value'); + + $this->assertEquals('new.value', $applicationSettingsItem->getValue()); + $this->assertGreaterThan($initialUpdatedAt, $applicationSettingsItem->getUpdatedAt()); + } + + #[DataProvider('invalidKeyProvider')] + public function testThrowsExceptionForInvalidKey(string $invalidKey): void + { + $this->expectException(InvalidArgumentException::class); + + new ApplicationSettingsItem( + Uuid::v7(), + $invalidKey, + 'value', + false + ); + } + + /** + * @return array> + */ + public static function invalidKeyProvider(): array + { + return [ + 'empty string' => [''], + 'whitespace only' => [' '], + 'too long' => [str_repeat('a', 256)], + 'with uppercase' => ['Test.Key'], + 'with numbers' => ['test.key.123'], + 'with underscore' => ['test_key'], + 'with hyphen' => ['test-key'], + 'spaces' => ['invalid key'], + 'special chars' => ['key@#$%'], + ]; + } + + #[DataProvider('validKeyProvider')] + public function testAcceptsValidKeys(string $validKey): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + $validKey, + 'value', + false + ); + + $this->assertEquals($validKey, $applicationSettingsItem->getKey()); + } + + /** + * @return array> + */ + public static function validKeyProvider(): array + { + return [ + 'simple lowercase' => ['key'], + 'with dots' => ['app.setting.key'], + 'multiple dots' => ['a.b.c.d.e'], + 'single char' => ['a'], + 'long valid key' => ['very.long.setting.key.name'], + ]; + } + + public function testThrowsExceptionForInvalidUserId(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Bitrix24 user ID must be positive integer'); + + new ApplicationSettingsItem( + Uuid::v7(), + 'test.key', + 'value', + false, // isRequired + 0 // Invalid: zero + ); + } + + public function testThrowsExceptionForNegativeUserId(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Bitrix24 user ID must be positive integer'); + + new ApplicationSettingsItem( + Uuid::v7(), + 'test.key', + 'value', + false, // isRequired + -1 // Invalid: negative + ); + } + + public function testThrowsExceptionForInvalidDepartmentId(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Bitrix24 department ID must be positive integer'); + + new ApplicationSettingsItem( + Uuid::v7(), + 'test.key', + 'value', + false, // isRequired + null, // No user ID + 0 // Invalid: zero + ); + } + + public function testCanCreateRequiredSetting(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'required.setting', + 'value', + true // isRequired + ); + + $this->assertTrue($applicationSettingsItem->isRequired()); + } + + public function testCanTrackWhoChangedSetting(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'tracking.test', + 'initial.value', + false, + null, + null, + 123 // changedByBitrix24UserId + ); + + $this->assertEquals(123, $applicationSettingsItem->getChangedByBitrix24UserId()); + + // Update value with different user + $applicationSettingsItem->updateValue('new.value', 456); + + $this->assertEquals(456, $applicationSettingsItem->getChangedByBitrix24UserId()); + $this->assertEquals('new.value', $applicationSettingsItem->getValue()); + } + + public function testDefaultStatusIsActive(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'status.test', + 'value', + false + ); + + $this->assertTrue($applicationSettingsItem->isActive()); + } + + public function testCanMarkAsDeleted(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'delete.test', + 'value', + false + ); + + $this->assertTrue($applicationSettingsItem->isActive()); + + $initialUpdatedAt = $applicationSettingsItem->getUpdatedAt(); + usleep(1000); + $applicationSettingsItem->markAsDeleted(); + + $this->assertFalse($applicationSettingsItem->isActive()); + $this->assertGreaterThan($initialUpdatedAt, $applicationSettingsItem->getUpdatedAt()); + } + + public function testMarkAsDeletedIsIdempotent(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + Uuid::v7(), + 'idempotent.test', + 'value', + false + ); + + $applicationSettingsItem->markAsDeleted(); + + $firstUpdatedAt = $applicationSettingsItem->getUpdatedAt(); + + usleep(1000); + $applicationSettingsItem->markAsDeleted(); // Second call should not change updatedAt + + $this->assertEquals($firstUpdatedAt, $applicationSettingsItem->getUpdatedAt()); + } +} diff --git a/tests/Unit/ApplicationSettings/Infrastructure/InMemory/ApplicationSettingsItemInMemoryRepositoryContractTest.php b/tests/Unit/ApplicationSettings/Infrastructure/InMemory/ApplicationSettingsItemInMemoryRepositoryContractTest.php new file mode 100644 index 00000000..589fb883 --- /dev/null +++ b/tests/Unit/ApplicationSettings/Infrastructure/InMemory/ApplicationSettingsItemInMemoryRepositoryContractTest.php @@ -0,0 +1,33 @@ +repository instanceof ApplicationSettingsItemInMemoryRepository) { + $this->repository->clear(); + } + } +} diff --git a/tests/Unit/ApplicationSettings/Infrastructure/InMemory/ApplicationSettingsItemInMemoryRepositoryTest.php b/tests/Unit/ApplicationSettings/Infrastructure/InMemory/ApplicationSettingsItemInMemoryRepositoryTest.php new file mode 100644 index 00000000..adac34cd --- /dev/null +++ b/tests/Unit/ApplicationSettings/Infrastructure/InMemory/ApplicationSettingsItemInMemoryRepositoryTest.php @@ -0,0 +1,88 @@ +repository = new ApplicationSettingsItemInMemoryRepository(); + } + + #[\Override] + protected function tearDown(): void + { + $this->repository->clear(); + } + + /** + * Test InMemory-specific clear() method. + */ + public function testClearRemovesAllSettings(): void + { + $uuidV7 = Uuid::v7(); + + $setting1 = new ApplicationSettingsItem($uuidV7, 'key.one', 'value1', false); + $setting2 = new ApplicationSettingsItem($uuidV7, 'key.two', 'value2', false); + + $this->repository->save($setting1); + $this->repository->save($setting2); + + $this->assertCount(2, $this->repository->findAllForInstallation($uuidV7)); + + $this->repository->clear(); + + $this->assertCount(0, $this->repository->findAllForInstallation($uuidV7)); + } + + /** + * Test InMemory-specific getAllIncludingDeleted() method. + */ + public function testGetAllIncludingDeletedReturnsDeletedSettings(): void + { + $uuidV7 = Uuid::v7(); + + $activeSetting = new ApplicationSettingsItem($uuidV7, 'active.key', 'value1', false); + $deletedSetting = new ApplicationSettingsItem($uuidV7, 'deleted.key', 'value2', false); + $deletedSetting->markAsDeleted(); + + $this->repository->save($activeSetting); + $this->repository->save($deletedSetting); + + $allIncludingDeleted = $this->repository->getAllIncludingDeleted(); + + $this->assertCount(2, $allIncludingDeleted); + + // Regular findAll should only return active + $activeOnly = $this->repository->findAllForInstallation($uuidV7); + $this->assertCount(1, $activeOnly); + } + + /** + * Test that getAllIncludingDeleted() returns empty array when repository is empty. + */ + public function testGetAllIncludingDeletedReturnsEmptyArrayWhenEmpty(): void + { + $result = $this->repository->getAllIncludingDeleted(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Unit/ApplicationSettings/Services/DefaultSettingsInstallerTest.php b/tests/Unit/ApplicationSettings/Services/DefaultSettingsInstallerTest.php new file mode 100644 index 00000000..f52443ed --- /dev/null +++ b/tests/Unit/ApplicationSettings/Services/DefaultSettingsInstallerTest.php @@ -0,0 +1,132 @@ +createHandler = $this->createMock(Handler::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->service = new DefaultSettingsInstaller($this->createHandler, $this->logger); + } + + public function testCanCreateDefaultSettings(): void + { + $uuidV7 = Uuid::v7(); + $defaultSettings = [ + 'app.name' => ['value' => 'Test App', 'required' => true], + 'app.language' => ['value' => 'ru', 'required' => false], + ]; + + // Expect Create Handler to be called twice (once for each setting) + $this->createHandler->expects($this->exactly(2)) + ->method('handle') + ->with($this->callback(function (Command $command) use ($uuidV7): bool { + // Verify command has correct application installation ID + if ($command->applicationInstallationId->toRfc4122() !== $uuidV7->toRfc4122()) { + return false; + } + + // Verify key and value match one of the settings + if ($command->key === 'app.name') { + return $command->value === 'Test App' && $command->isRequired; + } + + if ($command->key === 'app.language') { + return $command->value === 'ru' && false === $command->isRequired; + } + + return false; + })); + + $this->service->createDefaultSettings($uuidV7, $defaultSettings); + } + + public function testLogsStartAndFinish(): void + { + $uuidV7 = Uuid::v7(); + $defaultSettings = [ + 'test.key' => ['value' => 'test', 'required' => false], + ]; + + $this->logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function (string $message, array $context) use ($uuidV7): bool { + if ('DefaultSettingsInstaller.createDefaultSettings.start' === $message) { + $this->assertEquals($uuidV7->toRfc4122(), $context['applicationInstallationId']); + $this->assertEquals(1, $context['settingsCount']); + + return true; + } + + if ('DefaultSettingsInstaller.createDefaultSettings.finish' === $message) { + $this->assertEquals($uuidV7->toRfc4122(), $context['applicationInstallationId']); + + return true; + } + + return false; + }); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('DefaultSettingsInstaller.settingProcessed', $this->arrayHasKey('key')); + + $this->service->createDefaultSettings($uuidV7, $defaultSettings); + } + + public function testCreatesGlobalSettings(): void + { + $uuidV7 = Uuid::v7(); + $defaultSettings = [ + 'global.setting' => ['value' => 'value', 'required' => true], + ]; + + // Verify that created commands are for global settings (no user/department ID) + $this->createHandler->expects($this->once()) + ->method('handle') + ->with($this->callback(fn(Command $command): bool => null === $command->b24UserId && null === $command->b24DepartmentId)); + + $this->service->createDefaultSettings($uuidV7, $defaultSettings); + } + + public function testHandlesEmptySettingsArray(): void + { + $uuidV7 = Uuid::v7(); + $defaultSettings = []; + + // Create Handler should not be called + $this->createHandler->expects($this->never()) + ->method('handle'); + + // But logging should still happen + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $this->service->createDefaultSettings($uuidV7, $defaultSettings); + } +} diff --git a/tests/Unit/ApplicationSettings/Services/SettingsFetcherTest.php b/tests/Unit/ApplicationSettings/Services/SettingsFetcherTest.php new file mode 100644 index 00000000..50dea1ae --- /dev/null +++ b/tests/Unit/ApplicationSettings/Services/SettingsFetcherTest.php @@ -0,0 +1,562 @@ +repository = new ApplicationSettingsItemInMemoryRepository(); + + // Create real Symfony Serializer + $normalizers = [ + new DateTimeNormalizer(), + new ArrayDenormalizer(), + new ObjectNormalizer(), + ]; + $encoders = [new JsonEncoder()]; + + $this->serializer = new Serializer($normalizers, $encoders); + $this->logger = $this->createMock(LoggerInterface::class); + $this->fetcher = new SettingsFetcher($this->repository, $this->serializer, $this->logger); + $this->installationId = Uuid::v7(); + } + + #[\Override] + protected function tearDown(): void + { + $this->repository->clear(); + } + + public function testReturnsGlobalSettingWhenNoOverrides(): void + { + // Create only global setting + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $this->repository->save($applicationSettingsItem); + + $result = $this->fetcher->getItem($this->installationId, 'app.theme'); + + $this->assertEquals('light', $result->getValue()); + $this->assertTrue($result->isGlobal()); + } + + public function testDepartmentalOverridesGlobal(): void + { + // Create global and departmental settings + $globalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $deptSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'blue', + false, + null, + 456 // department ID + ); + + $this->repository->save($globalSetting); + $this->repository->save($deptSetting); + + // When requesting for department 456, should get departmental setting + $result = $this->fetcher->getItem($this->installationId, 'app.theme', null, 456); + + $this->assertEquals('blue', $result->getValue()); + $this->assertTrue($result->isDepartmental()); + } + + public function testPersonalOverridesGlobalAndDepartmental(): void + { + // Create all three levels + $globalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $deptSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'blue', + false, + null, + 456 // department ID + ); + + $personalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'dark', + false, + 123 // user ID + ); + + $this->repository->save($globalSetting); + $this->repository->save($deptSetting); + $this->repository->save($personalSetting); + + // When requesting for user 123 and department 456, should get personal setting + $result = $this->fetcher->getItem($this->installationId, 'app.theme', 123, 456); + + $this->assertEquals('dark', $result->getValue()); + $this->assertTrue($result->isPersonal()); + } + + public function testFallsBackToGlobalWhenPersonalNotFound(): void + { + // Only global setting exists + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $this->repository->save($applicationSettingsItem); + + // Request for user 123, should fallback to global + $result = $this->fetcher->getItem($this->installationId, 'app.theme', 123); + + $this->assertEquals('light', $result->getValue()); + $this->assertTrue($result->isGlobal()); + } + + public function testFallsBackToDepartmentalWhenPersonalNotFound(): void + { + // Global and departmental settings exist + $globalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $deptSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'blue', + false, + null, + 456 + ); + + $this->repository->save($globalSetting); + $this->repository->save($deptSetting); + + // Request for user 999 (no personal setting) but department 456 + $result = $this->fetcher->getItem($this->installationId, 'app.theme', 999, 456); + + $this->assertEquals('blue', $result->getValue()); + $this->assertTrue($result->isDepartmental()); + } + + public function testThrowsExceptionWhenNoSettingFound(): void + { + $this->expectException(ItemNotFoundException::class); + $this->expectExceptionMessage('Settings item with key "non.existent.key" not found'); + + $this->fetcher->getItem($this->installationId, 'non.existent.key'); + } + + public function testGetValueReturnsStringValue(): void + { + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'app.version', + '1.2.3', + false + ); + + $this->repository->save($applicationSettingsItem); + + $result = $this->fetcher->getValue($this->installationId, 'app.version'); + + $this->assertEquals('1.2.3', $result); + } + + public function testGetValueThrowsExceptionWhenNotFound(): void + { + $this->expectException(ItemNotFoundException::class); + $this->expectExceptionMessage('Settings item with key "non.existent" not found'); + + $this->fetcher->getValue($this->installationId, 'non.existent'); + } + + public function testGetValueDeserializesToObject(): void + { + $jsonValue = json_encode([ + 'endpoint' => 'https://api.example.com', + 'timeout' => 60, + 'enabled' => true, + ]); + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'api.config', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $testConfigDto = $this->fetcher->getValue( + $this->installationId, + 'api.config', + class: TestConfigDto::class + ); + + $this->assertInstanceOf(TestConfigDto::class, $testConfigDto); + $this->assertEquals('https://api.example.com', $testConfigDto->endpoint); + $this->assertEquals(60, $testConfigDto->timeout); + $this->assertTrue($testConfigDto->enabled); + } + + public function testGetValueWithoutClassReturnsRawString(): void + { + $jsonValue = '{"foo":"bar","baz":123}'; + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'raw.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $result = $this->fetcher->getValue($this->installationId, 'raw.setting'); + + $this->assertIsString($result); + $this->assertEquals($jsonValue, $result); + } + + public function testGetValueLogsDeserializationFailure(): void + { + $jsonValue = 'invalid json{'; + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'broken.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $this->logger->expects($this->once()) + ->method('error') + ->with('SettingsFetcher.getValue.deserializationFailed', $this->callback(fn($context): bool => isset($context['key'], $context['class'], $context['error']) + && 'broken.setting' === $context['key'] + && TestConfigDto::class === $context['class'])); + + $this->expectException(\Throwable::class); + + $this->fetcher->getValue( + $this->installationId, + 'broken.setting', + class: TestConfigDto::class + ); + } + + public function testPersonalSettingForDifferentUserNotUsed(): void + { + // Create global and personal for user 123 + $globalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $personalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'dark', + false, + 123 // user ID + ); + + $this->repository->save($globalSetting); + $this->repository->save($personalSetting); + + // Request for user 456 (different user), should get global + $result = $this->fetcher->getItem($this->installationId, 'app.theme', 456); + + $this->assertEquals('light', $result->getValue()); + $this->assertTrue($result->isGlobal()); + } + + public function testDepartmentalSettingForDifferentDepartmentNotUsed(): void + { + // Create global and departmental for dept 456 + $globalSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'light', + false + ); + + $deptSetting = new ApplicationSettingsItem( + $this->installationId, + 'app.theme', + 'blue', + false, + null, + 456 // department ID + ); + + $this->repository->save($globalSetting); + $this->repository->save($deptSetting); + + // Request for dept 789 (different department), should get global + $result = $this->fetcher->getItem($this->installationId, 'app.theme', null, 789); + + $this->assertEquals('light', $result->getValue()); + $this->assertTrue($result->isGlobal()); + } + + public function testGetValueDeserializesStringType(): void + { + $jsonValue = json_encode(['value' => 'test string']); + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'string.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $stringTypeDto = $this->fetcher->getValue( + $this->installationId, + 'string.setting', + class: StringTypeDto::class + ); + + $this->assertInstanceOf(StringTypeDto::class, $stringTypeDto); + $this->assertEquals('test string', $stringTypeDto->value); + } + + public function testGetValueDeserializesBoolType(): void + { + $jsonValue = json_encode(['active' => true]); + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'bool.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $boolTypeDto = $this->fetcher->getValue( + $this->installationId, + 'bool.setting', + class: BoolTypeDto::class + ); + + $this->assertInstanceOf(BoolTypeDto::class, $boolTypeDto); + $this->assertTrue($boolTypeDto->active); + + // Test with false + $jsonValueFalse = json_encode(['active' => false]); + $applicationSettingsItemFalse = new ApplicationSettingsItem( + $this->installationId, + 'bool.setting.false', + $jsonValueFalse, + false + ); + $this->repository->save($applicationSettingsItemFalse); + + $resultFalse = $this->fetcher->getValue( + $this->installationId, + 'bool.setting.false', + class: BoolTypeDto::class + ); + + $this->assertInstanceOf(BoolTypeDto::class, $resultFalse); + $this->assertFalse($resultFalse->active); + } + + public function testGetValueDeserializesIntType(): void + { + $jsonValue = json_encode(['count' => 42]); + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'int.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $intTypeDto = $this->fetcher->getValue( + $this->installationId, + 'int.setting', + class: IntTypeDto::class + ); + + $this->assertInstanceOf(IntTypeDto::class, $intTypeDto); + $this->assertIsInt($intTypeDto->count); + $this->assertEquals(42, $intTypeDto->count); + } + + public function testGetValueDeserializesFloatType(): void + { + $jsonValue = json_encode(['price' => 99.99]); + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'float.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $floatTypeDto = $this->fetcher->getValue( + $this->installationId, + 'float.setting', + class: FloatTypeDto::class + ); + + $this->assertInstanceOf(FloatTypeDto::class, $floatTypeDto); + $this->assertIsFloat($floatTypeDto->price); + $this->assertEquals(99.99, $floatTypeDto->price); + } + + public function testGetValueDeserializesDateTimeType(): void + { + $dateTime = new \DateTimeImmutable('2025-01-15 10:30:00'); + $jsonValue = json_encode(['createdAt' => $dateTime->format(\DateTimeInterface::RFC3339)]); + + $applicationSettingsItem = new ApplicationSettingsItem( + $this->installationId, + 'datetime.setting', + $jsonValue, + false + ); + + $this->repository->save($applicationSettingsItem); + + $dateTimeTypeDto = $this->fetcher->getValue( + $this->installationId, + 'datetime.setting', + class: DateTimeTypeDto::class + ); + + $this->assertInstanceOf(DateTimeTypeDto::class, $dateTimeTypeDto); + $this->assertInstanceOf(\DateTimeInterface::class, $dateTimeTypeDto->createdAt); + $this->assertEquals('2025-01-15', $dateTimeTypeDto->createdAt->format('Y-m-d')); + $this->assertEquals('10:30:00', $dateTimeTypeDto->createdAt->format('H:i:s')); + } +} diff --git a/tests/Unit/Bitrix24Accounts/UseCase/ChangeDomainUrl/CommandTest.php b/tests/Unit/Bitrix24Accounts/UseCase/ChangeDomainUrl/CommandTest.php index f8deb499..71cc8a59 100644 --- a/tests/Unit/Bitrix24Accounts/UseCase/ChangeDomainUrl/CommandTest.php +++ b/tests/Unit/Bitrix24Accounts/UseCase/ChangeDomainUrl/CommandTest.php @@ -5,7 +5,7 @@ namespace Bitrix24\Lib\Tests\Unit\Bitrix24Accounts\UseCase\ChangeDomainUrl; use Bitrix24\Lib\Bitrix24Accounts\UseCase\ChangeDomainUrl\Command; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Unit/Bitrix24Accounts/UseCase/InstallFinish/CommandTest.php b/tests/Unit/Bitrix24Accounts/UseCase/InstallFinish/CommandTest.php index 44559ef1..42dd7af2 100644 --- a/tests/Unit/Bitrix24Accounts/UseCase/InstallFinish/CommandTest.php +++ b/tests/Unit/Bitrix24Accounts/UseCase/InstallFinish/CommandTest.php @@ -12,7 +12,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; /** diff --git a/tests/Unit/Bitrix24Accounts/UseCase/InstallStart/CommandTest.php b/tests/Unit/Bitrix24Accounts/UseCase/InstallStart/CommandTest.php index 27a39a45..123d499d 100644 --- a/tests/Unit/Bitrix24Accounts/UseCase/InstallStart/CommandTest.php +++ b/tests/Unit/Bitrix24Accounts/UseCase/InstallStart/CommandTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; -use Bitrix24\Lib\Bitrix24Accounts\ValueObjects\Domain; +use Bitrix24\Lib\Common\ValueObjects\Domain; /** 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, + ], + ]; + } +} diff --git a/tests/Unit/Journal/Entity/JournalItemTest.php b/tests/Unit/Journal/Entity/JournalItemTest.php index 0aeef627..352caac1 100644 --- a/tests/Unit/Journal/Entity/JournalItemTest.php +++ b/tests/Unit/Journal/Entity/JournalItemTest.php @@ -15,7 +15,7 @@ use Bitrix24\Lib\Journal\Entity\JournalItem; use Bitrix24\Lib\Journal\Entity\LogLevel; -use Bitrix24\Lib\Journal\ValueObjects\JournalContext; +use Bitrix24\Lib\Journal\Entity\ValueObjects\Context; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; @@ -24,104 +24,111 @@ class JournalItemTest extends TestCase { private Uuid $applicationInstallationId; + private string $memberId; + + #[\Override] protected function setUp(): void { $this->applicationInstallationId = Uuid::v7(); + $this->memberId = 'test-member-id'; } public function testCreateJournalItemWithInfoLevel(): void { $message = 'Test info message'; - $context = new JournalContext( - label: 'test.label', + $label = 'test.label'; + $userName = 'test-user'; + $journalContext = new Context( payload: ['key' => 'value'], bitrix24UserId: 123 ); - $item = JournalItem::info($this->applicationInstallationId, $message, $context); - - $this->assertInstanceOf(JournalItem::class, $item); - $this->assertSame(LogLevel::info, $item->getLevel()); - $this->assertSame($message, $item->getMessage()); - $this->assertTrue($item->getApplicationInstallationId()->equals($this->applicationInstallationId)); - $this->assertSame('test.label', $item->getContext()->getLabel()); - $this->assertSame(['key' => 'value'], $item->getContext()->getPayload()); - $this->assertSame(123, $item->getContext()->getBitrix24UserId()); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, $message, $label, $userName, $journalContext); + + $this->assertInstanceOf(JournalItem::class, $journalItem); + $this->assertSame(LogLevel::info, $journalItem->getLevel()); + $this->assertSame($this->memberId, $journalItem->getMemberId()); + $this->assertSame($message, $journalItem->getMessage()); + $this->assertSame($label, $journalItem->getLabel()); + $this->assertSame($userName, $journalItem->getUserId()); + $this->assertTrue($journalItem->getApplicationInstallationId()->equals($this->applicationInstallationId)); + $this->assertSame(['key' => 'value'], $journalItem->getContext()->getPayload()); + $this->assertSame(123, $journalItem->getContext()->getBitrix24UserId()); } public function testCreateJournalItemWithEmergencyLevel(): void { - $context = new JournalContext('emergency.label'); - $item = JournalItem::emergency($this->applicationInstallationId, 'Emergency message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::emergency($this->memberId, $this->applicationInstallationId, 'Emergency message', 'emergency.label', null, $journalContext); - $this->assertSame(LogLevel::emergency, $item->getLevel()); - $this->assertSame('Emergency message', $item->getMessage()); + $this->assertSame(LogLevel::emergency, $journalItem->getLevel()); + $this->assertSame('Emergency message', $journalItem->getMessage()); } public function testCreateJournalItemWithAlertLevel(): void { - $context = new JournalContext('alert.label'); - $item = JournalItem::alert($this->applicationInstallationId, 'Alert message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::alert($this->memberId, $this->applicationInstallationId, 'Alert message', 'alert.label', null, $journalContext); - $this->assertSame(LogLevel::alert, $item->getLevel()); + $this->assertSame(LogLevel::alert, $journalItem->getLevel()); } public function testCreateJournalItemWithCriticalLevel(): void { - $context = new JournalContext('critical.label'); - $item = JournalItem::critical($this->applicationInstallationId, 'Critical message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::critical($this->memberId, $this->applicationInstallationId, 'Critical message', 'critical.label', null, $journalContext); - $this->assertSame(LogLevel::critical, $item->getLevel()); + $this->assertSame(LogLevel::critical, $journalItem->getLevel()); } public function testCreateJournalItemWithErrorLevel(): void { - $context = new JournalContext('error.label'); - $item = JournalItem::error($this->applicationInstallationId, 'Error message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::error($this->memberId, $this->applicationInstallationId, 'Error message', 'error.label', null, $journalContext); - $this->assertSame(LogLevel::error, $item->getLevel()); + $this->assertSame(LogLevel::error, $journalItem->getLevel()); } public function testCreateJournalItemWithWarningLevel(): void { - $context = new JournalContext('warning.label'); - $item = JournalItem::warning($this->applicationInstallationId, 'Warning message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::warning($this->memberId, $this->applicationInstallationId, 'Warning message', 'warning.label', null, $journalContext); - $this->assertSame(LogLevel::warning, $item->getLevel()); + $this->assertSame(LogLevel::warning, $journalItem->getLevel()); } public function testCreateJournalItemWithNoticeLevel(): void { - $context = new JournalContext('notice.label'); - $item = JournalItem::notice($this->applicationInstallationId, 'Notice message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::notice($this->memberId, $this->applicationInstallationId, 'Notice message', 'notice.label', null, $journalContext); - $this->assertSame(LogLevel::notice, $item->getLevel()); + $this->assertSame(LogLevel::notice, $journalItem->getLevel()); } public function testCreateJournalItemWithDebugLevel(): void { - $context = new JournalContext('debug.label'); - $item = JournalItem::debug($this->applicationInstallationId, 'Debug message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::debug($this->memberId, $this->applicationInstallationId, 'Debug message', 'debug.label', null, $journalContext); - $this->assertSame(LogLevel::debug, $item->getLevel()); + $this->assertSame(LogLevel::debug, $journalItem->getLevel()); } public function testJournalItemHasUniqueId(): void { - $context = new JournalContext('test.label'); - $item1 = JournalItem::info($this->applicationInstallationId, 'Message 1', $context); - $item2 = JournalItem::info($this->applicationInstallationId, 'Message 2', $context); + $journalContext = new Context(); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 1', 'test.label', null, $journalContext); + $item2 = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 2', 'test.label', null, $journalContext); - $this->assertNotEquals($item1->getId()->toRfc4122(), $item2->getId()->toRfc4122()); + $this->assertNotEquals($journalItem->getId()->toRfc4122(), $item2->getId()->toRfc4122()); } public function testJournalItemHasCreatedAt(): void { - $context = new JournalContext('test.label'); - $item = JournalItem::info($this->applicationInstallationId, 'Test message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Test message', 'test.label', null, $journalContext); - $this->assertNotNull($item->getCreatedAt()); - $this->assertInstanceOf(\Carbon\CarbonImmutable::class, $item->getCreatedAt()); + $this->assertNotNull($journalItem->getCreatedAt()); + $this->assertInstanceOf(\Carbon\CarbonImmutable::class, $journalItem->getCreatedAt()); } public function testCreateJournalItemWithEmptyMessageThrowsException(): void @@ -129,8 +136,8 @@ public function testCreateJournalItemWithEmptyMessageThrowsException(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Journal message cannot be empty'); - $context = new JournalContext('test.label'); - JournalItem::info($this->applicationInstallationId, '', $context); + $journalContext = new Context(); + JournalItem::info($this->memberId, $this->applicationInstallationId, '', 'test.label', null, $journalContext); } public function testCreateJournalItemWithWhitespaceMessageThrowsException(): void @@ -138,19 +145,28 @@ public function testCreateJournalItemWithWhitespaceMessageThrowsException(): voi $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Journal message cannot be empty'); - $context = new JournalContext('test.label'); - JournalItem::info($this->applicationInstallationId, ' ', $context); + $journalContext = new Context(); + JournalItem::info($this->memberId, $this->applicationInstallationId, ' ', 'test.label', null, $journalContext); + } + + public function testCreateJournalItemWithEmptyMemberIdThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('memberId cannot be empty'); + + $journalContext = new Context(); + JournalItem::info('', $this->applicationInstallationId, 'Message', 'test.label', null, $journalContext); } - public function testJournalItemContextWithOnlyLabel(): void + public function testJournalItemContextWithoutLabel(): void { - $context = new JournalContext('test.label'); - $item = JournalItem::info($this->applicationInstallationId, 'Test message', $context); + $journalContext = new Context(); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Test message', 'test.label', null, $journalContext); - $this->assertSame('test.label', $item->getContext()->getLabel()); - $this->assertNull($item->getContext()->getPayload()); - $this->assertNull($item->getContext()->getBitrix24UserId()); - $this->assertNull($item->getContext()->getIpAddress()); + $this->assertSame('test.label', $journalItem->getLabel()); + $this->assertNull($journalItem->getContext()->getPayload()); + $this->assertNull($journalItem->getContext()->getBitrix24UserId()); + $this->assertNull($journalItem->getContext()->getIpAddress()); } public function testJournalItemWithComplexPayload(): void @@ -164,13 +180,16 @@ public function testJournalItemWithComplexPayload(): void ], ]; - $context = new JournalContext('sync.label', $payload); - $item = JournalItem::info( + $journalContext = new Context(payload: $payload); + $journalItem = JournalItem::info( + $this->memberId, $this->applicationInstallationId, 'Sync completed', - $context + 'sync.label', + null, + $journalContext ); - $this->assertSame($payload, $item->getContext()->getPayload()); + $this->assertSame($payload, $journalItem->getContext()->getPayload()); } } diff --git a/tests/Unit/Journal/Entity/LogLevelTest.php b/tests/Unit/Journal/Entity/LogLevelTest.php index 0b20b429..76e77158 100644 --- a/tests/Unit/Journal/Entity/LogLevelTest.php +++ b/tests/Unit/Journal/Entity/LogLevelTest.php @@ -20,50 +20,50 @@ class LogLevelTest extends TestCase { public function testFromPsr3LevelEmergency(): void { - $level = LogLevel::fromPsr3Level('emergency'); - $this->assertSame(LogLevel::emergency, $level); + $logLevel = LogLevel::fromPsr3Level('emergency'); + $this->assertSame(LogLevel::emergency, $logLevel); } public function testFromPsr3LevelAlert(): void { - $level = LogLevel::fromPsr3Level('alert'); - $this->assertSame(LogLevel::alert, $level); + $logLevel = LogLevel::fromPsr3Level('alert'); + $this->assertSame(LogLevel::alert, $logLevel); } public function testFromPsr3LevelCritical(): void { - $level = LogLevel::fromPsr3Level('critical'); - $this->assertSame(LogLevel::critical, $level); + $logLevel = LogLevel::fromPsr3Level('critical'); + $this->assertSame(LogLevel::critical, $logLevel); } public function testFromPsr3LevelError(): void { - $level = LogLevel::fromPsr3Level('error'); - $this->assertSame(LogLevel::error, $level); + $logLevel = LogLevel::fromPsr3Level('error'); + $this->assertSame(LogLevel::error, $logLevel); } public function testFromPsr3LevelWarning(): void { - $level = LogLevel::fromPsr3Level('warning'); - $this->assertSame(LogLevel::warning, $level); + $logLevel = LogLevel::fromPsr3Level('warning'); + $this->assertSame(LogLevel::warning, $logLevel); } public function testFromPsr3LevelNotice(): void { - $level = LogLevel::fromPsr3Level('notice'); - $this->assertSame(LogLevel::notice, $level); + $logLevel = LogLevel::fromPsr3Level('notice'); + $this->assertSame(LogLevel::notice, $logLevel); } public function testFromPsr3LevelInfo(): void { - $level = LogLevel::fromPsr3Level('info'); - $this->assertSame(LogLevel::info, $level); + $logLevel = LogLevel::fromPsr3Level('info'); + $this->assertSame(LogLevel::info, $logLevel); } public function testFromPsr3LevelDebug(): void { - $level = LogLevel::fromPsr3Level('debug'); - $this->assertSame(LogLevel::debug, $level); + $logLevel = LogLevel::fromPsr3Level('debug'); + $this->assertSame(LogLevel::debug, $logLevel); } public function testFromPsr3LevelCaseInsensitive(): void @@ -98,7 +98,7 @@ public function testAllLogLevelsExist(): void $cases = LogLevel::cases(); $this->assertCount(8, $cases); - $values = array_map(static fn (LogLevel $level): string => $level->value, $cases); + $values = array_map(static fn (LogLevel $logLevel): string => $logLevel->value, $cases); $this->assertContains('emergency', $values); $this->assertContains('alert', $values); diff --git a/tests/Unit/Journal/Infrastructure/InMemory/InMemoryJournalItemRepository.php b/tests/Unit/Journal/Infrastructure/InMemory/InMemoryJournalItemRepository.php index 5b8789b1..d70c2819 100644 --- a/tests/Unit/Journal/Infrastructure/InMemory/InMemoryJournalItemRepository.php +++ b/tests/Unit/Journal/Infrastructure/InMemory/InMemoryJournalItemRepository.php @@ -13,14 +13,16 @@ namespace Bitrix24\Lib\Tests\Unit\Journal\Infrastructure\InMemory; +use Bitrix24\Lib\Common\ValueObjects\Domain; use Bitrix24\Lib\Journal\Entity\JournalItemInterface; use Bitrix24\Lib\Journal\Entity\LogLevel; use Bitrix24\Lib\Journal\Infrastructure\JournalItemRepositoryInterface; use Carbon\CarbonImmutable; +use Knp\Component\Pager\Pagination\PaginationInterface; use Symfony\Component\Uid\Uuid; /** - * In-memory implementation of JournalItemRepository for testing + * In-memory implementation of JournalItemRepository for testing. */ class InMemoryJournalItemRepository implements JournalItemRepositoryInterface { @@ -36,9 +38,9 @@ public function save(JournalItemInterface $journalItem): void } #[\Override] - public function findById(Uuid $id): ?JournalItemInterface + public function findById(Uuid $uuid): ?JournalItemInterface { - return $this->items[$id->toRfc4122()] ?? null; + return $this->items[$uuid->toRfc4122()] ?? null; } /** @@ -46,19 +48,24 @@ public function findById(Uuid $id): ?JournalItemInterface */ #[\Override] public function findByApplicationInstallationId( + string $memberId, Uuid $applicationInstallationId, - ?LogLevel $level = null, + ?LogLevel $logLevel = null, ?int $limit = null, ?int $offset = null ): array { $filtered = array_filter( $this->items, - static function (JournalItemInterface $item) use ($applicationInstallationId, $level): bool { - if (!$item->getApplicationInstallationId()->equals($applicationInstallationId)) { + static function (JournalItemInterface $journalItem) use ($applicationInstallationId, $memberId, $logLevel): bool { + if ($journalItem->getMemberId() !== $memberId) { return false; } - if ($level !== null && $item->getLevel() !== $level) { + if (!$journalItem->getApplicationInstallationId()->equals($applicationInstallationId)) { + return false; + } + + if (null !== $logLevel && $journalItem->getLevel() !== $logLevel) { return false; } @@ -67,41 +74,70 @@ static function (JournalItemInterface $item) use ($applicationInstallationId, $l ); // Sort by created date descending - usort($filtered, static function (JournalItemInterface $a, JournalItemInterface $b): int { - return $b->getCreatedAt()->getTimestamp() <=> $a->getCreatedAt()->getTimestamp(); - }); + usort($filtered, static fn (JournalItemInterface $a, JournalItemInterface $b): int => $b->getCreatedAt()->getTimestamp() <=> $a->getCreatedAt()->getTimestamp()); - if ($offset !== null) { + if (null !== $offset) { $filtered = array_slice($filtered, $offset); } - if ($limit !== null) { - $filtered = array_slice($filtered, 0, $limit); + if (null !== $limit) { + return array_slice($filtered, 0, $limit); } return $filtered; } + /** + * @return JournalItemInterface[] + */ #[\Override] - public function deleteByApplicationInstallationId(Uuid $applicationInstallationId): int - { - $count = 0; - foreach ($this->items as $key => $item) { - if ($item->getApplicationInstallationId()->equals($applicationInstallationId)) { - unset($this->items[$key]); - ++$count; + public function findByMemberId( + string $memberId, + ?LogLevel $logLevel = null, + ?int $limit = null, + ?int $offset = null + ): array { + $filtered = array_filter( + $this->items, + static function (JournalItemInterface $journalItem) use ($memberId, $logLevel): bool { + if ($journalItem->getMemberId() !== $memberId) { + return false; + } + + if (null !== $logLevel && $journalItem->getLevel() !== $logLevel) { + return false; + } + + return true; } + ); + + // Sort by created date descending + usort($filtered, static fn (JournalItemInterface $a, JournalItemInterface $b): int => $b->getCreatedAt()->getTimestamp() <=> $a->getCreatedAt()->getTimestamp()); + + if (null !== $offset) { + $filtered = array_slice($filtered, $offset); } - return $count; + if (null !== $limit) { + return array_slice($filtered, 0, $limit); + } + + return $filtered; } #[\Override] - public function deleteOlderThan(CarbonImmutable $date): int - { + public function deleteOlderThan( + string $memberId, + Uuid $applicationInstallationId, + CarbonImmutable $date + ): int { $count = 0; foreach ($this->items as $key => $item) { - if ($item->getCreatedAt()->isBefore($date)) { + if ($item->getMemberId() === $memberId + && $item->getApplicationInstallationId()->equals($applicationInstallationId) + && $item->getCreatedAt()->isBefore($date) + ) { unset($this->items[$key]); ++$count; } @@ -110,14 +146,8 @@ public function deleteOlderThan(CarbonImmutable $date): int return $count; } - #[\Override] - public function countByApplicationInstallationId(Uuid $applicationInstallationId, ?LogLevel $level = null): int - { - return count($this->findByApplicationInstallationId($applicationInstallationId, $level)); - } - /** - * Get all items (for testing purposes) + * Get all items (for testing purposes). * * @return JournalItemInterface[] */ @@ -127,7 +157,7 @@ public function findAll(): array } /** - * Clear all items (for testing purposes) + * Clear all items (for testing purposes). */ public function clear(): void { diff --git a/tests/Unit/Journal/Infrastructure/InMemoryJournalItemRepositoryTest.php b/tests/Unit/Journal/Infrastructure/InMemoryJournalItemRepositoryTest.php index 19c3d522..86c3b216 100644 --- a/tests/Unit/Journal/Infrastructure/InMemoryJournalItemRepositoryTest.php +++ b/tests/Unit/Journal/Infrastructure/InMemoryJournalItemRepositoryTest.php @@ -15,36 +15,45 @@ use Bitrix24\Lib\Journal\Entity\JournalItem; use Bitrix24\Lib\Journal\Entity\LogLevel; -use Bitrix24\Lib\Journal\ValueObjects\JournalContext; +use Bitrix24\Lib\Journal\Entity\ValueObjects\Context; use Bitrix24\Lib\Tests\Unit\Journal\Infrastructure\InMemory\InMemoryJournalItemRepository; use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; +/** + * @internal + * + * @coversNothing + */ class InMemoryJournalItemRepositoryTest extends TestCase { private InMemoryJournalItemRepository $repository; private Uuid $applicationInstallationId; + private string $memberId; + + #[\Override] protected function setUp(): void { $this->repository = new InMemoryJournalItemRepository(); $this->applicationInstallationId = Uuid::v7(); + $this->memberId = 'test-member-id'; } public function testSaveAndFindById(): void { - $context = new JournalContext('test.label'); - $item = JournalItem::info($this->applicationInstallationId, 'Test message', $context); + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Test message', 'test.label', null, $journalContext); - $this->repository->save($item); + $this->repository->save($journalItem); - $found = $this->repository->findById($item->getId()); + $found = $this->repository->findById($journalItem->getId()); $this->assertNotNull($found); - $this->assertSame($item->getId()->toRfc4122(), $found->getId()->toRfc4122()); - $this->assertSame($item->getMessage(), $found->getMessage()); + $this->assertSame($journalItem->getId()->toRfc4122(), $found->getId()->toRfc4122()); + $this->assertSame($journalItem->getMessage(), $found->getMessage()); } public function testFindByIdReturnsNullForNonexistent(): void @@ -56,34 +65,35 @@ public function testFindByIdReturnsNullForNonexistent(): void public function testFindByApplicationInstallationId(): void { - $context = new JournalContext('test.label'); - $item1 = JournalItem::info($this->applicationInstallationId, 'Message 1', $context); - $item2 = JournalItem::error($this->applicationInstallationId, 'Message 2', $context); - $item3 = JournalItem::info(Uuid::v7(), 'Message 3', $context); // Different installation + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 1', 'test.label', null, $journalContext); + $item2 = JournalItem::error($this->memberId, $this->applicationInstallationId, 'Message 2', 'test.label', null, $journalContext); + $item3 = JournalItem::info('other-member', Uuid::v7(), 'Message 3', 'test.label', null, $journalContext); // Different installation - $this->repository->save($item1); + $this->repository->save($journalItem); $this->repository->save($item2); $this->repository->save($item3); - $items = $this->repository->findByApplicationInstallationId($this->applicationInstallationId); + $items = $this->repository->findByApplicationInstallationId($this->memberId, $this->applicationInstallationId); $this->assertCount(2, $items); } public function testFindByApplicationInstallationIdWithLevelFilter(): void { - $context = new JournalContext('test.label'); - $item1 = JournalItem::info($this->applicationInstallationId, 'Message 1', $context); - $item2 = JournalItem::error($this->applicationInstallationId, 'Message 2', $context); - $item3 = JournalItem::info($this->applicationInstallationId, 'Message 3', $context); + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 1', 'test.label', null, $journalContext); + $item2 = JournalItem::error($this->memberId, $this->applicationInstallationId, 'Message 2', 'test.label', null, $journalContext); + $item3 = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 3', 'test.label', null, $journalContext); - $this->repository->save($item1); + $this->repository->save($journalItem); $this->repository->save($item2); $this->repository->save($item3); $items = $this->repository->findByApplicationInstallationId( + $this->memberId, $this->applicationInstallationId, - LogLevel::info + logLevel: LogLevel::info ); $this->assertCount(2, $items); @@ -92,15 +102,35 @@ public function testFindByApplicationInstallationIdWithLevelFilter(): void } } + public function testFindByMemberId(): void + { + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 1', 'test.label', null, $journalContext); + $item2 = JournalItem::error($this->memberId, $this->applicationInstallationId, 'Message 2', 'test.label', null, $journalContext); + $item3 = JournalItem::info('other-member', Uuid::v7(), 'Message 3', 'test.label', null, $journalContext); + + $this->repository->save($journalItem); + $this->repository->save($item2); + $this->repository->save($item3); + + $items = $this->repository->findByMemberId($this->memberId); + + $this->assertCount(2, $items); + foreach ($items as $item) { + $this->assertSame($this->memberId, $item->getMemberId()); + } + } + public function testFindByApplicationInstallationIdWithLimit(): void { - $context = new JournalContext('test.label'); + $journalContext = new Context(['key' => 'value']); for ($i = 1; $i <= 5; ++$i) { - $item = JournalItem::info($this->applicationInstallationId, "Message {$i}", $context); + $item = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message '.$i, 'test.label', null, $journalContext); $this->repository->save($item); } $items = $this->repository->findByApplicationInstallationId( + $this->memberId, $this->applicationInstallationId, limit: 3 ); @@ -110,13 +140,14 @@ public function testFindByApplicationInstallationIdWithLimit(): void public function testFindByApplicationInstallationIdWithOffset(): void { - $context = new JournalContext('test.label'); + $journalContext = new Context(['key' => 'value']); for ($i = 1; $i <= 5; ++$i) { - $item = JournalItem::info($this->applicationInstallationId, "Message {$i}", $context); + $item = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message '.$i, 'test.label', null, $journalContext); $this->repository->save($item); } $items = $this->repository->findByApplicationInstallationId( + $this->memberId, $this->applicationInstallationId, offset: 2 ); @@ -126,13 +157,14 @@ public function testFindByApplicationInstallationIdWithOffset(): void public function testFindByApplicationInstallationIdWithLimitAndOffset(): void { - $context = new JournalContext('test.label'); + $journalContext = new Context(['key' => 'value']); for ($i = 1; $i <= 10; ++$i) { - $item = JournalItem::info($this->applicationInstallationId, "Message {$i}", $context); + $item = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message '.$i, 'test.label', null, $journalContext); $this->repository->save($item); } $items = $this->repository->findByApplicationInstallationId( + $this->memberId, $this->applicationInstallationId, limit: 3, offset: 2 @@ -141,71 +173,24 @@ public function testFindByApplicationInstallationIdWithLimitAndOffset(): void $this->assertCount(3, $items); } - public function testDeleteByApplicationInstallationId(): void - { - $context = new JournalContext('test.label'); - $item1 = JournalItem::info($this->applicationInstallationId, 'Message 1', $context); - $item2 = JournalItem::info($this->applicationInstallationId, 'Message 2', $context); - $otherInstallationId = Uuid::v7(); - $item3 = JournalItem::info($otherInstallationId, 'Message 3', $context); - - $this->repository->save($item1); - $this->repository->save($item2); - $this->repository->save($item3); - - $deleted = $this->repository->deleteByApplicationInstallationId($this->applicationInstallationId); - - $this->assertSame(2, $deleted); - $this->assertEmpty($this->repository->findByApplicationInstallationId($this->applicationInstallationId)); - $this->assertCount(1, $this->repository->findByApplicationInstallationId($otherInstallationId)); - } - public function testDeleteOlderThan(): void { - $context = new JournalContext('test.label'); - $item = JournalItem::info($this->applicationInstallationId, 'Message', $context); - $this->repository->save($item); + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message', 'test.label', null, $journalContext); + $this->repository->save($journalItem); $futureDate = new CarbonImmutable('+1 day'); - $deleted = $this->repository->deleteOlderThan($futureDate); + $deleted = $this->repository->deleteOlderThan($this->memberId, $this->applicationInstallationId, $futureDate); // Item should be deleted as it's older than future date $this->assertSame(1, $deleted); } - public function testCountByApplicationInstallationId(): void - { - $context = new JournalContext('test.label'); - for ($i = 1; $i <= 5; ++$i) { - $item = JournalItem::info($this->applicationInstallationId, "Message {$i}", $context); - $this->repository->save($item); - } - - $count = $this->repository->countByApplicationInstallationId($this->applicationInstallationId); - - $this->assertSame(5, $count); - } - - public function testCountByApplicationInstallationIdWithLevelFilter(): void - { - $context = new JournalContext('test.label'); - $this->repository->save(JournalItem::info($this->applicationInstallationId, 'Info 1', $context)); - $this->repository->save(JournalItem::info($this->applicationInstallationId, 'Info 2', $context)); - $this->repository->save(JournalItem::error($this->applicationInstallationId, 'Error 1', $context)); - - $count = $this->repository->countByApplicationInstallationId( - $this->applicationInstallationId, - LogLevel::info - ); - - $this->assertSame(2, $count); - } - public function testClear(): void { - $context = new JournalContext('test.label'); - $item = JournalItem::info($this->applicationInstallationId, 'Message', $context); - $this->repository->save($item); + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message', 'test.label', null, $journalContext); + $this->repository->save($journalItem); $this->assertNotEmpty($this->repository->findAll()); @@ -216,11 +201,11 @@ public function testClear(): void public function testFindAll(): void { - $context = new JournalContext('test.label'); - $item1 = JournalItem::info($this->applicationInstallationId, 'Message 1', $context); - $item2 = JournalItem::error(Uuid::v7(), 'Message 2', $context); + $journalContext = new Context(['key' => 'value']); + $journalItem = JournalItem::info($this->memberId, $this->applicationInstallationId, 'Message 1', 'test.label', null, $journalContext); + $item2 = JournalItem::error('other-member', Uuid::v7(), 'Message 2', 'test.label', null, $journalContext); - $this->repository->save($item1); + $this->repository->save($journalItem); $this->repository->save($item2); $all = $this->repository->findAll(); diff --git a/tests/Unit/Journal/Services/JournalLoggerTest.php b/tests/Unit/Journal/Services/JournalLoggerTest.php index 66ad4ea2..98b207e5 100644 --- a/tests/Unit/Journal/Services/JournalLoggerTest.php +++ b/tests/Unit/Journal/Services/JournalLoggerTest.php @@ -28,15 +28,20 @@ class JournalLoggerTest extends TestCase private Uuid $applicationInstallationId; + private string $memberId; + private JournalLogger $logger; + #[\Override] protected function setUp(): void { $this->repository = new InMemoryJournalItemRepository(); $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->applicationInstallationId = Uuid::v7(); + $this->memberId = 'test-member-id'; $this->logger = new JournalLogger( + $this->memberId, $this->applicationInstallationId, $this->repository, $this->entityManager @@ -47,13 +52,15 @@ public function testLogInfoMessage(): void { $this->entityManager->expects($this->once())->method('flush'); - $this->logger->info('Test info message', ['label' => 'test.label']); + $this->logger->info('Test info message', ['label' => 'test.label', 'userId' => 'test-user']); $items = $this->repository->findAll(); $this->assertCount(1, $items); $this->assertSame(LogLevel::info, $items[0]->getLevel()); + $this->assertSame($this->memberId, $items[0]->getMemberId()); $this->assertSame('Test info message', $items[0]->getMessage()); - $this->assertSame('test.label', $items[0]->getContext()->getLabel()); + $this->assertSame('test.label', $items[0]->getLabel()); + $this->assertSame('test-user', $items[0]->getUserId()); } public function testLogErrorMessage(): void @@ -65,7 +72,7 @@ public function testLogErrorMessage(): void $items = $this->repository->findAll(); $this->assertCount(1, $items); $this->assertSame(LogLevel::error, $items[0]->getLevel()); - $this->assertSame('error.label', $items[0]->getContext()->getLabel()); + $this->assertSame('error.label', $items[0]->getLabel()); } public function testLogWarningMessage(): void @@ -144,7 +151,7 @@ public function testLogWithContext(): void $items = $this->repository->findAll(); $item = $items[0]; - $this->assertSame('test.label', $item->getContext()->getLabel()); + $this->assertSame('test.label', $item->getLabel()); $this->assertSame(['key' => 'value'], $item->getContext()->getPayload()); $this->assertSame(123, $item->getContext()->getBitrix24UserId()); $this->assertNotNull($item->getContext()->getIpAddress()); @@ -157,7 +164,7 @@ public function testLogWithoutLabelUsesDefault(): void $this->logger->info('Test message without label'); $items = $this->repository->findAll(); - $this->assertSame('application.log', $items[0]->getContext()->getLabel()); + $this->assertSame('application.log', $items[0]->getLabel()); } public function testLogMultipleMessages(): void