From 6faac33a89048eab809ffa945d8ed496a5b595b6 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 14:56:13 +0200 Subject: [PATCH 1/3] test: add server-free unit tests for note, path and cursor logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite so far has been integration-only: everything under tests/api/ boots a real Nextcloud, so there was no way to exercise the app's pure logic without a server, and no test at all covered the functions that turn user input into file names. composer.json already declared a `test:unit` script pointing at tests/unit/phpunit.xml, but neither the config nor PHPUnit itself was present. This makes that script real: * tests/unit/phpunit.xml + bootstrap.php — no server, no database, no web server. `composer test:unit` works on a bare checkout. The bootstrap declares OC\Hooks\Emitter, which OCP\Files\IRootFolder extends but nextcloud/ocp does not ship, the same way tests/stubs/ocp.php already fills gaps for Psalm. * phpunit/phpunit and doctrine/dbal as dev dependencies. DBAL is needed because mocking OCP\IDBConnection reflects over IQueryBuilder, whose signatures reference Doctrine's types; the server provides it at runtime. * OCA\Notes\ is mapped in autoload-dev — the app relies on Nextcloud's own app autoloader, which is absent outside a server. * A separate phpunit-unit.yml workflow so these run on every pull request in seconds, independently of the server-backed test.yml. 120 tests covering NoteUtil (category-path normalisation including traversal attempts, title derivation, collision-safe file names, markdown stripping), NotesService (which files count as notes, the folder walk, titles from content), Note (title, category, excerpt, BOM and object-storage content handling), Util::retryIfLocked and ChunkCursor. Three tests are marked in their docblocks as characterization tests: they pin current behaviour that looks wrong so that a fix is a visible change rather than a silent one. No production code is touched by this commit. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/phpunit-unit.yml | 79 + .gitignore | 2 + Makefile | 7 +- composer.json | 4 + composer.lock | 2261 ++++++++++++++++++--- tests/unit/Controller/ChunkCursorTest.php | 110 + tests/unit/Service/NoteTest.php | 241 +++ tests/unit/Service/NoteUtilTest.php | 291 +++ tests/unit/Service/NotesServiceTest.php | 316 +++ tests/unit/Service/UtilTest.php | 111 + tests/unit/bootstrap.php | 41 + tests/unit/phpunit.xml | 33 + 12 files changed, 3268 insertions(+), 228 deletions(-) create mode 100644 .github/workflows/phpunit-unit.yml create mode 100644 tests/unit/Controller/ChunkCursorTest.php create mode 100644 tests/unit/Service/NoteTest.php create mode 100644 tests/unit/Service/NoteUtilTest.php create mode 100644 tests/unit/Service/NotesServiceTest.php create mode 100644 tests/unit/Service/UtilTest.php create mode 100644 tests/unit/bootstrap.php create mode 100644 tests/unit/phpunit.xml 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/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/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..275f362b1 --- /dev/null +++ b/tests/unit/Service/NotesServiceTest.php @@ -0,0 +1,316 @@ +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'); + } + } + + // ---- 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 key that already exists and silently discards + * the rest. The result is that only top-level folders survive; every nested + * subcategory is dropped. `array_merge()` is the fix. + * + * 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 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 + + + From 27f152ab4e3eaf7fb434e1a0dc54279c698bba15 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 15:11:49 +0200 Subject: [PATCH 2/3] test: describe the category-union bug by position rather than by depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The characterization test claimed that every nested subcategory is dropped. That is only true while the parent's accumulated list is at least as long as the recursion's: the "+" union discards an entry whose index is already occupied, so a folder with more children than the parent has collected keeps the later ones. A folder 'Work' containing A, B and C therefore yields ['Work', 'Work/B', 'Work/C'] — 'Work/A' collides with 'Work' at index 0 and is lost, its siblings are not. Added as a second test case so the fix is verified against the real shape of the bug and not against a simpler mental model of it. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/Service/NotesServiceTest.php | 38 +++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/unit/Service/NotesServiceTest.php b/tests/unit/Service/NotesServiceTest.php index 275f362b1..0f339a0cf 100644 --- a/tests/unit/Service/NotesServiceTest.php +++ b/tests/unit/Service/NotesServiceTest.php @@ -239,9 +239,14 @@ public function testAFolderWithoutNotesIsStillACategory(): void { * $data['categories'] = $data['categories'] + $data_sub['categories']; * * Both operands are sequentially-keyed lists, so the union keeps the - * left-hand value for every key that already exists and silently discards - * the rest. The result is that only top-level folders survive; every nested - * subcategory is dropped. `array_merge()` is the fix. + * 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 @@ -271,6 +276,33 @@ public function testNestedCategoriesAreCurrentlyDropped(): void { ); } + /** + * 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 From d6617482f201b957d07e5ffa6a389c0c17428f97 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 15:09:11 +0200 Subject: [PATCH 3/3] perf(notes): bulk-load share types instead of eight queries per note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note::getData() asks NoteUtil::getShareTypes() for the share types of every note it serialises, and that ran one IManager::getSharesBy() query per share type — eight per note. The web index endpoint loads the whole collection at once (chunkSize is 0 there), so rendering the note list issued eight queries times the number of notes: about 4000 for a user with 500 notes, purely to decide whether to draw the "shared" indicator dot. IManager::getSharesInFolder() answers for every file in a folder in one go, so the cost becomes one call per folder instead of eight per note. NoteUtil::loadShareTypes() preloads a whole tree that way and getShareTypes() reads from that cache, falling back to the old per-file lookup for the single-note endpoints where preloading a tree would cost more than it saves. This mirrors TagService::loadTags(), which already solves the same problem for favorites and is called from the same place. getSharesInFolder() only reports on a folder's direct children — passing $shallow = false is rejected by the server — so gatherNoteFiles() now also returns every folder it walked, and loadShareTypes() queries each one. The payload is deliberately unchanged. Shares are filtered against the same eight types the old code asked about and emitted in the same order, so `shareTypes` and `isShared` are identical to before; types the previous code never requested (TYPE_USERGROUP, the per-user half of a group share) stay unreported. A folder whose owner cannot be resolved disables the preload rather than caching an empty result, so a missing owner can never turn a shared note into an unshared-looking one. Also drops the FIXME next to the hardcoded 15 and uses IShare::TYPE_SCIENCEMESH: the constant has existed since Nextcloud 26 and the app now requires 33. Covered by tests/unit/Service/NoteUtilShareTypesTest.php, which asserts that the query count follows the folder count and not the note count, that a preloaded result equals what the per-file path returns, and that the fallbacks still work. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Service/NoteUtil.php | 114 ++++++- lib/Service/NotesService.php | 11 + tests/unit/Service/NoteUtilShareTypesTest.php | 312 ++++++++++++++++++ tests/unit/Service/NotesServiceTest.php | 44 +++ 4 files changed, 469 insertions(+), 12 deletions(-) create mode 100644 tests/unit/Service/NoteUtilShareTypesTest.php 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/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/NotesServiceTest.php b/tests/unit/Service/NotesServiceTest.php index 0f339a0cf..e5b313944 100644 --- a/tests/unit/Service/NotesServiceTest.php +++ b/tests/unit/Service/NotesServiceTest.php @@ -211,6 +211,50 @@ public function testFilesAreKeyedByFileId(): void { } } + // ---- 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 {