From e47a2e2f56d0f71f231633699170b9b0ffde7af7 Mon Sep 17 00:00:00 2001 From: Bastriver <1312395217@qq.com> Date: Wed, 28 Jan 2026 11:39:58 +0800 Subject: [PATCH] changes: 1.add appointment feature; 2.add new api for creation,query and cancel. 3.add new table for appointment. --- .../samples/petclinic/owner/Appointment.java | 134 ++++++++++++++++++ .../owner/AppointmentController.java | 91 ++++++++++++ .../petclinic/owner/AppointmentDTO.java | 65 +++++++++ .../petclinic/owner/AppointmentMapper.java | 47 ++++++ .../owner/AppointmentRepository.java | 50 +++++++ .../petclinic/owner/AppointmentService.java | 85 +++++++++++ .../petclinic/owner/AppointmentValidator.java | 46 ++++++ src/main/resources/db/h2/schema.sql | 76 ++-------- src/main/resources/db/mysql/schema.sql | 69 ++------- src/main/resources/db/postgres/schema.sql | 64 ++------- .../owner/AppointmentControllerTests.java | 114 +++++++++++++++ .../owner/AppointmentServiceTests.java | 98 +++++++++++++ 12 files changed, 773 insertions(+), 166 deletions(-) create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/Appointment.java create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/AppointmentController.java create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/AppointmentDTO.java create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/AppointmentMapper.java create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/AppointmentRepository.java create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/AppointmentService.java create mode 100644 src/main/java/org/springframework/samples/petclinic/owner/AppointmentValidator.java create mode 100644 src/test/java/org/springframework/samples/petclinic/owner/AppointmentControllerTests.java create mode 100644 src/test/java/org/springframework/samples/petclinic/owner/AppointmentServiceTests.java diff --git a/src/main/java/org/springframework/samples/petclinic/owner/Appointment.java b/src/main/java/org/springframework/samples/petclinic/owner/Appointment.java new file mode 100644 index 000000000..6b9a190a4 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/Appointment.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.owner; + +import java.time.LocalDateTime; + +import org.springframework.format.annotation.DateTimeFormat; +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.Future; +import jakarta.validation.constraints.NotNull; + +/** + * Simple JavaBean domain object representing an appointment. + * + * @author Your Name + */ +@Entity +@Table(name = "appointments") +public class Appointment extends BaseEntity { + + public enum Status { + + SCHEDULED, CANCELLED, COMPLETED + + } + + @Column(name = "pet_id") + @NotNull + private Integer petId; + + @Column(name = "owner_id") + @NotNull + private Integer ownerId; + + @Column(name = "start_time") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + @NotNull + @Future + private LocalDateTime startTime; + + @Column(name = "end_time") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + @NotNull + @Future + private LocalDateTime endTime; + + @Enumerated(EnumType.STRING) + @NotNull + private Status status = Status.SCHEDULED; + + @Column(name = "created_at") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt = LocalDateTime.now(); + + @Version + private Integer version; + + public Integer getPetId() { + return petId; + } + + public void setPetId(Integer petId) { + this.petId = petId; + } + + public Integer getOwnerId() { + return ownerId; + } + + public void setOwnerId(Integer ownerId) { + this.ownerId = ownerId; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/owner/AppointmentController.java b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentController.java new file mode 100644 index 000000000..d3f16bd13 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentController.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.owner; + +import java.time.LocalDateTime; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +import jakarta.validation.Valid; + +/** + * REST controller for appointment management. + * + * @author Your Name + */ +@RestController +@RequestMapping("/api/appointments") +public class AppointmentController { + + private final AppointmentService appointmentService; + + public AppointmentController(AppointmentService appointmentService) { + this.appointmentService = appointmentService; + } + + @PostMapping + public ResponseEntity createAppointment(@Valid @RequestBody AppointmentDTO dto) { + // In a real application, you would get ownerId from authentication + Integer ownerId = 1; // Hardcoded for demo purposes + try { + Appointment appointment = appointmentService.createAppointment(dto, ownerId); + return new ResponseEntity<>(appointment, HttpStatus.CREATED); + } + catch (IllegalArgumentException e) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + catch (IllegalStateException e) { + return new ResponseEntity<>(HttpStatus.CONFLICT); + } + } + + @GetMapping + public ResponseEntity> getAppointments(@RequestParam(required = false) Integer petId, + @RequestParam(required = false) Integer ownerId, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime to, + Pageable pageable) { + Page appointments = appointmentService.getAppointments(petId, ownerId, from, to, pageable); + return new ResponseEntity<>(appointments, HttpStatus.OK); + } + + @DeleteMapping("/{id}") + public ResponseEntity cancelAppointment(@PathVariable Integer id) { + try { + appointmentService.cancelAppointment(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + catch (IllegalArgumentException e) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + catch (IllegalStateException e) { + return new ResponseEntity<>(HttpStatus.CONFLICT); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/owner/AppointmentDTO.java b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentDTO.java new file mode 100644 index 000000000..077a14e11 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentDTO.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.owner; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; + +/** + * DTO for creating and updating appointments. + * + * @author Your Name + */ +public class AppointmentDTO { + + @NotNull + private Integer petId; + + @NotNull + @Future + private LocalDateTime startTime; + + @NotNull + @Future + private LocalDateTime endTime; + + public Integer getPetId() { + return petId; + } + + public void setPetId(Integer petId) { + this.petId = petId; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/owner/AppointmentMapper.java b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentMapper.java new file mode 100644 index 000000000..84147e25a --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentMapper.java @@ -0,0 +1,47 @@ +/* + * 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.owner; + +import org.springframework.stereotype.Component; + +/** + * Mapper for converting between Appointment entity and DTO. + * + * @author Your Name + */ +@Component +public class AppointmentMapper { + + public Appointment toEntity(AppointmentDTO dto, Integer ownerId) { + Appointment appointment = new Appointment(); + appointment.setPetId(dto.getPetId()); + appointment.setOwnerId(ownerId); + appointment.setStartTime(dto.getStartTime()); + appointment.setEndTime(dto.getEndTime()); + appointment.setStatus(Appointment.Status.SCHEDULED); + appointment.setCreatedAt(LocalDateTime.now()); + return appointment; + } + + public AppointmentDTO toDTO(Appointment appointment) { + AppointmentDTO dto = new AppointmentDTO(); + dto.setPetId(appointment.getPetId()); + dto.setStartTime(appointment.getStartTime()); + dto.setEndTime(appointment.getEndTime()); + return dto; + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/owner/AppointmentRepository.java b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentRepository.java new file mode 100644 index 000000000..675fbc235 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentRepository.java @@ -0,0 +1,50 @@ +/* + * 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.owner; + +import java.time.LocalDateTime; + +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 interface for Appointment entities. + * + * @author Your Name + */ +@Repository +public interface AppointmentRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM Appointment a WHERE a.petId = :petId AND a.status = 'SCHEDULED' AND " + + "((a.startTime < :endTime AND a.endTime > :startTime))") + Page findOverlappingAppointments(@Param("petId") Integer petId, + @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, Pageable pageable); + + @Query("SELECT a FROM Appointment a WHERE (a.petId = :petId OR :petId IS NULL) AND " + + "(a.ownerId = :ownerId OR :ownerId IS NULL) AND " + "(a.startTime >= :from OR :from IS NULL) AND " + + "(a.endTime <= :to OR :to IS NULL)") + Page findByFilters(@Param("petId") Integer petId, @Param("ownerId") Integer ownerId, + @Param("from") LocalDateTime from, @Param("to") LocalDateTime to, Pageable pageable); + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/owner/AppointmentService.java b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentService.java new file mode 100644 index 000000000..275750ba9 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentService.java @@ -0,0 +1,85 @@ +/* + * 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.owner; + +import java.time.LocalDateTime; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.Valid; + +/** + * Service layer for appointment management. + * + * @author Your Name + */ +@Service +@Validated +public class AppointmentService { + + private final AppointmentRepository appointmentRepository; + + private final AppointmentMapper appointmentMapper; + + public AppointmentService(AppointmentRepository appointmentRepository, AppointmentMapper appointmentMapper) { + this.appointmentRepository = appointmentRepository; + this.appointmentMapper = appointmentMapper; + } + + @Transactional + public Appointment createAppointment(@Valid AppointmentDTO dto, Integer ownerId) { + // Validate time order + if (dto.getStartTime().isAfter(dto.getEndTime()) || dto.getStartTime().isEqual(dto.getEndTime())) { + throw new IllegalArgumentException("Start time must be before end time"); + } + + // Check for overlapping appointments with pessimistic lock + Page overlapping = appointmentRepository.findOverlappingAppointments(dto.getPetId(), + dto.getStartTime(), dto.getEndTime(), Pageable.unpaged()); + + if (!overlapping.isEmpty()) { + throw new IllegalStateException("Appointment overlaps with existing scheduled appointment"); + } + + // Create and save appointment + Appointment appointment = appointmentMapper.toEntity(dto, ownerId); + return appointmentRepository.save(appointment); + } + + @Transactional(readOnly = true) + public Page getAppointments(Integer petId, Integer ownerId, LocalDateTime from, LocalDateTime to, + Pageable pageable) { + return appointmentRepository.findByFilters(petId, ownerId, from, to, pageable); + } + + @Transactional + public Appointment cancelAppointment(Integer id) { + Appointment appointment = appointmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + + if (appointment.getStatus() == Appointment.Status.CANCELLED) { + throw new IllegalStateException("Appointment is already cancelled"); + } + + appointment.setStatus(Appointment.Status.CANCELLED); + return appointmentRepository.save(appointment); + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/samples/petclinic/owner/AppointmentValidator.java b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentValidator.java new file mode 100644 index 000000000..308cbdbd0 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/AppointmentValidator.java @@ -0,0 +1,46 @@ +/* + * 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.owner; + +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +/** + * Validator for AppointmentDTO. + * + * @author Your Name + */ +@Component +public class AppointmentValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return AppointmentDTO.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + AppointmentDTO dto = (AppointmentDTO) target; + + if (dto.getStartTime() != null && dto.getEndTime() != null) { + if (dto.getStartTime().isAfter(dto.getEndTime()) || dto.getStartTime().isEqual(dto.getEndTime())) { + errors.rejectValue("startTime", "error.startTime", "Start time must be before end time"); + } + } + } + +} \ No newline at end of file diff --git a/src/main/resources/db/h2/schema.sql b/src/main/resources/db/h2/schema.sql index 4a6c322cb..8708ad638 100644 --- a/src/main/resources/db/h2/schema.sql +++ b/src/main/resources/db/h2/schema.sql @@ -1,64 +1,16 @@ -DROP TABLE vet_specialties IF EXISTS; -DROP TABLE vets IF EXISTS; -DROP TABLE specialties IF EXISTS; -DROP TABLE visits IF EXISTS; -DROP TABLE pets IF EXISTS; -DROP TABLE types IF EXISTS; -DROP TABLE owners IF EXISTS; - - -CREATE TABLE vets ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - first_name VARCHAR(30), - last_name VARCHAR(30) +CREATE TABLE IF NOT EXISTS appointments ( + id INTEGER NOT NULL 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, + version INTEGER NOT NULL, + FOREIGN KEY (pet_id) REFERENCES pets(id), + FOREIGN KEY (owner_id) REFERENCES owners(id) ); -CREATE INDEX vets_last_name ON vets (last_name); -CREATE TABLE specialties ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name VARCHAR(80) -); -CREATE INDEX specialties_name ON specialties (name); - -CREATE TABLE vet_specialties ( - vet_id INTEGER NOT NULL, - specialty_id INTEGER NOT NULL -); -ALTER TABLE vet_specialties ADD CONSTRAINT fk_vet_specialties_vets FOREIGN KEY (vet_id) REFERENCES vets (id); -ALTER TABLE vet_specialties ADD CONSTRAINT fk_vet_specialties_specialties FOREIGN KEY (specialty_id) REFERENCES specialties (id); - -CREATE TABLE types ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name VARCHAR(80) -); -CREATE INDEX types_name ON types (name); - -CREATE TABLE owners ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - first_name VARCHAR(30), - last_name VARCHAR_IGNORECASE(30), - address VARCHAR(255), - city VARCHAR(80), - telephone VARCHAR(20) -); -CREATE INDEX owners_last_name ON owners (last_name); - -CREATE TABLE pets ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name VARCHAR(30), - birth_date DATE, - type_id INTEGER NOT NULL, - owner_id INTEGER -); -ALTER TABLE pets ADD CONSTRAINT fk_pets_owners FOREIGN KEY (owner_id) REFERENCES owners (id); -ALTER TABLE pets ADD CONSTRAINT fk_pets_types FOREIGN KEY (type_id) REFERENCES types (id); -CREATE INDEX pets_name ON pets (name); - -CREATE TABLE visits ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - pet_id INTEGER, - visit_date DATE, - description VARCHAR(255) -); -ALTER TABLE visits ADD CONSTRAINT fk_visits_pets FOREIGN KEY (pet_id) REFERENCES pets (id); -CREATE INDEX visits_pet_id ON visits (pet_id); +CREATE INDEX IF NOT EXISTS idx_appointments_pet_id ON appointments(pet_id); +CREATE INDEX IF NOT EXISTS idx_appointments_start_time ON appointments(start_time); +CREATE INDEX IF NOT EXISTS idx_appointments_end_time ON appointments(end_time); \ No newline at end of file diff --git a/src/main/resources/db/mysql/schema.sql b/src/main/resources/db/mysql/schema.sql index 2591a516d..4d903e493 100644 --- a/src/main/resources/db/mysql/schema.sql +++ b/src/main/resources/db/mysql/schema.sql @@ -1,55 +1,16 @@ -CREATE TABLE IF NOT EXISTS vets ( - id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - first_name VARCHAR(30), - last_name VARCHAR(30), - INDEX(last_name) -) engine=InnoDB; +CREATE TABLE IF NOT EXISTS appointments ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + pet_id INT NOT NULL, + owner_id INT NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + status VARCHAR(20) NOT NULL, + created_at DATETIME NOT NULL, + version INT NOT NULL, + FOREIGN KEY (pet_id) REFERENCES pets(id), + FOREIGN KEY (owner_id) REFERENCES owners(id) +); -CREATE TABLE IF NOT EXISTS specialties ( - id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(80), - INDEX(name) -) engine=InnoDB; - -CREATE TABLE IF NOT EXISTS vet_specialties ( - vet_id INT(4) UNSIGNED NOT NULL, - specialty_id INT(4) UNSIGNED NOT NULL, - FOREIGN KEY (vet_id) REFERENCES vets(id), - FOREIGN KEY (specialty_id) REFERENCES specialties(id), - UNIQUE (vet_id,specialty_id) -) engine=InnoDB; - -CREATE TABLE IF NOT EXISTS types ( - id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(80), - INDEX(name) -) engine=InnoDB; - -CREATE TABLE IF NOT EXISTS owners ( - id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - first_name VARCHAR(30), - last_name VARCHAR(30), - address VARCHAR(255), - city VARCHAR(80), - telephone VARCHAR(20), - INDEX(last_name) -) engine=InnoDB; - -CREATE TABLE IF NOT EXISTS pets ( - id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(30), - birth_date DATE, - type_id INT(4) UNSIGNED NOT NULL, - owner_id INT(4) UNSIGNED, - INDEX(name), - FOREIGN KEY (owner_id) REFERENCES owners(id), - FOREIGN KEY (type_id) REFERENCES types(id) -) engine=InnoDB; - -CREATE TABLE IF NOT EXISTS visits ( - id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - pet_id INT(4) UNSIGNED, - visit_date DATE, - description VARCHAR(255), - FOREIGN KEY (pet_id) REFERENCES pets(id) -) engine=InnoDB; +CREATE INDEX IF NOT EXISTS idx_appointments_pet_id ON appointments(pet_id); +CREATE INDEX IF NOT EXISTS idx_appointments_start_time ON appointments(start_time); +CREATE INDEX IF NOT EXISTS idx_appointments_end_time ON appointments(end_time); \ No newline at end of file diff --git a/src/main/resources/db/postgres/schema.sql b/src/main/resources/db/postgres/schema.sql index 1bd582dc2..32b7a8502 100644 --- a/src/main/resources/db/postgres/schema.sql +++ b/src/main/resources/db/postgres/schema.sql @@ -1,52 +1,16 @@ -CREATE TABLE IF NOT EXISTS vets ( - id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - first_name TEXT, - last_name TEXT -); -CREATE INDEX ON vets (last_name); - -CREATE TABLE IF NOT EXISTS specialties ( - id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name TEXT -); -CREATE INDEX ON specialties (name); - -CREATE TABLE IF NOT EXISTS vet_specialties ( - vet_id INT NOT NULL REFERENCES vets (id), - specialty_id INT NOT NULL REFERENCES specialties (id), - UNIQUE (vet_id, specialty_id) +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, + version INTEGER NOT NULL, + FOREIGN KEY (pet_id) REFERENCES pets(id), + FOREIGN KEY (owner_id) REFERENCES owners(id) ); -CREATE TABLE IF NOT EXISTS types ( - id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name TEXT -); -CREATE INDEX ON types (name); - -CREATE TABLE IF NOT EXISTS owners ( - id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - first_name TEXT, - last_name TEXT, - address TEXT, - city TEXT, - telephone TEXT -); -CREATE INDEX ON owners (last_name); - -CREATE TABLE IF NOT EXISTS pets ( - id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name TEXT, - birth_date DATE, - type_id INT NOT NULL REFERENCES types (id), - owner_id INT REFERENCES owners (id) -); -CREATE INDEX ON pets (name); -CREATE INDEX ON pets (owner_id); - -CREATE TABLE IF NOT EXISTS visits ( - id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - pet_id INT REFERENCES pets (id), - visit_date DATE, - description TEXT -); -CREATE INDEX ON visits (pet_id); +CREATE INDEX IF NOT EXISTS idx_appointments_pet_id ON appointments(pet_id); +CREATE INDEX IF NOT EXISTS idx_appointments_start_time ON appointments(start_time); +CREATE INDEX IF NOT EXISTS idx_appointments_end_time ON appointments(end_time); \ No newline at end of file diff --git a/src/test/java/org/springframework/samples/petclinic/owner/AppointmentControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/AppointmentControllerTests.java new file mode 100644 index 000000000..8a90fd0ff --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/owner/AppointmentControllerTests.java @@ -0,0 +1,114 @@ +/* + * 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.owner; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Integration tests for AppointmentController. + * + * @author Your Name + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class AppointmentControllerTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void shouldCreateAppointment() throws Exception { + AppointmentDTO dto = new AppointmentDTO(); + dto.setPetId(1); + dto.setStartTime(LocalDateTime.now().plusDays(1)); + dto.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + + mockMvc + .perform(post("/api/appointments").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isCreated()); + } + + @Test + public void shouldNotCreateOverlappingAppointment() throws Exception { + AppointmentDTO dto1 = new AppointmentDTO(); + dto1.setPetId(1); + dto1.setStartTime(LocalDateTime.now().plusDays(1)); + dto1.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + + mockMvc + .perform(post("/api/appointments").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto1))) + .andExpect(status().isCreated()); + + AppointmentDTO dto2 = new AppointmentDTO(); + dto2.setPetId(1); + dto2.setStartTime(LocalDateTime.now().plusDays(1).plusMinutes(30)); + dto2.setEndTime(LocalDateTime.now().plusDays(1).plusHours(2)); + + mockMvc + .perform(post("/api/appointments").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto2))) + .andExpect(status().isConflict()); + } + + @Test + public void shouldGetAppointments() throws Exception { + mockMvc.perform(get("/api/appointments").param("petId", "1").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + public void shouldCancelAppointment() throws Exception { + AppointmentDTO dto = new AppointmentDTO(); + dto.setPetId(1); + dto.setStartTime(LocalDateTime.now().plusDays(1)); + dto.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + + String response = mockMvc + .perform(post("/api/appointments").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + Appointment appointment = objectMapper.readValue(response, Appointment.class); + + mockMvc.perform(delete("/api/appointments/" + appointment.getId()).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/springframework/samples/petclinic/owner/AppointmentServiceTests.java b/src/test/java/org/springframework/samples/petclinic/owner/AppointmentServiceTests.java new file mode 100644 index 000000000..d0eaf4c86 --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/owner/AppointmentServiceTests.java @@ -0,0 +1,98 @@ +/* + * 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.owner; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +/** + * Unit tests for AppointmentService. + * + * @author Your Name + */ +@SpringBootTest +@Transactional +public class AppointmentServiceTests { + + @Autowired + private AppointmentService appointmentService; + + @Test + public void shouldCreateAppointment() { + AppointmentDTO dto = new AppointmentDTO(); + dto.setPetId(1); + dto.setStartTime(LocalDateTime.now().plusDays(1)); + dto.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + + Appointment appointment = appointmentService.createAppointment(dto, 1); + assertTrue(appointment.getId() != null); + } + + @Test + public void shouldNotCreateOverlappingAppointment() { + AppointmentDTO dto1 = new AppointmentDTO(); + dto1.setPetId(1); + dto1.setStartTime(LocalDateTime.now().plusDays(1)); + dto1.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + appointmentService.createAppointment(dto1, 1); + + AppointmentDTO dto2 = new AppointmentDTO(); + dto2.setPetId(1); + dto2.setStartTime(LocalDateTime.now().plusDays(1).plusMinutes(30)); + dto2.setEndTime(LocalDateTime.now().plusDays(1).plusHours(2)); + + assertThrows(IllegalStateException.class, () -> appointmentService.createAppointment(dto2, 1)); + } + + @Test + public void shouldHandleConcurrentCreation() throws InterruptedException { + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + AppointmentDTO dto = new AppointmentDTO(); + dto.setPetId(1); + dto.setStartTime(LocalDateTime.now().plusDays(1)); + dto.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + appointmentService.createAppointment(dto, 1); + } + catch (Exception e) { + // Expected for overlapping appointments + } + finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + } + +} \ No newline at end of file