From d997ab4259011739668e4ce233dfe0d80a4fd2f1 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. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Andy Scherzinger --- .github/workflows/phpunit-unit.yml | 79 + .gitignore | 2 + AGENTS.md | 4 +- Makefile | 7 +- composer.json | 4 + composer.lock | 2263 ++++++++++++++++++--- psalm.xml | 1 + tests/unit/Controller/ChunkCursorTest.php | 103 + tests/unit/Service/NoteTest.php | 217 ++ tests/unit/Service/NoteUtilTest.php | 279 +++ tests/unit/Service/NotesServiceTest.php | 297 +++ tests/unit/Service/UtilTest.php | 111 + tests/unit/bootstrap.php | 41 + tests/unit/phpunit.xml | 33 + 14 files changed, 3211 insertions(+), 230 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 797b6a852..dcc555988 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ report coverage/ clover.xml .php-cs-fixer.cache +.phpunit.cache/ +.phpunit.result.cache appinfo/info.xsd # just sane ignores diff --git a/AGENTS.md b/AGENTS.md index fab108aa8..f1fd4ca18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,14 +47,16 @@ Setup: `make dev-setup` (runs `composer install` + `npm install`). Requires PHP - `npm run stylelint` / `npm run stylelint:fix` ### PHP +- `composer run test:unit` — PHPUnit unit tests (`make test-unit`) - `composer run cs:check` / `composer run cs:fix` — php-cs-fixer (Nextcloud coding standard) - `composer run psalm` — static analysis - `composer run phan` — static analysis (CI uses `make lint-php-phan`) - `make lint` runs everything (PHP + JS + CSS + info.xml); `make lint-fix` auto-fixes ### Tests -There are no PHP unit tests. Three suites exist: +Four suites exist: +- **PHP unit tests** (`tests/unit/`): PHPUnit against mocked OCP interfaces, no server, no database. `make test-unit` (or `composer run test:unit`). Only for logic that can be reached without a running Nextcloud — tree walking, path and title handling, cursor encoding, share-type batching. Run a single test: `./vendor/bin/phpunit -c tests/unit/phpunit.xml --filter testMethodName` - **JS unit tests** (`src/tests/`): vitest in jsdom, no server needed. `npm run test` (or `test:coverage`). For plain modules only — testing a component would need `@vitejs/plugin-vue` adding to `vitest.config.js`. - **API tests** (`tests/api/`): PHPUnit tests that make HTTP requests via Guzzle against a **running Nextcloud server at `http://localhost:8080`** with the app enabled and a user `test`/`test`. Run with `make test-api`. Run a single test: `phpunit --bootstrap vendor/autoload.php --filter testMethodName tests/api/APIv1Test.php` - **Playwright e2e** (`playwright/e2e/`): `npm run test:e2e` (or `test:e2e:ui`). Automatically starts a Nextcloud Docker container on port 8089 (requires Docker; up to 5 min for first start). Tests run with a single worker on purpose — the bundled server uses SQLite and flakes under parallel logins. 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 84083c5ee..723264bb6 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.23", + "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 49b2eeb87..ac026fa08 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": "247ab3a67d53a67542fe94920affdece", + "content-hash": "e710552208a4f7d0637bb2cef3f75e7a", "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.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^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.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, { "name": "netresearch/jsonmapper", "version": "v5.0.1", @@ -850,6 +1016,63 @@ }, "time": "2026-09-02T01:51:47+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "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.9.0" + }, + "time": "2026-09-13T18:51:52+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.23", @@ -1322,157 +1663,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" }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "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": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "filesystem", + "iterator" ], "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-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" }, - "time": "2022-11-25T14:36:26+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "phpunit/php-invoker", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "4.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": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "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": [ - "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": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "template" ], "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/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" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phpunit/php-timer", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", "shasum": "" }, "require": { - "php": ">=7.2.0" + "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": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "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" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+00:00" + }, + { + "name": "psalm/phar", + "version": "5.26.1", + "source": { + "type": "git", + "url": "https://github.com/psalm/phar.git", + "reference": "8a38e7ad04499a0ccd2c506fd1da6fc01fff4547" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/psalm/phar/zipball/8a38e7ad04499a0ccd2c506fd1da6fc01fff4547", + "reference": "8a38e7ad04499a0ccd2c506fd1da6fc01fff4547", + "shasum": "" + }, + "require": { + "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": { @@ -1482,7 +2136,7 @@ }, "autoload": { "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "Psr\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1492,300 +2146,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.2", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" }, "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/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", "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.2" }, - "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": "2026-08-11T05:27:39+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", @@ -2769,6 +4526,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/psalm.xml b/psalm.xml index 3ece1741f..0a0e686e9 100644 --- a/psalm.xml +++ b/psalm.xml @@ -22,6 +22,7 @@ + diff --git a/tests/unit/Controller/ChunkCursorTest.php b/tests/unit/Controller/ChunkCursorTest.php new file mode 100644 index 000000000..748dfe73c --- /dev/null +++ b/tests/unit/Controller/ChunkCursorTest.php @@ -0,0 +1,103 @@ +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 array + */ + 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..0fb127849 --- /dev/null +++ b/tests/unit/Service/NoteTest.php @@ -0,0 +1,217 @@ +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. + */ + 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 { + $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..87e42d3b3 --- /dev/null +++ b/tests/unit/Service/NoteUtilTest.php @@ -0,0 +1,279 @@ +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)); + } + + /** + * @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..c2cc26546 --- /dev/null +++ b/tests/unit/Service/NotesServiceTest.php @@ -0,0 +1,297 @@ +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, + $this->createMock(IFilenameValidator::class), + ); + } + + // ---- 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; + } + + /** + * @param array> $spec + * @return array{files: array, categories: list} + */ + private function gather(array $spec, string $customExtension = 'md', bool $showHidden = true): array { + $method = new \ReflectionMethod(NotesService::class, 'gatherNoteFiles'); + /** @var array{files: array, categories: list} $result */ + $result = $method->invoke(null, $customExtension, $this->folder($spec), $showHidden); + 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'), + ); + } + + /** + * Characterization test — an empty custom extension is not reachable today, + * but the guard for it lives in SettingsService, not in isNote(). + */ + 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 { + $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 232ad539796ab410fec4674e2efb86823f7ba3f8 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. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Andy Scherzinger --- tests/unit/Service/NotesServiceTest.php | 42 +++++++++++-------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/tests/unit/Service/NotesServiceTest.php b/tests/unit/Service/NotesServiceTest.php index c2cc26546..afe4f706e 100644 --- a/tests/unit/Service/NotesServiceTest.php +++ b/tests/unit/Service/NotesServiceTest.php @@ -215,23 +215,9 @@ public function testAFolderWithoutNotesIsStillACategory(): void { /** * 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']. + * gatherNoteFiles() merges the recursion's categories with `+` rather than + * array_merge(), so a subcategory whose index the parent already occupies + * is discarded. */ public function testNestedCategoriesAreCurrentlyDropped(): void { $categories = $this->gather([ @@ -252,12 +238,22 @@ public function testNestedCategoriesAreCurrentlyDropped(): void { ); } - /** - * 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 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', + ); + } + public function testNoNoteIsLostByTheFileUnionAcrossSiblingFolders(): void { self::assertSame( ['a.txt', 'b.txt', 'c.txt', 'd.txt'], From 92b56541f06c1d14c6f884a3cc6a51a00e406bc5 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, and the per-file fallback reports no shares instead of dereferencing that missing owner. Coverage is tracked per note rather than per owner. getSharesInFolder() answers only for a folder's direct children, and only for what the user it ran as has shared, so a note is cached only when some lookup actually covered it: same owner, same parent folder. Sharing a folder and a standalone note into the same tree would otherwise let the folder's lookup mark the owner as loaded and answer "not shared" for the note beside it. getSharesInFolder() reports what one user has shared, so the cache only answers for notes owned by a user the preload queried as. A note owned by someone else — one shared into the notes folder, which mounts inside it — is looked up per file as before. Without that guard such a note reads as not shared for its recipient, which is a payload change and not a performance one. One getSharesInFolder() call costs about what serialising one note through the eight getSharesBy() calls it replaces costs, so the preload only pays off once a request serialises at least as many notes as the tree has folders. Which notes those are is known to Helper and not to getAll(): a chunked or pruned request returns a fraction of the collection, and the rest are emitted as bare ids that are never asked for their share types. getAll() therefore hands the walked folders back and Helper preloads for the notes it is about to serialise, so a small sync stays on the per-file path instead of walking the whole tree for a handful of notes. Measured on a dev instance with 59 notes in 14 folders and 12 shares, counting MySQL statements for GET /api/v1/notes, payload identical in every case for the owner and for both share recipients: whole collection 464 -> 194 chunkSize=50 403 -> 185 chunkSize=25 226 -> 160 chunkSize=10 121 -> 121 (preload declined) chunkSize=5 86 -> 86 (preload declined) sync of 3 changed 72 -> 72 (preload declined) 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, that the fallbacks still work, and that a note shared in beside a folder from the same owner keeps its share types. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Andy Scherzinger --- .github/workflows/phpunit-unit.yml | 2 +- lib/Controller/Helper.php | 6 + lib/Service/NoteUtil.php | 141 ++++++- lib/Service/NotesService.php | 18 +- tests/unit/Service/NoteUtilShareTypesTest.php | 398 ++++++++++++++++++ tests/unit/Service/NotesServiceTest.php | 46 +- 6 files changed, 592 insertions(+), 19 deletions(-) create mode 100644 tests/unit/Service/NoteUtilShareTypesTest.php diff --git a/.github/workflows/phpunit-unit.yml b/.github/workflows/phpunit-unit.yml index ba33e8278..cb87f2060 100644 --- a/.github/workflows/phpunit-unit.yml +++ b/.github/workflows/phpunit-unit.yml @@ -30,7 +30,7 @@ jobs: - name: Get version matrix id: versions - uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.0.0 + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.0.0 unit-tests: runs-on: ubuntu-latest diff --git a/lib/Controller/Helper.php b/lib/Controller/Helper.php index c876368a8..4ee546b2e 100644 --- a/lib/Controller/Helper.php +++ b/lib/Controller/Helper.php @@ -103,6 +103,12 @@ public function getNotesAndCategories( // if the chunk does not contain all remaining notes, then generate new chunk cursor $newChunkCursor = $numPendingNotes ? ChunkCursor::fromNote($lastUpdate, end($chunkedNotes)) : null; + $notesById = []; + foreach ($chunkedNotes as $chunked) { + $notesById[$chunked->note->getId()] = $chunked->note->getFile(); + } + $this->notesService->preloadShareTypes($data['folders'], $notesById); + // load data for the current chunk $notesData = array_map(function (MetaNote $m) use ($exclude) { return $this->getNoteData($m->note, $exclude, $m->meta); diff --git a/lib/Service/NoteUtil.php b/lib/Service/NoteUtil.php index 3eaee14e7..b883ef5d4 100644 --- a/lib/Service/NoteUtil.php +++ b/lib/Service/NoteUtil.php @@ -324,22 +324,137 @@ public function ensureNoteIsWritable(Node $node) : void { } } + /** + * Share types a note is reported as shared through, in this order. + * + * TYPE_USERGROUP is left out: it is the per-user half of a group share and + * would double-report one share. + * + * @var list + * @psalm-suppress DeprecatedConstant TYPE_SCIENCEMESH is reported for shares + * that already exist, so it cannot be dropped from the 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". + * + * @var array>|null + */ + private ?array $cachedShareTypes = null; + + /** + * Preload the share types for a whole notes tree. + * + * Every folder has to be passed in: getSharesInFolder() only looks at a + * folder's direct children and the server rejects $shallow = false. It + * reports what one user has shared, so a lookup answers for a note only + * when that note is a direct child of the folder and carries the same + * owner the lookup ran as. A note the walk found elsewhere — one shared + * into the tree on its own, beside a folder from the same owner — is left + * out of the cache and falls back to a per-note lookup. + * + * @param list $folders every folder of the notes tree, the notes folder included + * @param array $files the notes the caller is going to ask about, by id + */ + public function loadShareTypes(array $folders, array $files): void { + $this->cachedShareTypes = null; + + // one getSharesInFolder() call costs about what one note costs through + // the eight getSharesBy() calls it replaces + if (count($files) < count($folders)) { + return; + } + + $byOwnerAndParent = self::indexByParent($files); + $collected = []; + + foreach ($folders as $folder) { + $owner = $folder->getOwner(); + if ($owner === null) { + return; + } + + $uid = $owner->getUID(); + $covered = $byOwnerAndParent[$uid][rtrim($folder->getPath(), '/')] ?? []; + if ($covered === []) { + continue; + } + + $collected += array_fill_keys(array_keys($covered), []); + $sharesByFileId = $this->shareManager->getSharesInFolder($uid, $folder, false); + foreach ($sharesByFileId as $fileId => $shares) { + if (!isset($covered[$fileId])) { + continue; + } + foreach ($shares as $share) { + $collected[$fileId][$share->getShareType()] = true; + } + } + } + + $this->cachedShareTypes = array_map( + static fn (array $present): array + => array_values(array_intersect(self::SHARE_TYPES, array_keys($present))), + $collected, + ); + } + + /** + * The notes grouped by the lookup that can answer for them: their owner, + * then the folder they sit directly in. + * + * @param array $files + * @return array>> + */ + private static function indexByParent(array $files): array { + $byOwnerAndParent = []; + foreach ($files as $fileId => $file) { + $owner = $file->getOwner()?->getUID(); + if ($owner !== null) { + $byOwnerAndParent[$owner][dirname($file->getPath())][$fileId] = true; + } + } + return $byOwnerAndParent; + } + + /** + * @return list share types of $file, in SHARE_TYPES order + */ public function getShareTypes(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, - ]; + $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 callers that have not preloaded a tree. + * + * @return list + */ + private function fetchShareTypes(File $file): array { + $owner = $file->getOwner(); + if ($owner === null) { + return []; + } + + $userId = $owner->getUID(); $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 fda555e60..ffa3a8eec 100644 --- a/lib/Service/NotesService.php +++ b/lib/Service/NotesService.php @@ -41,9 +41,17 @@ public function getAll(string $userId, bool $autoCreateNotesFolder = false) : ar }, $data['files']); } catch (NotesFolderException $e) { $notes = []; - $data = [ 'categories' => [] ]; + $data = [ 'categories' => [], 'folders' => [] ]; } - return [ 'notes' => $notes, 'categories' => $data['categories'] ]; + return [ 'notes' => $notes, 'categories' => $data['categories'], 'folders' => $data['folders'] ]; + } + + /** + * @param list $folders the walked folders, from getAll() + * @param array $notes the notes that will be serialised, by id + */ + public function preloadShareTypes(array $folders, array $notes) : void { + $this->noteUtil->loadShareTypes($folders, $notes); } public function getTopNotes(string $userId) : array { @@ -244,6 +252,8 @@ private function getNotesFolder(string $userId, bool $create = true) : Folder { /** * gather note files in given directory and all subdirectories + * + * @return array{files: array, categories: array, folders: list} */ private static function gatherNoteFiles( string $customExtension, @@ -254,6 +264,7 @@ private static function gatherNoteFiles( $data = [ 'files' => [], 'categories' => [], + 'folders' => [$folder], ]; $nodes = $folder->getDirectoryListing(); foreach ($nodes as $node) { @@ -271,7 +282,8 @@ private static function gatherNoteFiles( $data_sub = self::gatherNoteFiles($customExtension, $node, $showHidden, $subCategory . '/'); $data['files'] = $data['files'] + $data_sub['files']; $data['categories'] = $data['categories'] + $data_sub['categories']; - } elseif (self::isNote($node, $customExtension)) { + $data['folders'] = array_merge($data['folders'], $data_sub['folders']); + } elseif ($node instanceof File && 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..6d85d7b7d --- /dev/null +++ b/tests/unit/Service/NoteUtilShareTypesTest.php @@ -0,0 +1,398 @@ +shareManager = $this->createMock(IManager::class); + $this->shareManager->method('getSharesInFolder') + ->willReturnCallback(function (string $userId, Folder $folder, bool $reshares = false, bool $shallow = true): array { + $this->getSharesInFolderCalls++; + self::assertSame($folder->getOwner()?->getUID(), $userId, 'shares must be looked up as the folder owner'); + self::assertFalse($reshares, 'reshares would report shares the owner did not make'); + self::assertTrue($shallow, 'the server rejects a non-shallow lookup'); + return $this->sharesInFolder[$folder->getPath()] ?? []; + }); + $this->shareManager->method('getSharesBy') + ->willReturnCallback(function ( + string $userId, + int $shareType, + ?Node $node = null, + bool $reshares = false, + int $limit = 50, + int $offset = 0, + ): array { + $this->getSharesByCalls++; + self::assertFalse($reshares, 'reshares would report shares the owner did not make'); + $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, string $owner = self::OWNER): Folder { + $folder = $this->createMock(Folder::class); + $folder->method('getPath')->willReturn($path); + $folder->method('getOwner')->willReturn($withOwner ? $this->user($owner) : null); + return $folder; + } + + /** @return File&MockObject */ + private function file(int $id, string $path = '/n/note.md', ?string $owner = self::OWNER): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn($id); + $file->method('getPath')->willReturn($path); + $file->method('getOwner')->willReturn($owner === null ? null : $this->user($owner)); + return $file; + } + + /** + * Notes sitting directly in one folder, as the preload takes them. + * + * @param list $ids + * @return array + */ + private function notesIn(Folder $folder, array $ids, ?string $owner = self::OWNER): array { + $files = []; + foreach ($ids as $id) { + $files[$id] = $this->file($id, $folder->getPath() . '/note' . $id . '.md', $owner); + } + return $files; + } + + /** @return IUser&MockObject */ + private function user(string $uid = self::OWNER): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + 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], $this->notesIn($root, range(1, 25)) + + $this->notesIn($work, range(26, 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], $this->notesIn($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); + } + + public function testQueryCountTracksFoldersNotNotes(): void { + $folders = [$this->folder('/f0'), $this->folder('/f1'), $this->folder('/f2')]; + + $this->noteUtil->loadShareTypes($folders, $this->notesIn($folders[0], range(1, 167)) + + $this->notesIn($folders[1], range(168, 334)) + + $this->notesIn($folders[2], range(335, 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], $this->notesIn($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], $this->notesIn($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], $this->notesIn($root, [1])); + + self::assertSame([IShare::TYPE_USER], $this->noteUtil->getShareTypes($this->file(1))); + } + + 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], $this->notesIn($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], $this->notesIn($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], $this->notesIn($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], $this->notesIn($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); + } + + 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], $this->notesIn($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 testAFileWithoutAnOwnerReportsNoSharesInsteadOfFailing(): void { + $ownerless = $this->folder('/n', withOwner: false); + $this->noteUtil->loadShareTypes([$ownerless], $this->notesIn($ownerless, [1])); + + self::assertSame([], $this->noteUtil->getShareTypes($this->file(1, owner: null))); + self::assertSame(0, $this->getSharesByCalls, 'there is no user to ask about shares'); + } + + public function testFewerNotesThanFoldersIsNotWorthAPreload(): void { + $folders = [$this->folder('/n'), $this->folder('/n/a'), $this->folder('/n/b')]; + $this->sharesByFile['1:' . IShare::TYPE_LINK] = $this->shares([IShare::TYPE_LINK]); + + $this->noteUtil->loadShareTypes($folders, $this->notesIn($folders[0], [1, 2])); + + self::assertSame(0, $this->getSharesInFolderCalls, 'three folders cost more than two notes'); + self::assertSame([IShare::TYPE_LINK], $this->noteUtil->getShareTypes($this->file(1))); + } + + public function testMoreNotesThanFoldersIsWorthAPreload(): void { + $folders = [$this->folder('/n'), $this->folder('/n/a'), $this->folder('/n/b')]; + $this->sharesInFolder['/n'] = [1 => $this->shares([IShare::TYPE_LINK])]; + + $this->noteUtil->loadShareTypes($folders, $this->notesIn($folders[0], range(1, 48)) + + $this->notesIn($folders[1], [49]) + + $this->notesIn($folders[2], [50])); + + self::assertSame(3, $this->getSharesInFolderCalls); + self::assertSame([IShare::TYPE_LINK], $this->noteUtil->getShareTypes($this->file(1))); + self::assertSame(0, $this->getSharesByCalls, 'the cache answered without a per-file lookup'); + } + + public function testAPreloadThatIsNotWorthMakingClearsAnEarlierOne(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [1 => $this->shares([IShare::TYPE_LINK])]; + $this->noteUtil->loadShareTypes([$root], $this->notesIn($root, [1])); + + $this->noteUtil->loadShareTypes([$root, $this->folder('/n/a')], $this->notesIn($root, [1])); + + self::assertSame([], $this->noteUtil->getShareTypes($this->file(1))); + self::assertSame(8, $this->getSharesByCalls, 'the stale cache must not answer'); + } + + public function testANoteOwnedBySomeoneElseIsLookedUpPerFile(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = []; + $this->sharesByFile['1:' . IShare::TYPE_USER] = $this->shares([IShare::TYPE_USER]); + + $this->noteUtil->loadShareTypes([$root], $this->notesIn($root, [1], owner: 'someone-else')); + + self::assertSame( + [IShare::TYPE_USER], + $this->noteUtil->getShareTypes($this->file(1, owner: 'someone-else')), + 'the preload only covers notes owned by the user it queried as', + ); + } + + public function testANoteSharedInBesideAFolderFromTheSameOwnerIsNotReportedUnshared(): void { + // bob shared a folder into alice's tree and, next to it, a single note + $root = $this->folder('/n'); + $bob = $this->folder('/n/bob', owner: 'bob'); + $this->sharesInFolder['/n'] = []; + $this->sharesInFolder['/n/bob'] = [2 => $this->shares([IShare::TYPE_USER])]; + $this->sharesByFile['1:' . IShare::TYPE_USER] = $this->shares([IShare::TYPE_USER]); + + $this->noteUtil->loadShareTypes( + [$root, $bob], + $this->notesIn($root, [1], owner: 'bob') + $this->notesIn($bob, [2], owner: 'bob'), + ); + + self::assertSame( + [IShare::TYPE_USER], + $this->noteUtil->getShareTypes($this->file(1, owner: 'bob')), + 'walking the shared folder must not answer for a note that sits outside it', + ); + self::assertGreaterThan(0, $this->getSharesByCalls, 'the note no lookup covered is read per file'); + + $this->getSharesByCalls = 0; + self::assertSame([IShare::TYPE_USER], $this->noteUtil->getShareTypes($this->file(2, owner: 'bob'))); + self::assertSame(0, $this->getSharesByCalls, 'the note inside the folder is still answered from the cache'); + } + + public function testASecondPreloadReplacesTheFirst(): void { + $root = $this->folder('/n'); + $this->sharesInFolder['/n'] = [1 => $this->shares([IShare::TYPE_LINK])]; + $this->noteUtil->loadShareTypes([$root], $this->notesIn($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], $this->notesIn($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 afe4f706e..b6a4c3149 100644 --- a/tests/unit/Service/NotesServiceTest.php +++ b/tests/unit/Service/NotesServiceTest.php @@ -99,11 +99,11 @@ private function file(string $name): File { /** * @param array> $spec - * @return array{files: array, categories: list} + * @return array{files: array, categories: array, folders: list} */ private function gather(array $spec, string $customExtension = 'md', bool $showHidden = true): array { $method = new \ReflectionMethod(NotesService::class, 'gatherNoteFiles'); - /** @var array{files: array, categories: list} $result */ + /** @var array{files: array, categories: array, folders: list} $result */ $result = $method->invoke(null, $customExtension, $this->folder($spec), $showHidden); return $result; } @@ -194,6 +194,48 @@ 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 { + $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 {