From 014fc45d824f582cbf323afa697dacd7dae20a51 Mon Sep 17 00:00:00 2001 From: dcorroyer Date: Sat, 19 Oct 2024 16:11:12 +0200 Subject: [PATCH 1/3] add 404 pages --- VERSION | 1 + app/assets/components/not-found.tsx | 4 ++-- app/assets/features/budgets/pages/detail.tsx | 6 ++---- 3 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..589268e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.3.0 \ No newline at end of file diff --git a/app/assets/components/not-found.tsx b/app/assets/components/not-found.tsx index 55df93c..273d623 100644 --- a/app/assets/components/not-found.tsx +++ b/app/assets/components/not-found.tsx @@ -16,11 +16,11 @@ const NotFound: React.FC = () => {
Nothing to see here - Page you are trying to open does not exist. The page is in construction. + Page you are trying to open does not exist.
diff --git a/app/assets/features/budgets/pages/detail.tsx b/app/assets/features/budgets/pages/detail.tsx index c40cdeb..19f6ad7 100644 --- a/app/assets/features/budgets/pages/detail.tsx +++ b/app/assets/features/budgets/pages/detail.tsx @@ -19,6 +19,7 @@ import { groupExpensesByCategory } from '../helpers/budgetDataTransformer' import { useBudget } from '../hooks/useBudget' import classes from './detail.module.css' +import NotFound from '@/components/not-found' const BudgetDetail: React.FC = () => { const { id } = useParams() @@ -28,10 +29,7 @@ const BudgetDetail: React.FC = () => { const [editMode, setEditMode] = useState(false) if (isFetching) return - - if (!budget) { - return Budget not found. - } + if (!budget) return const formattedExpenses = groupExpensesByCategory(budget.data.expenses) const budgetData = { ...budget?.data, expenses: formattedExpenses } From 886e4f0860364bbeb49134f4c18f9ff4aa29f09b Mon Sep 17 00:00:00 2001 From: dcorroyer Date: Sun, 20 Oct 2024 17:49:45 +0200 Subject: [PATCH 2/3] add duplicate --- app/assets/components/not-found.tsx | 2 +- app/assets/features/budgets/api/budgets.ts | 20 ++++++ .../budgets/components/budget-items.tsx | 25 +++++-- .../features/budgets/hooks/useBudget.ts | 66 ++++++++++++++++++- .../features/budgets/pages/list.module.css | 13 ++-- app/assets/features/budgets/pages/list.tsx | 51 +++++++++++--- .../Budget/DuplicateBudgetController.php | 45 +++++++++++++ app/src/Entity/Budget.php | 9 +-- app/src/Repository/BudgetRepository.php | 14 ++++ app/src/Service/BudgetService.php | 44 ++++++++++++- 10 files changed, 261 insertions(+), 28 deletions(-) create mode 100644 app/src/Controller/Budget/DuplicateBudgetController.php diff --git a/app/assets/components/not-found.tsx b/app/assets/components/not-found.tsx index 273d623..04e1146 100644 --- a/app/assets/components/not-found.tsx +++ b/app/assets/components/not-found.tsx @@ -20,7 +20,7 @@ const NotFound: React.FC = () => { diff --git a/app/assets/features/budgets/api/budgets.ts b/app/assets/features/budgets/api/budgets.ts index 8bc9907..b6fcdf8 100644 --- a/app/assets/features/budgets/api/budgets.ts +++ b/app/assets/features/budgets/api/budgets.ts @@ -66,3 +66,23 @@ export async function updateBudgetId( return await response.json() } + +export async function postDuplicateBudget(): Promise { + const response = await client('/api/budgets/duplicate', { + method: 'POST', + }) + + if (!response.ok) return Promise.reject('Failed to duplicate budget') + + return await response.json() +} + +export async function postDuplicateBudgetId(id: string): Promise { + const response = await client(`/api/budgets/duplicate/${id}`, { + method: 'POST', + }) + + if (!response.ok) return Promise.reject('Failed to duplicate budget') + + return await response.json() +} diff --git a/app/assets/features/budgets/components/budget-items.tsx b/app/assets/features/budgets/components/budget-items.tsx index 1fbf060..187687b 100644 --- a/app/assets/features/budgets/components/budget-items.tsx +++ b/app/assets/features/budgets/components/budget-items.tsx @@ -1,9 +1,8 @@ +import { ActionIcon, Badge, Card, Group, SimpleGrid, Text, rem } from '@mantine/core' +import { IconCopy, IconEye, IconTrash } from '@tabler/icons-react' import React from 'react' import { Link } from 'react-router-dom' -import { ActionIcon, Badge, Card, Group, SimpleGrid, Text, rem } from '@mantine/core' -import { IconEye, IconTrash } from '@tabler/icons-react' - import { CenteredLoader as Loader } from '@/components/centered-loader' import { useBudget } from '../hooks/useBudget' @@ -11,12 +10,16 @@ import classes from './budget-items.module.css' export const BudgetItems = ({ selectedYear, - openModal, + openDeleteModal, + openDuplicateModal, setBudgetIdToDelete, + setBudgetIdToDuplicate, }: { selectedYear: number - openModal: () => void + openDeleteModal: () => void + openDuplicateModal: () => void setBudgetIdToDelete: (id: string | null) => void + setBudgetIdToDuplicate: (id: string | null) => void }) => { const { useBudgetList } = useBudget() @@ -31,6 +34,16 @@ export const BudgetItems = ({ {budget.name}
+ { + setBudgetIdToDuplicate(budget.id.toString()) + openDuplicateModal() + }} + variant='subtle' + color='blue' + > + + { setBudgetIdToDelete(budget.id.toString()) - openModal() + openDeleteModal() }} variant='subtle' color='red' diff --git a/app/assets/features/budgets/hooks/useBudget.ts b/app/assets/features/budgets/hooks/useBudget.ts index d606898..02adc0d 100644 --- a/app/assets/features/budgets/hooks/useBudget.ts +++ b/app/assets/features/budgets/hooks/useBudget.ts @@ -10,6 +10,8 @@ import { getBudgetDetail, getBudgetList, postBudget, + postDuplicateBudget, + postDuplicateBudgetId, updateBudgetId, } from '@/features/budgets/api/budgets' @@ -125,15 +127,77 @@ export const useBudget = () => { }, }) + const duplicateBudget = useCallback((id: string) => { + duplicateBudgetMutation.mutate(id) + }, []) + + const duplicateBudgetMutation = useMutation({ + mutationFn: postDuplicateBudgetId, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budgets'] }) + navigate('/budgets') + notifications.show({ + withBorder: true, + radius: 'md', + color: 'blue', + title: 'Successful Duplication', + message: 'You have successfully duplicated a budget', + }) + }, + onError: (error: Error) => { + console.log('error:', error) + notifications.show({ + withBorder: true, + radius: 'md', + color: 'red', + title: 'Error', + message: 'There was an error during the budget duplication process', + }) + }, + }) + + const duplicateLatestBudget = useCallback(() => { + duplicateLatestBudgetMutation.mutate() + }, []) + + const duplicateLatestBudgetMutation = useMutation({ + mutationFn: postDuplicateBudget, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budgets'] }) + navigate('/budgets') + notifications.show({ + withBorder: true, + radius: 'md', + color: 'blue', + title: 'Successful Duplication', + message: 'You have successfully duplicated a budget', + }) + }, + onError: (error: Error) => { + console.log('error:', error) + notifications.show({ + withBorder: true, + radius: 'md', + color: 'red', + title: 'Error', + message: 'There was an error during the budget duplication process', + }) + }, + }) + return { useBudgetList, useBudgetDetail, createBudget, updateBudget, deleteBudget, + duplicateBudget, + duplicateLatestBudget, isLoading: createBudgetMutation.isPending || updateBudgetMutation.isPending || - deleteBudgetMutation.isPending, + deleteBudgetMutation.isPending || + duplicateBudgetMutation.isPending || + duplicateLatestBudgetMutation.isPending, } } diff --git a/app/assets/features/budgets/pages/list.module.css b/app/assets/features/budgets/pages/list.module.css index 03ce3c7..b43ef65 100644 --- a/app/assets/features/budgets/pages/list.module.css +++ b/app/assets/features/budgets/pages/list.module.css @@ -5,7 +5,9 @@ font-weight: 500; margin-top: var(--mantine-spacing-xs); background-color: var(--mantine-color-blue-light); - width: rem(80px); + width: auto; + height: auto; + padding: 0.2rem 0.5rem; transform: translateY(0.33rem); @mixin hover { @@ -23,7 +25,7 @@ padding: rem(2.5); } -.deleteItem { +.modalItem { display: flex; align-items: center; text-decoration: none; @@ -33,8 +35,9 @@ font-weight: 500; margin-top: var(--mantine-spacing-xs); background-color: var(--mantine-color-red-light); - width: rem(80px); - height: rem(35px); + width: auto; + height: auto; + padding: 0.33rem 0.75rem 0.33rem 0.5rem; @mixin hover { background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6)); @@ -46,7 +49,7 @@ } } -.deleteIcon { +.modalIcon { color: var(--mantine-color-red-light-color); padding: rem(5px); width: rem(30px); diff --git a/app/assets/features/budgets/pages/list.tsx b/app/assets/features/budgets/pages/list.tsx index f2a6de2..0496ddf 100644 --- a/app/assets/features/budgets/pages/list.tsx +++ b/app/assets/features/budgets/pages/list.tsx @@ -6,7 +6,8 @@ import { useDisclosure } from '@mantine/hooks' import { IconChevronLeft, IconChevronRight, - IconSquareRoundedPlus2, + IconCopy, + IconSquarePlus2, IconTrash, } from '@tabler/icons-react' @@ -16,18 +17,27 @@ import { useBudget } from '../hooks/useBudget' import classes from './list.module.css' const BudgetList: React.FC = () => { - const { deleteBudget } = useBudget() + const { deleteBudget, duplicateBudget } = useBudget() const currentYear = new Date().getFullYear() const [selectedYear, setSelectedYear] = useState(currentYear) - const [opened, { open, close }] = useDisclosure(false) + const [openedDelete, { open: openDelete, close: closeDelete }] = useDisclosure(false) + const [openedDuplicate, { open: openDuplicate, close: closeDuplicate }] = useDisclosure(false) const [budgetIdToDelete, setBudgetIdToDelete] = useState(null) + const [budgetIdToDuplicate, setBudgetIdToDuplicate] = useState(null) const handleDelete = () => { if (budgetIdToDelete) { deleteBudget(budgetIdToDelete) - close() + closeDelete() + } + } + + const handleDuplicate = () => { + if (budgetIdToDuplicate) { + duplicateBudget(budgetIdToDuplicate) + closeDuplicate() } } @@ -42,26 +52,45 @@ const BudgetList: React.FC = () => { component={Link} to={'/budgets/create'} > - + Create + {/* Delete Confirmation Modal */}
- - + + Delete
+ + {/* Duplicate Confirmation Modal */} + +
+ + + Duplicate + +
+
+ {
diff --git a/app/src/Controller/Budget/DuplicateBudgetController.php b/app/src/Controller/Budget/DuplicateBudgetController.php new file mode 100644 index 0000000..e222511 --- /dev/null +++ b/app/src/Controller/Budget/DuplicateBudgetController.php @@ -0,0 +1,45 @@ +successResponse( + data: $budgetService->duplicate($id), + groups: [SerializationGroups::BUDGET_CREATE], + status: Response::HTTP_CREATED, + ); + } +} diff --git a/app/src/Entity/Budget.php b/app/src/Entity/Budget.php index 499f1f9..e691420 100644 --- a/app/src/Entity/Budget.php +++ b/app/src/Entity/Budget.php @@ -33,7 +33,7 @@ class Budget #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - private int $id; + private ?int $id = null; #[Serializer\Groups([ SerializationGroups::BUDGET_GET, @@ -131,12 +131,12 @@ public function __construct() $this->expenses = new ArrayCollection(); } - public function getId(): int + public function getId(): ?int { return $this->id; } - public function setId(int $id): static + public function setId(?int $id): static { $this->id = $id; @@ -155,7 +155,8 @@ public function setName(string $name): static return $this; } - #[ORM\PreFlush] + #[ORM\PrePersist] + #[ORM\PreUpdate] public function updateName(): void { $this->name = 'Budget ' . $this->date->format('Y-m'); diff --git a/app/src/Repository/BudgetRepository.php b/app/src/Repository/BudgetRepository.php index be2bd52..77be28f 100644 --- a/app/src/Repository/BudgetRepository.php +++ b/app/src/Repository/BudgetRepository.php @@ -6,6 +6,7 @@ use App\Entity\Budget; use My\RestBundle\Repository\Common\AbstractEntityRepository; +use Symfony\Component\Security\Core\User\UserInterface; /** * @extends AbstractEntityRepository @@ -17,4 +18,17 @@ public function getEntityClass(): string { return Budget::class; } + + public function findLatestByUser(?UserInterface $user): ?Budget + { + // @phpstan-ignore-next-line + return $this->createQueryBuilder('b') + ->where('b.user = :user') + ->setParameter('user', $user) + ->orderBy('b.date', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } } diff --git a/app/src/Service/BudgetService.php b/app/src/Service/BudgetService.php index 8cbaf62..3acb561 100644 --- a/app/src/Service/BudgetService.php +++ b/app/src/Service/BudgetService.php @@ -50,7 +50,6 @@ public function update(BudgetPayload $budgetPayload, Budget $budget): Budget { $this->checkAccess($budget); - // Clear existing incomes and expenses $budget->clearIncomes(); $budget->clearExpenses(); @@ -93,6 +92,49 @@ public function delete(Budget $budget): Budget return $budget; } + public function duplicate(?int $id = null): Budget + { + if ($id === null) { + $budget = $this->budgetRepository->findLatestByUser($this->security->getUser()); + } else { + $budget = $this->budgetRepository->find($id); + } + + if ($budget === null) { + throw new NotFoundHttpException('No budget found'); + } + + $this->checkAccess($budget); + + $newBudget = new Budget(); + + $newBudget->setName($budget->getName()); + $newBudget->setIncomesAmount($budget->getIncomesAmount()); + $newBudget->setExpensesAmount($budget->getExpensesAmount()); + $newBudget->setSavingCapacity($budget->getSavingCapacity()); + $newBudget->setUser($budget->getUser()); + + $newDate = $this->budgetRepository->findLatestByUser($budget->getUser())->getDate(); // @phpstan-ignore-line + $newDate->modify('+1 month'); // @phpstan-ignore-line + $newBudget->setDate($newDate); + + foreach ($budget->getIncomes() as $income) { + $newIncome = clone $income; + $newIncome->setBudget($newBudget); + $newBudget->addIncome($newIncome); + } + + foreach ($budget->getExpenses() as $expense) { + $newExpense = clone $expense; + $newExpense->setBudget($newBudget); + $newBudget->addExpense($newExpense); + } + + $this->budgetRepository->save($newBudget, true); + + return $newBudget; + } + /** * @return SlidingPagination */ From e95221ee3594e605f7e72a22aa87eb06bd4e167e Mon Sep 17 00:00:00 2001 From: dcorroyer Date: Mon, 21 Oct 2024 23:01:13 +0200 Subject: [PATCH 3/3] test and linters --- VERSION | 2 +- app/tests/Common/Factory/BudgetFactory.php | 2 - app/tests/Common/Factory/ExpenseFactory.php | 3 +- app/tests/Common/Factory/IncomeFactory.php | 3 +- .../Budget/DuplicateBudgetControllerTest.php | 50 +++++++++++++ .../Repository/BudgetRepositoryTest.php | 72 +++++++++++++++++++ .../Repository/IncomeRepositoryTest.php | 12 ++-- .../Repository/UserRepositoryTest.php | 51 +++++++++++++ app/tests/Unit/Service/BudgetServiceTest.php | 40 +++++++++++ 9 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 app/tests/Functional/Budget/DuplicateBudgetControllerTest.php create mode 100644 app/tests/Integration/Repository/BudgetRepositoryTest.php create mode 100644 app/tests/Integration/Repository/UserRepositoryTest.php diff --git a/VERSION b/VERSION index 589268e..e21e727 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 \ No newline at end of file +1.4.0 \ No newline at end of file diff --git a/app/tests/Common/Factory/BudgetFactory.php b/app/tests/Common/Factory/BudgetFactory.php index f885980..a09a3c2 100644 --- a/app/tests/Common/Factory/BudgetFactory.php +++ b/app/tests/Common/Factory/BudgetFactory.php @@ -27,8 +27,6 @@ protected function defaults(): array|callable return [ 'id' => self::faker()->randomNumber(), 'date' => self::faker()->dateTime(), - 'name' => self::faker()->text(255), - 'savingCapacity' => self::faker()->randomFloat(), 'user' => UserFactory::new(), ]; } diff --git a/app/tests/Common/Factory/ExpenseFactory.php b/app/tests/Common/Factory/ExpenseFactory.php index 83d4828..647a062 100644 --- a/app/tests/Common/Factory/ExpenseFactory.php +++ b/app/tests/Common/Factory/ExpenseFactory.php @@ -25,8 +25,7 @@ public static function class(): string protected function defaults(): array|callable { return [ - 'amount' => self::faker()->randomFloat(), - 'budget' => BudgetFactory::new(), + 'amount' => self::faker()->randomFloat(2, 10, 1000), 'category' => self::faker()->text(255), 'name' => self::faker()->text(255), ]; diff --git a/app/tests/Common/Factory/IncomeFactory.php b/app/tests/Common/Factory/IncomeFactory.php index 2e8bf99..945233c 100644 --- a/app/tests/Common/Factory/IncomeFactory.php +++ b/app/tests/Common/Factory/IncomeFactory.php @@ -25,8 +25,7 @@ public static function class(): string protected function defaults(): array|callable { return [ - 'amount' => self::faker()->randomFloat(), - 'budget' => BudgetFactory::new(), + 'amount' => self::faker()->randomFloat(2, 2000, 2500), 'name' => self::faker()->text(255), ]; } diff --git a/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php b/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php new file mode 100644 index 0000000..a9684bb --- /dev/null +++ b/app/tests/Functional/Budget/DuplicateBudgetControllerTest.php @@ -0,0 +1,50 @@ +_real(); + $this->client->loginUser($user); + + $budget = BudgetFactory::createOne([ + 'user' => $user, + ])->_real(); + + $expectedDate = clone $budget->getDate(); + $expectedDate->modify('+1 month'); + + // ACT + $response = $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT . '/' . $budget->getId()); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseIsSuccessful(); + self::assertResponseFormatSame('json'); + self::assertSame($expectedDate->format('Y-m'), $responseData['date']); + } +} diff --git a/app/tests/Integration/Repository/BudgetRepositoryTest.php b/app/tests/Integration/Repository/BudgetRepositoryTest.php new file mode 100644 index 0000000..2de0e9c --- /dev/null +++ b/app/tests/Integration/Repository/BudgetRepositoryTest.php @@ -0,0 +1,72 @@ +budgetRepository = $container->get(BudgetRepository::class); + } + + #[TestDox('When you send an budget into find method, it should returns the budget')] + #[Test] + public function find_WhenDataOk_ReturnsBudget(): void + { + // ARRANGE + $user = UserFactory::createOne()->_save(); + $budget = BudgetFactory::createOne([ + 'user' => $user, + ])->_real(); + + // ACT + $budgetResponse = $this->budgetRepository->find($budget); + + // ASSERT + self::assertSame($budget, $budgetResponse); + } + + #[TestDox('When you send an budget into findLatestByUser method, it should returns the budget')] + #[Test] + public function findLatestByUser_WhenDataOk_ReturnsBudget(): void + { + // ARRANGE + $user = UserFactory::createOne()->_real(); + $budget = BudgetFactory::createOne([ + 'user' => $user, + ])->_real(); + + // ACT + $budgetResponse = $this->budgetRepository->findLatestByUser($user); + + // ASSERT + self::assertSame($budget, $budgetResponse); + } +} diff --git a/app/tests/Integration/Repository/IncomeRepositoryTest.php b/app/tests/Integration/Repository/IncomeRepositoryTest.php index e08968a..4572ef4 100644 --- a/app/tests/Integration/Repository/IncomeRepositoryTest.php +++ b/app/tests/Integration/Repository/IncomeRepositoryTest.php @@ -56,10 +56,10 @@ public function find_WhenBadData_ReturnsNull(): void public function find_WhenDataOk_ReturnsIncome(): void { // ARRANGE - $user = UserFactory::createOne()->_save(); + $user = UserFactory::createOne()->_real(); $budget = BudgetFactory::createOne([ 'user' => $user, - ])->_save(); + ])->_real(); $income = IncomeFactory::createOne([ 'budget' => $budget, ])->_real(); @@ -76,10 +76,10 @@ public function find_WhenDataOk_ReturnsIncome(): void public function save_WhenDataOk_ReturnsIncome(): void { // ARRANGE - $user = UserFactory::createOne()->_save(); + $user = UserFactory::createOne()->_real(); $budget = BudgetFactory::createOne([ 'user' => $user, - ])->_save(); + ])->_real(); $income = IncomeFactory::createOne([ 'budget' => $budget, ])->_real(); @@ -96,10 +96,10 @@ public function save_WhenDataOk_ReturnsIncome(): void public function save_WhenBadData_ReturnsNull(): void { // ARRANGE - $user = UserFactory::createOne()->_save(); + $user = UserFactory::createOne()->_real(); $budget = BudgetFactory::createOne([ 'user' => $user, - ])->_save(); + ])->_real(); $income = IncomeFactory::createOne([ 'budget' => $budget, ])->_real(); diff --git a/app/tests/Integration/Repository/UserRepositoryTest.php b/app/tests/Integration/Repository/UserRepositoryTest.php new file mode 100644 index 0000000..b4ac186 --- /dev/null +++ b/app/tests/Integration/Repository/UserRepositoryTest.php @@ -0,0 +1,51 @@ +userRepository = $container->get(UserRepository::class); + } + + #[TestDox('When you send an user and a password into upgradePassword method, it should returns the updated user')] + #[Test] + public function upgradePassword_WhenDataOk_ReturnsUpdatedUser(): void + { + // ARRANGE + $user = UserFactory::createOne()->_real(); + + // ACT + $this->userRepository->upgradePassword($user, 'password'); + + // ASSERT + self::assertSame('password', $user->getPassword()); + } +} diff --git a/app/tests/Unit/Service/BudgetServiceTest.php b/app/tests/Unit/Service/BudgetServiceTest.php index 1f7ffe3..231d6b0 100644 --- a/app/tests/Unit/Service/BudgetServiceTest.php +++ b/app/tests/Unit/Service/BudgetServiceTest.php @@ -33,6 +33,7 @@ final class BudgetServiceTest extends TestCase { use Factories; + private BudgetRepository $budgetRepository; private IncomeService $incomeService; @@ -233,6 +234,45 @@ public function deleteBudgetService_WithBadUser_ReturnsAccessDeniedException(): $this->budgetService->delete($budget); } + #[TestDox('When calling duplicate budget, it should clone and return the new budget created')] + #[Test] + public function duplicateBudgetService_WhenDataOk_ReturnsNewBudgetCreated(): void + { + // ARRANGE + $budget = BudgetFactory::createOne([ + 'user' => $this->security->getUser(), + 'date' => new \DateTime('2023-01-01'), + ]); + + $this->budgetRepository->expects($this->once()) + ->method('find') + ->willReturn($budget) + ; + + $this->budgetRepository->expects($this->once()) + ->method('findLatestByUser') + ->willReturn($budget) + ; + + $this->budgetRepository->expects($this->once()) + ->method('save') + ->willReturnCallback(static function (Budget $budget): void { + $budget->setId(2) + ->setDate(new \DateTime('2023-02-01')) + ->updateName() + ; + }) + ; + + // ACT + $budgetResponse = $this->budgetService->duplicate($budget->getId()); + + // ASSERT + self::assertInstanceOf(Budget::class, $budget); + self::assertSame(2, $budgetResponse->getId()); + self::assertSame('Budget 2023-02', $budgetResponse->getName()); + } + #[TestDox('When you call paginate, it should return the budgets list')] #[Test] public function paginateBudgetService_WhenDataOk_ReturnsBudgetsList(): void