diff --git a/.github/workflows/phpunit-unit.yml b/.github/workflows/phpunit-unit.yml new file mode 100644 index 000000000..ba33e8278 --- /dev/null +++ b/.github/workflows/phpunit-unit.yml @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT +# +# Unit tests. Deliberately separate from test.yml: these need no Nextcloud +# server, no database and no web server, so they finish in seconds and give +# feedback on every pull request. test.yml remains the place where the HTTP API +# is exercised against a real instance. + +name: PHPUnit unit + +on: pull_request + +permissions: + contents: read + +concurrency: + group: phpunit-unit-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest-low + outputs: + php-versions: ${{ steps.versions.outputs.php-versions }} + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.0.0 + + unit-tests: + runs-on: ubuntu-latest + needs: matrix + strategy: + fail-fast: false + matrix: + php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}} + + name: unit-tests (php ${{ matrix.php-versions }}) + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ matrix.php-versions }} + extensions: ctype, curl, dom, fileinfo, iconv, intl, json, libxml, mbstring, openssl, posix, simplexml, xmlreader, xmlwriter, zip, zlib + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run unit tests + run: composer run test:unit + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: unit-tests + + if: always() + + name: unit-tests-summary + + steps: + - name: Summary status + run: if ${{ needs.unit-tests.result != 'success' && needs.unit-tests.result != 'skipped' }}; then exit 1; fi diff --git a/.gitignore b/.gitignore index bada56f3d..f568f2dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ js/ report clover.xml .php-cs-fixer.cache +.phpunit.cache/ +.phpunit.result.cache appinfo/info.xsd # just sane ignores diff --git a/Makefile b/Makefile index fd0d4395f..03d8e9e64 100644 --- a/Makefile +++ b/Makefile @@ -87,8 +87,13 @@ watch-js: ##### Testing ##### -test: test-api +test: test-unit test-api +# No Nextcloud instance required. +test-unit: + composer run test:unit + +# Requires a running Nextcloud with the app enabled, see .github/workflows/test.yml test-api: phpunit --bootstrap vendor/autoload.php --testdox tests/api/ diff --git a/composer.json b/composer.json index 6da9a54e1..4a39d87ea 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,21 @@ { "require-dev": { + "doctrine/dbal": "^4", "guzzlehttp/guzzle": "^8", "nextcloud/coding-standard": "^1.0", "nextcloud/ocp": "dev-stable33", "phan/phan": "^6", "php-cs-fixer/shim": "3.95.17", + "phpunit/phpunit": "^10", "psalm/phar": "^5.26", "squizlabs/php_codesniffer": "^4", "staabm/annotate-pull-request-from-checkstyle": "^1.1.0" }, "autoload-dev": { "psr-4": { + "OCA\\Notes\\": "lib/", "OCA\\Notes\\Tests\\API\\": "tests/api/", + "OCA\\Notes\\Tests\\Unit\\": "tests/unit/", "OCP\\": "vendor/nextcloud/ocp/OCP/", "OC\\": "vendor/nextcloud/ocp/OC/" } diff --git a/composer.lock b/composer.lock index af96fd273..1f347f2dc 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": "8b782114452e7be03c67ce81a234301b", + "content-hash": "f0573202f46c53e4ee65b8a700417522", "packages": [], "packages-dev": [ { @@ -278,6 +278,112 @@ }, "time": "2026-01-12T21:07:10+00:00" }, + { + "name": "doctrine/dbal", + "version": "4.4.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-07-21T14:34:40+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -706,6 +812,66 @@ ], "time": "2026-05-12T16:22:19+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "netresearch/jsonmapper", "version": "v5.0.1", @@ -850,6 +1016,63 @@ }, "time": "2026-08-01T01:47:57+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, { "name": "phan/phan", "version": "6.0.7", @@ -1040,6 +1263,124 @@ ], "time": "2026-01-28T23:32:31+00:00" }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "php-cs-fixer/shim", "version": "v3.95.17", @@ -1316,157 +1657,470 @@ "time": "2026-01-25T14:56:51+00:00" }, { - "name": "psalm/phar", - "version": "5.26.1", + "name": "phpunit/php-code-coverage", + "version": "10.1.16", "source": { "type": "git", - "url": "https://github.com/psalm/phar.git", - "reference": "8a38e7ad04499a0ccd2c506fd1da6fc01fff4547" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/psalm/phar/zipball/8a38e7ad04499a0ccd2c506fd1da6fc01fff4547", - "reference": "8a38e7ad04499a0ccd2c506fd1da6fc01fff4547", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" }, - "conflict": { - "vimeo/psalm": "*" + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, - "bin": [ - "psalm.phar" - ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" ], - "description": "Composer-based Psalm Phar", "support": { - "issues": "https://github.com/psalm/phar/issues", - "source": "https://github.com/psalm/phar/tree/5.26.1" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" }, - "time": "2024-09-09T16:22:43+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" }, { - "name": "psr/clock", - "version": "1.0.0", + "name": "phpunit/php-file-iterator", + "version": "4.1.0", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0" + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } }, - "type": "library", "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "process" ], "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" }, - "time": "2022-11-25T14:36:26+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "phpunit/php-text-template", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "3.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.64", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" } }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "phpunit", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "psalm/phar", + "version": "5.26.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/psalm/phar.git", + "reference": "8a38e7ad04499a0ccd2c506fd1da6fc01fff4547" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/psalm/phar/zipball/8a38e7ad04499a0ccd2c506fd1da6fc01fff4547", + "reference": "8a38e7ad04499a0ccd2c506fd1da6fc01fff4547", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": "^7.1 || ^8.0" + }, + "conflict": { + "vimeo/psalm": "*" + }, + "bin": [ + "psalm.phar" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer-based Psalm Phar", + "support": { + "issues": "https://github.com/psalm/phar/issues", + "source": "https://github.com/psalm/phar/tree/5.26.1" + }, + "time": "2024-09-09T16:22:43+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" }, "type": "library", "extra": { @@ -1476,7 +2130,7 @@ }, "autoload": { "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "Psr\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1486,300 +2140,1403 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], - "description": "Standard interfaces for event handling.", + "description": "Common interface for caching libraries", "keywords": [ - "events", + "cache", "psr", - "psr-14" + "psr-6" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "source": "https://github.com/php-fig/cache/tree/3.0.0" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "sabre/event", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/event.git", + "reference": "bc31c95c94c0a7104a7565a2a41ffddc8dcf3012" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/event/zipball/bc31c95c94c0a7104a7565a2a41ffddc8dcf3012", + "reference": "bc31c95c94c0a7104a7565a2a41ffddc8dcf3012", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/coroutine.php", + "lib/Loop/functions.php", + "lib/Promise/functions.php" + ], + "psr-4": { + "Sabre\\Event\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + } + ], + "description": "sabre/event is a library for lightweight event-based programming", + "homepage": "http://sabre.io/event/", + "keywords": [ + "EventEmitter", + "async", + "coroutine", + "eventloop", + "events", + "hooks", + "plugin", + "promise", + "reactor", + "signal" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/event/issues", + "source": "https://github.com/fruux/sabre-event" + }, + "time": "2026-04-27T12:18:32+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" }, { - "name": "psr/http-client", - "version": "1.0.3", + "name": "sebastian/object-enumerator", + "version": "5.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "5.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "source": "https://github.com/php-fig/http-client" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" }, - "time": "2023-09-23T14:17:50+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "sebastian/object-reflector", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "3.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "source": "https://github.com/php-fig/http-factory" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" }, - "time": "2024-04-15T12:06:14+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" }, { - "name": "psr/http-message", - "version": "2.0", + "name": "sebastian/recursion-context", + "version": "5.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "5.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" }, - "time": "2023-04-04T09:54:51+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "sebastian/type", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-main": "4.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" }, { - "name": "sabre/event", - "version": "6.1.0", + "name": "sebastian/version", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sabre-io/event.git", - "reference": "bc31c95c94c0a7104a7565a2a41ffddc8dcf3012" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/event/zipball/bc31c95c94c0a7104a7565a2a41ffddc8dcf3012", - "reference": "bc31c95c94c0a7104a7565a2a41ffddc8dcf3012", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", "shasum": "" }, "require": { - "php": "^8.2" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^10.5", - "rector/rector": "^2.4" + "php": ">=8.1" }, "type": "library", - "autoload": { - "files": [ - "lib/coroutine.php", - "lib/Loop/functions.php", - "lib/Promise/functions.php" - ], - "psr-4": { - "Sabre\\Event\\": "lib/" + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { - "name": "Evert Pot", - "email": "me@evertpot.com", - "homepage": "http://evertpot.com/", - "role": "Developer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "sabre/event is a library for lightweight event-based programming", - "homepage": "http://sabre.io/event/", - "keywords": [ - "EventEmitter", - "async", - "coroutine", - "eventloop", - "events", - "hooks", - "plugin", - "promise", - "reactor", - "signal" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "forum": "https://groups.google.com/group/sabredav-discuss", - "issues": "https://github.com/sabre-io/event/issues", - "source": "https://github.com/fruux/sabre-event" + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" }, - "time": "2026-04-27T12:18:32+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2758,6 +4515,56 @@ ], "time": "2026-07-28T07:33:02+00:00" }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, { "name": "webmozart/assert", "version": "2.4.1", diff --git a/lib/Service/NoteUtil.php b/lib/Service/NoteUtil.php index 9b38c1fd2..cec1b5ed5 100644 --- a/lib/Service/NoteUtil.php +++ b/lib/Service/NoteUtil.php @@ -286,22 +286,112 @@ public function ensureNoteIsWritable(Node $node) : void { } } + /** + * Share types a note is reported as shared through, in this order. + * + * Deliberately a subset of IShare::TYPE_*: TYPE_USERGROUP for instance is + * the per-user half of a group share and would double-report one share. + * + * @var list + */ + private const SHARE_TYPES = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_LINK, + IShare::TYPE_REMOTE, + IShare::TYPE_EMAIL, + IShare::TYPE_ROOM, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, + ]; + + /** + * Share types per file id, or null when nothing has been preloaded. + * + * A file id present with an empty list means "looked up, not shared" — that + * is what makes the cache authoritative instead of just a hint. + * + * @var array>|null + */ + private ?array $cachedShareTypes = null; + + /** + * Preload the share types for a whole notes tree. + * + * Without this, getShareTypes() runs one getSharesBy() query per share type + * per note — eight queries for every note in the list, so a user with 500 + * notes produced 4000 queries just to render the "shared" indicator dot. + * + * getSharesInFolder() answers for every file in one folder at once, so the + * cost becomes one call per folder instead of eight per note. It only ever + * looks at the folder's direct children (passing $shallow = false is + * rejected by the server), which is why every folder of the tree has to be + * passed in rather than just the notes folder. + * + * Mirrors TagService::loadTags(), which solves the same problem for + * favorites. + * + * @param list $folders every folder of the notes tree, the notes folder included + * @param list $fileIds ids of the notes the caller is going to ask about + */ + public function loadShareTypes(array $folders, array $fileIds): void { + // an id with no entries is a note that is not shared; ids that never + // make it into this map fall back to a per-file lookup + $collected = array_fill_keys($fileIds, []); + + foreach ($folders as $folder) { + $owner = $folder->getOwner(); + if ($owner === null) { + // Without an owner there is nobody to ask for shares. Reporting + // "not shared" for the whole tree would be wrong, so give up on + // preloading entirely and let getShareTypes() query per file. + $this->cachedShareTypes = null; + return; + } + + $sharesByFileId = $this->shareManager->getSharesInFolder($owner->getUID(), $folder, false); + foreach ($sharesByFileId as $fileId => $shares) { + if (!array_key_exists($fileId, $collected)) { + // a subfolder, or a file that is not a note + continue; + } + foreach ($shares as $share) { + $collected[$fileId][$share->getShareType()] = true; + } + } + } + + // intersect against SHARE_TYPES to filter unreported types and to keep + // the declaration order, so the payload is unchanged by the preload + $this->cachedShareTypes = array_map( + static fn (array $present): array + => array_values(array_intersect(self::SHARE_TYPES, array_keys($present))), + $collected, + ); + } + + /** + * @return list share types of $file, in SHARE_TYPES order + */ public function getShareTypes(File $file): array { + $fileId = $file->getId(); + if ($this->cachedShareTypes !== null && array_key_exists($fileId, $this->cachedShareTypes)) { + return $this->cachedShareTypes[$fileId]; + } + return $this->fetchShareTypes($file); + } + + /** + * Per-file fallback for the single-note endpoints, where preloading a whole + * tree would cost more than it saves. + * + * @return list + */ + private function fetchShareTypes(File $file): array { $userId = $file->getOwner()->getUID(); - $requestedShareTypes = [ - IShare::TYPE_USER, - IShare::TYPE_GROUP, - IShare::TYPE_LINK, - IShare::TYPE_REMOTE, - IShare::TYPE_EMAIL, - IShare::TYPE_ROOM, - IShare::TYPE_DECK, - // FIXME: Move to constant once Nextcloud 26 is the minimum supported version - 15, // IShare::TYPE_SCIENCEMESH, - ]; $shareTypes = []; - foreach ($requestedShareTypes as $shareType) { + foreach (self::SHARE_TYPES as $shareType) { $shares = $this->shareManager->getSharesBy($userId, $shareType, $file, false, 1, 0); if (count($shares)) { diff --git a/lib/Service/NotesService.php b/lib/Service/NotesService.php index 644b61f0c..5d315b2f4 100644 --- a/lib/Service/NotesService.php +++ b/lib/Service/NotesService.php @@ -31,6 +31,9 @@ public function getAll(string $userId, bool $autoCreateNotesFolder = false) : ar $fileIds = array_keys($data['files']); // pre-load tags for all notes (performance improvement) $this->noteUtil->getTagService()->loadTags($fileIds); + // same for share types, which are otherwise one query per share type + // per note (performance improvement) + $this->noteUtil->loadShareTypes($data['folders'], $fileIds); $notes = array_map(function (File $file) use ($notesFolder) : Note { return new Note($file, $notesFolder, $this->noteUtil); }, $data['files']); @@ -237,6 +240,12 @@ private function getNotesFolder(string $userId, bool $create = true) : Folder { /** * gather note files in given directory and all subdirectories + * + * `folders` carries every folder that was walked, the given one included. + * NoteUtil::loadShareTypes() needs them because the bulk share lookup only + * covers a folder's direct children. + * + * @return array{files: array, categories: list, folders: list} */ private static function gatherNoteFiles( string $customExtension, @@ -246,6 +255,7 @@ private static function gatherNoteFiles( $data = [ 'files' => [], 'categories' => [], + 'folders' => [$folder], ]; $nodes = $folder->getDirectoryListing(); foreach ($nodes as $node) { @@ -255,6 +265,7 @@ private static function gatherNoteFiles( $data_sub = self::gatherNoteFiles($customExtension, $node, $subCategory . '/'); $data['files'] = $data['files'] + $data_sub['files']; $data['categories'] = $data['categories'] + $data_sub['categories']; + $data['folders'] = array_merge($data['folders'], $data_sub['folders']); } elseif (self::isNote($node, $customExtension)) { $data['files'][$node->getId()] = $node; } diff --git a/tests/unit/Controller/ChunkCursorTest.php b/tests/unit/Controller/ChunkCursorTest.php new file mode 100644 index 000000000..7db40eef4 --- /dev/null +++ b/tests/unit/Controller/ChunkCursorTest.php @@ -0,0 +1,110 @@ +timeStart->getTimestamp()); + self::assertSame(1749000000, $cursor->noteLastUpdate); + self::assertSame(4711, $cursor->noteId); + self::assertSame('1750000000-1749000000-4711', $cursor->toString()); + } + + public function testZeroesAreAValidCursor(): void { + $cursor = ChunkCursor::fromString('0-0-0'); + + self::assertNotNull($cursor); + self::assertSame(0, $cursor->timeStart->getTimestamp()); + self::assertSame(0, $cursor->noteLastUpdate); + self::assertSame(0, $cursor->noteId); + self::assertSame('0-0-0', $cursor->toString()); + } + + /** + * @return list + */ + public static function malformedCursors(): array { + return [ + 'empty' => [''], + 'too few parts' => ['1750000000-1749000000'], + 'too many parts' => ['1-2-3-4'], + 'trailing separator' => ['1-2-3-'], + 'leading separator' => ['-1-2-3'], + 'negative timestamp' => ['-1750000000-1749000000-4711'], + 'non numeric' => ['a-b-c'], + 'float' => ['1.5-2-3'], + 'whitespace padded' => [' 1-2-3 '], + 'sql-ish' => ["1-2-3' OR '1'='1"], + 'newline injected' => ["1-2-3\n4-5-6"], + ]; + } + + #[DataProvider('malformedCursors')] + public function testMalformedCursorIsRejected(string $input): void { + self::assertNull( + ChunkCursor::fromString($input), + 'a cursor the app did not produce must not decode', + ); + } + + public function testFromNoteTakesTheTimeStartItIsGivenAndNotTheNotesOwnTime(): void { + // timeStart is the moment the *sync* began, not the note's mtime: it is + // what the client sends back so the server can tell which notes changed + // after the run started. + $timeStart = (new \DateTime())->setTimestamp(1750000000); + + $meta = new Meta(); + $meta->setLastUpdate(1749000000); + + $note = $this->createMock(Note::class); + $note->method('getId')->willReturn(4711); + + $cursor = ChunkCursor::fromNote($timeStart, new MetaNote($note, $meta)); + + self::assertSame(1750000000, $cursor->timeStart->getTimestamp()); + self::assertSame(1749000000, $cursor->noteLastUpdate); + self::assertSame(4711, $cursor->noteId); + self::assertSame('1750000000-1749000000-4711', $cursor->toString()); + } + + public function testCursorFromNoteSurvivesASerialisationRoundTrip(): void { + $meta = new Meta(); + $meta->setLastUpdate(1749000000); + + $note = $this->createMock(Note::class); + $note->method('getId')->willReturn(4711); + + $original = ChunkCursor::fromNote( + (new \DateTime())->setTimestamp(1750000000), + new MetaNote($note, $meta), + ); + $restored = ChunkCursor::fromString($original->toString()); + + self::assertNotNull($restored); + self::assertSame($original->toString(), $restored->toString()); + } +} diff --git a/tests/unit/Service/NoteTest.php b/tests/unit/Service/NoteTest.php new file mode 100644 index 000000000..ff4e87c48 --- /dev/null +++ b/tests/unit/Service/NoteTest.php @@ -0,0 +1,241 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $db = $this->createMock(IDBConnection::class); + $db->method('supports4ByteText')->willReturn(true); + + $this->noteUtil = new NoteUtil( + new Util($l10n, $this->createMock(LoggerInterface::class)), + $this->createMock(IRootFolder::class), + $db, + $this->createMock(TagService::class), + $this->createMock(IManager::class), + $this->createMock(IUserSession::class), + $this->createMock(SettingsService::class), + ); + } + + /** + * @param string $path full path of the note file + * @param string|false $content what the storage returns for getContent() + */ + private function note(string $path, string|false $content = '', int $size = 1): Note { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn(basename($path)); + $file->method('getPath')->willReturn($path); + $file->method('getContent')->willReturn($content); + $file->method('getSize')->willReturn($size); + + $notesFolder = $this->createMock(Folder::class); + $notesFolder->method('getPath')->willReturn(self::NOTES_PATH); + + return new Note($file, $notesFolder, $this->noteUtil); + } + + // ---- title ------------------------------------------------------------- + + /** + * @return array + */ + public static function fileNames(): array { + return [ + 'txt' => ['Shopping.txt', 'Shopping'], + 'md' => ['Shopping.md', 'Shopping'], + 'dots in name' => ['v1.2.notes.md', 'v1.2.notes'], + 'no extension' => ['Makefile', 'Makefile'], + 'spaces' => ['My shopping list.txt', 'My shopping list'], + 'unicode' => ['Grüße 日本語.md', 'Grüße 日本語'], + 'numbered collision' => ['Shopping (2).txt', 'Shopping (2)'], + ]; + } + + #[DataProvider('fileNames')] + public function testTitleComesFromTheFileName(string $fileName, string $expected): void { + self::assertSame($expected, $this->note(self::NOTES_PATH . '/' . $fileName)->getTitle()); + } + + // ---- category ---------------------------------------------------------- + + public function testNoteDirectlyInTheNotesFolderHasNoCategory(): void { + self::assertSame('', $this->note(self::NOTES_PATH . '/loose.txt')->getCategory()); + } + + public function testCategoryIsTheFolderBelowTheNotesFolder(): void { + self::assertSame('Work', $this->note(self::NOTES_PATH . '/Work/a.txt')->getCategory()); + } + + public function testNestedCategoryKeepsItsFullPath(): void { + self::assertSame( + 'Work/Projects/2026', + $this->note(self::NOTES_PATH . '/Work/Projects/2026/a.txt')->getCategory(), + ); + } + + public function testCategoryHandlesUnicodeFolderNames(): void { + self::assertSame('Grüße', $this->note(self::NOTES_PATH . '/Grüße/a.txt')->getCategory()); + } + + // ---- content ----------------------------------------------------------- + + public function testUtf8ByteOrderMarkIsStripped(): void { + $note = $this->note(self::NOTES_PATH . '/a.md', "\u{FEFF}# Heading"); + + self::assertSame('# Heading', $note->getContent(), 'a BOM would show up as a stray glyph'); + } + + public function testEmptyFileOnObjectStorageReadsAsEmptyString(): void { + // object storage returns false rather than '' for a zero-byte file + $note = $this->note(self::NOTES_PATH . '/a.md', false, 0); + + self::assertSame('', $note->getContent()); + } + + public function testUnreadableContentThrows(): void { + $note = $this->note(self::NOTES_PATH . '/a.md', false, 42); + + $this->expectException(\Exception::class); + $note->getContent(); + } + + // ---- excerpt ----------------------------------------------------------- + + public function testExcerptSkipsTheTitleLine(): void { + $note = $this->note(self::NOTES_PATH . '/Shopping.txt', "Shopping\nmilk and eggs"); + + self::assertSame('milk and eggs', $note->getExcerpt()); + } + + public function testExcerptStripsMarkdownAndFlattensNewlines(): void { + $note = $this->note(self::NOTES_PATH . '/Shopping.txt', "Shopping\n- milk\n- eggs"); + + // newlines become em-spaces so the excerpt stays on one line + self::assertSame("milk\u{2003}eggs", $note->getExcerpt()); + } + + public function testExcerptKeepsContentThatDoesNotRepeatTheTitle(): void { + $note = $this->note(self::NOTES_PATH . '/Shopping.txt', 'milk and eggs'); + + self::assertSame('milk and eggs', $note->getExcerpt()); + } + + public function testExcerptIsTruncatedWithAnEllipsis(): void { + $note = $this->note(self::NOTES_PATH . '/a.txt', str_repeat('x', 250)); + + $excerpt = $note->getExcerpt(); + + self::assertSame(101, mb_strlen($excerpt, 'utf-8'), '100 characters plus the ellipsis'); + self::assertStringEndsWith('…', $excerpt); + } + + public function testExcerptRespectsAnExplicitMaxLength(): void { + $note = $this->note(self::NOTES_PATH . '/a.txt', str_repeat('x', 50)); + + self::assertSame(str_repeat('x', 10) . '…', $note->getExcerpt(10)); + } + + public function testEmptyNoteHasAnEmptyExcerpt(): void { + self::assertSame('', $this->note(self::NOTES_PATH . '/a.txt', '')->getExcerpt()); + } + + /** + * Characterization test — this pins a bug, it does not endorse it. + * + * getExcerpt() decides whether the content repeats the title with + * + * $length = mb_strlen($title, 'utf-8'); // characters + * strncasecmp($excerpt, $title, $length) // bytes + * mb_substr($excerpt, $length, null, 'utf-8') // characters + * + * so the comparison length is a character count handed to a byte-based + * function. For an ASCII title the two agree and everything works. For a + * multi-byte title, strncasecmp only compares the first few bytes, so + * content that merely *starts with the same first character* is mistaken + * for a repeated title and that many characters are cut off the excerpt. + * + * Below: the note is titled 日本語 and its body is "日曜日 is Sunday". Only + * the first character matches, but three characters are removed. + * + * Fixing it means comparing like for like — e.g. mb_strtolower() on both + * sides and str_starts_with(), or strlen() for the strncasecmp length. + */ + public function testExcerptMisdetectsARepeatedTitleForMultibyteTitles(): void { + $note = $this->note(self::NOTES_PATH . '/日本語.md', '日曜日 is Sunday'); + + self::assertSame( + 'is Sunday', + $note->getExcerpt(), + 'the excerpt loses 日曜日 — see the docblock', + ); + } + + public function testExcerptTitleStrippingIsCorrectForAsciiTitles(): void { + // the control case for the test above: byte and character counts agree + $note = $this->note(self::NOTES_PATH . '/Sunday.md', 'Sundays are quiet'); + + self::assertSame('s are quiet', $note->getExcerpt()); + } + + // ---- read-only --------------------------------------------------------- + + /** + * @return array + */ + public static function updateablePermissions(): array { + return [ + 'writable file is not read-only' => [true, false], + 'non-updateable file is read-only' => [false, true], + ]; + } + + #[DataProvider('updateablePermissions')] + public function testReadOnlyMirrorsTheFilePermission(bool $isUpdateable, bool $expected): void { + $file = $this->createMock(File::class); + $file->method('getPath')->willReturn(self::NOTES_PATH . '/a.txt'); + $file->method('isUpdateable')->willReturn($isUpdateable); + + $notesFolder = $this->createMock(Folder::class); + $notesFolder->method('getPath')->willReturn(self::NOTES_PATH); + + self::assertSame($expected, (new Note($file, $notesFolder, $this->noteUtil))->getReadOnly()); + } +} diff --git a/tests/unit/Service/NoteUtilShareTypesTest.php b/tests/unit/Service/NoteUtilShareTypesTest.php new file mode 100644 index 000000000..4297f9ba2 --- /dev/null +++ b/tests/unit/Service/NoteUtilShareTypesTest.php @@ -0,0 +1,312 @@ +shareManager = $this->createMock(IManager::class); + $this->shareManager->method('getSharesInFolder') + ->willReturnCallback(function (string $userId, Folder $folder): array { + $this->getSharesInFolderCalls++; + self::assertSame(self::OWNER, $userId, 'shares must be looked up as the folder owner'); + return $this->sharesInFolder[$folder->getPath()] ?? []; + }); + $this->shareManager->method('getSharesBy') + ->willReturnCallback(function (string $userId, int $shareType, ?\OCP\Files\Node $node): array { + $this->getSharesByCalls++; + $key = ($node?->getId() ?? 0) . ':' . $shareType; + return $this->sharesByFile[$key] ?? []; + }); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $db = $this->createMock(IDBConnection::class); + $db->method('supports4ByteText')->willReturn(true); + + $this->noteUtil = new NoteUtil( + new Util($l10n, $this->createMock(LoggerInterface::class)), + $this->createMock(IRootFolder::class), + $db, + $this->createMock(TagService::class), + $this->shareManager, + $this->createMock(IUserSession::class), + $this->createMock(SettingsService::class), + ); + } + + // ---- fixtures ---------------------------------------------------------- + + /** @return Folder&MockObject */ + private function folder(string $path, bool $withOwner = true): Folder { + $folder = $this->createMock(Folder::class); + $folder->method('getPath')->willReturn($path); + $folder->method('getOwner')->willReturn($withOwner ? $this->user() : null); + return $folder; + } + + /** @return File&MockObject */ + private function file(int $id): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn($id); + $file->method('getOwner')->willReturn($this->user()); + return $file; + } + + /** @return IUser&MockObject */ + private function user(): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn(self::OWNER); + return $user; + } + + /** + * @param list $shareTypes + * @return list + */ + private function shares(array $shareTypes): array { + return array_map(function (int $type): IShare { + $share = $this->createMock(IShare::class); + $share->method('getShareType')->willReturn($type); + return $share; + }, $shareTypes); + } + + // ---- the query count --------------------------------------------------- + + public function testPreloadCostsOneCallPerFolderRegardlessOfNoteCount(): void { + $root = $this->folder('/alice/files/Notes'); + $work = $this->folder('/alice/files/Notes/Work'); + + $this->noteUtil->loadShareTypes([$root, $work], range(1, 50)); + + self::assertSame(2, $this->getSharesInFolderCalls, 'one bulk call per folder'); + self::assertSame(0, $this->getSharesByCalls, 'no per-file query during preload'); + } + + public function testReadingPreloadedNotesIssuesNoFurtherQueries(): void { + $root = $this->folder('/alice/files/Notes'); + $this->sharesInFolder['/alice/files/Notes'] = [ + 7 => $this->shares([IShare::TYPE_LINK]), + ]; + + $this->noteUtil->loadShareTypes([$root], range(1, 50)); + $this->getSharesInFolderCalls = 0; + + for ($id = 1; $id <= 50; $id++) { + $this->noteUtil->getShareTypes($this->file($id)); + } + + self::assertSame(0, $this->getSharesByCalls, '50 notes must not trigger 400 queries'); + self::assertSame(0, $this->getSharesInFolderCalls); + } + + /** + * The scaling claim, stated as a test: adding notes must not add queries, + * only adding folders may. + */ + public function testQueryCountTracksFoldersNotNotes(): void { + $folders = [$this->folder('/f0'), $this->folder('/f1'), $this->folder('/f2')]; + + $this->noteUtil->loadShareTypes($folders, range(1, 500)); + for ($id = 1; $id <= 500; $id++) { + $this->noteUtil->getShareTypes($this->file($id)); + } + + self::assertSame(count($folders), $this->getSharesInFolderCalls); + self::assertSame(0, $this->getSharesByCalls); + } + + // ---- the values are unchanged ------------------------------------------ + + public function testPreloadedShareTypesMatchThePerFileLookup(): void { + $expected = [IShare::TYPE_USER, IShare::TYPE_LINK, IShare::TYPE_DECK]; + + // per-file path + foreach ($expected as $type) { + $this->sharesByFile['1:' . $type] = $this->shares([$type]); + } + $perFile = $this->noteUtil->getShareTypes($this->file(1)); + + // bulk path, same shares + $root = $this->folder('/alice/files/Notes'); + $this->sharesInFolder['/alice/files/Notes'] = [1 => $this->shares($expected)]; + $this->noteUtil->loadShareTypes([$root], [1]); + $preloaded = $this->noteUtil->getShareTypes($this->file(1)); + + self::assertSame($expected, $perFile); + self::assertSame($perFile, $preloaded, 'the payload must not depend on how shares were fetched'); + } + + public function testTypesAreReportedInTheDeclaredOrderNotTheProvidersOrder(): void { + $root = $this->folder('/n'); + // providers answer in their own order + $this->sharesInFolder['/n'] = [ + 1 => $this->shares([IShare::TYPE_DECK, IShare::TYPE_LINK, IShare::TYPE_USER]), + ]; + + $this->noteUtil->loadShareTypes([$root], [1]); + + self::assertSame( + [IShare::TYPE_USER, IShare::TYPE_LINK, IShare::TYPE_DECK], + $this->noteUtil->getShareTypes($this->file(1)), + ); + } + + public function testRepeatedSharesOfOneTypeAreReportedOnce(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [ + 1 => $this->shares([IShare::TYPE_USER, IShare::TYPE_USER, IShare::TYPE_USER]), + ]; + + $this->noteUtil->loadShareTypes([$root], [1]); + + self::assertSame([IShare::TYPE_USER], $this->noteUtil->getShareTypes($this->file(1))); + } + + /** + * getSharesBy() was only ever asked about eight specific types, whereas + * getSharesInFolder() returns everything every provider knows. Types outside + * the list must stay unreported — TYPE_USERGROUP in particular is the + * per-user half of a group share and would double-report it. + */ + public function testShareTypesOutsideTheReportedSetAreIgnored(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [ + 1 => $this->shares([IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE, IShare::TYPE_GUEST]), + 2 => $this->shares([IShare::TYPE_USERGROUP, IShare::TYPE_GROUP]), + ]; + + $this->noteUtil->loadShareTypes([$root], [1, 2]); + + self::assertSame([], $this->noteUtil->getShareTypes($this->file(1))); + self::assertSame([IShare::TYPE_GROUP], $this->noteUtil->getShareTypes($this->file(2))); + } + + public function testAnUnsharedNoteIsAnsweredFromTheCache(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [1 => $this->shares([IShare::TYPE_LINK])]; + + $this->noteUtil->loadShareTypes([$root], [1, 2]); + + self::assertSame([], $this->noteUtil->getShareTypes($this->file(2))); + self::assertSame( + 0, + $this->getSharesByCalls, + '"not shared" is a real answer and must not fall back to per-file queries', + ); + } + + public function testSharesOnNonNotesInTheSameFolderAreIgnored(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [ + 1 => $this->shares([IShare::TYPE_LINK]), + // a shared PDF and a shared subfolder living next to the notes + 99 => $this->shares([IShare::TYPE_USER]), + 98 => $this->shares([IShare::TYPE_GROUP]), + ]; + + $this->noteUtil->loadShareTypes([$root], [1]); + + self::assertSame([IShare::TYPE_LINK], $this->noteUtil->getShareTypes($this->file(1))); + } + + // ---- fallbacks --------------------------------------------------------- + + public function testAFileOutsideThePreloadStillFallsBackToAPerFileLookup(): void { + $root = $this->folder('/n'); + $this->noteUtil->loadShareTypes([$root], [1]); + $this->sharesByFile['42:' . IShare::TYPE_LINK] = $this->shares([IShare::TYPE_LINK]); + + // the single-note endpoints never preload a tree + self::assertSame([IShare::TYPE_LINK], $this->noteUtil->getShareTypes($this->file(42))); + self::assertGreaterThan(0, $this->getSharesByCalls); + } + + public function testWithoutAnyPreloadEveryLookupIsPerFile(): void { + $this->sharesByFile['1:' . IShare::TYPE_EMAIL] = $this->shares([IShare::TYPE_EMAIL]); + + self::assertSame([IShare::TYPE_EMAIL], $this->noteUtil->getShareTypes($this->file(1))); + self::assertSame(0, $this->getSharesInFolderCalls); + } + + /** + * A folder with no owner cannot be asked about shares. Caching an empty + * result for its notes would silently drop the shared indicator, so the + * preload has to disown the whole tree and let each note be looked up. + */ + public function testAFolderWithoutAnOwnerDisablesThePreloadInsteadOfReportingNotShared(): void { + $ownerless = $this->folder('/n', withOwner: false); + $this->sharesByFile['1:' . IShare::TYPE_LINK] = $this->shares([IShare::TYPE_LINK]); + + $this->noteUtil->loadShareTypes([$ownerless], [1]); + + self::assertSame( + [IShare::TYPE_LINK], + $this->noteUtil->getShareTypes($this->file(1)), + 'the share must still be found via the per-file path', + ); + } + + public function testASecondPreloadReplacesTheFirst(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [1 => $this->shares([IShare::TYPE_LINK])]; + $this->noteUtil->loadShareTypes([$root], [1]); + self::assertSame([IShare::TYPE_LINK], $this->noteUtil->getShareTypes($this->file(1))); + + // e.g. a share was removed between two calls within one request + $this->sharesInFolder['/n'] = []; + $this->noteUtil->loadShareTypes([$root], [1]); + + self::assertSame([], $this->noteUtil->getShareTypes($this->file(1))); + } +} diff --git a/tests/unit/Service/NoteUtilTest.php b/tests/unit/Service/NoteUtilTest.php new file mode 100644 index 000000000..19ca7d1fb --- /dev/null +++ b/tests/unit/Service/NoteUtilTest.php @@ -0,0 +1,291 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $db = $this->createMock(IDBConnection::class); + $db->method('supports4ByteText')->willReturn(true); + + $this->noteUtil = new NoteUtil( + new Util($l10n, $this->createMock(LoggerInterface::class)), + $this->createMock(IRootFolder::class), + $db, + $this->createMock(TagService::class), + $this->createMock(IManager::class), + $this->createMock(IUserSession::class), + $this->createMock(SettingsService::class), + ); + } + + // ---- category paths ---------------------------------------------------- + + /** + * @return array + */ + public static function categoryPaths(): array { + return [ + 'plain' => ['Work', 'Work'], + 'nested' => ['Work/Projects', 'Work/Projects'], + 'deeply nested' => ['a/b/c/d', 'a/b/c/d'], + 'empty' => ['', ''], + 'root slash' => ['/', ''], + 'leading slash' => ['/Work', 'Work'], + 'trailing slash' => ['Work/', 'Work'], + 'double slash collapses' => ['Work//Projects', 'Work/Projects'], + 'surrounding whitespace trimmed' => [' Work ', 'Work'], + 'inner whitespace kept' => ['My Notes', 'My Notes'], + 'unicode kept' => ['Grüße/日本語', 'Grüße/日本語'], + // a leading dot would create a hidden folder + 'leading dot dropped' => ['.hidden', 'hidden'], + 'leading dots dropped' => ['...hidden', 'hidden'], + 'inner dot kept' => ['my.notes', 'my.notes'], + // characters that are illegal on at least one supported platform + 'windows-illegal stripped' => ['a*b|c:d"eg?h', 'abcdefgh'], + 'backslash stripped' => ['Work\\Projects', 'WorkProjects'], + ]; + } + + #[DataProvider('categoryPaths')] + public function testNormalizeCategoryPath(string $input, string $expected): void { + self::assertSame($expected, $this->noteUtil->normalizeCategoryPath($input)); + } + + /** + * Traversal attempts must not survive normalisation. Each component is + * sanitised individually, so a '..' component loses its dots and is then + * dropped as empty — it must never be emitted, and must never consume the + * component in front of it either. + * + * @return array + */ + public static function traversalPaths(): array { + return [ + 'relative parent' => ['../../etc', 'etc'], + 'parent only' => ['..', ''], + 'many parents' => ['../../..', ''], + 'parent between names' => ['Work/../Secret', 'Work/Secret'], + 'current dir' => ['./Work', 'Work'], + 'absolute unix' => ['/etc/passwd', 'etc/passwd'], + 'absolute-ish windows' => ['C:\\Windows\\System32', 'CWindowsSystem32'], + 'dot dot slash repeated' => ['..././..', ''], + 'encoded-looking' => ['%2e%2e/Work', '%2e%2e/Work'], + 'null-ish name' => ['.. ', ''], + ]; + } + + #[DataProvider('traversalPaths')] + public function testNormalizeCategoryPathBlocksTraversal(string $input, string $expected): void { + $normalized = $this->noteUtil->normalizeCategoryPath($input); + + self::assertSame($expected, $normalized); + self::assertNotContains( + '..', + explode('/', $normalized), + 'no component may be a parent reference after normalisation', + ); + } + + // ---- titles ----------------------------------------------------------- + + /** + * @return array + */ + public static function titles(): array { + return [ + 'plain' => ['Shopping list', 'Shopping list'], + 'first line only' => ["Title\nbody text", 'Title'], + 'first line only (crlf)' => ["Title\r\nbody text", 'Title'], + 'first line only (cr)' => ["Title\rbody text", 'Title'], + 'trimmed' => [' Title ', 'Title'], + 'slash stripped' => ['a/b', 'ab'], + 'leading dot dropped' => ['.hidden', 'hidden'], + 'markdown is not stripped here' => ['# Heading', '# Heading'], + 'tabs become spaces' => ["A\tB", 'A B'], + 'nbsp becomes space' => ["A\u{00A0}B", 'A B'], + 'unicode kept' => ['Grüße 日本語', 'Grüße 日本語'], + ]; + } + + #[DataProvider('titles')] + public function testGetSafeTitle(string $input, string $expected): void { + self::assertSame($expected, $this->noteUtil->getSafeTitle($input)); + } + + public function testGetSafeTitleFallsBackWhenNothingUsableIsLeft(): void { + self::assertSame('New note', $this->noteUtil->getSafeTitle('')); + self::assertSame('New note', $this->noteUtil->getSafeTitle(' ')); + self::assertSame('New note', $this->noteUtil->getSafeTitle('///')); + self::assertSame('New note', $this->noteUtil->getSafeTitle('...')); + } + + /** + * Characterization test, not an endorsement: getSafeTitle() guards the + * fallback with empty(), and empty('0') is true in PHP. A note whose first + * line is exactly "0" is therefore titled "New note" instead of "0". + * Pinned so the behaviour change is visible if the guard is ever tightened + * to a strict comparison. + */ + public function testSingleZeroTitleFallsBackToNewNote(): void { + self::assertSame('New note', $this->noteUtil->getSafeTitle('0')); + self::assertSame('0.', $this->noteUtil->getSafeTitle('0.'), 'only a bare zero is affected'); + self::assertSame('00', $this->noteUtil->getSafeTitle('00')); + } + + public function testGetSafeTitleIsCappedAtOneHundredCharacters(): void { + $title = $this->noteUtil->getSafeTitle(str_repeat('a', 250)); + + self::assertSame(100, mb_strlen($title, 'UTF-8')); + } + + public function testGetSafeTitleCapCountsCharactersNotBytes(): void { + // 250 three-byte characters: a byte-based cap would cut mid-character + // and produce invalid UTF-8 + $title = $this->noteUtil->getSafeTitle(str_repeat('日', 250)); + + self::assertSame(100, mb_strlen($title, 'UTF-8')); + self::assertTrue(mb_check_encoding($title, 'UTF-8'), 'title must stay valid UTF-8'); + } + + // ---- markdown stripping ----------------------------------------------- + + /** + * @return array + */ + public static function markdown(): array { + return [ + 'atx heading' => ['# Heading', 'Heading'], + 'atx heading closed' => ['## Heading ##', 'Heading'], + 'setext underline removed' => ["Heading\n=======", "Heading\n"], + 'bullet dash' => ['- item', 'item'], + 'bullet star' => ['* item', 'item'], + 'bullet plus' => ['+ item', 'item'], + 'bold' => ['**bold**', 'bold'], + 'italic underscore' => ['_italic_', 'italic'], + 'plain text untouched' => ['just text', 'just text'], + 'inner dash kept' => ['well-known', 'well-known'], + ]; + } + + #[DataProvider('markdown')] + public function testStripMarkdown(string $input, string $expected): void { + self::assertSame($expected, $this->noteUtil->stripMarkdown($input)); + } + + // ---- file name generation --------------------------------------------- + + /** + * @param array $existing filename => file id already in the folder + */ + private function folderContaining(array $existing): Folder { + $folder = $this->createMock(Folder::class); + $folder->method('nodeExists') + ->willReturnCallback(static fn (string $name): bool => array_key_exists($name, $existing)); + $folder->method('get') + ->willReturnCallback(function (string $name) use ($existing): Node { + $node = $this->createMock(Node::class); + $node->method('getId')->willReturn($existing[$name] ?? 0); + return $node; + }); + return $folder; + } + + public function testGenerateFileNameUsesTheTitleWhenTheNameIsFree(): void { + self::assertSame( + 'Title.txt', + $this->noteUtil->generateFileName($this->folderContaining([]), 'Title', '.txt', -1), + ); + } + + public function testGenerateFileNameKeepsTheNameOfTheNoteItself(): void { + // renaming a note to the title it already has must not add a suffix + $folder = $this->folderContaining(['Title.txt' => 42]); + + self::assertSame('Title.txt', $this->noteUtil->generateFileName($folder, 'Title', '.txt', 42)); + } + + public function testGenerateFileNameAvoidsOverwritingADifferentNote(): void { + $folder = $this->folderContaining(['Title.txt' => 42]); + + self::assertSame('Title (2).txt', $this->noteUtil->generateFileName($folder, 'Title', '.txt', 7)); + } + + public function testGenerateFileNameCountsUpPastExistingSuffixes(): void { + $folder = $this->folderContaining([ + 'Title.txt' => 42, + 'Title (2).txt' => 43, + 'Title (3).txt' => 44, + ]); + + self::assertSame('Title (4).txt', $this->noteUtil->generateFileName($folder, 'Title', '.txt', 7)); + } + + public function testGenerateFileNameIncrementsAnExplicitlyNumberedTitle(): void { + $folder = $this->folderContaining(['Title (2).txt' => 42]); + + self::assertSame('Title (3).txt', $this->noteUtil->generateFileName($folder, 'Title (2)', '.txt', 7)); + } + + /** + * A title already at the 100-character cap has no room for the " (2)" + * suffix. If the suffix were simply appended, getSafeTitle() would trim it + * straight back off and the collision path would recurse for ever, so the + * base title has to be shortened to make room. + */ + public function testGenerateFileNameTerminatesOnAMaximumLengthTitle(): void { + $longTitle = str_repeat('a', 100); + $folder = $this->folderContaining([$longTitle . '.txt' => 42]); + + $filename = $this->noteUtil->generateFileName($folder, $longTitle, '.txt', 7); + + self::assertSame(str_repeat('a', 96) . ' (2).txt', $filename); + self::assertSame(100, mb_strlen(pathinfo($filename, PATHINFO_FILENAME), 'UTF-8')); + } + + public function testGenerateFileNameSanitisesBeforeCheckingForCollisions(): void { + // the slash must be gone before the name is looked up, or the lookup + // would ask the folder about a path rather than a name + $folder = $this->folderContaining([]); + + self::assertSame( + 'ab.md', + $this->noteUtil->generateFileName($folder, 'a/b', '.md', -1), + ); + } +} diff --git a/tests/unit/Service/NotesServiceTest.php b/tests/unit/Service/NotesServiceTest.php new file mode 100644 index 000000000..e5b313944 --- /dev/null +++ b/tests/unit/Service/NotesServiceTest.php @@ -0,0 +1,392 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $db = $this->createMock(IDBConnection::class); + $db->method('supports4ByteText')->willReturn(true); + + $noteUtil = new NoteUtil( + new Util($l10n, $this->createMock(LoggerInterface::class)), + $this->createMock(IRootFolder::class), + $db, + $this->createMock(TagService::class), + $this->createMock(IManager::class), + $this->createMock(IUserSession::class), + $this->createMock(SettingsService::class), + ); + + $this->notesService = new NotesService( + $this->createMock(MetaService::class), + $this->createMock(SettingsService::class), + $noteUtil, + ); + } + + // ---- tree fixtures ----------------------------------------------------- + + private int $nextFileId = 100; + + /** + * Builds a mocked folder tree. An array value is a subfolder, a string + * value is a file name (the id is assigned automatically). + * + * @param array> $spec + * @return Folder&MockObject + */ + private function folder(array $spec): Folder { + $children = []; + foreach ($spec as $name => $value) { + if (is_array($value)) { + $sub = $this->folder($value); + $sub->method('getName')->willReturn((string)$name); + $children[] = $sub; + } else { + $children[] = $this->file($value); + } + } + + $folder = $this->createMock(Folder::class); + $folder->method('getType')->willReturn(FileInfo::TYPE_FOLDER); + $folder->method('getDirectoryListing')->willReturn($children); + return $folder; + } + + /** @return File&MockObject */ + private function file(string $name): File { + $file = $this->createMock(File::class); + $file->method('getType')->willReturn(FileInfo::TYPE_FILE); + $file->method('getName')->willReturn($name); + $file->method('getId')->willReturn($this->nextFileId++); + return $file; + } + + /** + * The default mirrors production: SettingsService's `customSuffix` + * validator always yields a non-empty suffix (falling back to '.md'), and + * getCustomExtension() strips the leading dot. Passing '' here would not be + * a reachable state — see + * {@see testAnEmptyCustomExtensionMatchesEveryExtensionlessFile}. + * + * @param array> $spec + * @return array{files: array, categories: list} + */ + private function gather(array $spec, string $customExtension = 'md'): array { + $method = new \ReflectionMethod(NotesService::class, 'gatherNoteFiles'); + /** @var array{files: array, categories: list} $result */ + $result = $method->invoke(null, $customExtension, $this->folder($spec)); + return $result; + } + + /** + * @param array> $spec + * @return list + */ + private function gatheredNames(array $spec, string $customExtension = 'md'): array { + $names = array_map( + static fn (File $f): string => $f->getName(), + $this->gather($spec, $customExtension)['files'], + ); + sort($names); + return array_values($names); + } + + // ---- which files count as notes ---------------------------------------- + + public function testRecognisesTheBuiltInNoteExtensions(): void { + self::assertSame( + ['a.markdown', 'b.md', 'c.note', 'd.org', 'e.txt'], + $this->gatheredNames([ + 'a.markdown', 'b.md', 'c.note', 'd.org', 'e.txt', + ]), + ); + } + + public function testIgnoresFilesThatAreNotNotes(): void { + self::assertSame( + ['keep.md'], + $this->gatheredNames([ + 'keep.md', 'photo.jpg', 'report.pdf', 'archive.zip', 'noextension', + ]), + ); + } + + public function testExtensionMatchingIsCaseInsensitive(): void { + self::assertSame( + ['LOUD.TXT', 'Mixed.Md'], + $this->gatheredNames(['LOUD.TXT', 'Mixed.Md']), + ); + } + + public function testHonoursTheUsersCustomExtension(): void { + self::assertSame( + ['note.adoc', 'plain.txt'], + $this->gatheredNames(['note.adoc', 'plain.txt', 'other.rst'], 'adoc'), + ); + } + + /** + * A trap worth pinning: isNote() compares the file's extension against the + * custom one with `$ext === $customExtension`, and pathinfo() yields '' for + * a file without an extension. An empty custom extension therefore makes + * every extensionless file a note. + * + * That state is not reachable today — SettingsService's `customSuffix` + * validator falls back to '.md' and getCustomExtension() only strips the + * leading dot — but the guard lives in a different class from the + * comparison, so this records the coupling. + */ + public function testAnEmptyCustomExtensionMatchesEveryExtensionlessFile(): void { + self::assertSame( + ['keep.md'], + $this->gatheredNames(['keep.md', 'Makefile'], 'md'), + 'with a real custom extension an extensionless file is not a note', + ); + self::assertSame( + ['Makefile', 'keep.md'], + $this->gatheredNames(['keep.md', 'Makefile'], ''), + 'with an empty one it is — SettingsService is what prevents this', + ); + } + + public function testCollectsNotesFromEverySubfolder(): void { + self::assertSame( + ['deep.md', 'nested.txt', 'top.txt'], + $this->gatheredNames([ + 'top.txt', + 'Work' => [ + 'nested.txt', + 'Projects' => ['deep.md'], + ], + ]), + 'notes must be found at any depth', + ); + } + + public function testFilesAreKeyedByFileId(): void { + $files = $this->gather(['one.txt', 'two.txt'])['files']; + + foreach ($files as $id => $file) { + self::assertSame($id, $file->getId(), 'the array key must be the file id'); + } + } + + // ---- folders (needed for the bulk share lookup) ------------------------- + + /** + * NoteUtil::loadShareTypes() needs every folder of the tree, because + * IManager::getSharesInFolder() only reports on a folder's direct children + * (the server rejects $shallow = false). Missing a folder here would mean + * silently losing the shared indicator for the notes inside it. + */ + public function testTheWalkReportsEveryFolderIncludingTheNotesFolderItself(): void { + $result = $this->gather([ + 'top.txt', + 'Work' => [ + 'a.txt', + 'Projects' => [ + '2026' => ['deep.md'], + ], + ], + 'Personal' => [], + ]); + + self::assertCount( + 5, + $result['folders'], + 'the notes folder plus Work, Work/Projects, Work/Projects/2026 and Personal', + ); + self::assertContainsOnlyInstancesOf(Folder::class, $result['folders']); + } + + public function testFoldersIsAListWithNoGapsSoEveryEntryIsIterated(): void { + // this is the counterpart to the categories bug below: 'folders' is + // merged with array_merge(), so nested entries survive + $result = $this->gather([ + 'Work' => ['Projects' => []], + 'Personal' => ['Recipes' => []], + ]); + + self::assertSame( + range(0, count($result['folders']) - 1), + array_keys($result['folders']), + 'a "+" union here would drop nested folders and skip their shares', + ); + self::assertCount(5, $result['folders']); + } + + // ---- categories -------------------------------------------------------- + + public function testCollectsTopLevelCategories(): void { + $categories = $this->gather([ + 'loose.txt', + 'Work' => ['a.txt'], + 'Personal' => ['b.txt'], + ])['categories']; + + self::assertSame(['Work', 'Personal'], array_values($categories)); + } + + public function testAFolderWithoutNotesIsStillACategory(): void { + // this is the whole reason the server sends a category list at all: an + // empty folder has no note to derive the category from + $categories = $this->gather(['Empty' => []])['categories']; + + self::assertSame(['Empty'], array_values($categories)); + } + + /** + * Characterization test — this pins a bug, it does not endorse it. + * + * gatherNoteFiles() merges the recursion's categories with `+`: + * + * $data['categories'] = $data['categories'] + $data_sub['categories']; + * + * Both operands are sequentially-keyed lists, so the union keeps the + * left-hand value for every index that is already occupied and silently + * discards the rest. `array_merge()` is the fix. + * + * Which subcategories are lost therefore depends on position, not on depth: + * a subfolder is dropped only while the parent's list is already at least as + * long as the recursion's. That makes the outcome look arbitrary --- see + * {@see testWhichNestedCategoriesSurviveDependsOnSiblingOrder}, where + * 'Work/A' vanishes but its siblings 'Work/B' and 'Work/C' do not. + * + * Visible effect today: a nested folder that *contains* notes still appears + * in the UI, because the frontend derives categories from the notes + * themselves and only consults this list for folders that have none. So the + * symptom is an empty nested subcategory missing from the sidebar. The v1 + * API does not expose this list, so third-party clients are unaffected. + * + * When this is fixed, the expectation below becomes + * ['Work', 'Work/Projects', 'Work/Projects/2026', 'Personal', 'Personal/Recipes']. + */ + public function testNestedCategoriesAreCurrentlyDropped(): void { + $categories = $this->gather([ + 'Work' => [ + 'Projects' => [ + '2026' => [], + ], + ], + 'Personal' => [ + 'Recipes' => [], + ], + ])['categories']; + + self::assertSame( + ['Work', 'Personal'], + array_values($categories), + 'nested subcategories are lost to the "+" array union — see the docblock', + ); + } + + /** + * The companion to the test above, and the reason the bug is easy to + * misread as "nesting is unsupported": the union only loses an entry whose + * index is already taken, so a folder with more children than the parent + * has accumulated keeps the later ones. Here 'Work' occupies index 0, so + * 'Work/A' (index 0 of the recursion) is dropped while 'Work/B' and + * 'Work/C' survive. + * + * With array_merge() the expectation becomes + * ['Work', 'Work/A', 'Work/B', 'Work/C']. + */ + public function testWhichNestedCategoriesSurviveDependsOnSiblingOrder(): void { + $categories = $this->gather([ + 'Work' => [ + 'A' => [], + 'B' => [], + 'C' => [], + ], + ])['categories']; + + self::assertSame( + ['Work', 'Work/B', 'Work/C'], + array_values($categories), + 'the first sibling collides with the parent index and is lost', + ); + } + + /** + * The counterpart to the test above: the *files* union is keyed by file id, + * which is unique across the tree, so `+` is correct there and no note is + * lost. This is what makes the categories case a slip rather than a + * misunderstanding, and it must keep working if the merge is changed. + */ + public function testNoNoteIsLostByTheFileUnionAcrossSiblingFolders(): void { + self::assertSame( + ['a.txt', 'b.txt', 'c.txt', 'd.txt'], + $this->gatheredNames([ + 'Work' => [ + 'a.txt', + 'Deep' => ['b.txt'], + ], + 'Personal' => [ + 'c.txt', + 'Deeper' => ['d.txt'], + ], + ]), + ); + } + + // ---- title derivation -------------------------------------------------- + + /** + * @return array + */ + public static function contents(): array { + return [ + 'plain first line' => ["Shopping\nmilk", 'Shopping'], + 'heading becomes title' => ["# Shopping\nmilk", 'Shopping'], + 'bullet becomes title' => ["- Shopping\n- milk", 'Shopping'], + 'bold is unwrapped' => ['**Shopping**', 'Shopping'], + 'empty falls back' => ['', 'New note'], + 'slash is removed' => ['a/b', 'ab'], + ]; + } + + #[DataProvider('contents')] + public function testGetTitleFromContent(string $content, string $expected): void { + self::assertSame($expected, $this->notesService->getTitleFromContent($content)); + } +} diff --git a/tests/unit/Service/UtilTest.php b/tests/unit/Service/UtilTest.php new file mode 100644 index 000000000..be41a6585 --- /dev/null +++ b/tests/unit/Service/UtilTest.php @@ -0,0 +1,111 @@ +expectException(\RuntimeException::class); + try { + Util::retryIfLocked(function () use (&$calls): void { + $calls++; + throw new \RuntimeException('unrelated'); + }, 5, 0); + } finally { + self::assertSame(1, $calls, 'only lock contention justifies a retry'); + } + } + + public function testFalsyReturnValuesSurviveTheWrapper(): void { + // the wrapper returns whatever the callable returns; '0', 0 and [] are + // legitimate results and must not be confused with "no result" + self::assertSame('0', Util::retryIfLocked(static fn (): string => '0', 5, 0)); + self::assertSame(0, Util::retryIfLocked(static fn (): int => 0, 5, 0)); + self::assertSame([], Util::retryIfLocked(static fn (): array => [], 5, 0)); + self::assertNull(Util::retryIfLocked(static fn () => null, 5, 0)); + } + + /** + * Characterization test — an edge case no caller hits today. + * + * The retry loop is `for ($try = 1; $try <= $maxRetries; ...)`, so a + * maxRetries of zero or less never enters the body: the callable is not + * invoked at all and the function returns null by falling off the end. + * Pinned because "returns null without doing the work" is a failure mode + * that would be very hard to trace back to here. + */ + public function testNonPositiveMaxRetriesNeverInvokesTheCallable(): void { + $calls = 0; + $callable = function () use (&$calls): string { + $calls++; + return 'saved'; + }; + + self::assertNull(Util::retryIfLocked($callable, 0, 0)); + self::assertNull(Util::retryIfLocked($callable, -1, 0)); + self::assertSame(0, $calls); + } +} diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php new file mode 100644 index 000000000..5f6c36b87 --- /dev/null +++ b/tests/unit/bootstrap.php @@ -0,0 +1,41 @@ + + + + + + + . + + + + + ../../lib + + +