diff --git a/appinfo/routes.php b/appinfo/routes.php index ad4d168801..7871b149e2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -23,7 +23,8 @@ ['name' => 'appointment#show', 'url' => '/appointment/{token}', 'verb' => 'GET'], ['name' => 'booking#getBookableSlots', 'url' => '/appointment/{appointmentConfigToken}/slots', 'verb' => 'GET'], ['name' => 'booking#bookSlot', 'url' => '/appointment/{appointmentConfigToken}/book', 'verb' => 'POST'], - ['name' => 'booking#confirmBooking', 'url' => '/appointment/confirm/{token}', 'verb' => 'GET'], + ['name' => 'booking#showConfirmBooking', 'url' => '/appointment/confirm/{token}', 'verb' => 'GET'], + ['name' => 'booking#confirmBooking', 'url' => '/appointment/confirm/{token}', 'verb' => 'POST'], // Public views ['name' => 'publicView#public_index_with_branding', 'url' => '/p/{token}', 'verb' => 'GET'], ['name' => 'publicView#public_index_with_branding', 'url' => '/p/{token}/{view}/{timeRange}', 'verb' => 'GET', 'postfix' => 'publicview.timerange'], diff --git a/lib/Controller/BookingController.php b/lib/Controller/BookingController.php index 567a6f3c25..234b143efc 100644 --- a/lib/Controller/BookingController.php +++ b/lib/Controller/BookingController.php @@ -202,9 +202,8 @@ public function bookSlot(string $appointmentConfigToken, * * @param string $token * @return TemplateResponse - * @throws Exception */ - public function confirmBooking(string $token): TemplateResponse { + public function showConfirmBooking(string $token): TemplateResponse { try { $booking = $this->bookingService->findByToken($token); } catch (ClientException $e) { @@ -219,7 +218,7 @@ public function confirmBooking(string $token): TemplateResponse { try { $config = $this->appointmentConfigService->findById($booking->getApptConfigId()); - } catch (ServiceException $e) { + } catch (ClientException $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new TemplateResponse( Application::APP_ID, @@ -229,27 +228,56 @@ public function confirmBooking(string $token): TemplateResponse { ); } - $link = $this->urlGenerator->linkToRouteAbsolute('calendar.appointment.show', [ 'token' => $config->getToken() ]); - try { - $booking = $this->bookingService->confirmBooking($booking, $config); - } catch (ClientException $e) { - $this->logger->warning($e->getMessage(), ['exception' => $e]); - } + $link = $this->urlGenerator->linkToRouteAbsolute('calendar.appointment.show', ['token' => $config->getToken()]); - $this->initialState->provideInitialState( - 'appointment-link', - $link - ); - $this->initialState->provideInitialState( - 'booking', - $booking - ); + $this->initialState->provideInitialState('appointment-link', $link); + $this->initialState->provideInitialState('booking', $booking); + $this->initialState->provideInitialState('booking-token', $token); return new TemplateResponse( Application::APP_ID, - 'appointments/booking-conflict', + 'appointments/confirmation', [], TemplateResponse::RENDER_AS_GUEST ); } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @param string $token + * @return JsonResponse + */ + public function confirmBooking(string $token): JsonResponse { + try { + $booking = $this->bookingService->findByToken($token); + } catch (ClientException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return JsonResponse::fail(null, Http::STATUS_NOT_FOUND); + } + + if ($booking->isConfirmed()) { + return JsonResponse::success(['confirmed' => true]); + } + + try { + $config = $this->appointmentConfigService->findById($booking->getApptConfigId()); + } catch (ClientException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return JsonResponse::fail(null, Http::STATUS_NOT_FOUND); + } + + try { + $booking = $this->bookingService->confirmBooking($booking, $config); + } catch (NoSlotFoundException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return JsonResponse::fail('slot_unavailable', Http::STATUS_CONFLICT); + } catch (ClientException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return JsonResponse::fail(null, Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return JsonResponse::success(['confirmed' => $booking->isConfirmed()]); + } } diff --git a/lib/Service/Appointments/BookingService.php b/lib/Service/Appointments/BookingService.php index c739c486bf..27f2d60e88 100644 --- a/lib/Service/Appointments/BookingService.php +++ b/lib/Service/Appointments/BookingService.php @@ -84,13 +84,13 @@ public function __construct(AvailabilityGenerator $availabilityGenerator, } /** - * @throws ClientException|DbException + * @throws NoSlotFoundException|ClientException|DbException */ public function confirmBooking(Booking $booking, AppointmentConfig $config): Booking { $bookingSlot = current($this->getAvailableSlots($config, $booking->getStart(), $booking->getEnd())); if (!$bookingSlot) { - throw new ClientException('Slot for booking is not available any more'); + throw new NoSlotFoundException('Slot for booking is not available any more'); } $tz = new DateTimeZone($booking->getTimezone()); diff --git a/src/appointments/main-confirmation.js b/src/appointments/main-confirmation.js index 89711b833c..14a142e44b 100644 --- a/src/appointments/main-confirmation.js +++ b/src/appointments/main-confirmation.js @@ -11,7 +11,6 @@ import { createApp } from 'vue' import Confirmation from '../views/Appointments/Confirmation.vue' // CSP config for webpack dynamic chunk loading - __webpack_nonce__ = btoa(getRequestToken()) // Correct the root of the app for chunk loading @@ -21,10 +20,14 @@ __webpack_nonce__ = btoa(getRequestToken()) // eslint-disable-next-line __webpack_public_path__ = linkTo('calendar', 'js/') +const link = loadState('calendar', 'appointment-link') const booking = loadState('calendar', 'booking') +const token = loadState('calendar', 'booking-token') const app = createApp(Confirmation, { + link, booking, + token, }) app.config.globalProperties.$t = translate diff --git a/src/appointments/main-conflict.js b/src/appointments/main-conflict.js deleted file mode 100644 index b1a6c3e281..0000000000 --- a/src/appointments/main-conflict.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getRequestToken } from '@nextcloud/auth' -import { loadState } from '@nextcloud/initial-state' -import { translate, translatePlural } from '@nextcloud/l10n' -import { linkTo } from '@nextcloud/router' -import { createApp } from 'vue' -import Conflict from '../views/Appointments/Conflict.vue' - -// CSP config for webpack dynamic chunk loading - -__webpack_nonce__ = btoa(getRequestToken()) - -// Correct the root of the app for chunk loading -// OC.linkTo matches the apps folders -// OC.generateUrl ensure the index.php (or not) -// We do not want the index.php since we're loading files -// eslint-disable-next-line -__webpack_public_path__ = linkTo('calendar', 'js/') - -const link = loadState('calendar', 'appointment-link') -const booking = loadState('calendar', 'booking') - -const app = createApp(Conflict, { - link, - confirmed: booking.confirmed, - start: booking.start, - end: booking.end, -}) - -app.config.globalProperties.$t = translate -app.config.globalProperties.$n = translatePlural -app.config.globalProperties.t = translate -app.config.globalProperties.n = translatePlural - -const conflictInstance = app.mount('#appointment-conflict') - -export default conflictInstance diff --git a/src/views/Appointments/Conflict.vue b/src/views/Appointments/BookingResult.vue similarity index 98% rename from src/views/Appointments/Conflict.vue rename to src/views/Appointments/BookingResult.vue index 2181f57e10..6044e59553 100644 --- a/src/views/Appointments/Conflict.vue +++ b/src/views/Appointments/BookingResult.vue @@ -30,7 +30,7 @@ import moment from '@nextcloud/moment' export default { - name: 'Conflict', + name: 'BookingResult', props: { link: { required: true, diff --git a/src/views/Appointments/Confirmation.vue b/src/views/Appointments/Confirmation.vue index 97d315f133..7ce728f1ca 100644 --- a/src/views/Appointments/Confirmation.vue +++ b/src/views/Appointments/Confirmation.vue @@ -3,40 +3,157 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> + + diff --git a/templates/appointments/booking-conflict.php b/templates/appointments/booking-conflict.php deleted file mode 100644 index 9a6b886146..0000000000 --- a/templates/appointments/booking-conflict.php +++ /dev/null @@ -1,12 +0,0 @@ - - -
diff --git a/tests/php/unit/Controller/BookingControllerTest.php b/tests/php/unit/Controller/BookingControllerTest.php index 15e179945a..56bccb94a5 100644 --- a/tests/php/unit/Controller/BookingControllerTest.php +++ b/tests/php/unit/Controller/BookingControllerTest.php @@ -14,10 +14,14 @@ use OC\URLGenerator; use OCA\Calendar\Db\AppointmentConfig; use OCA\Calendar\Db\Booking; +use OCA\Calendar\Exception\ClientException; use OCA\Calendar\Exception\NoSlotFoundException; use OCA\Calendar\Exception\ServiceException; +use OCA\Calendar\Http\JsonResponse; use OCA\Calendar\Service\Appointments\AppointmentConfigService; use OCA\Calendar\Service\Appointments\BookingService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Calendar\ICalendarQuery; @@ -49,7 +53,7 @@ class BookingControllerTest extends TestCase { /** @var AppointmentConfigService|MockObject */ protected $service; - /** @var AppointmentConfigController */ + /** @var BookingController */ protected $controller; /** @var ITimeFactory|MockObject */ @@ -377,4 +381,191 @@ public function testBookInvalidEmail(): void { $this->controller->bookSlot('abc123', 1, 1, 'Test', $email, 'Test', 'Europe/Berlin'); } + + public function testShowConfirmBookingBookingNotFound(): void { + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->with('tok') + ->willThrowException(new ClientException()); + $this->apptService->expects(self::never()) + ->method('findById'); + $this->initialState->expects(self::never()) + ->method('provideInitialState'); + + $response = $this->controller->showConfirmBooking('tok'); + + self::assertInstanceOf(TemplateResponse::class, $response); + self::assertSame('appointments/404-booking', $response->getTemplateName()); + } + + public function testShowConfirmBookingConfigNotFound(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->willReturn($booking); + $this->apptService->expects(self::once()) + ->method('findById') + ->with(42) + ->willThrowException(new ClientException()); + $this->initialState->expects(self::never()) + ->method('provideInitialState'); + + $response = $this->controller->showConfirmBooking('tok'); + + self::assertInstanceOf(TemplateResponse::class, $response); + self::assertSame('appointments/404-booking', $response->getTemplateName()); + } + + public function testShowConfirmBooking(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $config = new AppointmentConfig(); + $config->setToken('cfg-tok'); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->with('tok') + ->willReturn($booking); + $this->apptService->expects(self::once()) + ->method('findById') + ->with(42) + ->willReturn($config); + $this->urlGenerator->expects(self::once()) + ->method('linkToRouteAbsolute') + ->with('calendar.appointment.show', ['token' => 'cfg-tok']) + ->willReturn('https://example.test/appt'); + $matcher = self::exactly(3); + $this->initialState->expects($matcher) + ->method('provideInitialState') + ->willReturnCallback(function (string $key, $value) use ($matcher, $booking): void { + match ($matcher->numberOfInvocations()) { + 1 => self::assertSame(['appointment-link', 'https://example.test/appt'], [$key, $value]), + 2 => self::assertSame(['booking', $booking], [$key, $value]), + 3 => self::assertSame(['booking-token', 'tok'], [$key, $value]), + }; + }); + + $response = $this->controller->showConfirmBooking('tok'); + + self::assertInstanceOf(TemplateResponse::class, $response); + self::assertSame('appointments/confirmation', $response->getTemplateName()); + } + + public function testConfirmBookingNotFound(): void { + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->with('tok') + ->willThrowException(new ClientException()); + $this->bookingService->expects(self::never()) + ->method('confirmBooking'); + + $response = $this->controller->confirmBooking('tok'); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testConfirmBookingAlreadyConfirmed(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $booking->setConfirmed(true); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->willReturn($booking); + $this->apptService->expects(self::never()) + ->method('findById'); + $this->bookingService->expects(self::never()) + ->method('confirmBooking'); + + $response = $this->controller->confirmBooking('tok'); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame(['status' => 'success', 'data' => ['confirmed' => true]], $response->getData()); + } + + public function testConfirmBookingConfigNotFound(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->willReturn($booking); + $this->apptService->expects(self::once()) + ->method('findById') + ->with(42) + ->willThrowException(new ClientException()); + $this->bookingService->expects(self::never()) + ->method('confirmBooking'); + + $response = $this->controller->confirmBooking('tok'); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testConfirmBookingSlotUnavailable(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $config = new AppointmentConfig(); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->willReturn($booking); + $this->apptService->expects(self::once()) + ->method('findById') + ->willReturn($config); + $this->bookingService->expects(self::once()) + ->method('confirmBooking') + ->with($booking, $config) + ->willThrowException(new NoSlotFoundException()); + + $response = $this->controller->confirmBooking('tok'); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + self::assertSame(['status' => 'fail', 'data' => 'slot_unavailable'], $response->getData()); + } + + public function testConfirmBookingClientError(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $config = new AppointmentConfig(); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->willReturn($booking); + $this->apptService->expects(self::once()) + ->method('findById') + ->willReturn($config); + $this->bookingService->expects(self::once()) + ->method('confirmBooking') + ->willThrowException(new ClientException()); + + $response = $this->controller->confirmBooking('tok'); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus()); + } + + public function testConfirmBooking(): void { + $booking = new Booking(); + $booking->setApptConfigId(42); + $config = new AppointmentConfig(); + $confirmed = new Booking(); + $confirmed->setConfirmed(true); + $this->bookingService->expects(self::once()) + ->method('findByToken') + ->willReturn($booking); + $this->apptService->expects(self::once()) + ->method('findById') + ->willReturn($config); + $this->bookingService->expects(self::once()) + ->method('confirmBooking') + ->with($booking, $config) + ->willReturn($confirmed); + + $response = $this->controller->confirmBooking('tok'); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame(['status' => 'success', 'data' => ['confirmed' => true]], $response->getData()); + } } diff --git a/webpack.config.js b/webpack.config.js index 2ec0a3f9f6..7df79a2880 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,7 +15,6 @@ webpackConfig.entry['contacts-menu'] = path.join(__dirname, 'src', 'contactsMenu // Add appointments entries webpackConfig.entry['appointments-booking'] = path.join(__dirname, 'src', 'appointments/main-booking.js') webpackConfig.entry['appointments-confirmation'] = path.join(__dirname, 'src', 'appointments/main-confirmation.js') -webpackConfig.entry['appointments-conflict'] = path.join(__dirname, 'src', 'appointments/main-conflict.js') webpackConfig.entry['appointments-overview'] = path.join(__dirname, 'src', 'appointments/main-overview.js') webpackConfig.entry['proposal-public'] = path.join(__dirname, 'src', 'proposal-public.ts')