From 3360bf08f487a7bd21a1b9e12c508b85bfcd81f9 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 6 Oct 2026 15:15:53 +0200 Subject: [PATCH 1/8] chore: add Pest test setup with WordPress function stubs --- composer.json | 9 +- composer.lock | 753 ++++++++++++++++++++++++++++++++++++++----------- phpunit.xml | 11 + tests/Pest.php | 134 +++++++++ 4 files changed, 733 insertions(+), 174 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/Pest.php diff --git a/composer.json b/composer.json index 62bfa98..9544196 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "brianhenryie/strauss": "^0.15.0", "friendsofphp/php-cs-fixer": "^3.0", "symfony/var-dumper": "^5.1", - "wp-coding-standards/wpcs": "^3.0" + "wp-coding-standards/wpcs": "^3.0", + "pestphp/pest": "^1.23" }, "extra": { "strauss": { @@ -73,13 +74,15 @@ "vendor/bin/strauss" ], "phpcs": "./vendor/squizlabs/php_codesniffer/bin/phpcs", - "phpcbf": "./vendor/squizlabs/php_codesniffer/bin/phpcbf" + "phpcbf": "./vendor/squizlabs/php_codesniffer/bin/phpcbf", + "test": "./vendor/bin/pest" }, "config": { "allow-plugins": { "composer/installers": true, "php-http/discovery": true, - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "pestphp/pest-plugin": true } } } diff --git a/composer.lock b/composer.lock index 96afdc0..61d8abd 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": "7e3ce4dbe45335d7c6ded7270692dffc", + "content-hash": "760846e42b25dce8b4d682aeca150461", "packages": [ { "name": "laravel/serializable-closure", @@ -1367,6 +1367,77 @@ ], "time": "2024-08-06T10:04:20+00:00" }, + { + "name": "filp/whoops", + "version": "2.18.6", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/7d0a2cde892089bb09372f4366e9b960eb3001a9", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.6" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2026-10-03T17:03:57+00:00" + }, { "name": "friendsofphp/php-cs-fixer", "version": "v3.65.0", @@ -1890,20 +1961,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -1938,15 +2009,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "myclabs/php-enum", @@ -2013,20 +2084,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -2041,7 +2111,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -2065,9 +2135,262 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v6.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "f05978827b9343cba381ca05b8c7deee346b6015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f05978827b9343cba381ca05b8c7deee346b6015", + "reference": "f05978827b9343cba381ca05b8c7deee346b6015", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.14.5", + "php": "^8.0.0", + "symfony/console": "^6.0.2" + }, + "require-dev": { + "brianium/paratest": "^6.4.1", + "laravel/framework": "^9.26.1", + "laravel/pint": "^1.1.1", + "nunomaduro/larastan": "^1.0.3", + "nunomaduro/mock-final-classes": "^1.1.0", + "orchestra/testbench": "^7.7", + "phpunit/phpunit": "^9.5.23", + "spatie/ignition": "^1.4.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-develop": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2023-01-03T12:54:54+00:00" + }, + { + "name": "pestphp/pest", + "version": "v1.23.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "5c56ad8772b89611c72a07e23f6e30aa29dc677a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5c56ad8772b89611c72a07e23f6e30aa29dc677a", + "reference": "5c56ad8772b89611c72a07e23f6e30aa29dc677a", + "shasum": "" + }, + "require": { + "nunomaduro/collision": "^5.11.0|^6.4.0", + "pestphp/pest-plugin": "^1.1.0", + "php": "^7.3 || ^8.0", + "phpunit/phpunit": "^9.6.10" + }, + "require-dev": { + "illuminate/console": "^8.83.27", + "illuminate/support": "^8.83.27", + "laravel/dusk": "^6.25.2", + "pestphp/pest-dev-tools": "^1.0.0", + "pestphp/pest-plugin-parallel": "^1.2.1" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Environment" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + }, + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v1.23.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2023-07-12T19:42:47+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "606c5f79c6a339b49838ffbee0151ca519efe378" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/606c5f79c6a339b49838ffbee0151ca519efe378", + "reference": "606c5f79c6a339b49838ffbee0151ca519efe378", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1.0 || ^2.0.0", + "php": "^7.3 || ^8.0" + }, + "conflict": { + "pestphp/pest": "<1.0" + }, + "require-dev": { + "composer/composer": "^2.4.2", + "pestphp/pest": "^1.22.1", + "pestphp/pest-dev-tools": "^1.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager", + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2022-09-18T13:18:17+00:00" }, { "name": "phar-io/manifest", @@ -2674,27 +2997,27 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.21", + "version": "9.6.38", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa" + "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", - "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8b5f757e8e92071bfdbfa93943f94f84c4e31007", + "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -2705,11 +3028,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -2757,23 +3080,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.21" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.38" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-09-19T10:50:18+00:00" + "time": "2026-10-05T05:44:26+00:00" }, { "name": "psr/cache", @@ -3674,16 +3989,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -3736,15 +4051,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "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": "2022-09-14T12:41:17+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -3934,16 +4261,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", "shasum": "" }, "require": { @@ -3999,28 +4326,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" }, "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": "2024-03-02T06:33:00+00:00" + "time": "2026-08-11T04:55:59+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -4063,15 +4402,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "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/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -4244,16 +4595,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "c85be6922b7fd365942b986b9a50397d65407611" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", "shasum": "" }, "require": { @@ -4295,15 +4646,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" }, "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": "2023-02-03T06:07:39+00:00" + "time": "2026-08-11T05:25:24+00:00" }, { "name": "sebastian/resource-operations", @@ -4819,16 +5182,16 @@ }, { "name": "symfony/cache-contracts", - "version": "v3.5.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "df6a1a44c890faded49a5fca33c2d5c5fd3c2197" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/df6a1a44c890faded49a5fca33c2d5c5fd3c2197", - "reference": "df6a1a44c890faded49a5fca33c2d5c5fd3c2197", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -4837,12 +5200,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -4875,7 +5238,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -4886,25 +5249,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/console", - "version": "v6.4.15", + "version": "v6.4.47", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd" + "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", + "url": "https://api.github.com/repos/symfony/console/zipball/0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", + "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", "shasum": "" }, "require": { @@ -4969,7 +5336,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.15" + "source": "https://github.com/symfony/console/tree/v6.4.47" }, "funding": [ { @@ -4980,25 +5347,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2026-09-18T10:23:48+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -5006,12 +5377,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -5036,7 +5407,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5047,12 +5418,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", @@ -5136,16 +5511,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -5154,12 +5529,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -5192,7 +5567,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5203,12 +5578,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -5409,16 +5788,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -5433,8 +5812,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5468,7 +5847,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -5479,25 +5858,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -5509,8 +5892,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5546,7 +5929,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -5557,25 +5940,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -5587,8 +5974,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5627,7 +6014,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -5638,28 +6025,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5671,8 +6063,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5707,7 +6099,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -5718,16 +6110,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -5745,8 +6141,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5783,7 +6179,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0" }, "funding": [ { @@ -5794,6 +6190,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5803,16 +6203,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -5821,8 +6221,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5863,7 +6263,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -5874,12 +6274,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php81", @@ -6020,16 +6424,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.5.0", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -6042,12 +6446,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -6083,7 +6487,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -6094,12 +6498,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stopwatch", @@ -6165,16 +6573,16 @@ }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", "shasum": "" }, "require": { @@ -6188,7 +6596,6 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", "symfony/http-client": "^5.4|^6.0|^7.0", "symfony/intl": "^6.2|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -6231,7 +6638,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v6.4.46" }, "funding": [ { @@ -6242,12 +6649,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2026-09-11T09:03:57+00:00" }, { "name": "symfony/var-dumper", @@ -6417,16 +6828,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -6455,7 +6866,7 @@ "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.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -6463,7 +6874,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -6541,5 +6952,5 @@ "php": "^8.0" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..eb25b65 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests/Unit + + + diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..bd1e1c1 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,134 @@ +params[ $key ] ?? null; + } + + public function get_header( string $key ) + { + return null; + } +} + +class WP_REST_Response +{ + public function __construct( public $data = null, public int $status = 200 ) + { + } +} + +function get_post( int $id ): ?WP_Post +{ + return $GLOBALS['ydpl_test']['posts'][ $id ] ?? null; +} + +function get_post_field( string $field, int $id ): string +{ + return get_post( $id )->$field; +} + +function get_post_meta( int $id, string $key = '', bool $single = false ) +{ + return $GLOBALS['ydpl_test']['meta'][ $id ][ $key ] ?? ''; +} + +function update_post_meta( int $id, string $key, $value ): void +{ + $GLOBALS['ydpl_test']['meta'][ $id ][ $key ] = $value; +} + +function delete_post_meta( int $id, string $key ): void +{ + unset( $GLOBALS['ydpl_test']['meta'][ $id ][ $key ] ); +} + +function current_time( string $type ): string +{ + return '2026-10-06 12:00:00'; +} + +function current_user_can( string $capability ): bool +{ + return $GLOBALS['ydpl_test']['can_cache']; +} + +function apply_filters( string $hook, $value ) +{ + return $value; +} + +function get_transient( string $key ) +{ + return $GLOBALS['ydpl_test']['transients'][ $key ] ?? false; +} + +function set_transient( string $key, $value, int $expiration ): void +{ + $GLOBALS['ydpl_test']['transients'][ $key ] = $value; +} + +function ydpl_resolve_from_container( string $key ) +{ + return YDPL\Singletons\SiteOptionsSingleton::get_instance( array() ); +} + +function fake_deepl_service(): YDPL\Services\TranslationService +{ + return new class() extends YDPL\Services\TranslationService { + public array $deepl_requests = array(); + + public function handle_translation_without_object_id( array $text, string $target_lang ): array + { + $this->deepl_requests[] = $text; + + return array_map( fn ( $source ) => pair( $source, "$target_lang:$source" ), $text ); + } + }; +} + +function given_post( int $id, string $post_modified ): void +{ + $post = new WP_Post(); + $post->post_modified = $post_modified; + + $GLOBALS['ydpl_test']['posts'][ $id ] = $post; +} + +function given_cached_translation( int $id, string $lang, array $translation, string $modified ): void +{ + update_post_meta( $id, "_translation_$lang", $translation ); + update_post_meta( $id, "_translation_modified_$lang", $modified ); +} + +function pair( string $text, string $translation ): array +{ + return array( + 'text' => $text, + 'translation' => $translation, + ); +} + +uses()->beforeEach( function () { + $GLOBALS['ydpl_test'] = array( + 'posts' => array(), + 'meta' => array(), + 'transients' => array(), + 'can_cache' => false, + ); + $_SERVER['REMOTE_ADDR'] = '10.0.0.1'; +} )->in( 'Unit' ); From 09aba79a0d0713f2517f209198230d351d3f3059 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 6 Oct 2026 15:15:53 +0200 Subject: [PATCH 2/8] fix: cache DeepL translations per text and translate missing texts The cache stored a complete response per object and language and returned it regardless of the requested texts. Texts outside the post content (menu, footer, news list, greetings) that changed after caching were never translated, and texts cached by logged-in users (admin bar) leaked into visitor responses. Cached translations are now looked up per text. Missing texts are sent to DeepL, merged into the cache when the user may cache, and the response contains exactly the requested texts. Requests with missing texts count towards the rate limit; when rate limited, the cached texts are returned instead of a 429. --- README.md | 9 +++- readme.txt | 9 +++- src/Controllers/RestAPIController.php | 12 +++-- src/Services/TranslationService.php | 59 ++++++++++++++++++---- tests/Unit/RestAPIControllerTest.php | 68 +++++++++++++++++++++++++ tests/Unit/TranslationServiceTest.php | 73 +++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/RestAPIControllerTest.php create mode 100644 tests/Unit/TranslationServiceTest.php diff --git a/README.md b/README.md index c27b3c8..ced6995 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ When providing translations to website visitors, you can configure which languag Each object that is translated will store its cached translation in the `wp_postmeta` table within the database. This caching mechanism ensures that translations are efficiently reused, reducing unnecessary API requests to DeepL and saving costs. -- **Serving Cached Translations:** If a cached translation is newer than the `post_modified` date of the object, the cached version is served. +- **Serving Cached Translations:** Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. - **Fetching New Translations:** When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. - **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. @@ -91,7 +91,7 @@ When making requests to the API, ensure that the nonce is included in the reques #### Rate Limiting -To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. +To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When a partially cached request is rate limited, the cached texts are returned without calling DeepL. #### Cache Capability @@ -148,6 +148,11 @@ xhr.send( data ); ## Changelog +### NEXT + +- Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot +- Change: requests with texts missing from the cache count towards the rate limit; when rate limited, the cached texts are returned instead of an error + ### 2.1.0 (Aug 03, 2026) - Add: configurable DeepL API base URL setting, defaulting to `https://api.deepl.com/v2/translate` diff --git a/readme.txt b/readme.txt index 5c23e83..a251abd 100644 --- a/readme.txt +++ b/readme.txt @@ -20,7 +20,7 @@ This plugin registers secure API endpoints that allow you to request translation Each object that is translated will store its cached translation in the `wp_postmeta` table within the database. This caching mechanism ensures that translations are efficiently reused, reducing unnecessary API requests to DeepL and saving costs. -* Serving Cached Translations: If a cached translation is newer than the `post_modified` date of the object, the cached version is served. +* Serving Cached Translations: Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. * Fetching New Translations: When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. * Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. @@ -74,7 +74,7 @@ The API endpoints registered by this plugin are secured using a WordPress nonce. When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `X-WP-Nonce`, and it should contain a nonce created with the `wp_rest` action (available via `ydpl.ydpl_api_request_nonce` on the front-end). -Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. +Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When a partially cached request is rate limited, the cached texts are returned without calling DeepL. Cache capability: Only users with the `edit_posts` capability can trigger cache creation. This can be customized using the `yard::deepl/cache_capability` filter. @@ -123,6 +123,11 @@ Response: == Changelog == += NEXT = + +* Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot +* Change: requests with texts missing from the cache count towards the rate limit; when rate limited, the cached texts are returned instead of an error + = 2.1.0: Aug 03, 2026 = * Add: configurable DeepL API base URL setting, defaulting to `https://api.deepl.com/v2/translate` diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index d4ed365..daab79c 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -58,15 +58,21 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re } catch ( ObjectNotFoundException $e ) { return $this->set_failure_response( 404, 'Object not found.' ); } + + $missing_texts = $this->service->missing_texts( $text, $cached_translation ); } else { $cached_translation = null; + $missing_texts = $text; } - // Apply rate limit check if object ID is absent or translation is not cached when an object ID is present. - if ( ! $cached_translation ) { - if ( $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { + if ( array() !== $missing_texts && $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { + $partial_translation = $this->service->translations_for( $text, $cached_translation ?? array() ); + + if ( array() === $partial_translation ) { return $this->set_failure_response( 429, 'Rate limit exceeded.' ); } + + return new WP_REST_Response( $partial_translation ); } try { diff --git a/src/Services/TranslationService.php b/src/Services/TranslationService.php index 14c754e..567102b 100644 --- a/src/Services/TranslationService.php +++ b/src/Services/TranslationService.php @@ -42,24 +42,65 @@ public function handle_translation( int $object_id, array $text, string $target_ public function handle_translation_with_object_id( int $object_id, array $text, string $target_lang, bool $cache = false, ?array $cached_translation = null ): array { if ( null === $cached_translation ) { - $cached_translation = $this->get_cached_translation( $object_id, $target_lang ); + $cached_translation = $this->get_cached_translation( $object_id, $target_lang ) ?? array(); } - if ( $cached_translation ) { - return $cached_translation; + $missing_texts = $this->missing_texts( $text, $cached_translation ); + + if ( array() === $missing_texts ) { + return $this->translations_for( $text, $cached_translation ); } - $translation = $this->handle_translation_without_object_id( $text, $target_lang ); + $translations = $this->translations_by_text( $this->handle_translation_without_object_id( $missing_texts, $target_lang ) ) + + $this->translations_by_text( $cached_translation ); + $merged = array_map( + fn ( $source, $translation ) => array( + 'text' => (string) $source, + 'translation' => $translation, + ), + array_keys( $translations ), + $translations + ); - if ( ! $cache ) { + if ( $cache ) { + $this->repository->store_translation( $object_id, $target_lang, $merged ); + } else { $this->repository->increment_uncached_request_count( $object_id, $target_lang ); } - if ( $cache ) { - $this->repository->store_translation( $object_id, $target_lang, $translation ); - } + return $this->translations_for( $text, $merged ); + } - return $translation; + /** + * @since 2.2.0 + */ + public function missing_texts( array $text, array $cached_translation ): array + { + return array_values( array_unique( array_diff( $text, array_keys( $this->translations_by_text( $cached_translation ) ) ) ) ); + } + + /** + * @since 2.2.0 + */ + public function translations_for( array $text, array $translation ): array + { + $translations = $this->translations_by_text( $translation ); + $known_texts = array_filter( array_values( $text ), fn ( $source ) => isset( $translations[ $source ] ) ); + + return array_values( + array_map( + fn ( $source ) => array( + 'text' => $source, + 'translation' => $translations[ $source ], + ), + $known_texts + ) + ); + } + + protected function translations_by_text( array $translation ): array + { + return array_column( $translation, 'translation', 'text' ); } /** diff --git a/tests/Unit/RestAPIControllerTest.php b/tests/Unit/RestAPIControllerTest.php new file mode 100644 index 0000000..08cf31f --- /dev/null +++ b/tests/Unit/RestAPIControllerTest.php @@ -0,0 +1,68 @@ +service = $service; + } + }; +} + +function translate_request( array $text ): WP_REST_Request +{ + return new WP_REST_Request( + array( + 'text' => $text, + 'target_lang' => 'EN-GB', + 'object_id' => 1, + ) + ); +} + +function exhaust_rate_limit( RestAPIController $controller ): void +{ + foreach ( range( 1, 3 ) as $i ) { + $controller->handle_translate_request( translate_request( array( "Tekst $i" ) ) ); + } +} + +beforeEach( function () { + given_post( 1, '2026-10-01 10:00:00' ); + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ), pair( 'Admin bar', 'Admin bar EN' ) ), '2026-10-02 10:00:00' ); + $this->service = fake_deepl_service(); + $this->controller = controller( $this->service ); +} ); + +it( 'does not count fully cached requests towards the rate limit', function () { + foreach ( range( 1, 5 ) as $i ) { + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu' ) ) ); + } + + expect( $response->status )->toBe( 200 ) + ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ); +} ); + +it( 'returns the cached part without calling DeepL when rate limited', function () { + exhaust_rate_limit( $this->controller ); + $requests_before = count( $this->service->deepl_requests ); + + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + + expect( $response->status )->toBe( 200 ) + ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ) + ->and( $this->service->deepl_requests )->toHaveCount( $requests_before ); +} ); + +it( 'rejects a rate limited request when nothing is cached', function () { + exhaust_rate_limit( $this->controller ); + + $response = $this->controller->handle_translate_request( translate_request( array( 'Goedemiddag' ) ) ); + + expect( $response->status )->toBe( 429 ); +} ); diff --git a/tests/Unit/TranslationServiceTest.php b/tests/Unit/TranslationServiceTest.php new file mode 100644 index 0000000..c3f3770 --- /dev/null +++ b/tests/Unit/TranslationServiceTest.php @@ -0,0 +1,73 @@ +service = fake_deepl_service(); +} ); + +it( 'sends only texts missing from the cache to DeepL', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Menu', 'Goedemiddag', 'Goedemiddag' ), 'EN-GB' ); + + expect( $this->service->deepl_requests )->toBe( array( array( 'Goedemiddag' ) ) ); +} ); + +it( 'does not call DeepL when every text is cached', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Menu' ), 'EN-GB' ); + + expect( $this->service->deepl_requests )->toBe( array() ); +} ); + +it( 'returns exactly the requested texts in request order', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ), pair( 'Admin bar', 'Admin bar EN' ) ), '2026-10-02 10:00:00' ); + + $translation = $this->service->handle_translation_with_object_id( 1, array( 'Nieuws', 'Menu', '2026' ), 'EN-GB' ); + + expect( $translation )->toBe( + array( + pair( 'Nieuws', 'EN-GB:Nieuws' ), + pair( 'Menu', 'Menu EN' ), + pair( '2026', 'EN-GB:2026' ), + ) + ); +} ); + +it( 'merges new translations into the cache when the user may cache', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + update_post_meta( 1, '_ydpl_uncached_request_count_EN-GB', 4 ); + + $this->service->handle_translation_with_object_id( 1, array( 'Nieuws' ), 'EN-GB', true ); + + expect( get_post_meta( 1, '_translation_EN-GB', true ) ) + ->toEqualCanonicalizing( array( pair( 'Nieuws', 'EN-GB:Nieuws' ), pair( 'Menu', 'Menu EN' ) ) ) + ->and( get_post_meta( 1, '_ydpl_uncached_request_count_EN-GB', true ) )->toBe( '' ); +} ); + +it( 'keeps the cache untouched and counts the request when the user may not cache', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Nieuws' ), 'EN-GB' ); + + expect( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ) + ->and( get_post_meta( 1, '_ydpl_uncached_request_count_EN-GB', true ) )->toBe( 1 ); +} ); + +it( 'ignores a cache older than the post and replaces it', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Old menu EN' ), pair( 'Oud', 'Old EN' ) ), '2026-09-30 10:00:00' ); + + $translation = $this->service->handle_translation_with_object_id( 1, array( 'Menu' ), 'EN-GB', true ); + + expect( $translation )->toBe( array( pair( 'Menu', 'EN-GB:Menu' ) ) ) + ->and( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( array( pair( 'Menu', 'EN-GB:Menu' ) ) ); +} ); + +it( 'does not cache when caching is disabled for the post', function () { + update_post_meta( 1, 'ydpl_disable_deepl_translation_cache', '1' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Menu' ), 'EN-GB', true ); + + expect( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( '' ); +} ); From 59378b90cb80fe0f3b32367464261b66150801e8 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 7 Oct 2026 17:07:27 +0200 Subject: [PATCH 3/8] fix: serve visitors cached texts without calling DeepL Visitors without the cache capability never write to the cache, so any text missing from an editor's snapshot made every language switch count towards the rate limit and ended in a 429 after three switches. When the cache contains any of the requested texts, visitors now get those cached texts only, without a DeepL call or rate limit hit. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- readme.txt | 4 ++-- src/Controllers/RestAPIController.php | 12 +++++++----- tests/Unit/RestAPIControllerTest.php | 20 ++++++++++++++------ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ced6995..d45b2f9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ When making requests to the API, ensure that the nonce is included in the reques #### Rate Limiting -To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When a partially cached request is rate limited, the cached texts are returned without calling DeepL. +To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. #### Cache Capability @@ -151,7 +151,7 @@ xhr.send( data ); ### NEXT - Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot -- Change: requests with texts missing from the cache count towards the rate limit; when rate limited, the cached texts are returned instead of an error +- Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached ### 2.1.0 (Aug 03, 2026) diff --git a/readme.txt b/readme.txt index a251abd..859c2d6 100644 --- a/readme.txt +++ b/readme.txt @@ -74,7 +74,7 @@ The API endpoints registered by this plugin are secured using a WordPress nonce. When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `X-WP-Nonce`, and it should contain a nonce created with the `wp_rest` action (available via `ydpl.ydpl_api_request_nonce` on the front-end). -Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When a partially cached request is rate limited, the cached texts are returned without calling DeepL. +Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. Cache capability: Only users with the `edit_posts` capability can trigger cache creation. This can be customized using the `yard::deepl/cache_capability` filter. @@ -126,7 +126,7 @@ Response: = NEXT = * Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot -* Change: requests with texts missing from the cache count towards the rate limit; when rate limited, the cached texts are returned instead of an error +* Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached = 2.1.0: Aug 03, 2026 = diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index daab79c..0358a58 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -65,14 +65,16 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re $missing_texts = $text; } - if ( array() !== $missing_texts && $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { - $partial_translation = $this->service->translations_for( $text, $cached_translation ?? array() ); + if ( ! $user_has_cache_capability ) { + $cached_texts = $this->service->translations_for( $text, $cached_translation ?? array() ); - if ( array() === $partial_translation ) { - return $this->set_failure_response( 429, 'Rate limit exceeded.' ); + if ( array() !== $cached_texts ) { + return new WP_REST_Response( $cached_texts ); } + } - return new WP_REST_Response( $partial_translation ); + if ( array() !== $missing_texts && $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { + return $this->set_failure_response( 429, 'Rate limit exceeded.' ); } try { diff --git a/tests/Unit/RestAPIControllerTest.php b/tests/Unit/RestAPIControllerTest.php index 08cf31f..9d4672b 100644 --- a/tests/Unit/RestAPIControllerTest.php +++ b/tests/Unit/RestAPIControllerTest.php @@ -48,15 +48,23 @@ function exhaust_rate_limit( RestAPIController $controller ): void ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ); } ); -it( 'returns the cached part without calling DeepL when rate limited', function () { - exhaust_rate_limit( $this->controller ); - $requests_before = count( $this->service->deepl_requests ); - - $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); +it( 'serves visitors only the cached texts without calling DeepL or counting towards the rate limit', function () { + foreach ( range( 1, 5 ) as $i ) { + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + } expect( $response->status )->toBe( 200 ) ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ) - ->and( $this->service->deepl_requests )->toHaveCount( $requests_before ); + ->and( $this->service->deepl_requests )->toBe( array() ); +} ); + +it( 'translates texts missing from the cache for users with the cache capability', function () { + $GLOBALS['ydpl_test']['can_cache'] = true; + + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + + expect( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ), pair( 'Goedemiddag', 'EN-GB:Goedemiddag' ) ) ) + ->and( $this->service->deepl_requests )->toBe( array( array( 'Goedemiddag' ) ) ); } ); it( 'rejects a rate limited request when nothing is cached', function () { From 30e0fab18ec110d9103315a3eb2a2200c731d8ec Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 7 Oct 2026 17:07:36 +0200 Subject: [PATCH 4/8] fix: raise visitor rate limit to 9 requests per minute Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- readme.txt | 3 ++- src/Controllers/RestAPIController.php | 2 +- tests/Unit/RestAPIControllerTest.php | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d45b2f9..cc11ef9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ When making requests to the API, ensure that the nonce is included in the reques #### Rate Limiting -To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. +To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **9 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. #### Cache Capability @@ -152,6 +152,7 @@ xhr.send( data ); - Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot - Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached +- Change: raise the rate limit from 3 to 9 requests per 60 seconds ### 2.1.0 (Aug 03, 2026) diff --git a/readme.txt b/readme.txt index 859c2d6..7f48d05 100644 --- a/readme.txt +++ b/readme.txt @@ -74,7 +74,7 @@ The API endpoints registered by this plugin are secured using a WordPress nonce. When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `X-WP-Nonce`, and it should contain a nonce created with the `wp_rest` action (available via `ydpl.ydpl_api_request_nonce` on the front-end). -Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. +Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 9 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. Cache capability: Only users with the `edit_posts` capability can trigger cache creation. This can be customized using the `yard::deepl/cache_capability` filter. @@ -127,6 +127,7 @@ Response: * Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot * Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached +* Change: raise the rate limit from 3 to 9 requests per 60 seconds = 2.1.0: Aug 03, 2026 = diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index 0358a58..b9c3589 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -22,7 +22,7 @@ */ class RestAPIController { - protected const RATE_LIMIT = 3; + protected const RATE_LIMIT = 9; protected const RATE_LIMIT_TIME_WINDOW_IN_SECONDS = 60; use ErrorLog; diff --git a/tests/Unit/RestAPIControllerTest.php b/tests/Unit/RestAPIControllerTest.php index 9d4672b..98a47fe 100644 --- a/tests/Unit/RestAPIControllerTest.php +++ b/tests/Unit/RestAPIControllerTest.php @@ -27,7 +27,7 @@ function translate_request( array $text ): WP_REST_Request function exhaust_rate_limit( RestAPIController $controller ): void { - foreach ( range( 1, 3 ) as $i ) { + foreach ( range( 1, 9 ) as $i ) { $controller->handle_translate_request( translate_request( array( "Tekst $i" ) ) ); } } From 6488667ff49bd9b45ef07f46e4efc4213945467b Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 4 Nov 2026 16:15:40 +0100 Subject: [PATCH 5/8] fix: flag incomplete caches and stop counting editors towards the rate limit Requests from users with the cache capability incremented the per-IP rate limit counter before the capability was checked, so editors used up the budget of visitors behind the same IP. Visitors served only part of their texts from the cache left no trace, so a partially filled cache showed as fully cached in the admin column. Those requests are now counted per language and shown as a yellow badge until an editor visit stores the missing texts or the cache is cleared. Also writes out missing_texts() step by step and marks new methods with @since NEXT, as requested in review. --- README.md | 7 ++- assets/css/admin-columns.css | 5 ++ languages/yard-deepl-nl_NL.mo | Bin 3766 -> 3631 bytes languages/yard-deepl-nl_NL.po | 9 +++- languages/yard-deepl.pot | 9 +++- readme.txt | 7 ++- src/Controllers/RestAPIController.php | 6 ++- src/Providers/AdminColumnsServiceProvider.php | 23 ++++++-- src/Repositories/TranslationRepository.php | 50 ++++++++++++------ src/Services/TranslationService.php | 19 +++++-- tests/Pest.php | 46 +++++++++++++++- .../Unit/AdminColumnsServiceProviderTest.php | 29 ++++++++++ tests/Unit/RestAPIControllerTest.php | 17 ++++++ tests/Unit/TranslationRepositoryTest.php | 28 ++++++++++ 14 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 tests/Unit/AdminColumnsServiceProviderTest.php create mode 100644 tests/Unit/TranslationRepositoryTest.php diff --git a/README.md b/README.md index cc11ef9..a779d84 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Each object that is translated will store its cached translation in the `wp_post - **Serving Cached Translations:** Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. - **Fetching New Translations:** When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. -- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. +- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: when any of the requested texts are cached, only those cached texts are returned; when none are cached, the texts are translated by DeepL without storing the result. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. @@ -40,10 +40,11 @@ A metabox labeled **Yard DeepL** is displayed on the edit screen of supported po A **Translation cache** column is added to the post list screen of all supported post types. Its purpose is to notify editors which posts should be cached as soon as possible to avoid unnecessary DeepL API costs. - **Green badge per language** (e.g. `NL`, `EN-US`): a valid, up-to-date cached translation exists for that language. Visitor requests are served from cache at no cost. +- **Yellow badge with a count per language** (e.g. `EN-US 3`): a cached translation exists, but that many visitor requests received only part of their texts because the rest is missing from the cache. Hovering the badge shows the full count as a tooltip. An editor visiting the page translates the missing texts, stores them and resets the count. - **Grey badge with a count per language** (e.g. `EN-US 42`): no cache exists for that language and visitors have triggered that many live DeepL API calls. Hovering the badge shows the full count as a tooltip. These posts are the most urgent to cache. - **—**: the post has never been requested for translation, or caching is disabled for this post. -The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. Posts with the **Disable translation cache** option enabled are excluded from the column entirely. +The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. The same applies to the incomplete response count, which is also removed when the cache is cleared. Posts with the **Disable translation cache** option enabled are excluded from the column entirely. ## External Services @@ -153,6 +154,8 @@ xhr.send( data ); - Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot - Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached - Change: raise the rate limit from 3 to 9 requests per 60 seconds +- Fix: requests from users with the cache capability no longer count towards the visitor rate limit +- Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts ### 2.1.0 (Aug 03, 2026) diff --git a/assets/css/admin-columns.css b/assets/css/admin-columns.css index c8949b2..37bec44 100644 --- a/assets/css/admin-columns.css +++ b/assets/css/admin-columns.css @@ -21,6 +21,11 @@ color: #fff; } +.ydpl-cache-badge--incomplete { + background: #dba617; + color: #1d2327; +} + .ydpl-cache-badge--uncached { background: #f0f0f1; color: #3c434a; diff --git a/languages/yard-deepl-nl_NL.mo b/languages/yard-deepl-nl_NL.mo index 1f1d2375fc611937acd67a738b9c172cb25629db..26d7fae5dc8b8b40f77f4585e8f223a93882fc66 100644 GIT binary patch delta 1055 zcmZ|N&r6g+9LMo*^{m^v{UB}AWTmAaScsKT5H5tUfuRs8LH2{f+)1`}pJ&;9(4b%s z5fO9<)SK0{n}Hzx1$&CTbP$SA3IaP6Q9%cz_op86)G|A-nR#Zu^PPE~h4#60?Ol^M zWBg9?PxF7fK|QnCP3s<}`I!D*e2ESC858&eccOV_DM+E_bz&=?#TbsG)|IdcL)>as zv->o5GcbdvaSq$?8*1TpDlJIi4m^mPu?ORrLj@j)97c*=pi+WYu@MWX`5|ik9c*BK zyB9rph?+Q!yYMNt-~wvnSJ;lrs11Li=EdmfhEBSKI?)&0hjF6o-FBij>ZRh)Hh|h_2$h&az3WNr$497}dV>`ENu~K-(rg>H zq7v*#a{s!bBMd0;@yH(J@Yah;un#FVNJVaT8TI@+>UjwjX95-YHtHL?i~6>nARn8( zKqXd3?e`(c{p;kb4CtgAxBLy*5}82-JcJbMj>b<#_Ms9Pj69EJ`d9E7F5)G;#qKoi zHEQD()XjVxjv8N4?{E!uRn6SfpDWUR9mr=2{#Z~d7hUKw6;~|>e%1YFxH|vbI}wYY v>&jK!XgKvLv484)BHI=C6G5@)@`drts2@%SK_#yM>%;Efxbx@R8fU%Vsrz)` delta 1228 zcmYk*OGs2v7{KvQ9V>M-v&U3w+QXV2mSrj~s*MmyR_ms=WsPHp#7>#Bi3Roa>Y?@K6espun#No60VEL z#Z@X@G~CAZ_!+rko|_LW;0ml{lx5g}OEHTM+>+RVK6emZU>BC-F|>al`uthsiVKDI zT*M$1I}YI*9L5YjKqt;)J&q$+%yP5eH+1JkjPAf0bfQLXBqOp(do%i6JG!s~$Q6f` z`Qtd10X&5x_zq9w2}X4RPm!j0gAOo`F8BkwfG@Zbf1>?TbgJSQ)3}%4wRGmM=t=BF zH+Tkf?&J!U&3F?%)0fB<6WmPcM|6Tu=z_kZXZ#zxF;lUap>s%6TtoX`$0~e;F6BV=a9C@jt34O^9p-0z) z&T|pn`5?N{ku>>tz=t&GV|2h#%-WBCkf!Ko`%Ziw z&CEdJ5PEdOIVz^=J{D4h99=9YoXB1LU)qu%ex0qvM#2m=6Mnvyt;8Hm|j0ei|>ew^vwOnO=$yR#{QLRDoupDwU}Ou@0B|*CUy{< ySmCAg7&;<4qn(N+OYA3F{E?JKG)=yA+lbu+2`ZHQ`;xZOTg_#i`MYKJQhx!=fLl)h diff --git a/languages/yard-deepl-nl_NL.po b/languages/yard-deepl-nl_NL.po index a5a63e7..7e191a2 100644 --- a/languages/yard-deepl-nl_NL.po +++ b/languages/yard-deepl-nl_NL.po @@ -229,8 +229,15 @@ msgstr "Geen talen geconfigureerd" msgid "No cached translations" msgstr "Geen gecachede vertalingen" +#. Translators: %d is the number of visitor requests that received only part of their texts from the cache. +#: src/Providers/AdminColumnsServiceProvider.php:90 +msgid "%d incomplete response" +msgid_plural "%d incomplete responses" +msgstr[0] "%d onvolledig antwoord" +msgstr[1] "%d onvolledige antwoorden" + #. Translators: %d is the number of uncached DeepL API calls made for this post and language. -#: src/Providers/AdminColumnsServiceProvider.php:94 +#: src/Providers/AdminColumnsServiceProvider.php:109 msgid "%d uncached call" msgid_plural "%d uncached calls" msgstr[0] "%d aanroep zonder cache" diff --git a/languages/yard-deepl.pot b/languages/yard-deepl.pot index 5bbc018..28fe3db 100644 --- a/languages/yard-deepl.pot +++ b/languages/yard-deepl.pot @@ -232,8 +232,15 @@ msgstr "" msgid "No cached translations" msgstr "" +#. Translators: %d is the number of visitor requests that received only part of their texts from the cache. +#: src/Providers/AdminColumnsServiceProvider.php:90 +msgid "%d incomplete response" +msgid_plural "%d incomplete responses" +msgstr[0] "" +msgstr[1] "" + #. Translators: %d is the number of uncached DeepL API calls made for this post and language. -#: src/Providers/AdminColumnsServiceProvider.php:94 +#: src/Providers/AdminColumnsServiceProvider.php:109 msgid "%d uncached call" msgid_plural "%d uncached calls" msgstr[0] "" diff --git a/readme.txt b/readme.txt index 7f48d05..1739851 100644 --- a/readme.txt +++ b/readme.txt @@ -22,7 +22,7 @@ Each object that is translated will store its cached translation in the `wp_post * Serving Cached Translations: Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. * Fetching New Translations: When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. -* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. +* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: when any of the requested texts are cached, only those cached texts are returned; when none are cached, the texts are translated by DeepL without storing the result. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. @@ -38,10 +38,11 @@ A metabox labeled Yard DeepL is displayed on the edit screen of supported post t A Translation cache column is added to the post list screen of all supported post types. Its purpose is to notify editors which posts should be cached as soon as possible to avoid unnecessary DeepL API costs. * Green badge per language (e.g. NL, EN-US): a valid, up-to-date cached translation exists for that language. Visitor requests are served from cache at no cost. +* Yellow badge with a count per language (e.g. EN-US 3): a cached translation exists, but that many visitor requests received only part of their texts because the rest is missing from the cache. Hovering the badge shows the full count as a tooltip. An editor visiting the page translates the missing texts, stores them and resets the count. * Grey badge with a count per language (e.g. EN-US 42): no cache exists for that language and visitors have triggered that many live DeepL API calls. Hovering the badge shows the full count as a tooltip. These posts are the most urgent to cache. * —: the post has never been requested for translation, or caching is disabled for this post. -The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. Posts with the Disable translation cache option enabled are excluded from the column entirely. +The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. The same applies to the incomplete response count, which is also removed when the cache is cleared. Posts with the Disable translation cache option enabled are excluded from the column entirely. == External Services == @@ -128,6 +129,8 @@ Response: * Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot * Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached * Change: raise the rate limit from 3 to 9 requests per 60 seconds +* Fix: requests from users with the cache capability no longer count towards the visitor rate limit +* Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts = 2.1.0: Aug 03, 2026 = diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index b9c3589..c437df3 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -69,11 +69,15 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re $cached_texts = $this->service->translations_for( $text, $cached_translation ?? array() ); if ( array() !== $cached_texts ) { + if ( array() !== $missing_texts ) { + $this->service->increment_incomplete_request_count( $object_id, $target_lang ); + } + return new WP_REST_Response( $cached_texts ); } } - if ( array() !== $missing_texts && $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { + if ( ! $user_has_cache_capability && array() !== $missing_texts && $this->is_rate_limit_exceeded() ) { return $this->set_failure_response( 429, 'Rate limit exceeded.' ); } diff --git a/src/Providers/AdminColumnsServiceProvider.php b/src/Providers/AdminColumnsServiceProvider.php index 701ef8f..da79d5b 100644 --- a/src/Providers/AdminColumnsServiceProvider.php +++ b/src/Providers/AdminColumnsServiceProvider.php @@ -72,14 +72,29 @@ public function render_column( string $column, int $post_id ): void return; } - $column_data = $this->repository->get_column_data( $post_id, $configured_languages ); - $cached_langs = $column_data['cached_languages']; - $uncached_counts = $column_data['uncached_request_counts']; + $column_data = $this->repository->get_column_data( $post_id, $configured_languages ); + $cached_langs = $column_data['cached_languages']; + $uncached_counts = $column_data['uncached_request_counts']; + $incomplete_counts = $column_data['incomplete_request_counts']; $badges = ''; foreach ( $configured_languages as $lang ) { - if ( in_array( $lang, $cached_langs, true ) ) { + if ( in_array( $lang, $cached_langs, true ) && isset( $incomplete_counts[ $lang ] ) ) { + $count = $incomplete_counts[ $lang ]; + $badges .= sprintf( + '%s %s', + esc_attr( + sprintf( + /* translators: %d: number of visitor requests that received only part of their texts from the cache */ + _n( '%d incomplete response', '%d incomplete responses', $count, 'yard-deepl' ), + $count + ) + ), + esc_html( $lang ), + esc_html( number_format_i18n( $count ) ) + ); + } elseif ( in_array( $lang, $cached_langs, true ) ) { $badges .= sprintf( '%s', esc_html( $lang ) diff --git a/src/Repositories/TranslationRepository.php b/src/Repositories/TranslationRepository.php index f895c78..a746505 100644 --- a/src/Repositories/TranslationRepository.php +++ b/src/Repositories/TranslationRepository.php @@ -46,19 +46,22 @@ public function get_cached_translation( int $object_id, string $target_lang ): ? /** * Returns data needed to render the admin list-table column in a single meta fetch. * - * 'cached_languages' — ISO codes from $language_codes whose cache is valid and non-stale. - * 'uncached_request_counts' — map of ISO code => cumulative uncached visitor API calls, only for - * languages that have at least one recorded call. + * 'cached_languages' — ISO codes from $language_codes whose cache is valid and non-stale. + * 'uncached_request_counts' — map of ISO code => cumulative uncached visitor API calls, only for + * languages that have at least one recorded call. + * 'incomplete_request_counts' — map of ISO code => visitor requests that received only part of + * their texts from the cache, only for languages with at least one. * * @since 2.0.0 * - * @return array{ cached_languages: string[], uncached_request_counts: array } + * @return array{ cached_languages: string[], uncached_request_counts: array, incomplete_request_counts: array } */ public function get_column_data( int $object_id, array $language_codes ): array { $empty = array( - 'cached_languages' => array(), - 'uncached_request_counts' => array(), + 'cached_languages' => array(), + 'uncached_request_counts' => array(), + 'incomplete_request_counts' => array(), ); if ( ! $this->translated_object_exists( $object_id ) || $this->is_cache_disabled( $object_id ) ) { @@ -70,6 +73,7 @@ public function get_column_data( int $object_id, array $language_codes ): array $all_meta = get_post_meta( $object_id ); $cached = array(); $counts = array(); + $incomplete_counts = array(); foreach ( $language_codes as $lang ) { $translation_value = $all_meta[ "_translation_$lang" ][0] ?? null; @@ -84,11 +88,18 @@ public function get_column_data( int $object_id, array $language_codes ): array if ( 0 < $count ) { $counts[ $lang ] = $count; } + + $incomplete_count = (int) ( $all_meta[ "_ydpl_incomplete_request_count_$lang" ][0] ?? 0 ); + + if ( 0 < $incomplete_count ) { + $incomplete_counts[ $lang ] = $incomplete_count; + } } return array( - 'cached_languages' => $cached, - 'uncached_request_counts' => $counts, + 'cached_languages' => $cached, + 'uncached_request_counts' => $counts, + 'incomplete_request_counts' => $incomplete_counts, ); } @@ -102,12 +113,24 @@ public function get_column_data( int $object_id, array $language_codes ): array * @since 2.0.0 */ public function increment_uncached_request_count( int $object_id, string $target_lang ): void + { + $this->increment_request_count( $object_id, "_ydpl_uncached_request_count_$target_lang" ); + } + + /** + * @since NEXT + */ + public function increment_incomplete_request_count( int $object_id, string $target_lang ): void + { + $this->increment_request_count( $object_id, "_ydpl_incomplete_request_count_$target_lang" ); + } + + protected function increment_request_count( int $object_id, string $key ): void { if ( ! $this->translated_object_exists( $object_id ) || $this->is_cache_disabled( $object_id ) ) { return; } - $key = "_ydpl_uncached_request_count_$target_lang"; $current = (int) get_post_meta( $object_id, $key, true ); update_post_meta( $object_id, $key, $current + 1 ); @@ -124,12 +147,6 @@ protected function translated_object_exists( int $object_id ): bool } /** - * Deletes all cached translations and their modified timestamps for every language. - * - * Removes both the cached translation data (_translation_) and the - * staleness timestamp (_translation_modified_) written by store_translation(). - * Returns silently when the object does not exist. - * * @since 2.0.0 */ public function delete_cached_translations( int $object_id ): void @@ -141,7 +158,7 @@ public function delete_cached_translations( int $object_id ): void $meta_keys = array_keys( get_post_meta( $object_id ) ); foreach ( $meta_keys as $key ) { - if ( 0 === strpos( $key, '_translation_' ) || 0 === strpos( $key, '_translation_modified_' ) ) { + if ( 0 === strpos( $key, '_translation_' ) || 0 === strpos( $key, '_ydpl_incomplete_request_count_' ) ) { delete_post_meta( $object_id, $key ); } } @@ -165,6 +182,7 @@ public function store_translation( int $object_id, string $target_lang, array $t update_post_meta( $object_id, "_translation_$target_lang", $translation ); update_post_meta( $object_id, "_translation_modified_$target_lang", current_time( 'mysql' ) ); delete_post_meta( $object_id, "_ydpl_uncached_request_count_$target_lang" ); + delete_post_meta( $object_id, "_ydpl_incomplete_request_count_$target_lang" ); } protected function is_cache_disabled( int $object_id ): bool diff --git a/src/Services/TranslationService.php b/src/Services/TranslationService.php index 567102b..2f341f6 100644 --- a/src/Services/TranslationService.php +++ b/src/Services/TranslationService.php @@ -72,15 +72,20 @@ public function handle_translation_with_object_id( int $object_id, array $text, } /** - * @since 2.2.0 + * @since NEXT */ public function missing_texts( array $text, array $cached_translation ): array { - return array_values( array_unique( array_diff( $text, array_keys( $this->translations_by_text( $cached_translation ) ) ) ) ); + $translations_by_text = $this->translations_by_text( $cached_translation ); + $cached_texts = array_keys( $translations_by_text ); + $missing_texts = array_diff( $text, $cached_texts ); + $unique_missing_texts = array_unique( $missing_texts ); + + return array_values( $unique_missing_texts ); } /** - * @since 2.2.0 + * @since NEXT */ public function translations_for( array $text, array $translation ): array { @@ -113,6 +118,14 @@ public function handle_translation_without_object_id( array $text, string $targe return $translation; } + /** + * @since NEXT + */ + public function increment_incomplete_request_count( int $object_id, string $target_lang ): void + { + $this->repository->increment_incomplete_request_count( $object_id, $target_lang ); + } + /** * @since 2.0.0 * diff --git a/tests/Pest.php b/tests/Pest.php index bd1e1c1..755cef0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -44,7 +44,13 @@ function get_post_field( string $field, int $id ): string function get_post_meta( int $id, string $key = '', bool $single = false ) { - return $GLOBALS['ydpl_test']['meta'][ $id ][ $key ] ?? ''; + $meta = $GLOBALS['ydpl_test']['meta'][ $id ] ?? array(); + + if ( '' === $key ) { + return array_map( fn ( $value ) => array( $value ), $meta ); + } + + return $meta[ $key ] ?? ''; } function update_post_meta( int $id, string $key, $value ): void @@ -84,9 +90,47 @@ function set_transient( string $key, $value, int $expiration ): void function ydpl_resolve_from_container( string $key ) { + if ( isset( $GLOBALS['ydpl_test']['languages'] ) ) { + return new class( $GLOBALS['ydpl_test']['languages'] ) { + public function __construct( private array $languages ) + { + } + + public function configured_supported_languages(): array + { + return $this->languages; + } + }; + } + return YDPL\Singletons\SiteOptionsSingleton::get_instance( array() ); } +function esc_attr( string $text ): string +{ + return $text; +} + +function esc_html( string $text ): string +{ + return $text; +} + +function esc_attr__( string $text, string $domain ): string +{ + return $text; +} + +function _n( string $single, string $plural, int $number, string $domain ): string +{ + return 1 === $number ? $single : $plural; +} + +function number_format_i18n( int $number ): string +{ + return (string) $number; +} + function fake_deepl_service(): YDPL\Services\TranslationService { return new class() extends YDPL\Services\TranslationService { diff --git a/tests/Unit/AdminColumnsServiceProviderTest.php b/tests/Unit/AdminColumnsServiceProviderTest.php new file mode 100644 index 0000000..678d5b7 --- /dev/null +++ b/tests/Unit/AdminColumnsServiceProviderTest.php @@ -0,0 +1,29 @@ +render_column( 'ydpl_translation_cache', 1 ); + + return ob_get_clean(); +} + +it( 'shows a cached language without incomplete responses as cached', function () { + expect( rendered_column() )->toContain( 'ydpl-cache-badge--cached' ); +} ); + +it( 'shows a cached language with incomplete responses as incomplete', function () { + update_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', 2 ); + + expect( rendered_column() ) + ->toContain( 'ydpl-cache-badge--incomplete" title="2 incomplete responses">EN-GB' ) + ->not->toContain( 'ydpl-cache-badge--cached' ); +} ); diff --git a/tests/Unit/RestAPIControllerTest.php b/tests/Unit/RestAPIControllerTest.php index 98a47fe..8471aed 100644 --- a/tests/Unit/RestAPIControllerTest.php +++ b/tests/Unit/RestAPIControllerTest.php @@ -74,3 +74,20 @@ function exhaust_rate_limit( RestAPIController $controller ): void expect( $response->status )->toBe( 429 ); } ); + +it( 'does not count requests from users with the cache capability towards the rate limit', function () { + $GLOBALS['ydpl_test']['can_cache'] = true; + exhaust_rate_limit( $this->controller ); + $GLOBALS['ydpl_test']['can_cache'] = false; + + $response = $this->controller->handle_translate_request( translate_request( array( 'Goedemiddag' ) ) ); + + expect( $response->status )->toBe( 200 ); +} ); + +it( 'counts visitor requests that received only part of their texts from the cache', function () { + $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + $this->controller->handle_translate_request( translate_request( array( 'Menu' ) ) ); + + expect( get_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', true ) )->toBe( 1 ); +} ); diff --git a/tests/Unit/TranslationRepositoryTest.php b/tests/Unit/TranslationRepositoryTest.php new file mode 100644 index 0000000..af1bad9 --- /dev/null +++ b/tests/Unit/TranslationRepositoryTest.php @@ -0,0 +1,28 @@ +repository = new TranslationRepository(); +} ); + +it( 'reports incomplete request counts per language', function () { + $column_data = $this->repository->get_column_data( 1, array( 'EN-GB', 'DE' ) ); + + expect( $column_data['incomplete_request_counts'] )->toBe( array( 'EN-GB' => 3 ) ); +} ); + +it( 'resets the incomplete request count when a translation is stored', function () { + $this->repository->store_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ) ); + + expect( get_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', true ) )->toBe( '' ); +} ); + +it( 'deletes the incomplete request count together with the cached translations', function () { + $this->repository->delete_cached_translations( 1 ); + + expect( get_post_meta( 1 ) )->toBe( array() ); +} ); From 8db044ca887753b00b818e74ff36e3978732422f Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 4 Nov 2026 16:22:12 +0100 Subject: [PATCH 6/8] fix: reset uncached call count when the translation cache is cleared Clearing the cache via the metabox left _ydpl_uncached_request_count_ behind, so the grey badge kept showing an old count for a post without cache. --- README.md | 1 + readme.txt | 1 + src/Repositories/TranslationRepository.php | 2 +- tests/Unit/TranslationRepositoryTest.php | 4 +++- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a779d84..cd9476f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ xhr.send( data ); - Change: raise the rate limit from 3 to 9 requests per 60 seconds - Fix: requests from users with the cache capability no longer count towards the visitor rate limit - Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts +- Fix: clearing the translation cache also resets the uncached call count ### 2.1.0 (Aug 03, 2026) diff --git a/readme.txt b/readme.txt index 1739851..5dbc459 100644 --- a/readme.txt +++ b/readme.txt @@ -131,6 +131,7 @@ Response: * Change: raise the rate limit from 3 to 9 requests per 60 seconds * Fix: requests from users with the cache capability no longer count towards the visitor rate limit * Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts +* Fix: clearing the translation cache also resets the uncached call count = 2.1.0: Aug 03, 2026 = diff --git a/src/Repositories/TranslationRepository.php b/src/Repositories/TranslationRepository.php index a746505..f86d6a6 100644 --- a/src/Repositories/TranslationRepository.php +++ b/src/Repositories/TranslationRepository.php @@ -158,7 +158,7 @@ public function delete_cached_translations( int $object_id ): void $meta_keys = array_keys( get_post_meta( $object_id ) ); foreach ( $meta_keys as $key ) { - if ( 0 === strpos( $key, '_translation_' ) || 0 === strpos( $key, '_ydpl_incomplete_request_count_' ) ) { + if ( 0 === strpos( $key, '_translation_' ) || 0 === strpos( $key, '_ydpl_incomplete_request_count_' ) || 0 === strpos( $key, '_ydpl_uncached_request_count_' ) ) { delete_post_meta( $object_id, $key ); } } diff --git a/tests/Unit/TranslationRepositoryTest.php b/tests/Unit/TranslationRepositoryTest.php index af1bad9..fc15880 100644 --- a/tests/Unit/TranslationRepositoryTest.php +++ b/tests/Unit/TranslationRepositoryTest.php @@ -21,7 +21,9 @@ expect( get_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', true ) )->toBe( '' ); } ); -it( 'deletes the incomplete request count together with the cached translations', function () { +it( 'deletes the request counts together with the cached translations', function () { + update_post_meta( 1, '_ydpl_uncached_request_count_DE', 5 ); + $this->repository->delete_cached_translations( 1 ); expect( get_post_meta( 1 ) )->toBe( array() ); From 0688e70145c52e28122c0fc4d23583ecca71960d Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 4 Nov 2026 17:19:21 +0100 Subject: [PATCH 7/8] fix: rebuild dist so the incomplete cache badge is styled Co-Authored-By: Claude Opus 5.5 --- dist/admin-columns.asset.php | 2 +- dist/admin-columns.css | 2 +- dist/editor.asset.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/admin-columns.asset.php b/dist/admin-columns.asset.php index f74de63..bb0a903 100644 --- a/dist/admin-columns.asset.php +++ b/dist/admin-columns.asset.php @@ -1 +1 @@ - array(), 'version' => '1d877b8f32d5c2ebba6e'); + array(), 'version' => '0921bcaa4b4ca4ac535b'); diff --git a/dist/admin-columns.css b/dist/admin-columns.css index 18ae8fd..8d7fb5d 100644 --- a/dist/admin-columns.css +++ b/dist/admin-columns.css @@ -1 +1 @@ -.ydpl-cache-badges{display:flex;flex-wrap:wrap;gap:3px}.ydpl-cache-badge{align-items:center;border-radius:3px;display:inline-flex;font-size:10px;font-weight:600;gap:4px;letter-spacing:.03em;line-height:1;padding:3px 6px}.ydpl-cache-badge--cached{background:#00a32a;color:#fff}.ydpl-cache-badge--uncached{background:#f0f0f1;color:#3c434a}.ydpl-badge-count{font-size:9px;font-weight:400;opacity:.7}.ydpl-cache-status--none{color:#8c8f94} +.ydpl-cache-badges{display:flex;flex-wrap:wrap;gap:3px}.ydpl-cache-badge{align-items:center;border-radius:3px;display:inline-flex;font-size:10px;font-weight:600;gap:4px;letter-spacing:.03em;line-height:1;padding:3px 6px}.ydpl-cache-badge--cached{background:#00a32a;color:#fff}.ydpl-cache-badge--incomplete{background:#dba617;color:#1d2327}.ydpl-cache-badge--uncached{background:#f0f0f1;color:#3c434a}.ydpl-badge-count{font-size:9px;font-weight:400;opacity:.7}.ydpl-cache-status--none{color:#8c8f94} diff --git a/dist/editor.asset.php b/dist/editor.asset.php index c31ee19..8712eb1 100644 --- a/dist/editor.asset.php +++ b/dist/editor.asset.php @@ -1 +1 @@ - array(), 'version' => '045a517d31b6755cceb1'); + array(), 'version' => '1e80dfdc79e5d8f97eba'); From 89621482556bc988aea0cad79bd5358c3c5a96c2 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 5 Nov 2026 17:19:25 +0100 Subject: [PATCH 8/8] fix: translate missing texts live for visitors and support all public post types Visitors without the cache capability now receive texts missing from the cache from DeepL, merged with the cached texts but not stored. When the rate limit is reached they fall back to the cached texts only. The cache metabox and translation cache column now default to every public post type except attachments, so editors also see the cache status on custom post types such as pdc-item. Columns are registered on admin_init so post types registered later on init are included. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- readme.txt | 9 +++++---- src/Controllers/RestAPIController.php | 16 +++++++--------- src/Providers/AdminColumnsServiceProvider.php | 13 +++++++++++-- src/Traits/CachePostTypesTrait.php | 2 +- tests/Pest.php | 15 +++++++++++++++ tests/Unit/CachePostTypesTraitTest.php | 16 ++++++++++++++++ tests/Unit/RestAPIControllerTest.php | 17 +++++++++++++---- 8 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/CachePostTypesTraitTest.php diff --git a/README.md b/README.md index cd9476f..6f10d92 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ Each object that is translated will store its cached translation in the `wp_post - **Serving Cached Translations:** Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. - **Fetching New Translations:** When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. -- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: when any of the requested texts are cached, only those cached texts are returned; when none are cached, the texts are translated by DeepL without storing the result. +- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: texts missing from the cache are translated by DeepL and returned together with the cached texts, without storing the result. When the rate limit is reached, only the cached texts are returned. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. ### Admin: Cache Metabox -A metabox labeled **Yard DeepL** is displayed on the edit screen of supported post types (default: `page`). It provides two options: +A metabox labeled **Yard DeepL** is displayed on the edit screen of supported post types (default: all public post types except `attachment`). It provides two options: - **Disable translation cache:** When checked, the cache is bypassed for this object. Useful for posts with dynamic content that should always be translated fresh. - **Clear translation cache:** When checked and the post is saved, all cached translations for this object are deleted. @@ -144,7 +144,7 @@ xhr.send( data ); | Filter | Default | Description | | -------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `yard::deepl/cache_capability` | `'edit_posts'` | The WordPress capability required to create cache entries. Users without this capability receive translations but results are not cached. | -| `yard::deepl/cache_metabox_post_types` | `['page']` | Post types on which the Yard DeepL cache metabox is displayed. | +| `yard::deepl/cache_metabox_post_types` | public types | Post types on which the Yard DeepL cache metabox is displayed. | | ~~`yard::deepl/disable_cache_metabox_post_types`~~ | — | **Deprecated.** Use `yard::deepl/cache_metabox_post_types` instead. | ## Changelog @@ -152,11 +152,12 @@ xhr.send( data ); ### NEXT - Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot -- Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached +- Change: users without the cache capability receive texts missing from the cache from DeepL without storing them; when rate limited they receive the cached texts only - Change: raise the rate limit from 3 to 9 requests per 60 seconds - Fix: requests from users with the cache capability no longer count towards the visitor rate limit - Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts - Fix: clearing the translation cache also resets the uncached call count +- Change: the cache metabox and translation cache column are shown on all public post types except `attachment` by default ### 2.1.0 (Aug 03, 2026) diff --git a/readme.txt b/readme.txt index 5dbc459..36a35f8 100644 --- a/readme.txt +++ b/readme.txt @@ -22,13 +22,13 @@ Each object that is translated will store its cached translation in the `wp_post * Serving Cached Translations: Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. * Fetching New Translations: When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. -* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: when any of the requested texts are cached, only those cached texts are returned; when none are cached, the texts are translated by DeepL without storing the result. +* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: texts missing from the cache are translated by DeepL and returned together with the cached texts, without storing the result. When the rate limit is reached, only the cached texts are returned. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. = Admin: Cache Metabox = -A metabox labeled Yard DeepL is displayed on the edit screen of supported post types (default: page). It provides two options: +A metabox labeled Yard DeepL is displayed on the edit screen of supported post types (default: all public post types except attachment). It provides two options: * Disable translation cache: When checked, the cache is bypassed for this object. Useful for posts with dynamic content that should always be translated fresh. * Clear translation cache: When checked and the post is saved, all cached translations for this object are deleted. @@ -119,7 +119,7 @@ Response: == Filters == * `yard::deepl/cache_capability` (default: `edit_posts`) — The WordPress capability required to create cache entries. Users without this capability receive translations but results are not cached. -* `yard::deepl/cache_metabox_post_types` (default: `['page']`) — Post types on which the Yard DeepL cache metabox is displayed. +* `yard::deepl/cache_metabox_post_types` (default: all public post types except `attachment`) — Post types on which the Yard DeepL cache metabox is displayed. * `yard::deepl/disable_cache_metabox_post_types` — Deprecated. Use `yard::deepl/cache_metabox_post_types` instead. == Changelog == @@ -127,11 +127,12 @@ Response: = NEXT = * Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot -* Change: users without the cache capability are served the cached texts only, without calling DeepL or counting towards the rate limit; DeepL is only called when none of the requested texts are cached +* Change: users without the cache capability receive texts missing from the cache from DeepL without storing them; when rate limited they receive the cached texts only * Change: raise the rate limit from 3 to 9 requests per 60 seconds * Fix: requests from users with the cache capability no longer count towards the visitor rate limit * Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts * Fix: clearing the translation cache also resets the uncached call count +* Change: the cache metabox and translation cache column are shown on all public post types except `attachment` by default = 2.1.0: Aug 03, 2026 = diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index c437df3..a615663 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -65,20 +65,18 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re $missing_texts = $text; } - if ( ! $user_has_cache_capability ) { + if ( ! $user_has_cache_capability && array() !== $missing_texts ) { $cached_texts = $this->service->translations_for( $text, $cached_translation ?? array() ); if ( array() !== $cached_texts ) { - if ( array() !== $missing_texts ) { - $this->service->increment_incomplete_request_count( $object_id, $target_lang ); - } - - return new WP_REST_Response( $cached_texts ); + $this->service->increment_incomplete_request_count( $object_id, $target_lang ); } - } - if ( ! $user_has_cache_capability && array() !== $missing_texts && $this->is_rate_limit_exceeded() ) { - return $this->set_failure_response( 429, 'Rate limit exceeded.' ); + if ( $this->is_rate_limit_exceeded() ) { + return array() !== $cached_texts + ? new WP_REST_Response( $cached_texts ) + : $this->set_failure_response( 429, 'Rate limit exceeded.' ); + } } try { diff --git a/src/Providers/AdminColumnsServiceProvider.php b/src/Providers/AdminColumnsServiceProvider.php index da79d5b..cf917a1 100644 --- a/src/Providers/AdminColumnsServiceProvider.php +++ b/src/Providers/AdminColumnsServiceProvider.php @@ -36,13 +36,22 @@ public function __construct() * @since 2.0.0 */ public function register(): void + { + add_action( 'admin_init', array( $this, 'register_columns' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + } + + /** + * Must run after every post type is registered on init. + * + * @since NEXT + */ + public function register_columns(): void { foreach ( $this->get_cache_metabox_post_types() as $post_type ) { add_filter( "manage_{$post_type}_posts_columns", array( $this, 'add_column' ) ); add_action( "manage_{$post_type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); } - - add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); } /** diff --git a/src/Traits/CachePostTypesTrait.php b/src/Traits/CachePostTypesTrait.php index cdb43f9..1b48f1b 100644 --- a/src/Traits/CachePostTypesTrait.php +++ b/src/Traits/CachePostTypesTrait.php @@ -26,7 +26,7 @@ protected function get_cache_metabox_post_types(): array { $post_types = apply_filters_deprecated( 'yard::deepl/disable_cache_metabox_post_types', - array( array( 'page' ) ), + array( array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) ) ), '2.0.0', 'yard::deepl/cache_metabox_post_types' ); diff --git a/tests/Pest.php b/tests/Pest.php index 755cef0..82f4bc1 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -78,6 +78,21 @@ function apply_filters( string $hook, $value ) return $value; } +function apply_filters_deprecated( string $hook, array $args ) +{ + return $args[0]; +} + +function get_post_types( array $args ): array +{ + return array( + 'post' => 'post', + 'page' => 'page', + 'attachment' => 'attachment', + 'pdc-item' => 'pdc-item', + ); +} + function get_transient( string $key ) { return $GLOBALS['ydpl_test']['transients'][ $key ] ?? false; diff --git a/tests/Unit/CachePostTypesTraitTest.php b/tests/Unit/CachePostTypesTraitTest.php new file mode 100644 index 0000000..b20c79d --- /dev/null +++ b/tests/Unit/CachePostTypesTraitTest.php @@ -0,0 +1,16 @@ +get_cache_metabox_post_types(); + } + }; + + expect( $subject->post_types() )->toBe( array( 'post', 'page', 'pdc-item' ) ); +} ); diff --git a/tests/Unit/RestAPIControllerTest.php b/tests/Unit/RestAPIControllerTest.php index 8471aed..f3ed767 100644 --- a/tests/Unit/RestAPIControllerTest.php +++ b/tests/Unit/RestAPIControllerTest.php @@ -48,10 +48,19 @@ function exhaust_rate_limit( RestAPIController $controller ): void ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ); } ); -it( 'serves visitors only the cached texts without calling DeepL or counting towards the rate limit', function () { - foreach ( range( 1, 5 ) as $i ) { - $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); - } +it( 'translates texts missing from the cache for visitors without storing them', function () { + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + + expect( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ), pair( 'Goedemiddag', 'EN-GB:Goedemiddag' ) ) ) + ->and( $this->service->deepl_requests )->toBe( array( array( 'Goedemiddag' ) ) ) + ->and( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( array( pair( 'Menu', 'Menu EN' ), pair( 'Admin bar', 'Admin bar EN' ) ) ); +} ); + +it( 'serves rate limited visitors the cached texts without calling DeepL', function () { + exhaust_rate_limit( $this->controller ); + $this->service->deepl_requests = array(); + + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); expect( $response->status )->toBe( 200 ) ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) )