diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2bd99..b660414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Add +- Add Reservation question type - Add configurable table question type ### Fixed diff --git a/public/js/modules/ReservationQuestionWidget.js b/public/js/modules/ReservationQuestionWidget.js new file mode 100644 index 0000000..fd176a6 --- /dev/null +++ b/public/js/modules/ReservationQuestionWidget.js @@ -0,0 +1,266 @@ +/** + * ------------------------------------------------------------------------- + * advancedforms plugin for GLPI + * ------------------------------------------------------------------------- + * + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2025 by the advancedforms plugin team. + * @license MIT https://opensource.org/licenses/mit-license.php + * @link https://github.com/pluginsGLPI/advancedforms + * ------------------------------------------------------------------------- + */ + +export class ReservationQuestionWidget { + #root; + #endpoint_url = `${CFG_GLPI.root_doc}/plugins/advancedforms/ReservationWidget`; + + /** @param {HTMLElement} root - root element rendered by templates/reservation_question.html.twig */ + constructor(root) { + if (!root) { + return; + } + + this.#root = root; + + this.#initItemSelect(); + this.#initDateChangeListeners(); + this.#initSubmitReset(); + } + + /** + * The form renderer validates each question server-side before submitting and + * surfaces any error next to the question, so clear our inline availability + * hint on submit to avoid showing duplicate or stale messaging. + */ + #initSubmitReset() { + const form = this.#root.closest('form'); + if (!form) { + return; + } + + $(form).on('submit', () => this.#showAvailability(null)); + $(form) + .find('[data-glpi-form-renderer-action="submit"]') + .on('click', () => this.#showAvailability(null)); + } + + #initItemSelect() { + const $select = $(this.#root.querySelector('[data-reservation-question-item-select]')); + if ($select.length === 0) { + return; + } + + const allowed_itemtypes = JSON.parse(this.#root.dataset.allowedItemtypes || '[]'); + + $select.select2({ + width: '100%', + allowClear: true, + placeholder: __('Select an item to reserve', 'advancedforms'), + ajax: { + url: `${this.#endpoint_url}/ReservableItems`, + type: 'POST', + delay: 250, + data: (params) => ({ + allowed_itemtypes: allowed_itemtypes, + search: params.term || '', + }), + processResults: (data) => ({ + results: this.#groupResultsByItemtype(Array.isArray(data) ? data : []), + }), + }, + }); + + $select.on('select2:select', () => this.#onItemSelected()); + $select.on('select2:clear select2:unselecting', () => this.#onItemCleared()); + } + + /** Groups flat {id, text, itemtype} results into Select2 optgroups per itemtype. */ + #groupResultsByItemtype(data) { + const groups = new Map(); + + for (const item of data) { + const group_label = item.itemtype || ''; + if (!groups.has(group_label)) { + groups.set(group_label, []); + } + groups.get(group_label).push({ + id: item.id, + text: item.text, + }); + } + + return Array.from(groups, ([text, children]) => ({ text, children })); + } + + #onItemSelected() { + const $select = $(this.#root.querySelector('[data-reservation-question-item-select]')); + this.#setReservationItemsId($select.val()); + $(this.#root.querySelector('[data-reservation-question-dates]')).removeClass('d-none'); + this.#checkAvailability(); + this.#loadReservations(); + } + + #onItemCleared() { + this.#setReservationItemsId(''); + if (this.#getBeginInput()) { + this.#getBeginInput().value = ''; + } + if (this.#getEndInput()) { + this.#getEndInput().value = ''; + } + $(this.#root.querySelector('[data-reservation-question-dates]')).addClass('d-none'); + this.#showAvailability(null); + this.#renderReservations([]); + } + + /** begin/end are rendered by the datetimeField macro (self-initializing Flatpickr); just react to changes. */ + #initDateChangeListeners() { + const begin_input = this.#getBeginInput(); + const end_input = this.#getEndInput(); + if (!begin_input || !end_input) { + return; + } + + $(begin_input).on('change', () => this.#checkAvailability()); + $(end_input).on('change', () => this.#checkAvailability()); + } + + #getBeginInput() { + return this.#root.querySelector('[data-reservation-question-begin-picker]'); + } + + #getEndInput() { + return this.#root.querySelector('[data-reservation-question-end-picker]'); + } + + #setReservationItemsId(value) { + const input = this.#root.querySelector('[data-reservation-question-field="reservationitems_id"]'); + if (input) { + input.value = value ?? ''; + } + } + + #checkAvailability() { + const reservationitems_id = this.#root.querySelector('[data-reservation-question-field="reservationitems_id"]')?.value ?? ''; + const begin = this.#getBeginInput()?.value ?? ''; + const end = this.#getEndInput()?.value ?? ''; + + if (!reservationitems_id || !begin || !end) { + this.#showAvailability(null); + return; + } + + // Catch the obvious end-before-begin case locally, before asking the server. + if (!this.#isRangeValid(begin, end)) { + this.#showRangeError(); + return; + } + + $.post(`${this.#endpoint_url}/CheckAvailability`, { reservationitems_id, begin, end }) + .done((data) => this.#showAvailability(data.available)) + .fail(() => this.#showStatus(__('Could not check availability, please try again', 'advancedforms'), 'text-danger')); + } + + /** @returns {boolean} false only when both dates parse and end is not strictly after begin. */ + #isRangeValid(begin, end) { + const begin_ts = Date.parse(begin.replace(' ', 'T')); + const end_ts = Date.parse(end.replace(' ', 'T')); + + if (Number.isNaN(begin_ts) || Number.isNaN(end_ts)) { + // Unparseable here: let the server-side validation decide. + return true; + } + + return end_ts > begin_ts; + } + + #showRangeError() { + this.#showStatus(__('The end date must be after the start date', 'advancedforms'), 'text-danger'); + } + + /** Fetches the equipment's existing reservations and lists them so the user can see busy slots. */ + #loadReservations() { + const reservationitems_id = this.#root.querySelector('[data-reservation-question-field="reservationitems_id"]')?.value ?? ''; + if (!reservationitems_id) { + this.#renderReservations([]); + return; + } + + $.post(`${this.#endpoint_url}/Reservations`, { reservationitems_id }) + .done((data) => this.#renderReservations(Array.isArray(data) ? data : [])) + .fail(() => this.#renderReservations([])); + } + + /** @param {Array<{begin: string, end: string}>} reservations */ + #renderReservations(reservations) { + const container = this.#root.querySelector('[data-reservation-question-reservations]'); + if (!container) { + return; + } + + if (reservations.length === 0) { + container.innerHTML = ''; + return; + } + + const title = document.createElement('div'); + title.className = 'text-muted small mb-1'; + title.textContent = __('Existing reservations for this item', 'advancedforms'); + + const list = document.createElement('ul'); + list.className = 'list-unstyled small mb-0'; + for (const reservation of reservations) { + const line = document.createElement('li'); + line.className = 'text-muted'; + // textContent, never innerHTML: dates come from the server but stay untrusted here. + line.textContent = `${reservation.begin} → ${reservation.end}`; + list.appendChild(line); + } + + container.replaceChildren(title, list); + } + + #showAvailability(available) { + if (available === null || available === undefined) { + this.#showStatus('', null); + return; + } + + this.#showStatus( + available ? __('This slot is available', 'advancedforms') : __('This slot is no longer available', 'advancedforms'), + available ? 'text-success' : 'text-danger', + ); + } + + /** Renders a single status line under the date pickers (availability, range error or request failure). */ + #showStatus(text, css_class) { + const $result = $(this.#root.querySelector('[data-reservation-question-availability]')); + if ($result.length === 0) { + return; + } + + $result.text(text).removeClass('text-danger text-success'); + if (css_class) { + $result.addClass(css_class); + } + } +} diff --git a/public/js/modules/ReservationRequestTimelineActions.js b/public/js/modules/ReservationRequestTimelineActions.js new file mode 100644 index 0000000..275ed91 --- /dev/null +++ b/public/js/modules/ReservationRequestTimelineActions.js @@ -0,0 +1,83 @@ +/** + * ------------------------------------------------------------------------- + * advancedforms plugin for GLPI + * ------------------------------------------------------------------------- + * + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2025 by the advancedforms plugin team. + * @license MIT https://opensource.org/licenses/mit-license.php + * @link https://github.com/pluginsGLPI/advancedforms + * ------------------------------------------------------------------------- + */ + +export class ReservationRequestTimelineActions { + #root; + #endpoint_url = `${CFG_GLPI.root_doc}/plugins/advancedforms/ReservationRequest`; + + /** @param {HTMLElement} root - one timeline card, rendered by templates/timeline/reservation_request.html.twig */ + constructor(root) { + if (!root) { + return; + } + + this.#root = root; + + this.#initButtons(); + } + + #initButtons() { + const $buttons = $(this.#root).find('[data-reservation-request-action]'); + if ($buttons.length === 0) { + return; + } + + $buttons.on('click', (e) => this.#onActionClicked($(e.currentTarget), $buttons)); + } + + #onActionClicked($button, $buttons) { + // Guard against double-submission while a request is in flight. + if ($buttons.prop('disabled')) { + return; + } + + const id = $button.data('reservationRequestId'); + const action = $button.data('reservationRequestAction'); + const comment = $(this.#root).find('textarea[name="comment"]').val() ?? ''; + + $buttons.prop('disabled', true); + + $.post(this.#endpoint_url, { id, action, comment }) + .done((data) => { + if (data.success) { + window.location.reload(); + return; + } + + glpi_toast_error(data.message || __('An error occurred', 'advancedforms')); + $buttons.prop('disabled', false); + }) + .fail(() => { + glpi_toast_error(__('An error occurred', 'advancedforms')); + $buttons.prop('disabled', false); + }); + } +} diff --git a/src/Controller/AbstractReservationWidgetController.php b/src/Controller/AbstractReservationWidgetController.php new file mode 100644 index 0000000..fe7f38b --- /dev/null +++ b/src/Controller/AbstractReservationWidgetController.php @@ -0,0 +1,51 @@ +checkReservationAccess(); + + $reservationitems_id = $request->request->getInt('reservationitems_id'); + $begin = $request->request->getString('begin'); + $end = $request->request->getString('end'); + + if ($reservationitems_id <= 0 || $begin === '' || $end === '') { + throw new BadRequestHttpException(); + } + + return new JsonResponse([ + 'available' => TicketReservationRequest::isSlotFree($reservationitems_id, $begin, $end), + ]); + } +} diff --git a/src/Controller/GetReservationsController.php b/src/Controller/GetReservationsController.php new file mode 100644 index 0000000..0e5a163 --- /dev/null +++ b/src/Controller/GetReservationsController.php @@ -0,0 +1,99 @@ +checkReservationAccess(); + + $reservationitems_id = $request->request->getInt('reservationitems_id'); + if ($reservationitems_id <= 0) { + throw new BadRequestHttpException(); + } + + $begin = $request->request->getString('begin', ''); + $end = $request->request->getString('end', ''); + + $where = ['reservationitems_id' => $reservationitems_id]; + if ($begin !== '') { + $where['end'] = ['>', $begin]; + } + + if ($end !== '') { + $where['begin'] = ['<', $end]; + } + + global $DB; + $rows = $DB->request([ + 'FROM' => Reservation::getTable(), + 'WHERE' => $where, + ]); + + $results = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $begin_value = $row['begin'] ?? null; + $end_value = $row['end'] ?? null; + $users_id_value = $row['users_id'] ?? null; + + $results[] = [ + 'begin' => is_scalar($begin_value) ? (string) $begin_value : '', + 'end' => is_scalar($end_value) ? (string) $end_value : '', + 'users_id' => is_numeric($users_id_value) ? (int) $users_id_value : 0, + ]; + } + + return new JsonResponse($results); + } +} diff --git a/src/Controller/ReservableItemsController.php b/src/Controller/ReservableItemsController.php new file mode 100644 index 0000000..34a2f87 --- /dev/null +++ b/src/Controller/ReservableItemsController.php @@ -0,0 +1,126 @@ +checkReservationAccess(); + + /** @var array $allowed_itemtypes */ + $allowed_itemtypes = $request->request->all('allowed_itemtypes'); + $search = $request->request->getString('search', ''); + + // Same fallback as the question config: given types, or all reservable types. + $allowed = array_values(array_filter($allowed_itemtypes, is_string(...))); + $itemtypes = (new ReservationQuestionConfig($allowed))->getEffectiveAllowedItemtypes(); + + $results = []; + foreach ($itemtypes as $itemtype) { + if (!is_string($itemtype) || !is_a($itemtype, CommonDBTM::class, true)) { + continue; + } + + $results = [...$results, ...$this->getReservableItemsForType($itemtype, $search)]; + } + + return new JsonResponse($results); + } + + /** + * @param class-string $itemtype + * @return array + */ + private function getReservableItemsForType(string $itemtype, string $search): array + { + global $DB; + + $item = getItemForItemtype($itemtype); + $where = [ + 'itemtype' => $itemtype, + 'is_active' => 1, + ]; + + if ($search !== '') { + $where['items_id'] = new QuerySubQuery([ + 'SELECT' => 'id', + 'FROM' => $itemtype::getTable(), + 'WHERE' => ['name' => ['LIKE', sprintf('%%%s%%', $search)]], + ]); + } + + $rows = $DB->request([ + 'FROM' => ReservationItem::getTable(), + 'WHERE' => $where, + ]); + + $results = []; + foreach ($rows as $row) { + if (!is_array($row) || !isset($row['items_id']) || !is_numeric($row['items_id'])) { + continue; + } + + if (!$item->getFromDB((int) $row['items_id'])) { + continue; + } + + $id = $row['id'] ?? null; + $results[] = [ + 'id' => is_numeric($id) ? (int) $id : 0, + 'text' => sprintf('%s (%s)', $item->getName(), $itemtype::getTypeName(1)), + 'itemtype' => $itemtype, + ]; + } + + return $results; + } +} diff --git a/src/Controller/ReservationRequestController.php b/src/Controller/ReservationRequestController.php new file mode 100644 index 0000000..71d11e5 --- /dev/null +++ b/src/Controller/ReservationRequestController.php @@ -0,0 +1,92 @@ +request->getInt('id'); + $action = $request->request->getString('action'); + $comment = $request->request->getString('comment', ''); + + if ($id <= 0 || !in_array($action, ['approve', 'refuse'], true)) { + throw new BadRequestHttpException(); + } + + $reservation_request = new TicketReservationRequest(); + if (!$reservation_request->getFromDB($id)) { + throw new BadRequestHttpException(); + } + + if (!$reservation_request->canAnswer()) { + throw new AccessDeniedHttpException(); + } + + $users_id_validate = (int) Session::getLoginUserID(); + + if ($action === 'approve') { + if (!$reservation_request->isSlotStillAvailable()) { + return new JsonResponse([ + 'success' => false, + 'message' => __('This slot is no longer available.', 'advancedforms'), + ]); + } + + $success = $reservation_request->approve($users_id_validate, $comment); + } else { + $success = $reservation_request->refuse($users_id_validate, $comment); + } + + return new JsonResponse(['success' => $success]); + } +} diff --git a/src/Model/Destination/PreReservationField.php b/src/Model/Destination/PreReservationField.php new file mode 100644 index 0000000..d1e6f06 --- /dev/null +++ b/src/Model/Destination/PreReservationField.php @@ -0,0 +1,301 @@ + */ + #[Override] + public function getStrategiesForDropdown(): array + { + $values = []; + foreach (PreReservationFieldStrategy::cases() as $strategy) { + $values[$strategy->value] = $strategy->getLabel(); + } + + return $values; + } + + /** @param array $display_options */ + #[Override] + public function renderConfigForm( + Form $form, + FormDestination $destination, + JsonFieldInterface $config, + string $input_name, + array $display_options, + ): string { + if (!$config instanceof PreReservationFieldConfig) { + throw new InvalidArgumentException("Unexpected config class"); + } + + $twig = TemplateRenderer::getInstance(); + return $twig->render('@advancedforms/destination/prereservation_config_field.html.twig', [ + 'CONFIG_FROM_SPECIFIC_QUESTION' => PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION->value, + + 'options' => $display_options, + + 'question_extra_field' => [ + 'empty_label' => __("Select a question...", 'advancedforms'), + 'value' => $config->getQuestionId(), + 'input_name' => $input_name . "[" . PreReservationFieldConfig::QUESTION_ID . "]", + 'possible_values' => $this->getReservationQuestionsValuesForDropdown($form), + ], + 'require_approval_extra_field' => [ + 'value' => $config->isApprovalRequired(), + 'input_name' => $input_name . "[" . PreReservationFieldConfig::REQUIRE_APPROVAL . "]", + ], + ]); + } + + #[Override] + public function applyConfiguratedValueAfterDestinationCreation( + FormDestination $destination, + JsonFieldInterface $config, + AnswersSet $answers_set, + array $created_objects, + ): void { + if (!$config instanceof PreReservationFieldConfig) { + return; + } + + $strategy = current($config->getStrategies()); + if ($strategy !== PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION) { + return; + } + + $question_id = $config->getQuestionId(); + if ($question_id === null) { + return; + } + + $destination_items = $created_objects[$destination->getID()] ?? []; + $ticket = $destination_items[0] ?? null; + if (!$ticket instanceof Ticket) { + return; + } + + $question_answer = $answers_set->getAnswerByQuestionId($question_id); + if (!$question_answer instanceof Answer) { + return; + } + + $raw_answer = $question_answer->getRawAnswer(); + if (!is_array($raw_answer)) { + return; + } + + try { + $answer = ReservationQuestionAnswer::fromArray($raw_answer); + } catch (InvalidArgumentException) { + return; + } + + // Reject incoherent timeframes (end before begin, etc.). + if (!$answer->isValidRange()) { + return; + } + + // A pre-reservation without a real requester makes no sense. + // @phpstan-ignore cast.int (CommonDBTM::$fields is not generically typed) + $users_id = (int) ($answers_set->fields['users_id'] ?? 0); + if ($users_id <= 0) { + return; + } + + // The item id comes from a client-controlled hidden field: re-check it is + // an active reservable item allowed by the question configuration. + if (!$this->isAnswerItemAllowed($answer->getReservationItemsId(), $question_id)) { + return; + } + + $require_approval = $config->isApprovalRequired(); + + $add_input = [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $answer->getReservationItemsId(), + 'users_id' => $users_id, + 'begin' => $answer->getBegin(), + 'end' => $answer->getEnd(), + 'status' => TicketReservationRequest::STATUS_WAITING, + ]; + + if (!$require_approval) { + $add_input['_disablenotif'] = true; + } + + $request = new TicketReservationRequest(); + $request_id = $request->add($add_input); + + if (!$request_id) { + return; + } + + if (!$require_approval) { + if ($request->isSlotStillAvailable()) { + $request->approve(0, ''); + } else { + $request->markUnavailable(); + } + } + } + + /** + * Whether the answered item is a still-active reservable item that the question accepts. + * The item id is client-controlled, so it must never be trusted as-is. + */ + private function isAnswerItemAllowed(int $reservationitems_id, int $question_id): bool + { + if ($reservationitems_id <= 0) { + return false; + } + + $reservation_item = new ReservationItem(); + if (!$reservation_item->getFromDB($reservationitems_id)) { + return false; + } + + $is_active = $reservation_item->fields['is_active'] ?? 0; + if (!is_numeric($is_active) || (int) $is_active !== 1) { + return false; + } + + $allowed_itemtypes = $this->getConfiguredAllowedItemtypes($question_id); + if ($allowed_itemtypes === []) { + // Question accepts any reservable item; being active is enough. + return true; + } + + $itemtype = $reservation_item->fields['itemtype'] ?? ''; + + return is_string($itemtype) && in_array($itemtype, $allowed_itemtypes, true); + } + + /** + * Itemtypes explicitly whitelisted on the question, or [] when unrestricted. + * + * @return array + */ + private function getConfiguredAllowedItemtypes(int $question_id): array + { + $question = Question::getById($question_id); + if (!$question instanceof Question) { + return []; + } + + $extra_data = $question->fields['extra_data'] ?? null; + if (!is_string($extra_data) || $extra_data === '') { + return []; + } + + try { + $decoded = json_decode($extra_data, associative: true); + } catch (JsonException) { + return []; + } + + if (!is_array($decoded)) { + return []; + } + + /** @var array{allowed_itemtypes?: array} $decoded */ + return ReservationQuestionConfig::jsonDeserialize($decoded)->getAllowedItemtypes(); + } + + /** @return array */ + private function getReservationQuestionsValuesForDropdown(Form $form): array + { + $values = []; + $questions = $form->getQuestionsByType(ReservationQuestion::class); + + foreach ($questions as $question) { + // @phpstan-ignore cast.string (CommonDBTM::$fields is not generically typed) + $values[$question->getId()] = (string) $question->fields['name']; + } + + return $values; + } +} diff --git a/src/Model/Destination/PreReservationFieldConfig.php b/src/Model/Destination/PreReservationFieldConfig.php new file mode 100644 index 0000000..e6c2b0f --- /dev/null +++ b/src/Model/Destination/PreReservationFieldConfig.php @@ -0,0 +1,117 @@ + $this->strategy->value, + self::QUESTION_ID => $this->question_id, + self::REQUIRE_APPROVAL => $this->require_approval, + ]; + } + + #[Override] + public static function getStrategiesInputName(): string + { + return self::STRATEGY; + } + + /** @return array */ + #[Override] + public function getStrategies(): array + { + return [$this->strategy]; + } + + public function getQuestionId(): ?int + { + return $this->question_id; + } + + public function isApprovalRequired(): bool + { + return $this->require_approval; + } +} diff --git a/src/Model/Destination/PreReservationFieldStrategy.php b/src/Model/Destination/PreReservationFieldStrategy.php new file mode 100644 index 0000000..b600880 --- /dev/null +++ b/src/Model/Destination/PreReservationFieldStrategy.php @@ -0,0 +1,48 @@ + __('No pre-reservation', 'advancedforms'), + self::FROM_SPECIFIC_QUESTION => __('Pre-reservation: answer from a specific question', 'advancedforms'), + }; + } +} diff --git a/src/Model/NotificationTargetTicketReservationRequest.php b/src/Model/NotificationTargetTicketReservationRequest.php new file mode 100644 index 0000000..dca202f --- /dev/null +++ b/src/Model/NotificationTargetTicketReservationRequest.php @@ -0,0 +1,132 @@ + */ +final class NotificationTargetTicketReservationRequest extends NotificationTarget +{ + #[Override] + public function getEvents(): array + { + return [ + 'reservation_request_created' => __('New pre-reservation request', 'advancedforms'), + 'reservation_request_approved' => __('Pre-reservation request approved', 'advancedforms'), + 'reservation_request_refused' => __('Pre-reservation request refused', 'advancedforms'), + 'reservation_request_slot_unavailable' => __('Reservation slot no longer available', 'advancedforms'), + ]; + } + + #[Override] + public function addAdditionalTargets($event = ''): void + { + // Only the requester is offered: this itemtype carries no technician + // fields, so core item-technician targets cannot be resolved against it. + $this->addTarget(Notification::AUTHOR, __('Requester')); + } + + #[Override] + public function addDataForTemplate($event, $options = []): void + { + if (!$this->obj instanceof TicketReservationRequest) { + return; + } + + $begin = $this->obj->getField('begin'); + $end = $this->obj->getField('end'); + $comment = $this->obj->getField('comment_validation'); + + $this->data['##reservationrequest.action##'] = $this->getEvents()[$event] ?? ''; + $this->data['##reservationrequest.begin##'] = is_string($begin) ? (Html::convDateTime($begin) ?? '') : ''; + $this->data['##reservationrequest.end##'] = is_string($end) ? (Html::convDateTime($end) ?? '') : ''; + $this->data['##reservationrequest.comment##'] = is_string($comment) ? $comment : ''; + + $tickets_id = $this->obj->fields['tickets_id'] ?? 0; + $ticket = new Ticket(); + if ((is_int($tickets_id) || is_string($tickets_id)) && $ticket->getFromDB($tickets_id)) { + $title = $ticket->fields['name'] ?? ''; + $this->data['##reservationrequest.ticket_title##'] = is_string($title) ? $title : ''; + $this->data['##reservationrequest.ticket_url##'] = $ticket->getFormURLWithID($ticket->getID()); + } else { + $this->data['##reservationrequest.ticket_title##'] = ''; + $this->data['##reservationrequest.ticket_url##'] = ''; + } + } + + #[Override] + public function getTags(): void + { + $tags = [ + 'reservationrequest.action' => __('Notification reason', 'advancedforms'), + 'reservationrequest.begin' => __('Reservation start date', 'advancedforms'), + 'reservationrequest.end' => __('Reservation end date', 'advancedforms'), + 'reservationrequest.comment' => __('Validation comment', 'advancedforms'), + 'reservationrequest.ticket_title' => __('Ticket title', 'advancedforms'), + 'reservationrequest.ticket_url' => __('Ticket URL', 'advancedforms'), + ]; + + foreach ($tags as $tag => $label) { + $this->addTagToList([ + 'tag' => $tag, + 'label' => $label, + 'value' => true, + ]); + } + + parent::getTags(); + } + + #[Override] + public function getObjectItem($event = '') + { + if ($this->obj instanceof TicketReservationRequest) { + $tickets_id = $this->obj->fields['tickets_id']; + $ticket = new Ticket(); + if ( + (is_int($tickets_id) || is_string($tickets_id)) + && $ticket->getFromDB($tickets_id) + ) { + $this->target_object[] = $ticket; + return; + } + } + + parent::getObjectItem($event); + } +} diff --git a/src/Model/QuestionType/ReservationQuestion.php b/src/Model/QuestionType/ReservationQuestion.php new file mode 100644 index 0000000..27e9444 --- /dev/null +++ b/src/Model/QuestionType/ReservationQuestion.php @@ -0,0 +1,266 @@ + $input */ + #[Override] + public function validateExtraDataInput(array $input): bool + { + return true; + } + + #[Override] + public function renderAdministrationTemplate(Question|null $question): string + { + $decoded_extra_data = []; + if ($question instanceof Question && is_string($question->fields['extra_data'])) { + $decoded_extra_data = json_decode( + $question->fields['extra_data'], + associative: true, + ); + + if (!is_array($decoded_extra_data)) { + $decoded_extra_data = []; + } + } + + $config = $this->getExtraDataConfig($decoded_extra_data); + if (!$config instanceof JsonFieldInterface) { + $config = new ReservationQuestionConfig(); + } + + global $CFG_GLPI; + $reservation_types = []; + $configured_types = $CFG_GLPI['reservation_types'] ?? []; + if (is_array($configured_types)) { + foreach ($configured_types as $itemtype) { + if (is_string($itemtype) && class_exists($itemtype)) { + $reservation_types[$itemtype] = $itemtype::getTypeName(); + } + } + } + + $twig = TemplateRenderer::getInstance(); + return $twig->render( + '@advancedforms/editor/question_types/reservation_config.html.twig', + [ + 'question' => $question, + 'extra_data' => $config, + 'reservation_types' => $reservation_types, + 'ALLOWED_ITEMTYPES' => ReservationQuestionConfig::ALLOWED_ITEMTYPES, + ], + ); + } + + #[Override] + public function renderEndUserTemplate(Question $question): string + { + $decoded_extra_data = []; + if (is_string($question->fields['extra_data'])) { + $decoded_extra_data = json_decode( + $question->fields['extra_data'], + associative: true, + ); + + if (!is_array($decoded_extra_data)) { + $decoded_extra_data = []; + } + } + + $config = $this->getExtraDataConfig($decoded_extra_data); + if (!$config instanceof ReservationQuestionConfig) { + $config = new ReservationQuestionConfig(); + } + + return TemplateRenderer::getInstance()->render('@advancedforms/reservation_question.html.twig', [ + 'input_name' => $question->getEndUserInputName(), + 'allowed_itemtypes' => $config->getAllowedItemtypes(), + ]); + } + + #[Override] + public function prepareEndUserAnswer(Question $question, mixed $answer): mixed + { + if (!is_array($answer)) { + return null; + } + + try { + $parsed = ReservationQuestionAnswer::fromArray($answer); + } catch (InvalidArgumentException) { + return null; + } + + // Drop incoherent timeframes (end before begin, etc.) rather than store them. + if (!$parsed->isValidRange()) { + return null; + } + + return $parsed->toArray(); + } + + /** + * Validates the end-user answer before submission so an item selected with + * missing or incoherent dates is reported instead of being silently dropped. + */ + #[Override] + public function validateAnswer(Question $question, mixed $answer): ValidationResult + { + $result = new ValidationResult(true); + + if (!is_array($answer)) { + $result->addError($question, __('Unexpected value', 'advancedforms')); + return $result; + } + + $item = $answer['reservationitems_id'] ?? ''; + $begin = $answer['begin'] ?? ''; + $end = $answer['end'] ?? ''; + + // Nothing filled at all: treat as unanswered. The generic mandatory check + // never fires here (the answer is a non-empty array), so enforce it now. + if (in_array($item, ['', null], true) && in_array($begin, ['', null], true) && in_array($end, ['', null], true)) { + if (!empty($question->fields['is_mandatory'])) { + $result->addError($question, __('This field is mandatory')); + } + + return $result; + } + + // Partially filled or complete: the whole triple is required. + try { + $parsed = ReservationQuestionAnswer::fromArray($answer); + } catch (InvalidArgumentException) { + $result->addError($question, __('Please select an item and both a start and end date.', 'advancedforms')); + return $result; + } + + if (!$parsed->isValidRange()) { + $result->addError($question, __('The end date must be after the start date.', 'advancedforms')); + } + + return $result; + } + + #[Override] + public function formatRawAnswer(mixed $answer, Question $question): string + { + if (!is_array($answer)) { + return ''; + } + + try { + $parsed = ReservationQuestionAnswer::fromArray($answer); + } catch (InvalidArgumentException) { + return ''; + } + + $slot = sprintf('%s → %s', $parsed->getBegin(), $parsed->getEnd()); + $equipment_name = TicketReservationRequest::getReservableItemName($parsed->getReservationItemsId()); + + return $equipment_name !== '' + ? sprintf('%s (%s)', $equipment_name, $slot) + : $slot; + } + + #[Override] + public static function getConfigKey(): string + { + return "enable_question_type_reservation"; + } + + #[Override] + public function getConfigTitle(): string + { + return __("Material reservation question type", 'advancedforms'); + } + + #[Override] + public function getConfigDescription(): string + { + return __("Allow users to reserve equipment from a form.", 'advancedforms'); + } + + #[Override] + public function getConfigIcon(): string + { + return $this->getIcon(); + } +} diff --git a/src/Model/QuestionType/ReservationQuestionAnswer.php b/src/Model/QuestionType/ReservationQuestionAnswer.php new file mode 100644 index 0000000..5c63ff0 --- /dev/null +++ b/src/Model/QuestionType/ReservationQuestionAnswer.php @@ -0,0 +1,106 @@ + $data */ + public static function fromArray(array $data): self + { + foreach (['reservationitems_id', 'begin', 'end'] as $key) { + if (!isset($data[$key]) || $data[$key] === '') { + throw new InvalidArgumentException('Missing or empty key: ' . $key); + } + } + + $reservationitems_id = $data['reservationitems_id']; + $begin = $data['begin']; + $end = $data['end']; + + if (!is_numeric($reservationitems_id) || !is_string($begin) || !is_string($end)) { + throw new InvalidArgumentException('Invalid type for reservation answer data'); + } + + return new self( + reservationitems_id: (int) $reservationitems_id, + begin: $begin, + end: $end, + ); + } + + /** @return array{reservationitems_id: int, begin: string, end: string} */ + public function toArray(): array + { + return [ + 'reservationitems_id' => $this->reservationitems_id, + 'begin' => $this->begin, + 'end' => $this->end, + ]; + } + + public function getReservationItemsId(): int + { + return $this->reservationitems_id; + } + + public function getBegin(): string + { + return $this->begin; + } + + public function getEnd(): string + { + return $this->end; + } + + public function isValidRange(): bool + { + try { + return strtotime($this->begin) < strtotime($this->end); + } catch (DatetimeException) { + return false; + } + } +} diff --git a/src/Model/QuestionType/ReservationQuestionConfig.php b/src/Model/QuestionType/ReservationQuestionConfig.php new file mode 100644 index 0000000..bfc38fe --- /dev/null +++ b/src/Model/QuestionType/ReservationQuestionConfig.php @@ -0,0 +1,88 @@ + $allowed_itemtypes */ + public function __construct( + private array $allowed_itemtypes = [], + ) {} + + /** @param array{allowed_itemtypes?: array} $data */ + #[Override] + public static function jsonDeserialize(array $data): self + { + return new self( + allowed_itemtypes: $data[self::ALLOWED_ITEMTYPES] ?? [], + ); + } + + /** @return array{allowed_itemtypes: array} */ + #[Override] + public function jsonSerialize(): array + { + return [ + self::ALLOWED_ITEMTYPES => $this->allowed_itemtypes, + ]; + } + + /** @return array */ + public function getAllowedItemtypes(): array + { + return $this->allowed_itemtypes; + } + + /** @return array */ + public function getEffectiveAllowedItemtypes(): array + { + global $CFG_GLPI; + + if ($this->allowed_itemtypes !== []) { + return $this->allowed_itemtypes; + } + + $reservation_types = $CFG_GLPI['reservation_types'] ?? []; + + return is_array($reservation_types) + ? array_values(array_filter($reservation_types, is_string(...))) + : []; + } +} diff --git a/src/Model/TicketReservationRequest.php b/src/Model/TicketReservationRequest.php new file mode 100644 index 0000000..2f6637e --- /dev/null +++ b/src/Model/TicketReservationRequest.php @@ -0,0 +1,286 @@ +getIntField('status') === self::STATUS_WAITING + && !isset($this->input['_disablenotif']) + ) { + NotificationEvent::raiseEvent('reservation_request_created', $this); + } + } + + /** + * Whether no existing Reservation overlaps the given slot for the item. + * Single source of truth for the overlap check, mirroring core's + * Reservation::is_reserved() (strict inequalities, back-to-back slots allowed). + */ + public static function isSlotFree(int $reservationitems_id, string $begin, string $end): bool + { + global $DB; + + $conflicts = $DB->request([ + 'COUNT' => 'cpt', + 'FROM' => Reservation::getTable(), + 'WHERE' => [ + 'reservationitems_id' => $reservationitems_id, + 'end' => ['>', $begin], + 'begin' => ['<', $end], + ], + ])->current(); + + $count = is_array($conflicts) && is_numeric($conflicts['cpt'] ?? null) + ? (int) $conflicts['cpt'] + : 0; + + return $count === 0; + } + + public function isSlotStillAvailable(): bool + { + return self::isSlotFree( + $this->getIntField('reservationitems_id'), + $this->getNullableStringField('begin') ?? '', + $this->getNullableStringField('end') ?? '', + ); + } + + /** Creates the Reservation for the original requester and accepts this request; returns false, untouched, if the slot is no longer available. */ + public function approve(int $users_id_validate, string $comment = ''): bool + { + $reservation = new Reservation(); + $created = $reservation->add([ + 'reservationitems_id' => $this->fields['reservationitems_id'], + 'begin' => $this->fields['begin'], + 'end' => $this->fields['end'], + 'users_id' => $this->fields['users_id'], + 'comment' => $this->fields['comment_submission'] ?? '', + ]); + + if (!$created) { + return false; + } + + $updated = $this->update([ + 'id' => $this->getID(), + 'status' => self::STATUS_ACCEPTED, + 'users_id_validate' => $users_id_validate, + 'comment_validation' => $comment, + // Keep a link back to the created Reservation for traceability and cleanup. + 'reservations_id' => $reservation->getID(), + 'validation_date' => $_SESSION['glpi_currenttime'] ?? date('Y-m-d H:i:s'), + ]); + + if ($updated) { + NotificationEvent::raiseEvent('reservation_request_approved', $this); + } + + return $updated; + } + + public function refuse(int $users_id_validate, string $comment = ''): bool + { + $updated = $this->update([ + 'id' => $this->getID(), + 'status' => self::STATUS_REFUSED, + 'users_id_validate' => $users_id_validate, + 'comment_validation' => $comment, + 'validation_date' => $_SESSION['glpi_currenttime'] ?? date('Y-m-d H:i:s'), + ]); + + if ($updated) { + NotificationEvent::raiseEvent('reservation_request_refused', $this); + } + + return $updated; + } + + /** Removes the Reservation created on approval so it never outlives its request. */ + #[Override] + public function pre_deleteItem(): bool + { + $reservations_id = $this->getIntField('reservations_id'); + if ($reservations_id > 0) { + $reservation = new Reservation(); + if ($reservation->getFromDB($reservations_id)) { + $reservation->delete(['id' => $reservations_id]); + } + } + + return parent::pre_deleteItem(); + } + + /** Marks this request canceled (slot no longer available, e.g. taken during a direct reservation). */ + public function markUnavailable(): bool + { + $updated = $this->update([ + 'id' => $this->getID(), + 'status' => self::STATUS_CANCELED, + ]); + + if ($updated) { + NotificationEvent::raiseEvent('reservation_request_slot_unavailable', $this); + } + + return $updated; + } + + /** + * @return array{ + * id: int, + * status: int, + * reservationitems_id: int, + * begin: string|null, + * end: string|null, + * comment_validation: string, + * can_answer: bool, + * is_direct_reservation: bool, + * } + */ + public function getTimelineInfo(): array + { + return [ + 'id' => $this->getID(), + 'status' => $this->getIntField('status'), + 'reservationitems_id' => $this->getIntField('reservationitems_id'), + 'begin' => $this->getNullableStringField('begin'), + 'end' => $this->getNullableStringField('end'), + 'comment_validation' => $this->getNullableStringField('comment_validation') ?? '', + 'can_answer' => $this->canAnswer(), + 'is_direct_reservation' => $this->getIntField('status') === self::STATUS_ACCEPTED + && $this->getIntField('users_id_validate') === 0, + ]; + } + + /** Resolves the reservable asset's display name; empty string if it no longer exists. */ + public static function getReservableItemName(int $reservationitems_id): string + { + $reservation_item = new ReservationItem(); + if (!$reservation_item->getFromDB($reservationitems_id)) { + return ''; + } + + $itemtype = $reservation_item->fields['itemtype'] ?? ''; + $itemtype = is_string($itemtype) ? $itemtype : ''; + + $items_id = $reservation_item->fields['items_id'] ?? 0; + $items_id = is_numeric($items_id) ? (int) $items_id : 0; + + $item = getItemForItemtype($itemtype); + if (!$item instanceof CommonDBTM || !$item->getFromDB($items_id)) { + return ''; + } + + return $item->getName(); + } + + /** Whether the current user may approve/refuse this still-waiting request. */ + public function canAnswer(): bool + { + if ($this->getIntField('status') !== self::STATUS_WAITING) { + return false; + } + + $ticket = new Ticket(); + if (!$ticket->getFromDB($this->getIntField('tickets_id'))) { + return false; + } + + return $ticket->canUpdateItem(); + } + + private function getIntField(string $field): int + { + $value = $this->fields[$field] ?? null; + + return is_numeric($value) ? (int) $value : 0; + } + + private function getNullableStringField(string $field): ?string + { + $value = $this->fields[$field] ?? null; + + return is_string($value) ? $value : null; + } +} diff --git a/src/Service/ConfigManager.php b/src/Service/ConfigManager.php index b83f04d..a488cc6 100644 --- a/src/Service/ConfigManager.php +++ b/src/Service/ConfigManager.php @@ -43,6 +43,7 @@ use GlpiPlugin\Advancedforms\Model\QuestionType\IpAddressQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\LdapQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestion; +use GlpiPlugin\Advancedforms\Model\QuestionType\ReservationQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\TreeCascadeDropdownQuestion; final class ConfigManager @@ -68,6 +69,7 @@ public function getConfigurableQuestionTypes(): array new LdapQuestion(), new TreeCascadeDropdownQuestion(), new TableQuestion(), + new ReservationQuestion(), ]; } diff --git a/src/Service/InitManager.php b/src/Service/InitManager.php index 45699c6..2c7e216 100644 --- a/src/Service/InitManager.php +++ b/src/Service/InitManager.php @@ -34,13 +34,17 @@ namespace GlpiPlugin\Advancedforms\Service; use Config; +use Glpi\Form\Destination\FormDestinationManager; +use Glpi\Form\Destination\FormDestinationTicket; use Glpi\Form\Migration\TypesConversionMapper; use Glpi\Form\QuestionType\QuestionTypesManager; use Glpi\Plugin\Hooks; use Glpi\Toolbox\SingletonTrait; use GlpiPlugin\Advancedforms\Model\Config\ConfigTab; +use GlpiPlugin\Advancedforms\Model\Destination\PreReservationField; use GlpiPlugin\Advancedforms\Model\QuestionType\AdvancedCategory; use GlpiPlugin\Advancedforms\Model\QuestionType\LegacyQuestionTypeInterface; +use GlpiPlugin\Advancedforms\Model\TicketReservationRequest; use Plugin; final class InitManager @@ -51,6 +55,24 @@ public function init(): void { $this->registerConfiguration(); $this->registerPluginTypes(); + $this->registerDestinationFields(); + $this->registerTimelineHooks(); + $this->registerNotifications(); + } + + private function registerNotifications(): void + { + global $CFG_GLPI; + + $itemtype = TicketReservationRequest::class; + + // init() may run multiple times per process; avoid duplicate registration. + $registered = $CFG_GLPI['notificationtemplates_types'] ?? []; + if (is_array($registered) && in_array($itemtype, $registered, true)) { + return; + } + + Plugin::registerClass($itemtype, ['notificationtemplates_types' => true]); } private function registerConfiguration(): void @@ -96,4 +118,31 @@ private function registerPluginTypes(): void } } } + + private function registerDestinationFields(): void + { + $destination_manager = FormDestinationManager::getInstance(); + + // init() may run multiple times per process; avoid double-registering the field. + $already_registered = array_filter( + $destination_manager->getPluginCommonITILConfigFields(FormDestinationTicket::class), + fn($field) => $field instanceof PreReservationField, + ); + if ($already_registered !== []) { + return; + } + + $destination_manager->registerPluginCommonITILConfigField( + FormDestinationTicket::class, + new PreReservationField(), + ); + } + + private function registerTimelineHooks(): void + { + global $PLUGIN_HOOKS; + + // @phpstan-ignore offsetAccess.nonOffsetAccessible (we don't have type hint for this array at this time) + $PLUGIN_HOOKS[Hooks::TIMELINE_ITEMS]['advancedforms'] = TimelineManager::addTimelineItems(...); + } } diff --git a/src/Service/InstallManager.php b/src/Service/InstallManager.php index 9aebf43..5e56baa 100644 --- a/src/Service/InstallManager.php +++ b/src/Service/InstallManager.php @@ -33,22 +33,252 @@ namespace GlpiPlugin\Advancedforms\Service; +use DBmysql; use Config; +use DBConnection; +use Glpi\DBAL\QueryExpression; use Glpi\Toolbox\SingletonTrait; +use GlpiPlugin\Advancedforms\Model\TicketReservationRequest; +use Migration; +use Notification; +use Notification_NotificationTemplate; +use NotificationTemplate; +use NotificationTemplateTranslation; + +use function countElementsInTable; final class InstallManager { use SingletonTrait; + /** + * Notifications seeded at install, keyed by event. + * Each target is [items_id, type] as stored in glpi_notificationtargets. + * + * Only the requester (AUTHOR) is targeted out of the box: it is the reliable + * recipient for a request-scoped itemtype. Notifying the ticket's assigned + * technicians would require a custom target resolving the linked ticket, so + * it is left for admins to add (or a follow-up), not seeded here. + */ + private const NOTIFICATIONS = [ + 'reservation_request_created' => [ + 'name' => 'New pre-reservation request', + 'targets' => [[Notification::AUTHOR, Notification::USER_TYPE]], + ], + 'reservation_request_approved' => [ + 'name' => 'Pre-reservation request approved', + 'targets' => [[Notification::AUTHOR, Notification::USER_TYPE]], + ], + 'reservation_request_refused' => [ + 'name' => 'Pre-reservation request refused', + 'targets' => [[Notification::AUTHOR, Notification::USER_TYPE]], + ], + 'reservation_request_slot_unavailable' => [ + 'name' => 'Reservation slot no longer available', + 'targets' => [[Notification::AUTHOR, Notification::USER_TYPE]], + ], + ]; + public function install(): bool { + global $DB; + + $migration = new Migration(PLUGIN_ADVANCEDFORMS_VERSION); + + $this->installTicketReservationRequestsTable($DB); + $this->installNotifications(); + + $migration->executeMigration(); + return true; } + private function installTicketReservationRequestsTable(DBmysql $DB): void + { + $table = TicketReservationRequest::getTable(); + + if ($DB->tableExists($table, false)) { + return; + } + + $default_charset = DBConnection::getDefaultCharset(); + $default_collation = DBConnection::getDefaultCollation(); + $default_key_sign = DBConnection::getDefaultPrimaryKeySignOption(); + + $query = "CREATE TABLE `{$table}` ( + `id` int {$default_key_sign} NOT NULL AUTO_INCREMENT, + `tickets_id` int {$default_key_sign} NOT NULL DEFAULT '0', + `reservationitems_id` int {$default_key_sign} NOT NULL DEFAULT '0', + `reservations_id` int {$default_key_sign} NOT NULL DEFAULT '0', + `users_id` int {$default_key_sign} NOT NULL DEFAULT '0', + `begin` timestamp NULL DEFAULT NULL, + `end` timestamp NULL DEFAULT NULL, + `status` int NOT NULL DEFAULT '1', + `comment_submission` text, + `comment_validation` text, + `users_id_validate` int {$default_key_sign} NOT NULL DEFAULT '0', + `date_creation` timestamp NULL DEFAULT NULL, + `date_mod` timestamp NULL DEFAULT NULL, + `validation_date` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `tickets_id` (`tickets_id`), + KEY `reservationitems_id` (`reservationitems_id`), + KEY `reservations_id` (`reservations_id`), + KEY `users_id` (`users_id`), + KEY `status` (`status`), + KEY `date_creation` (`date_creation`) + ) ENGINE=InnoDB DEFAULT CHARSET={$default_charset} COLLATE={$default_collation} ROW_FORMAT=DYNAMIC;"; + + $DB->doQuery($query); + } + + /** + * Seeds a mail template + one active notification per reservation event so + * the feature works out of the box. Idempotent: existing rows are left as-is + * (admins may have customized them). + */ + private function installNotifications(): void + { + global $DB; + + $itemtype = TicketReservationRequest::class; + + $templates_id = $this->ensureNotificationTemplate($itemtype); + if ($templates_id <= 0) { + return; + } + + foreach (self::NOTIFICATIONS as $event => $definition) { + if (countElementsInTable(Notification::getTable(), ['itemtype' => $itemtype, 'event' => $event]) > 0) { + continue; + } + + // Raw inserts (like core install/migration seeding): no login context required. + $DB->insert(Notification::getTable(), [ + 'name' => $definition['name'], + 'entities_id' => 0, + 'is_recursive' => 1, + 'is_active' => 1, + 'itemtype' => $itemtype, + 'event' => $event, + 'comment' => '', + 'date_creation' => new QueryExpression('NOW()'), + 'date_mod' => new QueryExpression('NOW()'), + ]); + $insert_id = $DB->insertId(); + $notifications_id = is_numeric($insert_id) ? (int) $insert_id : 0; + if ($notifications_id <= 0) { + continue; + } + + $DB->insert(Notification_NotificationTemplate::getTable(), [ + 'notifications_id' => $notifications_id, + 'mode' => Notification_NotificationTemplate::MODE_MAIL, + 'notificationtemplates_id' => $templates_id, + ]); + + foreach ($definition['targets'] as [$items_id, $type]) { + $DB->insert('glpi_notificationtargets', [ + 'notifications_id' => $notifications_id, + 'items_id' => $items_id, + 'type' => $type, + ]); + } + } + } + + /** Returns the id of the (created if needed) shared mail template for reservation requests. */ + private function ensureNotificationTemplate(string $itemtype): int + { + global $DB; + + $template = new NotificationTemplate(); + if ($template->getFromDBByCrit(['itemtype' => $itemtype])) { + return (int) $template->getID(); + } + + $DB->insert(NotificationTemplate::getTable(), [ + 'name' => 'Reservation requests', + 'itemtype' => $itemtype, + 'comment' => '', + 'css' => '', + 'date_creation' => new QueryExpression('NOW()'), + 'date_mod' => new QueryExpression('NOW()'), + ]); + $insert_id = $DB->insertId(); + $templates_id = is_numeric($insert_id) ? (int) $insert_id : 0; + if ($templates_id <= 0) { + return 0; + } + + $content_html = <<<'HTML' +

