From c009d77305f6b4583e5af748a52975cc4f77617d Mon Sep 17 00:00:00 2001 From: Bastriver <1312395217@qq.com> Date: Wed, 28 Jan 2026 11:39:31 +0800 Subject: [PATCH] changes: 1.add appointment feature; 2.add new api for creation,query and cancel. 3.add new table for appointment. --- .../petclinic/appointment/Appointment.java | 138 ++++++++ .../appointment/AppointmentController.java | 112 ++++++ .../appointment/AppointmentCreateDto.java | 65 ++++ .../petclinic/appointment/AppointmentDto.java | 107 ++++++ .../AppointmentExceptionHandler.java | 91 +++++ .../appointment/AppointmentMapper.java | 72 ++++ .../appointment/AppointmentRepository.java | 134 +++++++ .../appointment/AppointmentService.java | 188 ++++++++++ .../appointment/AppointmentStatus.java | 27 ++ .../petclinic/appointment/package-info.java | 20 ++ .../resources/db/h2/V1__add_appointment.sql | 23 ++ .../db/mysql/V1__add_appointment.sql | 17 + .../db/postgres/V1__add_appointment.sql | 19 + ...AppointmentControllerIntegrationTests.java | 297 ++++++++++++++++ .../AppointmentServiceConcurrentTests.java | 117 +++++++ .../appointment/AppointmentServiceTests.java | 329 ++++++++++++++++++ .../resources/application-test.properties | 9 + 17 files changed, 1765 insertions(+) create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/Appointment.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentController.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentCreateDto.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentDto.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentExceptionHandler.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentMapper.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentRepository.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentService.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/AppointmentStatus.java create mode 100644 src/main/java/org/springframework/samples/petclinic/appointment/package-info.java create mode 100644 src/main/resources/db/h2/V1__add_appointment.sql create mode 100644 src/main/resources/db/mysql/V1__add_appointment.sql create mode 100644 src/main/resources/db/postgres/V1__add_appointment.sql create mode 100644 src/test/java/org/springframework/samples/petclinic/appointment/AppointmentControllerIntegrationTests.java create mode 100644 src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceConcurrentTests.java create mode 100644 src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceTests.java create mode 100644 src/test/resources/application-test.properties diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/Appointment.java b/src/main/java/org/springframework/samples/petclinic/appointment/Appointment.java new file mode 100644 index 000000000..c4d925adf --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/Appointment.java @@ -0,0 +1,138 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; + +import org.springframework.core.style.ToStringCreator; +import org.springframework.samples.petclinic.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import jakarta.validation.constraints.NotNull; + +/** + * Simple JavaBean domain object representing an appointment. + * + * @author Spring PetClinic Team + */ +@Entity +@Table(name = "appointments") +public class Appointment extends BaseEntity { + + @Column(name = "pet_id", nullable = false) + @NotNull + private Integer petId; + + @Column(name = "owner_id", nullable = false) + @NotNull + private Integer ownerId; + + @Column(name = "start_time", nullable = false) + @NotNull + private LocalDateTime startTime; + + @Column(name = "end_time", nullable = false) + @NotNull + private LocalDateTime endTime; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + @NotNull + private AppointmentStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + @NotNull + private LocalDateTime createdAt; + + @Version + private Integer version; + + public Integer getPetId() { + return this.petId; + } + + public void setPetId(Integer petId) { + this.petId = petId; + } + + public Integer getOwnerId() { + return this.ownerId; + } + + public void setOwnerId(Integer ownerId) { + this.ownerId = ownerId; + } + + public LocalDateTime getStartTime() { + return this.startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return this.endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public AppointmentStatus getStatus() { + return this.status; + } + + public void setStatus(AppointmentStatus status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return this.createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Integer getVersion() { + return this.version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("id", this.getId()) + .append("new", this.isNew()) + .append("petId", this.petId) + .append("ownerId", this.ownerId) + .append("startTime", this.startTime) + .append("endTime", this.endTime) + .append("status", this.status) + .append("createdAt", this.createdAt) + .append("version", this.version) + .toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentController.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentController.java new file mode 100644 index 000000000..a00e3f8f6 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentController.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.samples.petclinic.owner.Owner; +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; + +/** + * REST controller for managing appointments. + * + * @author Spring PetClinic Team + */ +@RestController +@RequestMapping("/api/appointments") +public class AppointmentController { + + private final AppointmentService appointmentService; + + private final OwnerRepository ownerRepository; + + public AppointmentController(AppointmentService appointmentService, OwnerRepository ownerRepository) { + this.appointmentService = appointmentService; + this.ownerRepository = ownerRepository; + } + + /** + * Create a new appointment. + * @param appointmentCreateDto the appointment data + * @param ownerId the owner ID (from request parameter or authentication) + * @return the created appointment + */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity createAppointment( + @Valid @RequestBody AppointmentCreateDto appointmentCreateDto, @RequestParam("ownerId") Integer ownerId) { + + // Validate owner exists + ownerRepository.findById(ownerId) + .orElseThrow(() -> new IllegalArgumentException("Owner not found with ID: " + ownerId)); + + Appointment appointment = appointmentService.createAppointment(appointmentCreateDto, ownerId); + AppointmentDto result = AppointmentMapper.toDto(appointment); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + /** + * Get appointments with optional filtering. + * @param petId optional pet ID filter + * @param ownerId optional owner ID filter + * @param from optional start time filter (ISO format) + * @param to optional end time filter (ISO format) + * @param pageable pagination information + * @return page of appointments + */ + @GetMapping + public ResponseEntity> getAppointments(@RequestParam(required = false) Integer petId, + @RequestParam(required = false) Integer ownerId, @RequestParam(required = false) LocalDateTime from, + @RequestParam(required = false) LocalDateTime to, @PageableDefault(size = 20) Pageable pageable) { + + Page appointments = appointmentService.findAppointments(petId, ownerId, from, to, pageable); + Page result = appointments.map(AppointmentMapper::toDto); + return ResponseEntity.ok(result); + } + + /** + * Cancel an appointment. + * @param id the appointment ID + * @param ownerId the owner ID (from request parameter or authentication) + * @return the cancelled appointment + */ + @DeleteMapping("/{id}") + public ResponseEntity cancelAppointment(@PathVariable("id") Integer id, + @RequestParam("ownerId") Integer ownerId) { + + Appointment appointment = appointmentService.cancelAppointment(id, ownerId); + AppointmentDto result = AppointmentMapper.toDto(appointment); + return ResponseEntity.ok(result); + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentCreateDto.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentCreateDto.java new file mode 100644 index 000000000..0ac381020 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentCreateDto.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; + +/** + * DTO for creating a new appointment. + * + * @author Spring PetClinic Team + */ +public class AppointmentCreateDto { + + @NotNull(message = "Pet ID is required") + private Integer petId; + + @NotNull(message = "Start time is required") + @Future(message = "Start time must be in the future") + private LocalDateTime startTime; + + @NotNull(message = "End time is required") + @Future(message = "End time must be in the future") + private LocalDateTime endTime; + + public Integer getPetId() { + return this.petId; + } + + public void setPetId(Integer petId) { + this.petId = petId; + } + + public LocalDateTime getStartTime() { + return this.startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return this.endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentDto.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentDto.java new file mode 100644 index 000000000..394c9701b --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentDto.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; + +/** + * DTO for returning appointment data. + * + * @author Spring PetClinic Team + */ +public class AppointmentDto { + + private Integer id; + + private Integer petId; + + private Integer ownerId; + + private LocalDateTime startTime; + + private LocalDateTime endTime; + + private AppointmentStatus status; + + private LocalDateTime createdAt; + + private Integer version; + + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getPetId() { + return this.petId; + } + + public void setPetId(Integer petId) { + this.petId = petId; + } + + public Integer getOwnerId() { + return this.ownerId; + } + + public void setOwnerId(Integer ownerId) { + this.ownerId = ownerId; + } + + public LocalDateTime getStartTime() { + return this.startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return this.endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public AppointmentStatus getStatus() { + return this.status; + } + + public void setStatus(AppointmentStatus status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return this.createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Integer getVersion() { + return this.version; + } + + public void setVersion(Integer version) { + this.version = version; + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentExceptionHandler.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentExceptionHandler.java new file mode 100644 index 000000000..66631f2d3 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentExceptionHandler.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Global exception handler for appointment-related exceptions. + * + * @author Spring PetClinic Team + */ +@RestControllerAdvice(basePackages = "org.springframework.samples.petclinic.appointment") +public class AppointmentExceptionHandler { + + /** + * Handle validation exceptions. + * @param ex the exception + * @return error response with validation details + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { + Map response = new HashMap<>(); + response.put("timestamp", LocalDateTime.now()); + response.put("status", HttpStatus.BAD_REQUEST.value()); + response.put("error", "Validation Failed"); + + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + response.put("errors", errors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + /** + * Handle data integrity violations (e.g., double booking). + * @param ex the exception + * @return error response with conflict details + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleDataIntegrityViolation(DataIntegrityViolationException ex) { + Map response = new HashMap<>(); + response.put("timestamp", LocalDateTime.now()); + response.put("status", HttpStatus.CONFLICT.value()); + response.put("error", "Conflict"); + response.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + + /** + * Handle illegal argument exceptions. + * @param ex the exception + * @return error response with bad request details + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + Map response = new HashMap<>(); + response.put("timestamp", LocalDateTime.now()); + response.put("status", HttpStatus.BAD_REQUEST.value()); + response.put("error", "Bad Request"); + response.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentMapper.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentMapper.java new file mode 100644 index 000000000..d0439dca7 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentMapper.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +/** + * Mapper for converting between Appointment entity and DTOs. + * + * @author Spring PetClinic Team + */ +public final class AppointmentMapper { + + private AppointmentMapper() { + // Utility class + } + + /** + * Convert Appointment entity to AppointmentDto. + * @param appointment the entity + * @return the DTO + */ + public static AppointmentDto toDto(Appointment appointment) { + if (appointment == null) { + return null; + } + + AppointmentDto dto = new AppointmentDto(); + dto.setId(appointment.getId()); + dto.setPetId(appointment.getPetId()); + dto.setOwnerId(appointment.getOwnerId()); + dto.setStartTime(appointment.getStartTime()); + dto.setEndTime(appointment.getEndTime()); + dto.setStatus(appointment.getStatus()); + dto.setCreatedAt(appointment.getCreatedAt()); + dto.setVersion(appointment.getVersion()); + return dto; + } + + /** + * Convert AppointmentCreateDto to Appointment entity. + * @param dto the create DTO + * @param ownerId the owner ID + * @return the entity + */ + public static Appointment toEntity(AppointmentCreateDto dto, Integer ownerId) { + if (dto == null) { + return null; + } + + Appointment appointment = new Appointment(); + appointment.setPetId(dto.getPetId()); + appointment.setOwnerId(ownerId); + appointment.setStartTime(dto.getStartTime()); + appointment.setEndTime(dto.getEndTime()); + appointment.setStatus(AppointmentStatus.SCHEDULED); + appointment.setCreatedAt(java.time.LocalDateTime.now()); + return appointment; + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentRepository.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentRepository.java new file mode 100644 index 000000000..4b88352e1 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentRepository.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import jakarta.persistence.LockModeType; + +/** + * Repository class for Appointment domain objects All method names are + * compliant with Spring Data naming conventions. + * + * @author Spring PetClinic Team + */ +@Repository +public interface AppointmentRepository extends JpaRepository { + + /** + * Find overlapping appointments for a pet within a time range. This method uses + * pessimistic locking to prevent concurrent bookings. + * @param petId the pet ID + * @param startTime the start time of the appointment + * @param endTime the end time of the appointment + * @param excludeId the ID to exclude (for updates) + * @return list of overlapping appointments + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM Appointment a WHERE a.petId = :petId " + "AND a.status = 'SCHEDULED' " + + "AND a.id != :excludeId " + "AND ((a.startTime <= :startTime AND a.endTime > :startTime) " + + "OR (a.startTime < :endTime AND a.endTime >= :endTime) " + + "OR (a.startTime >= :startTime AND a.endTime <= :endTime))") + List findOverlappingAppointments(@Param("petId") Integer petId, + @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, + @Param("excludeId") Integer excludeId); + + /** + * Find overlapping appointments for a pet within a time range (for new appointments). + * @param petId the pet ID + * @param startTime the start time of the appointment + * @param endTime the end time of the appointment + * @return list of overlapping appointments + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM Appointment a WHERE a.petId = :petId " + "AND a.status = 'SCHEDULED' " + + "AND ((a.startTime <= :startTime AND a.endTime > :startTime) " + + "OR (a.startTime < :endTime AND a.endTime >= :endTime) " + + "OR (a.startTime >= :startTime AND a.endTime <= :endTime))") + List findOverlappingAppointments(@Param("petId") Integer petId, + @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + + /** + * Find appointments by pet ID with pagination. + * @param petId the pet ID + * @param pageable pagination information + * @return page of appointments + */ + Page findByPetId(Integer petId, Pageable pageable); + + /** + * Find appointments by owner ID with pagination. + * @param ownerId the owner ID + * @param pageable pagination information + * @return page of appointments + */ + Page findByOwnerId(Integer ownerId, Pageable pageable); + + /** + * Find appointments by time range with pagination. + * @param startTime the start time + * @param endTime the end time + * @param pageable pagination information + * @return page of appointments + */ + @Query("SELECT a FROM Appointment a WHERE a.startTime >= :startTime AND a.endTime <= :endTime") + Page findByTimeRange(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, Pageable pageable); + + /** + * Find appointments by pet ID and time range with pagination. + * @param petId the pet ID + * @param startTime the start time + * @param endTime the end time + * @param pageable pagination information + * @return page of appointments + */ + @Query("SELECT a FROM Appointment a WHERE a.petId = :petId " + + "AND a.startTime >= :startTime AND a.endTime <= :endTime") + Page findByPetIdAndTimeRange(@Param("petId") Integer petId, + @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, Pageable pageable); + + /** + * Find appointments by owner ID and time range with pagination. + * @param ownerId the owner ID + * @param startTime the start time + * @param endTime the end time + * @param pageable pagination information + * @return page of appointments + */ + @Query("SELECT a FROM Appointment a WHERE a.ownerId = :ownerId " + + "AND a.startTime >= :startTime AND a.endTime <= :endTime") + Page findByOwnerIdAndTimeRange(@Param("ownerId") Integer ownerId, + @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, Pageable pageable); + + /** + * Find a scheduled appointment by ID. + * @param id the appointment ID + * @return optional containing the appointment if found and scheduled + */ + Optional findByIdAndStatus(Integer id, AppointmentStatus status); + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentService.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentService.java new file mode 100644 index 000000000..069140b0b --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentService.java @@ -0,0 +1,188 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.samples.petclinic.owner.Owner; +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.samples.petclinic.owner.Pet; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +/** + * Service class for managing appointments. + * + * @author Spring PetClinic Team + */ +@Service +public class AppointmentService { + + private final AppointmentRepository appointmentRepository; + + private final OwnerRepository ownerRepository; + + @Autowired + public AppointmentService(AppointmentRepository appointmentRepository, OwnerRepository ownerRepository) { + this.appointmentRepository = appointmentRepository; + this.ownerRepository = ownerRepository; + } + + /** + * Create a new appointment. This method uses pessimistic locking to prevent + * concurrent bookings. + * @param appointmentCreateDto the appointment creation data + * @param ownerId the owner ID + * @return the created appointment + * @throws IllegalArgumentException if validation fails + * @throws DataIntegrityViolationException if there's a time conflict + */ + @Transactional + public Appointment createAppointment(AppointmentCreateDto appointmentCreateDto, Integer ownerId) { + Assert.notNull(appointmentCreateDto, "Appointment data must not be null"); + Assert.notNull(ownerId, "Owner ID must not be null"); + + // Validate pet ownership + Pet pet = validatePetOwnership(appointmentCreateDto.getPetId(), ownerId); + + // Validate time order + validateTimeOrder(appointmentCreateDto.getStartTime(), appointmentCreateDto.getEndTime()); + + // Check for overlapping appointments using pessimistic locking + List overlappingAppointments = appointmentRepository.findOverlappingAppointments( + appointmentCreateDto.getPetId(), appointmentCreateDto.getStartTime(), + appointmentCreateDto.getEndTime()); + + if (!overlappingAppointments.isEmpty()) { + throw new DataIntegrityViolationException( + "Time slot is already booked for this pet. Conflicting appointment ID: " + + overlappingAppointments.get(0).getId()); + } + + // Create and save appointment + Appointment appointment = AppointmentMapper.toEntity(appointmentCreateDto, ownerId); + return appointmentRepository.save(appointment); + } + + /** + * Cancel an appointment. + * @param appointmentId the appointment ID + * @param ownerId the owner ID + * @return the cancelled appointment + * @throws IllegalArgumentException if appointment not found or not owned by user + */ + @Transactional + public Appointment cancelAppointment(Integer appointmentId, Integer ownerId) { + Assert.notNull(appointmentId, "Appointment ID must not be null"); + Assert.notNull(ownerId, "Owner ID must not be null"); + + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found with ID: " + appointmentId)); + + if (!appointment.getOwnerId().equals(ownerId)) { + throw new IllegalArgumentException("Appointment does not belong to owner"); + } + + if (appointment.getStatus() != AppointmentStatus.SCHEDULED) { + throw new IllegalArgumentException("Only scheduled appointments can be cancelled"); + } + + appointment.setStatus(AppointmentStatus.CANCELLED); + return appointmentRepository.save(appointment); + } + + /** + * Find appointments with pagination and filtering. + * @param petId optional pet ID filter + * @param ownerId optional owner ID filter + * @param from optional start time filter + * @param to optional end time filter + * @param pageable pagination information + * @return page of appointments + */ + @Transactional(readOnly = true) + public Page findAppointments(Integer petId, Integer ownerId, LocalDateTime from, LocalDateTime to, + Pageable pageable) { + if (petId != null && from != null && to != null) { + return appointmentRepository.findByPetIdAndTimeRange(petId, from, to, pageable); + } + else if (ownerId != null && from != null && to != null) { + return appointmentRepository.findByOwnerIdAndTimeRange(ownerId, from, to, pageable); + } + else if (petId != null) { + return appointmentRepository.findByPetId(petId, pageable); + } + else if (ownerId != null) { + return appointmentRepository.findByOwnerId(ownerId, pageable); + } + else if (from != null && to != null) { + return appointmentRepository.findByTimeRange(from, to, pageable); + } + else { + return appointmentRepository.findAll(pageable); + } + } + + /** + * Find an appointment by ID. + * @param appointmentId the appointment ID + * @return optional containing the appointment + */ + @Transactional(readOnly = true) + public Optional findAppointmentById(Integer appointmentId) { + Assert.notNull(appointmentId, "Appointment ID must not be null"); + return appointmentRepository.findById(appointmentId); + } + + /** + * Validate that a pet belongs to an owner. + * @param petId the pet ID + * @param ownerId the owner ID + * @return the pet if validation passes + * @throws IllegalArgumentException if pet not found or doesn't belong to owner + */ + private Pet validatePetOwnership(Integer petId, Integer ownerId) { + Owner owner = ownerRepository.findById(ownerId) + .orElseThrow(() -> new IllegalArgumentException("Owner not found with ID: " + ownerId)); + + Pet pet = owner.getPet(petId); + if (pet == null) { + throw new IllegalArgumentException("Pet not found with ID: " + petId + " for owner " + ownerId); + } + + return pet; + } + + /** + * Validate that start time is before end time. + * @param startTime the start time + * @param endTime the end time + * @throws IllegalArgumentException if time order is invalid + */ + private void validateTimeOrder(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime.isAfter(endTime) || startTime.equals(endTime)) { + throw new IllegalArgumentException("Start time must be before end time"); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentStatus.java b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentStatus.java new file mode 100644 index 000000000..1d9657a8f --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/AppointmentStatus.java @@ -0,0 +1,27 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +/** + * Enumeration representing the status of an appointment. + * + * @author Spring PetClinic Team + */ +public enum AppointmentStatus { + + SCHEDULED, CANCELLED, COMPLETED + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/appointment/package-info.java b/src/main/java/org/springframework/samples/petclinic/appointment/package-info.java new file mode 100644 index 000000000..0f702f51b --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/appointment/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The classes in this package represent the appointment management layer of the PetClinic + * application. + */ +package org.springframework.samples.petclinic.appointment; \ No newline at end of file diff --git a/src/main/resources/db/h2/V1__add_appointment.sql b/src/main/resources/db/h2/V1__add_appointment.sql new file mode 100644 index 000000000..68cb5f6a2 --- /dev/null +++ b/src/main/resources/db/h2/V1__add_appointment.sql @@ -0,0 +1,23 @@ +-- Create appointments table +CREATE TABLE appointments ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + pet_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version INTEGER DEFAULT 0 +); + +-- Add foreign key constraints +ALTER TABLE appointments ADD CONSTRAINT fk_appointments_pets FOREIGN KEY (pet_id) REFERENCES pets (id); +ALTER TABLE appointments ADD CONSTRAINT fk_appointments_owners FOREIGN KEY (owner_id) REFERENCES owners (id); + +-- Create indexes for performance +CREATE INDEX appointments_pet_id ON appointments (pet_id); +CREATE INDEX appointments_start_time ON appointments (start_time); +CREATE INDEX appointments_pet_time_range ON appointments (pet_id, start_time, end_time); + +-- Add check constraint to ensure end_time is after start_time +ALTER TABLE appointments ADD CONSTRAINT chk_appointment_time_order CHECK (end_time > start_time); \ No newline at end of file diff --git a/src/main/resources/db/mysql/V1__add_appointment.sql b/src/main/resources/db/mysql/V1__add_appointment.sql new file mode 100644 index 000000000..970a9644c --- /dev/null +++ b/src/main/resources/db/mysql/V1__add_appointment.sql @@ -0,0 +1,17 @@ +-- Create appointments table +CREATE TABLE IF NOT EXISTS appointments ( + id INTEGER AUTO_INCREMENT PRIMARY KEY, + pet_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + status VARCHAR(20) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version INTEGER DEFAULT 0, + CONSTRAINT fk_appointments_pets FOREIGN KEY (pet_id) REFERENCES pets (id), + CONSTRAINT fk_appointments_owners FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT chk_appointment_time_order CHECK (end_time > start_time), + INDEX idx_appointments_pet_id (pet_id), + INDEX idx_appointments_start_time (start_time), + INDEX idx_appointments_pet_time_range (pet_id, start_time, end_time) +); \ No newline at end of file diff --git a/src/main/resources/db/postgres/V1__add_appointment.sql b/src/main/resources/db/postgres/V1__add_appointment.sql new file mode 100644 index 000000000..536e5bb9f --- /dev/null +++ b/src/main/resources/db/postgres/V1__add_appointment.sql @@ -0,0 +1,19 @@ +-- Create appointments table +CREATE TABLE IF NOT EXISTS appointments ( + id SERIAL PRIMARY KEY, + pet_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version INTEGER DEFAULT 0, + CONSTRAINT fk_appointments_pets FOREIGN KEY (pet_id) REFERENCES pets (id), + CONSTRAINT fk_appointments_owners FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT chk_appointment_time_order CHECK (end_time > start_time) +); + +-- Create indexes for performance +CREATE INDEX idx_appointments_pet_id ON appointments (pet_id); +CREATE INDEX idx_appointments_start_time ON appointments (start_time); +CREATE INDEX idx_appointments_pet_time_range ON appointments (pet_id, start_time, end_time); \ No newline at end of file diff --git a/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentControllerIntegrationTests.java b/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentControllerIntegrationTests.java new file mode 100644 index 000000000..87f19e154 --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentControllerIntegrationTests.java @@ -0,0 +1,297 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.samples.petclinic.owner.Owner; +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.samples.petclinic.owner.Pet; +import org.springframework.samples.petclinic.owner.PetType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import tools.jackson.databind.ObjectMapper; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integration tests for AppointmentController. + * + * @author Spring PetClinic Team + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AppointmentControllerIntegrationTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private OwnerRepository ownerRepository; + + @Autowired + private AppointmentRepository appointmentRepository; + + private Owner testOwner; + + private Pet testPet; + + private LocalDateTime futureTime; + + @BeforeEach + void setUp() { + // Create test data + testOwner = new Owner(); + testOwner.setFirstName("Test"); + testOwner.setLastName("Owner"); + testOwner.setAddress("123 Test St"); + testOwner.setCity("Test City"); + testOwner.setTelephone("1234567890"); + testOwner = ownerRepository.save(testOwner); + + PetType petType = new PetType(); + petType.setName("dog"); + + testPet = new Pet(); + testPet.setName("Test Pet"); + testPet.setBirthDate(java.time.LocalDate.now().minusYears(1)); + testPet.setType(petType); + testOwner.addPet(testPet); + + ownerRepository.save(testOwner); + + futureTime = LocalDateTime.now().plusDays(1); + } + + @Test + void shouldCreateAppointmentSuccessfully() throws Exception { + // Given + AppointmentCreateDto createDto = new AppointmentCreateDto(); + createDto.setPetId(testPet.getId()); + createDto.setStartTime(futureTime); + createDto.setEndTime(futureTime.plusHours(1)); + + // When & Then + mockMvc + .perform(post("/api/appointments").param("ownerId", testOwner.getId().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.petId").value(testPet.getId())) + .andExpect(jsonPath("$.ownerId").value(testOwner.getId())) + .andExpect(jsonPath("$.status").value("SCHEDULED")) + .andExpect(jsonPath("$.startTime").exists()) + .andExpect(jsonPath("$.endTime").exists()) + .andExpect(jsonPath("$.createdAt").exists()); + } + + @Test + void shouldRejectAppointmentWithInvalidTimeOrder() throws Exception { + // Given + AppointmentCreateDto createDto = new AppointmentCreateDto(); + createDto.setPetId(testPet.getId()); + createDto.setStartTime(futureTime.plusHours(1)); + createDto.setEndTime(futureTime); // End time before start time + + // When & Then + mockMvc + .perform(post("/api/appointments").param("ownerId", testOwner.getId().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Start time must be before end time")); + } + + @Test + void shouldRejectAppointmentWithPastTime() throws Exception { + // Given + LocalDateTime pastTime = LocalDateTime.now().minusHours(1); + AppointmentCreateDto createDto = new AppointmentCreateDto(); + createDto.setPetId(testPet.getId()); + createDto.setStartTime(pastTime); + createDto.setEndTime(pastTime.plusHours(1)); + + // When & Then + mockMvc + .perform(post("/api/appointments").param("ownerId", testOwner.getId().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors.startTime").exists()); + } + + @Test + void shouldRejectAppointmentWhenPetDoesNotBelongToOwner() throws Exception { + // Given - Create another owner and pet + Owner otherOwner = new Owner(); + otherOwner.setFirstName("Other"); + otherOwner.setLastName("Owner"); + otherOwner.setAddress("456 Other St"); + otherOwner.setCity("Other City"); + otherOwner.setTelephone("0987654321"); + otherOwner = ownerRepository.save(otherOwner); + + PetType petType = new PetType(); + petType.setName("cat"); + + Pet otherPet = new Pet(); + otherPet.setName("Other Pet"); + otherPet.setBirthDate(java.time.LocalDate.now().minusYears(2)); + otherPet.setType(petType); + otherOwner.addPet(otherPet); + ownerRepository.save(otherOwner); + + AppointmentCreateDto createDto = new AppointmentCreateDto(); + createDto.setPetId(otherPet.getId()); // Pet belongs to other owner + createDto.setStartTime(futureTime); + createDto.setEndTime(futureTime.plusHours(1)); + + // When & Then + mockMvc.perform(post("/api/appointments").param("ownerId", testOwner.getId().toString()) // Different + // owner + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message") + .value("Pet not found with ID: " + otherPet.getId() + " for owner " + testOwner.getId())); + } + + @Test + void shouldRejectAppointmentWithOverlappingTimeSlot() throws Exception { + // Given - Create an existing appointment + Appointment existingAppointment = new Appointment(); + existingAppointment.setPetId(testPet.getId()); + existingAppointment.setOwnerId(testOwner.getId()); + existingAppointment.setStartTime(futureTime.minusMinutes(30)); + existingAppointment.setEndTime(futureTime.plusMinutes(30)); + existingAppointment.setStatus(AppointmentStatus.SCHEDULED); + existingAppointment.setCreatedAt(LocalDateTime.now()); + appointmentRepository.save(existingAppointment); + + AppointmentCreateDto createDto = new AppointmentCreateDto(); + createDto.setPetId(testPet.getId()); + createDto.setStartTime(futureTime); // Overlaps with existing appointment + createDto.setEndTime(futureTime.plusHours(1)); + + // When & Then + mockMvc + .perform(post("/api/appointments").param("ownerId", testOwner.getId().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("Conflict")); + } + + @Test + void shouldGetAppointmentsWithPagination() throws Exception { + // Given - Create multiple appointments + for (int i = 0; i < 3; i++) { + Appointment appointment = new Appointment(); + appointment.setPetId(testPet.getId()); + appointment.setOwnerId(testOwner.getId()); + appointment.setStartTime(futureTime.plusHours(i)); + appointment.setEndTime(futureTime.plusHours(i + 1)); + appointment.setStatus(AppointmentStatus.SCHEDULED); + appointment.setCreatedAt(LocalDateTime.now()); + appointmentRepository.save(appointment); + } + + // When & Then + mockMvc + .perform(get("/api/appointments").param("ownerId", testOwner.getId().toString()) + .param("page", "0") + .param("size", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.totalElements").value(3)) + .andExpect(jsonPath("$.totalPages").value(2)); + } + + @Test + void shouldCancelAppointmentSuccessfully() throws Exception { + // Given - Create an appointment + Appointment appointment = new Appointment(); + appointment.setPetId(testPet.getId()); + appointment.setOwnerId(testOwner.getId()); + appointment.setStartTime(futureTime); + appointment.setEndTime(futureTime.plusHours(1)); + appointment.setStatus(AppointmentStatus.SCHEDULED); + appointment.setCreatedAt(LocalDateTime.now()); + appointment = appointmentRepository.save(appointment); + + // When & Then + mockMvc + .perform(delete("/api/appointments/{id}", appointment.getId()).param("ownerId", + testOwner.getId().toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(appointment.getId())) + .andExpect(jsonPath("$.status").value("CANCELLED")); + } + + @Test + void shouldRejectCancellationWhenAppointmentDoesNotBelongToOwner() throws Exception { + // Given - Create another owner and appointment + Owner otherOwner = new Owner(); + otherOwner.setFirstName("Other"); + otherOwner.setLastName("Owner"); + otherOwner.setAddress("456 Other St"); + otherOwner.setCity("Other City"); + otherOwner.setTelephone("0987654321"); + otherOwner = ownerRepository.save(otherOwner); + + Appointment appointment = new Appointment(); + appointment.setPetId(testPet.getId()); + appointment.setOwnerId(otherOwner.getId()); + appointment.setStartTime(futureTime); + appointment.setEndTime(futureTime.plusHours(1)); + appointment.setStatus(AppointmentStatus.SCHEDULED); + appointment.setCreatedAt(LocalDateTime.now()); + appointment = appointmentRepository.save(appointment); + + // When & Then + mockMvc + .perform(delete("/api/appointments/{id}", appointment.getId()).param("ownerId", + testOwner.getId().toString())) // Different owner + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Appointment does not belong to owner")); + } + + @Test + void shouldRejectCancellationWhenAppointmentNotFound() throws Exception { + // When & Then + mockMvc.perform(delete("/api/appointments/999").param("ownerId", testOwner.getId().toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Appointment not found with ID: 999")); + } + +} \ No newline at end of file diff --git a/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceConcurrentTests.java b/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceConcurrentTests.java new file mode 100644 index 000000000..d8d5e0544 --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceConcurrentTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.samples.petclinic.owner.Owner; +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.samples.petclinic.owner.Pet; +import org.springframework.samples.petclinic.owner.PetType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Concurrent integration tests for AppointmentService to verify pessimistic locking. + * + * @author Spring PetClinic Team + */ +@SpringBootTest +@ActiveProfiles("test") +class AppointmentServiceConcurrentTests { + + @Autowired + private AppointmentService appointmentService; + + @Autowired + private OwnerRepository ownerRepository; + + private Owner owner; + + @Test + @Transactional + void shouldPreventConcurrentDoubleBooking() throws InterruptedException { + // Given - Create test data + owner = new Owner(); + owner.setFirstName("Concurrent"); + owner.setLastName("Test"); + owner.setAddress("123 Concurrent St"); + owner.setCity("Concurrent City"); + owner.setTelephone("1234567890"); + owner = ownerRepository.save(owner); + + PetType petType = new PetType(); + petType.setName("dog"); + + Pet pet = new Pet(); + pet.setName("Concurrent Pet"); + pet.setBirthDate(java.time.LocalDate.now().minusYears(1)); + pet.setType(petType); + owner.addPet(pet); + owner = ownerRepository.save(owner); + + LocalDateTime appointmentTime = LocalDateTime.now().plusDays(1); + + // When - Try to create multiple appointments concurrently for the same time slot + int threadCount = 5; + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger conflictCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + AppointmentCreateDto createDto = new AppointmentCreateDto(); + createDto.setPetId(pet.getId()); + createDto.setStartTime(appointmentTime); + createDto.setEndTime(appointmentTime.plusHours(1)); + + appointmentService.createAppointment(createDto, owner.getId()); + successCount.incrementAndGet(); + } + catch (DataIntegrityViolationException e) { + conflictCount.incrementAndGet(); + } + catch (Exception e) { + // Other exceptions (shouldn't happen in this test) + } + finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + // Then - Only one appointment should be created successfully + assertThat(successCount.get()).isEqualTo(1); + assertThat(conflictCount.get()).isEqualTo(threadCount - 1); + } + +} \ No newline at end of file diff --git a/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceTests.java b/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceTests.java new file mode 100644 index 000000000..d12cba42d --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/appointment/AppointmentServiceTests.java @@ -0,0 +1,329 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.appointment; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.samples.petclinic.owner.Owner; +import org.springframework.samples.petclinic.owner.OwnerRepository; +import org.springframework.samples.petclinic.owner.Pet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for AppointmentService. + * + * @author Spring PetClinic Team + */ +@ExtendWith(MockitoExtension.class) +class AppointmentServiceTests { + + @Mock + private AppointmentRepository appointmentRepository; + + @Mock + private OwnerRepository ownerRepository; + + @InjectMocks + private AppointmentService appointmentService; + + private Owner testOwner; + + private Pet testPet; + + private AppointmentCreateDto validCreateDto; + + private LocalDateTime now; + + @BeforeEach + void setUp() { + now = LocalDateTime.now().plusDays(1); // Future time + + testOwner = new Owner(); + testOwner.setId(1); + testOwner.setFirstName("John"); + testOwner.setLastName("Doe"); + + testPet = new Pet(); + testPet.setId(1); + testPet.setName("Fluffy"); + testPet.setBirthDate(java.time.LocalDate.now().minusYears(2)); + testOwner.addPet(testPet); + + validCreateDto = new AppointmentCreateDto(); + validCreateDto.setPetId(1); + validCreateDto.setStartTime(now); + validCreateDto.setEndTime(now.plusHours(1)); + } + + @Test + void shouldCreateAppointmentSuccessfully() { + // Given + when(ownerRepository.findById(1)).thenReturn(Optional.of(testOwner)); + when(appointmentRepository.findOverlappingAppointments(eq(1), any(LocalDateTime.class), + any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + Appointment savedAppointment = new Appointment(); + savedAppointment.setId(1); + savedAppointment.setPetId(1); + savedAppointment.setOwnerId(1); + savedAppointment.setStartTime(validCreateDto.getStartTime()); + savedAppointment.setEndTime(validCreateDto.getEndTime()); + savedAppointment.setStatus(AppointmentStatus.SCHEDULED); + + when(appointmentRepository.save(any(Appointment.class))).thenReturn(savedAppointment); + + // When + Appointment result = appointmentService.createAppointment(validCreateDto, 1); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(1); + assertThat(result.getPetId()).isEqualTo(1); + assertThat(result.getOwnerId()).isEqualTo(1); + assertThat(result.getStatus()).isEqualTo(AppointmentStatus.SCHEDULED); + + verify(ownerRepository).findById(1); + verify(appointmentRepository).findOverlappingAppointments(eq(1), any(LocalDateTime.class), + any(LocalDateTime.class)); + verify(appointmentRepository).save(any(Appointment.class)); + } + + @Test + void shouldRejectAppointmentWhenPetNotFound() { + // Given + when(ownerRepository.findById(1)).thenReturn(Optional.of(testOwner)); + + AppointmentCreateDto dto = new AppointmentCreateDto(); + dto.setPetId(999); // Non-existent pet + dto.setStartTime(now); + dto.setEndTime(now.plusHours(1)); + + // When/Then + assertThatThrownBy(() -> appointmentService.createAppointment(dto, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Pet not found with ID: 999 for owner 1"); + + verify(ownerRepository).findById(1); + verify(appointmentRepository, never()).findOverlappingAppointments(any(), any(), any()); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldRejectAppointmentWhenOwnerNotFound() { + // Given + when(ownerRepository.findById(999)).thenReturn(Optional.empty()); + + // When/Then + assertThatThrownBy(() -> appointmentService.createAppointment(validCreateDto, 999)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Owner not found with ID: 999"); + + verify(ownerRepository).findById(999); + verify(appointmentRepository, never()).findOverlappingAppointments(any(), any(), any()); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldRejectAppointmentWhenTimeOrderIsInvalid() { + // Given + AppointmentCreateDto dto = new AppointmentCreateDto(); + dto.setPetId(1); + dto.setStartTime(now.plusHours(1)); + dto.setEndTime(now); // End time before start time + + // When/Then + assertThatThrownBy(() -> appointmentService.createAppointment(dto, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Start time must be before end time"); + + verify(ownerRepository, never()).findById(any()); + verify(appointmentRepository, never()).findOverlappingAppointments(any(), any(), any()); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldRejectAppointmentWhenTimeSlotIsAlreadyBooked() { + // Given + when(ownerRepository.findById(1)).thenReturn(Optional.of(testOwner)); + + Appointment existingAppointment = new Appointment(); + existingAppointment.setId(2); + existingAppointment.setPetId(1); + existingAppointment.setStartTime(now.minusMinutes(30)); + existingAppointment.setEndTime(now.plusMinutes(30)); + existingAppointment.setStatus(AppointmentStatus.SCHEDULED); + + when(appointmentRepository.findOverlappingAppointments(eq(1), any(LocalDateTime.class), + any(LocalDateTime.class))) + .thenReturn(Arrays.asList(existingAppointment)); + + // When/Then + assertThatThrownBy(() -> appointmentService.createAppointment(validCreateDto, 1)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("Time slot is already booked for this pet"); + + verify(ownerRepository).findById(1); + verify(appointmentRepository).findOverlappingAppointments(eq(1), any(LocalDateTime.class), + any(LocalDateTime.class)); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldCancelAppointmentSuccessfully() { + // Given + Appointment appointment = new Appointment(); + appointment.setId(1); + appointment.setPetId(1); + appointment.setOwnerId(1); + appointment.setStatus(AppointmentStatus.SCHEDULED); + + when(appointmentRepository.findById(1)).thenReturn(Optional.of(appointment)); + when(appointmentRepository.save(any(Appointment.class))).thenReturn(appointment); + + // When + Appointment result = appointmentService.cancelAppointment(1, 1); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(AppointmentStatus.CANCELLED); + + verify(appointmentRepository).findById(1); + verify(appointmentRepository).save(appointment); + } + + @Test + void shouldRejectCancellationWhenAppointmentNotFound() { + // Given + when(appointmentRepository.findById(999)).thenReturn(Optional.empty()); + + // When/Then + assertThatThrownBy(() -> appointmentService.cancelAppointment(999, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Appointment not found with ID: 999"); + + verify(appointmentRepository).findById(999); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldRejectCancellationWhenAppointmentDoesNotBelongToOwner() { + // Given + Appointment appointment = new Appointment(); + appointment.setId(1); + appointment.setPetId(1); + appointment.setOwnerId(2); // Different owner + appointment.setStatus(AppointmentStatus.SCHEDULED); + + when(appointmentRepository.findById(1)).thenReturn(Optional.of(appointment)); + + // When/Then + assertThatThrownBy(() -> appointmentService.cancelAppointment(1, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Appointment does not belong to owner"); + + verify(appointmentRepository).findById(1); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldRejectCancellationWhenAppointmentNotScheduled() { + // Given + Appointment appointment = new Appointment(); + appointment.setId(1); + appointment.setPetId(1); + appointment.setOwnerId(1); + appointment.setStatus(AppointmentStatus.CANCELLED); + + when(appointmentRepository.findById(1)).thenReturn(Optional.of(appointment)); + + // When/Then + assertThatThrownBy(() -> appointmentService.cancelAppointment(1, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Only scheduled appointments can be cancelled"); + + verify(appointmentRepository).findById(1); + verify(appointmentRepository, never()).save(any()); + } + + @Test + void shouldFindAppointmentsByPetId() { + // Given + Pageable pageable = PageRequest.of(0, 10); + Appointment appointment = new Appointment(); + appointment.setId(1); + appointment.setPetId(1); + Page page = new PageImpl<>(Arrays.asList(appointment)); + + when(appointmentRepository.findByPetId(1, pageable)).thenReturn(page); + + // When + Page result = appointmentService.findAppointments(1, null, null, null, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getPetId()).isEqualTo(1); + + verify(appointmentRepository).findByPetId(1, pageable); + } + + @Test + void shouldFindAppointmentsByOwnerId() { + // Given + Pageable pageable = PageRequest.of(0, 10); + Appointment appointment = new Appointment(); + appointment.setId(1); + appointment.setOwnerId(1); + Page page = new PageImpl<>(Arrays.asList(appointment)); + + when(appointmentRepository.findByOwnerId(1, pageable)).thenReturn(page); + + // When + Page result = appointmentService.findAppointments(null, 1, null, null, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getOwnerId()).isEqualTo(1); + + verify(appointmentRepository).findByOwnerId(1, pageable); + } + +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 000000000..1964b1534 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,9 @@ +# Test profile configuration +spring.profiles.active=test +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false +spring.h2.console.enabled=true \ No newline at end of file