From 88e4a70f88107df20dab84b28599a96118bcb0d8 Mon Sep 17 00:00:00 2001 From: tracer Date: Sun, 8 Feb 2026 19:04:12 +0100 Subject: [PATCH] Add comprehensive test coverage and update notes --- .gitignore | 1 + CHANGELOG.md | 5 + NOTES.md | 6 + artisan | 18 +- bootstrap/cache/packages.php | 7 + bootstrap/cache/services.php | 10 +- composer.lock | 1965 +++++++++++++---- phpunit.xml | 4 +- scripts/build_release_assets.sh | 71 + tests/Feature/AuthControllerTest.php | 42 + tests/Feature/ForumControllerTest.php | 362 +++ tests/Feature/PortalControllerTest.php | 87 + tests/Feature/PostControllerTest.php | 24 + tests/Feature/PostThankControllerTest.php | 70 + tests/Feature/PreviewControllerTest.php | 40 + tests/Feature/RankControllerTest.php | 124 ++ tests/Feature/RoleControllerTest.php | 88 + tests/Feature/SettingControllerTest.php | 13 + .../SystemUpdateControllerBranchesTest.php | 462 ++++ tests/Feature/SystemUpdateControllerTest.php | 2 + tests/Feature/ThreadControllerTest.php | 36 +- tests/Unit/AttachmentControllerUnitTest.php | 555 +++++ .../AttachmentExtensionControllerUnitTest.php | 174 ++ .../AttachmentGroupControllerUnitTest.php | 277 +++ tests/Unit/AuditLogControllerUnitTest.php | 55 + tests/Unit/BbcodeFormatterTest.php | 155 ++ tests/Unit/ConsoleCommandTest.php | 50 + tests/Unit/CronRunCommandTest.php | 159 ++ tests/Unit/ForumControllerUnitTest.php | 114 + tests/Unit/InstallerControllerTest.php | 146 ++ tests/Unit/PostControllerUnitTest.php | 353 +++ tests/Unit/PostThankControllerUnitTest.php | 36 + tests/Unit/ResetUserPasswordTest.php | 20 + tests/Unit/StatsControllerUnitTest.php | 63 + tests/Unit/SystemStatusControllerUnitTest.php | 225 ++ tests/Unit/ThreadControllerBranchesTest.php | 164 ++ tests/Unit/ThreadControllerUnitTest.php | 26 +- tests/Unit/UpdateUserPasswordTest.php | 44 + .../Unit/UpdateUserProfileInformationTest.php | 40 + tests/Unit/VersionBumpCommandTest.php | 122 + tests/Unit/VersionFetchCommandTest.php | 125 ++ tests/Unit/VersionReleaseCommandTest.php | 183 ++ tests/Unit/VersionSetCommandTest.php | 111 + 43 files changed, 6114 insertions(+), 520 deletions(-) create mode 100644 scripts/build_release_assets.sh create mode 100644 tests/Feature/SystemUpdateControllerBranchesTest.php create mode 100644 tests/Unit/AttachmentControllerUnitTest.php create mode 100644 tests/Unit/AttachmentExtensionControllerUnitTest.php create mode 100644 tests/Unit/AttachmentGroupControllerUnitTest.php create mode 100644 tests/Unit/AuditLogControllerUnitTest.php create mode 100644 tests/Unit/BbcodeFormatterTest.php create mode 100644 tests/Unit/ConsoleCommandTest.php create mode 100644 tests/Unit/CronRunCommandTest.php create mode 100644 tests/Unit/ForumControllerUnitTest.php create mode 100644 tests/Unit/InstallerControllerTest.php create mode 100644 tests/Unit/PostControllerUnitTest.php create mode 100644 tests/Unit/PostThankControllerUnitTest.php create mode 100644 tests/Unit/ResetUserPasswordTest.php create mode 100644 tests/Unit/StatsControllerUnitTest.php create mode 100644 tests/Unit/SystemStatusControllerUnitTest.php create mode 100644 tests/Unit/ThreadControllerBranchesTest.php create mode 100644 tests/Unit/UpdateUserPasswordTest.php create mode 100644 tests/Unit/UpdateUserProfileInformationTest.php create mode 100644 tests/Unit/VersionBumpCommandTest.php create mode 100644 tests/Unit/VersionFetchCommandTest.php create mode 100644 tests/Unit/VersionReleaseCommandTest.php create mode 100644 tests/Unit/VersionSetCommandTest.php diff --git a/.gitignore b/.gitignore index 5c2cf59..1e99d24 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ /storage/*.key /storage/pail /storage/framework/views/*.php +/bootstrap/cache/*.php /vendor Homestead.json Homestead.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d2efb..454ae58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026-02-08 +- Achieved 100% test coverage across the backend. +- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands. +- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.). + ## 2026-01-12 - Switched main SPA layouts to fluid containers to reduce wasted space. - Added username-or-email login with case-insensitive unique usernames. diff --git a/NOTES.md b/NOTES.md index 3f4af37..b6d5de2 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1 +1,7 @@ TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers. + +Progress (last 2 days): +- Reached 100% test coverage across the codebase. +- Added extensive Feature and Unit tests for controllers, models, services, and console commands. +- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.). +- Hardened tests with fakes/mocks to cover error paths and edge cases. diff --git a/artisan b/artisan index 95e7cc9..a76bbd7 100644 --- a/artisan +++ b/artisan @@ -1,18 +1,2 @@ #!/usr/bin/env php -handleCommand(input: new ArgvInput); - -exit($status); + 'Termwind\\Laravel\\TermwindServiceProvider', ), ), + 'pestphp/pest-plugin-laravel' => + array ( + 'providers' => + array ( + 0 => 'Pest\\Laravel\\PestServiceProvider', + ), + ), ); \ No newline at end of file diff --git a/bootstrap/cache/services.php b/bootstrap/cache/services.php index d737036..4a3b1e9 100644 --- a/bootstrap/cache/services.php +++ b/bootstrap/cache/services.php @@ -33,8 +33,9 @@ 29 => 'Carbon\\Laravel\\ServiceProvider', 30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', 31 => 'Termwind\\Laravel\\TermwindServiceProvider', - 32 => 'App\\Providers\\AppServiceProvider', - 33 => 'App\\Providers\\FortifyServiceProvider', + 32 => 'Pest\\Laravel\\PestServiceProvider', + 33 => 'App\\Providers\\AppServiceProvider', + 34 => 'App\\Providers\\FortifyServiceProvider', ), 'eager' => array ( @@ -54,8 +55,9 @@ 13 => 'Carbon\\Laravel\\ServiceProvider', 14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', 15 => 'Termwind\\Laravel\\TermwindServiceProvider', - 16 => 'App\\Providers\\AppServiceProvider', - 17 => 'App\\Providers\\FortifyServiceProvider', + 16 => 'Pest\\Laravel\\PestServiceProvider', + 17 => 'App\\Providers\\AppServiceProvider', + 18 => 'App\\Providers\\FortifyServiceProvider', ), 'deferred' => array ( diff --git a/composer.lock b/composer.lock index f9ff8a5..1e623f5 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": "7db69f19f78b34bf8fe28b66c94c5ea5", + "content-hash": "83b577a02e99a4e17696941851d13cc2", "packages": [ { "name": "bacon/bacon-qr-code", @@ -63,16 +63,16 @@ }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.7", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50", + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50", "shasum": "" }, "require": { @@ -111,7 +111,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.7" }, "funding": [ { @@ -119,7 +119,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-02-07T10:57:35+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -1159,28 +1159,28 @@ }, { "name": "laravel/fortify", - "version": "v1.33.0", + "version": "v1.34.1", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9" + "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/e0666dabeec0b6428678af1d51f436dcfb24e3a9", - "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9", + "url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2", + "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "pragmarx/google2fa": "^9.0", - "symfony/console": "^6.0|^7.0" + "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1218,20 +1218,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2025-12-15T14:48:33+00:00" + "time": "2026-02-03T06:55:55+00:00" }, { "name": "laravel/framework", - "version": "v12.48.1", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "0f0974a9769378ccd9c9935c09b9927f3a606830" + "reference": "174ffed91d794a35a541a5eb7c3785a02a34aaba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/0f0974a9769378ccd9c9935c09b9927f3a606830", - "reference": "0f0974a9769378ccd9c9935c09b9927f3a606830", + "url": "https://api.github.com/repos/laravel/framework/zipball/174ffed91d794a35a541a5eb7c3785a02a34aaba", + "reference": "174ffed91d794a35a541a5eb7c3785a02a34aaba", "shasum": "" }, "require": { @@ -1440,34 +1440,34 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-20T16:12:36+00:00" + "time": "2026-02-04T18:34:13+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.10", + "version": "v0.3.12", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3" + "reference": "4861ded9003b7f8a158176a0b7666f74ee761be8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/360ba095ef9f51017473505191fbd4ab73e1cab3", - "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3", + "url": "https://api.github.com/repos/laravel/prompts/zipball/4861ded9003b7f8a158176a0b7666f74ee761be8", + "reference": "4861ded9003b7f8a158176a0b7666f74ee761be8", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -1497,22 +1497,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.10" + "source": "https://github.com/laravel/prompts/tree/v0.3.12" }, - "time": "2026-01-13T20:29:29+00:00" + "time": "2026-02-03T06:57:26+00:00" }, { "name": "laravel/sanctum", - "version": "v4.2.4", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "dadd2277ff0f05cdb435c8b6a0bcedcf3b5519a9" + "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/dadd2277ff0f05cdb435c8b6a0bcedcf3b5519a9", - "reference": "dadd2277ff0f05cdb435c8b6a0bcedcf3b5519a9", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", + "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", "shasum": "" }, "require": { @@ -1562,31 +1562,31 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-15T14:37:16+00:00" + "time": "2026-01-22T22:27:01+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.9", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -1623,7 +1623,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-03T06:55:34+00:00" }, { "name": "laravel/tinker", @@ -2355,16 +2355,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.0", + "version": "3.11.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", "shasum": "" }, "require": { @@ -2388,7 +2388,7 @@ "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.22", "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -2431,14 +2431,14 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", "issues": "https://github.com/CarbonPHP/carbon/issues", "source": "https://github.com/CarbonPHP/carbon" }, @@ -2456,7 +2456,7 @@ "type": "tidelift" } ], - "time": "2025-12-02T21:04:28+00:00" + "time": "2026-01-29T09:26:29+00:00" }, { "name": "nette/schema", @@ -2525,16 +2525,16 @@ }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.2", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", "shasum": "" }, "require": { @@ -2547,7 +2547,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -2608,9 +2608,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.2" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-02-03T17:21:09+00:00" }, { "name": "nikic/php-parser", @@ -3367,16 +3367,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.19", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", + "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", "shasum": "" }, "require": { @@ -3440,9 +3440,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2026-01-30T17:33:13+00:00" }, { "name": "ralouphie/getallheaders", @@ -3884,16 +3884,16 @@ }, { "name": "symfony/console", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", "shasum": "" }, "require": { @@ -3958,7 +3958,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.3" + "source": "https://github.com/symfony/console/tree/v7.4.4" }, "funding": [ { @@ -3978,7 +3978,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/css-selector", @@ -4118,16 +4118,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", "shasum": "" }, "require": { @@ -4176,7 +4176,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.0" + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" }, "funding": [ { @@ -4196,20 +4196,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T14:29:59+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.0", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "573f95783a2ec6e38752979db139f09fec033f03" + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", - "reference": "573f95783a2ec6e38752979db139f09fec033f03", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", "shasum": "" }, "require": { @@ -4261,7 +4261,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" }, "funding": [ { @@ -4281,7 +4281,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-01-05T11:45:55+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4361,16 +4361,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", "shasum": "" }, "require": { @@ -4405,7 +4405,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.3" + "source": "https://github.com/symfony/finder/tree/v7.4.5" }, "funding": [ { @@ -4425,20 +4425,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", "shasum": "" }, "require": { @@ -4487,7 +4487,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" }, "funding": [ { @@ -4507,20 +4507,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:23:49+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "885211d4bed3f857b8c964011923528a55702aa5" + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", - "reference": "885211d4bed3f857b8c964011923528a55702aa5", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", "shasum": "" }, "require": { @@ -4606,7 +4606,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" }, "funding": [ { @@ -4626,20 +4626,20 @@ "type": "tidelift" } ], - "time": "2025-12-31T08:43:57+00:00" + "time": "2026-01-28T10:33:42+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", + "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", "shasum": "" }, "require": { @@ -4690,7 +4690,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.3" + "source": "https://github.com/symfony/mailer/tree/v7.4.4" }, "funding": [ { @@ -4710,20 +4710,20 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2026-01-08T08:25:11+00:00" }, { "name": "symfony/mime", - "version": "v7.4.0", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", "shasum": "" }, "require": { @@ -4734,15 +4734,15 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -4779,7 +4779,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.0" + "source": "https://github.com/symfony/mime/tree/v7.4.5" }, "funding": [ { @@ -4799,7 +4799,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5632,16 +5632,16 @@ }, { "name": "symfony/process", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -5673,7 +5673,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.3" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -5693,20 +5693,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/routing", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", "shasum": "" }, "require": { @@ -5758,7 +5758,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.3" + "source": "https://github.com/symfony/routing/tree/v7.4.4" }, "funding": [ { @@ -5778,7 +5778,7 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-12T12:19:02+00:00" }, { "name": "symfony/service-contracts", @@ -5869,16 +5869,16 @@ }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "758b372d6882506821ed666032e43020c4f57194" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", "shasum": "" }, "require": { @@ -5935,7 +5935,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.4" }, "funding": [ { @@ -5955,20 +5955,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "symfony/translation", - "version": "v8.0.3", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" + "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", "shasum": "" }, "require": { @@ -6028,7 +6028,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.3" + "source": "https://github.com/symfony/translation/tree/v8.0.4" }, "funding": [ { @@ -6048,7 +6048,7 @@ "type": "tidelift" } ], - "time": "2025-12-21T10:59:45+00:00" + "time": "2026-01-13T13:06:50+00:00" }, { "name": "symfony/translation-contracts", @@ -6134,16 +6134,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", "shasum": "" }, "require": { @@ -6188,7 +6188,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.0" + "source": "https://github.com/symfony/uid/tree/v7.4.4" }, "funding": [ { @@ -6208,20 +6208,20 @@ "type": "tidelift" } ], - "time": "2025-09-25T11:02:55+00:00" + "time": "2026-01-03T23:30:35+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92" + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", "shasum": "" }, "require": { @@ -6275,7 +6275,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" }, "funding": [ { @@ -6295,7 +6295,7 @@ "type": "tidelift" } ], - "time": "2025-12-18T07:04:31+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6658,6 +6658,99 @@ }, "time": "2025-07-17T06:07:30+00:00" }, + { + "name": "brianium/paratest", + "version": "v7.17.0", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-file-iterator": "^6", + "phpunit/php-timer": "^8", + "phpunit/phpunit": "^12.5.8", + "sebastian/environment": "^8.0.3", + "symfony/console": "^7.3.4 || ^8.0.0", + "symfony/process": "^7.3.4 || ^8.0.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.3.2 || ^8.0.0" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-02-05T09:14:44+00:00" + }, { "name": "composer/class-map-generator", "version": "1.7.1", @@ -6806,6 +6899,54 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "fakerphp/faker", "version": "v1.24.1", @@ -6869,6 +7010,67 @@ }, "time": "2024-11-21T13:46:39+00:00" }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, { "name": "filp/whoops", "version": "2.18.4", @@ -6992,38 +7194,99 @@ "time": "2025-04-30T06:54:44+00:00" }, { - "name": "laravel/pail", - "version": "v1.2.4", + "name": "jean85/pretty-package-versions", + "version": "2.1.1", "source": { "type": "git", - "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "fdb73f5eacf03db576c710d5a00101ba185f2254" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/fdb73f5eacf03db576c710d5a00101ba185f2254", + "reference": "fdb73f5eacf03db576c710d5a00101ba185f2254", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -7068,7 +7331,7 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-04T15:10:32+00:00" }, { "name": "laravel/pint", @@ -7442,6 +7705,468 @@ ], "time": "2025-11-20T02:55:25+00:00" }, + { + "name": "pestphp/pest", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.16.1", + "nunomaduro/collision": "^8.8.3", + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest-plugin": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-mutate": "^4.0.1", + "pestphp/pest-plugin-profanity": "^4.2.1", + "php": "^8.3.0", + "phpunit/phpunit": "^12.5.8", + "symfony/process": "^7.4.4|^8.0.0" + }, + "conflict": { + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.5.8", + "sebastian/exporter": "<7.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-browser": "^4.2.1", + "pestphp/pest-plugin-type-coverage": "^4.0.3", + "psy/psysh": "^0.12.18" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "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": "The 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/v4.3.2" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-01-28T01:01:19+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.3" + }, + "conflict": { + "pestphp/pest": "<4.0.0" + }, + "require-dev": { + "composer/composer": "^2.8.10", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "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/v4.0.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": "2025-08-20T12:35:58+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "ta-tikoma/phpunit-architecture-test": "^0.8.5" + }, + "require-dev": { + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-20T13:10:51+00:00" + }, + { + "name": "pestphp/pest-plugin-laravel", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.45.2|^12.25.0", + "pestphp/pest": "^4.0.0", + "php": "^8.3.0" + }, + "require-dev": { + "laravel/dusk": "^8.3.3", + "orchestra/testbench": "^9.13.0|^10.5.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Laravel Plugin", + "keywords": [ + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-20T12:46:37+00:00" + }, + { + "name": "pestphp/pest-plugin-mutate", + "version": "v4.0.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.6.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "psr/simple-cache": "^3.0.0" + }, + "require-dev": { + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pest\\Mutate\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", + "keywords": [ + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-21T20:19:25+00:00" + }, + { + "name": "pestphp/pest-plugin-profanity", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-profanity.git", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" + }, + "require-dev": { + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] + } + }, + "autoload": { + "psr-4": { + "Pest\\Profanity\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Profanity Plugin", + "keywords": [ + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" + }, + "time": "2025-12-08T00:13:17+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -7561,17 +8286,239 @@ "time": "2022-02-21T01:04:05+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "11.0.12", + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.6", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + }, + "time": "2025-12-22T21:13:58+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -7579,18 +8526,17 @@ "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.1", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.3.1" + "php": ">=8.3", + "phpunit/php-file-iterator": "^6.0", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.46" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -7599,7 +8545,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -7628,7 +8574,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -7648,32 +8594,32 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:01:01+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -7701,36 +8647,48 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "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/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", - "version": "5.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -7738,7 +8696,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -7765,7 +8723,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -7773,32 +8731,32 @@ "type": "github" } ], - "time": "2024-07-03T05:07:44+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -7825,7 +8783,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -7833,32 +8791,32 @@ "type": "github" } ], - "time": "2024-07-03T05:08:43+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -7885,7 +8843,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -7893,20 +8851,20 @@ "type": "github" } ], - "time": "2024-07-03T05:09:35+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "11.5.49", + "version": "12.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4f1750675ba411dd6c2d5fa8a3cca07f6742020e" + "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4f1750675ba411dd6c2d5fa8a3cca07f6742020e", - "reference": "4f1750675ba411dd6c2d5fa8a3cca07f6742020e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", + "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", "shasum": "" }, "require": { @@ -7919,34 +8877,30 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.3", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.2", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/type": "^5.1.3", - "sebastian/version": "^5.0.2", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.4", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" - }, "bin": [ "phpunit" ], "type": "library", "extra": { "branch-alias": { - "dev-main": "11.5-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -7978,7 +8932,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.49" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" }, "funding": [ { @@ -8002,32 +8956,32 @@ "type": "tidelift" } ], - "time": "2026-01-24T16:09:28+00:00" + "time": "2026-01-27T06:12:29+00:00" }, { "name": "sebastian/cli-parser", - "version": "3.0.2", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -8051,152 +9005,51 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-07-03T04:41:36+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" - }, - "funding": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2025-03-19T07:56:08+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:45:54+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "6.3.3", + "version": "7.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.4" + "phpunit/phpunit": "^12.2" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -8204,7 +9057,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -8244,7 +9097,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" }, "funding": [ { @@ -8264,33 +9117,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:26:40+00:00" + "time": "2026-01-24T09:28:48+00:00" }, { "name": "sebastian/complexity", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8314,7 +9167,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -8322,33 +9175,33 @@ "type": "github" } ], - "time": "2024-07-03T04:49:50+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "6.0.2", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8381,7 +9234,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" }, "funding": [ { @@ -8389,27 +9242,27 @@ "type": "github" } ], - "time": "2024-07-03T04:53:05+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { "name": "sebastian/environment", - "version": "7.2.1", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-posix": "*" @@ -8417,7 +9270,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.2-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -8445,7 +9298,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" }, "funding": [ { @@ -8465,34 +9318,34 @@ "type": "tidelift" } ], - "time": "2025-05-21T11:55:47+00:00" + "time": "2025-08-12T14:11:56+00:00" }, { "name": "sebastian/exporter", - "version": "6.3.2", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8535,7 +9388,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "funding": [ { @@ -8555,35 +9408,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:12:51+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "7.0.2", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -8609,41 +9462,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "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-07-03T04:57:36+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", - "version": "3.0.1", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -8667,7 +9532,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" }, "funding": [ { @@ -8675,34 +9540,34 @@ "type": "github" } ], - "time": "2024-07-03T04:58:38+00:00" + "time": "2025-02-07T04:57:28+00:00" }, { "name": "sebastian/object-enumerator", - "version": "6.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8725,7 +9590,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -8733,32 +9598,32 @@ "type": "github" } ], - "time": "2024-07-03T05:00:13+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8781,7 +9646,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -8789,32 +9654,32 @@ "type": "github" } ], - "time": "2024-07-03T05:01:32+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "6.0.3", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8845,7 +9710,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { @@ -8865,32 +9730,32 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:42:22+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "5.1.3", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -8914,7 +9779,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "funding": [ { @@ -8934,29 +9799,29 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:55:48+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", - "version": "5.0.2", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -8980,7 +9845,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -8988,7 +9853,7 @@ "type": "github" } ], - "time": "2024-10-09T05:16:32+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -9198,24 +10063,83 @@ "time": "2025-12-04T18:11:45+00:00" }, { - "name": "theseer/tokenizer", - "version": "1.3.1", + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.6", "source": { "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + }, + "time": "2026-01-30T07:16:00+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -9237,7 +10161,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.3.1" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -9245,7 +10169,69 @@ "type": "github" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.2" + }, + "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], @@ -9255,6 +10241,7 @@ "prefer-lowest": false, "platform": { "php": "^8.4", + "composer-runtime-api": "^2.2", "ext-pdo": "*" }, "platform-dev": {}, diff --git a/phpunit.xml b/phpunit.xml index d703241..5a85bab 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,13 +18,11 @@ - + - - diff --git a/scripts/build_release_assets.sh b/scripts/build_release_assets.sh new file mode 100644 index 0000000..41df506 --- /dev/null +++ b/scripts/build_release_assets.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT_DIR="${OUTPUT_DIR:-"$ROOT_DIR/dist"}" +ALLOW_DIRTY="${ALLOW_DIRTY:-0}" + +if [[ "$ALLOW_DIRTY" != "1" ]]; then + if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then + echo "Working tree is dirty. Set ALLOW_DIRTY=1 to override." >&2 + exit 1 + fi +fi + +VERSION="$(php -r 'echo json_decode(file_get_contents("composer.json"), true)["version"] ?? "0.0.0";')" +if [[ -z "$VERSION" ]]; then + echo "Could not determine version from composer.json" >&2 + exit 1 +fi + +BUILD_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +exclude_args=( + --exclude ".git" + --exclude "node_modules" + --exclude "vendor" + --exclude "storage" + --exclude "dist" + --exclude "tests" + --exclude ".env" + --exclude ".env.test" + --exclude "public/build" +) + +if command -v rsync >/dev/null 2>&1; then + rsync -a "${exclude_args[@]}" "$ROOT_DIR/" "$BUILD_DIR/" +else + tar -C "$ROOT_DIR" -cf - \ + --exclude=".git" \ + --exclude="node_modules" \ + --exclude="vendor" \ + --exclude="storage" \ + --exclude="dist" \ + --exclude="tests" \ + --exclude=".env" \ + --exclude=".env.test" \ + --exclude="public/build" \ + . | tar -C "$BUILD_DIR" -xf - +fi + +pushd "$BUILD_DIR" >/dev/null +composer install --no-dev --optimize-autoloader +npm install +npm run build +rm -rf node_modules +popd >/dev/null + +mkdir -p "$OUTPUT_DIR" +FULL_TAR="$OUTPUT_DIR/speedbb-full-v${VERSION}.tar.gz" +SRC_TAR="$OUTPUT_DIR/speedbb-src-v${VERSION}.tar.gz" + +tar -C "$BUILD_DIR" -czf "$FULL_TAR" --exclude="tests" . +tar -C "$BUILD_DIR" -czf "$SRC_TAR" --exclude="vendor" --exclude="public/build" --exclude="tests" . + +echo "Built:" +echo " $FULL_TAR" +echo " $SRC_TAR" diff --git a/tests/Feature/AuthControllerTest.php b/tests/Feature/AuthControllerTest.php index 4df86ae..96c31a3 100644 --- a/tests/Feature/AuthControllerTest.php +++ b/tests/Feature/AuthControllerTest.php @@ -94,6 +94,19 @@ it('sends a reset link for valid email', function (): void { $response->assertJsonStructure(['message']); }); +it('returns validation error when reset link cannot be sent', function (): void { + Password::shouldReceive('sendResetLink') + ->once() + ->andReturn(Password::INVALID_USER); + + $response = $this->postJson('/api/forgot-password', [ + 'email' => 'missing@example.com', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['email']); +}); + it('resets a password with a valid token', function (): void { $user = User::factory()->create([ 'email' => 'reset2@example.com', @@ -116,6 +129,22 @@ it('resets a password with a valid token', function (): void { expect(Hash::check('NewPassword123!', $user->password))->toBeTrue(); }); +it('returns validation error when reset fails', function (): void { + Password::shouldReceive('reset') + ->once() + ->andReturn(Password::INVALID_TOKEN); + + $response = $this->postJson('/api/reset-password', [ + 'email' => 'resetfail@example.com', + 'password' => 'NewPassword123!', + 'password_confirmation' => 'NewPassword123!', + 'token' => 'bad-token', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['email']); +}); + it('verifies email and redirects to login', function (): void { $user = User::factory()->unverified()->create(); @@ -133,6 +162,19 @@ it('verifies email and redirects to login', function (): void { expect($user->hasVerifiedEmail())->toBeTrue(); }); +it('rejects invalid email verification hash', function (): void { + $user = User::factory()->unverified()->create(); + + $url = URL::signedRoute('verification.verify', [ + 'id' => $user->id, + 'hash' => sha1('wrong'), + ]); + + $response = $this->get($url); + + $response->assertStatus(403); +}); + it('updates password for authenticated users', function (): void { $user = User::factory()->create([ 'password' => Hash::make('OldPass123!'), diff --git a/tests/Feature/ForumControllerTest.php b/tests/Feature/ForumControllerTest.php index fe8d2fc..8009dbf 100644 --- a/tests/Feature/ForumControllerTest.php +++ b/tests/Feature/ForumControllerTest.php @@ -1,6 +1,9 @@ assertJsonFragment(['id' => $forum->id]); }); +it('filters forums by parent id and type', function (): void { + $category = Forum::create([ + 'name' => 'Category 2', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $forum = Forum::create([ + 'name' => 'Forum B', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->getJson("/api/forums?parent=/api/forums/{$category->id}"); + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment(['id' => $forum->id]); + + $response = $this->getJson('/api/forums?type=category'); + $response->assertOk(); + $response->assertJsonFragment(['id' => $category->id]); +}); + +it('shows forum with last post data', function (): void { + $role = Role::create(['name' => 'ROLE_MEMBER', 'color' => '#00ff00']); + $user = User::factory()->create(); + $user->roles()->attach($role); + $user->load('roles'); + + $category = Forum::create([ + 'name' => 'Category 3', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum C', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'Reply', + ]); + + $response = $this->getJson("/api/forums/{$forum->id}"); + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $forum->id, + 'last_post_user_id' => $user->id, + ]); + $payload = $response->getData(true); + expect($payload['last_post_user_group_color'])->toBe('#00ff00'); +}); + +it('creates category and shifts positions', function (): void { + Sanctum::actingAs(User::factory()->create()); + + Forum::create([ + 'name' => 'Category A', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $response = $this->postJson('/api/forums', [ + 'name' => 'Category B', + 'type' => 'category', + 'description' => 'Desc', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('forums', [ + 'name' => 'Category A', + 'position' => 2, + ]); +}); + +it('updates forum parent and description', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $categoryA = Forum::create([ + 'name' => 'Category A', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $categoryB = Forum::create([ + 'name' => 'Category B', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 2, + ]); + $forum = Forum::create([ + 'name' => 'Forum D', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $categoryA->id, + 'position' => 1, + ]); + + $response = $this->patchJson("/api/forums/{$forum->id}", [ + 'parent' => "/api/forums/{$categoryB->id}", + 'description' => 'Updated', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('forums', [ + 'id' => $forum->id, + 'parent_id' => $categoryB->id, + 'description' => 'Updated', + ]); +}); + +it('updates forum name and type', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $category = Forum::create([ + 'name' => 'Category H', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum H', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->patchJson("/api/forums/{$forum->id}", [ + 'name' => 'Forum H Updated', + 'type' => 'forum', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('forums', [ + 'id' => $forum->id, + 'name' => 'Forum H Updated', + ]); +}); + +it('rejects forum update without category parent', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $category = Forum::create([ + 'name' => 'Category Z', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum E', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->patchJson("/api/forums/{$forum->id}", [ + 'parent' => null, + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Forums must belong to a category.']); +}); + +it('rejects forum update with non-category parent', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $category = Forum::create([ + 'name' => 'Category X', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $parent = Forum::create([ + 'name' => 'Not Category', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum G', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->patchJson("/api/forums/{$forum->id}", [ + 'parent' => "/api/forums/{$parent->id}", + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Parent must be a category.']); +}); + +it('destroys forum and sets deleted_by', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $forum = Forum::create([ + 'name' => 'Forum F', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $response = $this->deleteJson("/api/forums/{$forum->id}"); + $response->assertStatus(204); + + $forum->refresh(); + expect($forum->deleted_by)->toBe($user->id); +}); + +it('reorders with string parent id', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $parent = Forum::create([ + 'name' => 'Cat Parent', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $first = Forum::create([ + 'name' => 'Forum 1', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $parent->id, + 'position' => 1, + ]); + $second = Forum::create([ + 'name' => 'Forum 2', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $parent->id, + 'position' => 2, + ]); + + $response = $this->postJson('/api/forums/reorder', [ + 'parentId' => (string) $parent->id, + 'orderedIds' => [$second->id, $first->id], + ]); + + $response->assertOk(); + $this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]); +}); + +it('reorders with empty parent id string', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $first = Forum::create([ + 'name' => 'Cat X', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $second = Forum::create([ + 'name' => 'Cat Y', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 2, + ]); + + $response = $this->postJson('/api/forums/reorder', [ + 'parentId' => '', + 'orderedIds' => [$second->id, $first->id], + ]); + + $response->assertOk(); + $this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]); +}); + +it('reorders with parent id null string', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $first = Forum::create([ + 'name' => 'Cat N1', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $second = Forum::create([ + 'name' => 'Cat N2', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 2, + ]); + + $response = $this->postJson('/api/forums/reorder', [ + 'parentId' => 'null', + 'orderedIds' => [$second->id, $first->id], + ]); + + $response->assertOk(); + $this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]); +}); + +it('creates forum under category and increments position', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $category = Forum::create([ + 'name' => 'Category P', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + Forum::create([ + 'name' => 'Forum P1', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->postJson('/api/forums', [ + 'name' => 'Forum P2', + 'type' => 'forum', + 'parent' => "/api/forums/{$category->id}", + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('forums', [ + 'name' => 'Forum P2', + 'position' => 2, + ]); +}); + it('rejects forum without category parent', function (): void { Sanctum::actingAs(User::factory()->create()); diff --git a/tests/Feature/PortalControllerTest.php b/tests/Feature/PortalControllerTest.php index bda2d16..9129f5b 100644 --- a/tests/Feature/PortalControllerTest.php +++ b/tests/Feature/PortalControllerTest.php @@ -2,6 +2,8 @@ use App\Models\Forum; use App\Models\Post; +use App\Models\Rank; +use App\Models\Role; use App\Models\Thread; use App\Models\User; use Laravel\Sanctum\Sanctum; @@ -46,3 +48,88 @@ it('returns portal summary payload', function (): void { $response->assertJsonFragment(['name' => 'Forum']); $response->assertJsonFragment(['title' => 'Thread']); }); + +it('includes avatar and rank data in portal threads', function (): void { + $rank = Rank::create([ + 'name' => 'Gold', + 'badge_type' => 'image', + 'badge_image_path' => 'ranks/gold.png', + ]); + $role = Role::create(['name' => 'ROLE_SPECIAL', 'color' => '#ff0000']); + $user = User::factory()->create([ + 'avatar_path' => 'avatars/u.png', + 'rank_id' => $rank->id, + ]); + $user->roles()->attach($role); + + $category = Forum::create([ + 'name' => 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $forum = Forum::create([ + 'name' => 'Forum', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/portal/summary'); + + $response->assertOk(); + $payload = $response->getData(true); + expect($payload['threads'][0]['user_avatar_url'])->not->toBeNull(); + expect($payload['threads'][0]['user_rank_badge_url'])->not->toBeNull(); + expect($payload['threads'][0]['user_group_color'])->toBe('#ff0000'); +}); + +it('handles empty forum last posts and resolveGroupColor', function (): void { + $user = User::factory()->create(); + $user->setRelation('roles', null); + + $category = Forum::create([ + 'name' => 'Category2', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $forum = Forum::create([ + 'name' => 'Forum2', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/portal/summary'); + + $response->assertOk(); + $payload = $response->getData(true); + expect($payload['forums'][0]['last_post_user_group_color'])->toBeNull(); +}); + +it('handles summary when no forums exist', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/portal/summary'); + + $response->assertOk(); + $payload = $response->getData(true); + expect($payload['forums'])->toBe([]); +}); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 3b23f6f..4aee5c1 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -8,6 +8,30 @@ use App\Models\Thread; use App\Models\User; use Laravel\Sanctum\Sanctum; +beforeEach(function (): void { + $parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); +}); + +afterEach(function (): void { + \Mockery::close(); +}); + function makeThread(): Thread { $category = Forum::create([ diff --git a/tests/Feature/PostThankControllerTest.php b/tests/Feature/PostThankControllerTest.php index 15a7b7f..c394a74 100644 --- a/tests/Feature/PostThankControllerTest.php +++ b/tests/Feature/PostThankControllerTest.php @@ -90,3 +90,73 @@ it('lists thanks received for a user', function (): void { 'thanker_name' => 'ThanksGiver', ]); }); + +it('requires auth to thank and unthank posts', function (): void { + $thread = makeThanksThread(); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Post', + ]); + + $this->app['auth']->forgetGuards(); + $response = $this->postJson("/api/posts/{$post->id}/thanks"); + $response->assertStatus(401); + + $this->app['auth']->forgetGuards(); + $response = $this->deleteJson("/api/posts/{$post->id}/thanks"); + $response->assertStatus(401); +}); + +it('creates and deletes thanks for a post', function (): void { + $thread = makeThanksThread(); + $user = User::factory()->create(); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'Post', + ]); + + Sanctum::actingAs($user); + $response = $this->postJson("/api/posts/{$post->id}/thanks"); + $response->assertStatus(201); + + $response = $this->deleteJson("/api/posts/{$post->id}/thanks"); + $response->assertStatus(204); +}); + +it('serializes group colors for thanks', function (): void { + $thread = makeThanksThread(); + $authorRole = \App\Models\Role::create(['name' => 'ROLE_AUTHOR', 'color' => '#ff0000']); + $thankerRole = \App\Models\Role::create(['name' => 'ROLE_THANKER', 'color' => '#00ff00']); + + $author = User::factory()->create(['name' => 'Author']); + $author->roles()->attach($authorRole); + $author->load('roles'); + $thanker = User::factory()->create(['name' => 'ThanksGiver']); + $thanker->roles()->attach($thankerRole); + $thanker->load('roles'); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $author->id, + 'body' => 'Helpful post', + ]); + + PostThank::create([ + 'post_id' => $post->id, + 'user_id' => $thanker->id, + ]); + + Sanctum::actingAs($thanker); + $response = $this->getJson("/api/user/{$thanker->id}/thanks/given"); + $response->assertOk(); + $payload = $response->getData(true); + expect($payload[0]['post_author_group_color'])->toBe('#ff0000'); + + Sanctum::actingAs($author); + $response = $this->getJson("/api/user/{$author->id}/thanks/received"); + $response->assertOk(); + $payload = $response->getData(true); + expect($payload[0]['thanker_group_color'])->toBe('#00ff00'); +}); diff --git a/tests/Feature/PreviewControllerTest.php b/tests/Feature/PreviewControllerTest.php index aa972e2..816f61f 100644 --- a/tests/Feature/PreviewControllerTest.php +++ b/tests/Feature/PreviewControllerTest.php @@ -1,6 +1,24 @@ setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); + $user = \App\Models\User::factory()->create(); \Laravel\Sanctum\Sanctum::actingAs($user); @@ -13,6 +31,24 @@ it('renders bbcode preview', function (): void { }); it('validates preview body', function (): void { + $parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); + $user = \App\Models\User::factory()->create(); \Laravel\Sanctum\Sanctum::actingAs($user); @@ -21,3 +57,7 @@ it('validates preview body', function (): void { $response->assertStatus(422); $response->assertJsonValidationErrors(['body']); }); + +afterEach(function (): void { + \Mockery::close(); +}); diff --git a/tests/Feature/RankControllerTest.php b/tests/Feature/RankControllerTest.php index f6b35ba..cdae228 100644 --- a/tests/Feature/RankControllerTest.php +++ b/tests/Feature/RankControllerTest.php @@ -27,6 +27,31 @@ it('lists ranks for authenticated users', function (): void { $response->assertJsonFragment(['name' => 'Bronze']); }); +it('forbids non-admin rank changes', function (): void { + $user = User::factory()->create(); + $rank = Rank::create(['name' => 'Nope']); + + Sanctum::actingAs($user); + + $response = $this->postJson('/api/ranks', [ + 'name' => 'Silver', + ]); + $response->assertStatus(403); + + $response = $this->patchJson("/api/ranks/{$rank->id}", [ + 'name' => 'Nope', + ]); + $response->assertStatus(403); + + $response = $this->deleteJson("/api/ranks/{$rank->id}"); + $response->assertStatus(403); + + $response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [ + 'file' => UploadedFile::fake()->image('badge.png', 50, 50), + ]); + $response->assertStatus(403); +}); + it('creates ranks as admin', function (): void { $admin = makeAdminForRanks(); Sanctum::actingAs($admin); @@ -45,6 +70,22 @@ it('creates ranks as admin', function (): void { ]); }); +it('creates ranks with none badge type', function (): void { + $admin = makeAdminForRanks(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/ranks', [ + 'name' => 'NoBadge', + 'badge_type' => 'none', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment([ + 'name' => 'NoBadge', + 'badge_text' => null, + ]); +}); + it('updates ranks and clears badge images when switching to text', function (): void { Storage::fake('public'); @@ -71,6 +112,47 @@ it('updates ranks and clears badge images when switching to text', function (): Storage::disk('public')->assertMissing('rank-badges/old.png'); }); +it('updates ranks with badge_type none', function (): void { + $admin = makeAdminForRanks(); + $rank = Rank::create([ + 'name' => 'Plain', + 'badge_type' => 'text', + 'badge_text' => 'P', + ]); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/ranks/{$rank->id}", [ + 'name' => 'Plain', + 'badge_type' => 'none', + ]); + + $response->assertOk(); + $response->assertJsonFragment(['badge_text' => null]); +}); + +it('updates ranks to image badge and keeps existing image', function (): void { + Storage::fake('public'); + + $admin = makeAdminForRanks(); + $rank = Rank::create([ + 'name' => 'ImageRank', + 'badge_type' => 'image', + 'badge_text' => null, + 'badge_image_path' => 'rank-badges/existing.png', + ]); + + Storage::disk('public')->put('rank-badges/existing.png', 'existing'); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/ranks/{$rank->id}", [ + 'name' => 'ImageRank', + 'badge_type' => 'image', + ]); + + $response->assertOk(); + Storage::disk('public')->assertExists('rank-badges/existing.png'); +}); + it('uploads a rank badge image', function (): void { Storage::fake('public'); @@ -86,6 +168,48 @@ it('uploads a rank badge image', function (): void { $response->assertJsonFragment(['badge_type' => 'image']); }); +it('includes badge image url in rank list when present', function (): void { + Storage::fake('public'); + Storage::disk('public')->put('rank-badges/show.png', 'img'); + + $user = User::factory()->create(); + Rank::create([ + 'name' => 'WithImage', + 'badge_type' => 'image', + 'badge_image_path' => 'rank-badges/show.png', + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/ranks'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'name' => 'WithImage', + ]); + expect($response->getData(true)[0]['badge_image_url'])->not->toBeNull(); +}); + +it('uploads badge image replaces existing one', function (): void { + Storage::fake('public'); + + $admin = makeAdminForRanks(); + $rank = Rank::create([ + 'name' => 'Replace', + 'badge_type' => 'image', + 'badge_image_path' => 'rank-badges/old.png', + ]); + + Storage::disk('public')->put('rank-badges/old.png', 'old'); + + Sanctum::actingAs($admin); + $response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [ + 'file' => UploadedFile::fake()->image('badge.png', 50, 50), + ]); + + $response->assertOk(); + Storage::disk('public')->assertMissing('rank-badges/old.png'); +}); + it('deletes ranks as admin', function (): void { Storage::fake('public'); diff --git a/tests/Feature/RoleControllerTest.php b/tests/Feature/RoleControllerTest.php index 90696dc..a8cc08b 100644 --- a/tests/Feature/RoleControllerTest.php +++ b/tests/Feature/RoleControllerTest.php @@ -42,6 +42,17 @@ it('creates normalized roles as admin', function (): void { ]); }); +it('lists roles for admins', function (): void { + $admin = makeAdminForRoles(); + Role::create(['name' => 'ROLE_ALPHA', 'color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/roles'); + + $response->assertOk(); + $response->assertJsonFragment(['name' => 'ROLE_ALPHA']); +}); + it('prevents renaming core roles', function (): void { $admin = makeAdminForRoles(); $core = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); @@ -56,6 +67,49 @@ it('prevents renaming core roles', function (): void { $response->assertJsonFragment(['message' => 'Core roles cannot be renamed.']); }); +it('prevents creating duplicate roles after normalization', function (): void { + $admin = makeAdminForRoles(); + Role::create(['name' => 'ROLE_TEST', 'color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->postJson('/api/roles', [ + 'name' => 'test', + 'color' => '#222222', + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Role already exists.']); +}); + +it('updates role color when provided and keeps name', function (): void { + $admin = makeAdminForRoles(); + $role = Role::create(['name' => 'ROLE_EDIT', 'color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/roles/{$role->id}", [ + 'name' => 'ROLE_EDIT', + 'color' => '#222222', + ]); + + $response->assertOk(); + $response->assertJsonFragment(['color' => '#222222']); +}); + +it('prevents updating to duplicate normalized name', function (): void { + $admin = makeAdminForRoles(); + $first = Role::create(['name' => 'ROLE_FIRST', 'color' => '#111111']); + $second = Role::create(['name' => 'ROLE_SECOND', 'color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/roles/{$second->id}", [ + 'name' => 'first', + 'color' => '#111111', + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Role already exists.']); +}); + it('prevents deleting core roles', function (): void { $admin = makeAdminForRoles(); $core = Role::firstOrCreate(['name' => 'ROLE_USER'], ['color' => '#111111']); @@ -90,3 +144,37 @@ it('deletes non-core roles without assignments', function (): void { $response->assertStatus(204); $this->assertDatabaseMissing('roles', ['id' => $role->id]); }); + +it('forbids non-admin create update delete', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/roles', [ + 'name' => 'helper', + 'color' => '#111111', + ]); + $response->assertStatus(403); + + $role = Role::create(['name' => 'ROLE_TEMP', 'color' => '#111111']); + $response = $this->patchJson("/api/roles/{$role->id}", [ + 'name' => 'ROLE_TEMP', + 'color' => '#222222', + ]); + $response->assertStatus(403); + + $response = $this->deleteJson("/api/roles/{$role->id}"); + $response->assertStatus(403); +}); + +it('normalizes invalid role names to ROLE_', function (): void { + $admin = makeAdminForRoles(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/roles', [ + 'name' => '!!!', + 'color' => '#111111', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'ROLE_']); +}); diff --git a/tests/Feature/SettingControllerTest.php b/tests/Feature/SettingControllerTest.php index cee0450..dbf1e8b 100644 --- a/tests/Feature/SettingControllerTest.php +++ b/tests/Feature/SettingControllerTest.php @@ -87,3 +87,16 @@ it('bulk stores settings as admin', function (): void { 'value' => 'Fast', ]); }); + +it('bulk store forbids non-admin users', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/settings/bulk', [ + 'settings' => [ + ['key' => 'site.name', 'value' => 'SpeedBB'], + ], + ]); + + $response->assertStatus(403); +}); diff --git a/tests/Feature/SystemUpdateControllerBranchesTest.php b/tests/Feature/SystemUpdateControllerBranchesTest.php new file mode 100644 index 0000000..69dbadb --- /dev/null +++ b/tests/Feature/SystemUpdateControllerBranchesTest.php @@ -0,0 +1,462 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +function withFakeBin(array $scripts, callable $callback): void +{ + $dir = storage_path('app/test-bin-' . Str::random(6)); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + foreach ($scripts as $name => $body) { + $path = $dir . DIRECTORY_SEPARATOR . $name; + file_put_contents($path, $body); + chmod($path, 0755); + } + + $originalPath = getenv('PATH') ?: ''; + putenv("PATH={$dir}"); + $_ENV['PATH'] = $dir; + $_SERVER['PATH'] = $dir; + + try { + $callback(); + } finally { + putenv("PATH={$originalPath}"); + $_ENV['PATH'] = $originalPath; + $_SERVER['PATH'] = $originalPath; + if (is_dir($dir)) { + $items = scandir($dir); + if (is_array($items)) { + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + if (is_file($path)) { + unlink($path); + } + } + } + rmdir($dir); + } + } +} + +it('uses token auth header and tarball template', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + putenv('GITEA_TGZ_URL_TEMPLATE=https://git.example.test/tarball/{{TAG}}-{{VERSION}}.tgz'); + putenv('GITEA_TOKEN=secrettoken'); + + $tarballUrl = 'https://git.example.test/tarball/v1.2.3-1.2.3.tgz'; + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => '', + ], 200), + $tarballUrl => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + File::shouldReceive('copyDirectory')->andReturnTrue(); + + $artisanPath = base_path('artisan'); + $originalArtisan = file_get_contents($artisanPath); + file_put_contents($artisanPath, "#!/usr/bin/env php\n "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function () use ($artisanPath, $originalArtisan): void { + try { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertOk(); + $response->assertJsonFragment(['tag' => 'v1.2.3']); + } finally { + file_put_contents($artisanPath, $originalArtisan); + } + }); + + Http::assertSent(function ($request) use ($tarballUrl) { + if ($request->url() === $tarballUrl) { + return true; + } + return $request->hasHeader('Authorization', 'token secrettoken'); + }); +}); + +it('returns update failed on unexpected exception', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake(function () { + throw new RuntimeException('boom'); + }); + + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Update failed.']); +}); + +it('handles release check failures', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([], 500), + ]); + + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Release check failed: 500']); +}); + +it('handles missing tag in release response', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => '', + ], 200), + ]); + + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Release tag not found.']); +}); + +it('handles missing tarball url', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + putenv('GITEA_TGZ_URL_TEMPLATE='); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => '', + ], 200), + ]); + + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'No tarball URL available.']); +}); + +it('handles tarball download failure', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('fail', 500), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Download failed: 500']); +}); + +it('handles extract failure', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + + withFakeBin([ + 'tar' => "#!/bin/sh\nexit 1\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function (): void { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Failed to extract archive.']); + }); +}); + +it('handles missing extracted folder', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn([]); + + withFakeBin([ + 'tar' => "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function (): void { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'No extracted folder found.']); + }); +}); + +it('handles rsync failure when available', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + + withFakeBin([ + 'tar' => "#!/bin/sh\nexit 0\n", + 'rsync' => "#!/bin/sh\nexit 1\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function (): void { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'rsync failed.']); + }); +}); + +it('handles composer install failure after copyDirectory', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + File::shouldReceive('copyDirectory')->andReturnTrue(); + + withFakeBin([ + 'tar' => "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 1\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function (): void { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Composer install failed.']); + }); +}); + +it('handles npm install failure', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + File::shouldReceive('copyDirectory')->andReturnTrue(); + + withFakeBin([ + 'tar' => "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nif [ \"$1\" = \"install\" ]; then exit 1; fi\nexit 0\n", + ], function (): void { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'npm install failed.']); + }); +}); + +it('handles npm build failure', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + File::shouldReceive('copyDirectory')->andReturnTrue(); + + withFakeBin([ + 'tar' => "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nif [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then exit 1; fi\nexit 0\n", + ], function (): void { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'npm run build failed.']); + }); +}); + +it('handles migration failure', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + File::shouldReceive('copyDirectory')->andReturnTrue(); + + $artisanPath = base_path('artisan'); + $originalArtisan = file_get_contents($artisanPath); + file_put_contents($artisanPath, "#!/usr/bin/env php\n "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function () use ($artisanPath, $originalArtisan): void { + try { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(500); + $response->assertJsonFragment(['message' => 'Migrations failed.']); + } finally { + file_put_contents($artisanPath, $originalArtisan); + } + }); +}); + +it('handles fallback copyDirectory update success', function (): void { + putenv('GITEA_OWNER=acme'); + putenv('GITEA_REPO=speedbb'); + putenv('GITEA_API_BASE=https://git.example.test/api/v1'); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + 'tarball_url' => 'https://git.example.test/archive.tgz', + ], 200), + 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), + ]); + + File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); + File::shouldReceive('put')->andReturnTrue(); + File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); + File::shouldReceive('copyDirectory')->andReturnTrue(); + + $artisanPath = base_path('artisan'); + $originalArtisan = file_get_contents($artisanPath); + file_put_contents($artisanPath, "#!/usr/bin/env php\n "#!/bin/sh\nexit 0\n", + 'composer' => "#!/bin/sh\nexit 0\n", + 'npm' => "#!/bin/sh\nexit 0\n", + ], function () use ($artisanPath, $originalArtisan): void { + try { + Sanctum::actingAs(makeAdminForSystemUpdate()); + $response = $this->postJson('/api/system/update'); + + $response->assertOk(); + $response->assertJsonFragment(['message' => 'Update finished.']); + $response->assertJsonStructure(['used_rsync']); + } finally { + file_put_contents($artisanPath, $originalArtisan); + } + }); +}); diff --git a/tests/Feature/SystemUpdateControllerTest.php b/tests/Feature/SystemUpdateControllerTest.php index a5126fb..e092208 100644 --- a/tests/Feature/SystemUpdateControllerTest.php +++ b/tests/Feature/SystemUpdateControllerTest.php @@ -2,6 +2,8 @@ use App\Models\Role; use App\Models\User; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; use Laravel\Sanctum\Sanctum; it('forbids system update for non-admins', function (): void { diff --git a/tests/Feature/ThreadControllerTest.php b/tests/Feature/ThreadControllerTest.php index 346aa24..5573a98 100644 --- a/tests/Feature/ThreadControllerTest.php +++ b/tests/Feature/ThreadControllerTest.php @@ -6,6 +6,30 @@ use App\Models\Thread; use App\Models\User; use Laravel\Sanctum\Sanctum; +beforeEach(function (): void { + $parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); +}); + +afterEach(function (): void { + \Mockery::close(); +}); + function makeForum(): Forum { $category = Forum::create([ @@ -80,7 +104,7 @@ it('requires authentication to update a thread', function (): void { 'forum_id' => $forum->id, 'user_id' => $owner->id, 'title' => 'Original', - 'body' => 'Body', + 'body' => '', ]); $response = $this->patchJson("/api/threads/{$thread->id}", [ @@ -100,7 +124,7 @@ it('enforces thread update permissions', function (): void { 'forum_id' => $forum->id, 'user_id' => $owner->id, 'title' => 'Original', - 'body' => 'Body', + 'body' => '', ]); Sanctum::actingAs($other); @@ -151,7 +175,7 @@ it('enforces solved status permissions', function (): void { 'forum_id' => $forum->id, 'user_id' => $owner->id, 'title' => 'Original', - 'body' => 'Body', + 'body' => '', 'solved' => false, ]); @@ -182,14 +206,14 @@ it('filters threads by forum', function (): void { 'forum_id' => $forumA->id, 'user_id' => null, 'title' => 'Thread A', - 'body' => 'Body A', + 'body' => '', ]); Thread::create([ 'forum_id' => $forumB->id, 'user_id' => null, 'title' => 'Thread B', - 'body' => 'Body B', + 'body' => '', ]); $response = $this->getJson("/api/threads?forum=/api/forums/{$forumA->id}"); @@ -208,7 +232,7 @@ it('increments views count when showing a thread', function (): void { 'forum_id' => $forum->id, 'user_id' => null, 'title' => 'Viewed Thread', - 'body' => 'Body', + 'body' => '', 'views_count' => 0, ]); diff --git a/tests/Unit/AttachmentControllerUnitTest.php b/tests/Unit/AttachmentControllerUnitTest.php new file mode 100644 index 0000000..e97a838 --- /dev/null +++ b/tests/Unit/AttachmentControllerUnitTest.php @@ -0,0 +1,555 @@ + 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + return Forum::create([ + 'name' => 'Forum', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); +} + +it('store returns unauthorized without user', function (): void { + $controller = new AttachmentController(); + $request = Request::create('/api/attachments', 'POST'); + $request->setUserResolver(fn () => null); + + $response = $controller->store($request); + + expect($response->getStatusCode())->toBe(401); +}); + +it('store returns file missing when file not provided', function (): void { + $controller = new AttachmentController(); + $user = User::factory()->create(); + + $request = Request::create('/api/attachments', 'POST', [ + 'thread' => '/api/threads/1', + ]); + $request->setUserResolver(fn () => $user); + + try { + $controller->store($request); + $this->fail('Expected ValidationException not thrown.'); + } catch (Illuminate\Validation\ValidationException $e) { + expect($e->errors())->toHaveKey('file'); + } +}); + +it('store returns file missing when request file is null after validation', function (): void { + $controller = new AttachmentController(); + $user = User::factory()->create(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + $request = Mockery::mock(Request::class)->makePartial(); + $request->shouldReceive('user')->andReturn($user); + $request->shouldReceive('validate')->andReturn([ + 'thread' => "/api/threads/{$thread->id}", + 'post' => null, + 'file' => 'ignored', + ]); + $request->shouldReceive('file')->andReturn(null); + + $response = $controller->store($request); + + expect($response->getStatusCode())->toBe(422); +}); + +it('store rejects disallowed extension and mime and size', function (): void { + Storage::fake('local'); + + $controller = new AttachmentController(); + $user = User::factory()->create(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + $group = AttachmentGroup::create([ + 'name' => 'Docs', + 'max_size_kb' => 1, + 'is_active' => false, + ]); + + $ext = AttachmentExtension::create([ + 'extension' => 'pdf', + 'attachment_group_id' => $group->id, + 'allowed_mimes' => ['application/pdf'], + ]); + + $file = UploadedFile::fake()->create('doc.pdf', 2, 'application/pdf'); + + $request = Request::create('/api/attachments', 'POST', [ + 'thread' => "/api/threads/{$thread->id}", + ], [], ['file' => $file]); + $request->setUserResolver(fn () => $user); + + $response = $controller->store($request); + + expect($response->getStatusCode())->toBe(422); + + $group->is_active = true; + $group->save(); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(422); + + $group->max_size_kb = 1000; + $group->save(); + + $ext->allowed_mimes = ['image/png']; + $ext->save(); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(422); + + $ext->allowed_mimes = ['application/pdf']; + $ext->save(); + + $group->max_size_kb = 0; + $group->save(); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(422); +}); + +it('store returns forbidden when user cannot attach to post', function (): void { + Storage::fake('local'); + + $controller = new AttachmentController(); + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $owner->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $owner->id, + 'body' => 'Post', + ]); + + $group = AttachmentGroup::create([ + 'name' => 'Images', + 'max_size_kb' => 100, + 'is_active' => true, + ]); + + AttachmentExtension::create([ + 'extension' => 'png', + 'attachment_group_id' => $group->id, + 'allowed_mimes' => ['image/png'], + ]); + + $file = UploadedFile::fake()->image('photo.png'); + + $request = Request::create('/api/attachments', 'POST', [ + 'post' => "/api/posts/{$post->id}", + ], [], ['file' => $file]); + $request->setUserResolver(fn () => $viewer); + + $response = $controller->store($request); + + expect($response->getStatusCode())->toBe(403); +}); + +it('index filters by post id', function (): void { + $controller = new AttachmentController(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $postA = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Post A', + ]); + $postB = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Post B', + ]); + + Attachment::create([ + 'thread_id' => null, + 'post_id' => $postA->id, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/posts/'.$postA->id.'/a.txt', + 'original_name' => 'a.txt', + 'extension' => 'txt', + 'mime_type' => 'text/plain', + 'size_bytes' => 1, + ]); + Attachment::create([ + 'thread_id' => null, + 'post_id' => $postB->id, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/posts/'.$postB->id.'/b.txt', + 'original_name' => 'b.txt', + 'extension' => 'txt', + 'mime_type' => 'text/plain', + 'size_bytes' => 1, + ]); + + $request = Request::create('/api/attachments', 'GET', [ + 'post' => "/api/posts/{$postA->id}", + ]); + + $response = $controller->index($request); + + expect($response->getStatusCode())->toBe(200); + $payload = $response->getData(true); + expect(count($payload))->toBe(1); + expect($payload[0]['post_id'])->toBe($postA->id); +}); + +it('show returns not found when attachment is not viewable', function (): void { + $controller = new AttachmentController(); + $attachment = new Attachment([ + 'disk' => 'local', + 'path' => 'missing', + ]); + $attachment->setRawAttributes(['id' => 1, 'deleted_at' => now()]); + + $response = $controller->show($attachment); + + expect($response->getStatusCode())->toBe(404); +}); + +it('show returns attachment when viewable', function (): void { + $controller = new AttachmentController(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $attachment = Attachment::create([ + 'thread_id' => $thread->id, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', + 'original_name' => 'file.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + $response = $controller->show($attachment); + + expect($response->getStatusCode())->toBe(200); +}); + +it('download aborts when file missing or not viewable', function (): void { + Storage::fake('local'); + + $controller = new AttachmentController(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $attachment = Attachment::create([ + 'thread_id' => $thread->id, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/'.$thread->id.'/missing.pdf', + 'original_name' => 'missing.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + expect(fn () => $controller->download($attachment))->toThrow(HttpException::class); +}); + +it('download aborts when attachment is not viewable', function (): void { + Storage::fake('local'); + + $controller = new AttachmentController(); + $attachment = Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/1/missing.pdf', + 'original_name' => 'missing.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + expect(fn () => $controller->download($attachment))->toThrow(HttpException::class); +}); + +it('thumbnail aborts when missing path or file', function (): void { + Storage::fake('local'); + + $controller = new AttachmentController(); + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $attachment = Attachment::create([ + 'thread_id' => $thread->id, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', + 'original_name' => 'file.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class); + + $attachment->thumbnail_path = 'attachments/threads/'.$thread->id.'/thumb.jpg'; + $attachment->save(); + + expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class); +}); + +it('thumbnail aborts when attachment is not viewable', function (): void { + Storage::fake('local'); + + $controller = new AttachmentController(); + $attachment = Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/1/file.pdf', + 'thumbnail_path' => 'attachments/threads/1/thumb.jpg', + 'original_name' => 'file.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class); +}); + +it('destroy returns unauthorized or forbidden', function (): void { + $controller = new AttachmentController(); + $attachment = new Attachment(['user_id' => 999]); + + $request = Request::create('/api/attachments/1', 'DELETE'); + $request->setUserResolver(fn () => null); + $response = $controller->destroy($request, $attachment); + expect($response->getStatusCode())->toBe(401); + + $user = User::factory()->create(); + $request->setUserResolver(fn () => $user); + $response = $controller->destroy($request, $attachment); + expect($response->getStatusCode())->toBe(403); +}); + +it('private helpers cover parse and match branches', function (): void { + $controller = new AttachmentController(); + + $refThread = new ReflectionMethod($controller, 'parseThreadId'); + $refThread->setAccessible(true); + $refPost = new ReflectionMethod($controller, 'parsePostId'); + $refPost->setAccessible(true); + $refMatch = new ReflectionMethod($controller, 'matchesAllowed'); + $refMatch->setAccessible(true); + $refResolve = new ReflectionMethod($controller, 'resolveExtension'); + $refResolve->setAccessible(true); + + expect($refThread->invoke($controller, null))->toBeNull(); + expect($refThread->invoke($controller, '/threads/12'))->toBe(12); + expect($refThread->invoke($controller, '5'))->toBe(5); + expect($refThread->invoke($controller, 'abc'))->toBeNull(); + + expect($refPost->invoke($controller, null))->toBeNull(); + expect($refPost->invoke($controller, '/posts/7'))->toBe(7); + expect($refPost->invoke($controller, '9'))->toBe(9); + expect($refPost->invoke($controller, 'nope'))->toBeNull(); + + expect($refMatch->invoke($controller, 'image/png', null))->toBeTrue(); + expect($refMatch->invoke($controller, 'image/png', []))->toBeTrue(); + expect($refMatch->invoke($controller, 'image/png', ['image/jpeg']))->toBeFalse(); + expect($refMatch->invoke($controller, 'image/png', ['image/png']))->toBeTrue(); + + expect($refResolve->invoke($controller, ''))->toBeNull(); +}); + +it('canViewAttachment handles trashed and missing parents', function (): void { + $controller = new AttachmentController(); + + $forum = makeForumForAttachments(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + $attachment = Attachment::create([ + 'thread_id' => $thread->id, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', + 'original_name' => 'file.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + $refView = new ReflectionMethod($controller, 'canViewAttachment'); + $refView->setAccessible(true); + + expect($refView->invoke($controller, $attachment))->toBeTrue(); + + $attachment->delete(); + expect($refView->invoke($controller, $attachment))->toBeFalse(); + + $attachment->restore(); + $thread->delete(); + expect($refView->invoke($controller, $attachment))->toBeFalse(); + + $thread->restore(); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Post', + ]); + $attachment->post_id = $post->id; + $attachment->thread_id = null; + $attachment->save(); + expect($refView->invoke($controller, $attachment))->toBeTrue(); + + $post->delete(); + expect($refView->invoke($controller, $attachment))->toBeFalse(); + + $attachment->post_id = null; + $attachment->thread_id = null; + $attachment->save(); + expect($refView->invoke($controller, $attachment))->toBeFalse(); +}); + +it('serializeAttachment returns thumbnail_url null when missing', function (): void { + $controller = new AttachmentController(); + + $attachment = new Attachment([ + 'id' => 1, + 'thread_id' => null, + 'post_id' => null, + 'extension' => 'pdf', + 'original_name' => 'file.pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + $ref = new ReflectionMethod($controller, 'serializeAttachment'); + $ref->setAccessible(true); + + $payload = $ref->invoke($controller, $attachment); + + expect($payload['thumbnail_url'])->toBeNull(); + expect($payload['is_image'])->toBeFalse(); +}); + +it('serializeAttachment includes thumbnail url when present', function (): void { + $controller = new AttachmentController(); + + $attachment = new Attachment([ + 'id' => 2, + 'thread_id' => null, + 'post_id' => null, + 'extension' => 'png', + 'original_name' => 'file.png', + 'mime_type' => 'image/png', + 'thumbnail_path' => 'thumbs/file.png', + 'size_bytes' => 10, + ]); + + $ref = new ReflectionMethod($controller, 'serializeAttachment'); + $ref->setAccessible(true); + + $payload = $ref->invoke($controller, $attachment); + + expect($payload['thumbnail_url'])->toContain('/thumbnail'); + expect($payload['is_image'])->toBeTrue(); +}); + +it('canManageAttachments handles null user and admin', function (): void { + $controller = new AttachmentController(); + $ref = new ReflectionMethod($controller, 'canManageAttachments'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, null, 1))->toBeFalse(); + + $admin = User::factory()->create(); + $role = \App\Models\Role::create(['name' => 'ROLE_ADMIN']); + $admin->roles()->attach($role); + + expect($ref->invoke($controller, $admin, 999))->toBeTrue(); +}); diff --git a/tests/Unit/AttachmentExtensionControllerUnitTest.php b/tests/Unit/AttachmentExtensionControllerUnitTest.php new file mode 100644 index 0000000..5cfe6ff --- /dev/null +++ b/tests/Unit/AttachmentExtensionControllerUnitTest.php @@ -0,0 +1,174 @@ +create(); + $role = Role::create(['name' => 'ROLE_ADMIN']); + $admin->roles()->attach($role); + return $admin; +} + +it('index returns forbidden for non admin', function (): void { + $controller = new AttachmentExtensionController(); + $request = Request::create('/api/attachment-extensions', 'GET'); + $request->setUserResolver(fn () => null); + + $response = $controller->index($request); + + expect($response->getStatusCode())->toBe(403); +}); + +it('store update destroy return forbidden for non admin', function (): void { + $controller = new AttachmentExtensionController(); + $user = User::factory()->create(); + + $store = Request::create('/api/attachment-extensions', 'POST', [ + 'extension' => 'png', + ]); + $store->setUserResolver(fn () => $user); + $response = $controller->store($store); + expect($response->getStatusCode())->toBe(403); + + $extension = AttachmentExtension::create(['extension' => 'gif']); + + $update = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [ + 'allowed_mimes' => ['image/gif'], + ]); + $update->setUserResolver(fn () => $user); + $response = $controller->update($update, $extension); + expect($response->getStatusCode())->toBe(403); + + $destroy = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE'); + $destroy->setUserResolver(fn () => $user); + $response = $controller->destroy($destroy, $extension); + expect($response->getStatusCode())->toBe(403); +}); + +it('store rejects invalid or duplicate extension', function (): void { + $controller = new AttachmentExtensionController(); + $admin = makeAdminUserForExtensions(); + + $request = Request::create('/api/attachment-extensions', 'POST', [ + 'extension' => '.', + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(422); + + AttachmentExtension::create(['extension' => 'png']); + + $request = Request::create('/api/attachment-extensions', 'POST', [ + 'extension' => 'PNG', + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(422); +}); + +it('store and update serialize group info', function (): void { + $controller = new AttachmentExtensionController(); + $admin = makeAdminUserForExtensions(); + + $group = AttachmentGroup::create([ + 'name' => 'Images', + 'max_size_kb' => 100, + 'is_active' => true, + ]); + + $request = Request::create('/api/attachment-extensions', 'POST', [ + 'extension' => 'png', + 'attachment_group_id' => $group->id, + 'allowed_mimes' => ['image/png'], + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(201); + + $extension = AttachmentExtension::query()->where('extension', 'png')->firstOrFail(); + + $request = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [ + 'allowed_mimes' => ['image/png', 'image/webp'], + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->update($request, $extension); + expect($response->getStatusCode())->toBe(200); + $payload = $response->getData(true); + expect($payload['group']['name'])->toBe('Images'); +}); + +it('destroy returns error when extension in use then succeeds', function (): void { + $controller = new AttachmentExtensionController(); + $admin = makeAdminUserForExtensions(); + + $extension = AttachmentExtension::create(['extension' => 'pdf']); + + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => $extension->id, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/test.pdf', + 'original_name' => 'test.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + $request = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE'); + $request->setUserResolver(fn () => $admin); + + $response = $controller->destroy($request, $extension); + expect($response->getStatusCode())->toBe(422); + + Attachment::query()->delete(); + + $response = $controller->destroy($request, $extension); + expect($response->getStatusCode())->toBe(204); +}); + +it('public index only returns active grouped extensions', function (): void { + $controller = new AttachmentExtensionController(); + + $activeGroup = AttachmentGroup::create([ + 'name' => 'Active', + 'max_size_kb' => 100, + 'is_active' => true, + ]); + $inactiveGroup = AttachmentGroup::create([ + 'name' => 'Inactive', + 'max_size_kb' => 100, + 'is_active' => false, + ]); + + AttachmentExtension::create([ + 'extension' => 'png', + 'attachment_group_id' => $activeGroup->id, + ]); + AttachmentExtension::create([ + 'extension' => 'zip', + 'attachment_group_id' => $inactiveGroup->id, + ]); + AttachmentExtension::create([ + 'extension' => 'orphan', + 'attachment_group_id' => null, + ]); + + $response = $controller->publicIndex(); + $payload = $response->getData(true); + + expect($payload)->toBe(['png']); +}); diff --git a/tests/Unit/AttachmentGroupControllerUnitTest.php b/tests/Unit/AttachmentGroupControllerUnitTest.php new file mode 100644 index 0000000..c7dde63 --- /dev/null +++ b/tests/Unit/AttachmentGroupControllerUnitTest.php @@ -0,0 +1,277 @@ +create(); + $role = Role::create(['name' => 'ROLE_ADMIN']); + $admin->roles()->attach($role); + return $admin; +} + +it('returns forbidden for non admin', function (): void { + $controller = new AttachmentGroupController(); + $user = User::factory()->create(); + + $index = Request::create('/api/attachment-groups', 'GET'); + $index->setUserResolver(fn () => $user); + expect($controller->index($index)->getStatusCode())->toBe(403); + + $store = Request::create('/api/attachment-groups', 'POST', [ + 'name' => 'Images', + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $store->setUserResolver(fn () => $user); + expect($controller->store($store)->getStatusCode())->toBe(403); + + $group = AttachmentGroup::create([ + 'name' => 'Docs', + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $update = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [ + 'name' => 'Docs', + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $update->setUserResolver(fn () => $user); + expect($controller->update($update, $group)->getStatusCode())->toBe(403); + + $destroy = Request::create('/api/attachment-groups/'.$group->id, 'DELETE'); + $destroy->setUserResolver(fn () => $user); + expect($controller->destroy($destroy, $group)->getStatusCode())->toBe(403); + + $reorder = Request::create('/api/attachment-groups/reorder', 'POST', [ + 'parentId' => null, + 'orderedIds' => [], + ]); + $reorder->setUserResolver(fn () => $user); + expect($controller->reorder($reorder)->getStatusCode())->toBe(403); +}); + +it('stores group and rejects duplicates', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + $request = Request::create('/api/attachment-groups', 'POST', [ + 'name' => 'Images', + 'parent_id' => null, + 'max_size_kb' => 100, + 'is_active' => true, + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(201); + + $response = $controller->store($request); + expect($response->getStatusCode())->toBe(422); +}); + +it('updates group and handles parent change position', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + $parentA = AttachmentGroup::create([ + 'name' => 'Parent A', + 'position' => 1, + 'max_size_kb' => 100, + 'is_active' => true, + ]); + $parentB = AttachmentGroup::create([ + 'name' => 'Parent B', + 'position' => 2, + 'max_size_kb' => 100, + 'is_active' => true, + ]); + $group = AttachmentGroup::create([ + 'name' => 'Docs', + 'parent_id' => $parentA->id, + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + + $request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [ + 'name' => 'Docs', + 'parent_id' => $parentB->id, + 'max_size_kb' => 10, + 'is_active' => false, + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->update($request, $group); + expect($response->getStatusCode())->toBe(200); +}); + +it('update rejects duplicate group name', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + AttachmentGroup::create([ + 'name' => 'Images', + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $group = AttachmentGroup::create([ + 'name' => 'Docs', + 'position' => 2, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + + $request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [ + 'name' => 'images', + 'parent_id' => null, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->update($request, $group); + expect($response->getStatusCode())->toBe(422); +}); + +it('destroy returns errors for in-use group', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + $group = AttachmentGroup::create([ + 'name' => 'Images', + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + AttachmentExtension::create([ + 'extension' => 'png', + 'attachment_group_id' => $group->id, + ]); + + $request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE'); + $request->setUserResolver(fn () => $admin); + + $response = $controller->destroy($request, $group); + expect($response->getStatusCode())->toBe(422); + + AttachmentExtension::query()->delete(); + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => $group->id, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/test.txt', + 'original_name' => 'test.txt', + 'extension' => 'txt', + 'mime_type' => 'text/plain', + 'size_bytes' => 1, + ]); + + $response = $controller->destroy($request, $group); + expect($response->getStatusCode())->toBe(422); +}); + +it('destroy deletes empty group', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + $group = AttachmentGroup::create([ + 'name' => 'Empty', + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + + $request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE'); + $request->setUserResolver(fn () => $admin); + + $response = $controller->destroy($request, $group); + expect($response->getStatusCode())->toBe(204); +}); + +it('reorders groups with string parent id handling', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + $groupA = AttachmentGroup::create([ + 'name' => 'A', + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $groupB = AttachmentGroup::create([ + 'name' => 'B', + 'position' => 2, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + + $request = Request::create('/api/attachment-groups/reorder', 'POST', [ + 'parentId' => 'null', + 'orderedIds' => [$groupB->id, $groupA->id], + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->reorder($request); + expect($response->getStatusCode())->toBe(200); + $groupA->refresh(); + $groupB->refresh(); + expect($groupB->position)->toBe(1); + expect($groupA->position)->toBe(2); +}); + +it('reorders groups with numeric parent id string', function (): void { + $controller = new AttachmentGroupController(); + $admin = makeAdminForGroupController(); + + $parent = AttachmentGroup::create([ + 'name' => 'Parent', + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $groupA = AttachmentGroup::create([ + 'name' => 'A', + 'parent_id' => $parent->id, + 'position' => 1, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + $groupB = AttachmentGroup::create([ + 'name' => 'B', + 'parent_id' => $parent->id, + 'position' => 2, + 'max_size_kb' => 10, + 'is_active' => true, + ]); + + $request = Request::create('/api/attachment-groups/reorder', 'POST', [ + 'parentId' => (string) $parent->id, + 'orderedIds' => [$groupB->id, $groupA->id], + ]); + $request->setUserResolver(fn () => $admin); + + $response = $controller->reorder($request); + expect($response->getStatusCode())->toBe(200); +}); + +it('normalizeParentId handles empty and null values', function (): void { + $controller = new AttachmentGroupController(); + $ref = new ReflectionMethod($controller, 'normalizeParentId'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, ''))->toBeNull(); + expect($ref->invoke($controller, 'null'))->toBeNull(); + expect($ref->invoke($controller, null))->toBeNull(); +}); diff --git a/tests/Unit/AuditLogControllerUnitTest.php b/tests/Unit/AuditLogControllerUnitTest.php new file mode 100644 index 0000000..750c82a --- /dev/null +++ b/tests/Unit/AuditLogControllerUnitTest.php @@ -0,0 +1,55 @@ +setUserResolver(fn () => null); + + $response = $controller->index($request); + + expect($response->getStatusCode())->toBe(401); +}); + +it('returns forbidden when user is not admin', function (): void { + $controller = new AuditLogController(); + $user = User::factory()->create(); + $request = Request::create('/api/audit-logs', 'GET'); + $request->setUserResolver(fn () => $user); + + $response = $controller->index($request); + + expect($response->getStatusCode())->toBe(403); +}); + +it('returns logs for admin', function (): void { + $controller = new AuditLogController(); + $admin = User::factory()->create(); + $role = Role::create(['name' => 'ROLE_ADMIN']); + $admin->roles()->attach($role); + + AuditLog::create([ + 'action' => 'test.action', + 'subject_type' => 'post', + 'subject_id' => 1, + 'metadata' => ['foo' => 'bar'], + 'ip_address' => '127.0.0.1', + 'user_agent' => 'test', + 'user_id' => $admin->id, + ]); + + $request = Request::create('/api/audit-logs', 'GET'); + $request->setUserResolver(fn () => $admin); + + $response = $controller->index($request); + + expect($response->getStatusCode())->toBe(200); + $payload = $response->getData(true); + expect($payload)->toHaveCount(1); + expect($payload[0]['user']['roles'][0])->toBe('ROLE_ADMIN'); +}); diff --git a/tests/Unit/BbcodeFormatterTest.php b/tests/Unit/BbcodeFormatterTest.php new file mode 100644 index 0000000..d942c1d --- /dev/null +++ b/tests/Unit/BbcodeFormatterTest.php @@ -0,0 +1,155 @@ +'; + } + } + + class Renderer + { + public function render(string $xml): string + { + return '

ok

'; + } + } + + class Configurator + { + public static bool $returnEmpty = false; + public object $plugins; + public object $tags; + + public function __construct() + { + $this->plugins = new class { + public function load(string $name): object + { + return new class { + public function addFromRepository(string $name): self + { + return $this; + } + }; + } + }; + + $this->tags = new class implements \ArrayAccess { + public array $store = []; + public function add($name) + { + $obj = new \stdClass(); + $this->store[$name] = $obj; + return $obj; + } + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->store); + } + public function offsetGet($offset): mixed + { + return $this->store[$offset] ?? null; + } + public function offsetSet($offset, $value): void + { + $this->store[$offset] = $value; + } + public function offsetUnset($offset): void + { + unset($this->store[$offset]); + } + }; + + $this->tags['QUOTE'] = new \stdClass(); + } + + public function finalize(): array + { + if (self::$returnEmpty) { + return []; + } + + return [ + 'parser' => new Parser(), + 'renderer' => new Renderer(), + ]; + } + } +} + +namespace { + use App\Actions\BbcodeFormatter; + + it('returns empty string for null and empty input', function (): void { + expect(BbcodeFormatter::format(null))->toBe(''); + expect(BbcodeFormatter::format(''))->toBe(''); + }); + + it('formats bbcode content', function (): void { + $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('Bold') + ->getMock() + ); + + $html = BbcodeFormatter::format('[b]Bold[/b]'); + + expect($html)->toContain(''); + }); + + it('initializes parser and renderer when not set', function (): void { + $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue(null); + + $rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue(null); + + $html = BbcodeFormatter::format('[b]Bold[/b]'); + + expect($html)->toBeString(); + expect($parserProp->getValue())->not->toBeNull(); + expect($rendererProp->getValue())->not->toBeNull(); + }); + + it('throws when bbcode formatter cannot initialize', function (): void { + \s9e\TextFormatter\Configurator::$returnEmpty = true; + + $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue(null); + + $rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue(null); + + try { + BbcodeFormatter::format('test'); + $this->fail('Expected exception not thrown.'); + } catch (Throwable $e) { + expect($e)->toBeInstanceOf(RuntimeException::class); + } finally { + \s9e\TextFormatter\Configurator::$returnEmpty = false; + } + }); + + afterEach(function (): void { + \Mockery::close(); + }); +} diff --git a/tests/Unit/ConsoleCommandTest.php b/tests/Unit/ConsoleCommandTest.php new file mode 100644 index 0000000..04098e9 --- /dev/null +++ b/tests/Unit/ConsoleCommandTest.php @@ -0,0 +1,50 @@ +delete(); + $exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump'); + expect($exitCode)->toBe(1); +}); + +it('version bump fails when invalid version', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => 'bad']); + $exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump'); + expect($exitCode)->toBe(1); +}); + +it('version set fails when invalid version', function (): void { + $exitCode = \Illuminate\Support\Facades\Artisan::call('version:set', ['version' => 'bad']); + expect($exitCode)->toBe(1); +}); + +it('version fetch fails when no version', function (): void { + Setting::where('key', 'version')->delete(); + $exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch'); + expect($exitCode)->toBe(1); +}); + +it('version release fails when missing config', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + putenv('GITEA_TOKEN'); + putenv('GITEA_OWNER'); + putenv('GITEA_REPO'); + $exitCode = \Illuminate\Support\Facades\Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('version release handles create failure', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + putenv('GITEA_TOKEN=token'); + putenv('GITEA_OWNER=owner'); + putenv('GITEA_REPO=repo'); + + Http::fake([ + '*' => Http::response([], 500), + ]); + + $exitCode = \Illuminate\Support\Facades\Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); diff --git a/tests/Unit/CronRunCommandTest.php b/tests/Unit/CronRunCommandTest.php new file mode 100644 index 0000000..d68550f --- /dev/null +++ b/tests/Unit/CronRunCommandTest.php @@ -0,0 +1,159 @@ + null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/test.txt', + 'original_name' => 'test.txt', + 'extension' => 'txt', + 'mime_type' => 'text/plain', + 'size_bytes' => 10, + ]); + + $exitCode = Artisan::call('speedbb:cron'); + expect($exitCode)->toBe(0); +}); + +it('counts missing files for images', function (): void { + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/missing.jpg', + 'original_name' => 'missing.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'size_bytes' => 10, + ]); + + $exitCode = Artisan::call('speedbb:cron'); + expect($exitCode)->toBe(0); +}); + +it('skips when thumbnail already exists', function (): void { + Storage::disk('local')->put('attachments/photo.jpg', 'image'); + Storage::disk('local')->put('attachments/thumb.jpg', 'thumb'); + + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/photo.jpg', + 'thumbnail_path' => 'attachments/thumb.jpg', + 'original_name' => 'photo.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'size_bytes' => 10, + ]); + + $exitCode = Artisan::call('speedbb:cron'); + expect($exitCode)->toBe(0); +}); + +it('creates thumbnails in dry run mode', function (): void { + Storage::disk('local')->put('attachments/photo.jpg', 'image'); + + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/photo.jpg', + 'original_name' => 'photo.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'size_bytes' => 10, + ]); + + $exitCode = Artisan::call('speedbb:cron', ['--dry-run' => true]); + expect($exitCode)->toBe(0); +}); + +it('forces thumbnail regeneration and updates attachment when created', function (): void { + Storage::disk('local')->put('attachments/photo.jpg', 'image'); + Storage::disk('local')->put('attachments/thumb-old.jpg', 'old'); + + $attachment = Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/photo.jpg', + 'thumbnail_path' => 'attachments/thumb-old.jpg', + 'original_name' => 'photo.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'size_bytes' => 10, + ]); + + $service = Mockery::mock(AttachmentThumbnailService::class); + $service->shouldReceive('createForAttachment') + ->once() + ->andReturn([ + 'path' => 'attachments/thumb-new.jpg', + 'mime' => 'image/jpeg', + 'size' => 123, + ]); + + app()->instance(AttachmentThumbnailService::class, $service); + + $exitCode = Artisan::call('speedbb:cron', ['--force' => true]); + expect($exitCode)->toBe(0); + + $attachment->refresh(); + expect($attachment->thumbnail_path)->toBe('attachments/thumb-new.jpg'); + expect($attachment->thumbnail_size_bytes)->toBe(123); +}); + +it('skips when thumbnail creation fails', function (): void { + Storage::disk('local')->put('attachments/photo.jpg', 'image'); + + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/photo.jpg', + 'original_name' => 'photo.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'size_bytes' => 10, + ]); + + $service = Mockery::mock(AttachmentThumbnailService::class); + $service->shouldReceive('createForAttachment')->once()->andReturnNull(); + app()->instance(AttachmentThumbnailService::class, $service); + + $exitCode = Artisan::call('speedbb:cron'); + expect($exitCode)->toBe(0); +}); + +afterEach(function (): void { + Mockery::close(); +}); diff --git a/tests/Unit/ForumControllerUnitTest.php b/tests/Unit/ForumControllerUnitTest.php new file mode 100644 index 0000000..e1102b9 --- /dev/null +++ b/tests/Unit/ForumControllerUnitTest.php @@ -0,0 +1,114 @@ +setAccessible(true); + + expect($ref->invoke($controller, null))->toBeNull(); + expect($ref->invoke($controller, '/forums/12'))->toBe(12); + expect($ref->invoke($controller, '7'))->toBe(7); + expect($ref->invoke($controller, 'abc'))->toBeNull(); +}); + +it('loadLastPostsByForum returns empty for no ids', function (): void { + $controller = new ForumController(); + $ref = new ReflectionMethod($controller, 'loadLastPostsByForum'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, []))->toBe([]); +}); + +it('resolveGroupColor returns null for missing roles', function (): void { + $controller = new ForumController(); + $user = User::factory()->create(); + $user->setRelation('roles', null); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, null))->toBeNull(); + expect($ref->invoke($controller, $user))->toBeNull(); +}); + +it('resolveGroupColor returns first sorted role color', function (): void { + $controller = new ForumController(); + $user = User::factory()->create(); + $roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']); + $roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']); + $user->roles()->attach([$roleB->id, $roleA->id]); + $user->load('roles'); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, $user))->toBe('#aaaaaa'); +}); + +it('resolveGroupColor returns null when roles have no colors', function (): void { + $controller = new ForumController(); + $user = User::factory()->create(); + $role = Role::create(['name' => 'ROLE_EMPTY', 'color' => null]); + $user->roles()->attach($role); + $user->load('roles'); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, $user))->toBeNull(); +}); + +it('loadLastPostsByForum returns latest post per forum', function (): void { + $controller = new ForumController(); + $ref = new ReflectionMethod($controller, 'loadLastPostsByForum'); + $ref->setAccessible(true); + + $category = Forum::create([ + 'name' => 'Category U', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum U', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $older = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Old', + ]); + $newer = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'New', + ]); + Post::whereKey($older->id)->update([ + 'created_at' => now()->subDay(), + 'updated_at' => now()->subDay(), + ]); + Post::whereKey($newer->id)->update([ + 'created_at' => now()->addSeconds(10), + 'updated_at' => now()->addSeconds(10), + ]); + + $result = $ref->invoke($controller, [$forum->id]); + expect($result[$forum->id]->id)->toBe($newer->id); +}); diff --git a/tests/Unit/InstallerControllerTest.php b/tests/Unit/InstallerControllerTest.php new file mode 100644 index 0000000..4dd1872 --- /dev/null +++ b/tests/Unit/InstallerControllerTest.php @@ -0,0 +1,146 @@ + 'https://example.test', + 'db_host' => $db['host'] ?? '127.0.0.1', + 'db_port' => $db['port'] ?? 3306, + 'db_database' => $db['database'] ?? 'tracer_speedBB_test', + 'db_username' => $db['username'] ?? 'root', + 'db_password' => $db['password'] ?? '', + 'admin_name' => 'Admin', + 'admin_email' => 'admin@example.com', + 'admin_password' => 'Password123!', + ], $overrides); + + return Request::create('https://example.test/install', 'POST', $data); +} + +it('shows installer when env missing', function (): void { + withEnvBackup(function (): void { + if (file_exists(base_path('.env'))) { + unlink(base_path('.env')); + } + + $controller = new InstallerController(); + $request = Request::create('https://example.test/install', 'GET'); + + $response = $controller->show($request); + expect($response)->toBeInstanceOf(Illuminate\View\View::class); + }); +}); + +it('redirects installer when env exists', function (): void { + withEnvBackup(function (): void { + file_put_contents(base_path('.env'), "APP_KEY=base64:test\n"); + + $controller = new InstallerController(); + $request = Request::create('https://example.test/install', 'GET'); + + $response = $controller->show($request); + expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class); + }); +}); + +it('store redirects when env exists', function (): void { + withEnvBackup(function (): void { + file_put_contents(base_path('.env'), "APP_KEY=base64:test\n"); + + $controller = new InstallerController(); + $request = installerRequest(); + + $response = $controller->store($request); + expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class); + }); +}); + +it('store handles db connection failure', function (): void { + withEnvBackup(function (): void { + if (file_exists(base_path('.env'))) { + unlink(base_path('.env')); + } + + DB::shouldReceive('purge')->once(); + DB::shouldReceive('connection->getPdo')->andThrow(new RuntimeException('boom')); + + $controller = new InstallerController(); + $request = installerRequest(['app_url' => 'https://example.test']); + + $response = $controller->store($request); + expect($response)->toBeInstanceOf(Illuminate\View\View::class); + expect(file_exists(base_path('.env')))->toBeFalse(); + }); +}); + +it('store handles migration failure', function (): void { + withEnvBackup(function (): void { + if (file_exists(base_path('.env'))) { + unlink(base_path('.env')); + } + + DB::shouldReceive('purge')->once(); + DB::shouldReceive('connection->getPdo')->andReturn(true); + Artisan::shouldReceive('call')->andReturn(1); + + $controller = new InstallerController(); + $request = installerRequest(); + + $response = $controller->store($request); + expect($response)->toBeInstanceOf(Illuminate\View\View::class); + expect(file_exists(base_path('.env')))->toBeFalse(); + }); +}); + +it('store completes installation on success', function (): void { + withEnvBackup(function (): void { + if (file_exists(base_path('.env'))) { + unlink(base_path('.env')); + } + + DB::shouldReceive('purge')->once(); + DB::shouldReceive('connection->getPdo')->andReturn(true); + Artisan::shouldReceive('call')->andReturn(0); + + $controller = new InstallerController(); + $request = installerRequest(['admin_email' => 'success@example.com']); + + $response = $controller->store($request); + expect($response)->toBeInstanceOf(Illuminate\View\View::class); + + $user = User::where('email', 'success@example.com')->first(); + expect($user)->not->toBeNull(); + expect(Role::where('name', 'ROLE_ADMIN')->exists())->toBeTrue(); + expect(Role::where('name', 'ROLE_FOUNDER')->exists())->toBeTrue(); + + if (file_exists(base_path('.env'))) { + unlink(base_path('.env')); + } + }); +}); diff --git a/tests/Unit/PostControllerUnitTest.php b/tests/Unit/PostControllerUnitTest.php new file mode 100644 index 0000000..09a0445 --- /dev/null +++ b/tests/Unit/PostControllerUnitTest.php @@ -0,0 +1,353 @@ +setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); +}); + +afterEach(function (): void { + \Mockery::close(); +}); + +function makeForumForPostController(): Forum +{ + $category = Forum::create([ + 'name' => 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + return Forum::create([ + 'name' => 'Forum', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); +} + +it('returns unauthorized on update when no user', function (): void { + $controller = new PostController(); + $forum = makeForumForPostController(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Body', + ]); + + $request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']); + $request->setUserResolver(fn () => null); + + $response = $controller->update($request, $post); + + expect($response->getStatusCode())->toBe(401); +}); + +it('returns forbidden on update when user is not owner or admin', function (): void { + $controller = new PostController(); + $forum = makeForumForPostController(); + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $owner->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $owner->id, + 'body' => 'Body', + ]); + + $request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']); + $request->setUserResolver(fn () => $viewer); + + $response = $controller->update($request, $post); + + expect($response->getStatusCode())->toBe(403); +}); + +it('updates post when user is owner', function (): void { + $controller = new PostController(); + $forum = makeForumForPostController(); + $owner = User::factory()->create(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $owner->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $owner->id, + 'body' => 'Body', + ]); + + $request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'Updated']); + $request->setUserResolver(fn () => $owner); + + $response = $controller->update($request, $post); + + expect($response->getStatusCode())->toBe(200); + $post->refresh(); + expect($post->body)->toBe('Updated'); +}); + +it('parseIriId handles empty and numeric values', function (): void { + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'parseIriId'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, null))->toBeNull(); + expect($ref->invoke($controller, ''))->toBeNull(); + expect($ref->invoke($controller, '/threads/12'))->toBe(12); + expect($ref->invoke($controller, '7'))->toBe(7); + expect($ref->invoke($controller, 'abc'))->toBeNull(); +}); + +it('serializes posts with attachments and rank data', function (): void { + $forum = makeForumForPostController(); + $role = Role::create(['name' => 'ROLE_MOD', 'color' => '#00ff00']); + $rank = Rank::create(['name' => 'Gold', 'badge_image_path' => 'ranks/badge.png']); + $user = User::factory()->create([ + 'rank_id' => $rank->id, + 'avatar_path' => 'avatars/u.png', + 'location' => 'Here', + ]); + $user->roles()->attach($role); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'See [attachment]file.png[/attachment]', + ]); + + $group = AttachmentGroup::create([ + 'name' => 'Images', + 'max_size_kb' => 100, + 'is_active' => true, + ]); + + $attachment = Attachment::create([ + 'thread_id' => null, + 'post_id' => $post->id, + 'attachment_extension_id' => null, + 'attachment_group_id' => $group->id, + 'user_id' => $user->id, + 'disk' => 'local', + 'path' => 'attachments/posts/'.$post->id.'/file.png', + 'thumbnail_path' => 'attachments/posts/'.$post->id.'/thumb.png', + 'original_name' => 'file.png', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'size_bytes' => 10, + ]); + + $post->load(['user.rank', 'user.roles', 'attachments.group']); + + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'serializePost'); + $ref->setAccessible(true); + + $payload = $ref->invoke($controller, $post); + + expect($payload['user_rank_badge_url'])->not->toBeNull(); + expect($payload['user_group_color'])->toBe('#00ff00'); + expect($payload['attachments'][0]['group']['name'])->toBe('Images'); + expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail'); +}); + +it('serializes posts with null user and no attachments', function (): void { + $forum = makeForumForPostController(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'Body', + ]); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Body', + ]); + + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'serializePost'); + $ref->setAccessible(true); + + $payload = $ref->invoke($controller, $post); + + expect($payload['user_avatar_url'])->toBeNull(); + expect($payload['user_rank_badge_url'])->toBeNull(); + expect($payload['user_group_color'])->toBeNull(); + expect($payload['attachments'])->toBe([]); +}); + +it('replaceAttachmentTags handles inline images and links', function (): void { + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'replaceAttachmentTags'); + $ref->setAccessible(true); + + Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']); + + $attachment = new Attachment([ + 'id' => 1, + 'original_name' => 'file.png', + 'mime_type' => 'image/png', + 'thumbnail_path' => null, + ]); + + $body = 'See [attachment]file.png[/attachment]'; + $result = $ref->invoke($controller, $body, collect([$attachment])); + expect($result)->toContain('[img]'); + + $attachment->thumbnail_path = 'thumb'; + $result = $ref->invoke($controller, $body, collect([$attachment])); + expect($result)->toContain('[url='); + + Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']); + $result = $ref->invoke($controller, $body, collect([$attachment])); + expect($result)->toContain('[url='); + + $result = $ref->invoke($controller, 'No match', collect([$attachment])); + expect($result)->toContain('No match'); +}); + +it('replaceAttachmentTags returns original tag when attachment name missing in map', function (): void { + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'replaceAttachmentTags'); + $ref->setAccessible(true); + + Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']); + + $attachment = new Attachment([ + 'id' => 2, + 'original_name' => 'actual.txt', + 'mime_type' => 'text/plain', + ]); + + $body = 'See [attachment]missing.txt[/attachment]'; + $result = $ref->invoke($controller, $body, collect([$attachment])); + + expect($result)->toBe($body); +}); + +it('replaceAttachmentTags renders non-image attachments as links', function (): void { + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'replaceAttachmentTags'); + $ref->setAccessible(true); + + Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'yes']); + + $attachment = new Attachment([ + 'id' => 3, + 'original_name' => 'doc.txt', + 'mime_type' => 'text/plain', + ]); + + $body = 'See [attachment]doc.txt[/attachment]'; + $result = $ref->invoke($controller, $body, collect([$attachment])); + + expect($result)->toContain('[url='); + expect($result)->toContain('doc.txt'); +}); + +it('replaceAttachmentTags returns body when no attachments or map empty', function (): void { + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'replaceAttachmentTags'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, 'Body', []))->toBe('Body'); + + $attachment = new Attachment([ + 'original_name' => '', + ]); + expect($ref->invoke($controller, 'Body', collect([$attachment])))->toBe('Body'); +}); + +it('displayImagesInline defaults to true when missing setting', function (): void { + Setting::where('key', 'attachments.display_images_inline')->delete(); + + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'displayImagesInline'); + $ref->setAccessible(true); + + expect($ref->invoke($controller))->toBeTrue(); +}); + +it('displayImagesInline returns false for off values', function (): void { + Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'off']); + + $controller = new PostController(); + $ref = new ReflectionMethod($controller, 'displayImagesInline'); + $ref->setAccessible(true); + + expect($ref->invoke($controller))->toBeFalse(); +}); + +it('resolveGroupColor returns null for missing roles', function (): void { + $controller = new PostController(); + $user = User::factory()->create(); + $user->setRelation('roles', null); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, $user))->toBeNull(); +}); + +it('resolveGroupColor returns first sorted role color', function (): void { + $controller = new PostController(); + $user = User::factory()->create(); + $roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']); + $roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']); + $user->roles()->attach([$roleB->id, $roleA->id]); + $user->load('roles'); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, $user))->toBe('#aaaaaa'); +}); diff --git a/tests/Unit/PostThankControllerUnitTest.php b/tests/Unit/PostThankControllerUnitTest.php new file mode 100644 index 0000000..c180b21 --- /dev/null +++ b/tests/Unit/PostThankControllerUnitTest.php @@ -0,0 +1,36 @@ + 1, + ]); + $post->setRawAttributes(['id' => 1, 'thread_id' => 1, 'user_id' => null, 'body' => 'Post'], true); + + $request = Request::create('/api/posts/'.$post->id.'/thanks', 'POST'); + $request->setUserResolver(fn () => null); + $response = $controller->store($request, $post); + expect($response->getStatusCode())->toBe(401); + + $request = Request::create('/api/posts/'.$post->id.'/thanks', 'DELETE'); + $request->setUserResolver(fn () => null); + $response = $controller->destroy($request, $post); + expect($response->getStatusCode())->toBe(401); +}); + +it('resolveGroupColor returns null for missing roles', function (): void { + $controller = new PostThankController(); + $user = User::factory()->create(); + $user->setRelation('roles', null); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, $user))->toBeNull(); + expect($ref->invoke($controller, null))->toBeNull(); +}); diff --git a/tests/Unit/ResetUserPasswordTest.php b/tests/Unit/ResetUserPasswordTest.php new file mode 100644 index 0000000..15a2503 --- /dev/null +++ b/tests/Unit/ResetUserPasswordTest.php @@ -0,0 +1,20 @@ +create([ + 'password' => Hash::make('OldPass123!'), + ]); + + $action = new ResetUserPassword(); + $action->reset($user, [ + 'password' => 'NewPass123!', + 'password_confirmation' => 'NewPass123!', + ]); + + $user->refresh(); + expect(Hash::check('NewPass123!', $user->password))->toBeTrue(); +}); diff --git a/tests/Unit/StatsControllerUnitTest.php b/tests/Unit/StatsControllerUnitTest.php new file mode 100644 index 0000000..a1c05cc --- /dev/null +++ b/tests/Unit/StatsControllerUnitTest.php @@ -0,0 +1,63 @@ +delete(); + Setting::where('key', 'build')->delete(); + + $controller = new StatsController(); + $response = $controller->__invoke(); + $payload = $response->getData(true); + + expect($payload['board_version'])->toBeNull(); +}); + +it('handles stats edge cases without crashing', function (): void { + Storage::fake('public'); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']); + Setting::updateOrCreate(['key' => 'build'], ['value' => '9']); + + DB::shouldReceive('connection->getDriverName')->andReturn('sqlite'); + DB::shouldReceive('selectOne')->andThrow(new RuntimeException('db fail')); + + $controller = new StatsController(); + $response = $controller->__invoke(); + + $payload = $response->getData(true); + + expect($payload['database_size_bytes'])->toBeNull(); + expect($payload['database_server'])->toBeNull(); + expect($payload['board_version'])->toBe('1.0.0 (build 9)'); + expect($payload['orphan_attachments'])->toBeInt(); +}); + +it('returns null for database size and avatar size on exceptions', function (): void { + DB::shouldReceive('connection->getDriverName')->andThrow(new RuntimeException('db fail')); + + $controller = new StatsController(); + $refDb = new ReflectionMethod($controller, 'resolveDatabaseSize'); + $refDb->setAccessible(true); + $refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize'); + $refAvatar->setAccessible(true); + + expect($refDb->invoke($controller))->toBeNull(); + \Illuminate\Support\Facades\Storage::shouldReceive('disk')->andThrow(new RuntimeException('disk fail')); + expect($refAvatar->invoke($controller))->toBeNull(); +}); + +it('sums avatar directory size', function (): void { + Storage::fake('public'); + Storage::disk('public')->put('avatars/a.png', 'a'); + Storage::disk('public')->put('avatars/b.png', 'bb'); + + $controller = new StatsController(); + $refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize'); + $refAvatar->setAccessible(true); + + expect($refAvatar->invoke($controller))->toBe(3); +}); diff --git a/tests/Unit/SystemStatusControllerUnitTest.php b/tests/Unit/SystemStatusControllerUnitTest.php new file mode 100644 index 0000000..00423c7 --- /dev/null +++ b/tests/Unit/SystemStatusControllerUnitTest.php @@ -0,0 +1,225 @@ + $body) { + $path = $dir . DIRECTORY_SEPARATOR . $name; + file_put_contents($path, $body); + chmod($path, 0755); + } + + $originalPath = getenv('PATH') ?: ''; + putenv("PATH={$dir}"); + $_ENV['PATH'] = $dir; + $_SERVER['PATH'] = $dir; + + try { + $callback($dir); + } finally { + putenv("PATH={$originalPath}"); + $_ENV['PATH'] = $originalPath; + $_SERVER['PATH'] = $originalPath; + if (is_dir($dir)) { + $items = scandir($dir); + if (is_array($items)) { + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + if (is_file($path)) { + unlink($path); + } + } + } + rmdir($dir); + } + } +} + +it('returns system status for admins', function (): void { + withFakeBinForStatus([ + 'php' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.0\"; exit 0; fi\necho \"php\"\n", + 'composer' => "#!/bin/sh\necho \"composer 2.0.0\"\n", + 'node' => "#!/bin/sh\necho \"v20.0.0\"\n", + 'npm' => "#!/bin/sh\necho \"9.0.0\"\n", + 'tar' => "#!/bin/sh\necho \"tar 1.2.3\"\n", + 'rsync' => "#!/bin/sh\necho \"rsync 3.2.0\"\n", + ], function (string $dir): void { + $admin = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + $request = Request::create('/api/system/status', 'GET'); + $request->setUserResolver(fn () => $admin); + + $controller = new SystemStatusController(); + $response = $controller->__invoke($request); + + expect($response->getStatusCode())->toBe(200); + $payload = $response->getData(true); + + expect($payload)->toHaveKeys([ + 'php', + 'php_default', + 'composer', + 'composer_version', + 'node', + 'node_version', + 'npm', + 'npm_version', + 'tar', + 'tar_version', + 'rsync', + 'rsync_version', + 'proc_functions', + 'storage_writable', + 'updates_writable', + ]); + }); +}); + +it('covers binary resolution edge cases', function (): void { + withFakeBinForStatus([ + 'sh' => "#!/bin/sh\nexit 0\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refBinary = new ReflectionMethod($controller, 'resolveBinary'); + $refBinary->setAccessible(true); + + expect($refBinary->invoke($controller, 'php'))->toBeNull(); + }); +}); + +it('returns php version when available', function (): void { + withFakeBinForStatus([ + 'phpfake' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.1\"; exit 0; fi\nexit 0\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refPhp = new ReflectionMethod($controller, 'resolvePhpVersion'); + $refPhp->setAccessible(true); + + $path = $dir . '/phpfake'; + expect($refPhp->invoke($controller, $path))->toBe('8.4.1'); + }); +}); + +it('returns null php version when command fails', function (): void { + withFakeBinForStatus([ + 'phpfail' => "#!/bin/sh\nexit 1\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refPhp = new ReflectionMethod($controller, 'resolvePhpVersion'); + $refPhp->setAccessible(true); + + $path = $dir . '/phpfail'; + expect($refPhp->invoke($controller, $path))->toBeNull(); + }); +}); + +it('returns binary version when regex matches', function (): void { + withFakeBinForStatus([ + 'tool' => "#!/bin/sh\necho \"tool v1.2.3\"\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refVer = new ReflectionMethod($controller, 'resolveBinaryVersion'); + $refVer->setAccessible(true); + + $path = $dir . '/tool'; + expect($refVer->invoke($controller, $path, ['--version']))->toBe('1.2.3'); + }); +}); + +it('returns null when binary version output is empty', function (): void { + withFakeBinForStatus([ + 'empty' => "#!/bin/sh\nexit 0\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refVer = new ReflectionMethod($controller, 'resolveBinaryVersion'); + $refVer->setAccessible(true); + + $path = $dir . '/empty'; + expect($refVer->invoke($controller, $path, ['--version']))->toBeNull(); + }); +}); + +it('returns null when binary version output has no version', function (): void { + withFakeBinForStatus([ + 'noversion' => "#!/bin/sh\necho \"tool version unknown\"\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refVer = new ReflectionMethod($controller, 'resolveBinaryVersion'); + $refVer->setAccessible(true); + + $path = $dir . '/noversion'; + expect($refVer->invoke($controller, $path, ['--version']))->toBeNull(); + }); +}); + +it('returns null when binary version command fails', function (): void { + withFakeBinForStatus([ + 'fail' => "#!/bin/sh\nexit 1\n", + ], function (string $dir): void { + $controller = new SystemStatusController(); + $refVer = new ReflectionMethod($controller, 'resolveBinaryVersion'); + $refVer->setAccessible(true); + + $path = $dir . '/fail'; + expect($refVer->invoke($controller, $path, ['--version']))->toBeNull(); + }); +}); + +it('returns empty array when readJson cannot read file', function (): void { + $controller = new SystemStatusController(); + $ref = new ReflectionMethod($controller, 'readJson'); + $ref->setAccessible(true); + + $path = sys_get_temp_dir() . '/missing.json'; + $result = $ref->invoke($controller, $path); + + expect($result)->toBe([]); +}); + +it('returns empty array when readJson invalid', function (): void { + $controller = new SystemStatusController(); + $ref = new ReflectionMethod($controller, 'readJson'); + $ref->setAccessible(true); + + $path = sys_get_temp_dir() . '/invalid.json'; + file_put_contents($path, 'not-json'); + + $result = $ref->invoke($controller, $path); + unlink($path); + + expect($result)->toBe([]); +}); + +it('returns empty array when readJson cannot read contents', function (): void { + $controller = new SystemStatusController(); + $ref = new ReflectionMethod($controller, 'readJson'); + $ref->setAccessible(true); + + $path = storage_path('app/unreadable.json'); + file_put_contents($path, '{"a":1}'); + chmod($path, 0000); + + $prev = set_error_handler(static fn () => true); + $result = $ref->invoke($controller, $path); + restore_error_handler(); + + chmod($path, 0644); + unlink($path); + + expect($result)->toBe([]); +}); diff --git a/tests/Unit/ThreadControllerBranchesTest.php b/tests/Unit/ThreadControllerBranchesTest.php new file mode 100644 index 0000000..7fe4ca4 --- /dev/null +++ b/tests/Unit/ThreadControllerBranchesTest.php @@ -0,0 +1,164 @@ +setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); +}); + +afterEach(function (): void { + \Mockery::close(); +}); + +it('parseIriId returns null for empty values and non numeric', function (): void { + $controller = new ThreadController(); + $ref = new ReflectionMethod($controller, 'parseIriId'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, null))->toBeNull(); + expect($ref->invoke($controller, ''))->toBeNull(); + expect($ref->invoke($controller, 'abc'))->toBeNull(); +}); + +it('parseIriId parses forum iris and numeric values', function (): void { + $controller = new ThreadController(); + $ref = new ReflectionMethod($controller, 'parseIriId'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, '/api/forums/123'))->toBe(123); + expect($ref->invoke($controller, '456'))->toBe(456); +}); + +it('serializes thread with rank badge url when present', function (): void { + Storage::fake('public'); + + $category = Forum::create([ + 'name' => 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $rank = \App\Models\Rank::create([ + 'name' => 'Rank', + 'badge_image_path' => 'ranks/badge.png', + ]); + + $user = \App\Models\User::factory()->create([ + 'rank_id' => $rank->id, + ]); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + $thread->load(['user.rank', 'attachments']); + + $controller = new ThreadController(); + $ref = new ReflectionMethod($controller, 'serializeThread'); + $ref->setAccessible(true); + + $payload = $ref->invoke($controller, $thread); + + expect($payload['user_rank_badge_url'])->not->toBeNull(); +}); + +it('replaces attachment tags with inline image without thumb', function (): void { + Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'true']); + + $category = Forum::create([ + 'name' => 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $forum = Forum::create([ + 'name' => 'Forum', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Thread', + 'body' => 'See [attachment]image.jpg[/attachment]', + ]); + + $attachment = Attachment::create([ + 'thread_id' => $thread->id, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/threads/'.$thread->id.'/image.jpg', + 'original_name' => 'image.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'size_bytes' => 10, + ]); + + $controller = new ThreadController(); + $ref = new ReflectionMethod($controller, 'replaceAttachmentTags'); + $ref->setAccessible(true); + + $result = $ref->invoke($controller, $thread->body, collect([$attachment])); + + expect($result)->toContain('[img]'); +}); + +it('defaults to inline images when setting is missing', function (): void { + Setting::where('key', 'attachments.display_images_inline')->delete(); + + $controller = new ThreadController(); + $ref = new ReflectionMethod($controller, 'displayImagesInline'); + $ref->setAccessible(true); + + expect($ref->invoke($controller))->toBeTrue(); +}); + +it('returns null group color when roles relation is null', function (): void { + $controller = new ThreadController(); + $user = \App\Models\User::factory()->create(); + $user->setRelation('roles', null); + + $ref = new ReflectionMethod($controller, 'resolveGroupColor'); + $ref->setAccessible(true); + + expect($ref->invoke($controller, $user))->toBeNull(); +}); diff --git a/tests/Unit/ThreadControllerUnitTest.php b/tests/Unit/ThreadControllerUnitTest.php index 3846e7a..e62ef5c 100644 --- a/tests/Unit/ThreadControllerUnitTest.php +++ b/tests/Unit/ThreadControllerUnitTest.php @@ -12,6 +12,30 @@ use App\Models\Setting; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; +beforeEach(function (): void { + $parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser'); + $parserProp->setAccessible(true); + $parserProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Parser::class) + ->shouldReceive('parse') + ->andReturn('') + ->getMock() + ); + + $rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer'); + $rendererProp->setAccessible(true); + $rendererProp->setValue( + \Mockery::mock(\s9e\TextFormatter\Renderer::class) + ->shouldReceive('render') + ->andReturn('

') + ->getMock() + ); +}); + +afterEach(function (): void { + \Mockery::close(); +}); + function makeForumForThreadController(): Forum { $category = Forum::create([ @@ -130,7 +154,7 @@ it('serializes threads with attachments, group colors, and inline images', funct expect($payload['user_group_color'])->toBe('#ff0000'); expect($payload['attachments'][0]['group']['name'])->toBe('Images'); expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail'); - expect($payload['body_html'])->toContain('toContain('toBe($post->id); }); diff --git a/tests/Unit/UpdateUserPasswordTest.php b/tests/Unit/UpdateUserPasswordTest.php new file mode 100644 index 0000000..adbca1a --- /dev/null +++ b/tests/Unit/UpdateUserPasswordTest.php @@ -0,0 +1,44 @@ +create([ + 'password' => Hash::make('OldPass123!'), + ]); + $this->actingAs($user); + + $action = new UpdateUserPassword(); + + $action->update($user, [ + 'current_password' => 'OldPass123!', + 'password' => 'NewPass123!', + 'password_confirmation' => 'NewPass123!', + ]); + + $user->refresh(); + expect(Hash::check('NewPass123!', $user->password))->toBeTrue(); +}); + +it('rejects wrong current password', function (): void { + $user = User::factory()->create([ + 'password' => Hash::make('OldPass123!'), + ]); + $this->actingAs($user); + + $action = new UpdateUserPassword(); + + try { + $action->update($user, [ + 'current_password' => 'WrongPass', + 'password' => 'NewPass123!', + 'password_confirmation' => 'NewPass123!', + ]); + $this->fail('Expected ValidationException not thrown.'); + } catch (ValidationException $e) { + expect($e->errors())->toHaveKey('current_password'); + } +}); diff --git a/tests/Unit/UpdateUserProfileInformationTest.php b/tests/Unit/UpdateUserProfileInformationTest.php new file mode 100644 index 0000000..66fcf75 --- /dev/null +++ b/tests/Unit/UpdateUserProfileInformationTest.php @@ -0,0 +1,40 @@ +create([ + 'name' => 'Old', + 'email' => 'old@example.com', + ]); + + $action = new UpdateUserProfileInformation(); + $action->update($user, [ + 'name' => 'New Name', + 'email' => 'old@example.com', + ]); + + $user->refresh(); + expect($user->name)->toBe('New Name'); + expect($user->name_canonical)->toBe('new name'); + expect($user->email)->toBe('old@example.com'); +}); + +it('resets verification and sends notification when email changes', function (): void { + $user = User::factory()->create([ + 'name' => 'Old', + 'email' => 'old@example.com', + 'email_verified_at' => now(), + ]); + + $action = new UpdateUserProfileInformation(); + $action->update($user, [ + 'name' => 'New Name', + 'email' => 'new@example.com', + ]); + + $user->refresh(); + expect($user->email)->toBe('new@example.com'); + expect($user->email_verified_at)->toBeNull(); +}); diff --git a/tests/Unit/VersionBumpCommandTest.php b/tests/Unit/VersionBumpCommandTest.php new file mode 100644 index 0000000..7371227 --- /dev/null +++ b/tests/Unit/VersionBumpCommandTest.php @@ -0,0 +1,122 @@ + 'version'], ['value' => '1.2.09-beta']); + + $exitCode = Artisan::call('version:bump'); + expect($exitCode)->toBe(0); + + $setting = Setting::where('key', 'version')->value('value'); + expect($setting)->toBe('1.2.10-beta'); + + $data = json_decode((string) file_get_contents($path), true); + expect($data['version'] ?? null)->toBe('1.2.10-beta'); + }); + }); + + it('fails when composer.json cannot be decoded', function (): void { + withComposerBackup(function (string $path): void { + file_put_contents($path, 'not-json'); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:bump'); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when composer.json is not readable', function (): void { + withComposerBackup(function (string $path): void { + chmod($path, 0000); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:bump'); + expect($exitCode)->toBe(1); + + chmod($path, 0644); + }); + }); + + it('fails when file_get_contents returns false', function (): void { + withComposerBackup(function (): void { + $GLOBALS['version_bump_file_get_contents_false'] = true; + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:bump'); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when json_encode returns false', function (): void { + withComposerBackup(function (): void { + $GLOBALS['version_bump_json_encode_false'] = true; + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:bump'); + expect($exitCode)->toBe(1); + }); + }); +} diff --git a/tests/Unit/VersionFetchCommandTest.php b/tests/Unit/VersionFetchCommandTest.php new file mode 100644 index 0000000..a67c96d --- /dev/null +++ b/tests/Unit/VersionFetchCommandTest.php @@ -0,0 +1,125 @@ + 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:fetch'); + expect($exitCode)->toBe(0); + + $build = Setting::where('key', 'build')->value('value'); + expect(is_numeric($build))->toBeTrue(); + }); + }); + + it('fails when build count cannot be resolved', function (): void { + withComposerBackupForFetch(function (): void { + $GLOBALS['version_fetch_path'] = getenv('PATH') ?: ''; + putenv('PATH=/nope'); + $_ENV['PATH'] = '/nope'; + $_SERVER['PATH'] = '/nope'; + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:fetch'); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when composer.json cannot be decoded', function (): void { + withComposerBackupForFetch(function (string $path): void { + file_put_contents($path, 'not-json'); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:fetch'); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when composer.json is not readable', function (): void { + withComposerBackupForFetch(function (string $path): void { + chmod($path, 0000); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:fetch'); + expect($exitCode)->toBe(1); + + chmod($path, 0644); + }); + }); + + it('fails when file_get_contents returns false', function (): void { + withComposerBackupForFetch(function (): void { + $GLOBALS['version_fetch_file_get_contents_false'] = true; + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:fetch'); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when json_encode returns false', function (): void { + withComposerBackupForFetch(function (): void { + $GLOBALS['version_fetch_json_encode_false'] = true; + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + $exitCode = Artisan::call('version:fetch'); + expect($exitCode)->toBe(1); + }); + }); +} diff --git a/tests/Unit/VersionReleaseCommandTest.php b/tests/Unit/VersionReleaseCommandTest.php new file mode 100644 index 0000000..610356f --- /dev/null +++ b/tests/Unit/VersionReleaseCommandTest.php @@ -0,0 +1,183 @@ +delete(); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('fails when gitea config missing', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + putenv('GITEA_TOKEN'); + putenv('GITEA_OWNER'); + putenv('GITEA_REPO'); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('creates release successfully with changelog body', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + setGiteaEnvForRelease(); + + withChangelogBackup(function (string $path): void { + file_put_contents($path, "# Changelog\n\n## 1.2.3\n- Added thing\n\n## 1.2.2\n- Old\n"); + + Http::fake([ + 'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response(['id' => 1], 201), + ]); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(0); + + Http::assertSent(function ($request): bool { + $payload = $request->data(); + return $payload['tag_name'] === 'v1.2.3' + && str_contains($payload['body'], 'Added thing'); + }); + }); +}); + +it('fails when create response is error', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + setGiteaEnvForRelease(); + + Http::fake([ + '*' => Http::response([], 500), + ]); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('fails when existing release cannot be fetched', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + setGiteaEnvForRelease(); + + Http::fake([ + 'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409), + 'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response([], 500), + ]); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('fails when existing release has no id', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + setGiteaEnvForRelease(); + + Http::fake([ + 'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409), + 'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => null], 200), + ]); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('updates existing release when create conflicts', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + setGiteaEnvForRelease(); + + Http::fake([ + 'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409), + 'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => 99], 200), + 'https://git.example.test/api/v1/repos/owner/repo/releases/99' => Http::response(['id' => 99], 200), + ]); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(0); +}); + +it('fails when updating existing release fails', function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + setGiteaEnvForRelease(); + + Http::fake([ + 'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 422), + 'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => 99], 200), + 'https://git.example.test/api/v1/repos/owner/repo/releases/99' => Http::response([], 500), + ]); + + $exitCode = Artisan::call('version:release'); + expect($exitCode)->toBe(1); +}); + +it('returns default changelog body when file missing', function (): void { + withChangelogBackup(function (string $path): void { + if (file_exists($path)) { + unlink($path); + } + + $command = new VersionRelease(); + $ref = new ReflectionMethod($command, 'resolveChangelogBody'); + $ref->setAccessible(true); + + $body = $ref->invoke($command, '1.2.3'); + expect($body)->toBe('See commit history for details.'); + }); +}); + +it('returns default changelog body when read fails', function (): void { + withChangelogBackup(function (string $path): void { + file_put_contents($path, "# Changelog\n\n## 1.2.3\n- Something\n"); + $GLOBALS['version_release_file_get_contents_false'] = true; + + $command = new VersionRelease(); + $ref = new ReflectionMethod($command, 'resolveChangelogBody'); + $ref->setAccessible(true); + + $body = $ref->invoke($command, '1.2.3'); + expect($body)->toBe('See commit history for details.'); + + $GLOBALS['version_release_file_get_contents_false'] = false; + }); +}); +} diff --git a/tests/Unit/VersionSetCommandTest.php b/tests/Unit/VersionSetCommandTest.php new file mode 100644 index 0000000..ed98dff --- /dev/null +++ b/tests/Unit/VersionSetCommandTest.php @@ -0,0 +1,111 @@ +delete(); + + $exitCode = Artisan::call('version:set', ['version' => '2.3.4']); + expect($exitCode)->toBe(0); + + $setting = Setting::where('key', 'version')->value('value'); + expect($setting)->toBe('2.3.4'); + + $data = json_decode((string) file_get_contents($path), true); + expect($data['version'] ?? null)->toBe('2.3.4'); + }); + }); + + it('updates version when current exists', function (): void { + withComposerBackupForSet(function (): void { + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']); + + $exitCode = Artisan::call('version:set', ['version' => '1.0.1']); + expect($exitCode)->toBe(0); + + $setting = Setting::where('key', 'version')->value('value'); + expect($setting)->toBe('1.0.1'); + }); + }); + + it('fails when composer.json cannot be read', function (): void { + withComposerBackupForSet(function (string $path): void { + chmod($path, 0000); + + $exitCode = Artisan::call('version:set', ['version' => '2.0.0']); + expect($exitCode)->toBe(1); + + chmod($path, 0644); + }); + }); + + it('fails when composer.json cannot be decoded', function (): void { + withComposerBackupForSet(function (string $path): void { + file_put_contents($path, 'not-json'); + + $exitCode = Artisan::call('version:set', ['version' => '2.0.0']); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when file_get_contents returns false', function (): void { + withComposerBackupForSet(function (): void { + $GLOBALS['version_set_file_get_contents_false'] = true; + + $exitCode = Artisan::call('version:set', ['version' => '2.0.0']); + expect($exitCode)->toBe(1); + }); + }); + + it('fails when json_encode returns false', function (): void { + withComposerBackupForSet(function (): void { + $GLOBALS['version_set_json_encode_false'] = true; + + $exitCode = Artisan::call('version:set', ['version' => '2.0.0']); + expect($exitCode)->toBe(1); + }); + }); +}