diff --git a/app/travels/field-control.cds b/app/travels/field-control.cds index b50de98..cabc02f 100644 --- a/app/travels/field-control.cds +++ b/app/travels/field-control.cds @@ -29,7 +29,7 @@ annotate TravelService.Bookings with @UI.CreateHidden : (Travel.Status.code != # annotate TravelService.Bookings with @UI.DeleteHidden : (Travel.Status.code != #Open); annotate TravelService.Bookings { - BookingDate @Core.Computed; + BookingDate @readonly; Flight @readonly: (Travel.Status.code = #Accepted) @mandatory: (Travel.Status.code != #Accepted); FlightPrice @readonly: (Travel.Status.code = #Accepted) @mandatory: (Travel.Status.code != #Accepted); }; diff --git a/db/schema.cds b/db/schema.cds index 53e934a..e22b261 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -13,7 +13,7 @@ entity Travels : managed { BeginDate : Date default $now; EndDate : Date default $now; BookingFee : Price default 0; - TotalPrice : Price @readonly; + TotalPrice : Price default 0 @readonly; Currency : Currency default 'EUR'; Status : Association to TravelStatus default 'O'; Agency : Association to TravelAgencies; diff --git a/srv/pom.xml b/srv/pom.xml index ee923f0..af6b087 100644 --- a/srv/pom.xml +++ b/srv/pom.xml @@ -83,6 +83,12 @@ spring-boot-starter-test test + + + org.springframework.security + spring-security-test + test + diff --git a/srv/src/main/java/sap/capire/xtravels/handler/CreationHandler.java b/srv/src/main/java/sap/capire/xtravels/handler/CreationHandler.java index bc97dce..01a50aa 100644 --- a/srv/src/main/java/sap/capire/xtravels/handler/CreationHandler.java +++ b/srv/src/main/java/sap/capire/xtravels/handler/CreationHandler.java @@ -13,6 +13,7 @@ import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.Before; import com.sap.cds.services.handler.annotations.ServiceName; +import java.time.LocalDate; import org.springframework.stereotype.Component; @Component @@ -37,10 +38,18 @@ void calculateTravelId(final Travels travel) { .columns(t -> t.ID().max().as("maxID"))); int maxId = (int) result.single().get("maxID"); travel.setId(++maxId); + + if (travel.getBookings() != null) { + int nextPos = 1; + for (Bookings booking : travel.getBookings()) { + booking.setPos(nextPos++); + booking.setBookingDate(LocalDate.now()); // $now uses timestamp unexpectedly + } + } } // Fill in IDs as sequence numbers -> could be automated by auto-generation - @Before(event = EVENT_DRAFT_NEW) + @Before(event = {EVENT_CREATE, EVENT_DRAFT_NEW}) void calculateBookingPos(Bookings_ ref, final Bookings booking) { var result = service.run(Select.from(ref).columns(t -> t.Pos().max().as("maxPos"))); var maxPos = result.single().get("maxPos"); @@ -50,5 +59,6 @@ void calculateBookingPos(Bookings_ ref, final Bookings booking) { int pos = (int) maxPos; booking.setPos(++pos); } + booking.setBookingDate(LocalDate.now()); } } diff --git a/srv/src/test/java/sap/capire/xtravels/TravelServiceIntegrationTest.java b/srv/src/test/java/sap/capire/xtravels/TravelServiceIntegrationTest.java new file mode 100644 index 0000000..aca2429 --- /dev/null +++ b/srv/src/test/java/sap/capire/xtravels/TravelServiceIntegrationTest.java @@ -0,0 +1,330 @@ +package sap.capire.xtravels; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cds.gen.travelservice.Bookings; +import cds.gen.travelservice.Travels; +import com.sap.cds.CdsData; +import com.sap.cds.CdsJsonConverter; +import com.sap.cds.CdsJsonConverter.UnknownPropertyHandling; +import com.sap.cds.reflect.CdsModel; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +/** Integration tests for the CAP Travel Service OData endpoints */ +@SpringBootTest +@AutoConfigureMockMvc +class TravelServiceIntegrationTest { + + private static final String ODATA_BASE_URL = "/odata/v4/travel"; + private static final String TRAVELS_ENDPOINT = ODATA_BASE_URL + "/Travels"; + + @Autowired private MockMvc mockMvc; + @Autowired private CdsModel model; + private CdsJsonConverter converter; + + @BeforeEach + void setup() { + converter = + CdsJsonConverter.builder(model) + .unknownPropertyHandling(UnknownPropertyHandling.IGNORE) + .build(); + } + + @Test + @WithMockUser("admin") + void shouldGetMetadataSuccessfully() throws Exception { + mockMvc + .perform(get(ODATA_BASE_URL + "/$metadata")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/xml")); + } + + @Test + @WithMockUser("admin") + void shouldGetAllTravels() throws Exception { + mockMvc + .perform(get(TRAVELS_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.@context", containsString("Travels"))) + .andExpect(jsonPath("$.value").isArray()); + } + + @Test + @WithMockUser("admin") + void shouldCreateTravel() throws Exception { + Travels travel = createTravelData("shouldCreateTravel"); + + mockMvc + .perform(post(TRAVELS_ENDPOINT).contentType("application/json").content(travel.toJson())) + .andExpect(status().isCreated()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.Description").value(travel.getDescription())) + .andExpect(jsonPath("$.BeginDate").value(travel.getBeginDate().toString())) + .andExpect(jsonPath("$.EndDate").value(travel.getEndDate().toString())) + .andExpect(jsonPath("$.BookingFee").value(travel.getBookingFee().intValue())) + .andExpect(jsonPath("$.Currency_code").value(travel.getCurrencyCode())) + .andExpect(jsonPath("$.ID", notNullValue())); + } + + @Test + @WithMockUser("admin") + void shouldCreateAndRetrieveTravelSuccessfully() throws Exception { + Travels travel = createTravelData("shouldCreateAndRetrieveTravelSuccessfully"); + travel.setBookingFee(BigDecimal.valueOf(200.0)); + travel.setCurrencyCode("USD"); + + // Create travel + String response = + mockMvc + .perform( + post(TRAVELS_ENDPOINT).contentType("application/json").content(travel.toJson())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + // Verify the created travel can be retrieved + Travels createdTravel = converter.fromJsonObject(response, Travels.class); + assertNotNull(createdTravel.getId()); + + mockMvc + .perform(get(TRAVELS_ENDPOINT + "(ID=" + createdTravel.getId() + ",IsActiveEntity=true)")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.BookingFee").value(200.0)) + .andExpect(jsonPath("$.Currency_code").value("USD")); + } + + @Test + @WithMockUser("admin") + void shouldCreateAndRetrieveTravelWithBookingsSuccessfully() throws Exception { + Travels travel = createTravelData("shouldCreateAndRetrieveTravelWithBookingsSuccessfully"); + Bookings booking = Bookings.create(); + booking.setFlightId("GA0322"); + booking.setFlightDate(LocalDate.of(2024, 6, 2)); + booking.setFlightPrice(BigDecimal.valueOf(1103)); + Bookings.Supplements supplement = Bookings.Supplements.create(); + supplement.setBookedId("bv-0001"); + supplement.setPrice(new BigDecimal("2.30")); + booking.setSupplements(List.of(supplement)); + travel.setBookings(List.of(booking)); + + // Create travel + String response = + mockMvc + .perform( + post(TRAVELS_ENDPOINT).contentType("application/json").content(travel.toJson())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + // Verify the created travel can be retrieved + Travels createdTravel = converter.fromJsonObject(response, Travels.class); + assertNotNull(createdTravel.getId()); + + // Verify @federated data can be read + mockMvc + .perform( + get( + TRAVELS_ENDPOINT + + "(ID=" + + createdTravel.getId() + + ",IsActiveEntity=true)?$expand=Bookings($expand=Flight,Supplements($expand=booked))")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.Bookings[0].Flight.origin").value("Miami International Airport")) + .andExpect(jsonPath("$.Bookings[0].Supplements[0].booked.descr").value("Hot Chocolate")); + } + + @Test + @WithMockUser("admin") + void shouldGetReadOnlyEntitiesSuccessfully() throws Exception { + // Test Flights entity + mockMvc + .perform(get(ODATA_BASE_URL + "/Flights")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")); + + // Test Supplements entity + mockMvc + .perform(get(ODATA_BASE_URL + "/Supplements")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")); + + // Test Currencies entity + mockMvc + .perform(get(ODATA_BASE_URL + "/Currencies")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")); + } + + @Test + @WithMockUser("admin") + void shouldReturn400ForInvalidDiscountPercentage() throws Exception { + // First create a travel + Travels travelData = createTravelData("shouldReturn400ForInvalidDiscountPercentage"); + String response = + mockMvc + .perform( + post(TRAVELS_ENDPOINT).contentType("application/json").content(travelData.toJson())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + Travels createdTravel = converter.fromJsonObject(response, Travels.class); + + // Try to execute deductDiscount action with invalid percentage (>100) + CdsData actionParams = CdsData.create(); + actionParams.put("percent", 150); // Invalid percentage + + mockMvc + .perform( + post(TRAVELS_ENDPOINT + + "(ID=" + + createdTravel.getId() + + ",IsActiveEntity=true)/TravelService.deductDiscount") + .contentType("application/json") + .content(actionParams.toJson())) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser("admin") + void shouldExecuteAcceptTravelAction() throws Exception { + // First create a travel + Travels travelData = createTravelData("shouldExecuteAcceptTravelAction"); + String response = + mockMvc + .perform( + post(TRAVELS_ENDPOINT).contentType("application/json").content(travelData.toJson())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + Travels createdTravel = converter.fromJsonObject(response, Travels.class); + + // Execute acceptTravel action + mockMvc + .perform( + post(TRAVELS_ENDPOINT + + "(ID=" + + createdTravel.getId() + + ",IsActiveEntity=true)/TravelService.acceptTravel") + .contentType("application/json") + .content("{}")) + .andExpect(status().is2xxSuccessful()); + + // Check if travel status is accepted + mockMvc + .perform(get(TRAVELS_ENDPOINT + "(ID=" + createdTravel.getId() + ",IsActiveEntity=true)")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.Status_code").value("A")); + } + + @Test + @WithMockUser("admin") + void shouldExecuteRejectTravelAction() throws Exception { + // First create a travel + Travels travelData = createTravelData("shouldExecuteRejectTravelAction"); + String response = + mockMvc + .perform( + post(TRAVELS_ENDPOINT).contentType("application/json").content(travelData.toJson())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + Travels createdTravel = converter.fromJsonObject(response, Travels.class); + + // Execute rejectTravel action + mockMvc + .perform( + post(TRAVELS_ENDPOINT + + "(ID=" + + createdTravel.getId() + + ",IsActiveEntity=true)/TravelService.rejectTravel") + .contentType("application/json") + .content("{}")) + .andExpect(status().is2xxSuccessful()); + + // Check if travel status is rejected + mockMvc + .perform(get(TRAVELS_ENDPOINT + "(ID=" + createdTravel.getId() + ",IsActiveEntity=true)")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.Status_code").value("X")); + } + + @Test + @WithMockUser("admin") + void shouldExecuteDeductDiscountAction() throws Exception { + // First create a travel + Travels travelData = createTravelData("shouldExecuteDeductDiscountAction"); + + String response = + mockMvc + .perform( + post(TRAVELS_ENDPOINT).contentType("application/json").content(travelData.toJson())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + Travels createdTravel = converter.fromJsonObject(response, Travels.class); + + // Execute deductDiscount action with 10% discount + CdsData actionParams = CdsData.create(); + actionParams.put("percent", 10); + + mockMvc + .perform( + post(TRAVELS_ENDPOINT + + "(ID=" + + createdTravel.getId() + + ",IsActiveEntity=true)/TravelService.deductDiscount") + .contentType("application/json") + .content(actionParams.toJson())) + .andExpect(status().isOk()); + + mockMvc + .perform(get(TRAVELS_ENDPOINT + "(ID=" + createdTravel.getId() + ",IsActiveEntity=true)")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.BookingFee").value(90)); + } + + private Travels createTravelData(String testName) { + Travels travel = Travels.create(); + travel.setIsActiveEntity(true); + travel.setDescription(testName + " - Test Travel"); + travel.setBeginDate(LocalDate.of(2024, 6, 1)); + travel.setEndDate(LocalDate.of(2024, 6, 14)); + travel.setBookingFee(BigDecimal.valueOf(100)); + travel.setCurrencyCode("EUR"); + travel.setAgencyId("070001"); + travel.setCustomerId("000001"); + return travel; + } +}