From deaa620dd173a351b21f9ccc55f4acd1a494c0d7 Mon Sep 17 00:00:00 2001 From: AnonimProgrammer Date: Thu, 14 May 2026 14:31:20 +0400 Subject: [PATCH] feat(MED-35): Appointment completion flow added. --- bruno/appointments/Complete Appointment.bru | 15 ++++ .../CompleteAppointmentUseCase.java | 48 ++++++++++++ .../adapter/input/AppointmentRestAdapter.java | 8 ++ .../input/AppointmentRestAdapterTest.java | 77 +++++++++++++++++++ .../adapter/input/PatientRestAdapterTest.java | 74 ++++++++---------- 5 files changed, 178 insertions(+), 44 deletions(-) create mode 100644 bruno/appointments/Complete Appointment.bru create mode 100644 src/main/java/com/ironhack/application/usecase/appointment/CompleteAppointmentUseCase.java diff --git a/bruno/appointments/Complete Appointment.bru b/bruno/appointments/Complete Appointment.bru new file mode 100644 index 0000000..75a79a6 --- /dev/null +++ b/bruno/appointments/Complete Appointment.bru @@ -0,0 +1,15 @@ +meta { + name: Complete Appointment + type: http + seq: 5 +} + +post { + url: {{base_url}}/v1/appointments/{{id}}/complete + body: none + auth: none +} + +vars:pre-request { + id: 00000000-0000-4000-8000-000000000003 +} diff --git a/src/main/java/com/ironhack/application/usecase/appointment/CompleteAppointmentUseCase.java b/src/main/java/com/ironhack/application/usecase/appointment/CompleteAppointmentUseCase.java new file mode 100644 index 0000000..457b03d --- /dev/null +++ b/src/main/java/com/ironhack/application/usecase/appointment/CompleteAppointmentUseCase.java @@ -0,0 +1,48 @@ +package com.ironhack.application.usecase.appointment; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ironhack.application.dto.AppointmentDTO; +import com.ironhack.application.dto.response.ApiResponse; +import com.ironhack.application.exception.ConflictException; +import com.ironhack.application.exception.NotFoundException; +import com.ironhack.domain.AppointmentEntity; +import com.ironhack.domain.AppointmentStatus; +import com.ironhack.infra.adapter.mapper.MappingFacade; +import com.ironhack.infra.adapter.output.AppointmentRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CompleteAppointmentUseCase { + private final AppointmentRepository appointmentRepository; + private final MappingFacade mappingFacade; + + @Transactional + public ApiResponse invoke(UUID appointmentId) { + AppointmentEntity appointment = requireScheduledAppointment(appointmentId); + + appointment.setStatus(AppointmentStatus.COMPLETED); + AppointmentEntity saved = appointmentRepository.save(appointment); + AppointmentDTO dto = mappingFacade.toAppointmentDTO(saved); + + return ApiResponse.success(dto, "Appointment completed successfully."); + } + + private AppointmentEntity requireScheduledAppointment(UUID appointmentId) { + AppointmentEntity appointment = appointmentRepository + .findWithAssociationsById(appointmentId) + .orElseThrow(() -> new NotFoundException("Appointment not found.")); + ensureAppointmentIsScheduled(appointment); + return appointment; + } + + private void ensureAppointmentIsScheduled(AppointmentEntity appointment) { + if (appointment.getStatus() != AppointmentStatus.SCHEDULED) { + throw new ConflictException("Cannot complete appointment: only scheduled appointments may be completed."); + } + } +} diff --git a/src/main/java/com/ironhack/infra/adapter/input/AppointmentRestAdapter.java b/src/main/java/com/ironhack/infra/adapter/input/AppointmentRestAdapter.java index d495dc1..2c4fa7a 100644 --- a/src/main/java/com/ironhack/infra/adapter/input/AppointmentRestAdapter.java +++ b/src/main/java/com/ironhack/infra/adapter/input/AppointmentRestAdapter.java @@ -21,6 +21,7 @@ import com.ironhack.application.dto.response.ApiResponse; import com.ironhack.application.usecase.appointment.BookAppointmentUseCase; import com.ironhack.application.usecase.appointment.CancelAppointmentUseCase; +import com.ironhack.application.usecase.appointment.CompleteAppointmentUseCase; import com.ironhack.application.usecase.appointment.SearchAppointmentsUseCase; import com.ironhack.domain.AppointmentStatus; import jakarta.validation.Valid; @@ -32,6 +33,7 @@ public class AppointmentRestAdapter { private final BookAppointmentUseCase bookAppointmentUseCase; private final CancelAppointmentUseCase cancelAppointmentUseCase; + private final CompleteAppointmentUseCase completeAppointmentUseCase; private final SearchAppointmentsUseCase searchAppointmentsUseCase; @GetMapping @@ -57,4 +59,10 @@ public ResponseEntity> cancelAppointment(@PathVariab ApiResponse body = cancelAppointmentUseCase.invoke(id); return ResponseEntity.ok(body); } + + @PostMapping("/{id}/complete") + public ResponseEntity> completeAppointment(@PathVariable UUID id) { + ApiResponse body = completeAppointmentUseCase.invoke(id); + return ResponseEntity.ok(body); + } } diff --git a/src/test/java/com/ironhack/infra/adapter/input/AppointmentRestAdapterTest.java b/src/test/java/com/ironhack/infra/adapter/input/AppointmentRestAdapterTest.java index 9828467..d68bbc3 100644 --- a/src/test/java/com/ironhack/infra/adapter/input/AppointmentRestAdapterTest.java +++ b/src/test/java/com/ironhack/infra/adapter/input/AppointmentRestAdapterTest.java @@ -181,6 +181,83 @@ void shouldReturnConflictWhenAppointmentAlreadyCancelled() throws Exception { mockMvc.perform(post("/v1/appointments/{id}/cancel", appointmentId)).andExpect(status().isConflict()); } + // ==================== Complete Appointment Tests ==================== + + @Test + @DisplayName("Should successfully complete scheduled appointment") + void shouldCompleteAppointmentSuccessfully() throws Exception { + LocalDateTime futureTime = LocalDateTime.now().plusDays(7); + BookAppointmentRequest bookRequest = new BookAppointmentRequest(patientId, doctorId, futureTime); + + String response = mockMvc.perform(post("/v1/appointments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(bookRequest))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + String appointmentId = + objectMapper.readTree(response).get("data").get("id").asString(); + + mockMvc.perform(post("/v1/appointments/{id}/complete", appointmentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.message").value("Appointment completed successfully.")) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + } + + @Test + @DisplayName("Should return 404 when trying to complete non-existent appointment") + void shouldReturnNotFoundWhenCompletingAppointmentDoesNotExist() throws Exception { + mockMvc.perform(post("/v1/appointments/{id}/complete", UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 409 when trying to complete cancelled appointment") + void shouldReturnConflictWhenCompletingCancelledAppointment() throws Exception { + LocalDateTime futureTime = LocalDateTime.now().plusDays(7); + BookAppointmentRequest bookRequest = new BookAppointmentRequest(patientId, doctorId, futureTime); + + String response = mockMvc.perform(post("/v1/appointments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(bookRequest))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + String appointmentId = + objectMapper.readTree(response).get("data").get("id").asString(); + + mockMvc.perform(post("/v1/appointments/{id}/cancel", appointmentId)).andExpect(status().isOk()); + + mockMvc.perform(post("/v1/appointments/{id}/complete", appointmentId)).andExpect(status().isConflict()); + } + + @Test + @DisplayName("Should return 409 when trying to complete already completed appointment") + void shouldReturnConflictWhenAppointmentAlreadyCompleted() throws Exception { + LocalDateTime futureTime = LocalDateTime.now().plusDays(7); + BookAppointmentRequest bookRequest = new BookAppointmentRequest(patientId, doctorId, futureTime); + + String response = mockMvc.perform(post("/v1/appointments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(bookRequest))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + String appointmentId = + objectMapper.readTree(response).get("data").get("id").asString(); + + mockMvc.perform(post("/v1/appointments/{id}/complete", appointmentId)).andExpect(status().isOk()); + + mockMvc.perform(post("/v1/appointments/{id}/complete", appointmentId)).andExpect(status().isConflict()); + } + // ==================== List Patient Appointments Tests ==================== @Test diff --git a/src/test/java/com/ironhack/infra/adapter/input/PatientRestAdapterTest.java b/src/test/java/com/ironhack/infra/adapter/input/PatientRestAdapterTest.java index d8ea653..492ee93 100644 --- a/src/test/java/com/ironhack/infra/adapter/input/PatientRestAdapterTest.java +++ b/src/test/java/com/ironhack/infra/adapter/input/PatientRestAdapterTest.java @@ -1,27 +1,28 @@ package com.ironhack.infra.adapter.input; +import java.time.LocalDateTime; +import java.util.UUID; + +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + import com.ironhack.application.dto.request.BookAppointmentRequest; +import com.ironhack.application.dto.request.CreatePatientRequest; import com.ironhack.domain.AppointmentStatus; import com.ironhack.domain.DoctorEntity; import com.ironhack.domain.PatientEntity; import com.ironhack.domain.Specialty; import com.ironhack.infra.adapter.output.AppointmentRepository; import com.ironhack.infra.adapter.output.DoctorRepository; -import tools.jackson.databind.ObjectMapper; -import com.ironhack.application.dto.request.CreatePatientRequest; import com.ironhack.infra.adapter.output.PatientRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; 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.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.LocalDateTime; -import java.util.UUID; +import tools.jackson.databind.ObjectMapper; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -56,7 +57,7 @@ void setUp() { @Test @DisplayName("Should successfully create patient with valid data") void shouldSuccessfullyCreatePatientWithValidData() throws Exception { - CreatePatientRequest request = new CreatePatientRequest("Cafar Babayev","+994501234567"); + CreatePatientRequest request = new CreatePatientRequest("Cafar Babayev", "+994501234567"); mockMvc.perform(post("/v1/patients") .contentType(MediaType.APPLICATION_JSON) @@ -82,11 +83,9 @@ void shouldReturnValidationErrorWhenFullNameIsBlank() throws Exception { @DisplayName("Should return 409 when patient with same phone number already exists") void shouldReturnConflictWhenPatientPhoneNumberAlreadyExists() throws Exception { - CreatePatientRequest request1 = - new CreatePatientRequest("Cafar Babayev", "+994501234567"); + CreatePatientRequest request1 = new CreatePatientRequest("Cafar Babayev", "+994501234567"); - CreatePatientRequest request2 = - new CreatePatientRequest("Ali Hasanov", "+994501234567"); + CreatePatientRequest request2 = new CreatePatientRequest("Ali Hasanov", "+994501234567"); // Create first patient mockMvc.perform(post("/v1/patients") @@ -105,51 +104,39 @@ void shouldReturnConflictWhenPatientPhoneNumberAlreadyExists() throws Exception @DisplayName("Should list patient appointments successfully") void shouldListPatientAppointmentsSuccessfully() throws Exception { - PatientEntity patient = patientRepository.save( - PatientEntity.builder() - .fullName("Cafar Babayev") - .phoneNumber("+994501234567") - .build() - ); + PatientEntity patient = patientRepository.save(PatientEntity.builder() + .fullName("Cafar Babayev") + .phoneNumber("+994501234567") + .build()); - DoctorEntity doctor = doctorRepository.save( - DoctorEntity.builder() - .fullName("Dr. Ali") - .specialty(Specialty.CARDIOLOGY) - .build() - ); + DoctorEntity doctor = doctorRepository.save(DoctorEntity.builder() + .fullName("Dr. Ali") + .specialty(Specialty.CARDIOLOGY) + .build()); - LocalDateTime appointmentTime = - LocalDateTime.now().plusDays(5); + LocalDateTime appointmentTime = LocalDateTime.now().plusDays(5); mockMvc.perform(post("/v1/appointments") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString( - new BookAppointmentRequest( - patient.getId(), - doctor.getId(), - appointmentTime - )))) + new BookAppointmentRequest(patient.getId(), doctor.getId(), appointmentTime)))) .andExpect(status().isCreated()); mockMvc.perform(get("/v1/patients/{id}/appointments", patient.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.data.length()").value(1)) - .andExpect(jsonPath("$.data[0].status") - .value(AppointmentStatus.SCHEDULED.name())); + .andExpect(jsonPath("$.data[0].status").value(AppointmentStatus.SCHEDULED.name())); } @Test @DisplayName("Should return empty list when patient has no appointments") void shouldReturnEmptyListWhenPatientHasNoAppointments() throws Exception { - PatientEntity patient = patientRepository.save( - PatientEntity.builder() - .fullName("Cafar Babayev") - .phoneNumber("+994501234567") - .build() - ); + PatientEntity patient = patientRepository.save(PatientEntity.builder() + .fullName("Cafar Babayev") + .phoneNumber("+994501234567") + .build()); mockMvc.perform(get("/v1/patients/{id}/appointments", patient.getId())) .andExpect(status().isOk()) @@ -159,7 +146,6 @@ void shouldReturnEmptyListWhenPatientHasNoAppointments() throws Exception { @Test @DisplayName("Should return 404 when patient does not exist") void shouldReturnNotFoundWhenPatientDoesNotExist() throws Exception { - mockMvc.perform(get("/v1/patients/{id}/appointments", UUID.randomUUID())) .andExpect(status().isNotFound()); }