1.add appointment feature;
2.add new api for creation,query and cancel.
3.add new table for appointment.
This commit is contained in:
Bastriver 2026-01-28 11:39:31 +08:00
parent ab1d5364a0
commit c009d77305
17 changed files with 1765 additions and 0 deletions

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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");
}
}
}

View file

@ -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
}

View file

@ -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;

View 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);

View 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)
);

View 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);

View file

@ -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"));
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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