mirror of
https://github.com/spring-projects/spring-petclinic.git
synced 2026-02-04 21:31:11 +00:00
changes:
1.add appointment feature; 2.add new api for creation,query and cancel. 3.add new table for appointment.
This commit is contained in:
parent
ab1d5364a0
commit
c009d77305
17 changed files with 1765 additions and 0 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<AppointmentDto> 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<Page<AppointmentDto>> 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<Appointment> appointments = appointmentService.findAppointments(petId, ownerId, from, to, pageable);
|
||||
Page<AppointmentDto> 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<AppointmentDto> cancelAppointment(@PathVariable("id") Integer id,
|
||||
@RequestParam("ownerId") Integer ownerId) {
|
||||
|
||||
Appointment appointment = appointmentService.cancelAppointment(id, ownerId);
|
||||
AppointmentDto result = AppointmentMapper.toDto(appointment);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<Map<String, Object>> handleValidationExceptions(MethodArgumentNotValidException ex) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("timestamp", LocalDateTime.now());
|
||||
response.put("status", HttpStatus.BAD_REQUEST.value());
|
||||
response.put("error", "Validation Failed");
|
||||
|
||||
Map<String, String> 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<Map<String, Object>> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
|
||||
Map<String, Object> 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<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 <code>Appointment</code> domain objects All method names are
|
||||
* compliant with Spring Data naming conventions.
|
||||
*
|
||||
* @author Spring PetClinic Team
|
||||
*/
|
||||
@Repository
|
||||
public interface AppointmentRepository extends JpaRepository<Appointment, Integer> {
|
||||
|
||||
/**
|
||||
* 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<Appointment> 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<Appointment> 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<Appointment> 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<Appointment> 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<Appointment> 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<Appointment> 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<Appointment> 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<Appointment> findByIdAndStatus(Integer id, AppointmentStatus status);
|
||||
|
||||
}
|
||||
|
|
@ -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<Appointment> 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<Appointment> 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<Appointment> 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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
23
src/main/resources/db/h2/V1__add_appointment.sql
Normal file
23
src/main/resources/db/h2/V1__add_appointment.sql
Normal file
|
|
@ -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);
|
||||
17
src/main/resources/db/mysql/V1__add_appointment.sql
Normal file
17
src/main/resources/db/mysql/V1__add_appointment.sql
Normal file
|
|
@ -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)
|
||||
);
|
||||
19
src/main/resources/db/postgres/V1__add_appointment.sql
Normal file
19
src/main/resources/db/postgres/V1__add_appointment.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<Appointment> page = new PageImpl<>(Arrays.asList(appointment));
|
||||
|
||||
when(appointmentRepository.findByPetId(1, pageable)).thenReturn(page);
|
||||
|
||||
// When
|
||||
Page<Appointment> 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<Appointment> page = new PageImpl<>(Arrays.asList(appointment));
|
||||
|
||||
when(appointmentRepository.findByOwnerId(1, pageable)).thenReturn(page);
|
||||
|
||||
// When
|
||||
Page<Appointment> 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);
|
||||
}
|
||||
|
||||
}
|
||||
9
src/test/resources/application-test.properties
Normal file
9
src/test/resources/application-test.properties
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue