diff --git a/.env b/.env index efc547f..b4fe145 100644 --- a/.env +++ b/.env @@ -2,9 +2,9 @@ NGINX_IMAGE=nginx:stable-alpine3.23 # Backend -APP_IMAGE=split-fairly-dev:0.1.4 +APP_IMAGE=split-fairly-dev:0.1.5 APP_NAME=split-fairly -APP_VERSION=0.1.4 +APP_VERSION=0.1.5 APP_ENV=dev ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret diff --git a/Makefile b/Makefile index e654cad..e125918 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ APP_NAME = split-fairly -VERSION = 0.1.4 +VERSION = 0.1.5 .DEFAULT_GOAL := help @@ -18,10 +18,13 @@ help: @echo " make show-composer-updates - Show outdated composer packages" @echo " make update-composer-dependencies - Update composer packages" @echo " make update-npm-dependencies - Update npm packages" + @echo " make npm-install - Install npm packages and regenerate lock file (if it is outdated)" @echo "\n๐Ÿงช Testing & Quality:" @echo " make test - Run backend and frontend tests" @echo " make quality - Run quality checks" @echo " make phpstan - Run static code analysis" + @echo " make rector - Run rector code modernizer (dry-run)" + @echo " make rector-apply - Apply rector transformations" @echo " make style - Fix code style" @echo " make arch - Test architecture" @echo " make coverage - Generate coverage report" @@ -116,6 +119,14 @@ phpstan: @echo "๐Ÿ” Running static code analysis..." docker compose exec -it app vendor/bin/phpstan analyse --memory-limit=1G +rector: + @echo "โ™ป๏ธ Running rector (dry-run)..." + docker compose exec -it app /root/.composer/vendor/bin/rector process --dry-run + +rector-apply: + @echo "โ™ป๏ธ Applying rector transformations..." + docker compose exec -it app /root/.composer/vendor/bin/rector process + cs: style style: codestyle codestyle: code-style @@ -144,7 +155,7 @@ clear: docker compose exec -it app composer clear maintenance: maintain -maintain: show-composer-updates update-composer-dependencies update-npm-dependencies +maintain: show-composer-updates update-composer-dependencies update-npm-dependencies npm-install show-composer-updates: @echo "๐Ÿ“Š Checking for outdated composer packages..." @@ -158,6 +169,10 @@ update-npm-dependencies: @echo "๐Ÿ“ฆ Updating npm dependencies..." docker compose exec -it npm-dev npm update --save +npm-install: + @echo "๐Ÿ“ฆ Installing npm dependencies and regenerating lock file..." + docker compose exec -it npm-dev npm install + coverage: @echo "๐Ÿ“ˆ Generating coverage report..." docker compose exec -it app bin/phpunit -c phpunit.xml.dist --coverage-html ./coverage diff --git a/backend/composer.json b/backend/composer.json index 2168914..7d282a6 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -107,8 +107,8 @@ "require-dev": { "deptrac/deptrac": "^4.6", "friendsofphp/php-cs-fixer": "^3.94.2", - "phpstan/phpstan": "^2.1.42", - "phpunit/phpunit": "^13.0.5", + "phpstan/phpstan": "^2.1.45", + "phpunit/phpunit": "^13.0.6", "symfony/maker-bundle": "^1.67.0", "symfony/stopwatch": "8.0.*", "symfony/web-profiler-bundle": "8.0.*" diff --git a/backend/composer.lock b/backend/composer.lock index 79270a3..d6dcfb0 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "709e034126c4c31b85589c1ffb5b15c5", + "content-hash": "f9f5b48ba300e2854d2e42b6da872f48", "packages": [ { "name": "doctrine/collections", @@ -177,16 +177,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.2", + "version": "4.4.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722" + "reference": "61e730f1658814821a85f2402c945f3883407dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722", - "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", "shasum": "" }, "require": { @@ -263,7 +263,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.2" + "source": "https://github.com/doctrine/dbal/tree/4.4.3" }, "funding": [ { @@ -279,7 +279,7 @@ "type": "tidelift" } ], - "time": "2026-02-26T12:12:19+00:00" + "time": "2026-03-20T08:52:12+00:00" }, { "name": "doctrine/deprecations", @@ -6936,7 +6936,7 @@ }, { "name": "symfony/ux-twig-component", - "version": "v2.33.0", + "version": "v2.34.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", @@ -6999,7 +6999,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v2.33.0" + "source": "https://github.com/symfony/ux-twig-component/tree/v2.34.0" }, "funding": [ { @@ -8705,11 +8705,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.42", + "version": "2.1.45", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", - "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f8cdfd9421b7edb7686a2d150a234870464eac70", + "reference": "f8cdfd9421b7edb7686a2d150a234870464eac70", "shasum": "" }, "require": { @@ -8754,7 +8754,7 @@ "type": "github" } ], - "time": "2026-03-17T14:58:32+00:00" + "time": "2026-03-30T13:22:02+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9140,16 +9140,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.0.5", + "version": "13.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d57826e8921a534680c613924bfd921ded8047f4" + "reference": "9e426f7282c313c9138eeb9f25461e1a6be1e647" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d57826e8921a534680c613924bfd921ded8047f4", - "reference": "d57826e8921a534680c613924bfd921ded8047f4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9e426f7282c313c9138eeb9f25461e1a6be1e647", + "reference": "9e426f7282c313c9138eeb9f25461e1a6be1e647", "shasum": "" }, "require": { @@ -9171,7 +9171,7 @@ "sebastian/cli-parser": "^5.0.0", "sebastian/comparator": "^8.0.0", "sebastian/diff": "^8.0.0", - "sebastian/environment": "^9.0.0", + "sebastian/environment": "^9.1.0", "sebastian/exporter": "^8.0.0", "sebastian/global-state": "^9.0.0", "sebastian/object-enumerator": "^8.0.0", @@ -9218,31 +9218,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.5" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.6" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:40:03+00:00" + "time": "2026-03-31T06:44:39+00:00" }, { "name": "react/cache", @@ -10082,16 +10066,16 @@ }, { "name": "sebastian/environment", - "version": "9.0.1", + "version": "9.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "e26e9a944bd9d27b3a38a82fc2093d440951bfbe" + "reference": "c4a2dc54b1a24e13ef1839cbb5947b967cbae853" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/e26e9a944bd9d27b3a38a82fc2093d440951bfbe", - "reference": "e26e9a944bd9d27b3a38a82fc2093d440951bfbe", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c4a2dc54b1a24e13ef1839cbb5947b967cbae853", + "reference": "c4a2dc54b1a24e13ef1839cbb5947b967cbae853", "shasum": "" }, "require": { @@ -10106,7 +10090,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.0-dev" + "dev-main": "9.1-dev" } }, "autoload": { @@ -10134,7 +10118,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.0.1" + "source": "https://github.com/sebastianbergmann/environment/tree/9.1.0" }, "funding": [ { @@ -10154,7 +10138,7 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:13:02+00:00" + "time": "2026-03-22T06:31:50+00:00" }, { "name": "sebastian/exporter", diff --git a/backend/migrations/Version20260128102345.php b/backend/migrations/Version20260128102345.php new file mode 100644 index 0000000..b681910 --- /dev/null +++ b/backend/migrations/Version20260128102345.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE report DROP PRIMARY KEY, MODIFY id INT NOT NULL'); + $this->addSql('ALTER TABLE report DROP uuid, DROP compensation_id, DROP checksum, DROP id'); + $this->addSql('ALTER TABLE report ADD id VARCHAR(64) NOT NULL PRIMARY KEY'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE report ADD uuid BINARY(16) NOT NULL, ADD compensation_id VARCHAR(255) NOT NULL, ADD checksum VARCHAR(64) NOT NULL, CHANGE id id INT AUTO_INCREMENT NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_C42F7784D17F50A6 ON report (uuid)'); + } +} diff --git a/backend/phpstan-baseline.neon b/backend/phpstan-baseline.neon index c77b2c1..7a8dac7 100644 --- a/backend/phpstan-baseline.neon +++ b/backend/phpstan-baseline.neon @@ -6,12 +6,6 @@ parameters: count: 1 path: public/index.php - - - message: '#^Property App\\Entity\\Report\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#' - identifier: property.unusedType - count: 1 - path: src/Entity/Report.php - - message: '#^Property App\\Entity\\User\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#' identifier: property.unusedType diff --git a/backend/rector.php b/backend/rector.php new file mode 100644 index 0000000..ec40185 --- /dev/null +++ b/backend/rector.php @@ -0,0 +1,24 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + // Parallel processing (faster) + ->withParallel() + // Target PHP 8.5 + ->withPhpSets(php85: true) + ->withSets([ + // Symfony 8.0 rules + SymfonySetList::SYMFONY_80, + // Code quality + SetList::CODE_QUALITY, + SetList::CODING_STYLE, + ]); diff --git a/backend/src/Async/GenerateReportHandler.php b/backend/src/Async/GenerateReportHandler.php index 085311c..44de5da 100644 --- a/backend/src/Async/GenerateReportHandler.php +++ b/backend/src/Async/GenerateReportHandler.php @@ -18,15 +18,15 @@ use Twig\Environment; #[AsMessageHandler] -final class GenerateReportHandler +final readonly class GenerateReportHandler { public function __construct( - private readonly Instrumentation $instrumentation, - private readonly EntityManagerInterface $entityManager, - private readonly ReportRepository $reportRepository, - private readonly Calculator $calculator, - private readonly Environment $twig, - #[Autowire('%kernel.project_dir%/var/reports')] private readonly string $reportsDir, + private Instrumentation $instrumentation, + private EntityManagerInterface $entityManager, + private ReportRepository $reportRepository, + private Calculator $calculator, + private Environment $twig, + #[Autowire('%kernel.project_dir%/var/reports')] private string $reportsDir, ) { } @@ -39,9 +39,9 @@ public function __invoke(GenerateReportMessage $message): void // Clear any stale entity state $this->entityManager->clear(); - $report = $this->reportRepository->find($message->reportId); + $report = $this->reportRepository->find($message->id); if (!$report instanceof Report) { - throw new \RuntimeException(sprintf('Report with ID %d not found', $message->reportId)); + throw new \RuntimeException(sprintf('Report with ID %s not found', $message->id)); } $report->setStatus(Report::STATUS_GENERATING); @@ -67,7 +67,7 @@ public function __invoke(GenerateReportMessage $message): void $dompdf->render(); $pdfContent = $dompdf->output(); - $fileName = sprintf('report-%s.pdf', $report->getUuid()->toRfc4122()); + $fileName = sprintf('report-%s.pdf', $report->getId()); $filePath = $this->reportsDir.DIRECTORY_SEPARATOR.$fileName; Ensure::that(false !== file_put_contents($filePath, $pdfContent)); @@ -82,20 +82,20 @@ public function __invoke(GenerateReportMessage $message): void $fileName, $timer->getMillisecondsElapsed() )); - } catch (\Exception $e) { + } catch (\Exception $exception) { $this->instrumentation->getLogging()->info(sprintf( 'Failed to generate report: %s', - $e->getMessage() + $exception->getMessage() )); if ($report instanceof Report) { $report->setStatus(Report::STATUS_FAILED); - $report->setErrorMessage($e->getMessage()); + $report->setErrorMessage($exception->getMessage()); $report->setCompletedAt(new \DateTimeImmutable()); $this->entityManager->flush(); } - throw $e; + throw $exception; } } diff --git a/backend/src/Async/GenerateReportMessage.php b/backend/src/Async/GenerateReportMessage.php index fa09f02..56fcf55 100644 --- a/backend/src/Async/GenerateReportMessage.php +++ b/backend/src/Async/GenerateReportMessage.php @@ -4,14 +4,19 @@ namespace App\Async; +use App\Entity\Report; use Symfony\Component\Messenger\Attribute\AsMessage; #[AsMessage(transport: 'async')] -final class GenerateReportMessage +final readonly class GenerateReportMessage { - public function __construct( - public readonly int $reportId, - public readonly string $compensationId, + private function __construct( + public string $id, ) { } + + public static function fromReport(Report $report): self + { + return new self($report->getId()); + } } diff --git a/backend/src/Async/Message.php b/backend/src/Async/Message.php index cf15f53..8e68d28 100644 --- a/backend/src/Async/Message.php +++ b/backend/src/Async/Message.php @@ -7,11 +7,11 @@ use Symfony\Component\Messenger\Attribute\AsMessage; #[AsMessage(transport: 'async')] -final class Message +final readonly class Message { private function __construct( - public readonly float $createdAt, - public readonly string $type, + public float $createdAt, + public string $type, ) { } diff --git a/backend/src/Async/MessageHandler.php b/backend/src/Async/MessageHandler.php index 149e12c..a6c74af 100644 --- a/backend/src/Async/MessageHandler.php +++ b/backend/src/Async/MessageHandler.php @@ -25,8 +25,8 @@ private function tryHandle(Message $message): void try { $this->handle($message); - } catch (\Throwable $ex) { - $tracer->recordException('Handling the message has failed', $ex); + } catch (\Throwable $throwable) { + $tracer->recordException('Handling the message has failed', $throwable); } finally { $elapsed = Stopwatch::from($message->createdAt)->getMillisecondsElapsed(); $this->instrumentation->getMetrics()->record('handle_message', $elapsed, 'ms'); diff --git a/backend/src/Command/AddUserCommand.php b/backend/src/Command/AddUserCommand.php index efa2733..6a7dd63 100644 --- a/backend/src/Command/AddUserCommand.php +++ b/backend/src/Command/AddUserCommand.php @@ -47,7 +47,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Check if user already exists $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]); - if ($existingUser) { + if ($existingUser instanceof User) { $io->error(sprintf('User with email "%s" already exists', $email)); return Command::FAILURE; diff --git a/backend/src/Command/ReportsCleanupCommand.php b/backend/src/Command/ReportsCleanupCommand.php index fc13a1e..f24a755 100644 --- a/backend/src/Command/ReportsCleanupCommand.php +++ b/backend/src/Command/ReportsCleanupCommand.php @@ -41,11 +41,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $daysOption = $input->getOption('days'); assert(is_numeric($daysOption), 'Days option is not a number!'); $days = (int) $daysOption; - $cutoffDate = new \DateTimeImmutable("-{$days} days"); + $cutoffDate = new \DateTimeImmutable(sprintf('-%d days', $days)); $oldReports = $this->reportRepository->findOlderThan($cutoffDate); - if (empty($oldReports)) { + if ([] === $oldReports) { $output->writeln('No old reports found.'); return Command::SUCCESS; diff --git a/backend/src/Controller/API/CalculateExpensesController.php b/backend/src/Controller/API/CalculateExpensesController.php index 705c40c..fae6838 100644 --- a/backend/src/Controller/API/CalculateExpensesController.php +++ b/backend/src/Controller/API/CalculateExpensesController.php @@ -30,7 +30,7 @@ public function calculate(Request $request): JsonResponse $stopwatch = Stopwatch::start(); $currentUser = $this->getUser(); - if (!$currentUser) { + if (!$currentUser instanceof \Symfony\Component\Security\Core\User\UserInterface) { return $this->json([ 'error' => 'Please login first!', ], Response::HTTP_UNAUTHORIZED); @@ -45,7 +45,7 @@ public function calculate(Request $request): JsonResponse if ($withUserEmail) { // Find the specific user to calculate with $selectedUser = $this->userRepository->findOneBy(['email' => $withUserEmail]); - if (!$selectedUser) { + if (!$selectedUser instanceof \App\Entity\User) { return $this->json([ 'error' => sprintf('User %s not found!', $withUserEmail), ], Response::HTTP_BAD_REQUEST); @@ -87,7 +87,7 @@ public function calculate(Request $request): JsonResponse InstrumentationHolder::getLogging() ->info(sprintf('Calculated: %s (withUser: %s)', $compensation, $withUserEmail ?? 'all')); - return $this->json([ + $data = [ 'users' => array_map( static fn (Expenses $e) => [ 'user_email' => $e->userEmail, @@ -96,6 +96,11 @@ public function calculate(Request $request): JsonResponse $expenses ), 'compensation' => $compensation, - ]); + ]; + + $json = json_encode($data); + assert(is_string($json)); + + return $this->json([...$data, 'id' => hash('sha256', $json)]); } } diff --git a/backend/src/Controller/API/ListUsersController.php b/backend/src/Controller/API/ListUsersController.php index aa9af7b..c2624f3 100644 --- a/backend/src/Controller/API/ListUsersController.php +++ b/backend/src/Controller/API/ListUsersController.php @@ -2,6 +2,7 @@ namespace App\Controller\API; +use App\Entity\User; use App\Repository\UserRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -21,20 +22,24 @@ public function listUsers(): JsonResponse { $currentUser = $this->getUser(); - if (!$currentUser) { + if (!$currentUser instanceof \Symfony\Component\Security\Core\User\UserInterface) { return $this->json([ 'error' => 'Please login first!', ], Response::HTTP_UNAUTHORIZED); } - // Get all users except the current user - $allUsers = $this->userRepository->findAll(); - $otherUsers = array_filter($allUsers, fn ($user) => $user->getUserIdentifier() !== $currentUser->getUserIdentifier()); - - $users = array_map(fn ($user) => [ - 'id' => $user->getUuid()->toRfc4122(), - 'email' => $user->getEmail(), - ], $otherUsers); + // Provide only other non-admin users: + $users = array_map( + static fn (User $user) => [ + 'id' => $user->getUuid()->toRfc4122(), + 'email' => $user->getEmail(), + ], + array_filter( + $this->userRepository->findAll(), + static fn (User $user) => !$user->isAdmin() + && $user->getUserIdentifier() !== $currentUser->getUserIdentifier() + ) + ); return $this->json(['users' => array_values($users)]); } diff --git a/backend/src/Controller/API/MeController.php b/backend/src/Controller/API/MeController.php index d103451..8bc5a77 100644 --- a/backend/src/Controller/API/MeController.php +++ b/backend/src/Controller/API/MeController.php @@ -18,7 +18,7 @@ public function me(): JsonResponse $user = $this->getUser(); - if (!$user) { + if (!$user instanceof \Symfony\Component\Security\Core\User\UserInterface) { return $this->json([ 'error' => 'Please login first!', ], Response::HTTP_UNAUTHORIZED); diff --git a/backend/src/Controller/API/ReportController.php b/backend/src/Controller/API/ReportController.php index e5afd47..c7b30e7 100644 --- a/backend/src/Controller/API/ReportController.php +++ b/backend/src/Controller/API/ReportController.php @@ -8,10 +8,10 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Uid\Uuid; #[Route('/api', name: 'api.')] final class ReportController extends AbstractController @@ -24,40 +24,31 @@ public function __construct( } #[Route('/report/calculation', name: 'report.calculation', methods: ['POST'])] - public function initiate(): JsonResponse + public function initiate(Request $request): JsonResponse { - $compensationId = (new \DateTimeImmutable())->format('Y-m-d'); - $checksum = hash('sha256', $compensationId); + $id = $request->query->get('id'); + if (!$id) { + return new JsonResponse(['error' => 'Missing id parameter'], Response::HTTP_BAD_REQUEST); + } - $existingReport = $this->reportRepository->findByCompensationIdAndChecksum( - $compensationId, - $checksum - ); + $report = $this->reportRepository->find($id); - if ($existingReport) { - return new JsonResponse([ - 'id' => $existingReport->getUuid()->toRfc4122(), - 'status' => $existingReport->getStatus(), - 'filePath' => $existingReport->getFilePath(), + if ($report instanceof Report) { + return $this->json([ + 'id' => $report->getId(), + 'status' => $report->getStatus(), + 'filePath' => $report->getFilePath(), ]); } - $report = new Report($compensationId, $checksum); + $report = new Report($id); $this->entityManager->persist($report); $this->entityManager->flush(); - $reportId = $report->getId(); - if (null === $reportId) { - throw new \RuntimeException('Report ID should not be null after flush'); - } - - $this->messageBus->dispatch(new GenerateReportMessage( - $reportId, - $compensationId - )); + $this->messageBus->dispatch(GenerateReportMessage::fromReport($report)); return new JsonResponse([ - 'id' => $report->getUuid()->toRfc4122(), + 'id' => $report->getId(), 'status' => $report->getStatus(), ], Response::HTTP_ACCEPTED); } @@ -65,26 +56,20 @@ public function initiate(): JsonResponse #[Route('/report/{id}/status', name: 'report.status', methods: ['GET'])] public function getStatus(string $id): JsonResponse { - try { - $uuid = Uuid::fromString($id); - } catch (\Exception) { - return new JsonResponse(['error' => 'Invalid report ID'], Response::HTTP_BAD_REQUEST); - } - - $report = $this->reportRepository->findOneBy(['uuid' => $uuid]); + $report = $this->reportRepository->find($id); - if (!$report) { + if (!$report instanceof Report) { return new JsonResponse(['error' => 'Report not found'], Response::HTTP_NOT_FOUND); } $response = [ - 'id' => $report->getUuid()->toRfc4122(), + 'id' => $report->getId(), 'status' => $report->getStatus(), 'createdAt' => $report->getCreatedAt()->format(\DateTimeInterface::ATOM), ]; if ($report->isCompleted() && $report->getFilePath()) { - $response['downloadUrl'] = sprintf('/api/report/%s/download', $report->getUuid()->toRfc4122()); + $response['downloadUrl'] = sprintf('/api/report/%s/download', $report->getId()); } if ($report->isFailed()) { @@ -97,13 +82,7 @@ public function getStatus(string $id): JsonResponse #[Route('/report/{id}/download', name: 'report.download', methods: ['GET'])] public function download(string $id): Response { - try { - $uuid = Uuid::fromString($id); - } catch (\Exception) { - return new Response('Invalid report ID', Response::HTTP_BAD_REQUEST); - } - - $report = $this->reportRepository->findOneBy(['uuid' => $uuid]); + $report = $this->reportRepository->find($id); if (!$report || !$report->isCompleted() || !$report->getFilePath()) { return new Response('Report not found or not ready', Response::HTTP_NOT_FOUND); diff --git a/backend/src/Controller/API/TrackExpenseController.php b/backend/src/Controller/API/TrackExpenseController.php index f61b170..8d01b98 100644 --- a/backend/src/Controller/API/TrackExpenseController.php +++ b/backend/src/Controller/API/TrackExpenseController.php @@ -25,7 +25,7 @@ public function track(#[MapRequestPayload] Expense $expense): JsonResponse { $currentUser = $this->getUser(); - if (!$currentUser) { + if (!$currentUser instanceof \Symfony\Component\Security\Core\User\UserInterface) { return $this->json([ 'error' => 'Please login first!', ], Response::HTTP_UNAUTHORIZED); @@ -45,7 +45,7 @@ public function track(#[MapRequestPayload] Expense $expense): JsonResponse // Check recipient email exists in the system $recipientUser = $this->userRepository->findOneBy(['email' => $recipientEmail]); - if (!$recipientUser) { + if (!$recipientUser instanceof \App\Entity\User) { return $this->json([ 'error' => sprintf('User %s not found!', $recipientEmail), ], Response::HTTP_BAD_REQUEST); diff --git a/backend/src/Controller/Admin/DashboardController.php b/backend/src/Controller/Admin/DashboardController.php index 1cffdf4..3f291ee 100644 --- a/backend/src/Controller/Admin/DashboardController.php +++ b/backend/src/Controller/Admin/DashboardController.php @@ -19,6 +19,7 @@ public function __construct( } #[IsGranted('ROLE_ADMIN')] + #[\Override] public function index(): Response { $url = $this->adminUrlGenerator @@ -28,12 +29,14 @@ public function index(): Response return $this->redirect($url); } + #[\Override] public function configureDashboard(): Dashboard { return Dashboard::new() ->setTitle('Split Fairly Admin'); } + #[\Override] public function configureMenuItems(): iterable { yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); diff --git a/backend/src/Controller/Admin/EventCrudController.php b/backend/src/Controller/Admin/EventCrudController.php index a8b60fa..5465a7b 100644 --- a/backend/src/Controller/Admin/EventCrudController.php +++ b/backend/src/Controller/Admin/EventCrudController.php @@ -34,10 +34,11 @@ public static function getEntityFqcn(): string return EventEntity::class; } - public function __construct(private EventStoreInterface $eventRepository) + public function __construct(private readonly EventStoreInterface $eventRepository) { } + #[\Override] public function configureCrud(Crud $crud): Crud { return $crud @@ -50,6 +51,7 @@ public function configureCrud(Crud $crud): Crud ->setPageTitle(Crud::PAGE_EDIT, 'Edit Event'); } + #[\Override] public function configureActions(Actions $actions): Actions { $wipe = Action::new('wipeEvents', 'Wipe Events', 'fa fa-trash') @@ -62,6 +64,7 @@ public function configureActions(Actions $actions): Actions ->add(Crud::PAGE_INDEX, $wipe); } + #[\Override] public function configureFields(string $pageName): iterable { yield IdField::new('id') @@ -92,6 +95,7 @@ public function configureFields(string $pageName): iterable // trying to convert the array value to a string during field configuration } + #[\Override] public function createEditFormBuilder( EntityDto $entityDto, KeyValueStore $formOptions, @@ -103,6 +107,7 @@ public function createEditFormBuilder( return $builder; } + #[\Override] public function createNewFormBuilder( EntityDto $entityDto, KeyValueStore $formOptions, diff --git a/backend/src/Controller/Admin/UserCrudController.php b/backend/src/Controller/Admin/UserCrudController.php index 9bb166e..9f35005 100644 --- a/backend/src/Controller/Admin/UserCrudController.php +++ b/backend/src/Controller/Admin/UserCrudController.php @@ -30,6 +30,7 @@ public static function getEntityFqcn(): string return User::class; } + #[\Override] public function configureCrud(Crud $crud): Crud { return $crud @@ -39,12 +40,14 @@ public function configureCrud(Crud $crud): Crud ->setDefaultSort(['id' => 'DESC']); } + #[\Override] public function configureActions(Actions $actions): Actions { return $actions ->add(Crud::PAGE_INDEX, Action::DETAIL); } + #[\Override] public function configureFields(string $pageName): iterable { yield IdField::new('id') @@ -71,6 +74,7 @@ public function configureFields(string $pageName): iterable yield $passwordField; } + #[\Override] public function persistEntity(EntityManagerInterface $entityManager, $entityInstance): void { /* @var User $entityInstance */ @@ -78,6 +82,7 @@ public function persistEntity(EntityManagerInterface $entityManager, $entityInst parent::persistEntity($entityManager, $entityInstance); } + #[\Override] public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void { /* @var User $entityInstance */ @@ -89,7 +94,7 @@ private function hashPassword(User $user): void { $plainPassword = $user->getPassword(); - if (!empty($plainPassword)) { + if (!in_array($plainPassword, [null, '', '0'], true)) { $hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword); $user->setPassword($hashedPassword); } diff --git a/backend/src/Controller/SpaController.php b/backend/src/Controller/SpaController.php index 6a086b5..8a21667 100644 --- a/backend/src/Controller/SpaController.php +++ b/backend/src/Controller/SpaController.php @@ -32,7 +32,7 @@ public function index(): Response // Production: serve built HTML $buildIndex = $this->projectDir.'/public/build/index.html'; if (!file_exists($buildIndex)) { - throw new \RuntimeException("SPA build not found at {$buildIndex}. Run make npm-build!"); + throw new \RuntimeException(sprintf('SPA build not found at %s. Run make npm-build!', $buildIndex)); } $content = file_get_contents($buildIndex); diff --git a/backend/src/DataFixtures/AppFixtures.php b/backend/src/DataFixtures/AppFixtures.php index b57dfa4..663113b 100644 --- a/backend/src/DataFixtures/AppFixtures.php +++ b/backend/src/DataFixtures/AppFixtures.php @@ -13,13 +13,13 @@ final class AppFixtures extends Fixture { public function __construct( #[Autowire('%admin.email%')] - private string $adminEmail, + private readonly string $adminEmail, #[Autowire('%admin.password%')] - private string $adminPassword, + private readonly string $adminPassword, private readonly UserPasswordHasherInterface $passwordHasher, ) { - Ensure::that(!empty($adminEmail)); - Ensure::that(!empty($adminPassword)); + Ensure::that('' !== $adminEmail && '0' !== $adminEmail); + Ensure::that('' !== $adminPassword && '0' !== $adminPassword); } public function load(ObjectManager $manager): void diff --git a/backend/src/Entity/Event.php b/backend/src/Entity/Event.php index abf9643..5b2560d 100644 --- a/backend/src/Entity/Event.php +++ b/backend/src/Entity/Event.php @@ -14,40 +14,21 @@ class Event #[ORM\Column(type: UuidType::NAME, unique: true)] private Uuid $id; - #[ORM\Column] - private \DateTimeImmutable $createdAt; - - #[ORM\Column] - private string $createdBy; - - #[ORM\Column] - private string $subjectType; - - #[ORM\Column] - private string $subjectId; - - #[ORM\Column] - private string $eventType; - - /** @var array $payload */ - #[ORM\Column] - private array $payload; - /** @param array $payload */ public function __construct( - string $createdBy, - string $subjectType, - string $subjectId, - string $eventType, - array $payload = [], - \DateTimeImmutable $createdAt = new \DateTimeImmutable('now'), + #[ORM\Column] + private string $createdBy, + #[ORM\Column] + private string $subjectType, + #[ORM\Column] + private string $subjectId, + #[ORM\Column] + private string $eventType, + #[ORM\Column] + private array $payload = [], + #[ORM\Column] + private \DateTimeImmutable $createdAt = new \DateTimeImmutable('now'), ) { - $this->createdAt = $createdAt; - $this->createdBy = $createdBy; - $this->subjectType = $subjectType; - $this->subjectId = $subjectId; - $this->eventType = $eventType; - $this->payload = $payload; $this->id = new UuidV7(); } diff --git a/backend/src/Entity/Report.php b/backend/src/Entity/Report.php index f0d2bdf..a4de882 100644 --- a/backend/src/Entity/Report.php +++ b/backend/src/Entity/Report.php @@ -4,36 +4,23 @@ use App\Repository\ReportRepository; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Uid\Uuid; -use Symfony\Component\Uid\UuidV7; #[ORM\Entity(repositoryClass: ReportRepository::class)] -#[ORM\Index(columns: ['status'], name: 'idx_status')] -#[ORM\Index(columns: ['created_at'], name: 'idx_created_at')] +#[ORM\Index(name: 'idx_status', columns: ['status'])] +#[ORM\Index(name: 'idx_created_at', columns: ['created_at'])] class Report { public const STATUS_PENDING = 'pending'; + public const STATUS_GENERATING = 'generating'; - public const STATUS_COMPLETED = 'completed'; - public const STATUS_FAILED = 'failed'; - #[ORM\Id] - #[ORM\GeneratedValue] - #[ORM\Column] - private ?int $id = null; + public const STATUS_COMPLETED = 'completed'; - #[ORM\Column(type: 'uuid', unique: true)] - private Uuid $uuid; + public const STATUS_FAILED = 'failed'; #[ORM\Column(length: 50)] private string $status = self::STATUS_PENDING; - #[ORM\Column] - private string $compensationId; - - #[ORM\Column(length: 64)] - private string $checksum; - #[ORM\Column(length: 255, nullable: true)] private ?string $filePath = null; @@ -46,24 +33,19 @@ class Report #[ORM\Column(type: 'text', nullable: true)] private ?string $errorMessage = null; - public function __construct(string $compensationId, string $checksum) - { - $this->uuid = new UuidV7(); - $this->compensationId = $compensationId; - $this->checksum = $checksum; + public function __construct( + #[ORM\Id] + #[ORM\Column(length: 64, unique: true)] + private string $id, + ) { $this->createdAt = new \DateTimeImmutable(); } - public function getId(): ?int + public function getId(): string { return $this->id; } - public function getUuid(): Uuid - { - return $this->uuid; - } - public function getStatus(): string { return $this->status; @@ -76,16 +58,6 @@ public function setStatus(string $status): self return $this; } - public function getCompensationId(): string - { - return $this->compensationId; - } - - public function getChecksum(): string - { - return $this->checksum; - } - public function getFilePath(): ?string { return $this->filePath; diff --git a/backend/src/Entity/User.php b/backend/src/Entity/User.php index e80a581..37e0f3e 100644 --- a/backend/src/Entity/User.php +++ b/backend/src/Entity/User.php @@ -46,7 +46,7 @@ public function __construct() /** @param string[] $roles */ public static function create(string $email, array $roles = []): self { - Ensure::that(!empty($email), new \InvalidArgumentException('Email should not be empty!')); + Ensure::that('' !== $email && '0' !== $email, new \InvalidArgumentException('Email should not be empty!')); $user = new User(); $user->email = $email; @@ -83,7 +83,7 @@ public function setEmail(string $email): void public function getUserIdentifier(): string { $email = $this->email; - assert(!empty($email)); + assert(!in_array($email, [null, '', '0'], true)); return $email; } @@ -102,6 +102,11 @@ public function getRoles(): array return array_unique($roles); } + public function isAdmin(): bool + { + return in_array('ROLE_ADMIN', $this->roles, strict: true); + } + /** * @param string[] $roles */ @@ -123,13 +128,9 @@ public function setPassword(string $password): void $this->password = $password; } - /** - * @see UserInterface - */ - public function eraseCredentials(): void + public function serialize(): void { trigger_deprecation('Symfony', '7.3', 'erase credentials using the "__serialize()" method instead'); - // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } diff --git a/backend/src/EventListener/ErrorResponseSetter.php b/backend/src/EventListener/ErrorResponseSetter.php index 15d1951..5c6b86d 100644 --- a/backend/src/EventListener/ErrorResponseSetter.php +++ b/backend/src/EventListener/ErrorResponseSetter.php @@ -28,7 +28,7 @@ public function __invoke(ExceptionEvent $event): void 'title' => 'An error occurred', 'status' => self::getCode($exception), 'detail' => $exception->getMessage(), - 'class' => get_class($exception), + 'class' => $exception::class, ]; if ($this->isDebug) { diff --git a/backend/src/EventListener/EventStoredNotifier.php b/backend/src/EventListener/EventStoredNotifier.php index b085708..40a6cd4 100644 --- a/backend/src/EventListener/EventStoredNotifier.php +++ b/backend/src/EventListener/EventStoredNotifier.php @@ -20,7 +20,7 @@ #[AsEntityListener(event: Events::postPersist, method: 'onPostPersist', entity: EventEntity::class)] final readonly class EventStoredNotifier { - public function __construct(private readonly MessageBusInterface $bus) + public function __construct(private MessageBusInterface $bus) { } diff --git a/backend/src/EventListener/LoginCounter.php b/backend/src/EventListener/LoginCounter.php index f51749c..4321f6d 100644 --- a/backend/src/EventListener/LoginCounter.php +++ b/backend/src/EventListener/LoginCounter.php @@ -13,7 +13,7 @@ final readonly class LoginCounter { public function __construct( - private readonly MessageBusInterface $bus, + private MessageBusInterface $bus, ) { } @@ -21,6 +21,6 @@ public function __invoke(LoginSuccessEvent $event): void { $userId = $event->getUser()->getUserIdentifier(); - $this->bus->dispatch(Message::create("๐Ÿ” Successful login: {$userId}")); + $this->bus->dispatch(Message::create('๐Ÿ” Successful login: '.$userId)); } } diff --git a/backend/src/Instrumentation/Initializer.php b/backend/src/Instrumentation/Initializer.php index 872034f..71ec8b6 100644 --- a/backend/src/Instrumentation/Initializer.php +++ b/backend/src/Instrumentation/Initializer.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; #[AsEventListener()] -final class Initializer +final readonly class Initializer { public function __construct( #[Autowire('%app.telemetry%')] diff --git a/backend/src/Instrumentation/Instrumentation.php b/backend/src/Instrumentation/Instrumentation.php index f843b1a..0b8c82a 100644 --- a/backend/src/Instrumentation/Instrumentation.php +++ b/backend/src/Instrumentation/Instrumentation.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; -final class Instrumentation +final readonly class Instrumentation { public function __construct( #[Autowire('%app.telemetry%')] diff --git a/backend/src/Instrumentation/PsrLog/Logging.php b/backend/src/Instrumentation/PsrLog/Logging.php index c8f9cfd..f2e29cd 100644 --- a/backend/src/Instrumentation/PsrLog/Logging.php +++ b/backend/src/Instrumentation/PsrLog/Logging.php @@ -27,7 +27,7 @@ public function exception(\Throwable $ex): void { $this->logger ->error($ex->getMessage(), [ - 'exception_type' => get_class($ex), + 'exception_type' => $ex::class, 'stack_trace' => $ex->getTrace(), ]); } diff --git a/backend/src/Instrumentation/PsrLog/Metrics.php b/backend/src/Instrumentation/PsrLog/Metrics.php index 035c24c..acdf0d5 100644 --- a/backend/src/Instrumentation/PsrLog/Metrics.php +++ b/backend/src/Instrumentation/PsrLog/Metrics.php @@ -15,6 +15,6 @@ public function __construct(private LoggerInterface $logger) public function record(string $name, float|int $value, string $unit): void { - $this->logger->info("๐Ÿ“ˆ Metric recorded: {$name} = {$value} {$unit}"); + $this->logger->info(sprintf('๐Ÿ“ˆ Metric recorded: %s = %s %s', $name, $value, $unit)); } } diff --git a/backend/src/Instrumentation/PsrLog/Span.php b/backend/src/Instrumentation/PsrLog/Span.php index efc95c4..d086db8 100644 --- a/backend/src/Instrumentation/PsrLog/Span.php +++ b/backend/src/Instrumentation/PsrLog/Span.php @@ -15,7 +15,7 @@ final class Span implements SpanInterface private array $recordedExceptions = []; public function __construct( - private string $methodName, + private readonly string $methodName, private readonly LoggerInterface $logger, ) { } @@ -32,16 +32,17 @@ public function recordException(string $context, \Throwable $ex): void public function close(): void { - if (!empty($this->recordedExceptions)) { + if ([] !== $this->recordedExceptions) { foreach ($this->recordedExceptions as $context => $ex) { $this->logger->error( sprintf('๐Ÿ’ฅ %s: %s', $context, $ex->getMessage()), [ - 'exception_type' => get_class($ex), + 'exception_type' => $ex::class, 'stack_trace' => $ex->getTrace(), ] ); } + $this->logger->debug('Leaving '.$this->methodName); } else { $this->logger->debug('Exiting '.$this->methodName); diff --git a/backend/src/Normalizer/Normalizer.php b/backend/src/Normalizer/Normalizer.php index c4abf49..93a7897 100644 --- a/backend/src/Normalizer/Normalizer.php +++ b/backend/src/Normalizer/Normalizer.php @@ -37,9 +37,9 @@ public function toArray(mixed $object, array $ignoreFields = []): array { return $this->normalizer->normalize( $object, - context: !empty($ignoreFields) - ? [SymfonyAbstractNormalizer::IGNORED_ATTRIBUTES => $ignoreFields] - : [] + context: [] === $ignoreFields + ? [] + : [SymfonyAbstractNormalizer::IGNORED_ATTRIBUTES => $ignoreFields] ); } } diff --git a/backend/src/Repository/EventRepository.php b/backend/src/Repository/EventRepository.php index a9d9990..6eb38e6 100644 --- a/backend/src/Repository/EventRepository.php +++ b/backend/src/Repository/EventRepository.php @@ -101,36 +101,36 @@ private function createQueryBuilder(Options $options): QueryBuilder ->createQueryBuilder('e') ->orderBy('e.createdAt', 'ASC'); - if (!empty($options->createdBy)) { + if ([] !== $options->createdBy) { $builder = $builder ->where('e.createdBy IN (:createdBy)') ->setParameter('createdBy', $options->createdBy); } - if (!empty($options->subjectTypes)) { - $whereMethod = empty($options->createdBy) ? 'where' : 'andWhere'; + if ([] !== $options->subjectTypes) { + $whereMethod = [] === $options->createdBy ? 'where' : 'andWhere'; $builder = $builder ->$whereMethod('e.subjectType IN (:subjectTypes)') ->setParameter('subjectTypes', $options->subjectTypes); } - if (empty($options->subjectTypes) && !empty($options->subjectIds)) { - $whereMethod = empty($options->createdBy) ? 'where' : 'andWhere'; + if ([] === $options->subjectTypes && [] !== $options->subjectIds) { + $whereMethod = [] === $options->createdBy ? 'where' : 'andWhere'; $builder = $builder ->$whereMethod('e.subjectId IN (:subjectIds)') ->setParameter('subjectIds', $options->subjectIds); - } elseif (!empty($options->subjectIds)) { + } elseif ([] !== $options->subjectIds) { $builder = $builder ->andWhere('e.subjectId IN (:subjectIds)') ->setParameter('subjectIds', $options->subjectIds); } - if (empty($options->subjectTypes) && empty($options->subjectIds) && !empty($options->eventTypes)) { - $whereMethod = empty($options->createdBy) ? 'where' : 'andWhere'; + if ([] === $options->subjectTypes && [] === $options->subjectIds && [] !== $options->eventTypes) { + $whereMethod = [] === $options->createdBy ? 'where' : 'andWhere'; $builder = $builder ->$whereMethod('e.eventType IN (:eventTypes)') ->setParameter('eventTypes', $options->eventTypes); - } elseif (!empty($options->eventTypes)) { + } elseif ([] !== $options->eventTypes) { $builder = $builder ->andWhere('e.eventType IN (:eventTypes)') ->setParameter('eventTypes', $options->eventTypes); diff --git a/backend/src/Repository/ReportRepository.php b/backend/src/Repository/ReportRepository.php index 39e62f1..3266073 100644 --- a/backend/src/Repository/ReportRepository.php +++ b/backend/src/Repository/ReportRepository.php @@ -30,21 +30,4 @@ public function findOlderThan(\DateTimeInterface $date): array return $result; } - - public function findByCompensationIdAndChecksum(string $compensationId, string $checksum): ?Report - { - $result = $this->createQueryBuilder('r') - ->andWhere('r.compensationId = :compensationId') - ->andWhere('r.checksum = :checksum') - ->andWhere('r.status = :status') - ->setParameter('compensationId', $compensationId) - ->setParameter('checksum', $checksum) - ->setParameter('status', Report::STATUS_COMPLETED) - ->orderBy('r.createdAt', 'DESC') - ->setMaxResults(1) - ->getQuery() - ->getOneOrNullResult(); - - return $result instanceof Report ? $result : null; - } } diff --git a/backend/src/Repository/UserRepository.php b/backend/src/Repository/UserRepository.php index 446033e..a110230 100644 --- a/backend/src/Repository/UserRepository.php +++ b/backend/src/Repository/UserRepository.php @@ -39,7 +39,7 @@ public function getEmailFor(string $userUuid): string $user = $this->findOneBy(['uuid' => $userUuid]); assert($user instanceof User); $email = $user->getEmail(); - assert(!empty($email)); + assert(!in_array($email, [null, '', '0'], true)); return $email; } diff --git a/backend/src/SplitFairly/Category.php b/backend/src/SplitFairly/Category.php index 24a775d..74ba88f 100644 --- a/backend/src/SplitFairly/Category.php +++ b/backend/src/SplitFairly/Category.php @@ -6,11 +6,11 @@ use App\Invariant\Ensure; -final class Category +final readonly class Category { private function __construct( - public readonly string $type, - public readonly Price $sum, + public string $type, + public Price $sum, ) { } diff --git a/backend/src/SplitFairly/Compensation.php b/backend/src/SplitFairly/Compensation.php index fa4bfa3..2ca6af8 100644 --- a/backend/src/SplitFairly/Compensation.php +++ b/backend/src/SplitFairly/Compensation.php @@ -22,12 +22,12 @@ public static function calculate(Expenses $a, Expenses $b, array $includeTypes = $spentTypes = array_intersect($includeTypes, ['Groceries', 'Non-Food']); $lentTypes = array_intersect($includeTypes, ['Lent']); - $spentA = !empty($spentTypes) ? $a->spent($spentTypes)->divide(2) : Price::ZERO(); - $spentB = !empty($spentTypes) ? $b->spent($spentTypes)->divide(2) : Price::ZERO(); + $spentA = [] === $spentTypes ? Price::ZERO() : $a->spent($spentTypes)->divide(2); + $spentB = [] === $spentTypes ? Price::ZERO() : $b->spent($spentTypes)->divide(2); $spentDiff = $spentA->substract($spentB); - $lentA = !empty($lentTypes) ? $a->lent($lentTypes) : Price::ZERO(); - $lentB = !empty($lentTypes) ? $b->lent($lentTypes) : Price::ZERO(); + $lentA = [] === $lentTypes ? Price::ZERO() : $a->lent($lentTypes); + $lentB = [] === $lentTypes ? Price::ZERO() : $b->lent($lentTypes); $lentDiff = $lentA->substract($lentB); $totalDiff = $spentDiff->add($lentDiff); diff --git a/backend/src/SplitFairly/Expense.php b/backend/src/SplitFairly/Expense.php index 0ebdf6f..317eac4 100644 --- a/backend/src/SplitFairly/Expense.php +++ b/backend/src/SplitFairly/Expense.php @@ -15,9 +15,9 @@ public function __construct( public string $type, public string $location, ) { - Ensure::that(!empty($what)); - Ensure::that(!empty($type)); - Ensure::that(!empty($location)); + Ensure::that('' !== $what && '0' !== $what); + Ensure::that('' !== $type && '0' !== $type); + Ensure::that('' !== $location && '0' !== $location); Ensure::that( in_array($type, array_map(static fn (ExpenseType $e) => $e->value, ExpenseType::cases()), strict: true), sprintf('Invalid expense type: %s', $type) diff --git a/backend/src/SplitFairly/ExpenseTracker.php b/backend/src/SplitFairly/ExpenseTracker.php index e98521d..5afe9cd 100644 --- a/backend/src/SplitFairly/ExpenseTracker.php +++ b/backend/src/SplitFairly/ExpenseTracker.php @@ -7,9 +7,9 @@ final readonly class ExpenseTracker { public function __construct( - private readonly EventStoreInterface $eventStore, - private readonly NormalizerInterface $normalizer, - private readonly CurrentUserInterface $currentUser, + private EventStoreInterface $eventStore, + private NormalizerInterface $normalizer, + private CurrentUserInterface $currentUser, ) { } @@ -17,7 +17,7 @@ public function track(Expense $expense): void { $event = new Event( createdBy: $this->currentUser->getUuid(), - subjectType: array_last(explode('\\', get_class($expense))), + subjectType: array_last(explode('\\', $expense::class)), subjectId: $expense->getId()->toRfc4122(), eventType: 'tracked', payload: $this->normalizer->toArray($expense, ignoreFields: ['id']) diff --git a/backend/src/SplitFairly/Expenses.php b/backend/src/SplitFairly/Expenses.php index 88a2c7b..0d8c396 100644 --- a/backend/src/SplitFairly/Expenses.php +++ b/backend/src/SplitFairly/Expenses.php @@ -42,7 +42,7 @@ public function categories(array $filter = [/* no filter */]): array * @return array */ static function (array $carry, Expense $expense) use ($filter): array { - if (!empty($filter) && !in_array($expense->type, $filter, true)) { + if ([] !== $filter && !in_array($expense->type, $filter, true)) { return $carry; } diff --git a/backend/src/SplitFairly/Price.php b/backend/src/SplitFairly/Price.php index ae4d821..211861d 100644 --- a/backend/src/SplitFairly/Price.php +++ b/backend/src/SplitFairly/Price.php @@ -12,7 +12,7 @@ public function __construct( public float $value, public string $currency = 'EUR', ) { - Ensure::that(!empty($currency)); + Ensure::that('' !== $currency && '0' !== $currency); } public static function ABS(self $price): self diff --git a/backend/src/SplitFairly/QueryOptions.php b/backend/src/SplitFairly/QueryOptions.php index 50d8880..95f4b31 100644 --- a/backend/src/SplitFairly/QueryOptions.php +++ b/backend/src/SplitFairly/QueryOptions.php @@ -25,9 +25,9 @@ public function __construct( public function isEmpty(): bool { - return empty($this->createdBy) - && empty($this->subjectTypes) - && empty($this->subjectIds) - && empty($this->eventTypes); + return [] === $this->createdBy + && [] === $this->subjectTypes + && [] === $this->subjectIds + && [] === $this->eventTypes; } } diff --git a/backend/tests/Integration/API/CalculateExpensesControllerTest.php b/backend/tests/Integration/API/CalculateExpensesControllerTest.php new file mode 100644 index 0000000..f6347d9 --- /dev/null +++ b/backend/tests/Integration/API/CalculateExpensesControllerTest.php @@ -0,0 +1,89 @@ +getContainer(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get(EntityManagerInterface::class); + + /** @var UserPasswordHasherInterface $passwordHasher */ + $passwordHasher = $container->get(UserPasswordHasherInterface::class); + + $user1 = User::create('user1@example.com', ['ROLE_USER']); + $hashedPassword = $passwordHasher->hashPassword($user1, 'password123'); + $user1->setPassword($hashedPassword); + + $user2 = User::create('user2@example.com', ['ROLE_USER']); + $hashedPassword2 = $passwordHasher->hashPassword($user2, 'password123'); + $user2->setPassword($hashedPassword2); + + $entityManager->persist($user1); + $entityManager->persist($user2); + $entityManager->flush(); + + $this->createExpenseEvent($entityManager, $user1, $user2); + $this->createExpenseEvent($entityManager, $user2, $user1); + + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + $client->request('GET', '/api/calculate'); + + self::assertResponseIsSuccessful(); + + $content = $client->getResponse()->getContent(); + assert(is_string($content)); + + $response = json_decode($content, true); + assert(is_array($response)); + + self::assertArrayHasKey('id', $response); + self::assertIsString($response['id']); + self::assertEquals(64, strlen($response['id'])); + } + + private function createExpenseEvent(EntityManagerInterface $entityManager, User $paidBy, User $forUser): void + { + $expense = new Expense( + price: new Price(50.00, 'EUR'), + what: 'Test Expense', + type: 'Groceries', + location: 'Test Location' + ); + + $event = new Event( + createdBy: $paidBy->getUuid()->toRfc4122(), + subjectType: 'Expense', + subjectId: $expense->getId()->toRfc4122(), + eventType: 'tracked', + payload: [ + 'price' => ['value' => $expense->price->value, 'currency' => $expense->price->currency], + 'what' => $expense->what, + 'type' => $expense->type, + 'location' => $expense->location, + ] + ); + + $entityManager->persist($event); + $entityManager->flush(); + } +} diff --git a/backend/tests/Integration/API/ListUsersEndpointTest.php b/backend/tests/Integration/API/ListUsersEndpointTest.php index 2dfe0fc..82fd0a7 100644 --- a/backend/tests/Integration/API/ListUsersEndpointTest.php +++ b/backend/tests/Integration/API/ListUsersEndpointTest.php @@ -10,9 +10,13 @@ class ListUsersEndpointTest extends WebTestCase { private EntityManagerInterface $entityManager; + private UserPasswordHasherInterface $passwordHasher; + private User $user1; + private User $user2; + private User $user3; protected function setUp(): void diff --git a/backend/tests/Integration/API/ReportControllerTest.php b/backend/tests/Integration/API/ReportControllerTest.php new file mode 100644 index 0000000..b0dc304 --- /dev/null +++ b/backend/tests/Integration/API/ReportControllerTest.php @@ -0,0 +1,50 @@ +getContainer(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get(EntityManagerInterface::class); + + /** @var UserPasswordHasherInterface $passwordHasher */ + $passwordHasher = $container->get(UserPasswordHasherInterface::class); + + $user = User::create('user1@example.com', ['ROLE_USER']); + $hashedPassword = $passwordHasher->hashPassword($user, 'password123'); + $user->setPassword($hashedPassword); + $entityManager->persist($user); + $entityManager->flush(); + + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + $testId = hash('sha256', bin2hex(random_bytes(16))); + $client->request('POST', '/api/report/calculation?id='.$testId); + + self::assertResponseStatusCodeSame(202); + + $content = $client->getResponse()->getContent(); + assert(is_string($content)); + + $response = json_decode($content, true); + assert(is_array($response)); + + self::assertArrayHasKey('id', $response); + } +} diff --git a/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php b/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php index fd8a348..0478ebe 100644 --- a/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php +++ b/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php @@ -10,8 +10,11 @@ class TrackExpenseWithLendValidationTest extends WebTestCase { private EntityManagerInterface $entityManager; + private UserPasswordHasherInterface $passwordHasher; + private User $user1; + private User $user2; protected function setUp(): void diff --git a/backend/tests/Integration/Auth/ApiMeEndpointTest.php b/backend/tests/Integration/Auth/ApiMeEndpointTest.php index 337757f..12185b2 100644 --- a/backend/tests/Integration/Auth/ApiMeEndpointTest.php +++ b/backend/tests/Integration/Auth/ApiMeEndpointTest.php @@ -10,7 +10,9 @@ class ApiMeEndpointTest extends WebTestCase { private EntityManagerInterface $entityManager; + private UserPasswordHasherInterface $passwordHasher; + private User $testUser; protected function setUp(): void @@ -112,6 +114,7 @@ public function test_me_endpoint_with_different_users(): void // Check /api/me returns first user $client->request('GET', '/api/me'); + $responseContent = $client->getResponse()->getContent(); $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; $response = \is_array($response) ? $response : []; diff --git a/backend/tests/Integration/Auth/LoginFormFlowTest.php b/backend/tests/Integration/Auth/LoginFormFlowTest.php index 607332e..44e1243 100644 --- a/backend/tests/Integration/Auth/LoginFormFlowTest.php +++ b/backend/tests/Integration/Auth/LoginFormFlowTest.php @@ -10,7 +10,9 @@ class LoginFormFlowTest extends WebTestCase { private EntityManagerInterface $entityManager; + private UserPasswordHasherInterface $passwordHasher; + private User $testUser; protected function setUp(): void @@ -199,7 +201,7 @@ public function test_session_is_created_after_successful_login(): void // Check that a session cookie is set self::assertTrue($client->getResponse()->headers->has('Set-Cookie') - || count($client->getCookieJar()->all()) > 0); + || [] !== $client->getCookieJar()->all()); } public function test_remember_me_checkbox_preserves_session(): void diff --git a/backend/tests/Unit/Instrumentation/InitializerTest.php b/backend/tests/Unit/Instrumentation/InitializerTest.php index 21711b7..49641d8 100644 --- a/backend/tests/Unit/Instrumentation/InitializerTest.php +++ b/backend/tests/Unit/Instrumentation/InitializerTest.php @@ -6,6 +6,7 @@ use App\Instrumentation\Initializer; use App\Instrumentation\InstrumentationHolder; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -24,16 +25,16 @@ protected function setUp(): void // Reset the Holder singleton $reflection = new \ReflectionClass(InstrumentationHolder::class); $instanceProperty = $reflection->getProperty('instance'); - $instanceProperty->setAccessible(true); $instanceProperty->setValue(null, null); } + #[AllowMockObjectsWithoutExpectations] public function test_initializer_initializes_provider_on_invoke(): void { $initializer = new Initializer('PsrLog', $this->logger); $request = Request::create('/'); - $kernel = $this->createMock(HttpKernelInterface::class); + $kernel = $this->createStub(HttpKernelInterface::class); $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); $initializer($event); @@ -43,12 +44,13 @@ public function test_initializer_initializes_provider_on_invoke(): void self::assertInstanceOf(\App\Instrumentation\LoggingInterface::class, $logging); } + #[AllowMockObjectsWithoutExpectations] public function test_initializer_with_null_telemetry(): void { $initializer = new Initializer('Null', $this->logger); $request = Request::create('/'); - $kernel = $this->createMock(HttpKernelInterface::class); + $kernel = $this->createStub(HttpKernelInterface::class); $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); $initializer($event); diff --git a/backend/tests/Unit/Instrumentation/InstrumentationTest.php b/backend/tests/Unit/Instrumentation/InstrumentationTest.php index 7a937d3..8e1ba8c 100644 --- a/backend/tests/Unit/Instrumentation/InstrumentationTest.php +++ b/backend/tests/Unit/Instrumentation/InstrumentationTest.php @@ -19,7 +19,6 @@ protected function tearDown(): void // Reset the singleton instance via reflection $reflection = new \ReflectionClass(InstrumentationHolder::class); $instanceProperty = $reflection->getProperty('instance'); - $instanceProperty->setAccessible(true); $instanceProperty->setValue(null, null); parent::tearDown(); } diff --git a/backend/tests/Unit/Instrumentation/PsrLog/LoggingTest.php b/backend/tests/Unit/Instrumentation/PsrLog/LoggingTest.php index a851479..95047e0 100644 --- a/backend/tests/Unit/Instrumentation/PsrLog/LoggingTest.php +++ b/backend/tests/Unit/Instrumentation/PsrLog/LoggingTest.php @@ -12,6 +12,7 @@ final class LoggingTest extends TestCase { private LoggerInterface&MockObject $logger; + private Logging $logging; protected function setUp(): void @@ -55,13 +56,11 @@ public function test_exception_logs_error_with_context(): void ->method('error') ->with( 'Test error message', - $this->callback(function (mixed $context) use ($exception) { - return is_array($context) - && isset($context['exception_type']) - && $context['exception_type'] === get_class($exception) - && isset($context['stack_trace']) - && $context['stack_trace'] === $exception->getTrace(); - }) + $this->callback(fn (mixed $context) => is_array($context) + && isset($context['exception_type']) + && $context['exception_type'] === $exception::class + && isset($context['stack_trace']) + && $context['stack_trace'] === $exception->getTrace()) ); $this->logging->exception($exception); diff --git a/backend/tests/Unit/Instrumentation/PsrLog/MetricsTest.php b/backend/tests/Unit/Instrumentation/PsrLog/MetricsTest.php index 6b1838e..03d5c36 100644 --- a/backend/tests/Unit/Instrumentation/PsrLog/MetricsTest.php +++ b/backend/tests/Unit/Instrumentation/PsrLog/MetricsTest.php @@ -12,6 +12,7 @@ final class MetricsTest extends TestCase { private LoggerInterface&MockObject $logger; + private Metrics $metrics; protected function setUp(): void diff --git a/backend/tests/Unit/Instrumentation/PsrLog/TracingTest.php b/backend/tests/Unit/Instrumentation/PsrLog/TracingTest.php index 4d78482..5ba2730 100644 --- a/backend/tests/Unit/Instrumentation/PsrLog/TracingTest.php +++ b/backend/tests/Unit/Instrumentation/PsrLog/TracingTest.php @@ -6,6 +6,7 @@ use App\Instrumentation\PsrLog\Tracing; use App\Instrumentation\Tracer; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -13,6 +14,7 @@ final class TracingTest extends TestCase { private LoggerInterface&MockObject $logger; + private Tracing $tracing; protected function setUp(): void @@ -21,18 +23,20 @@ protected function setUp(): void $this->tracing = new Tracing($this->logger); } + #[AllowMockObjectsWithoutExpectations] public function test_create_tracer_returns_tracer_instance(): void { - $this->logger->expects($this->any())->method('debug'); + $this->logger->method('debug'); $tracer = $this->tracing->createTracer('testMethod', __FILE__); self::assertInstanceOf(Tracer::class, $tracer); } + #[AllowMockObjectsWithoutExpectations] public function test_create_tracer_with_context(): void { - $this->logger->expects($this->any())->method('debug'); + $this->logger->method('debug'); $context = ['request_id' => 'abc123', 'user_id' => 'user-1']; $tracer = $this->tracing->createTracer('testMethod', __FILE__, $context); @@ -40,18 +44,20 @@ public function test_create_tracer_with_context(): void self::assertInstanceOf(Tracer::class, $tracer); } + #[AllowMockObjectsWithoutExpectations] public function test_create_tracer_opens_span(): void { - $this->logger->expects($this->any())->method('debug'); + $this->logger->method('debug'); $tracer = $this->tracing->createTracer('processPayment', __FILE__); self::assertInstanceOf(Tracer::class, $tracer); } + #[AllowMockObjectsWithoutExpectations] public function test_create_multiple_tracers(): void { - $this->logger->expects($this->any())->method('debug'); + $this->logger->method('debug'); $tracer1 = $this->tracing->createTracer('method1', __FILE__); $tracer2 = $this->tracing->createTracer('method2', __FILE__); @@ -59,9 +65,10 @@ public function test_create_multiple_tracers(): void self::assertNotSame($tracer1, $tracer2); } + #[AllowMockObjectsWithoutExpectations] public function test_tracer_created_with_method_name_and_file(): void { - $this->logger->expects($this->any())->method('debug'); + $this->logger->method('debug'); $methodName = 'calculateTotal'; $file = __FILE__; diff --git a/backend/tests/Unit/SplitFairly/CalculatorTest.php b/backend/tests/Unit/SplitFairly/CalculatorTest.php index c5beb89..3957b8b 100644 --- a/backend/tests/Unit/SplitFairly/CalculatorTest.php +++ b/backend/tests/Unit/SplitFairly/CalculatorTest.php @@ -13,19 +13,21 @@ use App\SplitFairly\Expenses; use App\SplitFairly\Price; use App\SplitFairly\QueryOptions; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; final class CalculatorTest extends TestCase { + #[AllowMockObjectsWithoutExpectations] public function test_calculate_returns_empty_array_when_no_users(): void { - $eventStore = $this->createMock(EventStoreInterface::class); + $eventStore = $this->createStub(EventStoreInterface::class); $eventStore->method('getUserIds')->willReturn([]); $eventStore->method('getEvents')->willReturn([]); - $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer = $this->createStub(DenormalizerInterface::class); - $emailProvider = $this->createMock(EmailProviderInterface::class); + $emailProvider = $this->createStub(EmailProviderInterface::class); $emailProvider->method('getEmailFor')->willReturn('test@example.com'); $calculator = new Calculator($eventStore, $denormalizer, $emailProvider); @@ -35,6 +37,7 @@ public function test_calculate_returns_empty_array_when_no_users(): void self::assertSame([], $result); } + #[AllowMockObjectsWithoutExpectations] public function test_calculate_groups_expenses_by_multiple_users(): void { $user1 = 'user-123'; @@ -46,28 +49,28 @@ public function test_calculate_groups_expenses_by_multiple_users(): void $expense3 = new Expense(price: $price, what: 'Dinner', type: 'Lent', location: 'user@example.com'); $event1 = new Event( + createdBy: $user1, subjectType: 'Expense', subjectId: 'exp-1', eventType: 'tracked', payload: ['price' => ['value' => 10.50, 'currency' => 'EUR'], 'what' => 'Coffee', 'type' => 'Groceries', 'location' => 'Starbucks'], - createdAt: new \DateTimeImmutable(), - createdBy: $user1 + createdAt: new \DateTimeImmutable() ); $event2 = new Event( + createdBy: $user1, subjectType: 'Expense', subjectId: 'exp-2', eventType: 'tracked', payload: ['price' => ['value' => 10.50, 'currency' => 'EUR'], 'what' => 'Lunch', 'type' => 'Non-Food', 'location' => 'Restaurant'], - createdAt: new \DateTimeImmutable(), - createdBy: $user1 + createdAt: new \DateTimeImmutable() ); $event3 = new Event( + createdBy: $user2, subjectType: 'Expense', subjectId: 'exp-3', eventType: 'tracked', payload: ['price' => ['value' => 10.50, 'currency' => 'EUR'], 'what' => 'Dinner', 'type' => 'Lent', 'location' => 'user@example.com'], - createdAt: new \DateTimeImmutable(), - createdBy: $user2 + createdAt: new \DateTimeImmutable() ); $eventStore = $this->createMock(EventStoreInterface::class); @@ -78,19 +81,17 @@ public function test_calculate_groups_expenses_by_multiple_users(): void $eventStore ->expects($this->once()) ->method('getEvents') - ->with($this->callback(function (QueryOptions $options) use ($user1, $user2) { - return $options->createdBy === [$user1, $user2] - && $options->subjectTypes === ['Expense'] - && $options->eventTypes === ['tracked']; - })) + ->with($this->callback(fn (QueryOptions $options) => $options->createdBy === [$user1, $user2] + && $options->subjectTypes === ['Expense'] + && $options->eventTypes === ['tracked'])) ->willReturn([$event1, $event2, $event3]); - $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer = $this->createStub(DenormalizerInterface::class); $denormalizer ->method('fromArray') ->willReturnOnConsecutiveCalls($expense1, $expense2, $expense3); - $emailProvider = $this->createMock(EmailProviderInterface::class); + $emailProvider = $this->createStub(EmailProviderInterface::class); $emailProvider->method('getEmailFor')->willReturn('test@example.com'); $calculator = new Calculator($eventStore, $denormalizer, $emailProvider); @@ -114,6 +115,7 @@ public function test_calculate_groups_expenses_by_multiple_users(): void self::assertCount(1, $user2Expenses->expenses); } + #[AllowMockObjectsWithoutExpectations] public function test_calculate_calls_get_user_ids(): void { $eventStore = $this->createMock(EventStoreInterface::class); @@ -124,9 +126,9 @@ public function test_calculate_calls_get_user_ids(): void $eventStore->method('getEvents')->willReturn([]); - $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer = $this->createStub(DenormalizerInterface::class); - $emailProvider = $this->createMock(EmailProviderInterface::class); + $emailProvider = $this->createStub(EmailProviderInterface::class); $emailProvider->method('getEmailFor')->willReturn('test@example.com'); $calculator = new Calculator($eventStore, $denormalizer, $emailProvider); diff --git a/backend/tests/Unit/SplitFairly/ExpenseTrackerTest.php b/backend/tests/Unit/SplitFairly/ExpenseTrackerTest.php index 9ee6ebe..0c1a3ca 100644 --- a/backend/tests/Unit/SplitFairly/ExpenseTrackerTest.php +++ b/backend/tests/Unit/SplitFairly/ExpenseTrackerTest.php @@ -11,16 +11,18 @@ use App\SplitFairly\ExpenseTracker; use App\SplitFairly\NormalizerInterface; use App\SplitFairly\Price; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; final class ExpenseTrackerTest extends TestCase { + #[AllowMockObjectsWithoutExpectations] public function test_tracks_expense_and_persists_event(): void { $price = new Price(value: 10.50, currency: 'EUR'); $expense = new Expense(price: $price, what: 'Coffee', type: 'Groceries', location: 'Starbucks'); - $currentUser = $this->createMock(CurrentUserInterface::class); + $currentUser = $this->createStub(CurrentUserInterface::class); $currentUser->method('getUuid')->willReturn('user-123'); $normalizedPayload = ['price' => (string) $price, 'what' => 'Coffee', 'type' => 'Groceries', 'location' => 'Starbucks']; @@ -29,13 +31,11 @@ public function test_tracks_expense_and_persists_event(): void $eventStore = $this->createMock(EventStoreInterface::class); $eventStore->expects($this->once())->method('persist')->with( - $this->callback(function (Event $event) use ($expense, $normalizedPayload): bool { - return 'user-123' === $event->createdBy - && 'Expense' === $event->subjectType - && $event->subjectId === $expense->getId()->toRfc4122() - && 'tracked' === $event->eventType - && $event->payload === $normalizedPayload; - }), + $this->callback(fn (Event $event): bool => 'user-123' === $event->createdBy + && 'Expense' === $event->subjectType + && $event->subjectId === $expense->getId()->toRfc4122() + && 'tracked' === $event->eventType + && $event->payload === $normalizedPayload), false ); diff --git a/backend/tests/bootstrap.php b/backend/tests/bootstrap.php index ac2bb7d..38c3beb 100644 --- a/backend/tests/bootstrap.php +++ b/backend/tests/bootstrap.php @@ -5,11 +5,11 @@ require dirname(__DIR__).'/vendor/autoload.php'; if (file_exists(dirname(__DIR__).'/.env')) { - (new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); + new Dotenv()->bootEnv(dirname(__DIR__).'/.env'); } if (file_exists(dirname(__DIR__).'/.env.test')) { - (new Dotenv())->bootEnv(dirname(__DIR__).'/.env.test'); + new Dotenv()->bootEnv(dirname(__DIR__).'/.env.test'); } // Ensure test environment variables are set diff --git a/build/php/Dockerfile b/build/php/Dockerfile index 813a189..0dfc808 100644 --- a/build/php/Dockerfile +++ b/build/php/Dockerfile @@ -1,7 +1,7 @@ # ----------------------------------- # Base stage for Development (Debian-based) # ----------------------------------- -FROM php:8.4-fpm AS base-dev +FROM php:8.4.19-fpm AS base-dev WORKDIR /var/www/project @@ -20,7 +20,7 @@ COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini # ----------------------------------- # Base stage for Production (Alpine-based, minimal) # ----------------------------------- -FROM php:8.4.3-fpm-alpine3.20 AS base-prod +FROM php:8.4.19-fpm-alpine3.23 AS base-prod WORKDIR /var/www/project diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd77885..5498006 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -113,6 +113,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -462,6 +463,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -485,6 +487,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1035,6 +1038,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1709,9 +1723,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -1723,9 +1737,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1737,9 +1751,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -1751,9 +1765,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -1765,9 +1779,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -1779,9 +1793,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -1793,9 +1807,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -1807,9 +1821,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -1821,9 +1835,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -1835,9 +1849,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -1849,9 +1863,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -1863,9 +1877,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -1877,9 +1891,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], @@ -1891,9 +1905,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -1905,9 +1919,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -1919,9 +1933,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -1933,9 +1947,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -1947,9 +1961,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -1961,9 +1975,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -1975,9 +1989,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -1989,9 +2003,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -2003,9 +2017,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -2017,9 +2031,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -2031,9 +2045,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -2045,9 +2059,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -2071,6 +2085,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2235,8 +2250,9 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2245,8 +2261,9 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2273,31 +2290,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2306,7 +2323,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2318,26 +2335,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -2345,13 +2362,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2360,9 +2378,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -2370,36 +2388,38 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", - "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.2", "fflate": "^0.8.2", - "flatted": "^3.3.3", + "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.18" + "vitest": "4.1.2" } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2531,9 +2551,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.9", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", - "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2589,9 +2609,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -2608,12 +2628,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2647,9 +2668,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001780", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "dev": true, "funding": [ { @@ -2811,7 +2832,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2910,9 +2931,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.321", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", - "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", "dev": true, "license": "ISC" }, @@ -2950,9 +2971,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -3436,6 +3457,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3453,6 +3475,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -3778,9 +3801,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3830,6 +3853,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4024,6 +4048,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4033,6 +4058,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4127,9 +4153,9 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -4143,31 +4169,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -4278,9 +4304,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -4464,11 +4490,12 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4629,6 +4656,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4717,11 +4745,12 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4730,31 +4759,32 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4770,12 +4800,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4804,13 +4835,16 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -4899,9 +4933,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/src/features/calculation/Calculation.tsx b/frontend/src/features/calculation/Calculation.tsx index ee65afa..4faaa73 100644 --- a/frontend/src/features/calculation/Calculation.tsx +++ b/frontend/src/features/calculation/Calculation.tsx @@ -187,7 +187,7 @@ export function Calculation({ onUserSelected }: { onUserSelected?: (selected: bo ))} - + )} diff --git a/frontend/src/features/calculation/DownloadReportButton.tsx b/frontend/src/features/calculation/DownloadReportButton.tsx index 198ce63..554cacc 100644 --- a/frontend/src/features/calculation/DownloadReportButton.tsx +++ b/frontend/src/features/calculation/DownloadReportButton.tsx @@ -2,18 +2,22 @@ import { Button } from '@/components/ui/button' import { useState } from 'react' import { initiateReportGeneration, getReportStatus, downloadReport, ReportStatus } from './api' -export function DownloadReportButton() { +type Props = { + id: string +} + +export function DownloadReportButton({id}: Props) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [reportStatus, setReportStatus] = useState(null) - const pollReportStatus = async (reportId: string) => { + const pollReportStatus = async (id: string) => { let attempts = 0 const maxAttempts = 120 // 2 minutes with 1s intervals while (attempts < maxAttempts) { try { - const status = await getReportStatus(reportId) + const status = await getReportStatus(id) setReportStatus(status) if (status.status === 'completed') { @@ -40,7 +44,7 @@ export function DownloadReportButton() { try { // Initiate report generation - const initResponse = await initiateReportGeneration() + const initResponse = await initiateReportGeneration(id) setReportStatus(initResponse) // Poll for completion diff --git a/frontend/src/features/calculation/api.ts b/frontend/src/features/calculation/api.ts index 1164f30..e2d94d7 100644 --- a/frontend/src/features/calculation/api.ts +++ b/frontend/src/features/calculation/api.ts @@ -24,6 +24,7 @@ interface Compensation { export interface CalculationResponse { users: Expenses[] compensation: Compensation | null + id: string } export interface ReportStatus { @@ -53,8 +54,9 @@ export async function fetchCalculation(withUser?: string): Promise { - const response = await fetch(getApiUrl('/api/report/calculation'), { +export async function initiateReportGeneration(id: string): Promise { + // TODO: Consolidate URL parameter! + const response = await fetch(getApiUrl(`/api/report/calculation?id=${id}`), { method: 'POST', credentials: 'include', }) @@ -66,8 +68,8 @@ export async function initiateReportGeneration(): Promise { return response.json() } -export async function getReportStatus(reportId: string): Promise { - const response = await fetch(getApiUrl(`/api/report/${reportId}/status`), { +export async function getReportStatus(id: string): Promise { + const response = await fetch(getApiUrl(`/api/report/${id}/status`), { credentials: 'include', }) @@ -78,8 +80,8 @@ export async function getReportStatus(reportId: string): Promise { return response.json() } -export async function downloadReport(reportId: string): Promise { - return fetch(getApiUrl(`/api/report/${reportId}/download`), { +export async function downloadReport(id: string): Promise { + return fetch(getApiUrl(`/api/report/${id}/download`), { credentials: 'include', }) } diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 88b8a83..c7f4304 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: split-fairly description: Helm chart for the split-fairly application type: application -version: 0.1.4 -appVersion: "0.1.4" +version: 0.1.5 +appVersion: "0.1.5" keywords: - split-fairly - web diff --git a/helm/values.yaml b/helm/values.yaml index 2df3fcf..7e28a51 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -6,11 +6,11 @@ replicaCount: image: app: repository: "split-fairly" - tag: "0.1.4" + tag: "0.1.5" pullPolicy: IfNotPresent web: repository: "split-fairly-web" - tag: "0.1.4" + tag: "0.1.5" pullPolicy: IfNotPresent mysql: repository: "mysql" @@ -42,7 +42,7 @@ resources: env: APP_SECRET: "F3E46580-5F9A-4524-B218-90490B033192" APP_ENV: "prod" - APP_VERSION: "0.1.4" + APP_VERSION: "0.1.5" DATABASE_URL: "mysql://{{ .Values.mysql.user }}:{{ .Values.mysql.password }}@{{ include \"split-fairly.fullname\" . }}-db:{{ .Values.service.mysql.port }}/{{ default \"app\" .Values.mysql.database }}?serverVersion=8.0.31&charset=utf8mb4" ADMIN_EMAIL: "admin@example.com" ADMIN_PASSWORD: "secret"