##reservationrequest.action##

+

##reservationrequest.begin## → ##reservationrequest.end##

+

##reservationrequest.comment##

+

##reservationrequest.ticket_url##

+ HTML; + + $content_text = "##reservationrequest.action##\n" + . "##reservationrequest.begin## -> ##reservationrequest.end##\n" + . "##reservationrequest.comment##\n" + . "##reservationrequest.ticket_url##"; + + $DB->insert(NotificationTemplateTranslation::getTable(), [ + 'notificationtemplates_id' => $templates_id, + 'language' => '', + 'subject' => '##reservationrequest.action## - ##reservationrequest.ticket_title##', + 'content_html' => $content_html, + 'content_text' => $content_text, + ]); + + return $templates_id; + } + public function uninstall(): bool { + global $DB; + $config = new Config(); $config->deleteByCriteria(['context' => 'advancedforms']); + + $this->uninstallNotifications(); + + $table = TicketReservationRequest::getTable(); + if ($DB->tableExists($table, false)) { + $DB->dropTable($table); + } + return true; } + + /** Removes every notification artifact seeded by installNotifications(). */ + private function uninstallNotifications(): void + { + global $DB; + + $itemtype = TicketReservationRequest::class; + + foreach ($DB->request(['SELECT' => 'id', 'FROM' => Notification::getTable(), 'WHERE' => ['itemtype' => $itemtype]]) as $row) { + if (!is_array($row) || !is_numeric($row['id'] ?? null)) { + continue; + } + + $notifications_id = (int) $row['id']; + $DB->delete('glpi_notificationtargets', ['notifications_id' => $notifications_id]); + $DB->delete(Notification_NotificationTemplate::getTable(), ['notifications_id' => $notifications_id]); + } + + $DB->delete(Notification::getTable(), ['itemtype' => $itemtype]); + + foreach ($DB->request(['SELECT' => 'id', 'FROM' => NotificationTemplate::getTable(), 'WHERE' => ['itemtype' => $itemtype]]) as $row) { + if (!is_array($row) || !is_numeric($row['id'] ?? null)) { + continue; + } + + $templates_id = (int) $row['id']; + $DB->delete(NotificationTemplateTranslation::getTable(), ['notificationtemplates_id' => $templates_id]); + } + + $DB->delete(NotificationTemplate::getTable(), ['itemtype' => $itemtype]); + } } diff --git a/src/Service/TimelineManager.php b/src/Service/TimelineManager.php new file mode 100644 index 0000000..fd201df --- /dev/null +++ b/src/Service/TimelineManager.php @@ -0,0 +1,97 @@ +>} $params + * `$params['timeline']` holds a real PHP reference to the caller's array (see + * `CommonITILObject::getTimelineItems()`), which survives being passed here by + * value — do not declare this `array &$params`, `Plugin::doHook()` calls it via + * `call_user_func()`, which warns on by-ref params. + */ + public static function addTimelineItems(array $params): void + { + $ticket = $params['item'] ?? null; + if (!$ticket instanceof Ticket) { + return; + } + + $timeline = &$params['timeline']; + + $request = new TicketReservationRequest(); + + foreach ($request->find(['tickets_id' => $ticket->getID()]) as $row) { + if (!is_array($row)) { + continue; + } + + // find() already returns the full row; hydrate in place, no extra query. + $request->getFromResultSet($row); + + $reservationitems_id = $request->fields['reservationitems_id'] ?? 0; + $reservationitems_id = is_numeric($reservationitems_id) ? (int) $reservationitems_id : 0; + + $content = TemplateRenderer::getInstance()->render('@advancedforms/timeline/reservation_request.html.twig', [ + 'request' => $request->getTimelineInfo(), + 'equipment_name' => TicketReservationRequest::getReservableItemName($reservationitems_id), + ]); + + $date_creation = $request->fields['date_creation'] ?? null; + $users_id = $request->fields['users_id'] ?? 0; + $users_id = is_numeric($users_id) ? (int) $users_id : 0; + + $timeline['TicketReservationRequest_' . $request->getID()] = [ + 'type' => TicketReservationRequest::class, + 'item' => [ + 'id' => $request->getID(), + 'content' => $content, + 'is_content_safe' => true, + 'date' => is_string($date_creation) ? $date_creation : null, + 'users_id' => $users_id, + 'can_edit' => false, + 'timeline_position' => CommonITILObject::TIMELINE_LEFT, + ], + 'object' => clone $request, + ]; + } + } +} diff --git a/templates/destination/prereservation_config_field.html.twig b/templates/destination/prereservation_config_field.html.twig new file mode 100644 index 0000000..cd03dfe --- /dev/null +++ b/templates/destination/prereservation_config_field.html.twig @@ -0,0 +1,65 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{% import 'components/form/fields_macros.html.twig' as fields %} + +
+ {{ fields.dropdownArrayField( + question_extra_field.input_name, + question_extra_field.value, + question_extra_field.possible_values, + "", + options|merge({ + field_class: '', + mb: '', + no_label: true, + display_emptychoice: true, + emptylabel: question_extra_field.empty_label, + aria_label: question_extra_field.empty_label, + }) + ) }} + + +
diff --git a/templates/editor/question_types/reservation_config.html.twig b/templates/editor/question_types/reservation_config.html.twig new file mode 100644 index 0000000..21413da --- /dev/null +++ b/templates/editor/question_types/reservation_config.html.twig @@ -0,0 +1,53 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{% import 'components/form/fields_macros.html.twig' as fields %} + +
+ {{ fields.dropdownArrayField( + 'extra_data[' ~ ALLOWED_ITEMTYPES ~ ']', + '', + reservation_types, + __('Allowed equipment types', 'advancedforms'), + { + 'values' : extra_data.getAllowedItemtypes(), + 'multiple' : true, + 'display_emptychoice': true, + 'emptylabel' : __('All reservable types', 'advancedforms'), + 'full_width' : true, + 'is_horizontal' : false, + 'init' : question is not null ? true : false, + } + ) }} +
diff --git a/templates/reservation_question.html.twig b/templates/reservation_question.html.twig new file mode 100644 index 0000000..cc3a934 --- /dev/null +++ b/templates/reservation_question.html.twig @@ -0,0 +1,81 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{% import 'components/form/fields_macros.html.twig' as fields %} + +
+ + +
+
+ {{ fields.datetimeField( + input_name ~ '[begin]', + '', + __('Start date', 'advancedforms'), + { + is_horizontal: false, + full_width: true, + additional_attributes: {'data-reservation-question-begin-picker': ''}, + } + ) }} +
+
+ {{ fields.datetimeField( + input_name ~ '[end]', + '', + __('End date', 'advancedforms'), + { + is_horizontal: false, + full_width: true, + additional_attributes: {'data-reservation-question-end-picker': ''}, + } + ) }} +
+
+
+
+ + +
+ diff --git a/templates/timeline/reservation_request.html.twig b/templates/timeline/reservation_request.html.twig new file mode 100644 index 0000000..8e8c93b --- /dev/null +++ b/templates/timeline/reservation_request.html.twig @@ -0,0 +1,116 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{% set request_class = 'GlpiPlugin\\Advancedforms\\Model\\TicketReservationRequest' %} +{% set status_classes = { + (constant(request_class ~ '::STATUS_WAITING')): 'warning', + (constant(request_class ~ '::STATUS_ACCEPTED')): 'success', + (constant(request_class ~ '::STATUS_REFUSED')): 'danger', + (constant(request_class ~ '::STATUS_CANCELED')): 'secondary', +} %} +{% set status_labels = { + (constant(request_class ~ '::STATUS_WAITING')): __('Pending validation', 'advancedforms'), + (constant(request_class ~ '::STATUS_ACCEPTED')): __('Approved', 'advancedforms'), + (constant(request_class ~ '::STATUS_REFUSED')): __('Refused', 'advancedforms'), + (constant(request_class ~ '::STATUS_CANCELED')): __('Canceled', 'advancedforms'), +} %} + +
+
+ +
+
{{ equipment_name|default('') }}
+
+ {{ request.begin }} → {{ request.end }} +
+
+ + {{ status_labels[request.status]|default(request.status) }} + +
+ + {% if request.is_direct_reservation %} +
+ + {{ __('Reservation confirmed automatically', 'advancedforms') }} +
+ {% elseif request.status == constant(request_class ~ '::STATUS_CANCELED') %} +
+ + {{ __('This slot is no longer available. Please submit a new reservation request.', 'advancedforms') }} +
+ {% endif %} + + {% if request.comment_validation is not empty %} +
+ {{ request.comment_validation }} +
+ {% endif %} + + {% if request.can_answer %} +
+
+ +
+
+ + +
+
+ + {% endif %} +
diff --git a/tests/Controller/CheckAvailabilityControllerTest.php b/tests/Controller/CheckAvailabilityControllerTest.php new file mode 100644 index 0000000..0390626 --- /dev/null +++ b/tests/Controller/CheckAvailabilityControllerTest.php @@ -0,0 +1,138 @@ +login(); + $_SESSION['glpiactiveprofile']['reservation'] = 0; + + $controller = new CheckAvailabilityController(); + $this->expectException(AccessDeniedHttpException::class); + $controller(Request::create('/', 'POST', [])); + } + + public function testMissingParamsThrowsBadRequest(): void + { + $this->login(); + $controller = new CheckAvailabilityController(); + $this->expectException(BadRequestHttpException::class); + $controller(Request::create('/', 'POST', [])); + } + + public function testAvailableSlotReturnsTrue(): void + { + $this->login(); + $item = $this->getReservableItem(); + + $controller = new CheckAvailabilityController(); + $response = $controller(Request::create('/', 'POST', [ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 09:00:00', + 'end' => '2026-02-01 10:00:00', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['available' => true], json_decode((string) $response->getContent(), true)); + } + + public function testConflictingSlotReturnsFalse(): void + { + $this->login(); + $item = $this->getReservableItem(); + + $reservation = new Reservation(); + $this->assertGreaterThan(0, $reservation->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 09:00:00', + 'end' => '2026-02-01 11:00:00', + 'users_id' => Session::getLoginUserID(), + 'comment' => '', + ])); + + $controller = new CheckAvailabilityController(); + $response = $controller(Request::create('/', 'POST', [ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 10:00:00', // overlaps + 'end' => '2026-02-01 12:00:00', + ])); + + $this->assertSame(['available' => false], json_decode((string) $response->getContent(), true)); + } + + public function testBackToBackSlotIsAvailable(): void + { + $this->login(); + $item = $this->getReservableItem(); + + $reservation = new Reservation(); + $this->assertGreaterThan(0, $reservation->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 09:00:00', + 'end' => '2026-02-01 11:00:00', + 'users_id' => Session::getLoginUserID(), + 'comment' => '', + ])); + + $controller = new CheckAvailabilityController(); + $response = $controller(Request::create('/', 'POST', [ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 11:00:00', // starts exactly when the other ends: no overlap + 'end' => '2026-02-01 12:00:00', + ])); + + $this->assertSame(['available' => true], json_decode((string) $response->getContent(), true)); + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', ['name' => 'test-computer', 'entities_id' => 0]); + return $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } +} diff --git a/tests/Controller/GetReservationsControllerTest.php b/tests/Controller/GetReservationsControllerTest.php new file mode 100644 index 0000000..030a542 --- /dev/null +++ b/tests/Controller/GetReservationsControllerTest.php @@ -0,0 +1,153 @@ +login(); + $_SESSION['glpiactiveprofile']['reservation'] = 0; + + $controller = new GetReservationsController(); + $this->expectException(AccessDeniedHttpException::class); + $controller(Request::create('/', 'POST', [])); + } + + public function testMissingParamsThrowsBadRequest(): void + { + $this->login(); + $controller = new GetReservationsController(); + $this->expectException(BadRequestHttpException::class); + $controller(Request::create('/', 'POST', [])); + } + + public function testReturnsReservationsForItem(): void + { + $this->login(); + $item = $this->getReservableItem(); + + $reservation = new Reservation(); + $this->assertGreaterThan(0, $reservation->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 09:00:00', + 'end' => '2026-02-01 11:00:00', + 'users_id' => Session::getLoginUserID(), + 'comment' => 'this should not leak', + ])); + + $controller = new GetReservationsController(); + $response = $controller(Request::create('/', 'POST', [ + 'reservationitems_id' => $item->getID(), + ])); + + $this->assertSame(200, $response->getStatusCode()); + $data = json_decode((string) $response->getContent(), true); + $this->assertIsArray($data); + $this->assertCount(1, $data); + $this->assertSame('2026-02-01 09:00:00', $data[0]['begin']); + $this->assertSame('2026-02-01 11:00:00', $data[0]['end']); + $this->assertSame(Session::getLoginUserID(), $data[0]['users_id']); + $this->assertArrayNotHasKey('comment', $data[0]); + } + + public function testFiltersReservationsByDateRange(): void + { + $this->login(); + $item = $this->getReservableItem(); + + $reservation_in_range = new Reservation(); + $this->assertGreaterThan(0, $reservation_in_range->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 09:00:00', + 'end' => '2026-02-01 11:00:00', + 'users_id' => Session::getLoginUserID(), + ])); + + $reservation_out_of_range = new Reservation(); + $this->assertGreaterThan(0, $reservation_out_of_range->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-02 09:00:00', + 'end' => '2026-02-02 11:00:00', + 'users_id' => Session::getLoginUserID(), + ])); + + $controller = new GetReservationsController(); + $response = $controller(Request::create('/', 'POST', [ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-02-01 00:00:00', + 'end' => '2026-02-01 23:59:59', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $data = json_decode((string) $response->getContent(), true); + $this->assertIsArray($data); + $this->assertCount(1, $data); + $this->assertSame('2026-02-01 09:00:00', $data[0]['begin']); + $this->assertSame('2026-02-01 11:00:00', $data[0]['end']); + } + + public function testReturnsEmptyArrayWhenNoReservations(): void + { + $this->login(); + $item = $this->getReservableItem(); + + $controller = new GetReservationsController(); + $response = $controller(Request::create('/', 'POST', [ + 'reservationitems_id' => $item->getID(), + ])); + + $data = json_decode((string) $response->getContent(), true); + $this->assertSame([], $data); + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', ['name' => 'test-computer-resa', 'entities_id' => 0]); + return $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } +} diff --git a/tests/Controller/ReservableItemsControllerTest.php b/tests/Controller/ReservableItemsControllerTest.php new file mode 100644 index 0000000..8251f57 --- /dev/null +++ b/tests/Controller/ReservableItemsControllerTest.php @@ -0,0 +1,173 @@ +login(); + $_SESSION['glpiactiveprofile']['reservation'] = 0; + + $controller = new ReservableItemsController(); + $this->expectException(AccessDeniedHttpException::class); + $controller(Request::create('/', 'POST', [])); + } + + public function testReturnsActiveReservableItemsFilteredByAllowedItemtypes(): void + { + $this->login(); + + $computer = $this->createItem('Computer', ['name' => 'test-reservable-computer', 'entities_id' => 0]); + $res_item = $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + + // Item of an itemtype not in the allowed list must not be returned + $monitor = $this->createItem('Monitor', ['name' => 'test-reservable-monitor', 'entities_id' => 0]); + $this->createItem('ReservationItem', [ + 'itemtype' => 'Monitor', + 'items_id' => $monitor->getID(), + 'is_active' => 1, + ]); + + $controller = new ReservableItemsController(); + $response = $controller(Request::create('/', 'POST', [ + 'allowed_itemtypes' => ['Computer'], + ])); + + $this->assertSame(200, $response->getStatusCode()); + $data = json_decode((string) $response->getContent(), true); + $this->assertIsArray($data); + + $ids = array_column($data, 'id'); + $this->assertContains($res_item->getID(), $ids); + foreach ($data as $row) { + $this->assertSame('Computer', $row['itemtype']); + $this->assertSame('test-reservable-computer (Computer)', $row['text']); + } + } + + public function testInactiveItemIsExcluded(): void + { + $this->login(); + + $computer = $this->createItem('Computer', ['name' => 'test-inactive-computer', 'entities_id' => 0]); + $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 0, + ]); + + $controller = new ReservableItemsController(); + $response = $controller(Request::create('/', 'POST', [ + 'allowed_itemtypes' => ['Computer'], + ])); + + $data = json_decode((string) $response->getContent(), true); + $names = array_column($data, 'text'); + $this->assertNotContains('test-inactive-computer (Computer)', $names); + } + + public function testFallsBackToConfigReservationTypesWhenAllowedItemtypesOmitted(): void + { + $this->login(); + + global $CFG_GLPI; + $original_reservation_types = $CFG_GLPI['reservation_types'] ?? null; + $CFG_GLPI['reservation_types'] = ['Computer']; + + try { + $computer = $this->createItem('Computer', ['name' => 'test-fallback-computer', 'entities_id' => 0]); + $res_item = $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + + $controller = new ReservableItemsController(); + $response = $controller(Request::create('/', 'POST', [])); + + $this->assertSame(200, $response->getStatusCode()); + $data = json_decode((string) $response->getContent(), true); + $this->assertIsArray($data); + + $ids = array_column($data, 'id'); + $this->assertContains($res_item->getID(), $ids); + + foreach ($data as $row) { + $this->assertSame('Computer', $row['itemtype']); + } + } finally { + $CFG_GLPI['reservation_types'] = $original_reservation_types; + } + } + + public function testSearchFiltersResults(): void + { + $this->login(); + + $computer1 = $this->createItem('Computer', ['name' => 'alpha-computer', 'entities_id' => 0]); + $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer1->getID(), + 'is_active' => 1, + ]); + + $computer2 = $this->createItem('Computer', ['name' => 'beta-computer', 'entities_id' => 0]); + $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer2->getID(), + 'is_active' => 1, + ]); + + $controller = new ReservableItemsController(); + $response = $controller(Request::create('/', 'POST', [ + 'allowed_itemtypes' => ['Computer'], + 'search' => 'alpha', + ])); + + $data = json_decode((string) $response->getContent(), true); + $names = array_column($data, 'text'); + $this->assertContains('alpha-computer (Computer)', $names); + $this->assertNotContains('beta-computer (Computer)', $names); + } +} diff --git a/tests/Controller/ReservationRequestControllerTest.php b/tests/Controller/ReservationRequestControllerTest.php new file mode 100644 index 0000000..26a2900 --- /dev/null +++ b/tests/Controller/ReservationRequestControllerTest.php @@ -0,0 +1,210 @@ +login(); + + $controller = new ReservationRequestController(); + $this->expectException(BadRequestHttpException::class); + $controller(Request::create('/', 'POST', ['action' => 'approve'])); + } + + public function testInvalidActionThrowsBadRequest(): void + { + $this->login(); + $request = $this->createWaitingRequest(); + + $controller = new ReservationRequestController(); + $this->expectException(BadRequestHttpException::class); + $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'not-a-real-action', + ])); + } + + public function testNonExistentIdThrowsBadRequest(): void + { + $this->login(); + + $controller = new ReservationRequestController(); + $this->expectException(BadRequestHttpException::class); + $controller(Request::create('/', 'POST', [ + 'id' => 999999999, + 'action' => 'approve', + ])); + } + + public function testUserWithoutRightsCannotApprove(): void + { + // Ticket is created by the default logged in user (requester). + $this->login(); + $request = $this->createWaitingRequest(); + + // Switch to a "post-only" session: this user is not the ticket's requester + $this->login('post-only', 'postonly'); + + $controller = new ReservationRequestController(); + $this->expectException(AccessDeniedHttpException::class); + $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'approve', + ])); + } + + public function testUserWithoutRightsCannotRefuse(): void + { + $this->login(); + $request = $this->createWaitingRequest(); + + $this->login('post-only', 'postonly'); + + $controller = new ReservationRequestController(); + $this->expectException(AccessDeniedHttpException::class); + $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'refuse', + ])); + } + + public function testApproveByAuthorizedUserSucceeds(): void + { + $this->login(); + $request = $this->createWaitingRequest(); + + $controller = new ReservationRequestController(); + $response = $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'approve', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['success' => true], json_decode((string) $response->getContent(), true)); + + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_ACCEPTED, (int) $request->fields['status']); + + $reservation = new Reservation(); + $found = $reservation->find([ + 'reservationitems_id' => $request->fields['reservationitems_id'], + 'begin' => $request->fields['begin'], + 'end' => $request->fields['end'], + ]); + $this->assertCount(1, $found); + } + + public function testApproveWhenSlotBecameUnavailableReturnsFailureWithoutChangingStatus(): void + { + $this->login(); + $request = $this->createWaitingRequest(); + + $conflict = new Reservation(); + $this->assertGreaterThan(0, $conflict->add([ + 'reservationitems_id' => $request->fields['reservationitems_id'], + 'begin' => $request->fields['begin'], + 'end' => $request->fields['end'], + 'users_id' => Session::getLoginUserID(), + 'comment' => '', + ])); + + $controller = new ReservationRequestController(); + $response = $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'approve', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertFalse($body['success']); + $this->assertNotEmpty($body['message']); + + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_WAITING, (int) $request->fields['status']); + } + + public function testRefuseByAuthorizedUserSucceeds(): void + { + $this->login(); + $request = $this->createWaitingRequest(); + + $controller = new ReservationRequestController(); + $response = $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'refuse', + 'comment' => 'no thanks', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['success' => true], json_decode((string) $response->getContent(), true)); + + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_REFUSED, (int) $request->fields['status']); + } + + private function createWaitingRequest(): TicketReservationRequest + { + $ticket = $this->createItem('Ticket', [ + 'name' => 't', + 'content' => 'c', + 'entities_id' => $this->getTestRootEntity(true), + ]); + $computer = $this->createItem('Computer', ['name' => 'test-computer', 'entities_id' => 0]); + $item = $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + + return $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-06-01 09:00:00', + 'end' => '2026-06-01 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + } +} diff --git a/tests/Model/Destination/PreReservationFieldConfigTest.php b/tests/Model/Destination/PreReservationFieldConfigTest.php new file mode 100644 index 0000000..a8f1b3c --- /dev/null +++ b/tests/Model/Destination/PreReservationFieldConfigTest.php @@ -0,0 +1,65 @@ +jsonSerialize()); + + $this->assertSame(PreReservationFieldStrategy::NO_PRERESERVATION, $rebuilt->getStrategies()[0]); + $this->assertNull($rebuilt->getQuestionId()); + $this->assertTrue($rebuilt->isApprovalRequired()); + } + + public function testJsonRoundTripFromSpecificQuestion(): void + { + $config = new PreReservationFieldConfig( + PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + question_id: 42, + require_approval: false, + ); + $rebuilt = PreReservationFieldConfig::jsonDeserialize($config->jsonSerialize()); + + $this->assertSame(PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, $rebuilt->getStrategies()[0]); + $this->assertSame(42, $rebuilt->getQuestionId()); + $this->assertFalse($rebuilt->isApprovalRequired()); + } +} diff --git a/tests/Model/Destination/PreReservationFieldTest.php b/tests/Model/Destination/PreReservationFieldTest.php new file mode 100644 index 0000000..ccf4153 --- /dev/null +++ b/tests/Model/Destination/PreReservationFieldTest.php @@ -0,0 +1,288 @@ +submitReservationForm(PreReservationFieldStrategy::NO_PRERESERVATION, true); + + $request = new TicketReservationRequest(); + $this->assertCount(0, $request->find(['tickets_id' => $ticket->getID()])); + } + + public function testWithApprovalCreatesWaitingRequestOnly(): void + { + [$ticket, $item] = $this->submitReservationForm(PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, true); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + + $row = reset($rows); + $this->assertSame(TicketReservationRequest::STATUS_WAITING, (int) $row['status']); + $this->assertSame($ticket->getID(), (int) $row['tickets_id']); + $this->assertSame($item->getID(), (int) $row['reservationitems_id']); + $this->assertSame('2026-04-10 09:00:00', $row['begin']); + $this->assertSame('2026-04-10 10:00:00', $row['end']); + $this->assertSame(Session::getLoginUserID(), (int) $row['users_id']); + + $reservation = new Reservation(); + $this->assertCount(0, $reservation->find(['reservationitems_id' => $item->getID()])); + } + + public function testDirectModeSlotFreeAutoApproves(): void + { + [$ticket, $item] = $this->submitReservationForm(PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, false); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertSame(TicketReservationRequest::STATUS_ACCEPTED, (int) reset($rows)['status']); + + $reservation = new Reservation(); + $found = $reservation->find(['reservationitems_id' => $item->getID()]); + $this->assertCount(1, $found); + $this->assertSame(Session::getLoginUserID(), (int) reset($found)['users_id']); + } + + public function testDirectModeSlotConflictingCancels(): void + { + $item = $this->getReservableItem(); + + // Pre-existing reservation that overlaps the answer's timeframe. + $reservation = new Reservation(); + $this->assertGreaterThan(0, $reservation->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-04-01 09:00:00', + 'end' => '2026-04-01 11:00:00', + 'users_id' => Session::getLoginUserID(), + 'comment' => '', + ])); + + [$ticket] = $this->submitReservationForm( + PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + false, + item: $item, + begin: '2026-04-01 10:00:00', + end: '2026-04-01 12:00:00', + ); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertSame(TicketReservationRequest::STATUS_CANCELED, (int) reset($rows)['status']); + + // Only the original, pre-existing reservation should exist. + $this->assertCount(1, $reservation->find(['reservationitems_id' => $item->getID()])); + } + + public function testUnansweredQuestionCreatesNoRequestAndDoesNotCrash(): void + { + [$ticket] = $this->submitReservationForm( + PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + true, + answer_question: false, + ); + + $request = new TicketReservationRequest(); + $this->assertCount(0, $request->find(['tickets_id' => $ticket->getID()])); + } + + public function testAnswerWithMismatchedShapeCreatesNoRequestAndDoesNotCrash(): void + { + $this->login(); + $this->enableConfigurableItem(new ReservationQuestion()); + + $builder = new FormBuilder("Reservation form with mismatched pre-reservation question"); + $builder->addQuestion("Reservation", ReservationQuestion::class); + $builder->addQuestion( + "Not a reservation", + QuestionTypeCheckbox::class, + extra_data: json_encode(new QuestionTypeSelectableExtraDataConfig([ + 'foo' => 'Foo', + 'bar' => 'Bar', + ])), + ); + $form = $this->createForm($builder); + + // Point the pre-reservation config at the checkbox question rather + // than the actual `ReservationQuestion`. + $question_id = $this->getQuestionId($form, "Not a reservation"); + + $config = new PreReservationFieldConfig( + strategy: PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + question_id: $question_id, + require_approval: true, + ); + + $destinations = $form->getDestinations(); + $this->assertCount(1, $destinations); + $destination = current($destinations); + $this->updateItem( + $destination::getType(), + $destination->getId(), + ['config' => [PreReservationField::getKey() => $config->jsonSerialize()]], + ['config'], + ); + + $ticket = $this->sendFormAndGetCreatedTicket($form, [ + "Not a reservation" => ['foo', 'bar'], + ]); + + // Ticket creation must not be blocked by the mismatched answer shape. + $this->assertGreaterThan(0, $ticket->getID()); + + $request = new TicketReservationRequest(); + $this->assertCount(0, $request->find(['tickets_id' => $ticket->getID()])); + } + + public function testInvalidTimeRangeCreatesNoRequest(): void + { + // End before begin: the answer must be rejected server-side. + [$ticket] = $this->submitReservationForm( + PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + true, + begin: '2026-04-10 11:00:00', + end: '2026-04-10 09:00:00', + ); + + $request = new TicketReservationRequest(); + $this->assertCount(0, $request->find(['tickets_id' => $ticket->getID()])); + } + + public function testInactiveReservableItemCreatesNoRequest(): void + { + $computer = $this->createItem('Computer', [ + 'name' => 'prereservationfield-inactive-computer', + 'entities_id' => $this->getTestRootEntity(true), + ]); + $inactive_item = $this->createItem(ReservationItem::class, [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 0, + ]); + + [$ticket] = $this->submitReservationForm( + PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + false, + item: $inactive_item, + ); + + $request = new TicketReservationRequest(); + $this->assertCount(0, $request->find(['tickets_id' => $ticket->getID()])); + } + + /** @return array{0: Ticket, 1: ReservationItem} */ + private function submitReservationForm( + PreReservationFieldStrategy $strategy, + bool $require_approval, + ?ReservationItem $item = null, + string $begin = '2026-04-10 09:00:00', + string $end = '2026-04-10 10:00:00', + bool $answer_question = true, + ): array { + $this->login(); + $this->enableConfigurableItem(new ReservationQuestion()); + + $item ??= $this->getReservableItem(); + + $builder = new FormBuilder("Reservation form"); + $builder->addQuestion("Reservation", ReservationQuestion::class); + + $form = $this->createForm($builder); + + $question_id = $strategy === PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION + ? $this->getQuestionId($form, "Reservation") + : null; + + $config = new PreReservationFieldConfig( + strategy: $strategy, + question_id: $question_id, + require_approval: $require_approval, + ); + + $destinations = $form->getDestinations(); + $this->assertCount(1, $destinations); + $destination = current($destinations); + $this->updateItem( + $destination::getType(), + $destination->getId(), + ['config' => [PreReservationField::getKey() => $config->jsonSerialize()]], + ['config'], + ); + + $answers = []; + if ($answer_question) { + $answers['Reservation'] = [ + 'reservationitems_id' => $item->getID(), + 'begin' => $begin, + 'end' => $end, + ]; + } + + $ticket = $this->sendFormAndGetCreatedTicket($form, $answers); + + return [$ticket, $item]; + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', [ + 'name' => 'prereservationfield-test-computer', + 'entities_id' => $this->getTestRootEntity(true), + ]); + + return $this->createItem(ReservationItem::class, [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } +} diff --git a/tests/Model/NotificationTargetTicketReservationRequestTest.php b/tests/Model/NotificationTargetTicketReservationRequestTest.php new file mode 100644 index 0000000..a95f840 --- /dev/null +++ b/tests/Model/NotificationTargetTicketReservationRequestTest.php @@ -0,0 +1,176 @@ +assertSame( + NotificationTargetTicketReservationRequest::class, + NotificationTarget::getInstanceClass(TicketReservationRequest::class), + ); + } + + public function testGetEventsHasTheFourExpectedKeysInOrder(): void + { + $target = new NotificationTargetTicketReservationRequest(); + + $this->assertSame( + [ + 'reservation_request_created', + 'reservation_request_approved', + 'reservation_request_refused', + 'reservation_request_slot_unavailable', + ], + array_keys($target->getEvents()), + ); + } + + public function testGetEventsLabelsAreTranslatedStrings(): void + { + $target = new NotificationTargetTicketReservationRequest(); + + foreach ($target->getEvents() as $label) { + $this->assertIsString($label); + $this->assertNotSame('', $label); + } + } + + public function testAdditionalTargetsOfferOnlyTheRequester(): void + { + $this->login(); + + // This itemtype carries no technician fields, so only the requester is a + // resolvable target; item-technician targets would warn and never resolve. + $author_key = Notification::USER_TYPE . '_' . Notification::AUTHOR; + $tech_key = Notification::USER_TYPE . '_' . Notification::ITEM_TECH_IN_CHARGE; + $tech_group_key = Notification::USER_TYPE . '_' . Notification::ITEM_TECH_GROUP_IN_CHARGE; + + $events = [ + 'reservation_request_created', + 'reservation_request_approved', + 'reservation_request_refused', + 'reservation_request_slot_unavailable', + ]; + foreach ($events as $event) { + $target = new NotificationTargetTicketReservationRequest(); + $target->addAdditionalTargets($event); + $this->assertArrayHasKey($author_key, $target->notification_targets, 'requester missing for event ' . $event); + $this->assertArrayNotHasKey($tech_key, $target->notification_targets, 'technician unexpectedly present for event ' . $event); + $this->assertArrayNotHasKey($tech_group_key, $target->notification_targets, 'tech group unexpectedly present for event ' . $event); + } + } + + public function testGetTagsRegistersReservationRequestTags(): void + { + $target = new NotificationTargetTicketReservationRequest(); + $target->getTags(); + + $value_tags = array_keys($target->tag_descriptions[NotificationTarget::TAG_VALUE] ?? []); + + $this->assertContains('##reservationrequest.begin##', $value_tags); + $this->assertContains('##reservationrequest.end##', $value_tags); + $this->assertContains('##reservationrequest.comment##', $value_tags); + } + + public function testAddDataForTemplatePopulatesReservationRequestTags(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-04-01 09:00:00', + 'end' => '2026-04-01 10:00:00', + 'status' => TicketReservationRequest::STATUS_ACCEPTED, + 'comment_validation' => 'approved comment', + ]); + + $target = new NotificationTargetTicketReservationRequest(); + $target->obj = $request; + $target->addDataForTemplate('reservation_request_approved'); + + $this->assertSame(Html::convDateTime('2026-04-01 09:00:00'), $target->data['##reservationrequest.begin##']); + $this->assertSame(Html::convDateTime('2026-04-01 10:00:00'), $target->data['##reservationrequest.end##']); + $this->assertSame('approved comment', $target->data['##reservationrequest.comment##']); + } + + public function testGetObjectItemResolvesToParentTicket(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-04-02 09:00:00', + 'end' => '2026-04-02 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $notification_target = NotificationTarget::getInstance($request, 'reservation_request_created'); + + $this->assertNotFalse($notification_target); + $this->assertCount(1, $notification_target->target_object); + $resolved_object = $notification_target->target_object[0]; + $this->assertInstanceOf(Ticket::class, $resolved_object); + $this->assertSame($ticket->getID(), $resolved_object->getID()); + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', ['name' => 'test-computer', 'entities_id' => 0]); + return $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } +} diff --git a/tests/Model/QuestionType/ReservationQuestionAnswerTest.php b/tests/Model/QuestionType/ReservationQuestionAnswerTest.php new file mode 100644 index 0000000..cbafaa8 --- /dev/null +++ b/tests/Model/QuestionType/ReservationQuestionAnswerTest.php @@ -0,0 +1,87 @@ + 123, + 'begin' => '2026-01-15 09:00:00', + 'end' => '2026-01-15 12:00:00', + ]); + + $this->assertSame(123, $answer->getReservationItemsId()); + $this->assertSame('2026-01-15 09:00:00', $answer->getBegin()); + $this->assertSame('2026-01-15 12:00:00', $answer->getEnd()); + $this->assertSame([ + 'reservationitems_id' => 123, + 'begin' => '2026-01-15 09:00:00', + 'end' => '2026-01-15 12:00:00', + ], $answer->toArray()); + } + + public function testFromArrayThrowsOnMissingKeys(): void + { + $this->expectException(InvalidArgumentException::class); + ReservationQuestionAnswer::fromArray(['reservationitems_id' => 123]); + } + + public function testIsValidRange(): void + { + $valid = ReservationQuestionAnswer::fromArray([ + 'reservationitems_id' => 1, 'begin' => '2026-01-15 09:00:00', 'end' => '2026-01-15 12:00:00', + ]); + $this->assertTrue($valid->isValidRange()); + + $invalid = ReservationQuestionAnswer::fromArray([ + 'reservationitems_id' => 1, 'begin' => '2026-01-15 12:00:00', 'end' => '2026-01-15 09:00:00', + ]); + $this->assertFalse($invalid->isValidRange()); + } + + public function testIsValidRangeReturnsFalseOnMalformedDates(): void + { + // Safe\strtotime() throws on garbage: isValidRange() must swallow it, not bubble up. + $malformed = ReservationQuestionAnswer::fromArray([ + 'reservationitems_id' => 1, 'begin' => 'not-a-date', 'end' => 'also-not-a-date', + ]); + $this->assertFalse($malformed->isValidRange()); + } +} diff --git a/tests/Model/QuestionType/ReservationQuestionConfigTest.php b/tests/Model/QuestionType/ReservationQuestionConfigTest.php new file mode 100644 index 0000000..1097032 --- /dev/null +++ b/tests/Model/QuestionType/ReservationQuestionConfigTest.php @@ -0,0 +1,65 @@ +jsonSerialize(); + $this->assertSame(['allowed_itemtypes' => ['Computer', 'Monitor']], $serialized); + + $rebuilt = ReservationQuestionConfig::jsonDeserialize($serialized); + $this->assertSame(['Computer', 'Monitor'], $rebuilt->getAllowedItemtypes()); + } + + public function testEffectiveAllowedItemtypesFallsBackToConfiguredReservationTypes(): void + { + global $CFG_GLPI; + $CFG_GLPI['reservation_types'] = ['Computer', 'Monitor', 'Peripheral']; + + $config = new ReservationQuestionConfig([]); + $this->assertSame(['Computer', 'Monitor', 'Peripheral'], $config->getEffectiveAllowedItemtypes()); + } + + public function testEffectiveAllowedItemtypesUsesExplicitListWhenSet(): void + { + $config = new ReservationQuestionConfig(['Monitor']); + $this->assertSame(['Monitor'], $config->getEffectiveAllowedItemtypes()); + } +} diff --git a/tests/Model/QuestionType/ReservationQuestionTest.php b/tests/Model/QuestionType/ReservationQuestionTest.php new file mode 100644 index 0000000..67e66a6 --- /dev/null +++ b/tests/Model/QuestionType/ReservationQuestionTest.php @@ -0,0 +1,266 @@ +assertGreaterThan( + 0, + $html->filter('[data-glpi-form-editor-question-extra-details]')->count(), + ); + } + + #[Override] + protected function validateHelpdeskRenderingWhenEnabled( + Crawler $html, + ): void { + $this->assertGreaterThan(0, $html->filter('input[name$="[reservationitems_id]"]')->count()); + $this->assertGreaterThan(0, $html->filter('input[name$="[begin]"]')->count()); + $this->assertGreaterThan(0, $html->filter('input[name$="[end]"]')->count()); + + $module_scripts_text = implode( + "\n", + $html->filter('script[type="module"]')->each(fn(Crawler $node) => $node->text()), + ); + $this->assertStringContainsString("ReservationQuestionWidget.js", $module_scripts_text); + } + + #[Override] + protected function validateHelpdeskRenderingWhenDisabled( + Crawler $html, + ): void { + $this->assertCount(0, $html->filter('input[name$="[reservationitems_id]"]')); + } + + public function testFormatRawAnswerAndPrepareEndUserAnswerRoundTrip(): void + { + $type = new ReservationQuestion(); + $raw = [ + 'reservationitems_id' => 42, + 'begin' => '2026-01-15 09:00:00', + 'end' => '2026-01-15 12:00:00', + ]; + + $this->enableConfigurableItem($type); + $builder = new FormBuilder("My form"); + $builder->addQuestion("My question", ReservationQuestion::class); + + $form = $this->createForm($builder); + $questions_id = $this->getQuestionId($form, "My question"); + $question = new Question(); + $this->assertTrue($question->getFromDB($questions_id)); + + $prepared = $type->prepareEndUserAnswer($question, $raw); + $this->assertSame([ + 'reservationitems_id' => 42, + 'begin' => '2026-01-15 09:00:00', + 'end' => '2026-01-15 12:00:00', + ], $prepared); + + $formatted = $type->formatRawAnswer($raw, $question); + $this->assertStringContainsString('2026-01-15 09:00:00', $formatted); + $this->assertStringContainsString('2026-01-15 12:00:00', $formatted); + } + + public function testPrepareEndUserAnswerReturnsNullWhenAnswerIsEmptyOrIncomplete(): void + { + $type = new ReservationQuestion(); + + $this->enableConfigurableItem($type); + $builder = new FormBuilder("My form"); + $builder->addQuestion("My question", ReservationQuestion::class); + + $form = $this->createForm($builder); + $questions_id = $this->getQuestionId($form, "My question"); + $question = new Question(); + $this->assertTrue($question->getFromDB($questions_id)); + + $this->assertNull($type->prepareEndUserAnswer($question, [ + 'reservationitems_id' => '', + 'begin' => '', + 'end' => '', + ])); + + $this->assertNull($type->prepareEndUserAnswer($question, [])); + + $this->assertNull($type->prepareEndUserAnswer($question, [ + 'reservationitems_id' => 42, + 'begin' => '', + 'end' => '', + ])); + + $this->assertNull($type->prepareEndUserAnswer($question, null)); + } + + public function testFormatRawAnswerReturnsEmptyStringWhenAnswerIsEmptyOrIncomplete(): void + { + $type = new ReservationQuestion(); + + $this->enableConfigurableItem($type); + $builder = new FormBuilder("My form"); + $builder->addQuestion("My question", ReservationQuestion::class); + + $form = $this->createForm($builder); + $questions_id = $this->getQuestionId($form, "My question"); + $question = new Question(); + $this->assertTrue($question->getFromDB($questions_id)); + + $this->assertSame('', $type->formatRawAnswer([ + 'reservationitems_id' => '', + 'begin' => '', + 'end' => '', + ], $question)); + + $this->assertSame('', $type->formatRawAnswer([], $question)); + + $this->assertSame('', $type->formatRawAnswer([ + 'reservationitems_id' => 42, + 'begin' => '', + 'end' => '', + ], $question)); + + $this->assertSame('', $type->formatRawAnswer(null, $question)); + } + + public function testValidateAnswerAcceptsCompleteCoherentAnswer(): void + { + $type = new ReservationQuestion(); + $result = $type->validateAnswer($this->makeReservationQuestion(), [ + 'reservationitems_id' => 5, + 'begin' => '2026-01-15 09:00:00', + 'end' => '2026-01-15 12:00:00', + ]); + + $this->assertTrue($result->isValid()); + $this->assertSame([], $result->getErrors()); + } + + public function testValidateAnswerRejectsPartialAnswer(): void + { + $type = new ReservationQuestion(); + $result = $type->validateAnswer($this->makeReservationQuestion(), [ + 'reservationitems_id' => 5, + 'begin' => '2026-01-15 09:00:00', + 'end' => '', + ]); + + $this->assertFalse($result->isValid()); + } + + public function testValidateAnswerRejectsEndBeforeBegin(): void + { + $type = new ReservationQuestion(); + $result = $type->validateAnswer($this->makeReservationQuestion(), [ + 'reservationitems_id' => 5, + 'begin' => '2026-01-15 12:00:00', + 'end' => '2026-01-15 09:00:00', + ]); + + $this->assertFalse($result->isValid()); + } + + public function testValidateAnswerAcceptsFullyEmptyOptionalQuestion(): void + { + $type = new ReservationQuestion(); + $result = $type->validateAnswer($this->makeReservationQuestion(mandatory: false), [ + 'reservationitems_id' => '', + 'begin' => '', + 'end' => '', + ]); + + $this->assertTrue($result->isValid()); + } + + public function testValidateAnswerRejectsFullyEmptyMandatoryQuestion(): void + { + $type = new ReservationQuestion(); + $result = $type->validateAnswer($this->makeReservationQuestion(mandatory: true), [ + 'reservationitems_id' => '', + 'begin' => '', + 'end' => '', + ]); + + $this->assertFalse($result->isValid()); + } + + public function testValidateAnswerRejectsNonArrayAnswer(): void + { + $type = new ReservationQuestion(); + $result = $type->validateAnswer($this->makeReservationQuestion(), 'not-an-array'); + + $this->assertFalse($result->isValid()); + } + + private function makeReservationQuestion(bool $mandatory = false): Question + { + $type = new ReservationQuestion(); + $this->enableConfigurableItem($type); + + $builder = new FormBuilder("Reservation validation form"); + $builder->addQuestion("Reservation", ReservationQuestion::class); + + $form = $this->createForm($builder); + + $question = new Question(); + $this->assertTrue($question->getFromDB($this->getQuestionId($form, "Reservation"))); + + if ($mandatory) { + $question->fields['is_mandatory'] = 1; + } + + return $question; + } +} diff --git a/tests/Model/TicketReservationRequestTest.php b/tests/Model/TicketReservationRequestTest.php new file mode 100644 index 0000000..21aefc5 --- /dev/null +++ b/tests/Model/TicketReservationRequestTest.php @@ -0,0 +1,608 @@ +login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-01 09:00:00', + 'end' => '2026-03-01 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $this->assertTrue($request->isSlotStillAvailable()); + } + + public function testIsSlotStillAvailableWithOverlap(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-01 09:00:00', + 'end' => '2026-03-01 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $reservation = new Reservation(); + $this->assertGreaterThan(0, $reservation->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-03-01 09:30:00', + 'end' => '2026-03-01 10:30:00', + 'users_id' => Session::getLoginUserID(), + 'comment' => '', + ])); + + $this->assertFalse($request->isSlotStillAvailable()); + } + + public function testIsSlotStillAvailableWithBackToBackSlot(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-01 10:00:00', + 'end' => '2026-03-01 11:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $reservation = new Reservation(); + $this->assertGreaterThan(0, $reservation->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-03-01 09:00:00', + 'end' => '2026-03-01 10:00:00', // ends exactly when the request begins + 'users_id' => Session::getLoginUserID(), + 'comment' => '', + ])); + + $this->assertTrue($request->isSlotStillAvailable()); + } + + public function testApproveCreatesReservationWithOriginalRequester(): void + { + $this->login(); // requester + $requester_id = Session::getLoginUserID(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-03-02 09:00:00', + 'end' => '2026-03-02 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + 'comment_submission' => 'please', + ]); + + $this->login('glpi', 'glpi'); // validator (different user) + $validator_id = Session::getLoginUserID(); + $this->assertNotSame($requester_id, $validator_id); + + $this->assertTrue($request->approve($validator_id, 'ok')); + $this->assertTrue($request->getFromDB($request->getID())); + + $this->assertSame(TicketReservationRequest::STATUS_ACCEPTED, (int) $request->fields['status']); + $this->assertSame($validator_id, (int) $request->fields['users_id_validate']); + $this->assertSame('ok', $request->fields['comment_validation']); + $this->assertNotEmpty($request->fields['validation_date']); + + $reservation = new Reservation(); + $found = $reservation->find([ + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + ]); + $this->assertCount(1, $found); + $found_reservation = reset($found); + $this->assertSame('please', $found_reservation['comment']); + + // The created reservation must be linked back for traceability/cleanup. + $this->assertSame((int) $found_reservation['id'], (int) $request->fields['reservations_id']); + } + + public function testDeletingAcceptedRequestRemovesLinkedReservation(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-03-05 09:00:00', + 'end' => '2026-03-05 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $this->assertTrue($request->approve($requester_id, 'ok')); + $reservations_id = (int) $request->fields['reservations_id']; + $this->assertGreaterThan(0, $reservations_id); + + // Deleting the request must not leave its reservation behind. + $this->assertTrue($request->delete(['id' => $request->getID()], true)); + + $reservation = new Reservation(); + $this->assertFalse($reservation->getFromDB($reservations_id)); + } + + public function testApproveFailsAndDoesNotMutateStatusWhenSlotIsNoLongerAvailable(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-03-04 09:00:00', + 'end' => '2026-03-04 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $conflicting = new Reservation(); + $this->assertGreaterThan(0, $conflicting->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-03-04 09:00:00', + 'end' => '2026-03-04 10:00:00', + 'users_id' => $requester_id, + 'comment' => '', + ])); + + $this->assertFalse($request->approve($requester_id, 'ok')); + $this->hasSessionMessages(ERROR, ['The required item is already reserved for this timeframe']); + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_WAITING, (int) $request->fields['status']); + $this->assertSame(0, (int) $request->fields['users_id_validate']); + } + + public function testRefuseDoesNotCreateReservation(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-03 09:00:00', + 'end' => '2026-03-03 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $this->assertTrue($request->refuse(Session::getLoginUserID(), 'nope')); + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_REFUSED, (int) $request->fields['status']); + $this->assertSame('nope', $request->fields['comment_validation']); + + $reservation = new Reservation(); + $this->assertCount(0, $reservation->find(['reservationitems_id' => $item->getID()])); + } + + public function testMarkUnavailableSetsCanceledStatus(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-05 09:00:00', + 'end' => '2026-03-05 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $this->assertTrue($request->markUnavailable()); + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_CANCELED, (int) $request->fields['status']); + + $reservation = new Reservation(); + $this->assertCount(0, $reservation->find(['reservationitems_id' => $item->getID()])); + } + + public function testCreatingWaitingRequestRaisesCreatedNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + + $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-01 09:00:00', + 'end' => '2026-05-01 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $queue_after = countElementsInTable('glpi_queuednotifications'); + $this->assertGreaterThan($queue_before, $queue_after); + } + + public function testCreatingWaitingRequestWithDisablenotifFlagDoesNotRaiseCreatedNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + + $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-07 09:00:00', + 'end' => '2026-05-07 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + '_disablenotif' => true, + ]); + + $queue_after = countElementsInTable('glpi_queuednotifications'); + $this->assertSame($queue_before, $queue_after); + } + + public function testCreatingAlreadyAcceptedRequestDoesNotRaiseCreatedNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + + $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-02 09:00:00', + 'end' => '2026-05-02 10:00:00', + 'status' => TicketReservationRequest::STATUS_ACCEPTED, + ]); + + $queue_after = countElementsInTable('glpi_queuednotifications'); + $this->assertSame($queue_before, $queue_after); + } + + public function testApproveRaisesApprovedNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_approved'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-03 09:00:00', + 'end' => '2026-05-03 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + $this->assertTrue($request->approve($requester_id, 'ok')); + $queue_after = countElementsInTable('glpi_queuednotifications'); + + $this->assertGreaterThan($queue_before, $queue_after); + } + + public function testApproveFailureDoesNotRaiseApprovedNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_approved'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-04 09:00:00', + 'end' => '2026-05-04 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $conflicting = new Reservation(); + $this->assertGreaterThan(0, $conflicting->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-05-04 09:00:00', + 'end' => '2026-05-04 10:00:00', + 'users_id' => $requester_id, + 'comment' => '', + ])); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + $this->assertFalse($request->approve($requester_id, 'ok')); + $this->hasSessionMessages(ERROR, ['The required item is already reserved for this timeframe']); + $queue_after = countElementsInTable('glpi_queuednotifications'); + + $this->assertSame($queue_before, $queue_after); + } + + public function testRefuseRaisesRefusedNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_refused'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-05 09:00:00', + 'end' => '2026-05-05 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + $this->assertTrue($request->refuse($requester_id, 'nope')); + $queue_after = countElementsInTable('glpi_queuednotifications'); + + $this->assertGreaterThan($queue_before, $queue_after); + } + + public function testMarkUnavailableRaisesSlotUnavailableNotification(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_slot_unavailable'); + + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => $requester_id, + 'begin' => '2026-05-06 09:00:00', + 'end' => '2026-05-06 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $queue_before = countElementsInTable('glpi_queuednotifications'); + $this->assertTrue($request->markUnavailable()); + $queue_after = countElementsInTable('glpi_queuednotifications'); + + $this->assertGreaterThan($queue_before, $queue_after); + } + + public function testCanAnswerTrueWhenWaitingAndUserCanUpdateTicket(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-06 09:00:00', + 'end' => '2026-03-06 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $this->assertTrue($request->canAnswer()); + } + + public function testCanAnswerFalseWhenNotWaiting(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-07 09:00:00', + 'end' => '2026-03-07 10:00:00', + 'status' => TicketReservationRequest::STATUS_ACCEPTED, + ]); + + $this->assertFalse($request->canAnswer()); + } + + public function testCanAnswerFalseWhenSessionLacksTicketUpdateRight(): void + { + // Ticket is created by the default logged in user (requester). + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-08 09:00:00', + 'end' => '2026-03-08 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + // Switch to a "post-only" session: this user is not the ticket's requester + $this->login('post-only', 'postonly'); + $current_ticket = new Ticket(); + $this->assertTrue($current_ticket->getFromDB($ticket->getID())); + $this->assertFalse($current_ticket->canUpdateItem()); + + $this->assertFalse($request->canAnswer()); + } + + public function testGetTimelineInfo(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-03-09 09:00:00', + 'end' => '2026-03-09 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $info = $request->getTimelineInfo(); + + $this->assertSame($request->getID(), $info['id']); + $this->assertSame(TicketReservationRequest::STATUS_WAITING, $info['status']); + $this->assertSame($item->getID(), $info['reservationitems_id']); + $this->assertSame('2026-03-09 09:00:00', $info['begin']); + $this->assertSame('2026-03-09 10:00:00', $info['end']); + $this->assertTrue($info['can_answer']); + $this->assertFalse($info['is_direct_reservation']); + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', ['name' => 'test-computer', 'entities_id' => 0]); + return $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } + + private function giveUserADefaultEmail(int $users_id): void + { + $this->createItem(UserEmail::class, [ + 'users_id' => $users_id, + 'email' => 'reservationrequest-test-' . $users_id . '@example.com', + 'is_default' => 1, + ]); + } + + private function activateReservationRequestNotification(string $event): void + { + global $CFG_GLPI; + + $CFG_GLPI['use_notifications'] = 1; + $CFG_GLPI['notifications_mailing'] = 1; + + $notification = $this->createItem(Notification::class, [ + 'name' => 'test-' . $event, + 'itemtype' => TicketReservationRequest::class, + 'event' => $event, + 'entities_id' => 0, + 'is_recursive' => 1, + 'is_active' => 1, + ]); + + $template = $this->createItem(NotificationTemplate::class, [ + 'name' => 'test-template-' . $event, + 'itemtype' => TicketReservationRequest::class, + ]); + + $this->createItem(NotificationTemplateTranslation::class, [ + 'notificationtemplates_id' => $template->getID(), + 'language' => '', + 'subject' => 'test subject', + 'content_text' => 'test content', + 'content_html' => '', + ]); + + $this->createItem(Notification_NotificationTemplate::class, [ + 'notifications_id' => $notification->getID(), + 'mode' => Notification_NotificationTemplate::MODE_MAIL, + 'notificationtemplates_id' => $template->getID(), + ]); + + $this->createItem(NotificationTarget::class, [ + 'notifications_id' => $notification->getID(), + 'type' => Notification::USER_TYPE, + 'items_id' => Notification::AUTHOR, + ]); + } +} diff --git a/tests/ReservationWorkflowTest.php b/tests/ReservationWorkflowTest.php new file mode 100644 index 0000000..db88989 --- /dev/null +++ b/tests/ReservationWorkflowTest.php @@ -0,0 +1,422 @@ +login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + $this->activateReservationRequestNotification('reservation_request_approved'); + + $queue_before_submission = countElementsInTable('glpi_queuednotifications'); + + [$ticket, $item] = $this->submitReservationForm(require_approval: true); + $this->assertGreaterThan(0, $ticket->getID()); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertTrue($request->getFromDB((int) array_key_first($rows))); + $this->assertSame(TicketReservationRequest::STATUS_WAITING, (int) $request->fields['status']); + + $queue_after_submission = countElementsInTable('glpi_queuednotifications'); + $this->assertGreaterThan($queue_before_submission, $queue_after_submission); + + // Approve via the real HTTP controller, as a different (technician) user. + $this->login('glpi', 'glpi'); + $this->assertNotSame($requester_id, Session::getLoginUserID()); + + $controller = new ReservationRequestController(); + $response = $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'approve', + 'comment' => 'ok', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['success' => true], json_decode((string) $response->getContent(), true)); + + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_ACCEPTED, (int) $request->fields['status']); + + // Reservation must be attributed to the requester, not the approving technician. + $reservation = new Reservation(); + $found = $reservation->find(['reservationitems_id' => $item->getID()]); + $this->assertCount(1, $found); + $this->assertSame($requester_id, (int) reset($found)['users_id']); + + $queue_after_approval = countElementsInTable('glpi_queuednotifications'); + $this->assertGreaterThan($queue_after_submission, $queue_after_approval); + } + + public function testFullRefusalFlow(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + $this->activateReservationRequestNotification('reservation_request_refused'); + + [$ticket, $item] = $this->submitReservationForm(require_approval: true); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertTrue($request->getFromDB((int) array_key_first($rows))); + + $queue_before_refusal = countElementsInTable('glpi_queuednotifications'); + + $this->login('glpi', 'glpi'); + $controller = new ReservationRequestController(); + $response = $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'refuse', + 'comment' => 'no', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['success' => true], json_decode((string) $response->getContent(), true)); + + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_REFUSED, (int) $request->fields['status']); + + $reservation = new Reservation(); + $this->assertCount(0, $reservation->find(['reservationitems_id' => $item->getID()])); + + $queue_after_refusal = countElementsInTable('glpi_queuednotifications'); + $this->assertGreaterThan($queue_before_refusal, $queue_after_refusal); + } + + public function testDirectModeSlotFreeAutoApproves(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + + [$ticket, $item] = $this->submitReservationForm(require_approval: false); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $row = reset($rows); + $this->assertSame(TicketReservationRequest::STATUS_ACCEPTED, (int) $row['status']); + // No technician ever answered this request: it was auto-approved. + $this->assertSame(0, (int) $row['users_id_validate']); + + $reservation = new Reservation(); + $found = $reservation->find(['reservationitems_id' => $item->getID()]); + $this->assertCount(1, $found); + $this->assertSame($requester_id, (int) reset($found)['users_id']); + } + + public function testDirectModeSlotConflictingCancelsAndNotifiesRequester(): void + { + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_slot_unavailable'); + + $item = $this->getReservableItem(); + + // Seed a conflicting reservation for the same slot before submitting. + $conflict = new Reservation(); + $this->assertGreaterThan(0, $conflict->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-07-01 09:00:00', + 'end' => '2026-07-01 11:00:00', + 'users_id' => $requester_id, + 'comment' => '', + ])); + + $queue_before_submission = countElementsInTable('glpi_queuednotifications'); + + [$ticket] = $this->submitReservationForm( + require_approval: false, + item: $item, + begin: '2026-07-01 10:00:00', + end: '2026-07-01 12:00:00', + ); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertSame(TicketReservationRequest::STATUS_CANCELED, (int) reset($rows)['status']); + + // Only the pre-existing conflicting reservation must exist: no new + // Reservation was created for this request. + $reservation_found = (new Reservation())->find(['reservationitems_id' => $item->getID()]); + $this->assertCount(1, $reservation_found); + $this->assertSame('2026-07-01 09:00:00', reset($reservation_found)['begin']); + + $queue_after_submission = countElementsInTable('glpi_queuednotifications'); + $this->assertGreaterThan($queue_before_submission, $queue_after_submission); + } + + public function testDirectModeSlotFreeDoesNotRaiseCreatedNotification(): void + { + // Regression: direct mode must not also fire the "please wait for approval" event. + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + + $queue_before_submission = $this->countReservationRequestQueuedNotifications('reservation_request_created'); + + [$ticket] = $this->submitReservationForm(require_approval: false); + $this->assertGreaterThan(0, $ticket->getID()); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertSame(TicketReservationRequest::STATUS_ACCEPTED, (int) reset($rows)['status']); + + $queue_after_submission = $this->countReservationRequestQueuedNotifications('reservation_request_created'); + $this->assertSame($queue_before_submission, $queue_after_submission); + } + + public function testDirectModeSlotConflictingDoesNotRaiseCreatedNotification(): void + { + // Same regression, for the markUnavailable() branch. + $this->login(); + $requester_id = Session::getLoginUserID(); + $this->giveUserADefaultEmail($requester_id); + $this->activateReservationRequestNotification('reservation_request_created'); + + $item = $this->getReservableItem(); + + $conflict = new Reservation(); + $this->assertGreaterThan(0, $conflict->add([ + 'reservationitems_id' => $item->getID(), + 'begin' => '2026-07-03 09:00:00', + 'end' => '2026-07-03 11:00:00', + 'users_id' => $requester_id, + 'comment' => '', + ])); + + $queue_before_submission = $this->countReservationRequestQueuedNotifications('reservation_request_created'); + + [$ticket] = $this->submitReservationForm( + require_approval: false, + item: $item, + begin: '2026-07-03 10:00:00', + end: '2026-07-03 12:00:00', + ); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertSame(TicketReservationRequest::STATUS_CANCELED, (int) reset($rows)['status']); + + $queue_after_submission = $this->countReservationRequestQueuedNotifications('reservation_request_created'); + $this->assertSame($queue_before_submission, $queue_after_submission); + } + + public function testApproveDeniedWithoutTicketUpdateRightLeavesRequestUnchanged(): void + { + [$ticket] = $this->submitReservationForm(require_approval: true); + + $request = new TicketReservationRequest(); + $rows = $request->find(['tickets_id' => $ticket->getID()]); + $this->assertCount(1, $rows); + $this->assertTrue($request->getFromDB((int) array_key_first($rows))); + + // "post-only" is not the ticket's requester and uses the helpdesk interface. + $this->login('post-only', 'postonly'); + + $controller = new ReservationRequestController(); + $denied = false; + try { + $controller(Request::create('/', 'POST', [ + 'id' => $request->getID(), + 'action' => 'approve', + ])); + } catch (AccessDeniedHttpException) { + $denied = true; + } + + $this->assertTrue($denied, 'Expected an AccessDeniedHttpException to be thrown.'); + + // The denial must be a real gate: the request itself stays untouched. + $this->assertTrue($request->getFromDB($request->getID())); + $this->assertSame(TicketReservationRequest::STATUS_WAITING, (int) $request->fields['status']); + } + + /** Counts queued notifications for TicketReservationRequest only, ignoring unrelated core ones (New ticket, etc). */ + private function countReservationRequestQueuedNotifications(?string $event = null): int + { + $criteria = ['itemtype' => TicketReservationRequest::class]; + if ($event !== null) { + $criteria['event'] = $event; + } + + return countElementsInTable('glpi_queuednotifications', $criteria); + } + + /** @return array{0: Ticket, 1: ReservationItem} */ + private function submitReservationForm( + bool $require_approval, + ?ReservationItem $item = null, + string $begin = '2026-07-02 09:00:00', + string $end = '2026-07-02 10:00:00', + ): array { + $this->login(); + $this->enableConfigurableItem(new ReservationQuestion()); + + $item ??= $this->getReservableItem(); + + $builder = new FormBuilder("Reservation workflow form"); + $builder->addQuestion("Reservation", ReservationQuestion::class); + + $form = $this->createForm($builder); + + $question_id = $this->getQuestionId($form, "Reservation"); + + $config = new PreReservationFieldConfig( + strategy: PreReservationFieldStrategy::FROM_SPECIFIC_QUESTION, + question_id: $question_id, + require_approval: $require_approval, + ); + + $destinations = $form->getDestinations(); + $this->assertCount(1, $destinations); + $destination = current($destinations); + $this->updateItem( + $destination::getType(), + $destination->getId(), + ['config' => [PreReservationField::getKey() => $config->jsonSerialize()]], + ['config'], + ); + + $ticket = $this->sendFormAndGetCreatedTicket($form, [ + "Reservation" => [ + 'reservationitems_id' => $item->getID(), + 'begin' => $begin, + 'end' => $end, + ], + ]); + + return [$ticket, $item]; + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', [ + 'name' => 'reservationworkflow-test-computer', + 'entities_id' => $this->getTestRootEntity(true), + ]); + + return $this->createItem(ReservationItem::class, [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } + + /** NotificationTarget silently drops recipients without a resolvable email. */ + private function giveUserADefaultEmail(int $users_id): void + { + $this->createItem(UserEmail::class, [ + 'users_id' => $users_id, + 'email' => 'reservationworkflow-test-' . $users_id . '@example.com', + 'is_default' => 1, + ]); + } + + /** This plugin ships no notification config of its own; tests must register their own. */ + private function activateReservationRequestNotification(string $event): void + { + global $CFG_GLPI; + + $CFG_GLPI['use_notifications'] = 1; + $CFG_GLPI['notifications_mailing'] = 1; + + $notification = $this->createItem(Notification::class, [ + 'name' => 'test-' . $event, + 'itemtype' => TicketReservationRequest::class, + 'event' => $event, + 'entities_id' => 0, + 'is_recursive' => 1, + 'is_active' => 1, + ]); + + $template = $this->createItem(NotificationTemplate::class, [ + 'name' => 'test-template-' . $event, + 'itemtype' => TicketReservationRequest::class, + ]); + + $this->createItem(NotificationTemplateTranslation::class, [ + 'notificationtemplates_id' => $template->getID(), + 'language' => '', + 'subject' => 'test subject', + 'content_text' => 'test content', + 'content_html' => '', + ]); + + $this->createItem(Notification_NotificationTemplate::class, [ + 'notifications_id' => $notification->getID(), + 'mode' => Notification_NotificationTemplate::MODE_MAIL, + 'notificationtemplates_id' => $template->getID(), + ]); + + $this->createItem(NotificationTarget::class, [ + 'notifications_id' => $notification->getID(), + 'type' => Notification::USER_TYPE, + 'items_id' => Notification::AUTHOR, + ]); + } +} diff --git a/tests/Service/ConfigManagerTest.php b/tests/Service/ConfigManagerTest.php index 7857742..7837b67 100644 --- a/tests/Service/ConfigManagerTest.php +++ b/tests/Service/ConfigManagerTest.php @@ -35,6 +35,7 @@ use DOMElement; use GlpiPlugin\Advancedforms\Model\Config\ConfigurableItemInterface; +use GlpiPlugin\Advancedforms\Model\QuestionType\ReservationQuestion; use GlpiPlugin\Advancedforms\Service\ConfigManager; use GlpiPlugin\Advancedforms\Tests\AdvancedFormsTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -43,6 +44,15 @@ final class ConfigManagerTest extends AdvancedFormsTestCase { + public function testReservationQuestionIsConfigurable(): void + { + $types = array_map( + fn($t): string => $t::class, + ConfigManager::getInstance()->getConfigurableQuestionTypes(), + ); + $this->assertContains(ReservationQuestion::class, $types); + } + #[DataProvider('provideQuestionTypes')] public function testQuestionTypeConfigFormWhenEnabled( ConfigurableItemInterface $item, diff --git a/tests/Service/InstallManagerTest.php b/tests/Service/InstallManagerTest.php index 3ffc3bf..a58b060 100644 --- a/tests/Service/InstallManagerTest.php +++ b/tests/Service/InstallManagerTest.php @@ -34,8 +34,11 @@ namespace GlpiPlugin\Advancedforms\Tests\Service; use Config; +use GlpiPlugin\Advancedforms\Model\TicketReservationRequest; use GlpiPlugin\Advancedforms\Service\InstallManager; use GlpiPlugin\Advancedforms\Tests\AdvancedFormsTestCase; +use Notification; +use NotificationTemplate; final class InstallManagerTest extends AdvancedFormsTestCase { @@ -49,13 +52,61 @@ public function testUninstallRemoveConfig(): void Config::setConfigurationValues('advancedforms', $config_values); - // Act: uninstall plugin $config_before = Config::getConfigurationValues('advancedforms'); - InstallManager::getInstance()->uninstall(); - $config_after = Config::getConfigurationValues('advancedforms'); - - // Assert: config should be empty after uninstallation $this->assertNotEmpty($config_before); - $this->assertEmpty($config_after); + } + + public function testInstallCreatesTicketReservationRequestsTable(): void + { + global $DB; + + $this->assertTrue(InstallManager::getInstance()->install()); + $this->assertTrue($DB->tableExists(TicketReservationRequest::getTable())); + } + + public function testInstallIsIdempotent(): void + { + $this->assertTrue(InstallManager::getInstance()->install()); + $this->assertTrue(InstallManager::getInstance()->install()); + } + + public function testInstallSeedsReservationNotifications(): void + { + $this->assertTrue(InstallManager::getInstance()->install()); + + $itemtype = TicketReservationRequest::class; + + $events = [ + 'reservation_request_created', + 'reservation_request_approved', + 'reservation_request_refused', + 'reservation_request_slot_unavailable', + ]; + foreach ($events as $event) { + $notification = new Notification(); + $this->assertTrue( + $notification->getFromDBByCrit(['itemtype' => $itemtype, 'event' => $event]), + 'Missing seeded notification for event ' . $event, + ); + $this->assertSame(1, (int) $notification->fields['is_active']); + } + + // A single shared mail template must back those notifications. + $template = new NotificationTemplate(); + $this->assertTrue($template->getFromDBByCrit(['itemtype' => $itemtype])); + } + + public function testInstallNotificationsAreIdempotent(): void + { + $this->assertTrue(InstallManager::getInstance()->install()); + $this->assertTrue(InstallManager::getInstance()->install()); + + $this->assertSame( + 1, + countElementsInTable( + Notification::getTable(), + ['itemtype' => TicketReservationRequest::class, 'event' => 'reservation_request_created'], + ), + ); } } diff --git a/tests/Service/TimelineManagerTest.php b/tests/Service/TimelineManagerTest.php new file mode 100644 index 0000000..cec9860 --- /dev/null +++ b/tests/Service/TimelineManagerTest.php @@ -0,0 +1,344 @@ +login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-01 09:00:00', + 'end' => '2026-05-01 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $this->assertSame(TicketReservationRequest::class, $timeline[$key]['type']); + $this->assertTrue($timeline[$key]['item']['is_content_safe']); + $this->assertStringContainsString( + 'data-reservation-request-id="' . $request->getID() . '"', + $timeline[$key]['item']['content'], + ); + $this->assertStringContainsString( + 'data-reservation-request-action="approve"', + $timeline[$key]['item']['content'], + ); + $this->assertStringContainsString( + 'data-reservation-request-action="refuse"', + $timeline[$key]['item']['content'], + ); + } + + public function testAddTimelineItemsWiresApproveRefuseButtonsToController(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-01 09:30:00', + 'end' => '2026-05-01 10:30:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $content = $timeline[$key]['item']['content']; + + $this->assertStringContainsString('ReservationRequestTimelineActions.js', $content); + $this->assertStringContainsString('data-reservation-request-action="approve"', $content); + } + + public function testAddTimelineItemsHidesApproveRefuseButtonsWhenUserCannotAnswer(): void + { + // Ticket is created by the default logged-in user (requester). + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-01 11:00:00', + 'end' => '2026-05-01 12:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + // "post-only" is not the requester and uses the helpdesk interface. + $this->login('post-only', 'postonly'); + $current_ticket = new Ticket(); + $this->assertTrue($current_ticket->getFromDB($ticket->getID())); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $current_ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $this->assertStringNotContainsString( + 'data-reservation-request-action="approve"', + $timeline[$key]['item']['content'], + ); + $this->assertStringNotContainsString( + 'data-reservation-request-action="refuse"', + $timeline[$key]['item']['content'], + ); + } + + public function testAddTimelineItemsShowsDirectReservationMessageWhenAutoAccepted(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-01 13:00:00', + 'end' => '2026-05-01 14:00:00', + 'status' => TicketReservationRequest::STATUS_ACCEPTED, + 'users_id_validate' => 0, + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $content = $timeline[$key]['item']['content']; + $this->assertStringContainsString('confirmed automatically', $content); + $this->assertStringNotContainsString('data-reservation-request-action="approve"', $content); + $this->assertStringNotContainsString('data-reservation-request-action="refuse"', $content); + } + + public function testAddTimelineItemsAddsOneEntryPerRequest(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request_1 = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-02 09:00:00', + 'end' => '2026-05-02 10:00:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + $request_2 = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-02 11:00:00', + 'end' => '2026-05-02 12:00:00', + 'status' => TicketReservationRequest::STATUS_REFUSED, + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key_1 = 'TicketReservationRequest_' . $request_1->getID(); + $key_2 = 'TicketReservationRequest_' . $request_2->getID(); + $this->assertNotSame($key_1, $key_2); + $this->assertArrayHasKey($key_1, $timeline); + $this->assertArrayHasKey($key_2, $timeline); + $this->assertCount(2, $timeline); + } + + public function testAddTimelineItemsRendersStatusBadgeAndLabelForEachStatus(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + // Maps each status to the twig template's expected badge color + // ("badge bg-{color}") and translated label. + $expectations = [ + TicketReservationRequest::STATUS_WAITING => ['warning', 'Pending validation'], + TicketReservationRequest::STATUS_ACCEPTED => ['success', 'Approved'], + TicketReservationRequest::STATUS_REFUSED => ['danger', 'Refused'], + TicketReservationRequest::STATUS_CANCELED => ['secondary', 'Canceled'], + ]; + + $requests = []; + $hour = 9; + foreach (array_keys($expectations) as $status) { + $requests[$status] = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => sprintf('2026-05-03 %02d:00:00', $hour), + 'end' => sprintf('2026-05-03 %02d:00:00', $hour + 1), + 'status' => $status, + // Non-zero on ACCEPTED so this isn't read as a direct + // reservation, which is irrelevant to badge rendering. + 'users_id_validate' => $status === TicketReservationRequest::STATUS_ACCEPTED + ? Session::getLoginUserID() + : 0, + ]); + $hour++; + } + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + foreach ($expectations as $status => [$badge_color, $label]) { + $key = 'TicketReservationRequest_' . $requests[$status]->getID(); + $this->assertArrayHasKey($key, $timeline); + $content = $timeline[$key]['item']['content']; + $this->assertStringContainsString('badge bg-' . $badge_color, $content); + $this->assertStringContainsString($label, $content); + } + } + + public function testAddTimelineItemsShowsSlotUnavailableMessageWhenCanceledIsNotDirectReservation(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-04 09:00:00', + 'end' => '2026-05-04 10:00:00', + 'status' => TicketReservationRequest::STATUS_CANCELED, + // Non-zero: this was an approved/validated request that later + // became unavailable, not an auto-accepted direct reservation. + 'users_id_validate' => Session::getLoginUserID(), + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $content = $timeline[$key]['item']['content']; + $this->assertStringContainsString( + 'This slot is no longer available. Please submit a new reservation request.', + $content, + ); + $this->assertStringNotContainsString('confirmed automatically', $content); + } + + public function testAddTimelineItemsRendersCommentValidationWhenSet(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-05 09:00:00', + 'end' => '2026-05-05 10:00:00', + 'status' => TicketReservationRequest::STATUS_REFUSED, + 'users_id_validate' => Session::getLoginUserID(), + 'comment_validation' => 'Not available for this timeframe, please pick another slot.', + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $this->assertStringContainsString( + 'Not available for this timeframe, please pick another slot.', + $timeline[$key]['item']['content'], + ); + } + + public function testAddTimelineItemsRendersEquipmentNameAndTimeSlot(): void + { + $this->login(); + $ticket = $this->createItem('Ticket', ['name' => 't', 'content' => 'c', 'entities_id' => $this->getTestRootEntity(true)]); + $item = $this->getReservableItem(); + + $request = $this->createItem(TicketReservationRequest::class, [ + 'tickets_id' => $ticket->getID(), + 'reservationitems_id' => $item->getID(), + 'users_id' => Session::getLoginUserID(), + 'begin' => '2026-05-06 09:00:00', + 'end' => '2026-05-06 10:30:00', + 'status' => TicketReservationRequest::STATUS_WAITING, + ]); + + $timeline = []; + TimelineManager::addTimelineItems(['item' => $ticket, 'timeline' => &$timeline]); + + $key = 'TicketReservationRequest_' . $request->getID(); + $this->assertArrayHasKey($key, $timeline); + $content = $timeline[$key]['item']['content']; + $this->assertStringContainsString('test-computer', $content); + $this->assertStringContainsString('2026-05-06 09:00:00', $content); + $this->assertStringContainsString('2026-05-06 10:30:00', $content); + } + + private function getReservableItem(): ReservationItem + { + $computer = $this->createItem('Computer', ['name' => 'test-computer', 'entities_id' => $this->getTestRootEntity(true)]); + return $this->createItem('ReservationItem', [ + 'itemtype' => 'Computer', + 'items_id' => $computer->getID(), + 'is_active' => 1, + ]); + } +}