diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml index d0b180985f..483f3dbeee 100644 --- a/.github/workflows/ai-moderator.yml +++ b/.github/workflows/ai-moderator.yml @@ -5,8 +5,6 @@ on: types: [opened, edited] issue_comment: types: [created, edited] - pull_request: - types: [opened, edited] pull_request_review: types: [submitted, edited] pull_request_review_comment: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index b7b4fa0d2f..0000000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Benchmark -concurrency: - group: '${{ github.workflow }}-${{ github.ref }}' - cancel-in-progress: true -env: - COMPOSE_FILE: docker-compose.yml - IMAGE: appwrite-dev - CACHE_KEY: 'appwrite-dev-${{ github.event.pull_request.head.sha }}' -'on': - - pull_request -jobs: - setup: - name: Setup & Build Appwrite Image - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build Appwrite - uses: docker/build-push-action@v6 - with: - context: . - push: false - tags: '${{ env.IMAGE }}' - load: true - cache-from: type=gha - cache-to: 'type=gha,mode=max' - outputs: 'type=docker,dest=/tmp/${{ env.IMAGE }}.tar' - target: development - build-args: | - DEBUG=false - TESTING=true - VERSION=dev - - name: Cache Docker Image - uses: actions/cache@v4 - with: - key: '${{ env.CACHE_KEY }}' - path: '/tmp/${{ env.IMAGE }}.tar' - benchmarking: - name: Benchmark - runs-on: ubuntu-latest - needs: setup - permissions: - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - name: Load Cache - uses: actions/cache@v4 - with: - key: '${{ env.CACHE_KEY }}' - path: '/tmp/${{ env.IMAGE }}.tar' - fail-on-cache-miss: true - - name: Load and Start Appwrite - run: | - sed -i 's/traefik/localhost/g' .env - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - sleep 10 - - name: Install Oha - run: | - echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list - sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg - sudo apt update - sudo apt install oha - oha --version - - name: Benchmark PR - run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json' - - name: Cleaning - run: docker compose down -v - - name: Installing latest version - run: | - rm docker-compose.yml - rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env - sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env - docker compose up -d - sleep 10 - - name: Benchmark Latest - run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json - - name: Prepare comment - run: | - echo '## :sparkles: Benchmark results' > benchmark.txt - echo ' ' >> benchmark.txt - echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt - echo " " >> benchmark.txt - echo " " >> benchmark.txt - echo "## :zap: Benchmark Comparison" >> benchmark.txt - echo " " >> benchmark.txt - echo "| Metric | This PR | Latest version | " >> benchmark.txt - echo "| --- | --- | --- | " >> benchmark.txt - echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt - - name: Save results - uses: actions/upload-artifact@v6 - if: '${{ !cancelled() }}' - with: - name: benchmark.json - path: benchmark.json - retention-days: 7 - - name: Find Comment - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/find-comment@v3 - id: fc - with: - issue-number: '${{ github.event.pull_request.number }}' - comment-author: 'github-actions[bot]' - body-includes: Benchmark results - - name: Comment on PR - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/create-or-update-comment@v4 - with: - comment-id: '${{ steps.fc.outputs.comment-id }}' - issue-number: '${{ github.event.pull_request.number }}' - body-path: benchmark.txt - edit-mode: replace diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml deleted file mode 100644 index 17caf3aa6b..0000000000 --- a/.github/workflows/check-dependencies.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Check dependencies - -# Adapted from https://google.github.io/osv-scanner/github-action/#scan-on-pull-request - -on: - pull_request: - branches: [main, 1.*.x] - merge_group: - branches: [main, 1.*.x] - -permissions: - # Require writing security events to upload SARIF file to security tab - security-events: write - # Only need to read contents - contents: read - -jobs: - scan-pr: - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.7.1" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..e59b14e550 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,651 @@ +name: CI + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + COMPOSE_FILE: docker-compose.yml + IMAGE: appwrite-dev + CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }} + +on: + pull_request: + workflow_dispatch: + inputs: + response_format: + description: 'Response format version to test (e.g., 1.5.0, 1.4.0)' + required: false + type: string + default: '' + +jobs: + dependencies: + name: Checks / Dependencies + if: github.event_name == 'pull_request' + permissions: + actions: read + security-events: write + contents: read + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3" + + security: + name: Checks / Image + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Build the Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: pr_image:${{ github.sha }} + target: production + + - name: Run Trivy vulnerability scanner on image + uses: aquasecurity/trivy-action@0.35.0 + with: + image-ref: 'pr_image:${{ github.sha }}' + format: 'sarif' + output: 'trivy-image-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Run Trivy vulnerability scanner on source code + uses: aquasecurity/trivy-action@0.35.0 + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-fs-results.sarif' + severity: 'CRITICAL,HIGH' + skip-setup-trivy: true + + - name: Upload image scan results + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-image-results.sarif') != '' + with: + sarif_file: 'trivy-image-results.sarif' + category: 'trivy-image' + + - name: Upload source code scan results + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-fs-results.sarif') != '' + with: + sarif_file: 'trivy-fs-results.sarif' + category: 'trivy-source' + + format: + name: Checks / Format + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - run: git checkout HEAD^2 + if: github.event_name == 'pull_request' + + - name: Validate composer.json and composer.lock + run: | + docker run --rm -v $PWD:/app composer:2.8 sh -c \ + "composer validate" + + - name: Run Linter + run: | + docker run --rm -v $PWD:/app composer:2.8 sh -c \ + "composer install --profile --ignore-platform-reqs && composer lint" + + analyze: + name: Checks / Analyze + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Run PHPStan + run: | + docker run --rm -v $PWD:/app composer:2.8 sh -c \ + "composer install --profile --ignore-platform-reqs && composer check" + + - name: Run Locale check + run: | + docker run --rm -v $PWD:/app node:24-alpine sh -c \ + "cd /app/.github/workflows/static-analysis/locale && node index.js" + + matrix: + name: Tests / Matrix + runs-on: ubuntu-latest + outputs: + databases: ${{ steps.generate.outputs.databases }} + modes: ${{ steps.generate.outputs.modes }} + steps: + - name: Generate matrix + id: generate + uses: actions/github-script@v8 + with: + script: | + const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; + const allModes = ['dedicated', 'shared_v1', 'shared_v2']; + + const defaultDatabases = ['MongoDB']; + const defaultModes = ['dedicated']; + + const pr = context.payload.pull_request; + if (!pr) { + core.setOutput('databases', JSON.stringify(allDatabases)); + core.setOutput('modes', JSON.stringify(allModes)); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + + const lockFile = files.find(f => f.filename === 'composer.lock'); + const databaseChanged = lockFile?.patch?.includes('"name": "utopia-php/database"') ?? false; + + core.setOutput('databases', JSON.stringify(databaseChanged ? allDatabases : defaultDatabases)); + core.setOutput('modes', JSON.stringify(databaseChanged ? allModes : defaultModes)); + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build Appwrite + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: ${{ env.IMAGE }} + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar + target: development + build-args: | + DEBUG=false + TESTING=true + VERSION=dev + + - name: Cache Docker Image + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + + unit: + name: Tests / Unit + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + pull-requests: write + steps: + - name: checkout + uses: actions/checkout@v6 + + - name: Load Cache + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Environment Variables + run: docker compose exec -T appwrite vars + + - name: Run Unit Tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/unit + command: >- + docker compose exec + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/unit + + e2e_general: + name: Tests / E2E / General + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + pull-requests: write + steps: + - name: checkout + uses: actions/checkout@v6 + + - name: Load Cache + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Wait for Open Runtimes + timeout-minutes: 3 + run: | + while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do + echo "Waiting for Executor to come online" + sleep 1 + done + + - name: Run General Tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e/General + command: >- + docker compose exec -T + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/e2e/General + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + e2e_service: + name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }} + runs-on: ubuntu-latest + needs: [build, matrix] + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + database: ${{ fromJSON(needs.matrix.outputs.databases) }} + mode: ${{ fromJSON(needs.matrix.outputs.modes) }} + service: [ + Account, + Avatars, + Console, + Databases, + Functions, + FunctionsSchedule, + GraphQL, + Health, + Locale, + Projects, + Realtime, + Sites, + Proxy, + Storage, + Tokens, + Teams, + Users, + Webhooks, + VCS, + Messaging, + Migrations + ] + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Load Cache + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Set database environment + run: | + if [ "${{ matrix.database }}" = "MariaDB" ]; then + echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV + echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV + echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV + echo "_APP_DB_PORT=3306" >> $GITHUB_ENV + elif [ "${{ matrix.database }}" = "MongoDB" ]; then + echo "COMPOSE_PROFILES=mongodb" >> $GITHUB_ENV + echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV + echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV + echo "_APP_DB_PORT=27017" >> $GITHUB_ENV + elif [ "${{ matrix.database }}" = "PostgreSQL" ]; then + echo "COMPOSE_PROFILES=postgresql" >> $GITHUB_ENV + echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV + echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV + echo "_APP_DB_PORT=5432" >> $GITHUB_ENV + fi + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + env: + _APP_BROWSER_HOST: http://invalid-browser/v1 + _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} + _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Wait for Open Runtimes + timeout-minutes: 3 + run: | + while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do + echo "Waiting for Executor to come online" + sleep 1 + done + + - name: Run tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 20 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e/Services/${{ matrix.service }} + command: | + SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}" + + # Services that rely on sequential test method execution (shared static state) + FUNCTIONAL_FLAG="--functional" + case "${{ matrix.service }}" in + Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + esac + + docker compose exec -T \ + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ + appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + e2e_abuse: + name: Tests / E2E / Abuse (${{ matrix.mode }}) + runs-on: ubuntu-latest + needs: [build, matrix] + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + mode: ${{ fromJSON(needs.matrix.outputs.modes) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Load Cache + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + env: + _APP_OPTIONS_ABUSE: enabled + _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} + _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Run tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e + command: >- + docker compose exec -T + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/e2e --group=abuseEnabled + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + e2e_screenshots: + name: Tests / E2E / Screenshots (${{ matrix.mode }}) + runs-on: ubuntu-latest + needs: [build, matrix] + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + mode: ${{ fromJSON(needs.matrix.outputs.modes) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Load Cache + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + env: + _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} + _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Wait for Open Runtimes + timeout-minutes: 3 + run: | + while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do + echo "Waiting for Executor to come online" + sleep 1 + done + + - name: Run tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e/Services/Sites + command: >- + docker compose exec -T + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/e2e/Services/Sites --group=screenshots + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + benchmark: + name: Benchmark + runs-on: ubuntu-latest + needs: build + permissions: + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Load Cache + uses: actions/cache@v5 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + run: | + sed -i 's/traefik/localhost/g' .env + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose up -d + sleep 10 + + - name: Install Oha + run: | + echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list + sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg + sudo apt update + sudo apt install oha + oha --version + + - name: Benchmark PR + run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json' + + - name: Cleaning + run: docker compose down -v + + - name: Installing latest version + run: | + rm docker-compose.yml + rm .env + curl https://appwrite.io/install/compose -o docker-compose.yml + curl https://appwrite.io/install/env -o .env + sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env + docker compose up -d + sleep 10 + + - name: Benchmark Latest + run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json + + - name: Prepare comment + run: | + echo '## :sparkles: Benchmark results' > benchmark.txt + echo ' ' >> benchmark.txt + echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt + echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt + echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt + echo " " >> benchmark.txt + echo " " >> benchmark.txt + echo "## :zap: Benchmark Comparison" >> benchmark.txt + echo " " >> benchmark.txt + echo "| Metric | This PR | Latest version | " >> benchmark.txt + echo "| --- | --- | --- | " >> benchmark.txt + echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt + echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt + echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt + + - name: Save results + uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: benchmark.json + path: benchmark.json + retention-days: 7 + + - name: Find Comment + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/find-comment@v3 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: Benchmark results + + - name: Comment on PR + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: benchmark.txt + edit-mode: replace diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index f4ae5df1ce..0000000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: "Linter" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: [pull_request] -jobs: - lint: - name: Linter - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - run: git checkout HEAD^2 - - - name: Validate composer.json and composer.lock - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer validate" - - name: Run Linter - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer install --profile --ignore-platform-reqs && composer lint" diff --git a/.github/workflows/pr-scan.yml b/.github/workflows/pr-scan.yml deleted file mode 100644 index 51f3460d03..0000000000 --- a/.github/workflows/pr-scan.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: PR Security Scan -on: - pull_request_target: - types: [opened, synchronize, reopened] - -jobs: - scan: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Check out code - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - submodules: 'recursive' - - - name: Build the Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: false - load: true - tags: pr_image:${{ github.sha }} - target: production - - - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@0.20.0 - with: - image-ref: 'pr_image:${{ github.sha }}' - format: 'json' - output: 'trivy-image-results.json' - severity: 'CRITICAL,HIGH' - - - name: Run Trivy vulnerability scanner on source code - uses: aquasecurity/trivy-action@0.20.0 - with: - scan-type: 'fs' - scan-ref: '.' - format: 'json' - output: 'trivy-fs-results.json' - severity: 'CRITICAL,HIGH' - - - name: Process Trivy scan results - id: process-results - uses: actions/github-script@v8 - with: - script: | - const fs = require('fs'); - let commentBody = '## Security Scan Results for PR\n\n'; - - function processResults(results, title) { - let sectionBody = `### ${title}\n\n`; - if (results.Results && results.Results.some(result => result.Vulnerabilities && result.Vulnerabilities.length > 0)) { - sectionBody += '| Package | Version | Vulnerability | Severity |\n'; - sectionBody += '|---------|---------|----------------|----------|\n'; - - const uniqueVulns = new Set(); - results.Results.forEach(result => { - if (result.Vulnerabilities) { - result.Vulnerabilities.forEach(vuln => { - const vulnKey = `${vuln.PkgName}-${vuln.InstalledVersion}-${vuln.VulnerabilityID}`; - if (!uniqueVulns.has(vulnKey)) { - uniqueVulns.add(vulnKey); - sectionBody += `| ${vuln.PkgName} | ${vuln.InstalledVersion} | [${vuln.VulnerabilityID}](https://nvd.nist.gov/vuln/detail/${vuln.VulnerabilityID}) | ${vuln.Severity} |\n`; - } - }); - } - }); - } else { - sectionBody += '🎉 No vulnerabilities found!\n'; - } - return sectionBody; - } - - try { - const imageResults = JSON.parse(fs.readFileSync('trivy-image-results.json', 'utf8')); - const fsResults = JSON.parse(fs.readFileSync('trivy-fs-results.json', 'utf8')); - - commentBody += processResults(imageResults, "Docker Image Scan Results"); - commentBody += '\n'; - commentBody += processResults(fsResults, "Source Code Scan Results"); - - } catch (error) { - commentBody += `There was an error while running the security scan: ${error.message}\n`; - commentBody += 'Please contact the core team for assistance.'; - } - - core.setOutput('comment-body', commentBody); - - name: Find Comment - uses: peter-evans/find-comment@v3 - id: fc - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: Security Scan Results for PR - - - name: Create or update comment - uses: peter-evans/create-or-update-comment@v3 - with: - issue-number: ${{ github.event.pull_request.number }} - comment-id: ${{ steps.fc.outputs.comment-id }} - body: ${{ steps.process-results.outputs.comment-body }} - edit-mode: replace diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5987eeeb0c..6e4a8ba73b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days." diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index a0dc38b3b4..0000000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: "Static code analysis" - -on: [pull_request] -jobs: - lint: - name: CodeQL - runs-on: ubuntu-latest - - steps: - - name: Check out the repo - uses: actions/checkout@v6 - - - name: Run CodeQL - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer install --profile --ignore-platform-reqs && composer check" - - - name: Run Locale check - run: | - docker run --rm -v $PWD:/app node:24-alpine sh -c \ - "cd /app/.github/workflows/static-analysis/locale && node index.js" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index b738505894..0000000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,691 +0,0 @@ -name: "Tests" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - COMPOSE_FILE: docker-compose.yml - IMAGE: appwrite-dev - CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }} - -on: - pull_request: - workflow_dispatch: - inputs: - response_format: - description: 'Response format version to test (e.g., 1.5.0, 1.4.0)' - required: false - type: string - default: '' - -jobs: - check_database_changes: - name: Check if utopia-php/database changed - runs-on: ubuntu-latest - outputs: - database_changed: ${{ steps.check.outputs.database_changed }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Fetch base branch - run: git fetch origin ${{ github.event.pull_request.base.ref }} - - - name: Check for utopia-php/database changes - id: check - run: | - if git diff origin/${{ github.event.pull_request.base.ref }} HEAD -- composer.lock | grep -q '"name": "utopia-php/database"'; then - echo "Database version changed, going to run all mode tests." - echo "database_changed=true" >> "$GITHUB_ENV" - echo "database_changed=true" >> "$GITHUB_OUTPUT" - else - echo "database_changed=false" >> "$GITHUB_ENV" - echo "database_changed=false" >> "$GITHUB_OUTPUT" - fi - - setup: - name: Setup & Build Appwrite Image - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Appwrite - uses: docker/build-push-action@v6 - with: - context: . - push: false - tags: ${{ env.IMAGE }} - load: true - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar - target: development - build-args: | - DEBUG=false - TESTING=true - VERSION=dev - - - name: Cache Docker Image - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - - unit_test: - name: Unit Test - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Environment Variables - run: docker compose exec -T appwrite vars - - - name: Run Unit Tests - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/unit - command: >- - docker compose exec - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/unit - - e2e_general_test: - name: E2E General Test - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run General Tests - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/General - command: >- - docker compose exec -T - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/e2e/General --debug - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Logs ===" - docker compose logs - - e2e_service_test: - name: E2E Service Test - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - db_adapter: [ - MARIADB, - POSTGRESQL, - MONGODB - ] - service: [ - Account, - Avatars, - Console, - Databases, - Functions, - FunctionsSchedule, - GraphQL, - Health, - Locale, - Projects, - Realtime, - Sites, - Proxy, - Storage, - Tokens, - Teams, - Users, - Webhooks, - VCS, - Messaging, - Migrations - ] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Set DB Adapter environment - id: set-db-env - run: | - DB_ADAPTER_LOWER=$(echo "${{ matrix.db_adapter }}" | tr 'A-Z' 'a-z') - echo "COMPOSE_PROFILES=${DB_ADAPTER_LOWER}" >> $GITHUB_ENV - - if [ "${{ matrix.db_adapter }}" = "MARIADB" ]; then - echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV - echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV - echo "_APP_DB_PORT=3306" >> $GITHUB_ENV - elif [ "${{ matrix.db_adapter }}" = "MONGODB" ]; then - echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV - echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV - echo "_APP_DB_PORT=27017" >> $GITHUB_ENV - elif [ "${{ matrix.db_adapter }}" = "POSTGRESQL" ]; then - echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV - echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV - echo "_APP_DB_PORT=5432" >> $GITHUB_ENV - fi - - - name: Load and Start Appwrite - timeout-minutes: 3 - env: - _APP_BROWSER_HOST: http://invalid-browser/v1 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run ${{ matrix.service }} tests with Project table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 20 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/${{ matrix.service }} - command: | - echo "Using project tables" - SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}" - - # Services that rely on sequential test method execution (shared static state) - FUNCTIONAL_FLAG="--functional" - case "${{ matrix.service }}" in - Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;; - esac - - echo "Running with paratest (parallel) for: ${{ matrix.service }} ${FUNCTIONAL_FLAG:+(functional)}" - docker compose exec -T \ - -e _APP_DATABASE_SHARED_TABLES="" \ - -e _APP_DATABASE_SHARED_TABLES_V1="" \ - -e _APP_DB_ADAPTER="${{ env._APP_DB_ADAPTER }}" \ - -e _APP_DB_HOST="${{ env._APP_DB_HOST }}" \ - -e _APP_DB_PORT="${{ env._APP_DB_PORT }}" \ - -e _APP_DB_SCHEMA=appwrite \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Logs ===" - docker compose logs - - e2e_shared_mode_test: - name: E2E Shared Mode Service Test - runs-on: ubuntu-latest - needs: [ setup, check_database_changes ] - if: needs.check_database_changes.outputs.database_changed == 'true' - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - service: - [ - Account, - Avatars, - Console, - Databases, - Functions, - FunctionsSchedule, - GraphQL, - Health, - Locale, - Projects, - Realtime, - Sites, - Proxy, - Storage, - Teams, - Users, - Webhooks, - VCS, - Messaging, - Migrations, - Tokens - ] - tables-mode: [ - 'Shared V1', - 'Shared V2', - ] - - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run ${{ matrix.service }} tests with ${{ matrix.tables-mode }} table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 20 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/${{ matrix.service }} - command: | - if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then - echo "Using shared tables V1" - export _APP_DATABASE_SHARED_TABLES=database_db_main - export _APP_DATABASE_SHARED_TABLES_V1=database_db_main - elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then - echo "Using shared tables V2" - export _APP_DATABASE_SHARED_TABLES=database_db_main - export _APP_DATABASE_SHARED_TABLES_V1= - fi - - SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}" - - # Services that rely on sequential test method execution (shared static state) - FUNCTIONAL_FLAG="--functional" - case "${{ matrix.service }}" in - Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;; - esac - - docker compose exec -T \ - -e _APP_DATABASE_SHARED_TABLES \ - -e _APP_DATABASE_SHARED_TABLES_V1 \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_abuse_enabled: - name: E2E Service Test (Abuse enabled) - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Run Projects tests in dedicated table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/Projects - command: | - echo "Using project tables" - export _APP_DATABASE_SHARED_TABLES= - export _APP_DATABASE_SHARED_TABLES_V1= - - docker compose exec -T \ - -e _APP_DATABASE_SHARED_TABLES \ - -e _APP_DATABASE_SHARED_TABLES_V1 \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) /usr/src/code/tests/e2e/Services/Projects --group abuseEnabled - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_abuse_enabled_shared_mode: - name: E2E Shared Mode Service Test (Abuse enabled) - runs-on: ubuntu-latest - needs: [ setup, check_database_changes ] - if: needs.check_database_changes.outputs.database_changed == 'true' - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - tables-mode: [ - 'Shared V1', - 'Shared V2', - ] - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Run Projects tests in ${{ matrix.tables-mode }} table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/Projects - command: | - if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then - echo "Using shared tables V1" - export _APP_DATABASE_SHARED_TABLES=database_db_main - export _APP_DATABASE_SHARED_TABLES_V1=database_db_main - elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then - echo "Using shared tables V2" - export _APP_DATABASE_SHARED_TABLES=database_db_main - export _APP_DATABASE_SHARED_TABLES_V1= - fi - - docker compose exec -T \ - -e _APP_DATABASE_SHARED_TABLES \ - -e _APP_DATABASE_SHARED_TABLES_V1 \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) /usr/src/code/tests/e2e/Services/Projects --group abuseEnabled - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_screenshots: - name: E2E Service Test (Site Screenshots) - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run Site tests with browser connected in dedicated table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/Sites - command: | - echo "Keeping original value of _APP_BROWSER_HOST" - echo "Using project tables" - export _APP_DATABASE_SHARED_TABLES= - export _APP_DATABASE_SHARED_TABLES_V1= - - docker compose exec -T \ - -e _APP_DATABASE_SHARED_TABLES \ - -e _APP_DATABASE_SHARED_TABLES_V1 \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) /usr/src/code/tests/e2e/Services/Sites --group screenshots - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_screenshots_shared_mode: - name: E2E Shared Mode Service Test (Site Screenshots) - runs-on: ubuntu-latest - needs: [ setup, check_database_changes ] - if: needs.check_database_changes.outputs.database_changed == 'true' - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - tables-mode: [ - 'Shared V1', - 'Shared V2', - ] - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v4 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Load and Start Appwrite - timeout-minutes: 3 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env - docker compose up -d - until docker compose exec -T appwrite doctor > /dev/null 2>&1; do - echo "Waiting for Appwrite to be ready..." - sleep 2 - done - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run Site tests with browser connected in ${{ matrix.tables-mode }} table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 300 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/Sites - command: | - echo "Keeping original value of _APP_BROWSER_HOST" - if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then - echo "Using shared tables V1" - export _APP_DATABASE_SHARED_TABLES=database_db_main - export _APP_DATABASE_SHARED_TABLES_V1=database_db_main - elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then - echo "Using shared tables V2" - export _APP_DATABASE_SHARED_TABLES=database_db_main - export _APP_DATABASE_SHARED_TABLES_V1= - fi - - docker compose exec -T \ - -e _APP_DATABASE_SHARED_TABLES \ - -e _APP_DATABASE_SHARED_TABLES_V1 \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) /usr/src/code/tests/e2e/Services/Sites --group screenshots - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor diff --git a/Dockerfile b/Dockerfile index d3b5b486b7..7cb007c188 100755 --- a/Dockerfile +++ b/Dockerfile @@ -70,7 +70,7 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/sdks && \ chmod +x /usr/local/bin/specs && \ chmod +x /usr/local/bin/ssl && \ - chmod +x /usr/local/bin/time-travel && \ + chmod +x /usr/local/bin/task-time-travel && \ chmod +x /usr/local/bin/screenshot && \ chmod +x /usr/local/bin/test && \ chmod +x /usr/local/bin/upgrade && \ diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index d0e5e19a51..9d04018b10 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1560,12 +1560,15 @@ Http::patch('/v1/users/:userId/phone') $oldPhone = $user->getAttribute('phone'); + // Store null instead of empty string so unique constraint allows multiple users without phone + $phoneValue = $number !== '' ? $number : null; + $user - ->setAttribute('phone', $number) + ->setAttribute('phone', $phoneValue) ->setAttribute('phoneVerification', false) ; - if (\strlen($number) !== 0) { + if ($number !== '') { $target = $dbForProject->findOne('targets', [ Query::equal('identifier', [$number]), ]); @@ -1577,7 +1580,7 @@ Http::patch('/v1/users/:userId/phone') try { $user = $dbForProject->updateDocument('users', $user->getId(), new Document([ - 'phone' => $user->getAttribute('phone'), + 'phone' => $phoneValue, 'phoneVerification' => $user->getAttribute('phoneVerification'), ])); /** @@ -1586,14 +1589,14 @@ Http::patch('/v1/users/:userId/phone') $oldTarget = $user->find('identifier', $oldPhone, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { - if (\strlen($number) !== 0) { + if ($number !== '') { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number])); $oldTarget->setAttribute('identifier', $number); } else { $dbForProject->deleteDocument('targets', $oldTarget->getId()); } } else { - if (\strlen($number) !== 0) { + if ($number !== '') { $target = $dbForProject->createDocument('targets', new Document([ '$permissions' => [ Permission::read(Role::user($user->getId())), diff --git a/app/controllers/general.php b/app/controllers/general.php index 73e302edf0..1a099c4bde 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1399,6 +1399,9 @@ Http::error() $sdk = $route?->getLabel("sdk", false); $action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD'; if (!empty($sdk)) { + if (\is_array($sdk)) { + $sdk = $sdk[0]; + } /** @var \Appwrite\SDK\Method $sdk */ $action = $sdk->getNamespace() . '.' . $sdk->getMethodName(); } elseif ($route === null) { diff --git a/app/http.php b/app/http.php index 7f771de130..1302940856 100644 --- a/app/http.php +++ b/app/http.php @@ -581,6 +581,9 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool $action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD'; if (!empty($sdk)) { + if (\is_array($sdk)) { + $sdk = $sdk[0]; + } /** @var Appwrite\SDK\Method $sdk */ $action = $sdk->getNamespace() . '.' . $sdk->getMethodName(); } elseif ($route === null) { diff --git a/app/init/constants.php b/app/init/constants.php index 0cfaf44920..7a484c7f4e 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -362,6 +362,12 @@ const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated'; const METRIC_FUNCTIONS_RUNTIME = 'functions.runtimes.{runtime}'; const METRIC_SITES_FRAMEWORK = 'sites.frameworks.{framework}'; +// Realtime metrics +const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; +const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; +const METRIC_REALTIME_INBOUND = 'realtime.inbound'; +const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; + // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; const RESOURCE_TYPE_FUNCTIONS = 'functions'; diff --git a/app/init/registers.php b/app/init/registers.php index 26a9329270..7b68c2af9a 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -420,6 +420,7 @@ $register->set('smtp', function () { $mail->Password = $password; $mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', ''); $mail->SMTPAutoTLS = false; + $mail->SMTPKeepAlive = true; $mail->CharSet = 'UTF-8'; $mail->Timeout = 10; /* Connection timeout */ $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ diff --git a/app/init/span.php b/app/init/span.php index 76f37f5300..8afa01b2df 100644 --- a/app/init/span.php +++ b/app/init/span.php @@ -5,4 +5,9 @@ use Utopia\Span\Span; use Utopia\Span\Storage; Span::setStorage(new Storage\Coroutine()); -Span::addExporter(new Exporter\Pretty()); +Span::addExporter(new Exporter\Pretty(), function (Span $span): bool { + if (\str_starts_with($span->getAction(), 'listener.')) { + return $span->getError() !== null; + } + return true; +}); diff --git a/app/realtime.php b/app/realtime.php index e0591a2596..5addb2a78f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -224,6 +224,13 @@ if (!function_exists('getTelemetry')) { } } +if (!function_exists('triggerStats')) { + function triggerStats(array $event, string $projectId): void + { + return; + } +} + $realtime = getRealtime(); /** @@ -548,20 +555,41 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, } $total = 0; + $outboundBytes = 0; + foreach ($groups as $group) { $data = $event['data']; $data['subscriptions'] = $group['subscriptions']; - $server->send($group['ids'], json_encode([ + $payloadJson = json_encode([ 'type' => 'event', 'data' => $data - ])); - $total += count($group['ids']); + ]); + + $server->send($group['ids'], $payloadJson); + + $count = count($group['ids']); + $total += $count; + $outboundBytes += strlen($payloadJson) * $count; } if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); $stats->incr($event['project'], 'messages', $total); + + $projectId = $event['project'] ?? null; + + if (!empty($projectId)) { + $metrics = [ + METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, + ]; + + if ($outboundBytes > 0) { + $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; + } + + triggerStats($metrics, $projectId); + } } }); } catch (Throwable $th) { @@ -638,6 +666,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests'); } + $rawSize = $request->getSize(); + + triggerStats([ + METRIC_REALTIME_INBOUND => $rawSize, + ], $project->getId()); + /* * Validate Client Domain - Check to avoid CSRF attack. * Adding Appwrite API domains to allow XDOMAIN communication. @@ -692,14 +726,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $user = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT); - $server->send([$connection], json_encode([ + $connectedPayloadJson = json_encode([ 'type' => 'connected', 'data' => [ 'channels' => $names, 'subscriptions' => $mapping, 'user' => $user ] - ])); + ]); + + $server->send([$connection], $connectedPayloadJson); $register->get('telemetry.connectionCounter')->add(1); $register->get('telemetry.connectionCreatedCounter')->add(1); @@ -710,6 +746,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $stats->incr($project->getId(), 'connections'); $stats->incr($project->getId(), 'connectionsTotal'); + + $connectedOutboundBytes = \strlen($connectedPayloadJson); + + triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId()); + + } catch (Throwable $th) { logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); @@ -751,6 +793,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = null; try { + $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -763,7 +806,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $database = getConsoleDB(); $database->setAuthorization($authorization); - if ($projectId !== 'console') { + if (!empty($projectId) && $projectId !== 'console') { $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId)); $database = getProjectDB($project); @@ -789,17 +832,41 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many messages.'); } + // Record realtime inbound bytes for this project + if ($project !== null && !$project->isEmpty()) { + triggerStats([ + METRIC_REALTIME_INBOUND => $rawSize, + ], $project->getId()); + } + $message = json_decode($message, true); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) + if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); + } + switch ($message['type']) { case 'ping': - $server->send([$connection], json_encode([ + $pongPayloadJson = json_encode([ 'type' => 'pong' - ])); + ]); + + $server->send([$connection], $pongPayloadJson); + + if ($project !== null && !$project->isEmpty()) { + $pongOutboundBytes = \strlen($pongPayloadJson); + + if ($pongOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $pongOutboundBytes, + ], $project->getId()); + } + } break; case 'authentication': @@ -860,14 +927,27 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $user = $response->output($user, Response::MODEL_ACCOUNT); - $server->send([$connection], json_encode([ + + $authResponsePayloadJson = json_encode([ 'type' => 'response', 'data' => [ 'to' => 'authentication', 'success' => true, 'user' => $user ] - ])); + ]); + + $server->send([$connection], $authResponsePayloadJson); + + if ($project !== null && !$project->isEmpty()) { + $authOutboundBytes = \strlen($authResponsePayloadJson); + + if ($authOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $authOutboundBytes, + ], $project->getId()); + } + } break; @@ -908,6 +988,12 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); + + $projectId = $realtime->connections[$connection]['projectId']; + + triggerStats([ + METRIC_REALTIME_CONNECTIONS => -1, + ], $projectId); } $realtime->unsubscribe($connection); diff --git a/bin/time-travel b/bin/task-time-travel similarity index 100% rename from bin/time-travel rename to bin/task-time-travel diff --git a/composer.json b/composer.json index 991f32c5bd..c245781deb 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", - "utopia-php/migration": "1.6.*", + "utopia-php/migration": "1.7.*", "utopia-php/platform": "0.7.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index e497c59338..73f2b84625 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1cc64e07484256225f56bd525674c3b8", + "content-hash": "b99693284208ff3d006260a089a4f7b9", "packages": [ { "name": "adhocore/jwt", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.7", + "version": "5.3.8", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "438cc82af2981cd41ad200dd9b0df5bf00f3046a" + "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/438cc82af2981cd41ad200dd9b0df5bf00f3046a", - "reference": "438cc82af2981cd41ad200dd9b0df5bf00f3046a", + "url": "https://api.github.com/repos/utopia-php/database/zipball/4920bb60afb98d4bd81f4d331765716ae1d40255", + "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255", "shasum": "" }, "require": { @@ -3902,9 +3902,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.7" + "source": "https://github.com/utopia-php/database/tree/5.3.8" }, - "time": "2026-03-09T04:28:56+00:00" + "time": "2026-03-11T01:03:34+00:00" }, { "name": "utopia-php/detector", @@ -4058,16 +4058,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.5", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6" + "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6", + "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6", "shasum": "" }, "require": { @@ -4114,9 +4114,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.5" + "source": "https://github.com/utopia-php/domains/tree/1.0.2" }, - "time": "2026-03-03T09:20:50+00:00" + "time": "2026-02-25T08:18:25+00:00" }, { "name": "utopia-php/dsn", @@ -4517,16 +4517,16 @@ }, { "name": "utopia-php/migration", - "version": "1.6.3", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c2d016944cb029fa5ff822ceee704785a06ef289" + "reference": "97583ae502e40621ea91a71de19d053c5ae2e558" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c2d016944cb029fa5ff822ceee704785a06ef289", - "reference": "c2d016944cb029fa5ff822ceee704785a06ef289", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/97583ae502e40621ea91a71de19d053c5ae2e558", + "reference": "97583ae502e40621ea91a71de19d053c5ae2e558", "shasum": "" }, "require": { @@ -4566,9 +4566,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.6.3" + "source": "https://github.com/utopia-php/migration/tree/1.7.0" }, - "time": "2026-03-04T07:08:22+00:00" + "time": "2026-03-10T06:36:27+00:00" }, { "name": "utopia-php/mongo", @@ -5215,16 +5215,16 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920" + "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/92a1650824ba0c5e6a1bc46e622ac87c50a08920", - "reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/058049326e04a2a0c2f0ce8ad00c7e84825aba14", + "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14", "shasum": "" }, "require": { @@ -5258,9 +5258,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.1" + "source": "https://github.com/utopia-php/vcs/tree/2.0.0" }, - "time": "2026-02-27T12:18:49+00:00" + "time": "2026-02-25T11:36:45+00:00" }, { "name": "utopia-php/websocket", @@ -5438,16 +5438,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.6", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38" + "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38", - "reference": "f80e302d000cdc2f98b4bb5ff2fc3bd0bdff7b38", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb", + "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb", "shasum": "" }, "require": { @@ -5483,9 +5483,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.11.6" + "source": "https://github.com/appwrite/sdk-generator/tree/1.11.1" }, - "time": "2026-03-09T07:12:51+00:00" + "time": "2026-02-25T07:15:19+00:00" }, { "name": "brianium/paratest", @@ -5512,7 +5512,7 @@ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", "phpunit/php-file-iterator": "^6.0.1 || ^7", "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "phpunit/phpunit": "^12.5.9 || ^13", "sebastian/environment": "^8.0.3 || ^9", "symfony/console": "^7.4.7 || ^8.0.7", "symfony/process": "^7.4.5 || ^8.0.5" @@ -6398,16 +6398,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.5.1", + "version": "1.4.3", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "9a28fd0833f11171b949843c6fd663eb69b6d14c" + "reference": "b641dde59d969ea42eed70a39f9b51950bc96878" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/9a28fd0833f11171b949843c6fd663eb69b6d14c", - "reference": "9a28fd0833f11171b949843c6fd663eb69b6d14c", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/b641dde59d969ea42eed70a39f9b51950bc96878", + "reference": "b641dde59d969ea42eed70a39f9b51950bc96878", "shasum": "" }, "require": { @@ -6418,7 +6418,7 @@ "ext-reflection": "*", "ext-spl": "*", "ext-tokenizer": "*", - "php": "^8.2", + "php": "^8.1", "phpbench/container": "^2.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "seld/jsonlint": "^1.1", @@ -6438,9 +6438,8 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^11.5", + "phpunit/phpunit": "^10.4 || ^11.0", "rector/rector": "^1.2", - "sebastian/exporter": "^6.3.2", "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" }, @@ -6485,7 +6484,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.5.1" + "source": "https://github.com/phpbench/phpbench/tree/1.4.3" }, "funding": [ { @@ -6493,15 +6492,15 @@ "type": "github" } ], - "time": "2026-03-05T08:18:58+00:00" + "time": "2025-11-06T19:07:31+00:00" }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "1.12.32", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", "shasum": "" }, "require": { @@ -6546,7 +6545,7 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2025-09-30T10:16:31+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8096,16 +8095,16 @@ }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", "shasum": "" }, "require": { @@ -8162,7 +8161,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.4" }, "funding": [ { @@ -8182,20 +8181,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-01-13T13:06:50+00:00" }, { "name": "symfony/filesystem", - "version": "v8.0.6", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", "shasum": "" }, "require": { @@ -8232,7 +8231,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" }, "funding": [ { @@ -8252,20 +8251,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/finder", - "version": "v8.0.6", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", "shasum": "" }, "require": { @@ -8300,7 +8299,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.6" + "source": "https://github.com/symfony/finder/tree/v8.0.5" }, "funding": [ { @@ -8320,7 +8319,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:41:02+00:00" + "time": "2026-01-26T15:08:38+00:00" }, { "name": "symfony/options-resolver", @@ -8790,16 +8789,16 @@ }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "758b372d6882506821ed666032e43020c4f57194" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", "shasum": "" }, "require": { @@ -8856,7 +8855,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.4" }, "funding": [ { @@ -8876,7 +8875,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "textalk/websocket", diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000000..29182f01d2 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,144 @@ +# Dev tools for local development only. +# This file is automatically loaded by `docker compose` alongside docker-compose.yml. +# CI sets COMPOSE_FILE=docker-compose.yml explicitly, so these services are excluded from test runs. + +services: + appwrite-mongo-express: + profiles: ["mongodb"] + image: mongo-express + container_name: appwrite-mongo-express + networks: + - appwrite + ports: + - "8082:8081" + environment: + ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true" + ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER} + ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS} + depends_on: + - mongodb + + adminer: + image: adminer + container_name: appwrite-adminer + restart: always + ports: + - 9506:8080 + networks: + - appwrite + - gateway + environment: + - ADMINER_DESIGN=pepa-linha + - ADMINER_DEFAULT_SERVER=mariadb + - ADMINER_DEFAULT_USERNAME=root + - ADMINER_DEFAULT_PASSWORD=rootsecretpassword + - ADMINER_DEFAULT_DB=appwrite + configs: + - source: adminer-index.php + target: /var/www/html/index.php + mode: 0755 + labels: + - "traefik.enable=true" + - "traefik.constraint-label-stack=appwrite" + - "traefik.docker.network=gateway" + - "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080" + - "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web" + - "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)" + - "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer" + - "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure" + - "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)" + - "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer" + - "traefik.http.routers.appwrite_adminer_https.tls=true" + + redis-insight: + image: redis/redisinsight:latest + restart: unless-stopped + networks: + - appwrite + - gateway + environment: + - RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json + configs: + - source: redisinsight-connections.json + target: /mnt/connections.json + mode: 0755 + labels: + - "traefik.enable=true" + - "traefik.constraint-label-stack=appwrite" + - "traefik.docker.network=gateway" + - "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540" + - "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web" + - "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)" + - "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight" + - "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure" + - "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)" + - "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight" + - "traefik.http.routers.appwrite_redisinsight_https.tls=true" + ports: + - "8081:5540" + + graphql-explorer: + container_name: appwrite-graphql-explorer + image: appwrite/altair:0.3.0 + restart: unless-stopped + networks: + - appwrite + ports: + - "9509:3000" + environment: + - SERVER_URL=http://localhost/v1/graphql + +configs: + redisinsight-connections.json: + content: | + [ + { + "compressor": "NONE", + "id": "104dc90a-21ef-4d5e-8912-b30baabb152f", + "host": "redis", + "port": 6379, + "name": "redis:6379", + "db": 0, + "username": "default", + "password": null, + "connectionType": "STANDALONE", + "nameFromProvider": null, + "provider": "REDIS", + "lastConnection": "2025-10-16T09:22:02.591Z", + "modules": [ + { + "name": "ReJSON", + "version": 20808, + "semanticVersion": "2.8.8" + }, + { + "name": "search", + "version": 21015, + "semanticVersion": "2.10.15" + } + ], + "tls": false, + "tlsServername": null, + "verifyServerCert": null, + "caCert": null, + "clientCert": null, + "ssh": false, + "sshOptions": null, + "forceStandalone": false, + "tags": [] + } + ] + + adminer-index.php: + content: | + $$_ENV['ADMINER_DEFAULT_SERVER'], + 'driver' => 'server', /* seems to autodetect the driver from server settings */ + 'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'], + 'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'], + 'db' => $$_ENV['ADMINER_DEFAULT_DB'], + ]; + } + include './adminer.php'; diff --git a/docker-compose.yml b/docker-compose.yml index 0e3945da76..6aaa773ef3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,15 @@ x-logging: &x-logging max-file: "5" max-size: "10m" +x-build: &x-build + build: + context: . + target: development + args: + DEBUG: false + TESTING: true + VERSION: dev + services: traefik: image: traefik:3.6 @@ -50,15 +59,13 @@ services: appwrite: container_name: appwrite - <<: *x-logging + <<: [*x-logging, *x-build] image: appwrite-dev - build: - context: . - target: development - args: - DEBUG: false - TESTING: true - VERSION: dev + healthcheck: + test: ["CMD", "doctor"] + interval: 5s + timeout: 5s + retries: 12 ports: - 9501:80 networks: @@ -101,10 +108,10 @@ services: - ./dev:/usr/src/code/dev depends_on: - - ${_APP_DB_HOST:-mongodb} - - redis - - coredns - # - clamav + redis: + condition: service_healthy + coredns: + condition: service_started entrypoint: - php - -e @@ -260,7 +267,7 @@ services: appwrite-realtime: entrypoint: realtime - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-realtime image: appwrite-dev restart: unless-stopped @@ -312,7 +319,7 @@ services: appwrite-worker-audits: entrypoint: worker-audits - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-audits image: appwrite-dev networks: @@ -343,7 +350,7 @@ services: appwrite-worker-webhooks: entrypoint: worker-webhooks - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-webhooks image: appwrite-dev networks: @@ -378,7 +385,7 @@ services: appwrite-worker-deletes: entrypoint: worker-deletes - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-deletes image: appwrite-dev networks: @@ -443,7 +450,7 @@ services: appwrite-worker-databases: entrypoint: worker-databases - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-databases image: appwrite-dev networks: @@ -476,7 +483,7 @@ services: appwrite-worker-builds: entrypoint: worker-builds - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-builds image: appwrite-dev networks: @@ -551,7 +558,7 @@ services: appwrite-worker-screenshots: entrypoint: worker-screenshots - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-screenshots image: appwrite-dev networks: @@ -614,7 +621,7 @@ services: appwrite-worker-certificates: entrypoint: worker-certificates - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-certificates image: appwrite-dev networks: @@ -656,7 +663,7 @@ services: appwrite-worker-executions: entrypoint: worker-executions - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-executions image: appwrite-dev networks: @@ -686,7 +693,7 @@ services: appwrite-worker-functions: entrypoint: worker-functions - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-functions image: appwrite-dev networks: @@ -730,7 +737,7 @@ services: appwrite-worker-mails: entrypoint: worker-mails - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-mails image: appwrite-dev networks: @@ -772,7 +779,7 @@ services: appwrite-worker-messaging: entrypoint: worker-messaging - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-messaging restart: unless-stopped image: appwrite-dev @@ -829,7 +836,7 @@ services: appwrite-worker-migrations: entrypoint: worker-migrations - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-migrations restart: unless-stopped image: appwrite-dev @@ -874,7 +881,7 @@ services: appwrite-task-maintenance: entrypoint: maintenance - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-task-maintenance image: appwrite-dev networks: @@ -920,7 +927,7 @@ services: appwrite-task-interval: entrypoint: interval - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-task-interval image: appwrite-dev networks: @@ -961,7 +968,7 @@ services: appwrite-task-stats-resources: container_name: appwrite-task-stats-resources entrypoint: stats-resources - <<: *x-logging + <<: [*x-logging, *x-build] image: appwrite-dev networks: - appwrite @@ -993,7 +1000,7 @@ services: appwrite-worker-stats-resources: entrypoint: worker-stats-resources - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-stats-resources image: appwrite-dev networks: @@ -1026,7 +1033,7 @@ services: appwrite-worker-stats-usage: entrypoint: worker-stats-usage - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-worker-stats-usage image: appwrite-dev networks: @@ -1059,7 +1066,7 @@ services: appwrite-task-scheduler-functions: entrypoint: schedule-functions - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-task-scheduler-functions image: appwrite-dev networks: @@ -1089,7 +1096,7 @@ services: appwrite-task-scheduler-executions: entrypoint: schedule-executions - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-task-scheduler-executions image: appwrite-dev networks: @@ -1118,7 +1125,7 @@ services: appwrite-task-scheduler-messages: entrypoint: schedule-messages - <<: *x-logging + <<: [*x-logging, *x-build] container_name: appwrite-task-scheduler-messages image: appwrite-dev networks: @@ -1237,6 +1244,11 @@ services: - MYSQL_PASSWORD=${_APP_DB_PASS} - MARIADB_AUTO_UPGRADE=1 command: "mysqld --innodb-flush-method=fsync" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 12 mongodb: profiles: ["mongodb"] @@ -1275,20 +1287,7 @@ services: retries: 10 start_period: 30s - appwrite-mongo-express: - profiles: ["mongodb"] - image: mongo-express - container_name: appwrite-mongo-express - networks: - - appwrite - ports: - - "8082:8081" - environment: - ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true" - ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER} - ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS} - depends_on: - - mongodb + postgresql: profiles: ["postgresql"] @@ -1309,6 +1308,11 @@ services: - POSTGRES_USER=${_APP_DB_USER} - POSTGRES_PASSWORD=${_APP_DB_PASS} command: "postgres" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER}"] + interval: 5s + timeout: 5s + retries: 12 redis: image: redis:7.4.7-alpine @@ -1325,6 +1329,11 @@ services: - appwrite volumes: - appwrite-redis:/data:rw + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 12 coredns: # DNS server for testing purposes (Proxy APIs) image: coredns/coredns:1.12.4 @@ -1396,78 +1405,8 @@ services: networks: - appwrite - adminer: - image: adminer - container_name: appwrite-adminer - <<: *x-logging - restart: always - ports: - - 9506:8080 - networks: - - appwrite - - gateway - environment: - - ADMINER_DESIGN=pepa-linha - - ADMINER_DEFAULT_SERVER=mariadb - - ADMINER_DEFAULT_USERNAME=root - - ADMINER_DEFAULT_PASSWORD=rootsecretpassword - - ADMINER_DEFAULT_DB=appwrite - configs: - - source: adminer-index.php - target: /var/www/html/index.php - mode: 0755 - labels: - - "traefik.enable=true" - - "traefik.constraint-label-stack=appwrite" - - "traefik.docker.network=gateway" - - "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080" - - "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web" - - "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)" - - "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer" - - "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure" - - "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)" - - "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer" - - "traefik.http.routers.appwrite_adminer_https.tls=true" - - redis-insight: - image: redis/redisinsight:latest - restart: unless-stopped - networks: - - appwrite - - gateway - environment: - - RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json - configs: - - source: redisinsight-connections.json - target: /mnt/connections.json - mode: 0755 - labels: - - "traefik.enable=true" - - "traefik.constraint-label-stack=appwrite" - - "traefik.docker.network=gateway" - - "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540" - - "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web" - - "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)" - - "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight" - - "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure" - - "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)" - - "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight" - - "traefik.http.routers.appwrite_redisinsight_https.tls=true" - ports: - - "8081:5540" - - graphql-explorer: - container_name: appwrite-graphql-explorer - image: appwrite/altair:0.3.0 - restart: unless-stopped - networks: - - appwrite - ports: - - "9509:3000" - environment: - - SERVER_URL=http://localhost/v1/graphql - - # Dev Tools End ------------------------------------------------------------------------------------------ + # Dev tools (adminer, redis-insight, mongo-express, graphql-explorer) + # are defined in docker-compose.override.yml networks: gateway: @@ -1480,60 +1419,7 @@ networks: runtimes: name: runtimes -configs: - redisinsight-connections.json: - content: | - [ - { - "compressor": "NONE", - "id": "104dc90a-21ef-4d5e-8912-b30baabb152f", - "host": "redis", - "port": 6379, - "name": "redis:6379", - "db": 0, - "username": "default", - "password": null, - "connectionType": "STANDALONE", - "nameFromProvider": null, - "provider": "REDIS", - "lastConnection": "2025-10-16T09:22:02.591Z", - "modules": [ - { - "name": "ReJSON", - "version": 20808, - "semanticVersion": "2.8.8" - }, - { - "name": "search", - "version": 21015, - "semanticVersion": "2.10.15" - } - ], - "tls": false, - "tlsServername": null, - "verifyServerCert": null, - "caCert": null, - "clientCert": null, - "ssh": false, - "sshOptions": null, - "forceStandalone": false, - "tags": [] - } - ] - adminer-index.php: - content: | - $$_ENV['ADMINER_DEFAULT_SERVER'], - 'driver' => 'server', /* seems to autodetect the driver from server settings */ - 'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'], - 'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'], - 'db' => $$_ENV['ADMINER_DEFAULT_DB'], - ]; - } - include './adminer.php'; volumes: appwrite-mariadb: diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index 47195a3eb5..ba99cefb42 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 72f7cddd06..f144c58e1b 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -214,6 +214,7 @@ class Mails extends Action $mail->Password = $password; $mail->SMTPSecure = $smtp['secure']; $mail->SMTPAutoTLS = false; + $mail->SMTPKeepAlive = true; $mail->CharSet = 'UTF-8'; $mail->Timeout = 10; /* Connection timeout */ $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index d87edaf788..c4cb9ce415 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -317,6 +317,16 @@ class Migrations extends Action 'sites.write', 'tokens.read', 'tokens.write', + 'providers.read', + 'providers.write', + 'topics.read', + 'topics.write', + 'subscribers.read', + 'subscribers.write', + 'messages.read', + 'messages.write', + 'targets.read', + 'targets.write', ] ]); diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 56298a0dcd..07051d1f15 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -160,7 +160,7 @@ class StatsUsage extends Action } $this->stats[$projectId]['project'] = $project; - $this->stats[$projectId]['receivedAt'] = DateTime::now(); + $this->stats[$projectId]['receivedAt'] = DateTime::format(new \DateTime('@' . $message->getTimestamp())); foreach ($payload['metrics'] ?? [] as $metric) { $this->keys++; if (!isset($this->stats[$projectId]['keys'][$metric['key']])) { diff --git a/src/Appwrite/Utopia/Response/Model/MigrationReport.php b/src/Appwrite/Utopia/Response/Model/MigrationReport.php index 7ebc22d22e..850e4b5ae9 100644 --- a/src/Appwrite/Utopia/Response/Model/MigrationReport.php +++ b/src/Appwrite/Utopia/Response/Model/MigrationReport.php @@ -59,6 +59,30 @@ class MigrationReport extends Model 'default' => 0, 'example' => 5, ]) + ->addRule(Resource::TYPE_PROVIDER, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of providers to be migrated.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule(Resource::TYPE_TOPIC, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of topics to be migrated.', + 'default' => 0, + 'example' => 10, + ]) + ->addRule(Resource::TYPE_SUBSCRIBER, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of subscribers to be migrated.', + 'default' => 0, + 'example' => 100, + ]) + ->addRule(Resource::TYPE_MESSAGE, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of messages to be migrated.', + 'default' => 0, + 'example' => 50, + ]) ->addRule('size', [ 'type' => self::TYPE_INTEGER, 'description' => 'Size of files to be migrated in mb.', diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 90f9c8f95d..e6d6996748 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -251,7 +251,7 @@ class Comment $json = \base64_decode($state); $builds = \json_decode($json, true); - $this->builds = $builds; + $this->builds = \is_array($builds) ? $builds : []; return $this; } diff --git a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php index cc18d14a8e..06044d9984 100644 --- a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php @@ -686,7 +686,7 @@ class FunctionsConsoleClientTest extends Scope $stdout = ''; $stderr = ''; - $code = Console::execute("docker exec appwrite time-travel --projectId={$this->getProject()['$id']} --resourceType=deployment --resourceId={$deploymentIdInactiveOld} --createdAt=2020-01-01T00:00:00Z", '', $stdout, $stderr); + $code = Console::execute("docker exec appwrite task-time-travel --projectId={$this->getProject()['$id']} --resourceType=deployment --resourceId={$deploymentIdInactiveOld} --createdAt=2020-01-01T00:00:00Z", '', $stdout, $stderr); $this->assertSame(0, $code, "Time-travel command failed with code $code: $stderr ($stdout)"); $stdout = ''; diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 606f9e8127..d5fe7753a4 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1694,4 +1694,842 @@ trait MigrationsBase 'x-appwrite-key' => $this->getProject()['apiKey'] ]); } + + /** + * Messaging + */ + public function testAppwriteMigrationMessagingProvider(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid', + 'apiKey' => 'my-apikey', + 'from' => 'migration@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $this->assertNotEmpty($provider['body']['$id']); + + $providerId = $provider['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_PROVIDER], $result['resources']); + $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providerId, $response['body']['$id']); + $this->assertEquals('Migration Sendgrid', $response['body']['name']); + $this->assertEquals('email', $response['body']['type']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingProviderSMTP(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/smtp', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration SMTP', + 'host' => 'smtp.test.com', + 'port' => 587, + 'from' => 'migration-smtp@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providerId, $response['body']['$id']); + $this->assertEquals('Migration SMTP', $response['body']['name']); + $this->assertEquals('email', $response['body']['type']); + $this->assertEquals('smtp', $response['body']['provider']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingProviderTwilio(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/twilio', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Twilio', + 'from' => '+15551234567', + 'accountSid' => 'test-account-sid', + 'authToken' => 'test-auth-token', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providerId, $response['body']['$id']); + $this->assertEquals('Migration Twilio', $response['body']['name']); + $this->assertEquals('sms', $response['body']['type']); + $this->assertEquals('twilio', $response['body']['provider']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingTopic(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Topic', + 'apiKey' => 'my-apikey', + 'from' => 'migration-topic@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $this->assertNotEmpty($topic['body']['$id']); + + $topicId = $topic['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_TOPIC, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_TOPIC]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($topicId, $response['body']['$id']); + $this->assertEquals('Migration Topic', $response['body']['name']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingSubscriber(): void + { + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-sub@test.com', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $this->assertEquals(1, \count($user['body']['targets'])); + $targetId = $user['body']['targets'][0]['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Subscriber', + 'apiKey' => 'my-apikey', + 'from' => uniqid() . '-migration-sub@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Subscriber Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $subscriber = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topicId . '/subscribers', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'subscriberId' => ID::unique(), + 'targetId' => $targetId, + ]); + + $this->assertEquals(201, $subscriber['headers']['status-code']); + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_SUBSCRIBER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($topicId, $response['body']['$id']); + $this->assertGreaterThanOrEqual(1, $response['body']['emailTotal']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingMessage(): void + { + $this->getDestinationProject(true); + + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-msg@test.com', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $this->assertEquals(1, \count($user['body']['targets'])); + $targetId = $user['body']['targets'][0]['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Message', + 'apiKey' => 'my-apikey', + 'from' => 'migration-msg@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Message Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'messageId' => ID::unique(), + 'targets' => [$targetId], + 'topics' => [$topicId], + 'subject' => 'Migration Test Email', + 'content' => 'This is a migration test email', + 'draft' => true, + ]); + + $this->assertEquals(201, $message['headers']['status-code']); + $this->assertNotEmpty($message['body']['$id']); + + $messageId = $message['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($messageId, $response['body']['$id']); + $this->assertEquals('draft', $response['body']['status']); + $this->assertEquals('Migration Test Email', $response['body']['data']['subject']); + $this->assertEquals('This is a migration test email', $response['body']['data']['content']); + $this->assertContains($topicId, $response['body']['topics']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingSmsMessage(): void + { + $this->getDestinationProject(true); + + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-sms@test.com', + 'phone' => '+1' . str_pad((string) rand(200000000, 999999999), 10, '0', STR_PAD_LEFT), + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $this->assertGreaterThanOrEqual(1, \count($user['body']['targets'])); + + $smsTarget = null; + foreach ($user['body']['targets'] as $target) { + if ($target['providerType'] === 'sms') { + $smsTarget = $target; + break; + } + } + $this->assertNotNull($smsTarget); + $targetId = $smsTarget['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/twilio', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Twilio SMS Msg', + 'from' => '+15559876543', + 'accountSid' => 'test-account-sid', + 'authToken' => 'test-auth-token', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration SMS Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/sms', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'messageId' => ID::unique(), + 'targets' => [$targetId], + 'topics' => [$topicId], + 'content' => 'Migration SMS test content', + 'draft' => true, + ]); + + $this->assertEquals(201, $message['headers']['status-code']); + $messageId = $message['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($messageId, $response['body']['$id']); + $this->assertEquals('draft', $response['body']['status']); + $this->assertEquals('Migration SMS test content', $response['body']['data']['content']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingScheduledMessage(): void + { + $this->getDestinationProject(true); + + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-sched@test.com', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $targetId = $user['body']['targets'][0]['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Scheduled', + 'apiKey' => 'my-apikey', + 'from' => 'migration-sched@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Scheduled Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $subscriber = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topicId . '/subscribers', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'subscriberId' => ID::unique(), + 'targetId' => $targetId, + ]); + + $this->assertEquals(201, $subscriber['headers']['status-code']); + + // Create a scheduled message with a future date using topics only + // Direct targets use source IDs which won't resolve in the destination via API + $futureDate = (new \DateTime('+1 year'))->format(\DateTime::ATOM); + $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'messageId' => ID::unique(), + 'topics' => [$topicId], + 'subject' => 'Migration Scheduled Email', + 'content' => 'This is a scheduled migration test email', + 'scheduledAt' => $futureDate, + ]); + + $this->assertEquals(201, $message['headers']['status-code']); + $messageId = $message['body']['$id']; + $this->assertEquals('scheduled', $message['body']['status']); + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($messageId, $response['body']['$id']); + $this->assertEquals('scheduled', $response['body']['status']); + $this->assertEquals('Migration Scheduled Email', $response['body']['data']['subject']); + $this->assertEquals( + (new \DateTime($futureDate))->getTimestamp(), + (new \DateTime($response['body']['scheduledAt']))->getTimestamp(), + ); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } } diff --git a/tests/e2e/Services/Sites/SitesConsoleClientTest.php b/tests/e2e/Services/Sites/SitesConsoleClientTest.php index 2a94dded5f..2e0e1a892d 100644 --- a/tests/e2e/Services/Sites/SitesConsoleClientTest.php +++ b/tests/e2e/Services/Sites/SitesConsoleClientTest.php @@ -180,12 +180,12 @@ class SitesConsoleClientTest extends Scope $stdout = ''; $stderr = ''; - $code = Console::execute("docker exec appwrite-task-maintenance time-travel --projectId={$this->getProject()['$id']} --resourceType=deployment --resourceId={$deploymentIdInactiveOld} --createdAt=2020-01-01T00:00:00Z", '', $stdout, $stderr); + $code = Console::execute("docker exec appwrite task-time-travel --projectId={$this->getProject()['$id']} --resourceType=deployment --resourceId={$deploymentIdInactiveOld} --createdAt=2020-01-01T00:00:00Z", '', $stdout, $stderr); $this->assertSame(0, $code, "Time-travel command failed with code $code: $stderr ($stdout)"); $stdout = ''; $stderr = ''; - $code = Console::execute("docker exec appwrite-task-maintenance maintenance --type=trigger", '', $stdout, $stderr); + $code = Console::execute("docker exec appwrite maintenance --type=trigger", '', $stdout, $stderr); $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); $this->assertEventually(function () use ($siteId) { diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 5c7b289722..866ee591a2 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -1596,7 +1596,7 @@ trait UsersBase ]); $this->assertEquals($user['headers']['status-code'], 200); - $this->assertEquals($user['body']['phone'], $updatedNumber); + $this->assertEmpty($user['body']['phone'] ?? ''); $user = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'], array_merge([ 'content-type' => 'application/json', @@ -1604,7 +1604,7 @@ trait UsersBase ], $this->getHeaders())); $this->assertEquals($user['headers']['status-code'], 200); - $this->assertEquals($user['body']['phone'], $updatedNumber); + $this->assertEmpty($user['body']['phone'] ?? ''); $updatedNumber = "+910000000000"; //dummy number $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/phone', array_merge([ @@ -1648,6 +1648,58 @@ trait UsersBase static::$userNumberUpdated = true; } + public function testUpdateTwoUsersPhoneToEmpty(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + // Create two users with distinct valid phone numbers + $user1 = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'user1-phone-empty-test@appwrite.io', + 'password' => 'password', + 'name' => 'User One', + 'phone' => '+16175551201', + ]); + $this->assertEquals(201, $user1['headers']['status-code']); + $this->assertEquals('+16175551201', $user1['body']['phone']); + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'user2-phone-empty-test@appwrite.io', + 'password' => 'password', + 'name' => 'User Two', + 'phone' => '+16175551202', + ]); + $this->assertEquals(201, $user2['headers']['status-code']); + $this->assertEquals('+16175551202', $user2['body']['phone']); + + // Update first user's phone to empty - must succeed + $response1 = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1['body']['$id'] . '/phone', $headers, [ + 'number' => '', + ]); + $this->assertEquals(200, $response1['headers']['status-code'], 'First user phone should update to empty'); + $this->assertEmpty($response1['body']['phone'] ?? ''); + + // Update second user's phone to empty - must succeed (would fail with duplicate if empty was stored as '') + $response2 = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2['body']['$id'] . '/phone', $headers, [ + 'number' => '', + ]); + $this->assertEquals(200, $response2['headers']['status-code'], 'Second user phone should update to empty without duplicate error'); + $this->assertEmpty($response2['body']['phone'] ?? ''); + + // Verify both users have empty phone via GET + $get1 = $this->client->call(Client::METHOD_GET, '/users/' . $user1['body']['$id'], $headers); + $get2 = $this->client->call(Client::METHOD_GET, '/users/' . $user2['body']['$id'], $headers); + $this->assertEquals(200, $get1['headers']['status-code']); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertEmpty($get1['body']['phone'] ?? ''); + $this->assertEmpty($get2['body']['phone'] ?? ''); + } + public function testUpdateUserNumberSearch(): void { $data = $this->ensureUserNumberUpdated();