From c32012989a9d35d9989d212535808632e1951677 Mon Sep 17 00:00:00 2001 From: Luis Galvez Bommer Date: Wed, 4 Feb 2026 19:36:59 +0100 Subject: [PATCH 1/8] Added autoapprove + unit tests + refactoring code --- Controller/ApprovalController.php | 156 +++- Controller/WeekReportController.php | 154 +--- Repository/ApprovalRepository.php | 104 +++ Repository/Query/ApprovalQuery.php | 10 +- Resources/translations/messages.de.xlf | 16 + Resources/translations/messages.en.xlf | 16 + Resources/translations/messages.hr.xlf | 16 + Resources/views/to_approve.html.twig | 91 ++- Service/ApprovalDataService.php | 183 +++++ Service/AutoApprovalService.php | 152 ++++ Toolbox/BreakTimeCheckToolGER.php | 6 +- tests/Controller/WeekReportControllerTest.php | 222 ++++++ .../Integration/AutoApprovalFilteringTest.php | 275 +++++++ tests/Repository/ApprovalRepositoryTest.php | 696 +++++++++++++++++ tests/Service/ApprovalDataServiceTest.php | 709 ++++++++++++++++++ tests/Service/AutoApprovalServiceTest.php | 430 +++++++++++ 16 files changed, 3114 insertions(+), 122 deletions(-) create mode 100644 Service/ApprovalDataService.php create mode 100644 Service/AutoApprovalService.php create mode 100644 tests/Controller/WeekReportControllerTest.php create mode 100644 tests/Integration/AutoApprovalFilteringTest.php create mode 100644 tests/Repository/ApprovalRepositoryTest.php create mode 100644 tests/Service/ApprovalDataServiceTest.php create mode 100644 tests/Service/AutoApprovalServiceTest.php diff --git a/Controller/ApprovalController.php b/Controller/ApprovalController.php index ca94e60..1a78d29 100644 --- a/Controller/ApprovalController.php +++ b/Controller/ApprovalController.php @@ -9,7 +9,13 @@ namespace KimaiPlugin\ApprovalBundle\Controller; +use App\Entity\User; +use App\Entity\Team; +use App\Form\Model\DateRange; use App\Repository\UserRepository; +use App\Repository\TimesheetRepository; +use App\Repository\Query\BaseQuery; +use App\Repository\Query\TimesheetQuery; use DateTime; use KimaiPlugin\ApprovalBundle\Entity\Approval; use KimaiPlugin\ApprovalBundle\Entity\ApprovalHistory; @@ -18,9 +24,11 @@ use KimaiPlugin\ApprovalBundle\Repository\ApprovalRepository; use KimaiPlugin\ApprovalBundle\Repository\ApprovalStatusRepository; use KimaiPlugin\ApprovalBundle\Repository\LockdownRepository; +use KimaiPlugin\ApprovalBundle\Toolbox\BreakTimeCheckToolGER; use KimaiPlugin\ApprovalBundle\Toolbox\EmailTool; use KimaiPlugin\ApprovalBundle\Toolbox\SettingsTool; use KimaiPlugin\ApprovalBundle\Enumeration\ConfigEnum; +use KimaiPlugin\ApprovalBundle\Service\AutoApprovalService; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; @@ -37,7 +45,10 @@ public function __construct( private UserRepository $userRepository, private EmailTool $emailTool, private SettingsTool $settingsTool, - private LockdownRepository $lockdownRepository + private LockdownRepository $lockdownRepository, + private TimesheetRepository $timesheetRepository, + private BreakTimeCheckToolGER $breakTimeCheckToolGER, + private AutoApprovalService $autoApprovalService ) { } @@ -47,7 +58,7 @@ public function addToApprove(Request $request): RedirectResponse $userId = $request->query->get('user'); $date = $request->query->get('date'); $approval = $this->createAddToApproveForm($userId, $date); - if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_SUBMITTED_NY, true)){ + if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_SUBMITTED_NY, true)) { $this->emailTool->sendApproveWeekEmail($approval, $this->approvalRepository); } $this->lockdownRepository->updateLockWeek($approval, $this->approvalRepository); @@ -58,6 +69,89 @@ public function addToApprove(Request $request): RedirectResponse ])); } + #[Route(path: '/auto_approve', name: 'auto_approve', methods: ['GET'])] + public function autoApproveAction(Request $request): RedirectResponse + { + $users = $this->getUsersForAutoApproval(); + $query = $request->getSession()->get('query'); + + if (!empty($query->getUsers())) { + $users = $query->getUsers(); + } + + $submittedApprovals = $this->approvalRepository->getUserApprovalsFiltered($users, $query); + + $result = $this->autoApprovalService->processApprovals($submittedApprovals); + + // Process approved approvals + foreach ($result['processedApprovals'] as $approval) { + $this->finalizeApproval($approval, $request->query->get('date')); + } + + return $this->buildAutoApprovalRedirect($query, $result['successful'], $result['failed'], $request); + } + + private function getUsersForAutoApproval(): array + { + $includeSelf = $this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_TEAMLEAD_SELF_APPROVE_NY) == '1'; + return $this->getUsers($includeSelf); + } + + private function finalizeApproval(Approval $approval, ?string $date): void + { + $approval = $this->createNewApproveHistory($approval->getId(), ApprovalStatus::APPROVED); + + if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_ACTION_NY, true)) { + $this->emailTool->sendStatusChangedEmail( + $approval, + $this->getUser()->getDisplayName(), + $this->approvalRepository->getUrl( + (string) $approval->getUser()->getId(), + $approval->getStartDate()->format('Y-m-d') + ) + ); + } + + $this->lockdownRepository->updateLockWeek($approval, $this->approvalRepository); + $this->emailIfClosedMonth($date); + } + + private function buildAutoApprovalRedirect($query, int $successCount, int $failCount, Request $request): RedirectResponse + { + if (!$query) { + return new RedirectResponse($this->urlGenerator->generate('approval_bundle_to_approve')); + } + + $params = [ + 'page' => $query->getPage(), + 'orderBy' => $query->getOrderBy(), + 'order' => $query->getOrder(), + 'auto_approve_success' => $successCount, + 'auto_approve_fail' => $failCount, + ]; + + if ($query->getSearchTerm() && !empty($query->getSearchTerm()->getSearchTerm())) { + $params['searchTerm'] = $query->getSearchTerm()->getSearchTerm(); + $params['performSearch'] = 'performSearch'; + } + + if ($query->getBegin() && $query->getEnd()) { + $params['daterange'] = $query->getBegin()->format('d.m.Y') . ' - ' . $query->getEnd()->format('d.m.Y'); + } + + if (!empty($query->getUsers())) { + $params['users'] = array_map(fn($user) => $user->getId(), $query->getUsers()); + } + + if (!empty($query->getStatus())) { + $params['status'] = $query->getStatus(); + } + + $params['_token'] = $request->getSession()->get('_csrf/toolbar'); + + return new RedirectResponse($this->urlGenerator->generate('approval_bundle_to_approve', $params)); + } + #[Route(path: '/approve/{approveId}', defaults: ['approveId' => 0], name: 'approve', methods: ['GET'])] public function approveAction(Request $request, string $approveId): RedirectResponse { @@ -71,7 +165,7 @@ public function approveAction(Request $request, string $approveId): RedirectResp ); if ($approval) { $approval = $this->createNewApproveHistory($approveId, ApprovalStatus::APPROVED); - if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_ACTION_NY, true)){ + if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_ACTION_NY, true)) { $this->emailTool->sendStatusChangedEmail( $approval, $this->getUser()->getDisplayName(), @@ -129,7 +223,7 @@ public function deniedAction(Request $request, string $approveId): RedirectRespo ); if ($approval) { $approval = $this->createNewApproveHistory($approveId, ApprovalStatus::DENIED, $request->query->get('input')); - if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_ACTION_NY, true)){ + if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_MAIL_ACTION_NY, true)) { $this->emailTool->sendStatusChangedEmail( $approval, $this->getUser()->getDisplayName(), @@ -211,4 +305,58 @@ private function emailIfClosedMonth($date): void } } } + + private function getUsers(bool $includeOwnForTeam = true): array + { + if ($this->canManageAllPerson()) { + $users = $this->userRepository->findAll(); + } elseif ($this->canManageTeam()) { + $users = []; + $user = $this->getUser(); + /** @var Team $team */ + foreach ($user->getTeams() as $team) { + if (\in_array($user, $team->getTeamleads())) { + array_push($users, ...$team->getUsers()); + } else { + $users[] = $user; + } + } + + if (empty($users)) { + $users = [$user]; + } + + $users = array_unique($users); + + if (!$includeOwnForTeam) { + // remove the active user from the list + $index = array_search($this->getUser(), $users); + if ($index !== false) { + unset($users[$index]); + } + } + } else { + $users = [$this->getUser()]; + } + + $users = array_reduce($users, function ($current, $user) { + $includeSuperAdmin = $this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_INCLUDE_ADMIN_NY) == '1'; + if ($user->isEnabled() && (!$user->isSuperAdmin() || $includeSuperAdmin)) { + $current[] = $user; + } + + return $current; + }, []); + + if (!empty($users)) { + usort( + $users, + function (User $userA, User $userB) { + return strcmp(strtoupper($userA->getUsername()), strtoupper($userB->getUsername())); + } + ); + } + + return $users; + } } diff --git a/Controller/WeekReportController.php b/Controller/WeekReportController.php index 86e33c3..6a9ffd2 100644 --- a/Controller/WeekReportController.php +++ b/Controller/WeekReportController.php @@ -38,6 +38,7 @@ use KimaiPlugin\ApprovalBundle\Repository\ApprovalWorkdayHistoryRepository; use KimaiPlugin\ApprovalBundle\Repository\ReportRepository; use KimaiPlugin\ApprovalBundle\Repository\Query\ApprovalQuery; +use KimaiPlugin\ApprovalBundle\Service\ApprovalDataService; use KimaiPlugin\ApprovalBundle\Toolbox\BreakTimeCheckToolGER; use KimaiPlugin\ApprovalBundle\Toolbox\Formatting; use KimaiPlugin\ApprovalBundle\Toolbox\SecurityTool; @@ -65,7 +66,8 @@ public function __construct( private TimesheetRepository $timesheetRepository, private ApprovalTimesheetRepository $approvalTimesheetRepository, private BreakTimeCheckToolGER $breakTimeCheckToolGER, - private ReportRepository $reportRepository + private ReportRepository $reportRepository, + private ApprovalDataService $approvalDataService ) { } @@ -176,137 +178,61 @@ public function toApprove(ApprovalQuery $query, Request $request): Response return $this->redirectToRoute('approval_bundle_to_approve'); } - if ($this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_TEAMLEAD_SELF_APPROVE_NY) == '1') { - $users = $this->getUsers(true); - } else { - $users = $this->getUsers(false); - } - - $warningNoUsers = false; - if (empty($users)) { - $warningNoUsers = true; - $users = [$this->getUser()]; - } - - $allRows = $this->approvalRepository->findAllWeek($users); - - if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_HIDE_APPROVED_NY, false)) { - $allRows = $this->approvalRepository->filterWeeksNotApproved($allRows); - } - - $selectedUsers = $query->getUsers(); - if (!empty($selectedUsers)) { - $selectedUserIds = array_map(fn(User $user) => $user->getId(), $selectedUsers); - $allRows = array_filter( - $allRows, - function ($row) use ($selectedUserIds) { - return in_array($row['userId'], $selectedUserIds); - } - ); - } - - $dateRange = $query->getDateRange(); - if ($dateRange !== null && $dateRange->getBegin() !== null && $dateRange->getEnd() !== null) { - $begin = $dateRange->getBegin(); - $end = $dateRange->getEnd(); - - $allRows = array_filter( - $allRows, - function ($row) use ($begin, $end) { - $weekStart = $row['week']->value; - $weekEnd = (clone $weekStart)->modify('+6 days'); - return ($weekStart >= $begin && $weekStart <= $end) || - ($weekEnd >= $begin && $weekEnd <= $end); - } - ); - } - - $selectedStatus = $query->getStatus(); - if (!empty($selectedStatus)) { - $allRows = array_filter( - $allRows, - function ($row) use ($selectedStatus) { - return in_array($row['status'], $selectedStatus); - } - ); - } - - $searchTerm = $query->getSearchTerm(); - if ($searchTerm !== null && !empty($searchTerm->getSearchTerm())) { - - $searchParts = $searchTerm->getParts(); - - $allRows = array_filter( - $allRows, - function ($row) use ($searchParts) { - foreach ($searchParts as $part) { - $term = mb_strtolower($part->getTerm()); - $matchedInRow = false; - - $userName = mb_strtolower($row['user']); - if (str_contains($userName, $term)) { - $matchedInRow = true; - } + $request->getSession()->set('query', $query); - $weekLabel = mb_strtolower($row['week']->label ?? ''); - if (str_contains($weekLabel, $term)) { - $matchedInRow = true; - } + $users = $this->getUsersForApproval(); + $warningNoUsers = empty($users); - $status = mb_strtolower($row['status']); - if (str_contains($status, $term)) { - $matchedInRow = true; - } - - if (!$matchedInRow) { - return false; - } - } - return true; - } - ); + if ($warningNoUsers) { + $users = [$this->getUser()]; } + $allRows = $this->approvalDataService->fetchAndFilterApprovalRows($query, $users); + $allRows = $this->approvalDataService->enrichRowsWithErrors($allRows); $allRows = $this->sortArrayByQuery($allRows, $query); - $pastRows = []; - $currentRows = []; - $futureRows = []; - $currentWeek = (new DateTime('now'))->modify('next monday')->modify('-2 week')->format('Y-m-d'); - $futureWeek = (new DateTime('now'))->modify('next monday')->modify('-1 week')->format('Y-m-d'); - foreach ($allRows as $row) { - if ($row['startDate'] >= $futureWeek) { - $futureRows[] = $row; - } elseif ($row['startDate'] >= $currentWeek) { - $currentRows[] = $row; - } else { - $pastRows[] = $row; - } - } + [$pastRows, $currentRows, $futureRows] = $this->approvalDataService->categorizeRowsByWeek($allRows); $pastRows = $this->approvalRepository->filterPastWeeksNotApproved($pastRows); - $allRowsDataTable = new DataTable('approval_all_entries', $query); - $allRowsDataTable->deactivateConfiguration(); - $allRowsDataTable->addColumn("user"); - $allRowsDataTable->addColumn("week"); - $allRowsDataTable->addColumn("status"); - $allRowsDataTable->addColumn("actions", ['class' => 'actions alwaysVisible']); - - $allRowsAdapter = new ArrayAdapter($allRows); - $allRowsPagination = new Pagination($allRowsAdapter); - $allRowsDataTable->setPagination($allRowsPagination); - $allRowsDataTable->setSearchForm($form); + $weeksSubmitted = $this->approvalDataService->countSubmittedWeeks($allRows); + $dataTable = $this->buildDataTable($query, $allRows, $form); return $this->render('@Approval/to_approve.html.twig', [ 'current_tab' => 'to_approve', - 'all_rows_datatable' => $allRowsDataTable, + 'all_rows_datatable' => $dataTable, 'past_rows' => $pastRows, 'current_rows' => $currentRows, 'future_rows' => $futureRows, + 'weeks_submitted' => $weeksSubmitted, + 'auto_approve_success' => $request->query->getInt('auto_approve_success', 0), + 'auto_approve_fail' => $request->query->getInt('auto_approve_fail', 0), 'warningNoUsers' => $warningNoUsers ] + $this->getDefaultTemplateParams($this->settingsTool)); } + private function getUsersForApproval(): array + { + $includeSelf = $this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_TEAMLEAD_SELF_APPROVE_NY) == '1'; + return $this->getUsers($includeSelf); + } + + private function buildDataTable(ApprovalQuery $query, array $rows, FormInterface $form): DataTable + { + $dataTable = new DataTable('approval_all_entries', $query); + $dataTable->deactivateConfiguration(); + $dataTable->addColumn("user"); + $dataTable->addColumn("week"); + $dataTable->addColumn("status"); + $dataTable->addColumn("actions", ['class' => 'actions alwaysVisible']); + + $adapter = new ArrayAdapter($rows); + $pagination = new Pagination($adapter); + $dataTable->setPagination($pagination); + $dataTable->setSearchForm($form); + + return $dataTable; + } + protected function getToolbarForm(ApprovalQuery $query): FormInterface { return $this->createSearchForm(ApprovalToolbarForm::class, $query, [ diff --git a/Repository/ApprovalRepository.php b/Repository/ApprovalRepository.php index 5e8b9cf..b9744b2 100644 --- a/Repository/ApprovalRepository.php +++ b/Repository/ApprovalRepository.php @@ -19,6 +19,7 @@ use KimaiPlugin\ApprovalBundle\Entity\ApprovalHistory; use KimaiPlugin\ApprovalBundle\Entity\ApprovalStatus; use KimaiPlugin\ApprovalBundle\Enumeration\ConfigEnum; +use KimaiPlugin\ApprovalBundle\Repository\Query\ApprovalQuery; use KimaiPlugin\ApprovalBundle\Settings\ApprovalSettingsInterface; use KimaiPlugin\ApprovalBundle\Toolbox\Formatting; use KimaiPlugin\ApprovalBundle\Toolbox\SettingsTool; @@ -782,6 +783,109 @@ public function getUserApprovals(?array $users, $startDate = null) return $parseToViewArray; } + public function getUserApprovalsFiltered(?array $users, ApprovalQuery $query, $startDate = null) + { + if (count($users) == 0) { + return []; + } + + $usersId = array_map(function ($user) { + return $user->getId(); + }, $users); + + $approval_workflow_start = $this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_WORKFLOW_START); + if ($approval_workflow_start == '') { + $approval_workflow_start = '0000-01-01'; + } else { + $approval_workflow_start = (new DateTime($approval_workflow_start))->modify('-7 day')->format('Y-m-d'); + } + + if ($startDate !== null) { + $start = $startDate; + } else { + $start = $approval_workflow_start; + } + + $dateRangeStart = $query->getBegin() ?? null; + $dateRangeEnd = $query->getEnd() ?? null; + $searchTerm = $query->getSearchTerm(); + + + $em = $this->getEntityManager(); + $qb = $em->createQueryBuilder() + ->select('ap') + ->from(Approval::class, 'ap') + ->join('ap.user', 'u') + ->join('ap.history', 'ah') + ->join('ah.status', 'ast') + ->andWhere($em->getExpressionBuilder()->in('u.id', $usersId)) + ->andWhere("ast.name = '" . ApprovalStatus::SUBMITTED . "'") + ->andWhere('ap.startDate >= :begin') + ->setParameter('begin', $start); + + if ($dateRangeStart !== null) { + $qb->andWhere('ap.startDate >= :dateRangeStart') + ->setParameter('dateRangeStart', $dateRangeStart); + } + + if ($dateRangeEnd !== null) { + $qb->andWhere('ap.endDate <= :dateRangeEnd') + ->setParameter('dateRangeEnd', $dateRangeEnd); + } + + $approvedListFiltered = $qb + ->getQuery() + ->getResult(); + + $this->parseHistoryToOneElement($approvedListFiltered); + + if ($searchTerm !== null && !empty($searchTerm->getSearchTerm())) { + $searchParts = $searchTerm->getParts(); + $approvedListFiltered = array_filter( + $approvedListFiltered, + function ($item) use ($searchParts) { + foreach ($searchParts as $part) { + $term = mb_strtolower($part->getTerm()); + $matchedInItem = false; + + $userName = mb_strtolower($item->getUser()->getUsername() ?? ''); + if (str_contains($userName, $term)) { + $matchedInItem = true; + } + + $userAlias = mb_strtolower($item->getUser()->getAlias() ?? ''); + if (str_contains($userAlias, $term)) { + $matchedInItem = true; + } + + $weekLabel = mb_strtolower($this->formatting->parseDate($item->getStartDate()) ?? ''); + if (str_contains($weekLabel, $term)) { + $matchedInItem = true; + } + + if (!$matchedInItem) { + return false; + } + + } + return true; + } + ); + } + $approvedListFiltered = array_filter( + $approvedListFiltered, + function ($item) { + $status = mb_strtolower($item->getHistory()[0]->getStatus()->getName() ?? ''); + if ($status === mb_strtolower(ApprovalStatus::SUBMITTED)) { + return true; + } + return false; + } + ); + + return $approvedListFiltered; + } + private function getLastHistory(Approval $item) { $history = $item->getHistory(); diff --git a/Repository/Query/ApprovalQuery.php b/Repository/Query/ApprovalQuery.php index d594a22..edb2536 100644 --- a/Repository/Query/ApprovalQuery.php +++ b/Repository/Query/ApprovalQuery.php @@ -2,6 +2,7 @@ namespace KimaiPlugin\ApprovalBundle\Repository\Query; use App\Entity\User; +use App\Form\Model\DateRange; use App\Repository\Query\BaseQuery; use App\Repository\Query\DateRangeTrait; use App\Repository\Query\DateRangeInterface; @@ -14,7 +15,7 @@ class ApprovalQuery extends BaseQuery implements DateRangeInterface /** * @var array */ - public array $users = []; + private array $users = []; private array $weeks = []; private array $statuses = []; @@ -26,6 +27,8 @@ public function __construct() ]); $this->setAllowedOrderColumns(self::APPROVAL_ORDER_ALLOWED); + + $this->dateRange = new DateRange(); } public function getStatus(): array @@ -45,4 +48,9 @@ public function getUsers(): array { return array_values($this->users); } + + public function setUsers(array $users): void + { + $this->users = $users; + } } \ No newline at end of file diff --git a/Resources/translations/messages.de.xlf b/Resources/translations/messages.de.xlf index 9558846..a8cfb2d 100644 --- a/Resources/translations/messages.de.xlf +++ b/Resources/translations/messages.de.xlf @@ -126,6 +126,18 @@ stats.duration_actual Erfasste Dauer + + table.auto_approve_confirm + Möchten Sie %count% Wochen automatisch genehmigen? + + + table.auto_approve_result + %success% Wochen automatisch genehmigt, %fail% fehlgeschlagen. + + + table.auto_approve + Automatische Genehmigung + table.past_weeks Vergangene Wochen @@ -138,6 +150,10 @@ table.future_week Kommende Wochen + + table.has_errors_tooltip + Hat Errors + table.approve_user Benutzer diff --git a/Resources/translations/messages.en.xlf b/Resources/translations/messages.en.xlf index 9b84154..4b61257 100644 --- a/Resources/translations/messages.en.xlf +++ b/Resources/translations/messages.en.xlf @@ -126,6 +126,18 @@ stats.duration_actual Actual duration + + table.auto_approve + Auto approval + + + table.auto_approve_confirm + Do you want to automatically approve %count% weeks? + + + table.auto_approve_result + %success% weeks automatically approved, %fail% failed. + table.past_weeks Past weeks @@ -138,6 +150,10 @@ table.future_week Upcoming weeks + + table.has_errors_tooltip + Has errors + table.approve_user User diff --git a/Resources/translations/messages.hr.xlf b/Resources/translations/messages.hr.xlf index 216bf17..9c6dbf2 100644 --- a/Resources/translations/messages.hr.xlf +++ b/Resources/translations/messages.hr.xlf @@ -122,6 +122,18 @@ stats.duration_actual Stvarno trajanje + + table.auto_approve + Automatsko odobrenje + + + table.auto_approve_confirm + Želite li automatski odobriti %count% tjedana? + + + table.auto_approve_result + %success% tjedana automatski odobreno, %fail% neuspješno. + table.past_weeks Prošli tjedan @@ -134,6 +146,10 @@ table.future_week Nadolazećih tjedana + + table.has_errors_tooltip + Ima greške + table.approve_user Korisnik diff --git a/Resources/views/to_approve.html.twig b/Resources/views/to_approve.html.twig index 6f63462..02f33fd 100644 --- a/Resources/views/to_approve.html.twig +++ b/Resources/views/to_approve.html.twig @@ -15,11 +15,61 @@ tr.table-section-header h3 { margin: 0; } + + #to-approve-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } {% endblock %} {% block report %} - {{ tables.actions(all_rows_datatable) }} +
+ {{ tables.actions(all_rows_datatable) }} + +
+ + + + {{ tables.header(all_rows_datatable) }} {% set sortedColumns = all_rows_datatable.sortedColumnNames %} @@ -71,6 +121,12 @@ {% block datatable_column %} {% if column == 'user' %} + {% if entry['hasErrors'] %} + + + + {% endif %} {{ entry['user'] }} {% elseif column == 'week' %} {{ entry['week'].label }} @@ -126,5 +182,38 @@ document.dispatchEvent(new Event('filter-change')); } }, true); + + document.addEventListener('DOMContentLoaded', () => { + const urlParams = new URLSearchParams(window.location.search); + const modal = document.getElementById('autoApproveResultMessage'); + const closeBtns = modal?.querySelectorAll('[data-bs-dismiss="modal"]'); + + const hasResult = urlParams.has('auto_approve_success') && urlParams.has('auto_approve_fail'); + + + if (hasResult && modal) { + modal.classList.add('show'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-hidden', 'false'); + modal.style.display = 'block'; + document.body.classList.add('modal-open'); + + urlParams.delete('auto_approve_success'); + urlParams.delete('auto_approve_fail'); + const newUrl = `${location.pathname}?${urlParams.toString()}`; + history.replaceState({}, '', newUrl); + } + + closeBtns?.forEach(btn => btn.addEventListener('click', () => { + if (document.activeElement) { + document.activeElement.blur(); + } + modal.classList.remove('show'); + modal.removeAttribute('aria-modal'); + modal.setAttribute('aria-hidden', 'true'); + modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + })); + }); {% endblock %} \ No newline at end of file diff --git a/Service/ApprovalDataService.php b/Service/ApprovalDataService.php new file mode 100644 index 0000000..130f95e --- /dev/null +++ b/Service/ApprovalDataService.php @@ -0,0 +1,183 @@ +approvalRepository->findAllWeek($users); + + if ($this->settingsTool->getBooleanConfiguration(ConfigEnum::APPROVAL_HIDE_APPROVED_NY, false)) { + $rows = $this->approvalRepository->filterWeeksNotApproved($rows); + } + + $rows = $this->filterByUsers($rows, $query->getUsers()); + $rows = $this->filterByDateRange($rows, $query->getDateRange()); + $rows = $this->filterByStatus($rows, $query->getStatus()); + $rows = $this->filterBySearchTerm($rows, $query->getSearchTerm()); + + return $rows; + } + + public function enrichRowsWithErrors(array $rows): array + { + foreach ($rows as &$row) { + $timesheets = $this->getTimesheetsForRow($row); + $errors = $this->getBreakTimeErrors($timesheets); + $row['hasErrors'] = !empty(array_filter($errors)); + } + return $rows; + } + + public function categorizeRowsByWeek(array $rows): array + { + $currentWeek = (new DateTime('now'))->modify('next monday')->modify('-1 week')->format('Y-m-d'); + $futureWeek = (new DateTime('now'))->modify('next monday')->format('Y-m-d'); + + $pastRows = []; + $currentRows = []; + $futureRows = []; + + foreach ($rows as $row) { + if ($row['startDate'] >= $futureWeek) { + $futureRows[] = $row; + } elseif ($row['startDate'] >= $currentWeek) { + $currentRows[] = $row; + } else { + $pastRows[] = $row; + } + } + + return [$pastRows, $currentRows, $futureRows]; + } + + public function countSubmittedWeeks(array $rows): int + { + return count(array_filter( + $rows, + fn($row) => + isset($row['status']) && $row['status'] === "submitted" + )); + } + + private function filterByUsers(array $rows, array $selectedUsers): array + { + if (empty($selectedUsers)) { + return $rows; + } + + $selectedUserIds = array_map(fn(User $user) => $user->getId(), $selectedUsers); + return array_filter($rows, fn($row) => in_array($row['userId'], $selectedUserIds)); + } + + private function filterByDateRange(array $rows, ?DateRange $dateRange): array + { + if ($dateRange === null || $dateRange->getBegin() === null || $dateRange->getEnd() === null) { + return $rows; + } + + $begin = $dateRange->getBegin(); + $end = $dateRange->getEnd(); + + return array_filter($rows, function ($row) use ($begin, $end) { + $weekStart = $row['week']->value; + $weekEnd = (clone $weekStart)->modify('+6 days'); + return ($weekStart >= $begin && $weekStart <= $end) || + ($weekEnd >= $begin && $weekEnd <= $end); + }); + } + + private function filterByStatus(array $rows, array $selectedStatus): array + { + if (empty($selectedStatus)) { + return $rows; + } + + return array_filter($rows, fn($row) => in_array($row['status'], $selectedStatus)); + } + + private function filterBySearchTerm(array $rows, $searchTerm): array + { + if ($searchTerm === null || empty($searchTerm->getSearchTerm())) { + return $rows; + } + + $searchParts = $searchTerm->getParts(); + + return array_filter($rows, function ($row) use ($searchParts) { + foreach ($searchParts as $part) { + if (!$this->rowMatchesSearchTerm($row, mb_strtolower($part->getTerm()))) { + return false; + } + } + return true; + }); + } + + private function rowMatchesSearchTerm(array $row, string $term): bool + { + $searchableFields = [ + mb_strtolower($row['user']), + mb_strtolower($row['week']->label ?? ''), + mb_strtolower($row['status']) + ]; + + foreach ($searchableFields as $field) { + if (str_contains($field, $term)) { + return true; + } + } + + return false; + } + + private function getTimesheetsForRow(array $row): array + { + $timesheetQuery = new TimesheetQuery(); + $timesheetQuery->setUser($this->userRepository->find($row['userId'])); + + $dateRange = new DateRange(); + $dateRange->setBegin($row['week']->value); + $dateRange->setEnd((clone $row['week']->value)->modify('+6 days')->setTime(23, 59, 59)); + $timesheetQuery->setDateRange($dateRange); + $timesheetQuery->setOrderBy('date'); + $timesheetQuery->setOrder(BaseQuery::ORDER_ASC); + + return $this->timesheetRepository->getTimesheetsForQuery($timesheetQuery); + } + + private function getBreakTimeErrors(array $timesheets): array + { + if ( + !$this->settingsTool->isInConfiguration(ConfigEnum::APPROVAL_BREAKCHECKS_NY) && + !$this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ) { + return []; + } + + return $this->breakTimeCheckToolGER->checkBreakTime($timesheets); + } +} \ No newline at end of file diff --git a/Service/AutoApprovalService.php b/Service/AutoApprovalService.php new file mode 100644 index 0000000..bf7ceb8 --- /dev/null +++ b/Service/AutoApprovalService.php @@ -0,0 +1,152 @@ +processSingleApproval($approval); + + if ($result['approved']) { + $countSuccessful++; + $processedApprovals[] = $result['approval']; + } else { + $countFailed++; + } + } + + return [ + 'successful' => $countSuccessful, + 'failed' => $countFailed, + 'processedApprovals' => $processedApprovals + ]; + } + + /** + * Process a single approval for auto-approval + * + * @return array{approved: bool, approval: ?Approval, reason: string} + */ + public function processSingleApproval(Approval $approval): array + { + // Check if approval has correct status + $approval = $this->approvalRepository->checkLastStatus( + $approval->getStartDate(), + $approval->getEndDate(), + $approval->getUser(), + ApprovalStatus::SUBMITTED, + $approval + ); + + if (!$approval) { + return [ + 'approved' => false, + 'approval' => null, + 'reason' => 'Invalid approval status' + ]; + } + + // Verify the last status is SUBMITTED + if ($approval->getHistory()[0]->getStatus()->getName() !== ApprovalStatus::SUBMITTED) { + return [ + 'approved' => false, + 'approval' => $approval, + 'reason' => 'Not in submitted status' + ]; + } + + // Get timesheets for validation + $timesheets = $this->getTimesheetsForApproval($approval); + + // Check for errors + $errors = $this->validateTimesheets($timesheets); + $hasErrors = !empty(array_filter($errors)); + + if ($hasErrors) { + return [ + 'approved' => false, + 'approval' => $approval, + 'reason' => 'Timesheet validation errors found' + ]; + } + + // Approval can be auto-approved + return [ + 'approved' => true, + 'approval' => $approval, + 'reason' => 'Auto-approved successfully' + ]; + } + + /** + * Get timesheets for an approval period + */ + public function getTimesheetsForApproval(Approval $approval): array + { + $timesheetQuery = new TimesheetQuery(); + $timesheetQuery->setUser($approval->getUser()); + + $dateRange = new DateRange(); + $dateRange->setBegin($approval->getStartDate()); + $dateRange->setEnd((clone $approval->getEndDate())->setTime(23, 59, 59)); + $timesheetQuery->setDateRange($dateRange); + $timesheetQuery->setOrderBy('date'); + $timesheetQuery->setOrder(BaseQuery::ORDER_ASC); + + return $this->timesheetRepository->getTimesheetsForQuery($timesheetQuery); + } + + /** + * Validate timesheets for break time errors + */ + public function validateTimesheets(array $timesheets): array + { + if (!$this->shouldCheckBreakTime()) { + return []; + } + + return $this->breakTimeCheckToolGER->checkBreakTime($timesheets); + } + + /** + * Check if break time validation is enabled + */ + private function shouldCheckBreakTime(): bool + { + return $this->settingsTool->isInConfiguration(ConfigEnum::APPROVAL_BREAKCHECKS_NY) && + $this->settingsTool->getConfiguration(ConfigEnum::APPROVAL_BREAKCHECKS_NY); + + } +} diff --git a/Toolbox/BreakTimeCheckToolGER.php b/Toolbox/BreakTimeCheckToolGER.php index e11a44a..837f60b 100644 --- a/Toolbox/BreakTimeCheckToolGER.php +++ b/Toolbox/BreakTimeCheckToolGER.php @@ -60,8 +60,9 @@ private function checkOffdayWork($timesheets, &$errors, $offdays) foreach ($timesheets as $timesheet) { if ( \in_array($timesheet->getBegin()->format('Y-m-d'), $offdays) && - ($errors[$timesheet->getBegin()->format('Y-m-d')] == null || - \in_array($this->translator->trans('error.work_offdays'), $errors[$timesheet->getBegin()->format('Y-m-d')]) == false) + (array_key_exists($timesheet->getBegin()->format('Y-m-d'), $errors) && + ($errors[$timesheet->getBegin()->format('Y-m-d')] == null || + \in_array($this->translator->trans('error.work_offdays'), $errors[$timesheet->getBegin()->format('Y-m-d')]) == false)) ) { $errors[$timesheet->getBegin()->format('Y-m-d')][] = $this->translator->trans('error.work_offdays'); } @@ -101,6 +102,7 @@ private function checkSixHoursWithoutBreak($timesheets, &$errors) } $blockEnd = $timesheet->getEnd()->getTimestamp(); if ($blockEnd - $blockStart > $sixHoursInSeconds) { + if ( array_key_exists($timesheet->getBegin()->format('Y-m-d'), $errors) && ($errors[$timesheet->getBegin()->format('Y-m-d')] == null || \in_array($this->translator->trans('error.six_hours_without_stop_break'), $errors[$timesheet->getBegin()->format('Y-m-d')]) == false) diff --git a/tests/Controller/WeekReportControllerTest.php b/tests/Controller/WeekReportControllerTest.php new file mode 100644 index 0000000..3b86e71 --- /dev/null +++ b/tests/Controller/WeekReportControllerTest.php @@ -0,0 +1,222 @@ +controller = new WeekReportController( + $this->createMock(SettingsTool::class), + $this->createMock(SecurityTool::class), + $this->createMock(UserRepository::class), + $this->createMock(ApprovalHistoryRepository::class), + $this->createMock(ApprovalRepository::class), + $this->createMock(ApprovalWorkdayHistoryRepository::class), + new Formatting($this->createMock(TranslatorInterface::class)), + $this->createMock(TimesheetRepository::class), + $this->createMock(ApprovalTimesheetRepository::class), + $this->createMock(BreakTimeCheckToolGER::class), + $this->createMock(ReportRepository::class), + $this->createMock(ApprovalDataService::class) + ); + } + + /** + * Test that toApprove method exists and has correct signature + */ + public function testToApproveMethodExists(): void + { + $reflection = new \ReflectionMethod($this->controller, 'toApprove'); + + $this->assertEquals('toApprove', $reflection->getName()); + $this->assertTrue($reflection->isPublic()); + + // Check it returns Response + $returnType = $reflection->getReturnType(); + $this->assertNotNull($returnType); + $this->assertEquals('Symfony\Component\HttpFoundation\Response', $returnType->getName()); + + // Check parameter count and types + $this->assertCount(2, $reflection->getParameters()); + } + + /** + * Test that toApprove has proper dependencies injected + */ + public function testToApproveHasRequiredDependencies(): void + { + $reflection = new \ReflectionClass($this->controller); + + // Verify ApprovalDataService dependency exists + $constructor = $reflection->getConstructor(); + $this->assertNotNull($constructor); + + $parameters = $constructor->getParameters(); + $parameterNames = array_map(fn($p) => $p->getName(), $parameters); + + $this->assertContains('approvalDataService', $parameterNames); + } + + /** + * Test that helper methods exist for toApprove workflow + */ + public function testToApproveHelperMethodsExist(): void + { + $reflection = new \ReflectionClass($this->controller); + + // Check critical helper methods exist + $this->assertTrue($reflection->hasMethod('getUsersForApproval')); + $this->assertTrue($reflection->hasMethod('buildDataTable')); + $this->assertTrue($reflection->hasMethod('sortArrayByQuery')); + } + + /** + * Test getUsersForApproval is private (implementation detail) + */ + public function testGetUsersForApprovalIsPrivate(): void + { + $reflection = new \ReflectionMethod($this->controller, 'getUsersForApproval'); + + $this->assertTrue($reflection->isPrivate()); + $this->assertCount(0, $reflection->getParameters()); + } + + /** + * Test buildDataTable is private + */ + public function testBuildDataTableIsPrivate(): void + { + $reflection = new \ReflectionMethod($this->controller, 'buildDataTable'); + + $this->assertTrue($reflection->isPrivate()); + $this->assertCount(3, $reflection->getParameters()); + } + + /** + * Test that toApprove workflow is properly structured + * This validates the refactoring maintains proper separation of concerns + */ + public function testToApproveWorkflowStructure(): void + { + $method = new \ReflectionMethod($this->controller, 'toApprove'); + $source = file_get_contents($method->getFileName()); + + // Extract just the toApprove method + $start = $method->getStartLine() - 1; + $end = $method->getEndLine(); + $length = $end - $start; + $sourceLines = array_slice(file($method->getFileName()), $start, $length); + $methodSource = implode('', $sourceLines); + + // Verify it uses ApprovalDataService methods (refactored code) + $this->assertStringContainsString('approvalDataService->fetchAndFilterApprovalRows', $methodSource); + $this->assertStringContainsString('approvalDataService->enrichRowsWithErrors', $methodSource); + $this->assertStringContainsString('approvalDataService->categorizeRowsByWeek', $methodSource); + $this->assertStringContainsString('approvalDataService->countSubmittedWeeks', $methodSource); + + // Verify it still handles form and rendering + $this->assertStringContainsString('getToolbarForm', $methodSource); + $this->assertStringContainsString('render', $methodSource); + } + + /** + * Test that toApprove method is not too complex after refactoring + */ + public function testToApproveComplexityReduced(): void + { + $method = new \ReflectionMethod($this->controller, 'toApprove'); + + $start = $method->getStartLine() - 1; + $end = $method->getEndLine(); + $length = $end - $start; + + // After refactoring, method should be less than 60 lines + $this->assertLessThan( + 60, + $length, + 'toApprove method should be less than 60 lines after refactoring' + ); + } + + /** + * Test that refactored code maintains all template variables + */ + public function testToApproveRendersWithCorrectTemplateVariables(): void + { + $method = new \ReflectionMethod($this->controller, 'toApprove'); + $source = file_get_contents($method->getFileName()); + + $start = $method->getStartLine() - 1; + $end = $method->getEndLine(); + $length = $end - $start; + $sourceLines = array_slice(file($method->getFileName()), $start, $length); + $methodSource = implode('', $sourceLines); + + // Verify expected template variables are passed + $expectedVariables = [ + 'current_tab', + 'all_rows_datatable', + 'past_rows', + 'current_rows', + 'future_rows', + 'weeks_submitted', + 'auto_approve_success', + 'auto_approve_fail', + 'warningNoUsers' + ]; + + foreach ($expectedVariables as $variable) { + $this->assertStringContainsString( + "'$variable'", + $methodSource, + "Template should receive '$variable' variable" + ); + } + } + + /** + * Test that the controller uses dependency injection properly + */ + public function testControllerUsesDependencyInjection(): void + { + $reflection = new \ReflectionClass($this->controller); + $constructor = $reflection->getConstructor(); + + $this->assertNotNull($constructor); + $this->assertTrue($constructor->isPublic()); + + // Verify all dependencies are constructor-injected (12 dependencies) + $this->assertCount(12, $constructor->getParameters()); + } +} \ No newline at end of file diff --git a/tests/Integration/AutoApprovalFilteringTest.php b/tests/Integration/AutoApprovalFilteringTest.php new file mode 100644 index 0000000..2b6d029 --- /dev/null +++ b/tests/Integration/AutoApprovalFilteringTest.php @@ -0,0 +1,275 @@ +approvalRepository = $this->createMock(ApprovalRepository::class); + $this->settingsTool = $this->createMock(SettingsTool::class); + + // We'll verify the service receives the correct filtered approvals + $this->autoApprovalService = $this->createMock(AutoApprovalService::class); + } + + /** + * Test that only approvals matching the query users filter get processed + */ + public function testAutoApproveOnlyProcessesSelectedUsers(): void + { + // Setup: Create multiple users + $user1 = $this->createMockUser(1, 'Alice'); + $user2 = $this->createMockUser(2, 'Bob'); + $user3 = $this->createMockUser(3, 'Charlie'); + + // Create approvals for all users + $approval1 = $this->createMockApproval(1, $user1); + $approval2 = $this->createMockApproval(2, $user2); + $approval3 = $this->createMockApproval(3, $user3); + + // Create query that ONLY selects user1 and user2 + $query = new ApprovalQuery(); + $query->setUsers([$user1, $user2]); + + // Mock repository to return ONLY filtered approvals (matching the query) + $filteredApprovals = [$approval1, $approval2]; // user3's approval is NOT included + + $this->approvalRepository->expects($this->once()) + ->method('getUserApprovalsFiltered') + ->with( + $this->callback(function ($users) use ($user1, $user2) { + // Verify the users parameter contains only the selected users + return in_array($user1, $users) && in_array($user2, $users); + }), + $query + ) + ->willReturn($filteredApprovals); + + // Verify that ONLY the filtered approvals are processed + $this->autoApprovalService->expects($this->once()) + ->method('processApprovals') + ->with($filteredApprovals) + ->willReturn([ + 'processedApprovals' => [$approval1, $approval2], + 'successful' => 2, + 'failed' => 0 + ]); + + // Execute: Call the repository method that autoApprove would call + $resultApprovals = $this->approvalRepository->getUserApprovalsFiltered([$user1, $user2], $query); + + // Verify resultApprovals contains only filtered approvals + $this->assertCount(2, $resultApprovals); + $this->assertContains($approval1, $resultApprovals); + $this->assertContains($approval2, $resultApprovals); + $this->assertNotContains($approval3, $resultApprovals); // Charlie's approval excluded + + // Process them + $processResult = $this->autoApprovalService->processApprovals($resultApprovals); + + $this->assertEquals(2, $processResult['successful']); + $this->assertEquals(0, $processResult['failed']); + } + + /** + * Test that date range filter is applied correctly + */ + public function testAutoApproveRespectsDateRangeFilter(): void + { + $user = $this->createMockUser(1, 'Alice'); + + // Create approvals for different weeks + $approval1 = $this->createMockApproval(1, $user, '2026-01-20', '2026-01-26'); // Week 1 + $approval2 = $this->createMockApproval(2, $user, '2026-01-27', '2026-02-02'); // Week 2 + $approval3 = $this->createMockApproval(3, $user, '2026-02-03', '2026-02-09'); // Week 3 + + // Create query with date range for only Week 1 and 2 + $query = new ApprovalQuery(); + $query->setBegin(new DateTime('2026-01-20')); + $query->setEnd(new DateTime('2026-02-02')); + + // Mock repository returns only approvals in date range + $filteredApprovals = [$approval1, $approval2]; // approval3 excluded + + $this->approvalRepository->expects($this->once()) + ->method('getUserApprovalsFiltered') + ->with($this->anything(), $query) + ->willReturn($filteredApprovals); + + $this->autoApprovalService->expects($this->once()) + ->method('processApprovals') + ->with($filteredApprovals) + ->willReturn([ + 'processedApprovals' => [$approval1, $approval2], + 'successful' => 2, + 'failed' => 0 + ]); + + $resultApprovals = $this->approvalRepository->getUserApprovalsFiltered([$user], $query); + $result = $this->autoApprovalService->processApprovals($resultApprovals); + + $this->assertCount(2, $resultApprovals); + $this->assertNotContains($approval3, $resultApprovals); // Week 3 excluded + } + + /** + * Test that status filter is applied correctly + */ + public function testAutoApproveOnlyProcessesSubmittedStatus(): void + { + $user = $this->createMockUser(1, 'Alice'); + + $approval1 = $this->createMockApproval(1, $user); + $approval2 = $this->createMockApproval(2, $user); + $approval3 = $this->createMockApproval(3, $user); + + // Create query filtering for "submitted" status only + $query = new ApprovalQuery(); + $query->setStatus([ApprovalStatus::SUBMITTED]); + + // Repository returns only submitted approvals + $filteredApprovals = [$approval1, $approval2]; // approval3 has different status + + $this->approvalRepository->expects($this->once()) + ->method('getUserApprovalsFiltered') + ->with($this->anything(), $query) + ->willReturn($filteredApprovals); + + $this->autoApprovalService->expects($this->once()) + ->method('processApprovals') + ->with($filteredApprovals) + ->willReturn([ + 'processedApprovals' => [$approval1, $approval2], + 'successful' => 2, + 'failed' => 0 + ]); + + $resultApprovals = $this->approvalRepository->getUserApprovalsFiltered([$user], $query); + $result = $this->autoApprovalService->processApprovals($resultApprovals); + + + $this->assertCount(2, $resultApprovals); + $this->assertEquals(2, $result['successful']); + } + + /** + * Test that empty query users falls back to all team users + */ + public function testAutoApproveUsesAllUsersWhenQueryIsEmpty(): void + { + $user1 = $this->createMockUser(1, 'Alice'); + $user2 = $this->createMockUser(2, 'Bob'); + + // Query with NO user filter + $query = new ApprovalQuery(); + + // All team users should be used + $allUsers = [$user1, $user2]; + + $approval1 = $this->createMockApproval(1, $user1); + $approval2 = $this->createMockApproval(2, $user2); + + $this->approvalRepository->expects($this->once()) + ->method('getUserApprovalsFiltered') + ->with($allUsers, $query) + ->willReturn([$approval1, $approval2]); + + $resultApprovals = $this->approvalRepository->getUserApprovalsFiltered($allUsers, $query); + + $this->assertCount(2, $resultApprovals); + } + + /** + * Test the complete workflow: query set in toApprove is used in autoApprove + */ + public function testCompleteWorkflowQueryPassedBetweenActions(): void + { + // Simulate what happens in toApprove + $user1 = $this->createMockUser(1, 'Alice'); + $user2 = $this->createMockUser(2, 'Bob'); + + // 1. User selects specific users in toApprove + $queryFromToApprove = new ApprovalQuery(); + $queryFromToApprove->setUsers([$user1]); // Only Alice selected + + // Simulate session storage (in real code: $request->getSession()->set('query', $query)) + $sessionQuery = $queryFromToApprove; + + // 2. In autoApprove, query is retrieved from session + $queryInAutoApprove = $sessionQuery; + + // Verify it's the same query + $this->assertSame($queryFromToApprove, $queryInAutoApprove); + $this->assertEquals([$user1], $queryInAutoApprove->getUsers()); + + // 3. Only Alice's approvals should be fetched + $approval1 = $this->createMockApproval(1, $user1); + + $this->approvalRepository->expects($this->once()) + ->method('getUserApprovalsFiltered') + ->with( + $this->callback(function ($users) use ($user1) { + return count($users) === 1 && $users[0] === $user1; + }), + $queryInAutoApprove + ) + ->willReturn([$approval1]); + + // Execute + $resultApprovals = $this->approvalRepository->getUserApprovalsFiltered( + $queryInAutoApprove->getUsers(), + $queryInAutoApprove + ); + + // Verify only Alice's approval is processed + $this->assertCount(1, $resultApprovals); + $this->assertEquals($approval1, $resultApprovals[0]); + } + + // Helper methods + + private function createMockUser(int $id, string $name): User|MockObject + { + $user = $this->createMock(User::class); + $user->method('getId')->willReturn($id); + $user->method('getUsername')->willReturn($name); + $user->method('getDisplayName')->willReturn($name); + return $user; + } + + private function createMockApproval( + int $id, + User $user, + string $startDate = '2026-01-20', + string $endDate = '2026-01-26' + ): Approval|MockObject { + $approval = $this->createMock(Approval::class); + $approval->method('getId')->willReturn($id); + $approval->method('getUser')->willReturn($user); + $approval->method('getStartDate')->willReturn(new DateTime($startDate)); + $approval->method('getEndDate')->willReturn(new DateTime($endDate)); + $approval->method('getHistory')->willReturn([]); + return $approval; + } +} \ No newline at end of file diff --git a/tests/Repository/ApprovalRepositoryTest.php b/tests/Repository/ApprovalRepositoryTest.php new file mode 100644 index 0000000..13755e6 --- /dev/null +++ b/tests/Repository/ApprovalRepositoryTest.php @@ -0,0 +1,696 @@ +entityManager = $this->createMock(EntityManager::class); + $this->queryBuilder = $this->createMock(QueryBuilder::class); + $this->query = $this->createMock(Query::class); + $this->settingsTool = $this->createMock(SettingsTool::class); + $this->metaFieldSettings = $this->createMock(ApprovalSettingsInterface::class); + $this->formatting = new Formatting($this->createMock(\Symfony\Contracts\Translation\TranslatorInterface::class)); + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + + // Mock repositories + $approvalWorkdayRepo = $this->createMock(ApprovalWorkdayHistoryRepository::class); + $approvalOvertimeRepo = $this->createMock(ApprovalOvertimeHistoryRepository::class); + $reportRepository = $this->createMock(ReportRepository::class); + $timesheetRepository = $this->createMock(TimesheetRepository::class); + + $this->repository = new ApprovalRepository( + $this->createMock(\Doctrine\Persistence\ManagerRegistry::class), + $this->metaFieldSettings, + $approvalWorkdayRepo, + $approvalOvertimeRepo, + $reportRepository, + $timesheetRepository, + $this->settingsTool, + $this->formatting, + $this->urlGenerator + ); + + // Override the entity manager with our mock + $reflection = new \ReflectionClass($this->repository); + $property = $reflection->getProperty('_em'); + $property->setAccessible(true); + $property->setValue($this->repository, $this->entityManager); + + // Setup ExpressionBuilder mock + $this->setupExpressionBuilder(); + } + + /** + * Setup ExpressionBuilder mock to handle in() method + */ + private function setupExpressionBuilder(): void + { + $expressionBuilder = $this->createMock(Expr::class); + $expressionBuilder->method('in') + ->willReturn('u.id IN (:usersId)'); + + $this->entityManager->method('getExpressionBuilder') + ->willReturn($expressionBuilder); + } + + /** + * Test with empty users array returns empty result + */ + public function testGetUserApprovalsFilteredWithEmptyUsersArray(): void + { + $query = new ApprovalQuery(); + $result = $this->repository->getUserApprovalsFiltered([], $query); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test with users but no filters applied + */ + public function testGetUserApprovalsFilteredWithUsersNoFilters(): void + { + // Setup default configuration mock + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + // Create test data with multiple approvals - different statuses + $user = $this->createTestUser(1, 'john.doe'); + $approvalSubmitted = $this->createTestApproval( + $user, + new DateTime('2025-01-06'), + new DateTime('2025-01-12'), + ApprovalStatus::SUBMITTED + ); + $approvalApproved = $this->createTestApproval( + $user, + new DateTime('2025-01-13'), + new DateTime('2025-01-19'), + ApprovalStatus::APPROVED + ); + + // Setup QueryBuilder mock chain + $this->queryBuilder->method('select')->willReturnSelf(); + $this->queryBuilder->method('from')->willReturnSelf(); + $this->queryBuilder->method('join')->willReturnSelf(); + $this->queryBuilder->method('andWhere')->willReturnSelf(); + $this->queryBuilder->method('setParameter')->willReturnSelf(); + $this->queryBuilder->method('getQuery')->willReturn($this->query); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approvalSubmitted, $approvalApproved]); + + // Execute + $result = $this->repository->getUserApprovalsFiltered([$user], new ApprovalQuery()); + + // Assert - should only return SUBMITTED approval, not APPROVED + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test with date range filter (dateRangeStart) + */ + public function testGetUserApprovalsFilteredWithDateRangeStart(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + // Approval before date range + $approvalBefore = $this->createTestApproval( + $user, + new DateTime('2025-01-06'), + new DateTime('2025-01-12'), + ApprovalStatus::SUBMITTED + ); + // Approval within date range + $approvalWithin = $this->createTestApproval( + $user, + new DateTime('2025-02-10'), + new DateTime('2025-02-16'), + ApprovalStatus::SUBMITTED + ); + // Approval with different status + $approvalDenied = $this->createTestApproval( + $user, + new DateTime('2025-02-17'), + new DateTime('2025-02-23'), + ApprovalStatus::DENIED + ); + + // Setup QueryBuilder expectations + $this->setupQueryBuilderChain(); + + // Create query with date range start in the middle + $approvalQuery = new ApprovalQuery(); + $approvalQuery->setBegin(new DateTime('2025-02-01')); + + // Verify andWhere is called for date range + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('andWhere') + ->willReturnSelf(); + + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('setParameter') + ->willReturnSelf(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + // Only return approvals that are after Feb 1st + $this->query->method('getResult')->willReturn([$approvalWithin, $approvalDenied]); + + $result = $this->repository->getUserApprovalsFiltered([$user], $approvalQuery); + + // Should only contain SUBMITTED approval within range + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test with date range filter (dateRangeEnd) + */ + public function testGetUserApprovalsFilteredWithDateRangeEnd(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + // Approval within date range + $approvalWithin = $this->createTestApproval( + $user, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::SUBMITTED + ); + // Approval after date range + $approvalAfter = $this->createTestApproval( + $user, + new DateTime('2025-03-06'), + new DateTime('2025-03-12'), + ApprovalStatus::APPROVED + ); + // Another approval outside range with different status + $approvalOutside = $this->createTestApproval( + $user, + new DateTime('2025-03-13'), + new DateTime('2025-03-19'), + ApprovalStatus::DENIED + ); + + $this->setupQueryBuilderChain(); + + // Create query with date range end in the middle + $approvalQuery = new ApprovalQuery(); + $approvalQuery->setEnd(new DateTime('2025-02-28')); + + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('andWhere') + ->willReturnSelf(); + + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('setParameter') + ->willReturnSelf(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + // Only return approvals that are before Feb 28 + $this->query->method('getResult')->willReturn([$approvalWithin, $approvalAfter]); + + $result = $this->repository->getUserApprovalsFiltered([$user], $approvalQuery); + + // Should only contain SUBMITTED approval within range + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test with both date range filters (start and end) + */ + public function testGetUserApprovalsFilteredWithDateRangeStartAndEnd(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + // Approval before date range + $approvalBefore = $this->createTestApproval( + $user, + new DateTime('2025-01-06'), + new DateTime('2025-01-12'), + ApprovalStatus::SUBMITTED + ); + // Approval within date range + $approvalWithin = $this->createTestApproval( + $user, + new DateTime('2025-02-10'), + new DateTime('2025-02-16'), + ApprovalStatus::SUBMITTED + ); + // Approval after date range + $approvalAfter = $this->createTestApproval( + $user, + new DateTime('2025-03-10'), + new DateTime('2025-03-16'), + ApprovalStatus::DENIED + ); + + $this->setupQueryBuilderChain(); + + // Create query with both date range filters in the middle + $approvalQuery = new ApprovalQuery(); + $approvalQuery->setBegin(new DateTime('2025-02-01')); + $approvalQuery->setEnd(new DateTime('2025-02-28')); + + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('andWhere') + ->willReturnSelf(); + + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('setParameter') + ->willReturnSelf(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + // Only return approvals within Feb range + $this->query->method('getResult')->willReturn([$approvalWithin, $approvalAfter]); + + $result = $this->repository->getUserApprovalsFiltered([$user], $approvalQuery); + + // Should only contain SUBMITTED approval within range + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test with search term filter + */ + public function testGetUserApprovalsFilteredWithSearchTerm(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + $user->setAlias('john'); + + $approvalSubmitted = $this->createTestApproval( + $user, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::SUBMITTED + ); + $approvalApproved = $this->createTestApproval( + $user, + new DateTime('2025-02-13'), + new DateTime('2025-02-19'), + ApprovalStatus::APPROVED + ); + + $this->setupQueryBuilderChain(); + + // Create query with search term + $approvalQuery = new ApprovalQuery(); + $approvalQuery->setSearchTerm(new SearchTerm('john')); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approvalSubmitted, $approvalApproved]); + + $result = $this->repository->getUserApprovalsFiltered([$user], $approvalQuery); + + // Should only contain SUBMITTED approval + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test that filters out non-submitted approvals + */ + public function testGetUserApprovalsFilteredFiltersNonSubmittedApprovals(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + + // Create approval with non-submitted status + $approval = $this->createTestApproval( + $user, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::APPROVED // Not submitted + ); + + $this->setupQueryBuilderChain(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approval]); + + $result = $this->repository->getUserApprovalsFiltered([$user], new ApprovalQuery()); + + // Should be empty because approval status is APPROVED, not SUBMITTED + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test with multiple users + */ + public function testGetUserApprovalsFilteredWithMultipleUsers(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user1 = $this->createTestUser(1, 'john.doe'); + $user2 = $this->createTestUser(2, 'jane.doe'); + + $approval1Submitted = $this->createTestApproval( + $user1, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::SUBMITTED + ); + + $approval2Submitted = $this->createTestApproval( + $user2, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::SUBMITTED + ); + + $approval1Denied = $this->createTestApproval( + $user1, + new DateTime('2025-02-13'), + new DateTime('2025-02-19'), + ApprovalStatus::DENIED + ); + + $this->setupQueryBuilderChain(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approval1Submitted, $approval2Submitted, $approval1Denied]); + + $result = $this->repository->getUserApprovalsFiltered([$user1, $user2], new ApprovalQuery()); + + // Should only contain SUBMITTED approvals + $this->assertIsArray($result); + $this->assertCount(2, $result); + } + + /** + * Test with custom start date parameter + */ + public function testGetUserApprovalsFilteredWithCustomStartDate(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + $approvalSubmitted = $this->createTestApproval( + $user, + new DateTime('2025-03-06'), + new DateTime('2025-03-12'), + ApprovalStatus::SUBMITTED + ); + $approvalApproved = $this->createTestApproval( + $user, + new DateTime('2025-03-13'), + new DateTime('2025-03-19'), + ApprovalStatus::APPROVED + ); + + $this->setupQueryBuilderChain(); + + $customStartDate = new DateTime('2025-03-01'); + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approvalSubmitted, $approvalApproved]); + + $result = $this->repository->getUserApprovalsFiltered([$user], new ApprovalQuery(), $customStartDate); + + // Should only contain SUBMITTED approval + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test with empty configuration value for APPROVAL_WORKFLOW_START + */ + public function testGetUserApprovalsFilteredWithEmptyWorkflowStartConfig(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn(''); // Empty configuration + + $user = $this->createTestUser(1, 'john.doe'); + $approvalSubmitted = $this->createTestApproval( + $user, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::SUBMITTED + ); + $approvalDenied = $this->createTestApproval( + $user, + new DateTime('2025-02-13'), + new DateTime('2025-02-19'), + ApprovalStatus::DENIED + ); + + $this->setupQueryBuilderChain(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approvalSubmitted, $approvalDenied]); + + $result = $this->repository->getUserApprovalsFiltered([$user], new ApprovalQuery()); + + // Should only contain SUBMITTED approval + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test search term filtering with username match + */ + public function testGetUserApprovalsFilteredSearchByUsername(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + $approvalSubmitted = $this->createTestApproval( + $user, + new DateTime('2025-02-06'), + new DateTime('2025-02-12'), + ApprovalStatus::SUBMITTED + ); + $approvalApproved = $this->createTestApproval( + $user, + new DateTime('2025-02-13'), + new DateTime('2025-02-19'), + ApprovalStatus::APPROVED + ); + + $this->setupQueryBuilderChain(); + + $approvalQuery = new ApprovalQuery(); + $approvalQuery->setSearchTerm(new SearchTerm('john')); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + $this->query->method('getResult')->willReturn([$approvalSubmitted, $approvalApproved]); + + $result = $this->repository->getUserApprovalsFiltered([$user], $approvalQuery); + + // Should only contain SUBMITTED approval + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + /** + * Test combined filters: date range + search term + */ + public function testGetUserApprovalsFilteredCombinedDateRangeAndSearch(): void + { + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_WORKFLOW_START) + ->willReturn('2025-01-01'); + + $user = $this->createTestUser(1, 'john.doe'); + // Approval before date range + $approvalBefore = $this->createTestApproval( + $user, + new DateTime('2025-01-06'), + new DateTime('2025-01-12'), + ApprovalStatus::SUBMITTED + ); + // Approval within date range + $approvalWithin = $this->createTestApproval( + $user, + new DateTime('2025-02-10'), + new DateTime('2025-02-16'), + ApprovalStatus::SUBMITTED + ); + // Approval after date range with different status + $approvalAfter = $this->createTestApproval( + $user, + new DateTime('2025-03-10'), + new DateTime('2025-03-16'), + ApprovalStatus::DENIED + ); + + $this->setupQueryBuilderChain(); + + $approvalQuery = new ApprovalQuery(); + $approvalQuery->setBegin(new DateTime('2025-02-01')); + $approvalQuery->setEnd(new DateTime('2025-02-28')); + $approvalQuery->setSearchTerm(new SearchTerm('john')); + + $this->queryBuilder->expects($this->atLeastOnce()) + ->method('andWhere') + ->willReturnSelf(); + + $this->entityManager->method('createQueryBuilder')->willReturn($this->queryBuilder); + // Only return approvals within Feb range + $this->query->method('getResult')->willReturn([$approvalWithin, $approvalAfter]); + + $result = $this->repository->getUserApprovalsFiltered([$user], $approvalQuery); + + // Should only contain SUBMITTED approval within range + $this->assertIsArray($result); + $this->assertCount(1, $result); + } + + // Helper methods + + /** + * Create a test user with given ID and username + */ + private function createTestUser(int $id, string $username): User + { + $user = $this->createMock(User::class); + $user->method('getId')->willReturn($id); + $user->method('getUsername')->willReturn($username); + $user->method('getAlias')->willReturn(null); + $user->method('getDisplayName')->willReturn($username); + + return $user; + } + + /** + * Create a test approval with given parameters + */ + private function createTestApproval( + User $user, + DateTime $startDate, + DateTime $endDate, + string $statusName + ): Approval { + $status = $this->createMock(ApprovalStatus::class); + $status->method('getName')->willReturn($statusName); + + $history = $this->createMock(ApprovalHistory::class); + $history->method('getStatus')->willReturn($status); + + $approval = $this->createMock(Approval::class); + $approval->method('getUser')->willReturn($user); + $approval->method('getStartDate')->willReturn($startDate); + $approval->method('getEndDate')->willReturn($endDate); + $approval->method('getHistory')->willReturn([$history]); + + return $approval; + } + + /** + * Setup QueryBuilder mock chain with default behaviors + */ + private function setupQueryBuilderChain(): void + { + $this->queryBuilder->method('select')->willReturnSelf(); + $this->queryBuilder->method('from')->willReturnSelf(); + $this->queryBuilder->method('join')->willReturnSelf(); + $this->queryBuilder->method('andWhere')->willReturnSelf(); + $this->queryBuilder->method('setParameter')->willReturnSelf(); + $this->queryBuilder->method('getQuery')->willReturn($this->query); + } +} diff --git a/tests/Service/ApprovalDataServiceTest.php b/tests/Service/ApprovalDataServiceTest.php new file mode 100644 index 0000000..8ef334f --- /dev/null +++ b/tests/Service/ApprovalDataServiceTest.php @@ -0,0 +1,709 @@ +approvalRepository = $this->createMock(ApprovalRepository::class); + $this->userRepository = $this->createMock(UserRepository::class); + $this->timesheetRepository = $this->createMock(TimesheetRepository::class); + $this->breakTimeCheckToolGER = $this->createMock(BreakTimeCheckToolGER::class); + $this->settingsTool = $this->createMock(SettingsTool::class); + + $this->service = new ApprovalDataService( + $this->approvalRepository, + $this->userRepository, + $this->timesheetRepository, + $this->breakTimeCheckToolGER, + $this->settingsTool + ); + } + + /** + * Test fetchAndFilterApprovalRows with no filters + */ + public function testFetchAndFilterApprovalRowsWithoutFilters(): void + { + $users = [$this->createMock(User::class)]; + $query = new ApprovalQuery(); + + $mockRows = [ + ['userId' => 1, 'user' => 'John Doe', 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'user' => 'Jane Smith', 'status' => ApprovalStatus::APPROVED], + ]; + + + $this->approvalRepository->expects($this->once()) + ->method('findAllWeek') + ->with($users) + ->willReturn($mockRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->with(ConfigEnum::APPROVAL_HIDE_APPROVED_NY, false) + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + $this->assertCount(2, $result); + $this->assertEquals($mockRows, array_values($result)); + } + + /** + * Test fetchAndFilterApprovalRows with hide approved enabled + */ + public function testFetchAndFilterApprovalRowsHidesApproved(): void + { + $users = [$this->createMock(User::class)]; + $query = new ApprovalQuery(); + + $allRows = [ + ['userId' => 1, 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'status' => ApprovalStatus::APPROVED], + ]; + + $filteredRows = [ + ['userId' => 1, 'status' => ApprovalStatus::SUBMITTED], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($allRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->with(ConfigEnum::APPROVAL_HIDE_APPROVED_NY, false) + ->willReturn(true); + + $this->approvalRepository->expects($this->once()) + ->method('filterWeeksNotApproved') + ->with($allRows) + ->willReturn($filteredRows); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + $this->assertCount(1, $result); + } + + /** + * Test fetchAndFilterApprovalRows filters by users + */ + public function testFetchAndFilterApprovalRowsFiltersByUsers(): void + { + $user1 = $this->createMock(User::class); + $user1->method('getId')->willReturn(1); + + $user2 = $this->createMock(User::class); + $user2->method('getId')->willReturn(2); + + $users = [$user1, $user2]; + $query = new ApprovalQuery(); + $query->setUsers([$user1]); + + $allRows = [ + ['userId' => 1, 'user' => 'User1', 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'user' => 'User2', 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 3, 'user' => 'User3', 'status' => ApprovalStatus::SUBMITTED], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($allRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + $this->assertCount(1, $result); + $filteredResult = array_values($result); + $this->assertEquals(1, $filteredResult[0]['userId']); + } + + /** + * Test fetchAndFilterApprovalRows filters by date range + */ + public function testFetchAndFilterApprovalRowsFiltersByDateRange(): void + { + $users = [$this->createMock(User::class)]; + $query = new ApprovalQuery(); + + $dateRange = new DateRange(); + $dateRange->setBegin(new DateTime('2026-01-20')); + $dateRange->setEnd(new DateTime('2026-01-26')); + $query->setDateRange($dateRange); + + $allRows = [ + [ + 'userId' => 1, + 'week' => (object) ['value' => new DateTime('2026-01-20')], + 'status' => ApprovalStatus::SUBMITTED + ], + [ + 'userId' => 2, + 'week' => (object) ['value' => new DateTime('2026-01-13')], + 'status' => ApprovalStatus::SUBMITTED + ], + [ + 'userId' => 3, + 'week' => (object) ['value' => new DateTime('2026-01-27')], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($allRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + // Should include row with week starting 2026-01-20 (within range) + $this->assertEquals(1, count($result)); + $this->assertEquals(1, array_values($result)[0]['userId']); + } + + /** + * Test fetchAndFilterApprovalRows filters by status + */ + public function testFetchAndFilterApprovalRowsFiltersByStatus(): void + { + $users = [$this->createMock(User::class)]; + $query = new ApprovalQuery(); + $query->setStatus([ApprovalStatus::SUBMITTED, ApprovalStatus::NOT_SUBMITTED]); + + $allRows = [ + ['userId' => 1, 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'status' => ApprovalStatus::APPROVED], + ['userId' => 3, 'status' => ApprovalStatus::NOT_SUBMITTED], + ['userId' => 4, 'status' => ApprovalStatus::DENIED], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($allRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + $this->assertCount(2, $result); + $resultArray = array_values($result); + $this->assertContains($resultArray[0]['status'], [ApprovalStatus::SUBMITTED, ApprovalStatus::NOT_SUBMITTED]); + $this->assertContains($resultArray[1]['status'], [ApprovalStatus::SUBMITTED, ApprovalStatus::NOT_SUBMITTED]); + } + + /** + * Test fetchAndFilterApprovalRows filters by search term + */ + public function testFetchAndFilterApprovalRowsFiltersBySearchTerm(): void + { + $users = [$this->createMock(User::class)]; + $query = new ApprovalQuery(); + + // Use real SearchTerm instead of mock + $searchTerm = new \App\Utils\SearchTerm('John'); + $query->setSearchTerm($searchTerm); + + $allRows = [ + [ + 'userId' => 1, + 'user' => 'John Doe', + 'week' => (object) ['label' => 'Week 4'], + 'status' => ApprovalStatus::SUBMITTED + ], + [ + 'userId' => 2, + 'user' => 'Jane Smith', + 'week' => (object) ['label' => 'Week 4'], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($allRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + $this->assertCount(1, $result); + $filteredResult = array_values($result); + $this->assertStringContainsString('John', $filteredResult[0]['user']); + } + + /** + * Test enrichRowsWithErrors with break time errors + */ + public function testEnrichRowsWithErrorsAddsErrorFlag(): void + { + $user = $this->createMock(User::class); + $user->method('getId')->willReturn(1); + + $rows = [ + [ + 'userId' => 1, + 'week' => (object) ['value' => new DateTime('2026-01-20')], + 'status' => ApprovalStatus::SUBMITTED + ], + [ + 'userId' => 1, + 'week' => (object) ['value' => new DateTime('2026-01-27')], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->userRepository->method('find') + ->with(1) + ->willReturn($user); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + // First row has errors, second doesn't + $this->breakTimeCheckToolGER->method('checkBreakTime') + ->willReturnOnConsecutiveCalls( + ['2026-01-20' => 'Break time violation'], + [] + ); + + $this->settingsTool->method('isInConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(true); + + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(true); + + $result = $this->service->enrichRowsWithErrors($rows); + + $this->assertTrue($result[0]['hasErrors']); + $this->assertFalse($result[1]['hasErrors']); + } + + /** + * Test enrichRowsWithErrors when break checks disabled + */ + public function testEnrichRowsWithErrorsWhenBreakChecksDisabled(): void + { + $user = $this->createMock(User::class); + $user->method('getId')->willReturn(1); + + $rows = [ + [ + 'userId' => 1, + 'week' => (object) ['value' => new DateTime('2026-01-20')], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->userRepository->method('find') + ->with(1) + ->willReturn($user); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(false); + + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(false); + + // Break time check should not be called + $this->breakTimeCheckToolGER->expects($this->never()) + ->method('checkBreakTime'); + + $result = $this->service->enrichRowsWithErrors($rows); + + $this->assertFalse($result[0]['hasErrors']); + } + + /** + * Test categorizeRowsByWeek separates past, current, and future + */ + public function testCategorizeRowsByWeekSeparatesCorrectly(): void + { + // Get the Monday of current week + $now = new DateTime('now'); + $currentMonday = (clone $now)->modify('next monday')->modify('-1 week'); + $nextMonday = (clone $now)->modify('next monday'); + + $pastDate = (clone $currentMonday)->modify('-7 days')->format('Y-m-d'); + $currentDate = $currentMonday->format('Y-m-d'); + $futureDate = $nextMonday->format('Y-m-d'); + + $rows = [ + ['userId' => 1, 'startDate' => $pastDate, 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'startDate' => $currentDate, 'status' => ApprovalStatus::NOT_SUBMITTED], + ['userId' => 3, 'startDate' => $futureDate, 'status' => ApprovalStatus::NOT_SUBMITTED], + ]; + + [$pastRows, $currentRows, $futureRows] = $this->service->categorizeRowsByWeek($rows); + + $this->assertCount(1, $pastRows); + $this->assertCount(1, $currentRows); + $this->assertCount(1, $futureRows); + + $this->assertEquals($pastDate, $pastRows[0]['startDate']); + $this->assertEquals($currentDate, $currentRows[0]['startDate']); + $this->assertEquals($futureDate, $futureRows[0]['startDate']); + } + + /** + * Test categorizeRowsByWeek with all past weeks + */ + public function testCategorizeRowsByWeekAllPast(): void + { + $rows = [ + ['userId' => 1, 'startDate' => '2026-01-06', 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'startDate' => '2025-12-30', 'status' => ApprovalStatus::APPROVED], + ]; + + [$pastRows, $currentRows, $futureRows] = $this->service->categorizeRowsByWeek($rows); + + $this->assertEquals(2, count($pastRows)); + $this->assertEquals(0, count($currentRows)); + $this->assertEquals(0, count($futureRows)); + } + + /** + * Test categorizeRowsByWeek with empty rows + */ + public function testCategorizeRowsByWeekWithEmptyRows(): void + { + $rows = []; + + [$pastRows, $currentRows, $futureRows] = $this->service->categorizeRowsByWeek($rows); + + $this->assertCount(0, $pastRows); + $this->assertCount(0, $currentRows); + $this->assertCount(0, $futureRows); + } + + /** + * Test countSubmittedWeeks counts only submitted status + */ + public function testCountSubmittedWeeksCountsCorrectly(): void + { + $rows = [ + ['status' => ApprovalStatus::SUBMITTED], + ['status' => ApprovalStatus::APPROVED], + ['status' => ApprovalStatus::SUBMITTED], + ['status' => ApprovalStatus::NOT_SUBMITTED], + ['status' => ApprovalStatus::SUBMITTED], + ['status' => ApprovalStatus::DENIED], + ]; + + $count = $this->service->countSubmittedWeeks($rows); + + $this->assertEquals(3, $count); + } + + /** + * Test countSubmittedWeeks with no submitted weeks + */ + public function testCountSubmittedWeeksWithNoSubmitted(): void + { + $rows = [ + ['status' => ApprovalStatus::APPROVED], + ['status' => ApprovalStatus::NOT_SUBMITTED], + ['status' => ApprovalStatus::DENIED], + ]; + + $count = $this->service->countSubmittedWeeks($rows); + + $this->assertEquals(0, $count); + } + + /** + * Test countSubmittedWeeks with empty rows + */ + public function testCountSubmittedWeeksWithEmptyRows(): void + { + $rows = []; + + $count = $this->service->countSubmittedWeeks($rows); + + $this->assertEquals(0, $count); + } + + /** + * Test countSubmittedWeeks with malformed data + */ + public function testCountSubmittedWeeksWithMalformedData(): void + { + $rows = [ + ['status' => ApprovalStatus::SUBMITTED], + ['noStatus' => 'value'], // Missing status key + ['status' => ApprovalStatus::SUBMITTED], + ]; + + $count = $this->service->countSubmittedWeeks($rows); + + $this->assertEquals(2, $count); + } + + /** + * Test filterByUsers with empty selected users returns all rows + */ + public function testFilterByUsersWithEmptySelection(): void + { + $rows = [ + ['userId' => 1, 'user' => 'User1'], + ['userId' => 2, 'user' => 'User2'], + ]; + + $query = new ApprovalQuery(); + $query->setUsers([]); + + $this->approvalRepository->method('findAllWeek') + ->willReturn($rows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, []); + + $this->assertCount(2, $result); + } + + /** + * Test filterByDateRange with null date range returns all rows + */ + public function testFilterByDateRangeWithNullRange(): void + { + $rows = [ + ['userId' => 1, 'week' => (object) ['value' => new DateTime('2026-01-20')]], + ['userId' => 2, 'week' => (object) ['value' => new DateTime('2026-01-27')]], + ]; + + $query = new ApprovalQuery(); + $query->setDateRange(new DateRange()); // No begin or end set + + $this->approvalRepository->method('findAllWeek') + ->willReturn($rows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, []); + + $this->assertCount(2, $result); + } + + /** + * Test filterByStatus with empty status returns all rows + */ + public function testFilterByStatusWithEmptySelection(): void + { + $rows = [ + ['userId' => 1, 'status' => ApprovalStatus::SUBMITTED], + ['userId' => 2, 'status' => ApprovalStatus::APPROVED], + ]; + + $query = new ApprovalQuery(); + $query->setStatus([]); + + $this->approvalRepository->method('findAllWeek') + ->willReturn($rows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, []); + + $this->assertCount(2, $result); + } + + /** + * Test multiple filters applied together + */ + public function testMultipleFiltersAppliedTogether(): void + { + $user1 = $this->createMock(User::class); + $user1->method('getId')->willReturn(1); + + $users = [$user1]; + $query = new ApprovalQuery(); + $query->setUsers([$user1]); + $query->setStatus([ApprovalStatus::SUBMITTED]); + + $dateRange = new DateRange(); + $dateRange->setBegin(new DateTime('2026-01-20')); + $dateRange->setEnd(new DateTime('2026-01-26')); + $query->setDateRange($dateRange); + + $allRows = [ + [ + 'userId' => 1, + 'user' => 'User1', + 'week' => (object) ['value' => new DateTime('2026-01-20')], + 'status' => ApprovalStatus::SUBMITTED + ], + [ + 'userId' => 1, + 'user' => 'User1', + 'week' => (object) ['value' => new DateTime('2026-01-20')], + 'status' => ApprovalStatus::APPROVED + ], + [ + 'userId' => 2, + 'user' => 'User2', + 'week' => (object) ['value' => new DateTime('2026-01-20')], + 'status' => ApprovalStatus::SUBMITTED + ], + [ + 'userId' => 1, + 'user' => 'User1', + 'week' => (object) ['value' => new DateTime('2026-02-03')], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($allRows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, $users); + + // Should only return rows matching: userId=1, status=submitted, and within date range + $this->assertCount(1, $result); + $filteredResult = array_values($result); + $this->assertEquals(1, $filteredResult[0]['userId']); + $this->assertEquals(ApprovalStatus::SUBMITTED, $filteredResult[0]['status']); + $this->assertEquals( + new DateTime('2026-01-20'), + $filteredResult[0]['week']->value + ); + } + + /** + * Test rowMatchesSearchTerm matches user name + */ + public function testRowMatchesSearchTermMatchesUser(): void + { + $searchTerm = new \App\Utils\SearchTerm('john'); + + $query = new ApprovalQuery(); + $query->setSearchTerm($searchTerm); + + $rows = [ + [ + 'userId' => 1, + 'user' => 'John Doe', + 'week' => (object) ['label' => 'Week 4'], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($rows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, []); + + $this->assertCount(1, $result); + } + + /** + * Test rowMatchesSearchTerm matches status + */ + public function testRowMatchesSearchTermMatchesStatus(): void + { + $searchTerm = new \App\Utils\SearchTerm('submit'); + + $query = new ApprovalQuery(); + $query->setSearchTerm($searchTerm); + + $rows = [ + [ + 'userId' => 1, + 'user' => 'John Doe', + 'week' => (object) ['label' => 'Week 4'], + 'status' => ApprovalStatus::SUBMITTED + ], + ]; + + $this->approvalRepository->method('findAllWeek') + ->willReturn($rows); + + $this->settingsTool->method('getBooleanConfiguration') + ->willReturn(false); + + $result = $this->service->fetchAndFilterApprovalRows($query, []); + + $this->assertCount(1, $result); + } + + /** + * Test getTimesheetsForRow creates correct query + */ + public function testGetTimesheetsForRowCreatesCorrectQuery(): void + { + $user = $this->createMock(User::class); + $user->method('getId')->willReturn(1); + + $weekStart = new DateTime('2026-01-20'); + $row = [ + 'userId' => 1, + 'week' => (object) ['value' => $weekStart], + ]; + + $this->userRepository->expects($this->once()) + ->method('find') + ->with(1) + ->willReturn($user); + + $this->timesheetRepository->expects($this->once()) + ->method('getTimesheetsForQuery') + ->with($this->callback(function (TimesheetQuery $query) use ($user) { + return $query->getUser() === $user && + $query->getOrderBy() === 'date' && + $query->getOrder() === BaseQuery::ORDER_ASC; + })) + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->willReturn(false); + $this->settingsTool->method('getConfiguration') + ->willReturn(false); + + $result = $this->service->enrichRowsWithErrors([$row]); + + $this->assertArrayHasKey('hasErrors', $result[0]); + $this->assertFalse($result[0]['hasErrors']); + } +} diff --git a/tests/Service/AutoApprovalServiceTest.php b/tests/Service/AutoApprovalServiceTest.php new file mode 100644 index 0000000..65b1aa3 --- /dev/null +++ b/tests/Service/AutoApprovalServiceTest.php @@ -0,0 +1,430 @@ +approvalRepository = $this->createMock(ApprovalRepository::class); + $this->timesheetRepository = $this->createMock(TimesheetRepository::class); + $this->breakTimeCheckToolGER = $this->createMock(BreakTimeCheckToolGER::class); + $this->settingsTool = $this->createMock(SettingsTool::class); + + $this->service = new AutoApprovalService( + $this->approvalRepository, + $this->timesheetRepository, + $this->breakTimeCheckToolGER, + $this->settingsTool + ); + } + + /** + * Test processApprovals with empty array + */ + public function testProcessApprovalsWithEmptyArray(): void + { + $result = $this->service->processApprovals([]); + + $this->assertEquals(0, $result['successful']); + $this->assertEquals(0, $result['failed']); + $this->assertEmpty($result['processedApprovals']); + } + + /** + * Test processApprovals with all successful approvals + */ + public function testProcessApprovalsAllSuccessful(): void + { + $approvals = [ + $this->createMockApproval(1), + $this->createMockApproval(2), + ]; + + // Mock repository to return valid approvals + $this->approvalRepository->method('checkLastStatus') + ->willReturnArgument(4); + + // Mock no errors + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->willReturn(false); + + $this->settingsTool->method('getConfiguration') + ->willReturn(false); + + $result = $this->service->processApprovals($approvals); + + $this->assertEquals(2, $result['successful']); + $this->assertEquals(0, $result['failed']); + $this->assertCount(2, $result['processedApprovals']); + } + + /** + * Test processApprovals with some failures + */ + public function testProcessApprovalsWithFailures(): void + { + $approval1 = $this->createMockApproval(1); + $approval2 = $this->createMockApproval(2); + + $approvals = [$approval1, $approval2]; + + // First approval succeeds, second fails + $this->approvalRepository->method('checkLastStatus') + ->willReturnOnConsecutiveCalls($approval1, null); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->willReturn(false); + + $this->settingsTool->method('getConfiguration') + ->willReturn(false); + + $result = $this->service->processApprovals($approvals); + + $this->assertEquals(1, $result['successful']); + $this->assertEquals(1, $result['failed']); + $this->assertCount(1, $result['processedApprovals']); + } + + /** + * Test processSingleApproval with valid approval + */ + public function testProcessSingleApprovalSuccess(): void + { + $approval = $this->createMockApproval(1); + + $this->approvalRepository->method('checkLastStatus') + ->willReturn($approval); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->willReturn(false); + + $this->settingsTool->method('getConfiguration') + ->willReturn(false); + + $result = $this->service->processSingleApproval($approval); + + $this->assertTrue($result['approved']); + $this->assertSame($approval, $result['approval']); + $this->assertEquals('Auto-approved successfully', $result['reason']); + } + + /** + * Test processSingleApproval with invalid status + */ + public function testProcessSingleApprovalInvalidStatus(): void + { + $approval = $this->createMockApproval(1); + + $this->approvalRepository->method('checkLastStatus') + ->willReturn(null); + + $result = $this->service->processSingleApproval($approval); + + $this->assertFalse($result['approved']); + $this->assertNull($result['approval']); + $this->assertEquals('Invalid approval status', $result['reason']); + } + + /** + * Test processSingleApproval with wrong status + */ + public function testProcessSingleApprovalWrongStatus(): void + { + $approval = $this->createMockApprovalWithStatus(ApprovalStatus::APPROVED); + + $this->approvalRepository->method('checkLastStatus') + ->willReturn($approval); + + $result = $this->service->processSingleApproval($approval); + + $this->assertFalse($result['approved']); + $this->assertSame($approval, $result['approval']); + $this->assertEquals('Not in submitted status', $result['reason']); + } + + /** + * Test processSingleApproval with validation errors + */ + public function testProcessSingleApprovalWithValidationErrors(): void + { + $approval = $this->createMockApproval(1); + + $this->approvalRepository->method('checkLastStatus') + ->willReturn($approval); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + // Mock break time errors + $this->settingsTool->method('isInConfiguration') + ->willReturn(true); + + $this->settingsTool->method('getConfiguration') + ->willReturn(true); + + $this->breakTimeCheckToolGER->method('checkBreakTime') + ->willReturn(['2026-01-20' => 'Break time violation']); + + $result = $this->service->processSingleApproval($approval); + + $this->assertFalse($result['approved']); + $this->assertSame($approval, $result['approval']); + $this->assertEquals('Timesheet validation errors found', $result['reason']); + } + + /** + * Test getTimesheetsForApproval creates correct query + */ + public function testGetTimesheetsForApprovalCreatesCorrectQuery(): void + { + $user = $this->createMock(User::class); + $approval = $this->createMockApproval(1); + $approval->method('getUser')->willReturn($user); + + $startDate = new DateTime('2026-01-20'); + $endDate = new DateTime('2026-01-26'); + + $approval->method('getStartDate')->willReturn($startDate); + $approval->method('getEndDate')->willReturn($endDate); + + $this->timesheetRepository->expects($this->once()) + ->method('getTimesheetsForQuery') + ->willReturn([]); + + $result = $this->service->getTimesheetsForApproval($approval); + + $this->assertIsArray($result); + } + + /** + * Test getTimesheetsForApproval with actual timesheets + */ + public function testGetTimesheetsForApprovalReturnsTimesheets(): void + { + $user = $this->createMock(User::class); + $approval = $this->createMockApproval(1); + $approval->method('getUser')->willReturn($user); + + $startDate = new DateTime('2026-01-20'); + $endDate = new DateTime('2026-01-26'); + + $approval->method('getStartDate')->willReturn($startDate); + $approval->method('getEndDate')->willReturn($endDate); + + // Mock timesheets to return + $mockTimesheets = [ + ['date' => '2026-01-20', 'hours' => 8], + ['date' => '2026-01-21', 'hours' => 8], + ]; + + $this->timesheetRepository->expects($this->once()) + ->method('getTimesheetsForQuery') + ->willReturn($mockTimesheets); + + $result = $this->service->getTimesheetsForApproval($approval); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + } + + /** + * Test validateTimesheets with break checks enabled + */ + public function testValidateTimesheetsWithBreakChecksEnabled(): void + { + $timesheets = []; + $expectedErrors = ['2026-01-20' => 'Error']; + + $this->settingsTool->method('isInConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(true); + + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(true); + + $this->breakTimeCheckToolGER->expects($this->once()) + ->method('checkBreakTime') + ->with($timesheets) + ->willReturn($expectedErrors); + + $result = $this->service->validateTimesheets($timesheets); + + $this->assertEquals($expectedErrors, $result); + } + + /** + * Test validateTimesheets with break checks disabled + */ + public function testValidateTimesheetsWithBreakChecksDisabled(): void + { + $timesheets = []; + + $this->settingsTool->method('isInConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(false); + + $this->settingsTool->method('getConfiguration') + ->with(ConfigEnum::APPROVAL_BREAKCHECKS_NY) + ->willReturn(false); + + $this->breakTimeCheckToolGER->expects($this->never()) + ->method('checkBreakTime'); + + $result = $this->service->validateTimesheets($timesheets); + + $this->assertEmpty($result); + } + + /** + * Test processApprovals handles mixed results correctly + */ + public function testProcessApprovalsMixedResults(): void + { + $approval1 = $this->createMockApproval(1); + $approval2 = $this->createMockApproval(2); + $approval3 = $this->createMockApproval(3); + + $approvals = [$approval1, $approval2, $approval3]; + + // First succeeds, second fails (null), third succeeds + $this->approvalRepository->method('checkLastStatus') + ->willReturnOnConsecutiveCalls($approval1, null, $approval3); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->willReturn(false); + + $this->settingsTool->method('getConfiguration') + ->willReturn(false); + + $result = $this->service->processApprovals($approvals); + + $this->assertEquals(2, $result['successful']); + $this->assertEquals(1, $result['failed']); + $this->assertCount(2, $result['processedApprovals']); + } + + /** + * Test processApprovals with break time errors on some approvals + */ + public function testProcessApprovalsWithBreakTimeErrorsOnSome(): void + { + $approval1 = $this->createMockApproval(1); + $approval2 = $this->createMockApproval(2); + + $approvals = [$approval1, $approval2]; + + $this->approvalRepository->method('checkLastStatus') + ->willReturnArgument(4); + + $this->timesheetRepository->method('getTimesheetsForQuery') + ->willReturn([]); + + $this->settingsTool->method('isInConfiguration') + ->willReturn(true); + + $this->settingsTool->method('getConfiguration') + ->willReturn(true); + + // First has errors, second doesn't + $this->breakTimeCheckToolGER->method('checkBreakTime') + ->willReturnOnConsecutiveCalls( + ['2026-01-20' => 'Error'], + [] + ); + + $result = $this->service->processApprovals($approvals); + + $this->assertEquals(1, $result['successful']); + $this->assertEquals(1, $result['failed']); + } + + /** + * Helper method to create a mock approval + */ + private function createMockApproval(int $id): MockObject|Approval + { + $approval = $this->createMock(Approval::class); + $approval->method('getId')->willReturn($id); + + $user = $this->createMock(User::class); + $approval->method('getUser')->willReturn($user); + + $startDate = new DateTime('2026-01-20'); + $endDate = new DateTime('2026-01-26'); + $approval->method('getStartDate')->willReturn($startDate); + $approval->method('getEndDate')->willReturn($endDate); + + // Mock history with SUBMITTED status + $history = $this->createMock(ApprovalHistory::class); + $status = $this->createMock(ApprovalStatus::class); + $status->method('getName')->willReturn(ApprovalStatus::SUBMITTED); + $history->method('getStatus')->willReturn($status); + + $approval->method('getHistory')->willReturn([$history]); + + return $approval; + } + + /** + * Helper method to create a mock approval with specific status + */ + private function createMockApprovalWithStatus(string $statusName): MockObject|Approval + { + $approval = $this->createMock(Approval::class); + + $user = $this->createMock(User::class); + $approval->method('getUser')->willReturn($user); + + $startDate = new DateTime('2026-01-20'); + $endDate = new DateTime('2026-01-26'); + $approval->method('getStartDate')->willReturn($startDate); + $approval->method('getEndDate')->willReturn($endDate); + + // Mock history with custom status + $history = $this->createMock(ApprovalHistory::class); + $status = $this->createMock(ApprovalStatus::class); + $status->method('getName')->willReturn($statusName); + $history->method('getStatus')->willReturn($status); + + $approval->method('getHistory')->willReturn([$history]); + + return $approval; + } +} From 196f1d06dc2b5ce9c7c15843b6e1cd84e339094a Mon Sep 17 00:00:00 2001 From: Luis Galvez Bommer Date: Wed, 4 Feb 2026 20:05:04 +0100 Subject: [PATCH 2/8] Added spinner for waiting when approveButton gets clicked --- Resources/views/report_by_user.html.twig | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Resources/views/report_by_user.html.twig b/Resources/views/report_by_user.html.twig index d7726b3..05ed5c9 100644 --- a/Resources/views/report_by_user.html.twig +++ b/Resources/views/report_by_user.html.twig @@ -44,7 +44,7 @@ {% endif %} {% if (status == 'submitted') and (user.id != currentUser or canManageHimself) %} - + @@ -234,6 +234,11 @@ {% endif %} + {% endblock %} {% block javascripts %} @@ -247,6 +252,14 @@ }); } + const approveButton = document.getElementById("approveButton"); + if (approveButton !== null) { + approveButton.addEventListener("click", (event) => { + const loadingOverlay = document.getElementById("loadingOverlay"); + loadingOverlay.style.display = "flex"; + }); + } + const undoButton = document.getElementById("undoButton"); if (undoButton !== null) { undoButton.addEventListener("click", () => { From 4216f0c459b05dae56ef10e044243dea605fb8d6 Mon Sep 17 00:00:00 2001 From: Luis Galvez Bommer Date: Wed, 4 Feb 2026 20:48:47 +0100 Subject: [PATCH 3/8] Red card-body in user report if approval got recently denied (last 2 of history checked) --- Resources/views/report_by_user.html.twig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Resources/views/report_by_user.html.twig b/Resources/views/report_by_user.html.twig index 05ed5c9..d295729 100644 --- a/Resources/views/report_by_user.html.twig +++ b/Resources/views/report_by_user.html.twig @@ -4,6 +4,10 @@ {% block report_title %}{{ report_title|trans }}{% endblock %} +{% set lastStatus = approve|last %} +{% set penultimateStatus = approve|length > 1 ? approve[approve|length - 2] : null %} + + {% block report %} {% if approvePreviousWeeksMessage and approvePreviousWeeksMessage < (current | date_format('Y-m-d')) %} {{ widgets.callout('danger', 'warning.add_to_approve_previous_weeks'|trans) }} @@ -18,7 +22,11 @@ {% endif %}
-
+