diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0eb0f39..13a8e62 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: DB_DATABASE: externals_test DB_USERNAME: root DB_PASSWORD: '' - run: vendor/bin/phpunit --colors=always + run: vendor/bin/pest --colors=always - name: PHPStan run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress diff --git a/app/Http/Controllers/LoginController.php b/app/Http/Controllers/Auth/GithubController.php similarity index 68% rename from app/Http/Controllers/LoginController.php rename to app/Http/Controllers/Auth/GithubController.php index c90d5ee..db870ef 100644 --- a/app/Http/Controllers/LoginController.php +++ b/app/Http/Controllers/Auth/GithubController.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Http\Controllers; +namespace App\Http\Controllers\Auth; use App\Actions\GetOrCreateUser; +use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; @@ -12,8 +13,13 @@ use Symfony\Component\HttpFoundation\Response; use Throwable; -class LoginController extends Controller +class GithubController extends Controller { + /** + * GitHub redirects back to this same URL after authorization, so this + * entry point handles both phases: sending the user to GitHub, and + * processing the callback once GitHub returns with a `code`. + */ public function __invoke(Request $request): Response { if ($request->user()) { @@ -24,6 +30,14 @@ public function __invoke(Request $request): Response return Socialite::driver('github')->redirect(); } + return $this->handleCallback($request); + } + + /** + * Handle the callback from GitHub after authorization. + */ + private function handleCallback(Request $request): Response + { try { $githubUser = Socialite::driver('github')->user(); } catch (InvalidStateException) { diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php deleted file mode 100644 index ed64fe3..0000000 --- a/app/Http/Controllers/HomeController.php +++ /dev/null @@ -1,38 +0,0 @@ -user(); - $page = (int) $request->query('page', 1); - $perPage = 20; - - $threadCount = Cache::remember( - 'stats.threadCount', - now()->addMinutes(5), - fn() => Email::where('isThreadRoot', true)->count(), - ); - - return view('home', [ - 'threads' => $this->threads->findLatestThreads($page, $user), - 'page' => $page, - 'pageCount' => (int) ceil($threadCount / $perPage), - 'user' => $user, - ]); - } -} diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php deleted file mode 100644 index b3ed6aa..0000000 --- a/app/Http/Controllers/NewsController.php +++ /dev/null @@ -1,16 +0,0 @@ - $request->user()]); - } -} diff --git a/app/Http/Controllers/StatsController.php b/app/Http/Controllers/StatsController.php deleted file mode 100644 index d30f4d4..0000000 --- a/app/Http/Controllers/StatsController.php +++ /dev/null @@ -1,26 +0,0 @@ -addMinutes(5); - - return view('stats', [ - 'userCount' => Cache::remember('stats.userCount', $ttl, fn() => User::count()), - 'threadCount' => Cache::remember('stats.threadCount', $ttl, fn() => Email::where('isThreadRoot', true)->count()), - 'emailCount' => Cache::remember('stats.emailCount', $ttl, fn() => Email::count()), - 'user' => $request->user(), - ]); - } -} diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php deleted file mode 100644 index b06833c..0000000 --- a/app/Http/Controllers/ThreadController.php +++ /dev/null @@ -1,50 +0,0 @@ -first(); - if (! $email) { - throw new NotFoundException("Email $number was not found"); - } - - if (! $email->isThreadRoot()) { - $thread = Email::find($email->threadId); - if ($thread) { - return redirect("/message/{$thread->number}#$number"); - } - // Root message not found — render the thread from this URL anyway - } - - $user = $request->user(); - // Build the thread view BEFORE marking the thread as read - $threadView = $this->threads->getThreadView($email, $user); - if ($user) { - app(MarkEmailAsRead::class)->handle($email, $user); - } - - return response()->view('thread', [ - 'subject' => $email->subject, - 'thread' => $threadView, - 'threadId' => $number, - 'user' => $user, - ]); - } -} diff --git a/app/Http/Controllers/TopController.php b/app/Http/Controllers/TopController.php deleted file mode 100644 index 703bbf5..0000000 --- a/app/Http/Controllers/TopController.php +++ /dev/null @@ -1,26 +0,0 @@ -user(); - - return view('top', [ - 'threads' => $this->threads->findTopThreads(1, $user), - 'user' => $user, - ]); - } -} diff --git a/app/Http/Controllers/VoteController.php b/app/Http/Controllers/VoteController.php deleted file mode 100644 index d33c5b3..0000000 --- a/app/Http/Controllers/VoteController.php +++ /dev/null @@ -1,30 +0,0 @@ -user(); - if (! $user) { - return response()->json('You must be authenticated', 401); - } - - $vote = (int) $request->input('value', 0); - if ($vote > 1 || $vote < -1) { - return response()->json('Invalid value', 400); - } - - return response()->json([ - 'newTotal' => app(CastVote::class)->handle($user->id, $number, $vote), - 'newValue' => $vote, - ]); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3fef439..a388f29 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,7 +10,6 @@ use App\Services\Search\AlgoliaSearchIndex; use App\Services\Search\ReadOnlySearchIndex; use App\Services\Search\SearchIndex; -use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use League\CommonMark\CommonMarkConverter; use VStelmakh\UrlHighlight\Highlighter\HtmlHighlighter; @@ -59,12 +58,5 @@ public function register(): void $this->app->singleton(RssRfcBuilder::class, fn() => new RssRfcBuilder((string) config('externals.rss_host'))); } - public function boot(): void - { - View::share('version', (string) config('externals.version')); - View::share('assetsBaseUrl', (string) config('externals.assets_base_url')); - View::share('algoliaIndex', config('services.algolia.index_prefix') . 'emails'); - View::share('noIndex', (bool) config('externals.no_index')); - View::share('debug', (bool) config('app.debug')); - } + public function boot(): void {} } diff --git a/app/Providers/VoltServiceProvider.php b/app/Providers/VoltServiceProvider.php new file mode 100644 index 0000000..8b761d0 --- /dev/null +++ b/app/Providers/VoltServiceProvider.php @@ -0,0 +1,25 @@ +> + * @return ThreadSummary[] */ public function findLatestThreads(int $page, ?User $user): array { - return $this->findThreads('', 'ORDER BY threads.lastUpdate DESC', $page, $user); + return $this->findThreads('', [], 'ORDER BY threads.lastUpdate DESC', $page, $user); } /** - * @return array> + * @return ThreadSummary[] */ public function findTopThreads(int $page, ?User $user): array { - $where = 'WHERE threads.votes > 0 AND threads.lastUpdate > DATE_SUB(NOW(), INTERVAL 1 MONTH)'; + $where = 'WHERE threads.votes > 0 AND threads.lastUpdate > ?'; + $oneMonthAgo = now()->subMonth()->toDateTimeString(); - return $this->findThreads($where, 'ORDER BY threads.votes DESC, threads.lastUpdate DESC', $page, $user); + return $this->findThreads($where, [$oneMonthAgo], 'ORDER BY threads.votes DESC, threads.lastUpdate DESC', $page, $user); } /** - * @return array> + * @return ThreadSummary[] */ public function findLatestRfcThreads(): array { - return $this->findThreads("WHERE threadInfos.subject LIKE '%RFC%'", 'ORDER BY threadInfos.date DESC', 1, null); + return $this->findThreads("WHERE threadInfos.subject LIKE '%RFC%'", [], 'ORDER BY threadInfos.date DESC', 1, null); } /** @@ -94,9 +96,13 @@ public function getThreadView(Email $email, ?User $user = null): array } /** - * @return array> + * @return ThreadSummary[] */ - private function findThreads(string $where, string $orderBy, int $page, ?User $user): array + /** + * @param list $whereBindings Bindings for any `?` placeholders in $where. + * @return ThreadSummary[] + */ + private function findThreads(string $where, array $whereBindings, string $orderBy, int $page, ?User $user): array { $page = max(1, $page); $offset = ($page - 1) * 20; @@ -121,7 +127,7 @@ private function findThreads(string $where, string $orderBy, int $page, ?User $u $orderBy LIMIT 20 OFFSET $offset SQL; - $parameters = [$user->id, $user->id]; + $parameters = [$user->id, $user->id, ...$whereBindings]; } else { $query = << (array) $row, DB::select($query, $parameters)); + return array_map(ThreadSummary::fromRow(...), DB::select($query, $parameters)); } } diff --git a/app/Services/Rss/RssRfcBuilder.php b/app/Services/Rss/RssRfcBuilder.php index ba80b5d..a13b140 100644 --- a/app/Services/Rss/RssRfcBuilder.php +++ b/app/Services/Rss/RssRfcBuilder.php @@ -4,7 +4,7 @@ namespace App\Services\Rss; -use DateTimeImmutable; +use App\Support\Email\ThreadSummary; use DomDocument; use DomElement; use RuntimeException; @@ -18,7 +18,7 @@ public function __construct( ) {} /** - * @param array> $threads + * @param ThreadSummary[] $threads */ public function build(array $threads): string { @@ -40,11 +40,11 @@ public function build(array $threads): string foreach ($threads as $thread) { $item = $this->dom->createElement('item'); - $this->addTextNode('title', $thread['subject'], $item); - $this->addTextNode('link', $this->host . '/message/' . $thread['number'], $item); - $this->addTextNode('description', $thread['subject'], $item); - $this->addTextNode('guid', (string) $thread['number'], $item); - $this->addTextNode('pubDate', (new DateTimeImmutable($thread['date']))->format('r'), $item); + $this->addTextNode('title', $thread->subject, $item); + $this->addTextNode('link', $this->host . '/message/' . $thread->number, $item); + $this->addTextNode('description', $thread->subject, $item); + $this->addTextNode('guid', (string) $thread->number, $item); + $this->addTextNode('pubDate', $thread->date->format('r'), $item); $channel->appendChild($item); } diff --git a/app/Support/Email/ThreadSummary.php b/app/Support/Email/ThreadSummary.php new file mode 100644 index 0000000..cf97cca --- /dev/null +++ b/app/Support/Email/ThreadSummary.php @@ -0,0 +1,46 @@ +number, + subject: (string) $row->subject, + date: CarbonImmutable::parse($row->date), + fromName: $row->fromName !== null ? (string) $row->fromName : null, + fromEmail: isset($row->fromEmail) ? (string) $row->fromEmail : null, + emailCount: (int) $row->emailCount, + lastUpdate: CarbonImmutable::parse($row->lastUpdate), + votes: (int) $row->votes, + isRead: (bool) $row->isRead, + userVote: $row->userVote !== null ? (int) $row->userVote : null, + ); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..7d50f93 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,6 @@ =0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4890,9 +5036,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "riverline/multipart-parser", @@ -5215,16 +5361,16 @@ }, { "name": "symfony/console", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "shasum": "" }, "require": { @@ -5291,7 +5437,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.0" + "source": "https://github.com/symfony/console/tree/v8.1.1" }, "funding": [ { @@ -5311,7 +5457,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/css-selector", @@ -5384,16 +5530,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -5431,7 +5577,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5451,7 +5597,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", @@ -5536,16 +5682,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { @@ -5598,7 +5744,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.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -5618,20 +5764,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -5678,7 +5824,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5698,7 +5844,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -5773,16 +5919,16 @@ }, { "name": "symfony/finder", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "58d2e767a66052c1487356f953445634a8194c64" + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", - "reference": "58d2e767a66052c1487356f953445634a8194c64", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", "shasum": "" }, "require": { @@ -5817,7 +5963,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.0" + "source": "https://github.com/symfony/finder/tree/v8.1.1" }, "funding": [ { @@ -5837,20 +5983,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-27T09:05:56+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" + "reference": "6a168c8fcee806b57ac020244da14293d1f9a883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883", + "reference": "6a168c8fcee806b57ac020244da14293d1f9a883", "shasum": "" }, "require": { @@ -5898,7 +6044,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.1" }, "funding": [ { @@ -5918,20 +6064,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-12T08:43:41+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" + "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", - "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2", + "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2", "shasum": "" }, "require": { @@ -6007,7 +6153,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/v8.1.0" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.1" }, "funding": [ { @@ -6027,20 +6173,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T08:46:08+00:00" + "time": "2026-06-27T09:27:36+00:00" }, { "name": "symfony/mailer", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "9418d772df3a03a142e3bc06f602adb2b8724877" + "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/9418d772df3a03a142e3bc06f602adb2b8724877", - "reference": "9418d772df3a03a142e3bc06f602adb2b8724877", + "url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12", + "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12", "shasum": "" }, "require": { @@ -6087,7 +6233,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.0" + "source": "https://github.com/symfony/mailer/tree/v8.1.1" }, "funding": [ { @@ -6107,7 +6253,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/mime", @@ -7478,16 +7624,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -7541,7 +7687,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -7561,7 +7707,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", @@ -7655,16 +7801,16 @@ }, { "name": "symfony/translation", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", "shasum": "" }, "require": { @@ -7724,7 +7870,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.0" + "source": "https://github.com/symfony/translation/tree/v8.1.1" }, "funding": [ { @@ -7744,20 +7890,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -7806,7 +7952,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -7826,7 +7972,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", @@ -7908,16 +8054,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" + "reference": "40096a2515a979f3125c5c928603995b8664c62a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a", + "reference": "40096a2515a979f3125c5c928603995b8664c62a", "shasum": "" }, "require": { @@ -7971,7 +8117,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.1" }, "funding": [ { @@ -7991,7 +8137,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-09T10:54:51+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8476,45 +8622,56 @@ ], "packages-dev": [ { - "name": "fakerphp/faker", - "version": "v1.24.1", + "name": "brianium/paratest", + "version": "v7.20.0", "source": { "type": "git", - "url": "https://github.com/FakerPHP/Faker.git", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + "url": "https://github.com/paratestphp/paratest.git", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "psr/container": "^1.0 || ^2.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "conflict": { - "fzaninotto/faker": "*" + "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.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", - "doctrine/persistence": "^1.3 || ^2.0", - "ext-intl": "*", - "phpunit/phpunit": "^9.5.26", - "symfony/phpunit-bridge": "^5.4.16" - }, - "suggest": { - "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", - "ext-curl": "Required by Faker\\Provider\\Image to download images.", - "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", - "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", - "ext-mbstring": "Required for multibyte Unicode string functionality." + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], "type": "library", "autoload": { "psr-4": { - "Faker\\": "src/Faker/" + "ParaTest\\": [ + "src/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -8523,57 +8680,79 @@ ], "authors": [ { - "name": "François Zaninotto" + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" } ], - "description": "Faker is a PHP library that generates fake data for you.", + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", "keywords": [ - "data", - "faker", - "fixtures" + "concurrent", + "parallel", + "phpunit", + "testing" ], "support": { - "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, - "time": "2024-11-21T13:46:39+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-03-29T15:46:14+00:00" }, { - "name": "filp/whoops", - "version": "2.18.4", + "name": "composer/pcre", + "version": "3.3.2", "source": { "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "psr/log": "^1.0.1 || ^2.0 || ^3.0" + "php": "^7.4 || ^8.0" }, - "require-dev": { - "mockery/mockery": "^1.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^4.0 || ^5.0" + "conflict": { + "phpstan/phpstan": "<1.11.10" }, - "suggest": { - "symfony/var-dumper": "Pretty print complex values better with var-dumper available", - "whoops/soap": "Formats errors as SOAP responses" + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, "branch-alias": { - "dev-master": "2.7-dev" + "dev-main": "3.x-dev" } }, "autoload": { "psr-4": { - "Whoops\\": "src/Whoops/" + "Composer\\Pcre\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -8582,180 +8761,870 @@ ], "authors": [ { - "name": "Filipe Dobreira", - "homepage": "https://github.com/filp", - "role": "Developer" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "php error handling for cool kids", - "homepage": "https://filp.github.io/whoops/", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", "keywords": [ - "error", - "exception", - "handling", - "library", - "throwable", - "whoops" + "PCRE", + "preg", + "regex", + "regular expression" ], "support": { - "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.4" + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { - "url": "https://github.com/denis-sokolov", + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "time": "2025-08-08T12:00:00+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { - "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": "^7.4|^8.0" - }, - "replace": { - "cordoval/hamcrest-php": "*", - "davedevelopment/hamcrest-php": "*", - "kodova/hamcrest-php": "*" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.1-dev" - } - }, "autoload": { - "classmap": [ - "hamcrest" - ] + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "description": "This is the PHP port of Hamcrest Matchers", + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", "keywords": [ - "test" + "Xdebug", + "performance" ], "support": { - "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, - "time": "2025-04-30T06:54:44+00:00" - }, - { - "name": "iamcal/sql-parser", - "version": "v0.7", + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+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", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "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", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2970f83398154178a739609c244577267c7ee8eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.2.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-05-28T08:00:58+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "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|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "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|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", "source": { "type": "git", - "url": "https://github.com/iamcal/SQLParser.git", - "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", - "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, "require-dev": { - "php-coveralls/php-coveralls": "^1.0", - "phpunit/phpunit": "^5|^6|^7|^8|^9" + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], "psr-4": { - "iamcal\\": "src" + "DeepCopy\\": "src/DeepCopy/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Cal Henderson", - "email": "cal@iamcal.com" - } + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" ], - "description": "MySQL schema parser", "support": { - "issues": "https://github.com/iamcal/SQLParser/issues", - "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, - "time": "2026-01-28T22:20:33+00:00" + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" }, { - "name": "larastan/larastan", - "version": "v3.10.0", + "name": "nunomaduro/collision", + "version": "v8.9.4", "source": { "type": "git", - "url": "https://github.com/larastan/larastan.git", - "reference": "2970f83398154178a739609c244577267c7ee8eb" + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", - "reference": "2970f83398154178a739609c244577267c7ee8eb", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { - "ext-json": "*", - "iamcal/sql-parser": "^0.7.0", - "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", - "php": "^8.2", - "phpstan/phpstan": "^2.2.0" + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" }, - "require-dev": { - "doctrine/coding-standard": "^14", - "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", - "mockery/mockery": "^1.6.12", - "nikic/php-parser": "^5.4", - "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", - "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", - "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, - "suggest": { - "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", - "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, - "type": "phpstan-extension", + "type": "library", "extra": { - "phpstan": { - "includes": [ - "extension.neon" + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] }, "branch-alias": { - "dev-master": "3.0-dev" + "dev-8.x": "8.x-dev" } }, "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], "psr-4": { - "Larastan\\Larastan\\": "src/" + "NunoMaduro\\Collision\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -8764,82 +9633,126 @@ ], "authors": [ { - "name": "Can Vural", - "email": "can9119@gmail.com" + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } ], - "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "description": "Cli error handling for console/command-line PHP applications.", "keywords": [ - "PHPStan", - "code analyse", - "code analysis", - "larastan", + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", "laravel", - "package", + "laravel-zero", "php", - "static analysis" + "symfony" ], "support": { - "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.10.0" + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" }, "funding": [ { - "url": "https://github.com/canvural", + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" } ], - "time": "2026-05-28T08:00:58+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { - "name": "laravel/pail", - "version": "v1.2.7", + "name": "pestphp/pest", + "version": "v4.7.4", "source": { "type": "git", - "url": "https://github.com/laravel/pail.git", - "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + "url": "https://github.com/pestphp/pest.git", + "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", - "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "url": "https://api.github.com/repos/pestphp/pest/zipball/ee2e97e932d158faceeaa63a4dc17324b15152cb", + "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb", "shasum": "" }, "require": { - "ext-mbstring": "*", - "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|^8.0" + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", + "pestphp/pest-plugin-mutate": "^4.0.1", + "pestphp/pest-plugin-profanity": "^4.2.1", + "php": "^8.3.0", + "phpunit/phpunit": "^12.5.30", + "symfony/process": "^7.4.13|^8.1.0" + }, + "conflict": { + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.5.30", + "sebastian/exporter": "<7.0.0", + "webmozart/assert": "<1.11.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0|^13.0", - "laravel/pint": "^1.13", - "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|^8.0", - "symfony/yaml": "^6.3|^7.0|^8.0" + "mrpunyapal/peststan": "^0.2.10", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.23" }, + "bin": [ + "bin/pest" + ], "type": "library", "extra": { - "laravel": { - "providers": [ - "Laravel\\Pail\\PailServiceProvider" + "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\\Tia", + "Pest\\Plugins\\Parallel" ] }, - "branch-alias": { - "dev-main": "1.x-dev" + "phpstan": { + "includes": [ + "extension.neon" + ] } }, "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], "psr-4": { - "Laravel\\Pail\\": "src/" + "Pest\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -8847,336 +9760,381 @@ "MIT" ], "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, { "name": "Nuno Maduro", "email": "enunomaduro@gmail.com" } ], - "description": "Easily delve into your Laravel application's log files directly from the command line.", - "homepage": "https://github.com/laravel/pail", + "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.7.4" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-06-25T19:09:05+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": [ - "dev", - "laravel", - "logs", + "framework", + "manager", + "pest", "php", - "tail" + "plugin", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/laravel/pail/issues", - "source": "https://github.com/laravel/pail" + "source": "https://github.com/pestphp/pest-plugin/tree/v4.0.0" }, - "time": "2026-05-20T22:24:57+00:00" + "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": "laravel/pint", - "version": "v1.29.1", + "name": "pestphp/pest-plugin-arch", + "version": "v4.0.2", "source": { "type": "git", - "url": "https://github.com/laravel/pint.git", - "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", - "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "ext-tokenizer": "*", - "ext-xml": "*", - "php": "^8.2.0" + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95.1", - "illuminate/view": "^12.56.0", - "larastan/larastan": "^3.9.6", - "laravel-zero/framework": "^12.1.0", - "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.3" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } }, - "bin": [ - "builds/pint" - ], - "type": "project", "autoload": { + "files": [ + "src/Autoload.php" + ], "psr-4": { - "App\\": "app/", - "Database\\Seeders\\": "database/seeders/", - "Database\\Factories\\": "database/factories/" + "Pest\\Arch\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nuno Maduro", - "email": "enunomaduro@gmail.com" - } - ], - "description": "An opinionated code formatter for PHP.", - "homepage": "https://laravel.com", + "description": "The Arch plugin for Pest PHP.", "keywords": [ - "dev", - "format", - "formatter", - "lint", - "linter", - "php" + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/laravel/pint/issues", - "source": "https://github.com/laravel/pint" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, - "time": "2026-04-20T15:26:14+00:00" + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-04-10T17:20:19+00:00" }, { - "name": "mockery/mockery", - "version": "1.6.12", + "name": "pestphp/pest-plugin-laravel", + "version": "v4.1.0", "source": { "type": "git", - "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/3057a36669ff11416cc0dc2b521b3aec58c488d0", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", - "php": ">=7.3" - }, - "conflict": { - "phpunit/phpunit": "<8.0" + "laravel/framework": "^11.45.2|^12.52.0|^13.0", + "pestphp/pest": "^4.4.1", + "php": "^8.3.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" + "laravel/dusk": "^8.3.6", + "orchestra/testbench": "^9.13.0|^10.9.0|^11.0", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, "autoload": { "files": [ - "library/helpers.php", - "library/Mockery.php" + "src/Autoload.php" ], "psr-4": { - "Mockery\\": "library/Mockery" + "Pest\\Laravel\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Pádraic Brady", - "email": "padraic.brady@gmail.com", - "homepage": "https://github.com/padraic", - "role": "Author" - }, - { - "name": "Dave Marshall", - "email": "dave.marshall@atstsolutions.co.uk", - "homepage": "https://davedevelopment.co.uk", - "role": "Developer" - }, - { - "name": "Nathanael Esayeas", - "email": "nathanael.esayeas@protonmail.com", - "homepage": "https://github.com/ghostwriter", - "role": "Lead Developer" - } + "MIT" ], - "description": "Mockery is a simple yet flexible PHP mock object framework", - "homepage": "https://github.com/mockery/mockery", + "description": "The Pest Laravel Plugin", "keywords": [ - "BDD", - "TDD", - "library", - "mock", - "mock objects", - "mockery", - "stub", + "framework", + "laravel", + "pest", + "php", "test", - "test double", - "testing" + "testing", + "unit" ], "support": { - "docs": "https://docs.mockery.io/", - "issues": "https://github.com/mockery/mockery/issues", - "rss": "https://github.com/mockery/mockery/releases.atom", - "security": "https://github.com/mockery/mockery/security/advisories", - "source": "https://github.com/mockery/mockery" + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.1.0" }, - "time": "2024-05-16T03:13:13+00:00" + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-02-21T00:29:45+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.4", + "name": "pestphp/pest-plugin-mutate", + "version": "v4.0.1", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "nikic/php-parser": "^5.6.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "psr/simple-cache": "^3.0.0" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" }, "type": "library", "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Pest\\Mutate\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "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-01T08:46:24+00:00" + "time": "2025-08-21T20:19:25+00:00" }, { - "name": "nunomaduro/collision", - "version": "v8.9.4", + "name": "pestphp/pest-plugin-profanity", + "version": "v4.2.1", "source": { "type": "git", - "url": "https://github.com/nunomaduro/collision.git", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + "url": "https://github.com/pestphp/pest-plugin-profanity.git", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", "shasum": "" }, "require": { - "filp/whoops": "^2.18.4", - "nunomaduro/termwind": "^2.4.0", - "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.8" - }, - "conflict": { - "laravel/framework": "<11.48.0 || >=14.0.0", - "phpunit/phpunit": "<11.5.50 || >=14.0.0" + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" }, "require-dev": { - "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.6", - "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", - "laravel/pint": "^1.29.1", - "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", - "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "library", "extra": { - "laravel": { - "providers": [ - "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" ] - }, - "branch-alias": { - "dev-8.x": "8.x-dev" } }, "autoload": { - "files": [ - "./src/Adapters/Phpunit/Autoload.php" - ], "psr-4": { - "NunoMaduro\\Collision\\": "src/" + "Pest\\Profanity\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nuno Maduro", - "email": "enunomaduro@gmail.com" - } - ], - "description": "Cli error handling for console/command-line PHP applications.", + "description": "The Pest Profanity Plugin", "keywords": [ - "artisan", - "cli", - "command-line", - "console", - "dev", - "error", - "handling", - "laravel", - "laravel-zero", + "framework", + "pest", "php", - "symfony" + "plugin", + "profanity", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/nunomaduro/collision/issues", - "source": "https://github.com/nunomaduro/collision" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" }, - "funding": [ - { - "url": "https://www.paypal.com/paypalme/enunomaduro", - "type": "custom" - }, - { - "url": "https://github.com/nunomaduro", - "type": "github" - }, - { - "url": "https://www.patreon.com/nunomaduro", - "type": "patreon" - } - ], - "time": "2026-04-21T14:04:20+00:00" + "time": "2025-12-08T00:13:17+00:00" }, { "name": "phar-io/manifest", @@ -9296,6 +10254,229 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "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": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^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", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "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/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.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/2.0.0" + }, + "time": "2026-01-06T21:53:42+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": "phpstan/phpstan", "version": "2.2.1", @@ -9707,16 +10888,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.28", + "version": "12.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4" + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4", - "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", "shasum": "" }, "require": { @@ -9730,7 +10911,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-code-coverage": "^12.5.7", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -9740,7 +10921,7 @@ "sebastian/diff": "^7.0.0", "sebastian/environment": "^8.1.2", "sebastian/exporter": "^7.0.3", - "sebastian/global-state": "^8.0.2", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.4", @@ -9785,7 +10966,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" }, "funding": [ { @@ -9793,7 +10974,7 @@ "type": "other" } ], - "time": "2026-05-27T14:01:10+00:00" + "time": "2026-06-15T13:12:30+00:00" }, { "name": "sebastian/cli-parser", @@ -10249,26 +11430,26 @@ }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -10299,7 +11480,7 @@ "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/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -10319,7 +11500,7 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", @@ -10756,6 +11937,65 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "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 || ^13.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.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, { "name": "theseer/tokenizer", "version": "2.0.1", @@ -10805,6 +12045,72 @@ } ], "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "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.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], diff --git a/config/app.php b/config/app.php deleted file mode 100644 index 978839a..0000000 --- a/config/app.php +++ /dev/null @@ -1,126 +0,0 @@ - env('APP_NAME', 'Laravel'), - - /* - |-------------------------------------------------------------------------- - | Application Environment - |-------------------------------------------------------------------------- - | - | This value determines the "environment" your application is currently - | running in. This may determine how you prefer to configure various - | services the application utilizes. Set this in your ".env" file. - | - */ - - 'env' => env('APP_ENV', 'production'), - - /* - |-------------------------------------------------------------------------- - | Application Debug Mode - |-------------------------------------------------------------------------- - | - | When your application is in debug mode, detailed error messages with - | stack traces will be shown on every error that occurs within your - | application. If disabled, a simple generic error page is shown. - | - */ - - 'debug' => (bool) env('APP_DEBUG', false), - - /* - |-------------------------------------------------------------------------- - | Application URL - |-------------------------------------------------------------------------- - | - | This URL is used by the console to properly generate URLs when using - | the Artisan command line tool. You should set this to the root of - | the application so that it's available within Artisan commands. - | - */ - - 'url' => env('APP_URL', 'http://localhost'), - - /* - |-------------------------------------------------------------------------- - | Application Timezone - |-------------------------------------------------------------------------- - | - | Here you may specify the default timezone for your application, which - | will be used by the PHP date and date-time functions. The timezone - | is set to "UTC" by default as it is suitable for most use cases. - | - */ - - 'timezone' => 'UTC', - - /* - |-------------------------------------------------------------------------- - | Application Locale Configuration - |-------------------------------------------------------------------------- - | - | The application locale determines the default locale that will be used - | by Laravel's translation / localization methods. This option can be - | set to any locale for which you plan to have translation strings. - | - */ - - 'locale' => env('APP_LOCALE', 'en'), - - 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), - - 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), - - /* - |-------------------------------------------------------------------------- - | Encryption Key - |-------------------------------------------------------------------------- - | - | This key is utilized by Laravel's encryption services and should be set - | to a random, 32 character string to ensure that all encrypted values - | are secure. You should do this prior to deploying the application. - | - */ - - 'cipher' => 'AES-256-CBC', - - 'key' => env('APP_KEY'), - - 'previous_keys' => [ - ...array_filter( - explode(',', (string) env('APP_PREVIOUS_KEYS', '')), - ), - ], - - /* - |-------------------------------------------------------------------------- - | Maintenance Mode Driver - |-------------------------------------------------------------------------- - | - | These configuration options determine the driver used to determine and - | manage Laravel's "maintenance mode" status. The "cache" driver will - | allow maintenance mode to be controlled across multiple machines. - | - | Supported drivers: "file", "cache" - | - */ - - 'maintenance' => [ - 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), - 'store' => env('APP_MAINTENANCE_STORE', 'database'), - ], - -]; diff --git a/config/auth.php b/config/auth.php deleted file mode 100644 index 76ddf87..0000000 --- a/config/auth.php +++ /dev/null @@ -1,117 +0,0 @@ - [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/config/cache.php b/config/cache.php deleted file mode 100644 index 97a5cdd..0000000 --- a/config/cache.php +++ /dev/null @@ -1,130 +0,0 @@ - env('CACHE_STORE', 'database'), - - /* - |-------------------------------------------------------------------------- - | Cache Stores - |-------------------------------------------------------------------------- - | - | Here you may define all of the cache "stores" for your application as - | well as their drivers. You may even define multiple stores for the - | same cache driver to group types of items stored in your caches. - | - | Supported drivers: "array", "database", "file", "memcached", - | "redis", "dynamodb", "octane", - | "failover", "null" - | - */ - - 'stores' => [ - - 'array' => [ - 'driver' => 'array', - 'serialize' => false, - ], - - 'database' => [ - 'driver' => 'database', - 'connection' => env('DB_CACHE_CONNECTION'), - 'table' => env('DB_CACHE_TABLE', 'cache'), - 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), - 'lock_table' => env('DB_CACHE_LOCK_TABLE'), - ], - - 'file' => [ - 'driver' => 'file', - 'path' => storage_path('framework/cache/data'), - 'lock_path' => storage_path('framework/cache/data'), - ], - - 'memcached' => [ - 'driver' => 'memcached', - 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), - 'sasl' => [ - env('MEMCACHED_USERNAME'), - env('MEMCACHED_PASSWORD'), - ], - 'options' => [ - // Memcached::OPT_CONNECT_TIMEOUT => 2000, - ], - 'servers' => [ - [ - 'host' => env('MEMCACHED_HOST', '127.0.0.1'), - 'port' => env('MEMCACHED_PORT', 11211), - 'weight' => 100, - ], - ], - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), - 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), - ], - - 'dynamodb' => [ - 'driver' => 'dynamodb', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), - 'endpoint' => env('DYNAMODB_ENDPOINT'), - ], - - 'octane' => [ - 'driver' => 'octane', - ], - - 'failover' => [ - 'driver' => 'failover', - 'stores' => [ - 'database', - 'array', - ], - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Cache Key Prefix - |-------------------------------------------------------------------------- - | - | When utilizing the APC, database, memcached, Redis, and DynamoDB cache - | stores, there might be other applications using the same cache. For - | that reason, you may prefix every cache key to avoid collisions. - | - */ - - 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'), - - /* - |-------------------------------------------------------------------------- - | Serializable Classes - |-------------------------------------------------------------------------- - | - | This value determines the classes that can be unserialized from cache - | storage. By default, no PHP classes will be unserialized from your - | cache to prevent gadget chain attacks if your APP_KEY is leaked. - | - */ - - 'serializable_classes' => false, - -]; diff --git a/config/database.php b/config/database.php deleted file mode 100644 index 70e21b4..0000000 --- a/config/database.php +++ /dev/null @@ -1,184 +0,0 @@ - env('DB_CONNECTION', 'sqlite'), - - /* - |-------------------------------------------------------------------------- - | Database Connections - |-------------------------------------------------------------------------- - | - | Below are all of the database connections defined for your application. - | An example configuration is provided for each database system which - | is supported by Laravel. You're free to add / remove connections. - | - */ - - 'connections' => [ - - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, - 'transaction_mode' => 'DEFERRED', - ], - - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'mariadb' => [ - 'driver' => 'mariadb', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => env('DB_SSLMODE', 'prefer'), - ], - - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - // 'encrypt' => env('DB_ENCRYPT', 'yes'), - // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Migration Repository Table - |-------------------------------------------------------------------------- - | - | This table keeps track of all the migrations that have already run for - | your application. Using this information, we can determine which of - | the migrations on disk haven't actually been run on the database. - | - */ - - 'migrations' => [ - 'table' => 'migrations', - 'update_date_on_publish' => true, - ], - - /* - |-------------------------------------------------------------------------- - | Redis Databases - |-------------------------------------------------------------------------- - | - | Redis is an open source, fast, and advanced key-value store that also - | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. - | - */ - - 'redis' => [ - - 'client' => env('REDIS_CLIENT', 'phpredis'), - - 'options' => [ - 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'), - 'persistent' => env('REDIS_PERSISTENT', false), - ], - - 'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_DB', '0'), - 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), - ], - - 'cache' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_CACHE_DB', '1'), - 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), - ], - - ], - -]; diff --git a/config/filesystems.php b/config/filesystems.php deleted file mode 100644 index 973a0b0..0000000 --- a/config/filesystems.php +++ /dev/null @@ -1,80 +0,0 @@ - env('FILESYSTEM_DISK', 'local'), - - /* - |-------------------------------------------------------------------------- - | Filesystem Disks - |-------------------------------------------------------------------------- - | - | Below you may configure as many filesystem disks as necessary, and you - | may even configure multiple disks for the same driver. Examples for - | most supported storage drivers are configured here for reference. - | - | Supported drivers: "local", "ftp", "sftp", "s3" - | - */ - - 'disks' => [ - - 'local' => [ - 'driver' => 'local', - 'root' => storage_path('app/private'), - 'serve' => true, - 'throw' => false, - 'report' => false, - ], - - 'public' => [ - 'driver' => 'local', - 'root' => storage_path('app/public'), - 'url' => mb_rtrim(env('APP_URL', 'http://localhost'), '/') . '/storage', - 'visibility' => 'public', - 'throw' => false, - 'report' => false, - ], - - 's3' => [ - 'driver' => 's3', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION'), - 'bucket' => env('AWS_BUCKET'), - 'url' => env('AWS_URL'), - 'endpoint' => env('AWS_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), - 'throw' => false, - 'report' => false, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Symbolic Links - |-------------------------------------------------------------------------- - | - | Here you may configure the symbolic links that will be created when the - | `storage:link` Artisan command is executed. The array keys should be - | the locations of the links and the values should be their targets. - | - */ - - 'links' => [ - public_path('storage') => storage_path('app/public'), - ], - -]; diff --git a/config/logging.php b/config/logging.php deleted file mode 100644 index e4ff8f3..0000000 --- a/config/logging.php +++ /dev/null @@ -1,132 +0,0 @@ - env('LOG_CHANNEL', 'stack'), - - /* - |-------------------------------------------------------------------------- - | Deprecations Log Channel - |-------------------------------------------------------------------------- - | - | This option controls the log channel that should be used to log warnings - | regarding deprecated PHP and library features. This allows you to get - | your application ready for upcoming major versions of dependencies. - | - */ - - 'deprecations' => [ - 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => env('LOG_DEPRECATIONS_TRACE', false), - ], - - /* - |-------------------------------------------------------------------------- - | Log Channels - |-------------------------------------------------------------------------- - | - | Here you may configure the log channels for your application. Laravel - | utilizes the Monolog PHP logging library, which includes a variety - | of powerful log handlers and formatters that you're free to use. - | - | Available drivers: "single", "daily", "slack", "syslog", - | "errorlog", "monolog", "custom", "stack" - | - */ - - 'channels' => [ - - 'stack' => [ - 'driver' => 'stack', - 'channels' => explode(',', (string) env('LOG_STACK', 'single')), - 'ignore_exceptions' => false, - ], - - 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'replace_placeholders' => true, - ], - - 'daily' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'days' => env('LOG_DAILY_DAYS', 14), - 'replace_placeholders' => true, - ], - - 'slack' => [ - 'driver' => 'slack', - 'url' => env('LOG_SLACK_WEBHOOK_URL'), - 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), - 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), - 'level' => env('LOG_LEVEL', 'critical'), - 'replace_placeholders' => true, - ], - - 'papertrail' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), - 'handler_with' => [ - 'host' => env('PAPERTRAIL_URL'), - 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), - ], - 'processors' => [PsrLogMessageProcessor::class], - ], - - 'stderr' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => StreamHandler::class, - 'handler_with' => [ - 'stream' => 'php://stderr', - ], - 'formatter' => env('LOG_STDERR_FORMATTER'), - 'processors' => [PsrLogMessageProcessor::class], - ], - - 'syslog' => [ - 'driver' => 'syslog', - 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), - 'replace_placeholders' => true, - ], - - 'errorlog' => [ - 'driver' => 'errorlog', - 'level' => env('LOG_LEVEL', 'debug'), - 'replace_placeholders' => true, - ], - - 'null' => [ - 'driver' => 'monolog', - 'handler' => NullHandler::class, - ], - - 'emergency' => [ - 'path' => storage_path('logs/laravel.log'), - ], - - ], - -]; diff --git a/config/mail.php b/config/mail.php deleted file mode 100644 index 2ae648a..0000000 --- a/config/mail.php +++ /dev/null @@ -1,118 +0,0 @@ - env('MAIL_MAILER', 'log'), - - /* - |-------------------------------------------------------------------------- - | Mailer Configurations - |-------------------------------------------------------------------------- - | - | Here you may configure all of the mailers used by your application plus - | their respective settings. Several examples have been configured for - | you and you are free to add your own as your application requires. - | - | Laravel supports a variety of mail "transport" drivers that can be used - | when delivering an email. You may specify which one you're using for - | your mailers below. You may also add additional mailers if needed. - | - | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", - | "postmark", "resend", "log", "array", - | "failover", "roundrobin" - | - */ - - 'mailers' => [ - - 'smtp' => [ - 'transport' => 'smtp', - 'scheme' => env('MAIL_SCHEME'), - 'url' => env('MAIL_URL'), - 'host' => env('MAIL_HOST', '127.0.0.1'), - 'port' => env('MAIL_PORT', 2525), - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), - ], - - 'ses' => [ - 'transport' => 'ses', - ], - - 'postmark' => [ - 'transport' => 'postmark', - // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), - // 'client' => [ - // 'timeout' => 5, - // ], - ], - - 'resend' => [ - 'transport' => 'resend', - ], - - 'sendmail' => [ - 'transport' => 'sendmail', - 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), - ], - - 'log' => [ - 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), - ], - - 'array' => [ - 'transport' => 'array', - ], - - 'failover' => [ - 'transport' => 'failover', - 'mailers' => [ - 'smtp', - 'log', - ], - 'retry_after' => 60, - ], - - 'roundrobin' => [ - 'transport' => 'roundrobin', - 'mailers' => [ - 'ses', - 'postmark', - ], - 'retry_after' => 60, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Global "From" Address - |-------------------------------------------------------------------------- - | - | You may wish for all emails sent by your application to be sent from - | the same address. Here you may specify a name and address that is - | used globally for all emails that are sent by your application. - | - */ - - 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), - ], - -]; diff --git a/config/queue.php b/config/queue.php deleted file mode 100644 index d0773e8..0000000 --- a/config/queue.php +++ /dev/null @@ -1,129 +0,0 @@ - env('QUEUE_CONNECTION', 'database'), - - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection options for every queue backend - | used by your application. An example configuration is provided for - | each backend supported by Laravel. You're also free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", - | "deferred", "background", "failover", "null" - | - */ - - 'connections' => [ - - 'sync' => [ - 'driver' => 'sync', - ], - - 'database' => [ - 'driver' => 'database', - 'connection' => env('DB_QUEUE_CONNECTION'), - 'table' => env('DB_QUEUE_TABLE', 'jobs'), - 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), - 'after_commit' => false, - ], - - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), - 'queue' => env('BEANSTALKD_QUEUE', 'default'), - 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), - 'block_for' => 0, - 'after_commit' => false, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), - 'block_for' => null, - 'after_commit' => false, - ], - - 'deferred' => [ - 'driver' => 'deferred', - ], - - 'background' => [ - 'driver' => 'background', - ], - - 'failover' => [ - 'driver' => 'failover', - 'connections' => [ - 'database', - 'deferred', - ], - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Job Batching - |-------------------------------------------------------------------------- - | - | The following options configure the database and table that store job - | batching information. These options can be updated to any database - | connection and table which has been defined by your application. - | - */ - - 'batching' => [ - 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'job_batches', - ], - - /* - |-------------------------------------------------------------------------- - | Failed Queue Jobs - |-------------------------------------------------------------------------- - | - | These options configure the behavior of failed queue job logging so you - | can control how and where failed jobs are stored. Laravel ships with - | support for storing failed jobs in a simple file or in a database. - | - | Supported drivers: "database-uuids", "dynamodb", "file", "null" - | - */ - - 'failed' => [ - 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'failed_jobs', - ], - -]; diff --git a/config/session.php b/config/session.php deleted file mode 100644 index dfc6a51..0000000 --- a/config/session.php +++ /dev/null @@ -1,233 +0,0 @@ - env('SESSION_DRIVER', 'database'), - - /* - |-------------------------------------------------------------------------- - | Session Lifetime - |-------------------------------------------------------------------------- - | - | Here you may specify the number of minutes that you wish the session - | to be allowed to remain idle before it expires. If you want them - | to expire immediately when the browser is closed then you may - | indicate that via the expire_on_close configuration option. - | - */ - - 'lifetime' => (int) env('SESSION_LIFETIME', 120), - - 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), - - /* - |-------------------------------------------------------------------------- - | Session Encryption - |-------------------------------------------------------------------------- - | - | This option allows you to easily specify that all of your session data - | should be encrypted before it's stored. All encryption is performed - | automatically by Laravel and you may use the session like normal. - | - */ - - 'encrypt' => env('SESSION_ENCRYPT', false), - - /* - |-------------------------------------------------------------------------- - | Session File Location - |-------------------------------------------------------------------------- - | - | When utilizing the "file" session driver, the session files are placed - | on disk. The default storage location is defined here; however, you - | are free to provide another location where they should be stored. - | - */ - - 'files' => storage_path('framework/sessions'), - - /* - |-------------------------------------------------------------------------- - | Session Database Connection - |-------------------------------------------------------------------------- - | - | When using the "database" or "redis" session drivers, you may specify a - | connection that should be used to manage these sessions. This should - | correspond to a connection in your database configuration options. - | - */ - - 'connection' => env('SESSION_CONNECTION'), - - /* - |-------------------------------------------------------------------------- - | Session Database Table - |-------------------------------------------------------------------------- - | - | When using the "database" session driver, you may specify the table to - | be used to store sessions. Of course, a sensible default is defined - | for you; however, you're welcome to change this to another table. - | - */ - - 'table' => env('SESSION_TABLE', 'sessions'), - - /* - |-------------------------------------------------------------------------- - | Session Cache Store - |-------------------------------------------------------------------------- - | - | When using one of the framework's cache driven session backends, you may - | define the cache store which should be used to store the session data - | between requests. This must match one of your defined cache stores. - | - | Affects: "dynamodb", "memcached", "redis" - | - */ - - 'store' => env('SESSION_STORE'), - - /* - |-------------------------------------------------------------------------- - | Session Sweeping Lottery - |-------------------------------------------------------------------------- - | - | Some session drivers must manually sweep their storage location to get - | rid of old sessions from storage. Here are the chances that it will - | happen on a given request. By default, the odds are 2 out of 100. - | - */ - - 'lottery' => [2, 100], - - /* - |-------------------------------------------------------------------------- - | Session Cookie Name - |-------------------------------------------------------------------------- - | - | Here you may change the name of the session cookie that is created by - | the framework. Typically, you should not need to change this value - | since doing so does not grant a meaningful security improvement. - | - */ - - 'cookie' => env( - 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')) . '-session', - ), - - /* - |-------------------------------------------------------------------------- - | Session Cookie Path - |-------------------------------------------------------------------------- - | - | The session cookie path determines the path for which the cookie will - | be regarded as available. Typically, this will be the root path of - | your application, but you're free to change this when necessary. - | - */ - - 'path' => env('SESSION_PATH', '/'), - - /* - |-------------------------------------------------------------------------- - | Session Cookie Domain - |-------------------------------------------------------------------------- - | - | This value determines the domain and subdomains the session cookie is - | available to. By default, the cookie will be available to the root - | domain without subdomains. Typically, this shouldn't be changed. - | - */ - - 'domain' => env('SESSION_DOMAIN'), - - /* - |-------------------------------------------------------------------------- - | HTTPS Only Cookies - |-------------------------------------------------------------------------- - | - | By setting this option to true, session cookies will only be sent back - | to the server if the browser has a HTTPS connection. This will keep - | the cookie from being sent to you when it can't be done securely. - | - */ - - 'secure' => env('SESSION_SECURE_COOKIE'), - - /* - |-------------------------------------------------------------------------- - | HTTP Access Only - |-------------------------------------------------------------------------- - | - | Setting this value to true will prevent JavaScript from accessing the - | value of the cookie and the cookie will only be accessible through - | the HTTP protocol. It's unlikely you should disable this option. - | - */ - - 'http_only' => env('SESSION_HTTP_ONLY', true), - - /* - |-------------------------------------------------------------------------- - | Same-Site Cookies - |-------------------------------------------------------------------------- - | - | This option determines how your cookies behave when cross-site requests - | take place, and can be used to mitigate CSRF attacks. By default, we - | will set this value to "lax" to permit secure cross-site requests. - | - | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value - | - | Supported: "lax", "strict", "none", null - | - */ - - 'same_site' => env('SESSION_SAME_SITE', 'lax'), - - /* - |-------------------------------------------------------------------------- - | Partitioned Cookies - |-------------------------------------------------------------------------- - | - | Setting this value to true will tie the cookie to the top-level site for - | a cross-site context. Partitioned cookies are accepted by the browser - | when flagged "secure" and the Same-Site attribute is set to "none". - | - */ - - 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), - - /* - |-------------------------------------------------------------------------- - | Session Serialization - |-------------------------------------------------------------------------- - | - | This value controls the serialization strategy for session data, which - | is JSON by default. Setting this to "php" allows the storage of PHP - | objects in the session but can make an application vulnerable to - | "gadget chain" serialization attacks if the APP_KEY is leaked. - | - | Supported: "json", "php" - | - */ - - 'serialization' => 'json', - -]; diff --git a/package-lock.json b/package-lock.json index 6bb25ac..a2fbccb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,9 +4,6 @@ "requires": true, "packages": { "": { - "dependencies": { - "jquery": "^3.7.1" - }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "concurrently": "^9.2.3", @@ -3373,12 +3370,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.0.tgz", diff --git a/package.json b/package.json index 7f417aa..76140f7 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,6 @@ "build": "vite build", "dev": "vite" }, - "dependencies": { - "jquery": "^3.7.1" - }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "concurrently": "^9.2.3", diff --git a/resources/css/app.css b/resources/css/app.css index 8bb62d0..79786df 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,6 +8,9 @@ padding-inline: 1rem; } +/* Alpine: hide elements until Alpine initialises */ +[x-cloak] { display: none !important; } + /* https://stackoverflow.com/questions/71074/how-to-remove-firefoxs-dotted-outline-on-buttons-as-well-as-links */ :focus { outline: none; } ::-moz-focus-inner { border: 0; } diff --git a/resources/js/app.js b/resources/js/app.js index 9b64b40..b1de7f0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,44 +1,3 @@ -import jquery from 'jquery'; - -window.$ = window.jQuery = jquery; - -$(function () { - $('body') - .on('click', '.upvote', function (e) { - e.preventDefault(); - vote($(this), 1); - }) - .on('click', '.downvote', function (e) { - e.preventDefault(); - vote($(this), -1); - }); -}); - -function vote($voteLink, value) { - if (typeof userId === 'undefined') { - alert('You must log in to vote. To log in, click the "log in" link in the top-right corner.'); - return; - } - - var emailId = $voteLink.data('email-id'); - if ($voteLink.hasClass('active')) { - value = 0; - } - - $.post('/votes/' + emailId, { - userId: userId, - value: value, - }) - .done(function (data) { - $voteLink.parent().find('.vote-action').removeClass('active'); - if (data.newValue === 1) { - $voteLink.parent().find('.upvote').addClass('active'); - } else if (data.newValue === -1) { - $voteLink.parent().find('.downvote').addClass('active'); - } - $voteLink.parent().find('.vote-value').text(data.newTotal); - }) - .fail(function () { - alert('An error occurred'); - }); -} +// Livewire (v4) automatically injects its runtime and Alpine. +// Interactivity that used to live here (voting, email collapse, "next unread") +// is now handled by Livewire actions and Alpine directives in the components. diff --git a/resources/views/auth/login-error.blade.php b/resources/views/auth/login-error.blade.php index 928387e..42d0a2e 100644 --- a/resources/views/auth/login-error.blade.php +++ b/resources/views/auth/login-error.blade.php @@ -1,9 +1,5 @@ -@extends('layout') - -@section('content') - +

It seems there was an error while authenticating:

{{ $error }}
- -@endsection +
diff --git a/resources/views/layout.blade.php b/resources/views/components/layouts/app.blade.php similarity index 85% rename from resources/views/layout.blade.php rename to resources/views/components/layouts/app.blade.php index 1d92f06..c66110d 100644 --- a/resources/views/layout.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -1,3 +1,4 @@ +@php($defaultTitle = "#externals - Opening PHP's #internals to the outside") @@ -6,7 +7,7 @@ - @yield('pageTitle', "#externals - Opening PHP's #internals to the outside") + {{ $title ?? $defaultTitle }} @@ -19,7 +20,7 @@ - + @@ -28,15 +29,7 @@ - @isset($user) - @if($user) - - @endif - @endisset - - @if($noIndex) + @if(config('externals.no_index')) @endif @@ -52,10 +45,10 @@ Top
- @if(!empty($user)) - - {{ $user->name }} -
+ @auth + + {{ auth()->user()->name }} + @csrf
- @yield('content') + {{ $slot }}
@@ -88,7 +81,5 @@

- - @yield('scripts') diff --git a/resources/views/threads/thread-item.blade.php b/resources/views/components/thread-item.blade.php similarity index 87% rename from resources/views/threads/thread-item.blade.php rename to resources/views/components/thread-item.blade.php index 97bd8cc..feb1c2f 100644 --- a/resources/views/threads/thread-item.blade.php +++ b/resources/views/components/thread-item.blade.php @@ -1,10 +1,13 @@ +@props(['item']) + @php($email = $item->email)
-
-
+
@foreach($item->replies as $reply) - @include('threads.thread-item', ['item' => $reply]) + @endforeach
diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index 7406f98..45fffb0 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -1,11 +1,7 @@ -@extends('layout') - -@section('content') - +

😱

- -@endsection +
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php deleted file mode 100644 index 292ad06..0000000 --- a/resources/views/home.blade.php +++ /dev/null @@ -1,134 +0,0 @@ -@extends('layout') - -@section('content') - -
-

Opening PHP's #internals mailing list to the outside.

- -
- - - {{-- Search --}} -
- - Algolia -
-
-

Search results

-
    -
    - @verbatim - - - @endverbatim - -
    - -
    - @include('threads.thread-list') -
    - -
    - @if($page > 1) - - 1 - - @endif - @if($page > 5) -
    - ... -
    - @endif - @if($page > 2) - @for($i = max($page - 3, 2); $i <= $page - 1; $i++) - - {{ $i }} - - @endfor - @endif -
    - {{ $page }} -
    - @if($page < $pageCount) - @for($i = $page + 1; $i <= min($page + 4, $pageCount); $i++) - - {{ $i }} - - @endfor - @endif - @if($page + 4 < $pageCount) -
    - ... -
    - @endif -
    - -
    - -@endsection - -@section('scripts') - @parent - - - - - -@endsection diff --git a/resources/views/livewire/pages/home.blade.php b/resources/views/livewire/pages/home.blade.php new file mode 100644 index 0000000..95bcd73 --- /dev/null +++ b/resources/views/livewire/pages/home.blade.php @@ -0,0 +1,96 @@ + + +
    +
    +

    Opening PHP's #internals mailing list to the outside.

    + +
    + + + {{-- Search --}} +
    + + Algolia +
    +
    +

    Search results

    +
      +
      + @verbatim + + + @endverbatim + +
      + +
      + + @assets + + @endassets + + @script + + @endscript +
      diff --git a/resources/views/news.blade.php b/resources/views/livewire/pages/news.blade.php similarity index 96% rename from resources/views/news.blade.php rename to resources/views/livewire/pages/news.blade.php index 8e30b23..d4060b7 100644 --- a/resources/views/news.blade.php +++ b/resources/views/livewire/pages/news.blade.php @@ -1,7 +1,13 @@ -@extends('layout') + + +
      @@ -55,5 +61,4 @@

      The most interesting threads can be seen on the "Top threads" page.

      - -@endsection +
      diff --git a/resources/views/livewire/pages/stats.blade.php b/resources/views/livewire/pages/stats.blade.php new file mode 100644 index 0000000..8ef7b52 --- /dev/null +++ b/resources/views/livewire/pages/stats.blade.php @@ -0,0 +1,35 @@ + + */ + public function with(): array + { + $ttl = now()->addMinutes(5); + + return [ + 'userCount' => Cache::remember('stats.userCount', $ttl, fn() => User::count()), + 'threadCount' => Cache::remember('stats.threadCount', $ttl, fn() => Email::where('isThreadRoot', true)->count()), + 'emailCount' => Cache::remember('stats.emailCount', $ttl, fn() => Email::count()), + ]; + } +}; ?> + +
      +

      Stats!

      + +

      There are:

      + +
        +
      • {{ $userCount }} users
      • +
      • {{ $threadCount }} threads
      • +
      • {{ $emailCount }} emails
      • +
      +
      diff --git a/resources/views/livewire/pages/thread.blade.php b/resources/views/livewire/pages/thread.blade.php new file mode 100644 index 0000000..2153643 --- /dev/null +++ b/resources/views/livewire/pages/thread.blade.php @@ -0,0 +1,93 @@ +first(); + if (! $email) { + throw new NotFoundException("Email $number was not found"); + } + + if (! $email->isThreadRoot()) { + $root = Email::find($email->threadId); + if ($root) { + return $this->redirect("/message/{$root->number}#$number"); + } + // Root message not found — render the thread from this URL anyway + } + + $user = auth()->user(); + // Build the thread view BEFORE marking the thread as read + $this->thread = $threads->getThreadView($email, $user); + $this->number = $number; + $this->subject = $email->subject; + + if ($user) { + app(MarkEmailAsRead::class)->handle($email, $user); + } + + return null; + } + + public function rendering(View $view): void + { + $view->title($this->subject . ' - Externals'); + } + + /** + * @return array + */ + public function with(): array + { + return ['thread' => $this->thread]; + } +}; ?> + +
      + +

      + + + + {{ $subject }} +

      + + @auth + + @endauth + + @foreach($thread as $item) + + @endforeach +
      diff --git a/resources/views/livewire/pages/top.blade.php b/resources/views/livewire/pages/top.blade.php new file mode 100644 index 0000000..0747451 --- /dev/null +++ b/resources/views/livewire/pages/top.blade.php @@ -0,0 +1,14 @@ + + +
      +

      Most interesting threads of the last month

      + + +
      diff --git a/resources/views/livewire/thread-list.blade.php b/resources/views/livewire/thread-list.blade.php new file mode 100644 index 0000000..8f4c3ad --- /dev/null +++ b/resources/views/livewire/thread-list.blade.php @@ -0,0 +1,136 @@ +check()) { + $this->js(<<<'JS' + alert('You must log in to vote. To log in, click the "log in" link in the top-right corner.'); + JS); + + return; + } + + $userId = (int) auth()->id(); + + // Clicking the arrow that is already active removes the vote. + $current = (int) (Vote::query() + ->where('userId', $userId) + ->where('emailNumber', $number) + ->value('value') ?? 0); + $newValue = $current === $value ? 0 : $value; + + app(CastVote::class)->handle($userId, $number, $newValue); + } + + /** + * @return array + */ + public function with(): array + { + $user = auth()->user(); + + if ($this->mode === 'top') { + return [ + 'threads' => app(ThreadQuery::class)->findTopThreads(1, $user), + 'pageCount' => 1, + ]; + } + + $threadCount = Cache::remember( + 'stats.threadCount', + now()->addMinutes(5), + fn() => Email::where('isThreadRoot', true)->count(), + ); + + return [ + 'threads' => app(ThreadQuery::class)->findLatestThreads($this->page, $user), + 'pageCount' => (int) ceil($threadCount / 20), + ]; + } +}; ?> + +
      + @foreach($threads as $thread) +
      +
      + + ▴ + +
      {{ $thread->votes }}
      + + ▾ + +
      +
      + + {{ $thread->subject }} + +
      + {{ $thread->date->diffForHumans() }} + @if($thread->fromName) + by {{ $thread->fromName }} + @endif +
      +
      + @if($thread->emailCount > 1) + + @endif +
      + @endforeach + + @if($mode === 'latest') +
      + @if($page > 1) + + @endif + @if($page > 5) +
      + @endif + @if($page > 2) + @for($i = max($page - 3, 2); $i <= $page - 1; $i++) + + @endfor + @endif +
      + {{ $page }} +
      + @if($page < $pageCount) + @for($i = $page + 1; $i <= min($page + 4, $pageCount); $i++) + + @endfor + @endif + @if($page + 4 < $pageCount) +
      + @endif +
      + @endif +
      diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php deleted file mode 100644 index 16acaff..0000000 --- a/resources/views/stats.blade.php +++ /dev/null @@ -1,15 +0,0 @@ -@extends('layout') - -@section('content') - -

      Stats!

      - -

      There are:

      - -
        -
      • {{ $userCount }} users
      • -
      • {{ $threadCount }} threads
      • -
      • {{ $emailCount }} emails
      • -
      - -@endsection diff --git a/resources/views/thread.blade.php b/resources/views/thread.blade.php deleted file mode 100644 index ed905f8..0000000 --- a/resources/views/thread.blade.php +++ /dev/null @@ -1,71 +0,0 @@ -@extends('layout') - -@section('pageTitle', $subject . ' - Externals') - -@section('content') - -

      - - - - {{ $subject }} -

      - - @if(!empty($user)) - - @endif - - @foreach($thread as $item) - @include('threads.thread-item', ['item' => $item]) - @endforeach - -@endsection - -@section('scripts') - @parent - - -@endsection diff --git a/resources/views/threads/thread-list.blade.php b/resources/views/threads/thread-list.blade.php deleted file mode 100644 index d16114b..0000000 --- a/resources/views/threads/thread-list.blade.php +++ /dev/null @@ -1,37 +0,0 @@ -@foreach($threads as $thread) -
      -
      - - ▴ - -
      {{ $thread['votes'] }}
      - - ▾ - -
      -
      - - {{ $thread['subject'] }} - -
      - {{-- Date --}} - {{ \Carbon\Carbon::parse($thread['date'])->diffForHumans() }} - {{-- Author --}} - @if(!empty($thread['fromName'])) - by {{ $thread['fromName'] }} - @endif -
      -
      - {{-- Number of responses --}} - @if($thread['emailCount'] > 1) - - @endif -
      -@endforeach diff --git a/resources/views/top.blade.php b/resources/views/top.blade.php deleted file mode 100644 index 6d887f8..0000000 --- a/resources/views/top.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@extends('layout') - -@section('content') - -

      Most interesting threads of the last month

      - - @include('threads.thread-list') - -@endsection diff --git a/routes/web.php b/routes/web.php index 405bf6e..2da1128 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,31 +2,25 @@ declare(strict_types=1); +use App\Http\Controllers\Auth\GithubController; use App\Http\Controllers\EmailSourceController; -use App\Http\Controllers\HomeController; use App\Http\Controllers\LegacyThreadRedirectController; -use App\Http\Controllers\LoginController; use App\Http\Controllers\LogoutController; -use App\Http\Controllers\NewsController; use App\Http\Controllers\RssController; use App\Http\Controllers\RssRfcController; -use App\Http\Controllers\StatsController; -use App\Http\Controllers\ThreadController; -use App\Http\Controllers\TopController; -use App\Http\Controllers\VoteController; use Illuminate\Support\Facades\Route; +use Livewire\Volt\Volt; -Route::get('/', HomeController::class); -Route::get('/top', TopController::class); -Route::get('/news', NewsController::class); -Route::get('/stats', StatsController::class); +Volt::route('/', 'pages.home'); +Volt::route('/top', 'pages.top'); +Volt::route('/news', 'pages.news'); +Volt::route('/stats', 'pages.stats'); -Route::get('/login', LoginController::class)->name('login'); -Route::post('/logout', LogoutController::class); +Route::get('/login', GithubController::class)->name('login'); +Route::post('/logout', LogoutController::class)->name('logout'); -Route::get('/message/{number}', ThreadController::class)->whereNumber('number'); +Volt::route('/message/{number}', 'pages.thread')->whereNumber('number'); Route::get('/email/{number}/source', EmailSourceController::class)->whereNumber('number')->middleware('auth'); -Route::post('/votes/{number}', VoteController::class)->whereNumber('number'); Route::get('/rss', RssController::class); Route::get('/rss-rfc', RssRfcController::class); diff --git a/tests/Feature/Actions/CastVoteTest.php b/tests/Feature/Actions/CastVoteTest.php index a155cb9..6b54ac3 100644 --- a/tests/Feature/Actions/CastVoteTest.php +++ b/tests/Feature/Actions/CastVoteTest.php @@ -2,64 +2,54 @@ declare(strict_types=1); -namespace Feature\Actions; - use App\Actions\CastVote; use App\Models\Email; use App\Models\Thread; use App\Models\User; use App\Models\Vote; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -class CastVoteTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_records_a_new_vote_and_returns_total(): void - { - Email::factory()->create(['number' => 1]); - $user = User::factory()->create(); +test('records a new vote and returns total', function (): void { + Email::factory()->create(['number' => 1]); + $user = User::factory()->create(); - $total = app(CastVote::class)->handle($user->id, 1, 1); + $total = app(CastVote::class)->handle($user->id, 1, 1); - $this->assertSame(1, $total); - $this->assertDatabaseHas('votes', ['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]); - } + $this->assertSame(1, $total); + $this->assertDatabaseHas('votes', ['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]); +}); - public function test_updates_existing_vote_for_same_user(): void - { - Email::factory()->create(['number' => 1]); - $user = User::factory()->create(); +test('updates existing vote for same user', function (): void { + Email::factory()->create(['number' => 1]); + $user = User::factory()->create(); - app(CastVote::class)->handle($user->id, 1, 1); - $total = app(CastVote::class)->handle($user->id, 1, -1); + app(CastVote::class)->handle($user->id, 1, 1); + $total = app(CastVote::class)->handle($user->id, 1, -1); - $this->assertSame(-1, $total); - $this->assertSame(1, Vote::where('userId', $user->id)->where('emailNumber', 1)->count()); - } + $this->assertSame(-1, $total); + $this->assertSame(1, Vote::where('userId', $user->id)->where('emailNumber', 1)->count()); +}); - public function test_sums_votes_from_multiple_users(): void - { - Email::factory()->create(['number' => 1]); - $alice = User::factory()->create(); - $bob = User::factory()->create(); +test('sums votes from multiple users', function (): void { + Email::factory()->create(['number' => 1]); + $alice = User::factory()->create(); + $bob = User::factory()->create(); - app(CastVote::class)->handle($alice->id, 1, 1); - $total = app(CastVote::class)->handle($bob->id, 1, 1); + app(CastVote::class)->handle($alice->id, 1, 1); + $total = app(CastVote::class)->handle($bob->id, 1, 1); - $this->assertSame(2, $total); - } + $this->assertSame(2, $total); +}); - public function test_refreshes_thread_row(): void - { - Email::factory()->create(['id' => '', 'number' => 1]); - $user = User::factory()->create(); +test('refreshes thread row', function (): void { + Email::factory()->create(['id' => '', 'number' => 1]); + $user = User::factory()->create(); - app(CastVote::class)->handle($user->id, 1, 1); + app(CastVote::class)->handle($user->id, 1, 1); - $thread = Thread::find(''); - $this->assertNotNull($thread); - $this->assertSame(1, $thread->votes); - } -} + $thread = Thread::find(''); + $this->assertNotNull($thread); + $this->assertSame(1, $thread->votes); +}); diff --git a/tests/Feature/Actions/GetOrCreateUserTest.php b/tests/Feature/Actions/GetOrCreateUserTest.php index 5f470e1..755c562 100644 --- a/tests/Feature/Actions/GetOrCreateUserTest.php +++ b/tests/Feature/Actions/GetOrCreateUserTest.php @@ -2,43 +2,34 @@ declare(strict_types=1); -namespace Feature\Actions; - use App\Actions\GetOrCreateUser; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -class GetOrCreateUserTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_should_create_new_user(): void - { - $user = app(GetOrCreateUser::class)->handle('abc', 'joe'); +test('should create new user', function (): void { + $user = app(GetOrCreateUser::class)->handle('abc', 'joe'); - $this->assertSame('abc', $user->githubId); - $this->assertSame('joe', $user->name); - $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'joe']); - } + $this->assertSame('abc', $user->githubId); + $this->assertSame('joe', $user->name); + $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'joe']); +}); - public function test_should_return_existing_user(): void - { - $existing = User::factory()->create(['githubId' => 'abc', 'name' => 'joe']); +test('should return existing user', function (): void { + $existing = User::factory()->create(['githubId' => 'abc', 'name' => 'joe']); - $user = app(GetOrCreateUser::class)->handle('abc', 'joe'); + $user = app(GetOrCreateUser::class)->handle('abc', 'joe'); - $this->assertSame($existing->id, $user->id); - $this->assertSame('joe', $user->name); - } + $this->assertSame($existing->id, $user->id); + $this->assertSame('joe', $user->name); +}); - public function test_should_update_user_name_when_changed(): void - { - User::factory()->create(['githubId' => 'abc', 'name' => 'joe']); +test('should update user name when changed', function (): void { + User::factory()->create(['githubId' => 'abc', 'name' => 'joe']); - $user = app(GetOrCreateUser::class)->handle('abc', 'jane'); + $user = app(GetOrCreateUser::class)->handle('abc', 'jane'); - $this->assertSame('jane', $user->name); - $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'jane']); - } -} + $this->assertSame('jane', $user->name); + $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'jane']); +}); diff --git a/tests/Feature/Actions/MarkEmailAsReadTest.php b/tests/Feature/Actions/MarkEmailAsReadTest.php index e501e54..859c354 100644 --- a/tests/Feature/Actions/MarkEmailAsReadTest.php +++ b/tests/Feature/Actions/MarkEmailAsReadTest.php @@ -2,46 +2,38 @@ declare(strict_types=1); -namespace Feature\Actions; - use App\Actions\MarkEmailAsRead; use App\Models\Email; use App\Models\User; use App\Models\UserEmailRead; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; -use Tests\TestCase; -class MarkEmailAsReadTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_creates_read_record(): void - { - Carbon::setTestNow('2026-05-14 10:00:00'); - $email = Email::factory()->create(); - $user = User::factory()->create(); +test('creates read record', function (): void { + Carbon::setTestNow('2026-05-14 10:00:00'); + $email = Email::factory()->create(); + $user = User::factory()->create(); - app(MarkEmailAsRead::class)->handle($email, $user); + app(MarkEmailAsRead::class)->handle($email, $user); - $row = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->first(); - $this->assertNotNull($row); - $this->assertSame('2026-05-14 10:00:00', $row->lastReadDate->format('Y-m-d H:i:s')); - } + $row = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->first(); + $this->assertNotNull($row); + $this->assertSame('2026-05-14 10:00:00', $row->lastReadDate->format('Y-m-d H:i:s')); +}); - public function test_updates_existing_read_record(): void - { - $email = Email::factory()->create(); - $user = User::factory()->create(); +test('updates existing read record', function (): void { + $email = Email::factory()->create(); + $user = User::factory()->create(); - Carbon::setTestNow('2026-01-01 00:00:00'); - app(MarkEmailAsRead::class)->handle($email, $user); + Carbon::setTestNow('2026-01-01 00:00:00'); + app(MarkEmailAsRead::class)->handle($email, $user); - Carbon::setTestNow('2026-05-14 10:00:00'); - app(MarkEmailAsRead::class)->handle($email, $user); + Carbon::setTestNow('2026-05-14 10:00:00'); + app(MarkEmailAsRead::class)->handle($email, $user); - $rows = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->get(); - $this->assertCount(1, $rows); - $this->assertSame('2026-05-14 10:00:00', $rows->first()->lastReadDate->format('Y-m-d H:i:s')); - } -} + $rows = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->get(); + $this->assertCount(1, $rows); + $this->assertSame('2026-05-14 10:00:00', $rows->first()->lastReadDate->format('Y-m-d H:i:s')); +}); diff --git a/tests/Feature/Actions/RefreshAllThreadsTest.php b/tests/Feature/Actions/RefreshAllThreadsTest.php index f288316..200eddd 100644 --- a/tests/Feature/Actions/RefreshAllThreadsTest.php +++ b/tests/Feature/Actions/RefreshAllThreadsTest.php @@ -2,59 +2,50 @@ declare(strict_types=1); -namespace Feature\Actions; - use App\Actions\RefreshAllThreads; use App\Models\Email; use App\Models\Thread; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - -class RefreshAllThreadsTest extends TestCase -{ - use RefreshDatabase; - - public function test_creates_a_thread_row_for_every_root(): void - { - $rootA = Email::factory()->create(['id' => '']); - Email::factory()->replyTo($rootA)->create(); - Email::factory()->create(['id' => '']); - - app(RefreshAllThreads::class)->handle(); - - $threadA = Thread::find(''); - $threadB = Thread::find(''); - $this->assertNotNull($threadA); - $this->assertNotNull($threadB); - $this->assertSame(2, $threadA->emailCount); - $this->assertSame(1, $threadB->emailCount); - } - - public function test_ignores_non_root_emails(): void - { - $root = Email::factory()->create(); - Email::factory()->replyTo($root)->create(); - - app(RefreshAllThreads::class)->handle(); - - $this->assertSame(1, Thread::count()); - } - - public function test_replaces_existing_thread_rows(): void - { - Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); - Thread::factory()->create([ - 'emailId' => '', - 'emailNumber' => 1, - 'lastUpdate' => '2020-01-01 00:00:00', - 'emailCount' => 999, - 'votes' => 999, - ]); - - app(RefreshAllThreads::class)->handle(); - - $thread = Thread::find(''); - $this->assertSame(1, $thread->emailCount); - $this->assertSame(0, $thread->votes); - } -} + +uses(RefreshDatabase::class); + +test('creates a thread row for every root', function (): void { + $rootA = Email::factory()->create(['id' => '']); + Email::factory()->replyTo($rootA)->create(); + Email::factory()->create(['id' => '']); + + app(RefreshAllThreads::class)->handle(); + + $threadA = Thread::find(''); + $threadB = Thread::find(''); + $this->assertNotNull($threadA); + $this->assertNotNull($threadB); + $this->assertSame(2, $threadA->emailCount); + $this->assertSame(1, $threadB->emailCount); +}); + +test('ignores non root emails', function (): void { + $root = Email::factory()->create(); + Email::factory()->replyTo($root)->create(); + + app(RefreshAllThreads::class)->handle(); + + $this->assertSame(1, Thread::count()); +}); + +test('replaces existing thread rows', function (): void { + Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); + Thread::factory()->create([ + 'emailId' => '', + 'emailNumber' => 1, + 'lastUpdate' => '2020-01-01 00:00:00', + 'emailCount' => 999, + 'votes' => 999, + ]); + + app(RefreshAllThreads::class)->handle(); + + $thread = Thread::find(''); + $this->assertSame(1, $thread->emailCount); + $this->assertSame(0, $thread->votes); +}); diff --git a/tests/Feature/Actions/RefreshThreadTest.php b/tests/Feature/Actions/RefreshThreadTest.php index 6467a5b..c11b98f 100644 --- a/tests/Feature/Actions/RefreshThreadTest.php +++ b/tests/Feature/Actions/RefreshThreadTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -namespace Feature\Actions; - use App\Actions\RefreshThread; use App\Models\Email; use App\Models\Thread; @@ -11,115 +9,102 @@ use App\Models\Vote; use Illuminate\Foundation\Testing\RefreshDatabase; use InvalidArgumentException; -use Tests\TestCase; - -class RefreshThreadTest extends TestCase -{ - use RefreshDatabase; - - public function test_creates_thread_row_with_email_count_and_votes(): void - { - $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); - Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']); - Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-03 12:00:00']); - - $user = User::factory()->create(); - Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]); - - app(RefreshThread::class)->handle(1); - - $thread = Thread::find(''); - $this->assertNotNull($thread); - $this->assertSame(1, $thread->emailNumber); - $this->assertSame(3, $thread->emailCount); - $this->assertSame(1, $thread->votes); - $this->assertSame('2026-01-03 12:00:00', $thread->lastUpdate->format('Y-m-d H:i:s')); - } - - public function test_root_with_no_replies_has_count_one_and_zero_votes(): void - { - Email::factory()->create(['id' => '', 'number' => 10]); - - app(RefreshThread::class)->handle(10); - - $thread = Thread::find(''); - $this->assertNotNull($thread); - $this->assertSame(1, $thread->emailCount); - $this->assertSame(0, $thread->votes); - } - - public function test_replaces_existing_thread_row(): void - { - Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); - Thread::factory()->create([ - 'emailId' => '', - 'emailNumber' => 1, - 'lastUpdate' => '2020-01-01 00:00:00', - 'emailCount' => 999, - 'votes' => 999, - ]); - - app(RefreshThread::class)->handle(1); - - $thread = Thread::find(''); - $this->assertSame(1, $thread->emailCount); - $this->assertSame(0, $thread->votes); - $this->assertSame('2026-01-01 10:00:00', $thread->lastUpdate->format('Y-m-d H:i:s')); - } - - public function test_only_refreshes_the_target_thread(): void - { - Email::factory()->create(['id' => '', 'number' => 1]); - Email::factory()->create(['id' => '', 'number' => 2]); - - app(RefreshThread::class)->handle(1); - - $this->assertNotNull(Thread::find('')); - $this->assertNull(Thread::find('')); - } - - public function test_does_nothing_when_email_is_not_a_thread_root(): void - { - $root = Email::factory()->create(['id' => '', 'number' => 1]); - Email::factory()->replyTo($root)->create(['number' => 2]); - - app(RefreshThread::class)->handle(2); - - $this->assertNull(Thread::find('')); - } - - public function test_refreshes_by_email_id(): void - { - $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); - Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']); - - app(RefreshThread::class)->handle(emailId: ''); - - $thread = Thread::find(''); - $this->assertNotNull($thread); - $this->assertSame(2, $thread->emailCount); - } - - public function test_refreshes_only_target_thread_when_called_by_email_id(): void - { - Email::factory()->create(['id' => '', 'number' => 1]); - Email::factory()->create(['id' => '', 'number' => 2]); - - app(RefreshThread::class)->handle(emailId: ''); - - $this->assertNotNull(Thread::find('')); - $this->assertNull(Thread::find('')); - } - - public function test_throws_when_no_argument_is_provided(): void - { - $this->expectException(InvalidArgumentException::class); - app(RefreshThread::class)->handle(); - } - - public function test_throws_when_both_arguments_are_provided(): void - { - $this->expectException(InvalidArgumentException::class); - app(RefreshThread::class)->handle(1, ''); - } -} + +uses(RefreshDatabase::class); + +test('creates thread row with email count and votes', function (): void { + $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); + Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']); + Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-03 12:00:00']); + + $user = User::factory()->create(); + Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]); + + app(RefreshThread::class)->handle(1); + + $thread = Thread::find(''); + $this->assertNotNull($thread); + $this->assertSame(1, $thread->emailNumber); + $this->assertSame(3, $thread->emailCount); + $this->assertSame(1, $thread->votes); + $this->assertSame('2026-01-03 12:00:00', $thread->lastUpdate->format('Y-m-d H:i:s')); +}); + +test('root with no replies has count one and zero votes', function (): void { + Email::factory()->create(['id' => '', 'number' => 10]); + + app(RefreshThread::class)->handle(10); + + $thread = Thread::find(''); + $this->assertNotNull($thread); + $this->assertSame(1, $thread->emailCount); + $this->assertSame(0, $thread->votes); +}); + +test('replaces existing thread row', function (): void { + Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); + Thread::factory()->create([ + 'emailId' => '', + 'emailNumber' => 1, + 'lastUpdate' => '2020-01-01 00:00:00', + 'emailCount' => 999, + 'votes' => 999, + ]); + + app(RefreshThread::class)->handle(1); + + $thread = Thread::find(''); + $this->assertSame(1, $thread->emailCount); + $this->assertSame(0, $thread->votes); + $this->assertSame('2026-01-01 10:00:00', $thread->lastUpdate->format('Y-m-d H:i:s')); +}); + +test('only refreshes the target thread', function (): void { + Email::factory()->create(['id' => '', 'number' => 1]); + Email::factory()->create(['id' => '', 'number' => 2]); + + app(RefreshThread::class)->handle(1); + + $this->assertNotNull(Thread::find('')); + $this->assertNull(Thread::find('')); +}); + +test('does nothing when email is not a thread root', function (): void { + $root = Email::factory()->create(['id' => '', 'number' => 1]); + Email::factory()->replyTo($root)->create(['number' => 2]); + + app(RefreshThread::class)->handle(2); + + $this->assertNull(Thread::find('')); +}); + +test('refreshes by email id', function (): void { + $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); + Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']); + + app(RefreshThread::class)->handle(emailId: ''); + + $thread = Thread::find(''); + $this->assertNotNull($thread); + $this->assertSame(2, $thread->emailCount); +}); + +test('refreshes only target thread when called by email id', function (): void { + Email::factory()->create(['id' => '', 'number' => 1]); + Email::factory()->create(['id' => '', 'number' => 2]); + + app(RefreshThread::class)->handle(emailId: ''); + + $this->assertNotNull(Thread::find('')); + $this->assertNull(Thread::find('')); +}); + +test('throws when no argument is provided', function (): void { + $this->expectException(InvalidArgumentException::class); + app(RefreshThread::class)->handle(); +}); + +test('throws when both arguments are provided', function (): void { + $this->expectException(InvalidArgumentException::class); + app(RefreshThread::class)->handle(1, ''); +}); diff --git a/tests/Feature/Http/EmailSourceControllerTest.php b/tests/Feature/Http/EmailSourceControllerTest.php index d35798b..b1f2735 100644 --- a/tests/Feature/Http/EmailSourceControllerTest.php +++ b/tests/Feature/Http/EmailSourceControllerTest.php @@ -2,38 +2,29 @@ declare(strict_types=1); -namespace Feature\Http; - use App\Models\Email; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - -class EmailSourceControllerTest extends TestCase -{ - use RefreshDatabase; - - public function test_redirects_to_login_when_unauthenticated(): void - { - $this->get('/email/42/source')->assertRedirect('/login'); - } - - public function test_returns_404_when_email_does_not_exist(): void - { - $this->actingAs(User::factory()->create()) - ->get('/email/9999/source') - ->assertNotFound(); - } - - public function test_returns_raw_source_as_plain_text(): void - { - Email::factory()->create(['number' => 42, 'source' => "Subject: hi\r\n\r\nhello"]); - - $response = $this->actingAs(User::factory()->create()) - ->get('/email/42/source'); - - $response->assertOk(); - $response->assertHeader('Content-Type', 'text/plain; charset=utf-8'); - $this->assertSame("Subject: hi\r\n\r\nhello", $response->getContent()); - } -} + +uses(RefreshDatabase::class); + +test('redirects to login when unauthenticated', function (): void { + $this->get('/email/42/source')->assertRedirect('/login'); +}); + +test('returns 404 when email does not exist', function (): void { + $this->actingAs(User::factory()->create()) + ->get('/email/9999/source') + ->assertNotFound(); +}); + +test('returns raw source as plain text', function (): void { + Email::factory()->create(['number' => 42, 'source' => "Subject: hi\r\n\r\nhello"]); + + $response = $this->actingAs(User::factory()->create()) + ->get('/email/42/source'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + $this->assertSame("Subject: hi\r\n\r\nhello", $response->getContent()); +}); diff --git a/tests/Feature/Http/GithubControllerTest.php b/tests/Feature/Http/GithubControllerTest.php new file mode 100644 index 0000000..3279a15 --- /dev/null +++ b/tests/Feature/Http/GithubControllerTest.php @@ -0,0 +1,80 @@ +create(); + + $response = $this->actingAs($user)->get('/login'); + + $response->assertRedirect('/'); +}); + +test('unauthenticated user without code is redirected to github', function (): void { + Socialite::fake('github'); + + $response = $this->get('/login'); + + $response->assertRedirect('https://socialite.fake/github/authorize'); +}); + +test('callback with code creates user and logs in', function (): void { + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => '12345', + 'nickname' => 'octocat', + ])); + + $response = $this->get('/login?code=abc'); + + $response->assertRedirect('/'); + $user = User::where('githubId', '12345')->firstOrFail(); + $this->assertDatabaseHas('users', ['githubId' => '12345', 'name' => 'octocat']); + $this->assertAuthenticatedAs($user); + $this->assertNotNull($user->remember_token); + $this->assertSame(60, mb_strlen($user->remember_token)); +}); + +test('callback logs in existing user', function (): void { + $existing = User::factory()->create(['githubId' => '12345', 'name' => 'octocat']); + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => '12345', + 'nickname' => 'octocat', + ])); + + $response = $this->get('/login?code=abc'); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($existing); + $this->assertSame(1, User::count()); +}); + +test('callback with invalid state renders error view', function (): void { + Socialite::fake('github', fn() => throw new InvalidStateException); + + $response = $this->get('/login?code=abc'); + + $response->assertStatus(400); + $response->assertViewIs('auth.login-error'); + $response->assertViewHas('error', 'Invalid state'); + $this->assertGuest(); +}); + +test('callback with socialite failure renders error view', function (): void { + Socialite::fake('github', fn() => throw new RuntimeException('boom')); + + $response = $this->get('/login?code=abc'); + + $response->assertStatus(400); + $response->assertViewIs('auth.login-error'); + $response->assertViewHas('error', 'boom'); + $this->assertGuest(); +}); diff --git a/tests/Feature/Http/HomeControllerTest.php b/tests/Feature/Http/HomeControllerTest.php index daa6e19..a1d350c 100644 --- a/tests/Feature/Http/HomeControllerTest.php +++ b/tests/Feature/Http/HomeControllerTest.php @@ -2,47 +2,25 @@ declare(strict_types=1); -namespace Feature\Http; - -use App\Models\Email; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - -class HomeControllerTest extends TestCase -{ - use RefreshDatabase; - - public function test_renders_home_for_guest(): void - { - $response = $this->get('/'); - - $response->assertOk(); - $response->assertViewIs('home'); - $response->assertViewHas('user', null); - $response->assertViewHas('page', 1); - $response->assertSee('instantsearch.js/2/instantsearch.min.js', false); - $response->assertSee('function init_search()', false); - } - public function test_renders_home_for_authenticated_user(): void - { - $user = User::factory()->create(); +uses(RefreshDatabase::class); - $response = $this->actingAs($user)->get('/'); +test('renders home for guest', function (): void { + $response = $this->get('/'); - $response->assertOk(); - $response->assertViewHas('user', fn($viewUser) => $viewUser?->is($user)); - } + $response->assertOk(); + $response->assertSee('mailing list to the outside', false); + $response->assertSee('id="search-input"', false); +}); - public function test_page_count_reflects_thread_root_count(): void - { - Email::factory()->count(25)->create(); +test('renders home for authenticated user', function (): void { + $user = User::factory()->create(['name' => 'octocat']); - $response = $this->get('/'); + $response = $this->actingAs($user)->get('/'); - $response->assertOk(); - // 25 threads / 20 per page = 2 pages - $response->assertViewHas('pageCount', 2); - } -} + $response->assertOk(); + // The nav greets the logged-in user by name. + $response->assertSee('octocat'); +}); diff --git a/tests/Feature/Http/LegacyThreadRedirectControllerTest.php b/tests/Feature/Http/LegacyThreadRedirectControllerTest.php index d40057b..95a631a 100644 --- a/tests/Feature/Http/LegacyThreadRedirectControllerTest.php +++ b/tests/Feature/Http/LegacyThreadRedirectControllerTest.php @@ -2,46 +2,37 @@ declare(strict_types=1); -namespace Feature\Http; - use App\Models\Email; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; -use Tests\TestCase; - -class LegacyThreadRedirectControllerTest extends TestCase -{ - use RefreshDatabase; - - public function test_returns_404_when_legacy_thread_id_unknown(): void - { - $this->get('/thread/123')->assertNotFound(); - } - - public function test_returns_404_when_no_email_matches_subject(): void - { - DB::table('threads_old')->insert(['id' => 1, 'subject' => 'No match']); - - $this->get('/thread/1')->assertNotFound(); - } - - public function test_redirects_to_latest_matching_thread_root(): void - { - DB::table('threads_old')->insert(['id' => 1, 'subject' => 'Reused subject']); - Email::factory()->create([ - 'subject' => 'Reused subject', - 'number' => 100, - 'date' => '2020-01-01 00:00:00', - ]); - Email::factory()->create([ - 'subject' => 'Reused subject', - 'number' => 200, - 'date' => '2024-01-01 00:00:00', - ]); - - $response = $this->get('/thread/1'); - - $response->assertRedirect('/message/200'); - $response->assertStatus(301); - } -} + +uses(RefreshDatabase::class); + +test('returns 404 when legacy thread id unknown', function (): void { + $this->get('/thread/123')->assertNotFound(); +}); + +test('returns 404 when no email matches subject', function (): void { + DB::table('threads_old')->insert(['id' => 1, 'subject' => 'No match']); + + $this->get('/thread/1')->assertNotFound(); +}); + +test('redirects to latest matching thread root', function (): void { + DB::table('threads_old')->insert(['id' => 1, 'subject' => 'Reused subject']); + Email::factory()->create([ + 'subject' => 'Reused subject', + 'number' => 100, + 'date' => '2020-01-01 00:00:00', + ]); + Email::factory()->create([ + 'subject' => 'Reused subject', + 'number' => 200, + 'date' => '2024-01-01 00:00:00', + ]); + + $response = $this->get('/thread/1'); + + $response->assertRedirect('/message/200'); + $response->assertStatus(301); +}); diff --git a/tests/Feature/Http/LoginControllerTest.php b/tests/Feature/Http/LoginControllerTest.php deleted file mode 100644 index daa1938..0000000 --- a/tests/Feature/Http/LoginControllerTest.php +++ /dev/null @@ -1,92 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get('/login'); - - $response->assertRedirect('/'); - } - - public function test_unauthenticated_user_without_code_is_redirected_to_github(): void - { - Socialite::fake('github'); - - $response = $this->get('/login'); - - $response->assertRedirect('https://socialite.fake/github/authorize'); - } - - public function test_callback_with_code_creates_user_and_logs_in(): void - { - Socialite::fake('github', (new SocialiteUser)->map([ - 'id' => '12345', - 'nickname' => 'octocat', - ])); - - $response = $this->get('/login?code=abc'); - - $response->assertRedirect('/'); - $user = User::where('githubId', '12345')->firstOrFail(); - $this->assertDatabaseHas('users', ['githubId' => '12345', 'name' => 'octocat']); - $this->assertAuthenticatedAs($user); - $this->assertNotNull($user->remember_token); - $this->assertSame(60, mb_strlen($user->remember_token)); - } - - public function test_callback_logs_in_existing_user(): void - { - $existing = User::factory()->create(['githubId' => '12345', 'name' => 'octocat']); - Socialite::fake('github', (new SocialiteUser)->map([ - 'id' => '12345', - 'nickname' => 'octocat', - ])); - - $response = $this->get('/login?code=abc'); - - $response->assertRedirect('/'); - $this->assertAuthenticatedAs($existing); - $this->assertSame(1, User::count()); - } - - public function test_callback_with_invalid_state_renders_error_view(): void - { - Socialite::fake('github', fn() => throw new InvalidStateException); - - $response = $this->get('/login?code=abc'); - - $response->assertStatus(400); - $response->assertViewIs('auth.login-error'); - $response->assertViewHas('error', 'Invalid state'); - $this->assertGuest(); - } - - public function test_callback_with_socialite_failure_renders_error_view(): void - { - Socialite::fake('github', fn() => throw new RuntimeException('boom')); - - $response = $this->get('/login?code=abc'); - - $response->assertStatus(400); - $response->assertViewIs('auth.login-error'); - $response->assertViewHas('error', 'boom'); - $this->assertGuest(); - } -} diff --git a/tests/Feature/Http/LogoutControllerTest.php b/tests/Feature/Http/LogoutControllerTest.php index ff75455..a76ece5 100644 --- a/tests/Feature/Http/LogoutControllerTest.php +++ b/tests/Feature/Http/LogoutControllerTest.php @@ -2,31 +2,23 @@ declare(strict_types=1); -namespace Feature\Http; - use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -class LogoutControllerTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_logs_out_authenticated_user(): void - { - $user = User::factory()->create(); +test('logs out authenticated user', function (): void { + $user = User::factory()->create(); - $response = $this->actingAs($user)->post('/logout'); + $response = $this->actingAs($user)->post('/logout'); - $response->assertRedirect('/'); - $this->assertGuest(); - } + $response->assertRedirect('/'); + $this->assertGuest(); +}); - public function test_logout_when_already_guest_is_safe(): void - { - $response = $this->post('/logout'); +test('logout when already guest is safe', function (): void { + $response = $this->post('/logout'); - $response->assertRedirect('/'); - $this->assertGuest(); - } -} + $response->assertRedirect('/'); + $this->assertGuest(); +}); diff --git a/tests/Feature/Http/NewsControllerTest.php b/tests/Feature/Http/NewsControllerTest.php index 9cc20e2..311850d 100644 --- a/tests/Feature/Http/NewsControllerTest.php +++ b/tests/Feature/Http/NewsControllerTest.php @@ -2,17 +2,9 @@ declare(strict_types=1); -namespace Feature\Http; +test('renders news page', function (): void { + $response = $this->get('/news'); -use Tests\TestCase; - -class NewsControllerTest extends TestCase -{ - public function test_renders_news_page(): void - { - $response = $this->get('/news'); - - $response->assertOk(); - $response->assertViewIs('news'); - } -} + $response->assertOk(); + $response->assertSee("What's new?", false); +}); diff --git a/tests/Feature/Http/RssControllerTest.php b/tests/Feature/Http/RssControllerTest.php index 27e0e3d..cca88d9 100644 --- a/tests/Feature/Http/RssControllerTest.php +++ b/tests/Feature/Http/RssControllerTest.php @@ -2,39 +2,31 @@ declare(strict_types=1); -namespace Feature\Http; - use App\Models\Email; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - -class RssControllerTest extends TestCase -{ - use RefreshDatabase; - - public function test_returns_rss_xml(): void - { - Email::factory()->create(['number' => 1, 'subject' => 'First']); - Email::factory()->create(['number' => 2, 'subject' => 'Second']); - - $response = $this->get('/rss'); - - $response->assertOk(); - $response->assertHeader('Content-Type', 'text/xml; charset=utf-8'); - $this->assertStringContainsString('getContent()); - $this->assertStringContainsString('First', $response->getContent()); - $this->assertStringContainsString('Second', $response->getContent()); - } - - public function test_since_query_filters_older_emails(): void - { - Email::factory()->create(['number' => 1, 'subject' => 'Old']); - Email::factory()->create(['number' => 5, 'subject' => 'New']); - - $response = $this->get('/rss?since=2'); - - $response->assertOk(); - $this->assertStringNotContainsString('Old', $response->getContent()); - $this->assertStringContainsString('New', $response->getContent()); - } -} + +uses(RefreshDatabase::class); + +test('returns rss xml', function (): void { + Email::factory()->create(['number' => 1, 'subject' => 'First']); + Email::factory()->create(['number' => 2, 'subject' => 'Second']); + + $response = $this->get('/rss'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/xml; charset=utf-8'); + $this->assertStringContainsString('getContent()); + $this->assertStringContainsString('First', $response->getContent()); + $this->assertStringContainsString('Second', $response->getContent()); +}); + +test('since query filters older emails', function (): void { + Email::factory()->create(['number' => 1, 'subject' => 'Old']); + Email::factory()->create(['number' => 5, 'subject' => 'New']); + + $response = $this->get('/rss?since=2'); + + $response->assertOk(); + $this->assertStringNotContainsString('Old', $response->getContent()); + $this->assertStringContainsString('New', $response->getContent()); +}); diff --git a/tests/Feature/Http/RssRfcControllerTest.php b/tests/Feature/Http/RssRfcControllerTest.php index 8ea9ebd..b1aebda 100644 --- a/tests/Feature/Http/RssRfcControllerTest.php +++ b/tests/Feature/Http/RssRfcControllerTest.php @@ -2,21 +2,14 @@ declare(strict_types=1); -namespace Feature\Http; - use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -class RssRfcControllerTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_returns_rss_xml(): void - { - $response = $this->get('/rss-rfc'); +test('returns rss xml', function (): void { + $response = $this->get('/rss-rfc'); - $response->assertOk(); - $response->assertHeader('Content-Type', 'text/xml; charset=utf-8'); - $this->assertStringContainsString('getContent()); - } -} + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/xml; charset=utf-8'); + $this->assertStringContainsString('getContent()); +}); diff --git a/tests/Feature/Http/StatsControllerTest.php b/tests/Feature/Http/StatsControllerTest.php index 24495c3..ed2b779 100644 --- a/tests/Feature/Http/StatsControllerTest.php +++ b/tests/Feature/Http/StatsControllerTest.php @@ -2,30 +2,22 @@ declare(strict_types=1); -namespace Feature\Http; - use App\Models\Email; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -class StatsControllerTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_renders_stats_with_counts(): void - { - User::factory()->count(3)->create(); - $root = Email::factory()->create(); - Email::factory()->replyTo($root)->create(); - Email::factory()->replyTo($root)->create(); +test('renders stats with counts', function (): void { + User::factory()->count(3)->create(); + $root = Email::factory()->create(); + Email::factory()->replyTo($root)->create(); + Email::factory()->replyTo($root)->create(); - $response = $this->get('/stats'); + $response = $this->get('/stats'); - $response->assertOk(); - $response->assertViewIs('stats'); - $response->assertViewHas('userCount', 3); - $response->assertViewHas('threadCount', 1); - $response->assertViewHas('emailCount', 3); - } -} + $response->assertOk(); + $response->assertSee('3 users'); + $response->assertSee('1 threads'); + $response->assertSee('3 emails'); +}); diff --git a/tests/Feature/Http/ThreadControllerTest.php b/tests/Feature/Http/ThreadControllerTest.php index fba03cd..22030cf 100644 --- a/tests/Feature/Http/ThreadControllerTest.php +++ b/tests/Feature/Http/ThreadControllerTest.php @@ -2,80 +2,65 @@ declare(strict_types=1); -namespace Feature\Http; - use App\Models\Email; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -class ThreadControllerTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_returns_404_when_email_does_not_exist(): void - { - $response = $this->get('/message/12345'); +test('returns 404 when email does not exist', function (): void { + $response = $this->get('/message/12345'); - $response->assertNotFound(); - } + $response->assertNotFound(); +}); - public function test_renders_thread_view_for_root_email(): void - { - Email::factory()->create(['number' => 100, 'subject' => 'Hello world']); +test('renders thread view for root email', function (): void { + Email::factory()->create(['number' => 100, 'subject' => 'Hello world']); - $response = $this->get('/message/100'); + $response = $this->get('/message/100'); - $response->assertOk(); - $response->assertViewIs('thread'); - $response->assertViewHas('subject', 'Hello world'); - $response->assertViewHas('threadId', 100); - } + $response->assertOk(); + $response->assertSee('Hello world'); +}); - public function test_redirects_replies_to_thread_root_with_anchor(): void - { - $root = Email::factory()->create(['number' => 100]); - $reply = Email::factory()->replyTo($root)->create(['number' => 101]); +test('redirects replies to thread root with anchor', function (): void { + $root = Email::factory()->create(['number' => 100]); + $reply = Email::factory()->replyTo($root)->create(['number' => 101]); - $response = $this->get('/message/' . $reply->number); + $response = $this->get('/message/' . $reply->number); - $response->assertRedirect("/message/{$root->number}#{$reply->number}"); - } + $response->assertRedirect("/message/{$root->number}#{$reply->number}"); +}); - public function test_renders_orphan_reply_when_thread_root_missing(): void - { - Email::factory()->create([ - 'number' => 200, - 'id' => '', - 'threadId' => '', - 'isThreadRoot' => false, - ]); +test('renders orphan reply when thread root missing', function (): void { + Email::factory()->create([ + 'number' => 200, + 'id' => '', + 'threadId' => '', + 'isThreadRoot' => false, + ]); - $response = $this->get('/message/200'); + $response = $this->get('/message/200'); - $response->assertOk(); - $response->assertViewIs('thread'); - } + $response->assertOk(); +}); - public function test_marks_email_as_read_for_authenticated_user(): void - { - Email::factory()->create(['id' => '', 'number' => 300]); - $user = User::factory()->create(); +test('marks email as read for authenticated user', function (): void { + Email::factory()->create(['id' => '', 'number' => 300]); + $user = User::factory()->create(); - $this->actingAs($user)->get('/message/300')->assertOk(); + $this->actingAs($user)->get('/message/300')->assertOk(); - $this->assertDatabaseHas('user_emails_read', [ - 'emailId' => '', - 'userId' => $user->id, - ]); - } + $this->assertDatabaseHas('user_emails_read', [ + 'emailId' => '', + 'userId' => $user->id, + ]); +}); - public function test_does_not_mark_as_read_for_guest(): void - { - Email::factory()->create(['id' => '', 'number' => 301]); +test('does not mark as read for guest', function (): void { + Email::factory()->create(['id' => '', 'number' => 301]); - $this->get('/message/301')->assertOk(); + $this->get('/message/301')->assertOk(); - $this->assertDatabaseCount('user_emails_read', 0); - } -} + $this->assertDatabaseCount('user_emails_read', 0); +}); diff --git a/tests/Feature/Http/VoteControllerTest.php b/tests/Feature/Http/VoteControllerTest.php deleted file mode 100644 index 5eec9d7..0000000 --- a/tests/Feature/Http/VoteControllerTest.php +++ /dev/null @@ -1,72 +0,0 @@ -postJson('/votes/1', ['value' => 1]); - - $response->assertStatus(401); - $this->assertSame('"You must be authenticated"', $response->getContent()); - } - - public function test_rejects_value_greater_than_one(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->postJson('/votes/1', ['value' => 2]); - - $response->assertStatus(400); - $this->assertSame('"Invalid value"', $response->getContent()); - } - - public function test_rejects_value_less_than_minus_one(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->postJson('/votes/1', ['value' => -2]); - - $response->assertStatus(400); - } - - public function test_records_vote_and_returns_new_total(): void - { - Email::factory()->create(['number' => 42]); - $user = User::factory()->create(); - - $response = $this->actingAs($user)->postJson('/votes/42', ['value' => 1]); - - $response->assertOk(); - $response->assertExactJson(['newTotal' => 1, 'newValue' => 1]); - $this->assertDatabaseHas('votes', [ - 'userId' => $user->id, - 'emailNumber' => 42, - 'value' => 1, - ]); - } - - public function test_zero_value_clears_existing_vote(): void - { - Email::factory()->create(['number' => 42]); - $user = User::factory()->create(); - $this->actingAs($user)->postJson('/votes/42', ['value' => 1]); - - $response = $this->actingAs($user)->postJson('/votes/42', ['value' => 0]); - - $response->assertOk(); - $response->assertJson(['newValue' => 0]); - $this->assertSame(0, Vote::where('userId', $user->id)->where('emailNumber', 42)->sum('value')); - } -} diff --git a/tests/Feature/Livewire/ThreadListTest.php b/tests/Feature/Livewire/ThreadListTest.php new file mode 100644 index 0000000..035d2c5 --- /dev/null +++ b/tests/Feature/Livewire/ThreadListTest.php @@ -0,0 +1,94 @@ +count(25)->create(); + app(RefreshAllThreads::class)->handle(); + + // 25 threads / 20 per page = 2 pages + Volt::test('thread-list', ['mode' => 'latest']) + ->assertViewHas('pageCount', 2); +}); + +test('guest cannot vote', function (): void { + Email::factory()->create(['number' => 42]); + + Volt::test('thread-list', ['mode' => 'latest']) + ->call('vote', 42, 1); + + $this->assertDatabaseCount('votes', 0); +}); + +test('authenticated user can vote', function (): void { + Email::factory()->create(['number' => 42]); + $user = User::factory()->create(); + + Volt::actingAs($user) + ->test('thread-list', ['mode' => 'latest']) + ->call('vote', 42, 1); + + $this->assertDatabaseHas('votes', [ + 'userId' => $user->id, + 'emailNumber' => 42, + 'value' => 1, + ]); +}); + +test('clicking the active vote again removes it', function (): void { + Email::factory()->create(['number' => 42]); + $user = User::factory()->create(); + + $component = Volt::actingAs($user)->test('thread-list', ['mode' => 'latest']); + $component->call('vote', 42, 1); + $component->call('vote', 42, 1); + + $this->assertSame(0, (int) Vote::where('userId', $user->id)->where('emailNumber', 42)->sum('value')); +}); + +test('top mode lists recently updated threads with positive votes', function (): void { + Email::factory()->create([ + 'number' => 42, + 'subject' => 'Voted recent thread', + 'fetchDate' => now()->subDays(3), + ]); + Vote::factory()->create(['userId' => User::factory()->create()->id, 'emailNumber' => 42, 'value' => 1]); + app(RefreshAllThreads::class)->handle(); + + Volt::test('thread-list', ['mode' => 'top']) + ->assertSee('Voted recent thread'); +}); + +test('top mode excludes threads without positive votes', function (): void { + Email::factory()->create([ + 'number' => 42, + 'subject' => 'Unvoted recent thread', + 'fetchDate' => now()->subDays(3), + ]); + app(RefreshAllThreads::class)->handle(); + + Volt::test('thread-list', ['mode' => 'top']) + ->assertDontSee('Unvoted recent thread'); +}); + +test('top mode excludes voted threads older than a month', function (): void { + Email::factory()->create([ + 'number' => 42, + 'subject' => 'Voted stale thread', + 'fetchDate' => now()->subMonths(2), + ]); + Vote::factory()->create(['userId' => User::factory()->create()->id, 'emailNumber' => 42, 'value' => 1]); + app(RefreshAllThreads::class)->handle(); + + Volt::test('thread-list', ['mode' => 'top']) + ->assertDontSee('Voted stale thread'); +}); diff --git a/tests/Feature/NoIndexTest.php b/tests/Feature/NoIndexTest.php index 9a9f1fa..f091986 100644 --- a/tests/Feature/NoIndexTest.php +++ b/tests/Feature/NoIndexTest.php @@ -2,59 +2,44 @@ declare(strict_types=1); -namespace Feature; - -use App\Providers\AppServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\View; -use Tests\TestCase; -class NoIndexTest extends TestCase -{ - use RefreshDatabase; +uses(RefreshDatabase::class); - public function test_noindex_meta_tag_is_rendered_when_enabled(): void - { - View::share('noIndex', true); +test('noindex meta tag is rendered when enabled', function (): void { + config()->set('externals.no_index', true); - $response = $this->get('/'); + $response = $this->get('/'); - $response->assertOk(); - $response->assertSee('', false); - } + $response->assertOk(); + $response->assertSee('', false); +}); - public function test_noindex_meta_tag_is_not_rendered_when_disabled(): void - { - View::share('noIndex', false); +test('noindex meta tag is not rendered when disabled', function (): void { + config()->set('externals.no_index', false); - $response = $this->get('/'); + $response = $this->get('/'); - $response->assertOk(); - $response->assertDontSee('', false); - } + $response->assertOk(); + $response->assertDontSee('', false); +}); - public function test_noindex_config_truthy_shares_true_to_views(): void - { - config()->set('externals.no_index', '1'); - $this->app->register(AppServiceProvider::class, force: true); +test('noindex config truthy renders meta tag', function (): void { + config()->set('externals.no_index', '1'); - $this->assertTrue(View::shared('noIndex')); - } + $this->get('/')->assertSee('', false); +}); - public function test_noindex_config_falsy_shares_false_to_views(): void - { - config()->set('externals.no_index', '0'); - $this->app->register(AppServiceProvider::class, force: true); +test('noindex config falsy does not render meta tag', function (): void { + config()->set('externals.no_index', '0'); - $this->assertFalse(View::shared('noIndex')); - } + $this->get('/')->assertDontSee('', false); +}); - public function test_noindex_defaults_to_false_when_env_var_is_absent(): void - { - // Matches the production configuration where GOOGLE_NO_INDEX is not set. - // The default of `env('GOOGLE_NO_INDEX', false)` resolves to false. - $config = require config_path('externals.php'); +test('noindex defaults to false when env var is absent', function (): void { + // Matches the production configuration where GOOGLE_NO_INDEX is not set. + // The default of `env('GOOGLE_NO_INDEX', false)` resolves to false. + $config = require config_path('externals.php'); - $this->assertFalse($config['no_index']); - } -} + $this->assertFalse($config['no_index']); +}); diff --git a/tests/Feature/Services/Email/ThreadQueryTest.php b/tests/Feature/Services/Email/ThreadQueryTest.php index 48008e6..9a50f4a 100644 --- a/tests/Feature/Services/Email/ThreadQueryTest.php +++ b/tests/Feature/Services/Email/ThreadQueryTest.php @@ -2,104 +2,92 @@ declare(strict_types=1); -namespace Feature\Services\Email; - use App\Actions\RefreshAllThreads; use App\Models\Email; use App\Models\User; use App\Models\Vote; use App\Services\Email\ThreadQuery; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - -class ThreadQueryTest extends TestCase -{ - use RefreshDatabase; - - public function test_find_latest_threads_orders_by_last_update_desc(): void - { - Email::factory()->create(['number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); - Email::factory()->create(['number' => 2, 'fetchDate' => '2026-03-01 10:00:00']); - Email::factory()->create(['number' => 3, 'fetchDate' => '2026-02-01 10:00:00']); - app(RefreshAllThreads::class)->handle(); - - $threads = (new ThreadQuery)->findLatestThreads(1, null); - - $this->assertCount(3, $threads); - $this->assertSame(2, $threads[0]['number']); - $this->assertSame(3, $threads[1]['number']); - $this->assertSame(1, $threads[2]['number']); - } - - public function test_find_latest_rfc_threads_only_includes_subjects_containing_rfc(): void - { - Email::factory()->create(['subject' => '[RFC] My idea']); - Email::factory()->create(['subject' => 'Just a discussion']); - app(RefreshAllThreads::class)->handle(); - - $threads = (new ThreadQuery)->findLatestRfcThreads(); - - $this->assertCount(1, $threads); - $this->assertSame('[RFC] My idea', $threads[0]['subject']); - } - - public function test_find_latest_threads_paginates_results(): void - { - // 21 threads so the second page contains exactly one - Email::factory()->count(21)->create(); - app(RefreshAllThreads::class)->handle(); - - $page1 = (new ThreadQuery)->findLatestThreads(1, null); - $page2 = (new ThreadQuery)->findLatestThreads(2, null); - - $this->assertCount(20, $page1); - $this->assertCount(1, $page2); - } - - public function test_find_latest_threads_returns_user_vote_when_user_is_given(): void - { - Email::factory()->create(['number' => 1]); - $user = User::factory()->create(); - Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]); - app(RefreshAllThreads::class)->handle(); - - $threads = (new ThreadQuery)->findLatestThreads(1, $user); - - $this->assertSame(1, (int) $threads[0]['userVote']); - } - - public function test_get_thread_view_builds_tree_of_replies(): void - { - $root = Email::factory()->create(['id' => '', 'date' => '2026-01-01 10:00:00']); - $reply1 = Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-02 10:00:00']); - Email::factory()->replyTo($reply1)->create(['id' => '', 'date' => '2026-01-03 10:00:00']); - Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-04 10:00:00']); - - $rootItems = (new ThreadQuery)->getThreadView($root); - - $this->assertCount(1, $rootItems); - $this->assertSame('', $rootItems[0]->email->id); - $this->assertCount(2, $rootItems[0]->replies); - $this->assertSame('', $rootItems[0]->replies[0]->email->id); - $this->assertCount(1, $rootItems[0]->replies[0]->replies); - $this->assertSame('', $rootItems[0]->replies[0]->replies[0]->email->id); - $this->assertSame('', $rootItems[0]->replies[1]->email->id); - $this->assertCount(0, $rootItems[0]->replies[1]->replies); - } - - public function test_get_thread_view_promotes_replies_with_unknown_parent_to_root(): void - { - // The "real" thread root is missing — orphaned replies should still surface. - $orphan = Email::factory()->create([ - 'id' => '', - 'threadId' => '', - 'isThreadRoot' => false, - 'inReplyTo' => '', - ]); - - $rootItems = (new ThreadQuery)->getThreadView($orphan); - - $this->assertCount(1, $rootItems); - $this->assertSame('', $rootItems[0]->email->id); - } -} + +uses(RefreshDatabase::class); + +test('find latest threads orders by last update desc', function (): void { + Email::factory()->create(['number' => 1, 'fetchDate' => '2026-01-01 10:00:00']); + Email::factory()->create(['number' => 2, 'fetchDate' => '2026-03-01 10:00:00']); + Email::factory()->create(['number' => 3, 'fetchDate' => '2026-02-01 10:00:00']); + app(RefreshAllThreads::class)->handle(); + + $threads = (new ThreadQuery)->findLatestThreads(1, null); + + $this->assertCount(3, $threads); + $this->assertSame(2, $threads[0]->number); + $this->assertSame(3, $threads[1]->number); + $this->assertSame(1, $threads[2]->number); +}); + +test('find latest rfc threads only includes subjects containing rfc', function (): void { + Email::factory()->create(['subject' => '[RFC] My idea']); + Email::factory()->create(['subject' => 'Just a discussion']); + app(RefreshAllThreads::class)->handle(); + + $threads = (new ThreadQuery)->findLatestRfcThreads(); + + $this->assertCount(1, $threads); + $this->assertSame('[RFC] My idea', $threads[0]->subject); +}); + +test('find latest threads paginates results', function (): void { + // 21 threads so the second page contains exactly one + Email::factory()->count(21)->create(); + app(RefreshAllThreads::class)->handle(); + + $page1 = (new ThreadQuery)->findLatestThreads(1, null); + $page2 = (new ThreadQuery)->findLatestThreads(2, null); + + $this->assertCount(20, $page1); + $this->assertCount(1, $page2); +}); + +test('find latest threads returns user vote when user is given', function (): void { + Email::factory()->create(['number' => 1]); + $user = User::factory()->create(); + Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]); + app(RefreshAllThreads::class)->handle(); + + $threads = (new ThreadQuery)->findLatestThreads(1, $user); + + $this->assertSame(1, $threads[0]->userVote); +}); + +test('get thread view builds tree of replies', function (): void { + $root = Email::factory()->create(['id' => '', 'date' => '2026-01-01 10:00:00']); + $reply1 = Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-02 10:00:00']); + Email::factory()->replyTo($reply1)->create(['id' => '', 'date' => '2026-01-03 10:00:00']); + Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-04 10:00:00']); + + $rootItems = (new ThreadQuery)->getThreadView($root); + + $this->assertCount(1, $rootItems); + $this->assertSame('', $rootItems[0]->email->id); + $this->assertCount(2, $rootItems[0]->replies); + $this->assertSame('', $rootItems[0]->replies[0]->email->id); + $this->assertCount(1, $rootItems[0]->replies[0]->replies); + $this->assertSame('', $rootItems[0]->replies[0]->replies[0]->email->id); + $this->assertSame('', $rootItems[0]->replies[1]->email->id); + $this->assertCount(0, $rootItems[0]->replies[1]->replies); +}); + +test('get thread view promotes replies with unknown parent to root', function (): void { + // The "real" thread root is missing — orphaned replies should still surface. + $orphan = Email::factory()->create([ + 'id' => '', + 'threadId' => '', + 'isThreadRoot' => false, + 'inReplyTo' => '', + ]); + + $rootItems = (new ThreadQuery)->getThreadView($orphan); + + $this->assertCount(1, $rootItems); + $this->assertSame('', $rootItems[0]->email->id); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..3566ea6 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +extend(TestCase::class)->in('Feature', 'Unit'); diff --git a/tests/Unit/Services/Email/EmailAddressParserTest.php b/tests/Unit/Services/Email/EmailAddressParserTest.php index 521b8c0..36037b1 100644 --- a/tests/Unit/Services/Email/EmailAddressParserTest.php +++ b/tests/Unit/Services/Email/EmailAddressParserTest.php @@ -2,88 +2,73 @@ declare(strict_types=1); -namespace Tests\Unit\Services\Email; - use App\Services\Email\EmailAddressParser; -use Tests\TestCase; - -class EmailAddressParserTest extends TestCase -{ - public function test_should_parse_simple_email(): void - { - $identities = (new EmailAddressParser('john@example.com'))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('john@example.com', $identities[0]->email); - $this->assertNull($identities[0]->name); - } - - public function test_should_parse_email_with_name(): void - { - $identities = (new EmailAddressParser('John Doe '))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('john@example.com', $identities[0]->email); - $this->assertSame('John Doe', $identities[0]->name); - } - - public function test_should_parse_email_with_quoted_name(): void - { - $identities = (new EmailAddressParser('"John Doe" '))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('john@example.com', $identities[0]->email); - $this->assertSame('John Doe', $identities[0]->name); - } - - public function test_should_parse_email_with_parenthesised_name(): void - { - $identities = (new EmailAddressParser('john@example.com (John Doe)'))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('john@example.com', $identities[0]->email); - $this->assertSame('John Doe', $identities[0]->name); - } - - public function test_should_strip_original_marker(): void - { - $identities = (new EmailAddressParser('John Doe (original) '))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('john@example.com', $identities[0]->email); - $this->assertSame('John Doe', $identities[0]->name); - } - - public function test_should_collapse_dotted_email(): void - { - $identities = (new EmailAddressParser('john . doe@example.com'))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('john.doe@example.com', $identities[0]->email); - } - - public function test_should_invalidate_malformed_email(): void - { - $identities = (new EmailAddressParser('not-an-email'))->parse(); - - $this->assertCount(1, $identities); - $this->assertNull($identities[0]->email); - } - - public function test_should_skip_short_names(): void - { - $identities = (new EmailAddressParser('Jo '))->parse(); - - $this->assertCount(1, $identities); - $this->assertSame('jo@example.com', $identities[0]->email); - $this->assertNull($identities[0]->name); - } - - public function test_should_skip_name_containing_url(): void - { - $identities = (new EmailAddressParser('http://example.com '))->parse(); - - $this->assertSame('john@example.com', $identities[0]->email); - $this->assertNull($identities[0]->name); - } -} + +test('should parse simple email', function (): void { + $identities = (new EmailAddressParser('john@example.com'))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('john@example.com', $identities[0]->email); + $this->assertNull($identities[0]->name); +}); + +test('should parse email with name', function (): void { + $identities = (new EmailAddressParser('John Doe '))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('john@example.com', $identities[0]->email); + $this->assertSame('John Doe', $identities[0]->name); +}); + +test('should parse email with quoted name', function (): void { + $identities = (new EmailAddressParser('"John Doe" '))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('john@example.com', $identities[0]->email); + $this->assertSame('John Doe', $identities[0]->name); +}); + +test('should parse email with parenthesised name', function (): void { + $identities = (new EmailAddressParser('john@example.com (John Doe)'))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('john@example.com', $identities[0]->email); + $this->assertSame('John Doe', $identities[0]->name); +}); + +test('should strip original marker', function (): void { + $identities = (new EmailAddressParser('John Doe (original) '))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('john@example.com', $identities[0]->email); + $this->assertSame('John Doe', $identities[0]->name); +}); + +test('should collapse dotted email', function (): void { + $identities = (new EmailAddressParser('john . doe@example.com'))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('john.doe@example.com', $identities[0]->email); +}); + +test('should invalidate malformed email', function (): void { + $identities = (new EmailAddressParser('not-an-email'))->parse(); + + $this->assertCount(1, $identities); + $this->assertNull($identities[0]->email); +}); + +test('should skip short names', function (): void { + $identities = (new EmailAddressParser('Jo '))->parse(); + + $this->assertCount(1, $identities); + $this->assertSame('jo@example.com', $identities[0]->email); + $this->assertNull($identities[0]->name); +}); + +test('should skip name containing url', function (): void { + $identities = (new EmailAddressParser('http://example.com '))->parse(); + + $this->assertSame('john@example.com', $identities[0]->email); + $this->assertNull($identities[0]->name); +}); diff --git a/tests/Unit/Services/Email/EmailContentParserTest.php b/tests/Unit/Services/Email/EmailContentParserTest.php index d67d44b..a7abfd4 100644 --- a/tests/Unit/Services/Email/EmailContentParserTest.php +++ b/tests/Unit/Services/Email/EmailContentParserTest.php @@ -2,241 +2,217 @@ declare(strict_types=1); -namespace Tests\Unit\Services\Email; - use App\Services\Email\EmailContentParser; -use Tests\TestCase; - -class EmailContentParserTest extends TestCase -{ - private EmailContentParser $parser; - - protected function setUp(): void - { - parent::setUp(); - $this->parser = $this->app->make(EmailContentParser::class); - } - - public function test_should_parse_markdown(): void - { - $content = <<<'MARKDOWN' - This is a paragraph. - - echo 'code'; - - > Take that! - MARKDOWN; - $expected = <<<'HTML' -

      This is a paragraph.

      -
      echo 'code';
      -        
      -
      -

      Take that!

      -
      - HTML; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_escape_html(): void - { - $content = 'Test of XSS injection'; - $this->assertEquals( - '

      Test of <strong>XSS</strong> <script>alert("xss")</script> injection

      ', - mb_trim($this->parser->parse($content)), - ); - } - - public function test_should_keep_line_breaks(): void - { - $content = <<<'MARKDOWN' - This is a paragraph - that spans on 2 lines: + +beforeEach(function (): void { + $this->parser = $this->app->make(EmailContentParser::class); +}); + +test('should parse markdown', function (): void { + $content = <<<'MARKDOWN' + This is a paragraph. echo 'code'; - echo 'another code; - MARKDOWN; - $expected = <<<'HTML' -

      This is a paragraph
      - that spans on 2 lines:

      -

      echo 'code';
      - echo 'another code;

      - HTML; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_encode_html_entities(): void - { - $content = <<<'EMAIL' - > and the test: - - test - - We use 2/3 vote for "a feature affecting the language itself". - EMAIL; - $expected = <<<'HTML' -
      -

      and the test: <hello>

      -
      -

      <foo></foo> test

      -

      We use 2/3 vote for "a feature affecting the language itself".

      - HTML; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_support_php_opening_tag(): void - { - $content = <<<'EMAIL' - one - - ini_set(); - EMAIL; - $expected = <<<'HTML' -

      one

      -

      <?php
      - echo $foo;

      -

      two <? hehe

      -
      -

      <?
      - ini_set();

      -
      - HTML; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_strip_mailing_list_signature(): void - { - $content = <<<'MARKDOWN' - Hello - - --- - PHP Internals - PHP Runtime Development Mailing List - To unsubscribe, visit: http://www.php.net/unsub.php - MARKDOWN; - $this->assertEquals('

      Hello

      ', mb_trim($this->parser->parse($content))); - } - - public function test_should_strip_unindented_trailing_quotation_1(): void - { - $content = <<<'MARKDOWN' - Hello Georges - - --- - - From: Georges Henry gh@example.com - Sent: Friday, June 24, 2016 6:50:59 PM - To: Pierre Lefroie - Cc: PHP internals - Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling - MARKDOWN; - $this->assertEquals('

      Hello Georges

      ', mb_trim($this->parser->parse($content))); - } - - public function test_should_strip_unindented_trailing_quotation_2(): void - { - $content = <<<'MARKDOWN' - Hello Georges - - ________________________________ - From: Georges Henry gh@example.com - Sent: Friday, June 24, 2016 6:50:59 PM - To: Pierre Lefroie - Cc: PHP internals - Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling - MARKDOWN; - $this->assertEquals('

      Hello Georges

      ', mb_trim($this->parser->parse($content))); - } - - public function test_should_strip_trailing_line_breaks(): void - { - $content = <<<'MARKDOWN' - Hello - - - MARKDOWN; - $this->assertEquals('

      Hello

      ', mb_trim($this->parser->parse($content))); - } - - public function test_should_linkify_links(): void - { - $content = 'Hello http://google.com'; - $expected = '

      Hello http://google.com

      '; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_detect_php_functions(): void - { - $content = 'Try to call preg_match() without parameters.'; - $expected = '

      Try to call preg_match() without parameters.

      '; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_detect_php_constants(): void - { - $content = 'Try to use PHP_INT_MAX and you will see.'; - $expected = '

      Try to use PHP_INT_MAX and you will see.

      '; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_handle_leading_blockquote(): void - { - $content = <<<'MARKDOWN' - > But you still have to remember to use a - > proper escaping function. - - I see no problem. - MARKDOWN; - $expected = <<<'HTML' -
      -

      But you still have to remember to use a
      - proper escaping function.

      -
      -

      I see no problem.

      - HTML; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } - - public function test_should_strip_quote_headers(): void - { - $content = << wrote:\r\n - - > But you still have to remember to use - - Test with indented quotation header: - - > Test that the blockquote is not cut in half - > - > On 30/06/16 23:46, Thomas Bley wrote: - > - > > But you still have to remember to use - > > - >>> On 30/06/16 23:46, Thomas Bley wrote: - >>> abc - - Trick: don't forget that On Wed, Stanislav wrote: - MARKDOWN; - $expected = <<<'HTML' -
      -

      But you still have to remember to use

      -
      -

      Test with indented quotation header:

      -
      -

      Test that the blockquote is not cut in half

      -
      -

      But you still have to remember to use

      -
      -

      abc

      -
      -
      -
      -

      Trick: don't forget that On Wed, Stanislav wrote:

      - HTML; - $this->assertEquals($expected, mb_trim($this->parser->parse($content))); - } -} + + > Take that! + MARKDOWN; + $expected = <<<'HTML' +

      This is a paragraph.

      +
      echo 'code';
      +    
      +
      +

      Take that!

      +
      + HTML; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should escape html', function (): void { + $content = 'Test of XSS injection'; + $this->assertEquals( + '

      Test of <strong>XSS</strong> <script>alert("xss")</script> injection

      ', + mb_trim($this->parser->parse($content)), + ); +}); + +test('should keep line breaks', function (): void { + $content = <<<'MARKDOWN' + This is a paragraph + that spans on 2 lines: + + echo 'code'; + echo 'another code; + MARKDOWN; + $expected = <<<'HTML' +

      This is a paragraph
      + that spans on 2 lines:

      +

      echo 'code';
      + echo 'another code;

      + HTML; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should encode html entities', function (): void { + $content = <<<'EMAIL' + > and the test: + + test + + We use 2/3 vote for "a feature affecting the language itself". + EMAIL; + $expected = <<<'HTML' +
      +

      and the test: <hello>

      +
      +

      <foo></foo> test

      +

      We use 2/3 vote for "a feature affecting the language itself".

      + HTML; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should support php opening tag', function (): void { + $content = <<<'EMAIL' + one + + ini_set(); + EMAIL; + $expected = <<<'HTML' +

      one

      +

      <?php
      + echo $foo;

      +

      two <? hehe

      +
      +

      <?
      + ini_set();

      +
      + HTML; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should strip mailing list signature', function (): void { + $content = <<<'MARKDOWN' + Hello + + --- + PHP Internals - PHP Runtime Development Mailing List + To unsubscribe, visit: http://www.php.net/unsub.php + MARKDOWN; + $this->assertEquals('

      Hello

      ', mb_trim($this->parser->parse($content))); +}); + +test('should strip unindented trailing quotation 1', function (): void { + $content = <<<'MARKDOWN' + Hello Georges + + --- + + From: Georges Henry gh@example.com + Sent: Friday, June 24, 2016 6:50:59 PM + To: Pierre Lefroie + Cc: PHP internals + Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling + MARKDOWN; + $this->assertEquals('

      Hello Georges

      ', mb_trim($this->parser->parse($content))); +}); + +test('should strip unindented trailing quotation 2', function (): void { + $content = <<<'MARKDOWN' + Hello Georges + + ________________________________ + From: Georges Henry gh@example.com + Sent: Friday, June 24, 2016 6:50:59 PM + To: Pierre Lefroie + Cc: PHP internals + Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling + MARKDOWN; + $this->assertEquals('

      Hello Georges

      ', mb_trim($this->parser->parse($content))); +}); + +test('should strip trailing line breaks', function (): void { + $content = <<<'MARKDOWN' + Hello + + + MARKDOWN; + $this->assertEquals('

      Hello

      ', mb_trim($this->parser->parse($content))); +}); + +test('should linkify links', function (): void { + $content = 'Hello http://google.com'; + $expected = '

      Hello http://google.com

      '; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should detect php functions', function (): void { + $content = 'Try to call preg_match() without parameters.'; + $expected = '

      Try to call preg_match() without parameters.

      '; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should detect php constants', function (): void { + $content = 'Try to use PHP_INT_MAX and you will see.'; + $expected = '

      Try to use PHP_INT_MAX and you will see.

      '; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should handle leading blockquote', function (): void { + $content = <<<'MARKDOWN' + > But you still have to remember to use a + > proper escaping function. + + I see no problem. + MARKDOWN; + $expected = <<<'HTML' +
      +

      But you still have to remember to use a
      + proper escaping function.

      +
      +

      I see no problem.

      + HTML; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); + +test('should strip quote headers', function (): void { + $content = << wrote:\r\n + + > But you still have to remember to use + + Test with indented quotation header: + + > Test that the blockquote is not cut in half + > + > On 30/06/16 23:46, Thomas Bley wrote: + > + > > But you still have to remember to use + > > + >>> On 30/06/16 23:46, Thomas Bley wrote: + >>> abc + + Trick: don't forget that On Wed, Stanislav wrote: + MARKDOWN; + $expected = <<<'HTML' +
      +

      But you still have to remember to use

      +
      +

      Test with indented quotation header:

      +
      +

      Test that the blockquote is not cut in half

      +
      +

      But you still have to remember to use

      +
      +

      abc

      +
      +
      +
      +

      Trick: don't forget that On Wed, Stanislav wrote:

      + HTML; + $this->assertEquals($expected, mb_trim($this->parser->parse($content))); +}); diff --git a/tests/Unit/Services/Email/EmailSubjectParserTest.php b/tests/Unit/Services/Email/EmailSubjectParserTest.php index 80a6f80..9575583 100644 --- a/tests/Unit/Services/Email/EmailSubjectParserTest.php +++ b/tests/Unit/Services/Email/EmailSubjectParserTest.php @@ -2,60 +2,42 @@ declare(strict_types=1); -namespace Tests\Unit\Services\Email; - use App\Services\Email\EmailSubjectParser; -use Tests\TestCase; - -class EmailSubjectParserTest extends TestCase -{ - private EmailSubjectParser $parser; - - protected function setUp(): void - { - parent::setUp(); - $this->parser = new EmailSubjectParser; - } - - public function test_should_strip_php_dev_tag(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Hello world')); - } - - public function test_should_strip_re_prefix(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('Re: Hello world')); - } - - public function test_should_strip_re_prefix_case_insensitively(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('RE: Hello world')); - $this->assertSame('Hello world', $this->parser->sanitize('re: Hello world')); - } - - public function test_should_strip_re_prefix_without_space(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('Re:Hello world')); - } - - public function test_should_strip_multiple_re_prefixes(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('Re: Re: Re: Hello world')); - } - - public function test_should_strip_php_dev_and_re_together(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('Re: [PHP-DEV] Hello world')); - $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Re: Hello world')); - } - - public function test_should_leave_normal_subject_untouched(): void - { - $this->assertSame('Hello world', $this->parser->sanitize('Hello world')); - } - - public function test_should_not_strip_re_in_the_middle_of_subject(): void - { - $this->assertSame('Talking about Re: discussions', $this->parser->sanitize('Talking about Re: discussions')); - } -} + +beforeEach(function (): void { + $this->parser = new EmailSubjectParser; +}); + +test('should strip php dev tag', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Hello world')); +}); + +test('should strip re prefix', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('Re: Hello world')); +}); + +test('should strip re prefix case insensitively', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('RE: Hello world')); + $this->assertSame('Hello world', $this->parser->sanitize('re: Hello world')); +}); + +test('should strip re prefix without space', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('Re:Hello world')); +}); + +test('should strip multiple re prefixes', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('Re: Re: Re: Hello world')); +}); + +test('should strip php dev and re together', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('Re: [PHP-DEV] Hello world')); + $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Re: Hello world')); +}); + +test('should leave normal subject untouched', function (): void { + $this->assertSame('Hello world', $this->parser->sanitize('Hello world')); +}); + +test('should not strip re in the middle of subject', function (): void { + $this->assertSame('Talking about Re: discussions', $this->parser->sanitize('Talking about Re: discussions')); +}); diff --git a/tests/Unit/Services/Rss/RssBuilderTest.php b/tests/Unit/Services/Rss/RssBuilderTest.php index af7300a..078834e 100644 --- a/tests/Unit/Services/Rss/RssBuilderTest.php +++ b/tests/Unit/Services/Rss/RssBuilderTest.php @@ -2,64 +2,55 @@ declare(strict_types=1); -namespace Tests\Unit\Services\Rss; - use App\Models\Email; use App\Services\Rss\RssBuilder; use DateTimeImmutable; use SimpleXMLElement; -use Tests\TestCase; - -class RssBuilderTest extends TestCase -{ - public function test_should_build_valid_rss_with_no_items(): void - { - $xml = (new RssBuilder('https://externals.io'))->build([]); - - $rss = new SimpleXMLElement($xml); - $this->assertSame('rss', $rss->getName()); - $this->assertSame('2.0', (string) $rss['version']); - $this->assertSame('#externals', (string) $rss->channel->title); - $this->assertSame('https://externals.io', (string) $rss->channel->link); - $this->assertCount(0, $rss->channel->item); - } - - public function test_should_build_item_for_each_email(): void - { - $email = new Email([ - 'id' => '', - 'number' => 42, - 'subject' => 'Hello world', - 'content' => '

      Body

      ', - 'date' => new DateTimeImmutable('2026-01-15 10:00:00'), - ]); - - $xml = (new RssBuilder('https://externals.io'))->build([$email]); - - $rss = new SimpleXMLElement($xml); - $this->assertCount(1, $rss->channel->item); - $item = $rss->channel->item[0]; - $this->assertSame('Hello world', (string) $item->title); - $this->assertSame('https://externals.io/message/42', (string) $item->link); - $this->assertSame('

      Body

      ', (string) $item->description); - $this->assertSame('', (string) $item->guid); - $this->assertSame((new DateTimeImmutable('2026-01-15 10:00:00'))->format('r'), (string) $item->pubDate); - } - - public function test_should_escape_special_characters_in_subject(): void - { - $email = new Email([ - 'id' => '', - 'number' => 1, - 'subject' => 'Subject with & "quotes"', - 'content' => '', - 'date' => new DateTimeImmutable('2026-01-01 00:00:00'), - ]); - - $xml = (new RssBuilder('https://externals.io'))->build([$email]); - // Round-trip through SimpleXMLElement: special chars must survive intact. - $rss = new SimpleXMLElement($xml); - $this->assertSame('Subject with & "quotes"', (string) $rss->channel->item[0]->title); - } -} +test('should build valid rss with no items', function (): void { + $xml = (new RssBuilder('https://externals.io'))->build([]); + + $rss = new SimpleXMLElement($xml); + $this->assertSame('rss', $rss->getName()); + $this->assertSame('2.0', (string) $rss['version']); + $this->assertSame('#externals', (string) $rss->channel->title); + $this->assertSame('https://externals.io', (string) $rss->channel->link); + $this->assertCount(0, $rss->channel->item); +}); + +test('should build item for each email', function (): void { + $email = new Email([ + 'id' => '', + 'number' => 42, + 'subject' => 'Hello world', + 'content' => '

      Body

      ', + 'date' => new DateTimeImmutable('2026-01-15 10:00:00'), + ]); + + $xml = (new RssBuilder('https://externals.io'))->build([$email]); + + $rss = new SimpleXMLElement($xml); + $this->assertCount(1, $rss->channel->item); + $item = $rss->channel->item[0]; + $this->assertSame('Hello world', (string) $item->title); + $this->assertSame('https://externals.io/message/42', (string) $item->link); + $this->assertSame('

      Body

      ', (string) $item->description); + $this->assertSame('', (string) $item->guid); + $this->assertSame((new DateTimeImmutable('2026-01-15 10:00:00'))->format('r'), (string) $item->pubDate); +}); + +test('should escape special characters in subject', function (): void { + $email = new Email([ + 'id' => '', + 'number' => 1, + 'subject' => 'Subject with & "quotes"', + 'content' => '', + 'date' => new DateTimeImmutable('2026-01-01 00:00:00'), + ]); + + $xml = (new RssBuilder('https://externals.io'))->build([$email]); + + // Round-trip through SimpleXMLElement: special chars must survive intact. + $rss = new SimpleXMLElement($xml); + $this->assertSame('Subject with & "quotes"', (string) $rss->channel->item[0]->title); +}); diff --git a/tests/Unit/Services/Rss/RssRfcBuilderTest.php b/tests/Unit/Services/Rss/RssRfcBuilderTest.php index d9c2c25..0086984 100644 --- a/tests/Unit/Services/Rss/RssRfcBuilderTest.php +++ b/tests/Unit/Services/Rss/RssRfcBuilderTest.php @@ -2,54 +2,62 @@ declare(strict_types=1); -namespace Tests\Unit\Services\Rss; - use App\Services\Rss\RssRfcBuilder; +use App\Support\Email\ThreadSummary; +use Carbon\CarbonImmutable; use DateTimeImmutable; use SimpleXMLElement; -use Tests\TestCase; - -class RssRfcBuilderTest extends TestCase -{ - public function test_should_build_valid_rss_with_no_threads(): void - { - $xml = (new RssRfcBuilder('https://externals.io'))->build([]); - - $rss = new SimpleXMLElement($xml); - $this->assertSame('rss', $rss->getName()); - $this->assertSame('#externals - Latest RFC Threads', (string) $rss->channel->title); - $this->assertSame('https://externals.io', (string) $rss->channel->link); - $this->assertCount(0, $rss->channel->item); - } - - public function test_should_build_item_for_each_thread(): void - { - $threads = [ - [ - 'number' => 100, - 'subject' => '[RFC] My proposal', - 'date' => '2026-02-10 12:34:56', - ], - [ - 'number' => 101, - 'subject' => '[RFC] Another proposal', - 'date' => '2026-02-11 09:00:00', - ], - ]; - - $xml = (new RssRfcBuilder('https://externals.io'))->build($threads); - - $rss = new SimpleXMLElement($xml); - $this->assertCount(2, $rss->channel->item); - - $first = $rss->channel->item[0]; - $this->assertSame('[RFC] My proposal', (string) $first->title); - $this->assertSame('https://externals.io/message/100', (string) $first->link); - $this->assertSame('[RFC] My proposal', (string) $first->description); - $this->assertSame('100', (string) $first->guid); - $this->assertSame((new DateTimeImmutable('2026-02-10 12:34:56'))->format('r'), (string) $first->pubDate); - - $second = $rss->channel->item[1]; - $this->assertSame('101', (string) $second->guid); - } -} + +test('should build valid rss with no threads', function (): void { + $xml = (new RssRfcBuilder('https://externals.io'))->build([]); + + $rss = new SimpleXMLElement($xml); + $this->assertSame('rss', $rss->getName()); + $this->assertSame('#externals - Latest RFC Threads', (string) $rss->channel->title); + $this->assertSame('https://externals.io', (string) $rss->channel->link); + $this->assertCount(0, $rss->channel->item); +}); + +test('should build item for each thread', function (): void { + $threads = [ + new ThreadSummary( + number: 100, + subject: '[RFC] My proposal', + date: CarbonImmutable::parse('2026-02-10 12:34:56'), + fromName: null, + fromEmail: null, + emailCount: 1, + lastUpdate: CarbonImmutable::parse('2026-02-10 12:34:56'), + votes: 0, + isRead: false, + userVote: null, + ), + new ThreadSummary( + number: 101, + subject: '[RFC] Another proposal', + date: CarbonImmutable::parse('2026-02-11 09:00:00'), + fromName: null, + fromEmail: null, + emailCount: 1, + lastUpdate: CarbonImmutable::parse('2026-02-11 09:00:00'), + votes: 0, + isRead: false, + userVote: null, + ), + ]; + + $xml = (new RssRfcBuilder('https://externals.io'))->build($threads); + + $rss = new SimpleXMLElement($xml); + $this->assertCount(2, $rss->channel->item); + + $first = $rss->channel->item[0]; + $this->assertSame('[RFC] My proposal', (string) $first->title); + $this->assertSame('https://externals.io/message/100', (string) $first->link); + $this->assertSame('[RFC] My proposal', (string) $first->description); + $this->assertSame('100', (string) $first->guid); + $this->assertSame((new DateTimeImmutable('2026-02-10 12:34:56'))->format('r'), (string) $first->pubDate); + + $second = $rss->channel->item[1]; + $this->assertSame('101', (string) $second->guid); +});