diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 2d86e03c..f10701b4 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -1,135 +1,134 @@ - - 4.0.0 + + + 4.0.0 - - my - bookshop-parent - ${revision} - + + my + bookshop-parent + ${revision} + - bookshop-integration-tests + bookshop-integration-tests - bookshop-integration-tests + bookshop-integration-tests - - ../mtx/sidecar - + + ../mtx/sidecar + - - - my - bookshop - ${revision} - test - - - ch.qos.logback - logback-classic - - - + + + my + bookshop + ${revision} + test + + + ch.qos.logback + logback-classic + + + - - org.springframework.boot - spring-boot-starter-test - test - + + org.springframework.boot + spring-boot-starter-test + test + - - org.springframework.security - spring-security-test - test - - + + org.springframework.security + spring-security-test + test + + - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.4 - - false - - + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + false + + - - org.graalvm.buildtools - native-maven-plugin - - true - - + + org.graalvm.buildtools + native-maven-plugin + + true + + - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.5.4 - - - - integration-test - verify - - - - + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + + + integration-test + verify + + + + - - com.sap.cds - cds-maven-plugin - ${cds.services.version} - - - cds.install-node - - install-node - - + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + cds.install-node + + install-node + + - - install-sidecar - - npm - - pre-integration-test - - ci - ${skipTests} - ${sidecar.dir} - - - - + + install-sidecar + + npm + + pre-integration-test + + ci + ${skipTests} + ${sidecar.dir} + + + + - - org.codehaus.mojo - exec-maven-plugin - 3.6.3 - - - pre-integration-test - start-sidecar - - exec - - - - - ${cds.npm.executable} - - ${cds.node.directory}${path.separator}${env.PATH} - - ${skipTests} - ${sidecar.dir} - true - true - run start - - - - + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + ${cds.npm.executable} + + ${cds.node.directory}${path.separator}${env.PATH} + + ${skipTests} + ${sidecar.dir} + true + true + run start + + + + start-sidecar + + exec + + pre-integration-test + + + + + diff --git a/integration-tests/src/test/java/my/bookshop/it/FeatureTogglesIT.java b/integration-tests/src/test/java/my/bookshop/it/FeatureTogglesIT.java index 816337c4..e266e334 100644 --- a/integration-tests/src/test/java/my/bookshop/it/FeatureTogglesIT.java +++ b/integration-tests/src/test/java/my/bookshop/it/FeatureTogglesIT.java @@ -19,40 +19,40 @@ @SpringBootTest class FeatureTogglesIT { - private static final String ENDPOINT = "/api/browse/Books(aebdfc8a-0dfa-4468-bd36-48aabd65e663)"; - - @Autowired - private MockMvc client; - - @Test - @WithMockUser("authenticated") // This user has all feature toggles disabled - void withoutToggles_basicModelVisible() throws Exception { - // Elements are not visible and not changed by the event handler - client.perform(get(ENDPOINT)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isbn").doesNotExist()) - .andExpect(jsonPath("$.title").value(containsString("11%"))); - } - - @Test - @WithMockUser("admin") // This user has all feature toggles enabled - void togglesOn_extensionsAndChangesAreVisible() throws Exception { - // Elements are visible and changed by the event handler - client.perform(get(ENDPOINT)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isbn").value("979-8669820985")) - .andExpect(jsonPath("$.title").value(containsString("14%"))); - } - - @Test - @WithMockUser("user") // This user has only 'isbn' toggle enabled - void toggleIsbnOn_extensionsAndChangesAreVisible() throws Exception { - // Elements are visible - client.perform(get(ENDPOINT)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isbn").value("979-8669820985")) - .andExpect(jsonPath("$.title").value(containsString("11%"))); - } - + private static final String ENDPOINT = "/api/browse/Books(aebdfc8a-0dfa-4468-bd36-48aabd65e663)"; + + @Autowired private MockMvc client; + + @Test + @WithMockUser("authenticated") // This user has all feature toggles disabled + void withoutToggles_basicModelVisible() throws Exception { + // Elements are not visible and not changed by the event handler + client + .perform(get(ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isbn").doesNotExist()) + .andExpect(jsonPath("$.title").value(containsString("11%"))); + } + + @Test + @WithMockUser("admin") // This user has all feature toggles enabled + void togglesOn_extensionsAndChangesAreVisible() throws Exception { + // Elements are visible and changed by the event handler + client + .perform(get(ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isbn").value("979-8669820985")) + .andExpect(jsonPath("$.title").value(containsString("14%"))); + } + + @Test + @WithMockUser("user") // This user has only 'isbn' toggle enabled + void toggleIsbnOn_extensionsAndChangesAreVisible() throws Exception { + // Elements are visible + client + .perform(get(ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isbn").value("979-8669820985")) + .andExpect(jsonPath("$.title").value(containsString("11%"))); + } } - diff --git a/pom.xml b/pom.xml index 52b031ba..b0cfdd5b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,173 +1,200 @@ - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.5.9 - - - - my - bookshop-parent - ${revision} - pom - - bookshop parent - - - - 1.0.0-SNAPSHOT - - - 21 - 4.6.1 - 5.25.0 - 3.6.5 - 3.8.6 - 1.2.4 - - - - srv - integration-tests - - - - - - - com.sap.cds - cds-services-bom - ${cds.services.version} - pom - import - - - - - com.sap.cloud.sdk - sdk-modules-bom - ${cloud.sdk.version} - pom - import - - - - - com.sap.cloud.security - java-bom - ${xsuaa.version} - pom - import - - - - - - - - - - com.sap.cds - cds-maven-plugin - ${cds.services.version} - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.1 - - ${jdk.version} - UTF-8 - - - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.4 - - true - - - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.5.4 - - - - - org.codehaus.mojo - flatten-maven-plugin - 1.7.3 - - true - resolveCiFriendliesOnly - - - - flatten - process-resources - - flatten - - - - flatten.clean - clean - - clean - - - - - - - - org.apache.maven.plugins - maven-enforcer-plugin - 3.6.2 - - - Project Structure Checks - - enforce - - - - - 3.6.3 - - - ${jdk.version} - - - - true - - - - - - + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.9 + + + + + my + bookshop-parent + ${revision} + pom + + bookshop parent + + + srv + integration-tests + + + + + 1.0.0-SNAPSHOT + + + 21 + 4.6.1 + 5.25.0 + 3.6.5 + 3.8.6 + 1.2.4 + + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + + com.sap.cloud.sdk + sdk-modules-bom + ${cloud.sdk.version} + pom + import + + + + + com.sap.cloud.security + java-bom + ${xsuaa.version} + pom + import + + + + + + + + + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${jdk.version} + UTF-8 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + true + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + true + resolveCiFriendliesOnly + + + + flatten + + flatten + + process-resources + + + flatten.clean + + clean + + clean + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.2 + + + Project Structure Checks + + enforce + + + + + 3.6.3 + + + ${jdk.version} + + + + true + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.1.0 + + + + + + + + + pom.xml + + + + + + + + check + + process-sources + + + + + diff --git a/srv/pom.xml b/srv/pom.xml index df053511..ef4135d6 100644 --- a/srv/pom.xml +++ b/srv/pom.xml @@ -1,245 +1,244 @@ - - 4.0.0 - - - bookshop-parent - my - ${revision} - - - bookshop - jar - - bookshop - - - - - - com.sap.cds - cds-starter-spring-boot - - - - com.sap.cds - cds-adapter-odata-v4 - runtime - - - - com.sap.cds - cds-starter-cloudfoundry - runtime - - - - com.sap.cds - cds-starter-k8s - runtime - - - - com.sap.cds - cds-feature-enterprise-messaging - runtime - - - - com.sap.cds - cds-feature-remote-odata - runtime - - - - com.sap.cds - cds-feature-change-tracking - runtime - - - - com.sap.cds - cds-feature-attachments - ${cds-feature-attachments.version} - runtime - - - - - com.sap.cloud.sdk.cloudplatform - resilience - - - - com.sap.cloud.sdk - sdk-core - - - - com.sap.cloud.sdk.cloudplatform - connectivity-apache-httpclient4 - - - - - com.h2database - h2 - runtime - - - - org.xerial - sqlite-jdbc - runtime - - - - com.sap.hcp.cf.logging - cf-java-logging-support-servlet-jakarta - ${cf-java-logging-support.version} - - - - com.sap.hcp.cf.logging - cf-java-logging-support-logback - ${cf-java-logging-support.version} - - - - org.springframework.boot - spring-boot-starter-security - - - - org.springframework.boot - spring-boot-starter-actuator - - - - org.springframework.boot - spring-boot-starter-test - test - - - - org.springframework.security - spring-security-test - test - - - - org.springframework.boot - spring-boot-starter-webflux - test - - - - - org.springframework.boot - spring-boot-devtools - true - - - - - bookshop - - - - org.springframework.boot - spring-boot-maven-plugin - - false - - - - repackage - - repackage - - - exec - - - - - - - org.graalvm.buildtools - native-maven-plugin - - - 0.3.14 - - - - - - - com.sap.cds - cds-maven-plugin - - - cds.clean - - clean - - - - - cds.install-node - - install-node - - - - - cds.npm-ci - - npm - - - ci - - - - - cds.resolve - - resolve - - - - - cds.build - - cds - - - - build --for java - deploy --to h2 --with-mocks --dry --out "${project.basedir}/src/main/resources/schema-h2.sql" - compile srv/cat-service.cds -2 openapi --openapi:url /api/browse > + + + 4.0.0 + + + my + bookshop-parent + ${revision} + + + bookshop + jar + + bookshop + + + + + + com.sap.cds + cds-starter-spring-boot + + + + com.sap.cds + cds-adapter-odata-v4 + runtime + + + + com.sap.cds + cds-starter-cloudfoundry + runtime + + + + com.sap.cds + cds-starter-k8s + runtime + + + + com.sap.cds + cds-feature-enterprise-messaging + runtime + + + + com.sap.cds + cds-feature-remote-odata + runtime + + + + com.sap.cds + cds-feature-change-tracking + runtime + + + + com.sap.cds + cds-feature-attachments + ${cds-feature-attachments.version} + runtime + + + + + com.sap.cloud.sdk.cloudplatform + resilience + + + + com.sap.cloud.sdk + sdk-core + + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient4 + + + + + com.h2database + h2 + runtime + + + + org.xerial + sqlite-jdbc + runtime + + + + com.sap.hcp.cf.logging + cf-java-logging-support-servlet-jakarta + ${cf-java-logging-support.version} + + + + com.sap.hcp.cf.logging + cf-java-logging-support-logback + ${cf-java-logging-support.version} + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + + org.springframework.boot + spring-boot-starter-webflux + test + + + + + org.springframework.boot + spring-boot-devtools + true + + + + + bookshop + + + + org.springframework.boot + spring-boot-maven-plugin + + false + + + + repackage + + repackage + + + exec + + + + + + + org.graalvm.buildtools + native-maven-plugin + + + 0.3.14 + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.clean + + clean + + + + + cds.install-node + + install-node + + + + + cds.npm-ci + + npm + + + ci + + + + + cds.resolve + + resolve + + + + + cds.build + + cds + + + + build --for java + deploy --to h2 --with-mocks --dry --out "${project.basedir}/src/main/resources/schema-h2.sql" + compile srv/cat-service.cds -2 openapi --openapi:url /api/browse > "${project.basedir}/src/main/resources/swagger/openapi.json" - - - - - - cds.generate - - generate - - - cds.gen - true - true - true - - - - - - + + + + + + cds.generate + + generate + + + cds.gen + true + true + true + + + + + + diff --git a/srv/src/main/java/my/bookshop/Application.java b/srv/src/main/java/my/bookshop/Application.java index d4ab598c..406183e8 100644 --- a/srv/src/main/java/my/bookshop/Application.java +++ b/srv/src/main/java/my/bookshop/Application.java @@ -1,29 +1,27 @@ package my.bookshop; +import com.sap.hcp.cf.logging.servlet.filter.RequestLoggingFilter; +import jakarta.servlet.DispatcherType; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; -import com.sap.hcp.cf.logging.servlet.filter.RequestLoggingFilter; - -import jakarta.servlet.DispatcherType; - @SpringBootApplication public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } - - @Bean - public FilterRegistrationBean loggingFilter() { - FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(); - filterRegistrationBean.setFilter(new RequestLoggingFilter()); - filterRegistrationBean.setName("request-logging"); - filterRegistrationBean.addUrlPatterns("/*"); - filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST); - return filterRegistrationBean; - } + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + @Bean + public FilterRegistrationBean loggingFilter() { + FilterRegistrationBean filterRegistrationBean = + new FilterRegistrationBean<>(); + filterRegistrationBean.setFilter(new RequestLoggingFilter()); + filterRegistrationBean.setName("request-logging"); + filterRegistrationBean.addUrlPatterns("/*"); + filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST); + return filterRegistrationBean; + } } diff --git a/srv/src/main/java/my/bookshop/MessageKeys.java b/srv/src/main/java/my/bookshop/MessageKeys.java index 70e7de3d..10001051 100644 --- a/srv/src/main/java/my/bookshop/MessageKeys.java +++ b/srv/src/main/java/my/bookshop/MessageKeys.java @@ -2,17 +2,17 @@ public class MessageKeys { - public static final String BOOK_REQUIRE_STOCK = "book.require.stock"; - public static final String BOOK_ADDED_ORDER = "book.added.order"; - public static final String BOOK_MISSING = "book.missing"; - public static final String ORDERITEM_MISSING = "orderitem.missing"; - public static final String ORDER_MISSING = "order.missing"; - public static final String ORDER_INDRAFT = "order.indraft"; - public static final String BUPA_MISSING = "bupa.missing"; + public static final String BOOK_REQUIRE_STOCK = "book.require.stock"; + public static final String BOOK_ADDED_ORDER = "book.added.order"; + public static final String BOOK_MISSING = "book.missing"; + public static final String ORDERITEM_MISSING = "orderitem.missing"; + public static final String ORDER_MISSING = "order.missing"; + public static final String ORDER_INDRAFT = "order.indraft"; + public static final String BUPA_MISSING = "bupa.missing"; - public static final String REVIEW_ADDED = "review.added"; - public static final String REVIEW_ADD_FORBIDDEN = "review.add.forbidden"; - public static final String ORDER_EXCEEDS_STOCK = "order.exceeds.stock"; - public static final String BOOK_IMPORT_FAILED = "book.import.failed"; - public static final String BOOK_IMPORT_INVALID_CSV = "book.import.invalid.csv"; + public static final String REVIEW_ADDED = "review.added"; + public static final String REVIEW_ADD_FORBIDDEN = "review.add.forbidden"; + public static final String ORDER_EXCEEDS_STOCK = "order.exceeds.stock"; + public static final String BOOK_IMPORT_FAILED = "book.import.failed"; + public static final String BOOK_IMPORT_INVALID_CSV = "book.import.invalid.csv"; } diff --git a/srv/src/main/java/my/bookshop/RatingCalculator.java b/srv/src/main/java/my/bookshop/RatingCalculator.java index a983da84..b43b0b29 100644 --- a/srv/src/main/java/my/bookshop/RatingCalculator.java +++ b/srv/src/main/java/my/bookshop/RatingCalculator.java @@ -14,48 +14,43 @@ import java.util.stream.Stream; import org.springframework.stereotype.Component; -/** - * Takes care of calculating the average rating of a book based on its review - * ratings. - */ +/** Takes care of calculating the average rating of a book based on its review ratings. */ @Component public class RatingCalculator { - private PersistenceService db; - - RatingCalculator(PersistenceService db) { - this.db = db; - } - - /** - * Initializes the ratings for all existing books based on their reviews. - */ - public void initBookRatings() { - var result = db.run(Select.from(BOOKS).columns(b -> b.ID())); - for (Books book : result) { - setBookRating(book.getId()); - } - } - - /** - * Sets the average rating for the given book. - * - * @param bookId - */ - public void setBookRating(String bookId) { - Result run = db.run(Select.from(BOOKS, b -> b.filter(b.ID().eq(bookId)).reviews())); - - Stream ratings = run.streamOf(Reviews.class).map(r -> r.getRating().doubleValue()); - BigDecimal rating = getAvgRating(ratings); - - db.run(Update.entity(BOOKS).byId(bookId).data(Books.RATING, rating)); - } - - static BigDecimal getAvgRating(Stream ratings) { - OptionalDouble avg = ratings.mapToDouble(Double::doubleValue).average(); - if (!avg.isPresent()) { - return BigDecimal.ZERO; - } - return BigDecimal.valueOf(avg.getAsDouble()).setScale(1, RoundingMode.HALF_UP); - } + private PersistenceService db; + + RatingCalculator(PersistenceService db) { + this.db = db; + } + + /** Initializes the ratings for all existing books based on their reviews. */ + public void initBookRatings() { + var result = db.run(Select.from(BOOKS).columns(b -> b.ID())); + for (Books book : result) { + setBookRating(book.getId()); + } + } + + /** + * Sets the average rating for the given book. + * + * @param bookId + */ + public void setBookRating(String bookId) { + Result run = db.run(Select.from(BOOKS, b -> b.filter(b.ID().eq(bookId)).reviews())); + + Stream ratings = run.streamOf(Reviews.class).map(r -> r.getRating().doubleValue()); + BigDecimal rating = getAvgRating(ratings); + + db.run(Update.entity(BOOKS).byId(bookId).data(Books.RATING, rating)); + } + + static BigDecimal getAvgRating(Stream ratings) { + OptionalDouble avg = ratings.mapToDouble(Double::doubleValue).average(); + if (!avg.isPresent()) { + return BigDecimal.ZERO; + } + return BigDecimal.valueOf(avg.getAsDouble()).setScale(1, RoundingMode.HALF_UP); + } } diff --git a/srv/src/main/java/my/bookshop/config/CustomFeatureToggleProvider.java b/srv/src/main/java/my/bookshop/config/CustomFeatureToggleProvider.java index d4dfec9e..87a00b38 100644 --- a/srv/src/main/java/my/bookshop/config/CustomFeatureToggleProvider.java +++ b/srv/src/main/java/my/bookshop/config/CustomFeatureToggleProvider.java @@ -1,30 +1,28 @@ package my.bookshop.config; -import java.util.HashMap; -import java.util.Map; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - import com.sap.cds.services.request.FeatureTogglesInfo; import com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.FeatureTogglesInfoProvider; +import java.util.HashMap; +import java.util.Map; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; @Component @Profile("cloud") // locally, feature toggles are configured directly with mock users public class CustomFeatureToggleProvider implements FeatureTogglesInfoProvider { - @Override - public FeatureTogglesInfo get(UserInfo userInfo, ParameterInfo parameterInfo) { - if (userInfo.getTenant() == null && userInfo.isSystemUser()) { - // technical provider user runs with all feature toggles - return FeatureTogglesInfo.all(); - } + @Override + public FeatureTogglesInfo get(UserInfo userInfo, ParameterInfo parameterInfo) { + if (userInfo.getTenant() == null && userInfo.isSystemUser()) { + // technical provider user runs with all feature toggles + return FeatureTogglesInfo.all(); + } - Map toggles = new HashMap<>(); - toggles.put("isbn", userInfo.hasRole("expert")); - toggles.put("discount", userInfo.hasRole("premium-customer")); - return FeatureTogglesInfo.create(toggles); - } + Map toggles = new HashMap<>(); + toggles.put("isbn", userInfo.hasRole("expert")); + toggles.put("discount", userInfo.hasRole("premium-customer")); + return FeatureTogglesInfo.create(toggles); + } } diff --git a/srv/src/main/java/my/bookshop/config/DestinationConfiguration.java b/srv/src/main/java/my/bookshop/config/DestinationConfiguration.java index 94f796a4..afb86753 100644 --- a/srv/src/main/java/my/bookshop/config/DestinationConfiguration.java +++ b/srv/src/main/java/my/bookshop/config/DestinationConfiguration.java @@ -1,5 +1,9 @@ package my.bookshop.config; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultDestinationLoader; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationAccessor; +import com.sap.cloud.sdk.cloudplatform.security.BasicCredentials; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; @@ -7,31 +11,26 @@ import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; -import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultDestinationLoader; -import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; -import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationAccessor; -import com.sap.cloud.sdk.cloudplatform.security.BasicCredentials; - @Component @Profile("mocked") public class DestinationConfiguration { - @Autowired - private Environment environment; - - @EventListener - void applicationReady(ApplicationReadyEvent ready) { - Integer port = environment.getProperty("local.server.port", Integer.class); - String destinationName = environment.getProperty("cds.remote.services.'[API_BUSINESS_PARTNER]'.destination.name"); - if(port != null && destinationName != null) { - DefaultHttpDestination httpDestination = DefaultHttpDestination - .builder("http://localhost:" + port) - .basicCredentials(new BasicCredentials("authenticated", "")) - .name(destinationName).build(); + @Autowired private Environment environment; - DestinationAccessor.prependDestinationLoader( - new DefaultDestinationLoader().registerDestination(httpDestination)); - } - } + @EventListener + void applicationReady(ApplicationReadyEvent ready) { + Integer port = environment.getProperty("local.server.port", Integer.class); + String destinationName = + environment.getProperty("cds.remote.services.'[API_BUSINESS_PARTNER]'.destination.name"); + if (port != null && destinationName != null) { + DefaultHttpDestination httpDestination = + DefaultHttpDestination.builder("http://localhost:" + port) + .basicCredentials(new BasicCredentials("authenticated", "")) + .name(destinationName) + .build(); + DestinationAccessor.prependDestinationLoader( + new DefaultDestinationLoader().registerDestination(httpDestination)); + } + } } diff --git a/srv/src/main/java/my/bookshop/config/SwaggerResourceConfig.java b/srv/src/main/java/my/bookshop/config/SwaggerResourceConfig.java index af8b24b2..b6f319a3 100644 --- a/srv/src/main/java/my/bookshop/config/SwaggerResourceConfig.java +++ b/srv/src/main/java/my/bookshop/config/SwaggerResourceConfig.java @@ -7,8 +7,8 @@ @Configuration public class SwaggerResourceConfig implements WebMvcConfigurer { - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/swagger/**").addResourceLocations("classpath:/swagger/"); - } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/swagger/**").addResourceLocations("classpath:/swagger/"); + } } diff --git a/srv/src/main/java/my/bookshop/config/WebSecurityConfig.java b/srv/src/main/java/my/bookshop/config/WebSecurityConfig.java index f42aaba4..bd90cd29 100644 --- a/srv/src/main/java/my/bookshop/config/WebSecurityConfig.java +++ b/srv/src/main/java/my/bookshop/config/WebSecurityConfig.java @@ -14,11 +14,15 @@ @EnableWebSecurity public class WebSecurityConfig { - @Bean - @Order(1) - public SecurityFilterChain configure(HttpSecurity http) throws Exception { - return http.securityMatchers(s -> s.requestMatchers(PathPatternRequestMatcher.withDefaults().matcher("/swagger/**"))) // - .csrf(c -> c.disable()).authorizeHttpRequests(a -> a.anyRequest().permitAll()) - .build(); - } + @Bean + @Order(1) + public SecurityFilterChain configure(HttpSecurity http) throws Exception { + return http.securityMatchers( + s -> + s.requestMatchers( + PathPatternRequestMatcher.withDefaults().matcher("/swagger/**"))) // + .csrf(c -> c.disable()) + .authorizeHttpRequests(a -> a.anyRequest().permitAll()) + .build(); + } } diff --git a/srv/src/main/java/my/bookshop/handlers/AdminServiceAddressHandler.java b/srv/src/main/java/my/bookshop/handlers/AdminServiceAddressHandler.java index 240e1025..04d10b74 100644 --- a/srv/src/main/java/my/bookshop/handlers/AdminServiceAddressHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/AdminServiceAddressHandler.java @@ -39,112 +39,144 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; -/** - * Custom handler for the Admin Service Addresses, which come from a remote S/4 System - */ +/** Custom handler for the Admin Service Addresses, which come from a remote S/4 System */ @Component @ServiceName(AdminService_.CDS_NAME) public class AdminServiceAddressHandler implements EventHandler { - private final static Logger logger = LoggerFactory.getLogger(AdminServiceAddressHandler.class); - - // We are mashing up the AdminService with two other services... - private final PersistenceService db; - private final ApiBusinessPartner bupa; - - AdminServiceAddressHandler(PersistenceService db, @Qualifier(ApiBusinessPartner_.CDS_NAME) ApiBusinessPartner bupa) { - this.db = db; - this.bupa = bupa; - } - - // Delegate ValueHelp requests to S/4 backend, fetching current user's addresses from there - @On(entity = Addresses_.CDS_NAME) - public void readAddresses(CdsReadEventContext context) { - if(context.getCqn().ref().segments().size() != 1) { - return; // no value help request - } - - // add BusinessPartner where condition - String businessPartner = context.getUserInfo().getAttributeValues("businessPartner").stream().findFirst() - .orElseThrow(() -> new ServiceException(ErrorStatuses.FORBIDDEN, MessageKeys.BUPA_MISSING)); - - CqnSelect select = CQL.copy(context.getCqn(), new Modifier() { - - public Predicate where(Predicate original) { - Predicate where = CQL.get(Addresses.BUSINESS_PARTNER).eq(businessPartner); - if(original != null) { - where = original.and(where); - } - return where; - } - - }); - - // using Cloud SDK resilience capabilities.. - ResilienceConfiguration config = ResilienceConfiguration.of(AdminServiceAddressHandler.class) - .timeLimiterConfiguration(TimeLimiterConfiguration.of(Duration.ofSeconds(10))); - - context.setResult(ResilienceDecorator.executeSupplier(() -> { - // ..to access the S/4 system in a resilient way.. - logger.info("Delegating GET Addresses to S/4 service"); - return bupa.run(select); - }, config, (t) -> { - // ..falling back to the already replicated addresses in our own database - logger.warn("Falling back to already replicated Addresses"); - return db.run(select); - })); - } - - // Replicate chosen addresses from S/4 when filling orders - @Before(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, DraftService.EVENT_DRAFT_PATCH }) - public void patchAddressId(EventContext context, Stream orders) { - String businessPartner = context.getUserInfo().getAttributeValues("businessPartner").stream().findFirst() - .orElseThrow(() -> new ServiceException(ErrorStatuses.FORBIDDEN, MessageKeys.BUPA_MISSING)); - - orders.filter(o -> o.getShippingAddressId() != null).forEach(order -> { - String addressId = order.getShippingAddressId(); - var replica = db.run(Select.from(ADDRESSES).where(a -> a.businessPartner().eq(businessPartner).and(a.ID().eq(addressId)))); - // check if the address was not yet replicated - if(replica.rowCount() < 1) { - logger.info("Replicating Address '{}' from S/4 service", addressId); - Addresses remoteAddress = bupa.run(Select.from(ADDRESSES) - .where(a -> a.businessPartner().eq(businessPartner).and(a.ID().eq(addressId)))) - .single(); - - remoteAddress.setTombstone(false); - db.run(Insert.into(ADDRESSES).entry(remoteAddress)); - } - order.setShippingAddressBusinessPartner(businessPartner); - }); - } - - @On(service = ApiBusinessPartner_.CDS_NAME) - public void updateBusinessPartnerAddresses(BusinessPartnerChangedContext context) { - logger.info(">> received: " + context.getData()); - String businessPartner = context.getData().getBusinessPartner(); - - // fetch affected entries from local replicas - var replicas = db.run(Select.from(ADDRESSES).where(a -> a.businessPartner().eq(businessPartner))); - if(replicas.rowCount() > 0) { - logger.info("Updating Addresses for BusinessPartner '{}'", businessPartner); - // fetch changed data from S/4 -> might be less than local due to deletes - var remoteAddresses = bupa.run(Select.from(ADDRESSES).where(a -> a.businessPartner().eq(businessPartner))); - // update replicas or add tombstone if external address was deleted - replicas.stream().forEach(rep -> { - Optional matching = remoteAddresses - .stream() - .filter(ext -> ext.getId().equals(rep.getId())) - .findFirst(); - - if(matching.isEmpty()) { - rep.setTombstone(true); - } else { - matching.get().forEach(rep::put); - } - }); - // update local replicas with changes from S/4 - db.run(Upsert.into(ADDRESSES).entries(replicas)); - } - } - + private static final Logger logger = LoggerFactory.getLogger(AdminServiceAddressHandler.class); + + // We are mashing up the AdminService with two other services... + private final PersistenceService db; + private final ApiBusinessPartner bupa; + + AdminServiceAddressHandler( + PersistenceService db, @Qualifier(ApiBusinessPartner_.CDS_NAME) ApiBusinessPartner bupa) { + this.db = db; + this.bupa = bupa; + } + + // Delegate ValueHelp requests to S/4 backend, fetching current user's addresses from there + @On(entity = Addresses_.CDS_NAME) + public void readAddresses(CdsReadEventContext context) { + if (context.getCqn().ref().segments().size() != 1) { + return; // no value help request + } + + // add BusinessPartner where condition + String businessPartner = + context.getUserInfo().getAttributeValues("businessPartner").stream() + .findFirst() + .orElseThrow( + () -> new ServiceException(ErrorStatuses.FORBIDDEN, MessageKeys.BUPA_MISSING)); + + CqnSelect select = + CQL.copy( + context.getCqn(), + new Modifier() { + + public Predicate where(Predicate original) { + Predicate where = CQL.get(Addresses.BUSINESS_PARTNER).eq(businessPartner); + if (original != null) { + where = original.and(where); + } + return where; + } + }); + + // using Cloud SDK resilience capabilities.. + ResilienceConfiguration config = + ResilienceConfiguration.of(AdminServiceAddressHandler.class) + .timeLimiterConfiguration(TimeLimiterConfiguration.of(Duration.ofSeconds(10))); + + context.setResult( + ResilienceDecorator.executeSupplier( + () -> { + // ..to access the S/4 system in a resilient way.. + logger.info("Delegating GET Addresses to S/4 service"); + return bupa.run(select); + }, + config, + (t) -> { + // ..falling back to the already replicated addresses in our own database + logger.warn("Falling back to already replicated Addresses"); + return db.run(select); + })); + } + + // Replicate chosen addresses from S/4 when filling orders + @Before( + event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, DraftService.EVENT_DRAFT_PATCH}) + public void patchAddressId(EventContext context, Stream orders) { + String businessPartner = + context.getUserInfo().getAttributeValues("businessPartner").stream() + .findFirst() + .orElseThrow( + () -> new ServiceException(ErrorStatuses.FORBIDDEN, MessageKeys.BUPA_MISSING)); + + orders + .filter(o -> o.getShippingAddressId() != null) + .forEach( + order -> { + String addressId = order.getShippingAddressId(); + var replica = + db.run( + Select.from(ADDRESSES) + .where( + a -> + a.businessPartner() + .eq(businessPartner) + .and(a.ID().eq(addressId)))); + // check if the address was not yet replicated + if (replica.rowCount() < 1) { + logger.info("Replicating Address '{}' from S/4 service", addressId); + Addresses remoteAddress = + bupa.run( + Select.from(ADDRESSES) + .where( + a -> + a.businessPartner() + .eq(businessPartner) + .and(a.ID().eq(addressId)))) + .single(); + + remoteAddress.setTombstone(false); + db.run(Insert.into(ADDRESSES).entry(remoteAddress)); + } + order.setShippingAddressBusinessPartner(businessPartner); + }); + } + + @On(service = ApiBusinessPartner_.CDS_NAME) + public void updateBusinessPartnerAddresses(BusinessPartnerChangedContext context) { + logger.info(">> received: " + context.getData()); + String businessPartner = context.getData().getBusinessPartner(); + + // fetch affected entries from local replicas + var replicas = + db.run(Select.from(ADDRESSES).where(a -> a.businessPartner().eq(businessPartner))); + if (replicas.rowCount() > 0) { + logger.info("Updating Addresses for BusinessPartner '{}'", businessPartner); + // fetch changed data from S/4 -> might be less than local due to deletes + var remoteAddresses = + bupa.run(Select.from(ADDRESSES).where(a -> a.businessPartner().eq(businessPartner))); + // update replicas or add tombstone if external address was deleted + replicas.stream() + .forEach( + rep -> { + Optional matching = + remoteAddresses.stream() + .filter(ext -> ext.getId().equals(rep.getId())) + .findFirst(); + + if (matching.isEmpty()) { + rep.setTombstone(true); + } else { + matching.get().forEach(rep::put); + } + }); + // update local replicas with changes from S/4 + db.run(Upsert.into(ADDRESSES).entries(replicas)); + } + } } diff --git a/srv/src/main/java/my/bookshop/handlers/AdminServiceAuditHandler.java b/srv/src/main/java/my/bookshop/handlers/AdminServiceAuditHandler.java index c9ed7176..bc8d294c 100644 --- a/srv/src/main/java/my/bookshop/handlers/AdminServiceAuditHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/AdminServiceAuditHandler.java @@ -26,116 +26,130 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; -/** - * A custom handler that creates AuditLog messages. - */ +/** A custom handler that creates AuditLog messages. */ @Component @ServiceName(AdminService_.CDS_NAME) class AdminServiceAuditHandler implements EventHandler { - private final PersistenceService db; - - private final AuditLogService auditLog; - - AdminServiceAuditHandler(PersistenceService db, AuditLogService auditLog) { - this.db = db; - this.auditLog = auditLog; - } - - @Before(event = { CqnService.EVENT_CREATE }) - public void beforeCreateOrder(Stream orders) { - orders.forEach(order -> { - ConfigChange cfgChange = createConfigChange(order, null); - this.auditLog.logConfigChange(Action.CREATE, cfgChange); - }); - } - - @Before(event = { CqnService.EVENT_UPDATE, CqnService.EVENT_UPSERT }) - public void beforeUpdateOrUpsertOrder(EventContext context, Stream orders) { - orders.forEach(order -> { - ConfigChange cfgChange = null; - Action action = null; - Optional oldOrders = readOldOrders(order.getId()); - if (oldOrders.isPresent()) { - if (!StringUtils.equals(order.getCurrencyCode(), oldOrders.get().getCurrencyCode())) { - cfgChange = createConfigChange(order, oldOrders.get()); - action = Action.UPDATE; - } - } else { - cfgChange = createConfigChange(order, null); - action = Action.CREATE; - } - if (cfgChange != null && action != null) { - auditCfgChange(action, cfgChange, context); - } - }); - } - - @Before(entity = { Orders_.CDS_NAME }) - public void beforeDelete(CdsDeleteEventContext context) { - // prepare a select statement to read old currency code - Select ordersSelect = toSelect(context.getCqn()); - - // read old order number from DB - this.db.run(ordersSelect).first(Orders.class).ifPresent(oldOrders -> { - ConfigChange cfgChange = createConfigChange(null, oldOrders); - auditCfgChange(Action.DELETE, cfgChange, context); - }); - } - - private void auditCfgChange(final Action action, final ConfigChange cfgChange, EventContext context) { - // create new request context and send audit log message into provider tenant - context.getCdsRuntime().requestContext().systemUserProvider().run(ctx -> { - this.auditLog.logConfigChange(action, cfgChange); - }); - } - - private Optional readOldOrders(String ordersId) { - // prepare a select statement to read old order number - var ordersSelect = Select.from(ORDERS).columns(Orders_::OrderNo) - .where(o -> o.ID().eq(ordersId).and(o.IsActiveEntity().eq(true))); - - // read old orders from DB - return this.db.run(ordersSelect).first(); - } - - private static ConfigChange createConfigChange(Orders orders, Orders oldOrders) { - ChangedAttribute currencyCodeAttr = createChangedAttribute(orders != null ? orders.getCurrencyCode() : null, - oldOrders != null ? oldOrders.getCurrencyCode() : null); - - ConfigChange cfgChange = ConfigChange.create(); - cfgChange.setDataObject(createDataObject(orders != null ? orders : oldOrders)); - cfgChange.setAttributes(Arrays.asList(currencyCodeAttr)); - return cfgChange; - } - - private static DataObject createDataObject(Orders order) { - KeyValuePair id = createId(order); - - DataObject dataObject = DataObject.create(); - dataObject.setType(Orders_.CDS_NAME); - dataObject.setId(Arrays.asList(id)); - return dataObject; - } - - private static ChangedAttribute createChangedAttribute(String newValue, String oldValue) { - ChangedAttribute attribute = ChangedAttribute.create(); - attribute.setName(Orders.CURRENCY_CODE); - attribute.setOldValue(oldValue); - attribute.setNewValue(newValue); - return attribute; - } - - private static KeyValuePair createId(Orders order) { - KeyValuePair id = KeyValuePair.create(); - id.setKeyName(Orders.ID); - id.setValue(order.getId()); - return id; - } - - private static Select toSelect(CqnDelete delete) { - Select select = Select.from(delete.ref()); - delete.where().ifPresent(select::where); - return select; - } + private final PersistenceService db; + + private final AuditLogService auditLog; + + AdminServiceAuditHandler(PersistenceService db, AuditLogService auditLog) { + this.db = db; + this.auditLog = auditLog; + } + + @Before(event = {CqnService.EVENT_CREATE}) + public void beforeCreateOrder(Stream orders) { + orders.forEach( + order -> { + ConfigChange cfgChange = createConfigChange(order, null); + this.auditLog.logConfigChange(Action.CREATE, cfgChange); + }); + } + + @Before(event = {CqnService.EVENT_UPDATE, CqnService.EVENT_UPSERT}) + public void beforeUpdateOrUpsertOrder(EventContext context, Stream orders) { + orders.forEach( + order -> { + ConfigChange cfgChange = null; + Action action = null; + Optional oldOrders = readOldOrders(order.getId()); + if (oldOrders.isPresent()) { + if (!StringUtils.equals(order.getCurrencyCode(), oldOrders.get().getCurrencyCode())) { + cfgChange = createConfigChange(order, oldOrders.get()); + action = Action.UPDATE; + } + } else { + cfgChange = createConfigChange(order, null); + action = Action.CREATE; + } + if (cfgChange != null && action != null) { + auditCfgChange(action, cfgChange, context); + } + }); + } + + @Before(entity = {Orders_.CDS_NAME}) + public void beforeDelete(CdsDeleteEventContext context) { + // prepare a select statement to read old currency code + Select ordersSelect = toSelect(context.getCqn()); + + // read old order number from DB + this.db + .run(ordersSelect) + .first(Orders.class) + .ifPresent( + oldOrders -> { + ConfigChange cfgChange = createConfigChange(null, oldOrders); + auditCfgChange(Action.DELETE, cfgChange, context); + }); + } + + private void auditCfgChange( + final Action action, final ConfigChange cfgChange, EventContext context) { + // create new request context and send audit log message into provider tenant + context + .getCdsRuntime() + .requestContext() + .systemUserProvider() + .run( + ctx -> { + this.auditLog.logConfigChange(action, cfgChange); + }); + } + + private Optional readOldOrders(String ordersId) { + // prepare a select statement to read old order number + var ordersSelect = + Select.from(ORDERS) + .columns(Orders_::OrderNo) + .where(o -> o.ID().eq(ordersId).and(o.IsActiveEntity().eq(true))); + + // read old orders from DB + return this.db.run(ordersSelect).first(); + } + + private static ConfigChange createConfigChange(Orders orders, Orders oldOrders) { + ChangedAttribute currencyCodeAttr = + createChangedAttribute( + orders != null ? orders.getCurrencyCode() : null, + oldOrders != null ? oldOrders.getCurrencyCode() : null); + + ConfigChange cfgChange = ConfigChange.create(); + cfgChange.setDataObject(createDataObject(orders != null ? orders : oldOrders)); + cfgChange.setAttributes(Arrays.asList(currencyCodeAttr)); + return cfgChange; + } + + private static DataObject createDataObject(Orders order) { + KeyValuePair id = createId(order); + + DataObject dataObject = DataObject.create(); + dataObject.setType(Orders_.CDS_NAME); + dataObject.setId(Arrays.asList(id)); + return dataObject; + } + + private static ChangedAttribute createChangedAttribute(String newValue, String oldValue) { + ChangedAttribute attribute = ChangedAttribute.create(); + attribute.setName(Orders.CURRENCY_CODE); + attribute.setOldValue(oldValue); + attribute.setNewValue(newValue); + return attribute; + } + + private static KeyValuePair createId(Orders order) { + KeyValuePair id = KeyValuePair.create(); + id.setKeyName(Orders.ID); + id.setValue(order.getId()); + return id; + } + + private static Select toSelect(CqnDelete delete) { + Select select = Select.from(delete.ref()); + delete.where().ifPresent(select::where); + return select; + } } diff --git a/srv/src/main/java/my/bookshop/handlers/AdminServiceHandler.java b/srv/src/main/java/my/bookshop/handlers/AdminServiceHandler.java index a7245171..171d04a3 100644 --- a/srv/src/main/java/my/bookshop/handlers/AdminServiceHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/AdminServiceHandler.java @@ -51,240 +51,306 @@ /** * Custom business logic for the "Admin Service" (see admin-service.cds) * - * Handles creating and editing orders. + *

Handles creating and editing orders. */ @Component @ServiceName(AdminService_.CDS_NAME) class AdminServiceHandler implements EventHandler { - private final AdminService.Draft adminService; - private final PersistenceService db; - private final Messages messages; - private final CqnAnalyzer analyzer; - - AdminServiceHandler(AdminService.Draft adminService, PersistenceService db, Messages messages, CdsModel model) { - this.adminService = adminService; - this.db = db; - this.messages = messages; - - // model is a tenant-dependant model proxy - this.analyzer = CqnAnalyzer.create(model); - } - - /** - * Validate correctness of an order before finishing the order proces: - * 1. Check Order quantity for each Item and return a message if quantity is empty or <= 0 - * 2. Check Order quantity for each Item is available, return message if the stock is too low - * - * @param orders - */ - @Before(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPSERT, CqnService.EVENT_UPDATE }) - public void beforeCreateOrder(Stream orders, EventContext context) { - orders.forEach(order -> { - // reset total - order.setTotal(BigDecimal.valueOf(0)); - if(order.getItems() != null) { - order.getItems().forEach(orderItem -> { - // validation of the Order creation request - Integer quantity = orderItem.getQuantity(); - String bookId = orderItem.getBookId(); - - if(quantity == null || quantity <= 0 || bookId == null) { - return; // follow up validations rely on these - } - - // calculate the actual quantity difference - // FIXME this should handle book changes, currently only quantity changes are handled - int diffQuantity = quantity - db.run(Select.from(Bookshop_.ORDER_ITEMS).columns(i -> i.quantity()).byId(orderItem.getId())) - .first().map(i -> i.getQuantity()).orElse(0); - - // check if enough books are available - var result = db.run(Select.from(BOOKS).columns(b -> b.ID(), b -> b.stock(), b -> b.price()).byId(bookId)); - result.first().ifPresent(book -> { - if (book.getStock() < diffQuantity) { - // Tip: you can have localized messages and use parameters in your messages - messages.error(MessageKeys.BOOK_REQUIRE_STOCK, book.getStock()) - .target(ORDERS, o -> o.Items(i -> i.ID().eq(orderItem.getId()).and(i.IsActiveEntity().eq(orderItem.getIsActiveEntity()))).quantity()); - return; // no need to update follow-up values with invalid quantity / stock - } - - // update the book with the new stock - book.setStock(book.getStock() - diffQuantity); - db.run(Update.entity(BOOKS).data(book)); - - // update the amount - BigDecimal updatedAmount = book.getPrice().multiply(BigDecimal.valueOf(quantity)); - orderItem.setAmount(updatedAmount); - - // update the total - order.setTotal(order.getTotal().add(updatedAmount)); - }); - }); - } - }); - } - - /* - * Calculate the total order value preview when editing an order item - */ - @Before - public void patchOrderItems(DraftPatchEventContext context, OrderItems_ ref, OrderItems orderItem) { - // check if quantity or book was updated - Integer quantity = orderItem.getQuantity(); - String bookId = orderItem.getBookId(); - BigDecimal amount = calculateAmountInDraft(ref, quantity, bookId); - if (amount != null) { - orderItem.setAmount(amount); - } - } - - /* - * Calculate the total order value preview when deleting an order item from the order - */ - @Before - public void cancelOrderItems(DraftCancelEventContext context, OrderItems_ ref) { - if(ref.asRef().targetSegment().filter().isPresent()) { - calculateAmountInDraft(ref, 0, null); - } - } - - private BigDecimal calculateAmountInDraft(OrderItems_ ref, Integer newQuantity, String newBookId) { - Integer quantity = newQuantity; - String bookId = newBookId; - if (quantity == null && bookId == null) { - return null; // nothing changed - } - - // get the order item that was updated (to get access to the book price, quantity and order total) - var result = adminService.run(Select.from(ref) - .columns(o -> o.quantity(), o -> o.amount(), - o -> o.book().expand(b -> b.ID(), b -> b.price()), - o -> o.parent().expand(p -> p.ID(), p -> p.total()))); - OrderItems itemToPatch = result.single(); - BigDecimal bookPrice = null; - - // fallback to existing values - if(quantity == null) { - quantity = itemToPatch.getQuantity(); - } - - if(bookId == null && itemToPatch.getBook() != null) { - bookId = itemToPatch.getBook().getId(); - bookPrice = itemToPatch.getBook().getPrice(); - } - - if(quantity == null || bookId == null) { - return null; // not enough data available - } - - // get the price of the updated book ID - if(bookPrice == null) { - var bookResult = db.run(Select.from(BOOKS).byId(bookId).columns(b -> b.price())); - bookPrice = bookResult.single().getPrice(); - } - - // update the amount of the order item - BigDecimal updatedAmount = bookPrice.multiply(BigDecimal.valueOf(quantity)); - - // update the order's total - BigDecimal previousAmount = defaultZero(itemToPatch.getAmount()); - BigDecimal currentTotal = defaultZero(itemToPatch.getParent().getTotal()); - BigDecimal newTotal = currentTotal.subtract(previousAmount).add(updatedAmount); - adminService.patchDraft(Update.entity(ORDERS) - .where(o -> o.ID().eq(itemToPatch.getParent().getId()).and(o.IsActiveEntity().eq(false))) - .data(Orders.TOTAL, newTotal)); - - return updatedAmount; - } - - /** - * Adds a book to an order - * @param context - */ - @On(entity = Books_.CDS_NAME) - public Orders addBookToOrder(BooksAddToOrderContext context) { - String orderId = context.getOrderId(); - List orders = adminService.run(Select.from(ORDERS).columns(o -> o._all(), o -> o.Items().expand()).where(o -> o.ID().eq(orderId))).list(); - Orders order = orders.stream().filter(p -> p.getIsActiveEntity()).findFirst().orElse(null); - - // check that the order with given ID exists and is not in draft-mode - if((orders.size() > 0 && order == null) || orders.size() > 1) { - throw new ServiceException(ErrorStatuses.CONFLICT, MessageKeys.ORDER_INDRAFT); - } else if (orders.size() <= 0) { - throw new ServiceException(ErrorStatuses.NOT_FOUND, MessageKeys.ORDER_MISSING); - } - - if(order.getItems() == null) { - order.setItems(new ArrayList<>()); - } - - // get ID of the book on which the action was called (bound action) - String bookId = (String) analyzer.analyze(context.getCqn()).targetKeys().get(Books.ID); - - // create order item - OrderItems newItem = OrderItems.create(); - newItem.setId(UUID.randomUUID().toString()); - newItem.setBookId(bookId); - newItem.setQuantity(context.getQuantity()); - order.getItems().add(newItem); - - Orders updatedOrder = adminService.run(Update.entity(ORDERS).data(order)).single(); - messages.success(MessageKeys.BOOK_ADDED_ORDER); - return updatedOrder; - } - - /** - * @return the static CSV singleton upload entity - */ - @On(entity = Upload_.CDS_NAME, event = CqnService.EVENT_READ) - public Upload getUploadSingleton() { - return Upload.create(); - } - - /** - * Handles CSV uploads with book data - * @param context - * @param csv - */ - @On - public List addBooksViaCsv(CdsUpdateEventContext context, Upload upload) { - InputStream is = upload.getCsv(); - if (is != null) { - try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) { - br.lines().skip(1).forEach((line) -> { - String[] p = line.split(";"); - Books book = Books.create(); - book.setId(p[0]); - book.setTitle(p[1]); - book.setDescr(p[2]); - book.setAuthorId(p[3]); - book.setStock(Integer.valueOf(p[4]).intValue()); - book.setPrice(BigDecimal.valueOf(Double.valueOf(p[5]))); - book.setCurrencyCode(p[6]); - book.setGenreId(String.valueOf(p[7])); - - // separate transaction per line - context.getCdsRuntime().changeSetContext().run(ctx -> { - db.run(Upsert.into(BOOKS).entry(book)); - }); - }); - } catch (IOException e) { - throw new ServiceException(ErrorStatuses.SERVER_ERROR, MessageKeys.BOOK_IMPORT_FAILED, e); - } catch (IndexOutOfBoundsException e) { - throw new ServiceException(ErrorStatuses.SERVER_ERROR, MessageKeys.BOOK_IMPORT_INVALID_CSV, e); - } - } - return Arrays.asList(upload); - } - - @Before(event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, DraftService.EVENT_DRAFT_NEW, DraftService.EVENT_DRAFT_PATCH}) - public void restoreCoversUpId(CqnStructuredTypeRef ref, BooksCovers cover) { - // restore up__ID, which is not provided via OData due to containment - cover.setUpId((String) analyzer.analyze(ref).rootKeys().get(Books.ID)); - } - - private BigDecimal defaultZero(BigDecimal decimal) { - return decimal == null ? BigDecimal.valueOf(0) : decimal; - } - + private final AdminService.Draft adminService; + private final PersistenceService db; + private final Messages messages; + private final CqnAnalyzer analyzer; + + AdminServiceHandler( + AdminService.Draft adminService, PersistenceService db, Messages messages, CdsModel model) { + this.adminService = adminService; + this.db = db; + this.messages = messages; + + // model is a tenant-dependant model proxy + this.analyzer = CqnAnalyzer.create(model); + } + + /** + * Validate correctness of an order before finishing the order proces: 1. Check Order quantity for + * each Item and return a message if quantity is empty or <= 0 2. Check Order quantity for each + * Item is available, return message if the stock is too low + * + * @param orders + */ + @Before(event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPSERT, CqnService.EVENT_UPDATE}) + public void beforeCreateOrder(Stream orders, EventContext context) { + orders.forEach( + order -> { + // reset total + order.setTotal(BigDecimal.valueOf(0)); + if (order.getItems() != null) { + order + .getItems() + .forEach( + orderItem -> { + // validation of the Order creation request + Integer quantity = orderItem.getQuantity(); + String bookId = orderItem.getBookId(); + + if (quantity == null || quantity <= 0 || bookId == null) { + return; // follow up validations rely on these + } + + // calculate the actual quantity difference + // FIXME this should handle book changes, currently only quantity changes are + // handled + int diffQuantity = + quantity + - db.run( + Select.from(Bookshop_.ORDER_ITEMS) + .columns(i -> i.quantity()) + .byId(orderItem.getId())) + .first() + .map(i -> i.getQuantity()) + .orElse(0); + + // check if enough books are available + var result = + db.run( + Select.from(BOOKS) + .columns(b -> b.ID(), b -> b.stock(), b -> b.price()) + .byId(bookId)); + result + .first() + .ifPresent( + book -> { + if (book.getStock() < diffQuantity) { + // Tip: you can have localized messages and use parameters in your + // messages + messages + .error(MessageKeys.BOOK_REQUIRE_STOCK, book.getStock()) + .target( + ORDERS, + o -> + o.Items( + i -> + i.ID() + .eq(orderItem.getId()) + .and( + i.IsActiveEntity() + .eq( + orderItem + .getIsActiveEntity()))) + .quantity()); + return; // no need to update follow-up values with invalid + // quantity / stock + } + + // update the book with the new stock + book.setStock(book.getStock() - diffQuantity); + db.run(Update.entity(BOOKS).data(book)); + + // update the amount + BigDecimal updatedAmount = + book.getPrice().multiply(BigDecimal.valueOf(quantity)); + orderItem.setAmount(updatedAmount); + + // update the total + order.setTotal(order.getTotal().add(updatedAmount)); + }); + }); + } + }); + } + + /* + * Calculate the total order value preview when editing an order item + */ + @Before + public void patchOrderItems( + DraftPatchEventContext context, OrderItems_ ref, OrderItems orderItem) { + // check if quantity or book was updated + Integer quantity = orderItem.getQuantity(); + String bookId = orderItem.getBookId(); + BigDecimal amount = calculateAmountInDraft(ref, quantity, bookId); + if (amount != null) { + orderItem.setAmount(amount); + } + } + + /* + * Calculate the total order value preview when deleting an order item from the order + */ + @Before + public void cancelOrderItems(DraftCancelEventContext context, OrderItems_ ref) { + if (ref.asRef().targetSegment().filter().isPresent()) { + calculateAmountInDraft(ref, 0, null); + } + } + + private BigDecimal calculateAmountInDraft( + OrderItems_ ref, Integer newQuantity, String newBookId) { + Integer quantity = newQuantity; + String bookId = newBookId; + if (quantity == null && bookId == null) { + return null; // nothing changed + } + + // get the order item that was updated (to get access to the book price, quantity and order + // total) + var result = + adminService.run( + Select.from(ref) + .columns( + o -> o.quantity(), + o -> o.amount(), + o -> o.book().expand(b -> b.ID(), b -> b.price()), + o -> o.parent().expand(p -> p.ID(), p -> p.total()))); + OrderItems itemToPatch = result.single(); + BigDecimal bookPrice = null; + + // fallback to existing values + if (quantity == null) { + quantity = itemToPatch.getQuantity(); + } + + if (bookId == null && itemToPatch.getBook() != null) { + bookId = itemToPatch.getBook().getId(); + bookPrice = itemToPatch.getBook().getPrice(); + } + + if (quantity == null || bookId == null) { + return null; // not enough data available + } + + // get the price of the updated book ID + if (bookPrice == null) { + var bookResult = db.run(Select.from(BOOKS).byId(bookId).columns(b -> b.price())); + bookPrice = bookResult.single().getPrice(); + } + + // update the amount of the order item + BigDecimal updatedAmount = bookPrice.multiply(BigDecimal.valueOf(quantity)); + + // update the order's total + BigDecimal previousAmount = defaultZero(itemToPatch.getAmount()); + BigDecimal currentTotal = defaultZero(itemToPatch.getParent().getTotal()); + BigDecimal newTotal = currentTotal.subtract(previousAmount).add(updatedAmount); + adminService.patchDraft( + Update.entity(ORDERS) + .where( + o -> o.ID().eq(itemToPatch.getParent().getId()).and(o.IsActiveEntity().eq(false))) + .data(Orders.TOTAL, newTotal)); + + return updatedAmount; + } + + /** + * Adds a book to an order + * + * @param context + */ + @On(entity = Books_.CDS_NAME) + public Orders addBookToOrder(BooksAddToOrderContext context) { + String orderId = context.getOrderId(); + List orders = + adminService + .run( + Select.from(ORDERS) + .columns(o -> o._all(), o -> o.Items().expand()) + .where(o -> o.ID().eq(orderId))) + .list(); + Orders order = orders.stream().filter(p -> p.getIsActiveEntity()).findFirst().orElse(null); + + // check that the order with given ID exists and is not in draft-mode + if ((orders.size() > 0 && order == null) || orders.size() > 1) { + throw new ServiceException(ErrorStatuses.CONFLICT, MessageKeys.ORDER_INDRAFT); + } else if (orders.size() <= 0) { + throw new ServiceException(ErrorStatuses.NOT_FOUND, MessageKeys.ORDER_MISSING); + } + + if (order.getItems() == null) { + order.setItems(new ArrayList<>()); + } + + // get ID of the book on which the action was called (bound action) + String bookId = (String) analyzer.analyze(context.getCqn()).targetKeys().get(Books.ID); + + // create order item + OrderItems newItem = OrderItems.create(); + newItem.setId(UUID.randomUUID().toString()); + newItem.setBookId(bookId); + newItem.setQuantity(context.getQuantity()); + order.getItems().add(newItem); + + Orders updatedOrder = adminService.run(Update.entity(ORDERS).data(order)).single(); + messages.success(MessageKeys.BOOK_ADDED_ORDER); + return updatedOrder; + } + + /** + * @return the static CSV singleton upload entity + */ + @On(entity = Upload_.CDS_NAME, event = CqnService.EVENT_READ) + public Upload getUploadSingleton() { + return Upload.create(); + } + + /** + * Handles CSV uploads with book data + * + * @param context + * @param csv + */ + @On + public List addBooksViaCsv(CdsUpdateEventContext context, Upload upload) { + InputStream is = upload.getCsv(); + if (is != null) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) { + br.lines() + .skip(1) + .forEach( + (line) -> { + String[] p = line.split(";"); + Books book = Books.create(); + book.setId(p[0]); + book.setTitle(p[1]); + book.setDescr(p[2]); + book.setAuthorId(p[3]); + book.setStock(Integer.valueOf(p[4]).intValue()); + book.setPrice(BigDecimal.valueOf(Double.valueOf(p[5]))); + book.setCurrencyCode(p[6]); + book.setGenreId(String.valueOf(p[7])); + + // separate transaction per line + context + .getCdsRuntime() + .changeSetContext() + .run( + ctx -> { + db.run(Upsert.into(BOOKS).entry(book)); + }); + }); + } catch (IOException e) { + throw new ServiceException(ErrorStatuses.SERVER_ERROR, MessageKeys.BOOK_IMPORT_FAILED, e); + } catch (IndexOutOfBoundsException e) { + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, MessageKeys.BOOK_IMPORT_INVALID_CSV, e); + } + } + return Arrays.asList(upload); + } + + @Before( + event = { + CqnService.EVENT_CREATE, + CqnService.EVENT_UPDATE, + DraftService.EVENT_DRAFT_NEW, + DraftService.EVENT_DRAFT_PATCH + }) + public void restoreCoversUpId(CqnStructuredTypeRef ref, BooksCovers cover) { + // restore up__ID, which is not provided via OData due to containment + cover.setUpId((String) analyzer.analyze(ref).rootKeys().get(Books.ID)); + } + + private BigDecimal defaultZero(BigDecimal decimal) { + return decimal == null ? BigDecimal.valueOf(0) : decimal; + } } diff --git a/srv/src/main/java/my/bookshop/handlers/BookRatingInitialization.java b/srv/src/main/java/my/bookshop/handlers/BookRatingInitialization.java index 6e482dfd..2297505c 100644 --- a/srv/src/main/java/my/bookshop/handlers/BookRatingInitialization.java +++ b/srv/src/main/java/my/bookshop/handlers/BookRatingInitialization.java @@ -1,31 +1,27 @@ package my.bookshop.handlers; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; - import my.bookshop.RatingCalculator; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; -/** - * Initializes the book ratings based on their review ratings. - */ +/** Initializes the book ratings based on their review ratings. */ @Component @Profile("default") @ServiceName(ApplicationLifecycleService.DEFAULT_NAME) public class BookRatingInitialization implements EventHandler { - private RatingCalculator ratingCalculator; + private RatingCalculator ratingCalculator; - BookRatingInitialization(RatingCalculator ratingCalculator) { - this.ratingCalculator = ratingCalculator; - } + BookRatingInitialization(RatingCalculator ratingCalculator) { + this.ratingCalculator = ratingCalculator; + } - @After(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) - public void initBookRatings() { - this.ratingCalculator.initBookRatings(); - } + @After(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) + public void initBookRatings() { + this.ratingCalculator.initBookRatings(); + } } diff --git a/srv/src/main/java/my/bookshop/handlers/CatalogServiceHandler.java b/srv/src/main/java/my/bookshop/handlers/CatalogServiceHandler.java index e61c1821..b443ff95 100644 --- a/srv/src/main/java/my/bookshop/handlers/CatalogServiceHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/CatalogServiceHandler.java @@ -43,147 +43,165 @@ /** * Custom business logic for the "Catalog Service" (see cat-service.cds) * - * Handles Reading of Books + *

Handles Reading of Books * - * Adds Discount Message to the Book Title if too much stock is available + *

Adds Discount Message to the Book Title if too much stock is available * - * Provides adding book reviews + *

Provides adding book reviews */ @Component @ServiceName(CatalogService_.CDS_NAME) class CatalogServiceHandler implements EventHandler { - private final PersistenceService db; - private final ReviewService reviewService; - - private final Messages messages; - private final FeatureTogglesInfo featureToggles; - private final RatingCalculator ratingCalculator; - private final CqnAnalyzer analyzer; - - CatalogServiceHandler(PersistenceService db, ReviewService reviewService, Messages messages, - FeatureTogglesInfo featureToggles, RatingCalculator ratingCalculator, CdsModel model) { - this.db = db; - this.reviewService = reviewService; - this.messages = messages; - this.featureToggles = featureToggles; - this.ratingCalculator = ratingCalculator; - this.analyzer = CqnAnalyzer.create(model); - } - - @Before(entity = Books_.CDS_NAME) - public void alwaysSelectStock(CdsReadEventContext context) { - CqnSelect copy = CQL.copy(context.getCqn(), new Modifier() { - @Override - public List items(List items) { - var paths = items.stream().filter(i -> i.isRef()).map(i -> i.asRef().path()).collect(Collectors.toSet()); - if (paths.contains(Books.TITLE) && !paths.contains(Books.STOCK)) { - items.add(CQL.get(Books.STOCK)); - } - return items; - } - }); - context.setCqn(copy); - } - - /* - * Invokes some validations before creating a review. - */ - @Before - public void beforeAddReview(Books_ ref, BooksAddReviewContext context) { - String user = context.getUserInfo().getName(); - - var result = db.run(Select.from(ref.reviews()) - .where(review -> review.createdBy().eq(user))); - - if (result.first().isPresent()) { - throw new ServiceException(ErrorStatuses.METHOD_NOT_ALLOWED, MessageKeys.REVIEW_ADD_FORBIDDEN); - } - } - - /** - * Handles the review creation from the given context. - * - * @param context {@link ReviewContext} - */ - @On - public Reviews onAddReview(Books_ ref, BooksAddReviewContext context) { - String bookId = (String) analyzer.analyze(context.getCqn()).targetKeys().get(Books.ID); - cds.gen.reviewservice.Reviews review = cds.gen.reviewservice.Reviews.create(); - review.setBookId(bookId); - review.setRating(context.getRating()); - review.setTitle(context.getTitle()); - review.setText(context.getText()); - - Result res = reviewService.run(Insert.into(ReviewService_.REVIEWS).entry(review)); - - messages.success(MessageKeys.REVIEW_ADDED); - return res.single(Reviews.class); - } - - /** - * Recalculates and sets the book rating after a new review for the given book. - * - * @param context {@link ReviewContext} - */ - @After(entity = Books_.CDS_NAME) - public void afterAddReview(BooksAddReviewContext context) { - ratingCalculator.setBookRating(context.getResult().getBookId()); - } - - @After(event = CqnService.EVENT_READ) - public void discountBooks(Stream books) { - books.filter(b -> b.getTitle() != null).forEach(b -> { - discountBooksWithMoreThan111Stock(b, featureToggles.isEnabled("discount")); - }); - } - - @After - public void setIsReviewable(CdsReadEventContext context, List books) { - String user = context.getUserInfo().getName(); - List bookIds = books.stream().filter(b -> b.getId() != null).map(b -> b.getId()) - .collect(Collectors.toList()); - - if (bookIds.isEmpty()) { - return; - } - - var query = Select.from(BOOKS, b -> b.filter(b.ID().in(bookIds)).reviews()) - .where(r -> r.createdBy().eq(user)); - - Set reviewedBooks = db.run(query).stream().map(Reviews::getBookId) - .collect(Collectors.toSet()); - - for (Books book : books) { - if (reviewedBooks.contains(book.getId())) { - book.setIsReviewable(false); - } - } - } - - @On - public SubmitOrderContext.ReturnType onSubmitOrder(SubmitOrderContext context) { - Integer quantity = context.getQuantity(); - String bookId = context.getBook(); - - Books book = db.run(Select.from(BOOKS).columns(Books_::stock).byId(bookId)).single(); - int stock = book.getStock(); - - if (stock >= quantity) { - db.run(Update.entity(BOOKS).byId(bookId).data(Books.STOCK, stock -= quantity)); - - SubmitOrderContext.ReturnType result = SubmitOrderContext.ReturnType.create(); - result.setStock(stock); - return result; - } else { - throw new ServiceException(ErrorStatuses.CONFLICT, MessageKeys.ORDER_EXCEEDS_STOCK, quantity); - } - } - - private void discountBooksWithMoreThan111Stock(Books b, boolean premium) { - if (b.getStock() != null && b.getStock() > 111) { - b.setTitle("%s -- %s%% discount".formatted(b.getTitle(), premium ? 14 : 11)); - } - } - + private final PersistenceService db; + private final ReviewService reviewService; + + private final Messages messages; + private final FeatureTogglesInfo featureToggles; + private final RatingCalculator ratingCalculator; + private final CqnAnalyzer analyzer; + + CatalogServiceHandler( + PersistenceService db, + ReviewService reviewService, + Messages messages, + FeatureTogglesInfo featureToggles, + RatingCalculator ratingCalculator, + CdsModel model) { + this.db = db; + this.reviewService = reviewService; + this.messages = messages; + this.featureToggles = featureToggles; + this.ratingCalculator = ratingCalculator; + this.analyzer = CqnAnalyzer.create(model); + } + + @Before(entity = Books_.CDS_NAME) + public void alwaysSelectStock(CdsReadEventContext context) { + CqnSelect copy = + CQL.copy( + context.getCqn(), + new Modifier() { + @Override + public List items(List items) { + var paths = + items.stream() + .filter(i -> i.isRef()) + .map(i -> i.asRef().path()) + .collect(Collectors.toSet()); + if (paths.contains(Books.TITLE) && !paths.contains(Books.STOCK)) { + items.add(CQL.get(Books.STOCK)); + } + return items; + } + }); + context.setCqn(copy); + } + + /* + * Invokes some validations before creating a review. + */ + @Before + public void beforeAddReview(Books_ ref, BooksAddReviewContext context) { + String user = context.getUserInfo().getName(); + + var result = db.run(Select.from(ref.reviews()).where(review -> review.createdBy().eq(user))); + + if (result.first().isPresent()) { + throw new ServiceException( + ErrorStatuses.METHOD_NOT_ALLOWED, MessageKeys.REVIEW_ADD_FORBIDDEN); + } + } + + /** + * Handles the review creation from the given context. + * + * @param context {@link ReviewContext} + */ + @On + public Reviews onAddReview(Books_ ref, BooksAddReviewContext context) { + String bookId = (String) analyzer.analyze(context.getCqn()).targetKeys().get(Books.ID); + cds.gen.reviewservice.Reviews review = cds.gen.reviewservice.Reviews.create(); + review.setBookId(bookId); + review.setRating(context.getRating()); + review.setTitle(context.getTitle()); + review.setText(context.getText()); + + Result res = reviewService.run(Insert.into(ReviewService_.REVIEWS).entry(review)); + + messages.success(MessageKeys.REVIEW_ADDED); + return res.single(Reviews.class); + } + + /** + * Recalculates and sets the book rating after a new review for the given book. + * + * @param context {@link ReviewContext} + */ + @After(entity = Books_.CDS_NAME) + public void afterAddReview(BooksAddReviewContext context) { + ratingCalculator.setBookRating(context.getResult().getBookId()); + } + + @After(event = CqnService.EVENT_READ) + public void discountBooks(Stream books) { + books + .filter(b -> b.getTitle() != null) + .forEach( + b -> { + discountBooksWithMoreThan111Stock(b, featureToggles.isEnabled("discount")); + }); + } + + @After + public void setIsReviewable(CdsReadEventContext context, List books) { + String user = context.getUserInfo().getName(); + List bookIds = + books.stream() + .filter(b -> b.getId() != null) + .map(b -> b.getId()) + .collect(Collectors.toList()); + + if (bookIds.isEmpty()) { + return; + } + + var query = + Select.from(BOOKS, b -> b.filter(b.ID().in(bookIds)).reviews()) + .where(r -> r.createdBy().eq(user)); + + Set reviewedBooks = + db.run(query).stream().map(Reviews::getBookId).collect(Collectors.toSet()); + + for (Books book : books) { + if (reviewedBooks.contains(book.getId())) { + book.setIsReviewable(false); + } + } + } + + @On + public SubmitOrderContext.ReturnType onSubmitOrder(SubmitOrderContext context) { + Integer quantity = context.getQuantity(); + String bookId = context.getBook(); + + Books book = db.run(Select.from(BOOKS).columns(Books_::stock).byId(bookId)).single(); + int stock = book.getStock(); + + if (stock >= quantity) { + db.run(Update.entity(BOOKS).byId(bookId).data(Books.STOCK, stock -= quantity)); + + SubmitOrderContext.ReturnType result = SubmitOrderContext.ReturnType.create(); + result.setStock(stock); + return result; + } else { + throw new ServiceException(ErrorStatuses.CONFLICT, MessageKeys.ORDER_EXCEEDS_STOCK, quantity); + } + } + + private void discountBooksWithMoreThan111Stock(Books b, boolean premium) { + if (b.getStock() != null && b.getStock() > 111) { + b.setTitle("%s -- %s%% discount".formatted(b.getTitle(), premium ? 14 : 11)); + } + } } diff --git a/srv/src/main/java/my/bookshop/handlers/HierarchySiblingActionHandler.java b/srv/src/main/java/my/bookshop/handlers/HierarchySiblingActionHandler.java index 5cd4dfad..a7c28157 100644 --- a/srv/src/main/java/my/bookshop/handlers/HierarchySiblingActionHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/HierarchySiblingActionHandler.java @@ -17,52 +17,54 @@ @Component @ServiceName(AdminService_.CDS_NAME) -/** - * Example of a custom handler for nextSiblingAction - */ +/** Example of a custom handler for nextSiblingAction */ public class HierarchySiblingActionHandler implements EventHandler { - private final PersistenceService db; + private final PersistenceService db; - HierarchySiblingActionHandler(PersistenceService db) { - this.db = db; - } - - @On - void onMoveSiblingAction(GenreHierarchy_ ref, GenreHierarchyMoveSiblingContext context) { - // Find current node and its parent - GenreHierarchy toMove = db.run(Select.from(ref) - .columns(c -> c.ID(), c -> c.parent_ID())) - .single(); + HierarchySiblingActionHandler(PersistenceService db) { + this.db = db; + } - // Find all children of the parent, which are siblings of the entry being moved - List siblingNodes = db.run(Select.from(GENRE_HIERARCHY) - .columns(c -> c.ID(), c -> c.siblingRank()) - .where(c -> c.parent_ID().eq(toMove.getParentId()))) - .list(); + @On + void onMoveSiblingAction(GenreHierarchy_ ref, GenreHierarchyMoveSiblingContext context) { + // Find current node and its parent + GenreHierarchy toMove = + db.run(Select.from(ref).columns(c -> c.ID(), c -> c.parent_ID())).single(); - int oldPosition = 0; - int newPosition = siblingNodes.size(); - for (int i = 0; i < siblingNodes.size(); ++i) { - GenreHierarchy sibling = siblingNodes.get(i); - if (sibling.getId().equals(toMove.getId())) { - oldPosition = i; - } - if (context.getNextSibling() != null && sibling.getId().equals(context.getNextSibling().getId())) { - newPosition = i; - } - } + // Find all children of the parent, which are siblings of the entry being moved + List siblingNodes = + db.run( + Select.from(GENRE_HIERARCHY) + .columns(c -> c.ID(), c -> c.siblingRank()) + .where(c -> c.parent_ID().eq(toMove.getParentId()))) + .list(); - // Move siblings - siblingNodes.add(oldPosition < newPosition ? newPosition - 1 : newPosition, siblingNodes.remove(oldPosition)); + int oldPosition = 0; + int newPosition = siblingNodes.size(); + for (int i = 0; i < siblingNodes.size(); ++i) { + GenreHierarchy sibling = siblingNodes.get(i); + if (sibling.getId().equals(toMove.getId())) { + oldPosition = i; + } + if (context.getNextSibling() != null + && sibling.getId().equals(context.getNextSibling().getId())) { + newPosition = i; + } + } - // Recalculate ranks - for (int i = 0; i < siblingNodes.size(); ++i) { - siblingNodes.get(i).setSiblingRank(i); - } + // Move siblings + siblingNodes.add( + oldPosition < newPosition ? newPosition - 1 : newPosition, + siblingNodes.remove(oldPosition)); - // Update DB - db.run(Update.entity(GENRE_HIERARCHY).entries(siblingNodes)); - context.setCompleted(); + // Recalculate ranks + for (int i = 0; i < siblingNodes.size(); ++i) { + siblingNodes.get(i).setSiblingRank(i); } + + // Update DB + db.run(Update.entity(GENRE_HIERARCHY).entries(siblingNodes)); + context.setCompleted(); + } } diff --git a/srv/src/main/java/my/bookshop/handlers/NotesServiceHandler.java b/srv/src/main/java/my/bookshop/handlers/NotesServiceHandler.java index b257b803..6b1de404 100644 --- a/srv/src/main/java/my/bookshop/handlers/NotesServiceHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/NotesServiceHandler.java @@ -41,166 +41,220 @@ @ServiceName(NotesService_.CDS_NAME) public class NotesServiceHandler implements EventHandler { - private final ApiBusinessPartner bupa; - private final CqnAnalyzer analyzer; - - NotesServiceHandler(@Qualifier(ApiBusinessPartner_.CDS_NAME) ApiBusinessPartner bupa, CdsModel model) { - this.bupa = bupa; - this.analyzer = CqnAnalyzer.create(model); - } - - @On(entity = Addresses_.CDS_NAME) - void readAddresses(CdsReadEventContext context) { - List segments = context.getCqn().ref().segments(); - // via note - if(segments.size() == 2 && segments.getFirst().id().equals(Notes_.CDS_NAME)) { - Map noteKeys = analyzer.analyze(context.getCqn()).rootKeys(); - Notes note = context.getService().run(Select.from(NOTES).columns(n -> n.address_businessPartner(), n -> n.address_ID()).matching(noteKeys)).single(); - CqnSelect addressOfNote = CQL.copy(context.getCqn(), new Modifier() { - - @Override - public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { - return CQL.entity(ADDRESSES) - .filter(p -> p.businessPartner().eq(note.getAddressBusinessPartner()) - .and(p.ID().eq(note.getAddressId()))) - .asRef(); - } - - }); - context.setResult(context.getService().run(addressOfNote)); - return; - } - - // notes expanded? - AtomicReference notesExpandHolder = new AtomicReference<>(); - CqnSelect noNotesExpand = CQL.copy(context.getCqn(), new Modifier() { - - public List items(List items) { - notesExpandHolder.set(removeIfExpanded(items, Addresses.NOTES)); - return ensureSelected(items, Addresses.BUSINESS_PARTNER, Addresses.ID); - } - - }); - - // read addresses - var addresses = CdsResult.of(bupa.run(noNotesExpand), Addresses.class); - - // add expanded notes? - CqnExpand notesExpand = notesExpandHolder.get(); - if(notesExpand != null) { - var notesSelect = Select.from(NOTES) - .columns(ensureSelected(notesExpand.items(), Notes.ADDRESS_BUSINESS_PARTNER, Notes.ADDRESS_ID)) - .orderBy(notesExpand.orderBy()) - .where(n -> CQL.or(addresses.stream() - .map(address -> n.address_businessPartner().eq(address.getBusinessPartner()).and(n.address_ID().eq(address.getId()))) - .collect(Collectors.toList())) - .and(predicate(notesExpand.ref().rootSegment()))); - - var notes = context.getService().run(notesSelect); - for(Addresses address : addresses) { - address.setNotes( - notes.stream() - .filter(n -> n.getAddressBusinessPartner().equals(address.getBusinessPartner()) - && n.getAddressId().equals(address.getId())) - .collect(Collectors.toList())); - } - } - - context.setResult(addresses); - } - - @On(entity = Notes_.CDS_NAME) - void readNotes(CdsReadEventContext context) { - List segments = context.getCqn().ref().segments(); - // via addresses - if(segments.size() == 2 && segments.getFirst().id().equals(Addresses_.CDS_NAME)) { - Map addressKeys = analyzer.analyze(context.getCqn()).rootKeys(); - CqnSelect notesOfAddress = CQL.copy(context.getCqn(), new Modifier() { - - @Override - public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { - return CQL.entity(NOTES).filter(predicate(segments.get(1))).asRef(); - } - - @Override - public Predicate where(Predicate where) { - Predicate ofAddress = CQL.get(Notes.ADDRESS_BUSINESS_PARTNER).eq(addressKeys.get(Addresses.BUSINESS_PARTNER)) - .and(CQL.get(Notes.ADDRESS_ID).eq(addressKeys.get(Addresses.ID))); - if(where != null) { - ofAddress = ofAddress.and(where); - } - return ofAddress; - } - - }); - context.setResult(context.getService().run(notesOfAddress)); - return; - } - - // address expanded? - AtomicReference addressExpandHolder = new AtomicReference<>(); - CqnSelect noAddressExpand = CQL.copy(context.getCqn(), new Modifier() { - - public List items(List items) { - addressExpandHolder.set(removeIfExpanded(items, Notes.ADDRESS)); - return ensureSelected(items, Notes.ADDRESS_BUSINESS_PARTNER, Notes.ADDRESS_ID); - } - - }); - - CqnExpand addressExpand = addressExpandHolder.get(); - if(addressExpand != null) { - // read notes and join with addresses - var notes = CdsResult.of(context.getService().run(noAddressExpand), Notes.class); - List notesWithAddresses = notes.stream().filter(n -> n.getAddressBusinessPartner() != null && n.getAddressId() != null).collect(Collectors.toList()); - if (notesWithAddresses.size() > 0) { - var addressSelect = Select.from(ADDRESSES) - .columns(ensureSelected(addressExpand.items(), Addresses.BUSINESS_PARTNER, Addresses.ID)) - .orderBy(addressExpand.orderBy()) - .where(a -> CQL.or(notesWithAddresses.stream() - .map(n -> a.businessPartner().eq(n.getAddressBusinessPartner()).and(a.ID().eq(n.getAddressId()))) - .collect(Collectors.toList())) - .and(predicate(addressExpand.ref().rootSegment()))); - - var addresses = context.getService().run(addressSelect); - for(Notes note : notes) { - note.setAddress(addresses.stream() - .filter(a -> a.getBusinessPartner().equals(note.getAddressBusinessPartner()) - && a.getId().equals(note.getAddressId())) - .findFirst().orElse(null)); - } - } - context.setResult(notes); - return; - } - } - - private CqnExpand removeIfExpanded(List items, String association) { - CqnExpand expanded = items.stream().filter(i -> i.isExpand()).map(i -> i.asExpand()) - .filter(i -> i.ref().firstSegment().equals(association)).findFirst().orElse(null); - if(expanded != null) { - items.remove(expanded); - } - return expanded; - } - - private List ensureSelected(List items, String... elements) { - if(items.stream().anyMatch(i -> i.isStar())) { - return items; - } - Set newElements = new HashSet<>(); - for(String element : elements) { - if(!items.stream().anyMatch(i -> i.isValue() && i.asValue().displayName().equals(element))) { - newElements.add(element); - } - } - List newItems = new ArrayList<>(items); - newElements.forEach(element -> newItems.add(CQL.get(element))); - return newItems; - } - - private CqnPredicate predicate(Segment segment) { - return segment.filter().orElse(CQL.constant(true).eq(true)); - } + private final ApiBusinessPartner bupa; + private final CqnAnalyzer analyzer; + NotesServiceHandler( + @Qualifier(ApiBusinessPartner_.CDS_NAME) ApiBusinessPartner bupa, CdsModel model) { + this.bupa = bupa; + this.analyzer = CqnAnalyzer.create(model); + } + + @On(entity = Addresses_.CDS_NAME) + void readAddresses(CdsReadEventContext context) { + List segments = context.getCqn().ref().segments(); + // via note + if (segments.size() == 2 && segments.getFirst().id().equals(Notes_.CDS_NAME)) { + Map noteKeys = analyzer.analyze(context.getCqn()).rootKeys(); + Notes note = + context + .getService() + .run( + Select.from(NOTES) + .columns(n -> n.address_businessPartner(), n -> n.address_ID()) + .matching(noteKeys)) + .single(); + CqnSelect addressOfNote = + CQL.copy( + context.getCqn(), + new Modifier() { + + @Override + public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { + return CQL.entity(ADDRESSES) + .filter( + p -> + p.businessPartner() + .eq(note.getAddressBusinessPartner()) + .and(p.ID().eq(note.getAddressId()))) + .asRef(); + } + }); + context.setResult(context.getService().run(addressOfNote)); + return; + } + + // notes expanded? + AtomicReference notesExpandHolder = new AtomicReference<>(); + CqnSelect noNotesExpand = + CQL.copy( + context.getCqn(), + new Modifier() { + + public List items(List items) { + notesExpandHolder.set(removeIfExpanded(items, Addresses.NOTES)); + return ensureSelected(items, Addresses.BUSINESS_PARTNER, Addresses.ID); + } + }); + + // read addresses + var addresses = CdsResult.of(bupa.run(noNotesExpand), Addresses.class); + + // add expanded notes? + CqnExpand notesExpand = notesExpandHolder.get(); + if (notesExpand != null) { + var notesSelect = + Select.from(NOTES) + .columns( + ensureSelected( + notesExpand.items(), Notes.ADDRESS_BUSINESS_PARTNER, Notes.ADDRESS_ID)) + .orderBy(notesExpand.orderBy()) + .where( + n -> + CQL.or( + addresses.stream() + .map( + address -> + n.address_businessPartner() + .eq(address.getBusinessPartner()) + .and(n.address_ID().eq(address.getId()))) + .collect(Collectors.toList())) + .and(predicate(notesExpand.ref().rootSegment()))); + + var notes = context.getService().run(notesSelect); + for (Addresses address : addresses) { + address.setNotes( + notes.stream() + .filter( + n -> + n.getAddressBusinessPartner().equals(address.getBusinessPartner()) + && n.getAddressId().equals(address.getId())) + .collect(Collectors.toList())); + } + } + + context.setResult(addresses); + } + + @On(entity = Notes_.CDS_NAME) + void readNotes(CdsReadEventContext context) { + List segments = context.getCqn().ref().segments(); + // via addresses + if (segments.size() == 2 && segments.getFirst().id().equals(Addresses_.CDS_NAME)) { + Map addressKeys = analyzer.analyze(context.getCqn()).rootKeys(); + CqnSelect notesOfAddress = + CQL.copy( + context.getCqn(), + new Modifier() { + + @Override + public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { + return CQL.entity(NOTES).filter(predicate(segments.get(1))).asRef(); + } + + @Override + public Predicate where(Predicate where) { + Predicate ofAddress = + CQL.get(Notes.ADDRESS_BUSINESS_PARTNER) + .eq(addressKeys.get(Addresses.BUSINESS_PARTNER)) + .and(CQL.get(Notes.ADDRESS_ID).eq(addressKeys.get(Addresses.ID))); + if (where != null) { + ofAddress = ofAddress.and(where); + } + return ofAddress; + } + }); + context.setResult(context.getService().run(notesOfAddress)); + return; + } + + // address expanded? + AtomicReference addressExpandHolder = new AtomicReference<>(); + CqnSelect noAddressExpand = + CQL.copy( + context.getCqn(), + new Modifier() { + + public List items(List items) { + addressExpandHolder.set(removeIfExpanded(items, Notes.ADDRESS)); + return ensureSelected(items, Notes.ADDRESS_BUSINESS_PARTNER, Notes.ADDRESS_ID); + } + }); + + CqnExpand addressExpand = addressExpandHolder.get(); + if (addressExpand != null) { + // read notes and join with addresses + var notes = CdsResult.of(context.getService().run(noAddressExpand), Notes.class); + List notesWithAddresses = + notes.stream() + .filter(n -> n.getAddressBusinessPartner() != null && n.getAddressId() != null) + .collect(Collectors.toList()); + if (notesWithAddresses.size() > 0) { + var addressSelect = + Select.from(ADDRESSES) + .columns( + ensureSelected(addressExpand.items(), Addresses.BUSINESS_PARTNER, Addresses.ID)) + .orderBy(addressExpand.orderBy()) + .where( + a -> + CQL.or( + notesWithAddresses.stream() + .map( + n -> + a.businessPartner() + .eq(n.getAddressBusinessPartner()) + .and(a.ID().eq(n.getAddressId()))) + .collect(Collectors.toList())) + .and(predicate(addressExpand.ref().rootSegment()))); + + var addresses = context.getService().run(addressSelect); + for (Notes note : notes) { + note.setAddress( + addresses.stream() + .filter( + a -> + a.getBusinessPartner().equals(note.getAddressBusinessPartner()) + && a.getId().equals(note.getAddressId())) + .findFirst() + .orElse(null)); + } + } + context.setResult(notes); + return; + } + } + + private CqnExpand removeIfExpanded(List items, String association) { + CqnExpand expanded = + items.stream() + .filter(i -> i.isExpand()) + .map(i -> i.asExpand()) + .filter(i -> i.ref().firstSegment().equals(association)) + .findFirst() + .orElse(null); + if (expanded != null) { + items.remove(expanded); + } + return expanded; + } + + private List ensureSelected( + List items, String... elements) { + if (items.stream().anyMatch(i -> i.isStar())) { + return items; + } + Set newElements = new HashSet<>(); + for (String element : elements) { + if (!items.stream().anyMatch(i -> i.isValue() && i.asValue().displayName().equals(element))) { + newElements.add(element); + } + } + List newItems = new ArrayList<>(items); + newElements.forEach(element -> newItems.add(CQL.get(element))); + return newItems; + } + + private CqnPredicate predicate(Segment segment) { + return segment.filter().orElse(CQL.constant(true).eq(true)); + } } diff --git a/srv/src/main/java/my/bookshop/handlers/SubscriptionHandler.java b/srv/src/main/java/my/bookshop/handlers/SubscriptionHandler.java index 2c5b3479..1d7223d5 100644 --- a/srv/src/main/java/my/bookshop/handlers/SubscriptionHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/SubscriptionHandler.java @@ -1,33 +1,28 @@ package my.bookshop.handlers; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - import com.sap.cds.services.auditlog.AuditLogService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.mt.SubscribeEventContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; -/** - * Handler that implements subscription logic - */ +/** Handler that implements subscription logic */ @Component @Profile("cloud") @ServiceName(DeploymentService.DEFAULT_NAME) class SubscriptionHandler implements EventHandler { - @Autowired - private AuditLogService auditLog; - - @After - public void afterSubscribe(SubscribeEventContext context) { - String msg = "New tenant '%s' subscribed.".formatted(context.getTenant()); + @Autowired private AuditLogService auditLog; - // send audit log security message to provider tenant as user's tenant is null - auditLog.logSecurityEvent("tenant subscribed", msg); - } + @After + public void afterSubscribe(SubscribeEventContext context) { + String msg = "New tenant '%s' subscribed.".formatted(context.getTenant()); + // send audit log security message to provider tenant as user's tenant is null + auditLog.logSecurityEvent("tenant subscribed", msg); + } } diff --git a/srv/src/main/java/my/bookshop/handlers/external/ApiBusinessPartnerEventMockHandler.java b/srv/src/main/java/my/bookshop/handlers/external/ApiBusinessPartnerEventMockHandler.java index 2a353754..6cb021d3 100644 --- a/srv/src/main/java/my/bookshop/handlers/external/ApiBusinessPartnerEventMockHandler.java +++ b/srv/src/main/java/my/bookshop/handlers/external/ApiBusinessPartnerEventMockHandler.java @@ -1,10 +1,10 @@ package my.bookshop.handlers.external; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - +import cds.gen.api_business_partner.ABusinessPartnerAddress; +import cds.gen.api_business_partner.ABusinessPartnerAddress_; +import cds.gen.api_business_partner.ApiBusinessPartner_; +import cds.gen.api_business_partner.BusinessPartnerChanged; +import cds.gen.api_business_partner.BusinessPartnerChangedContext; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsUpdateEventContext; @@ -12,39 +12,41 @@ import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; -import cds.gen.api_business_partner.ABusinessPartnerAddress; -import cds.gen.api_business_partner.ABusinessPartnerAddress_; -import cds.gen.api_business_partner.ApiBusinessPartner_; -import cds.gen.api_business_partner.BusinessPartnerChanged; -import cds.gen.api_business_partner.BusinessPartnerChangedContext; - -/** - * This class mocks the event emitting of the S/4 API - */ +/** This class mocks the event emitting of the S/4 API */ @Component @Profile("!cloud") -@ServiceName(value = { ApiBusinessPartner_.CDS_NAME, "api-business-partner-mocked"}, type = ApplicationService.class) +@ServiceName( + value = {ApiBusinessPartner_.CDS_NAME, "api-business-partner-mocked"}, + type = ApplicationService.class) public class ApiBusinessPartnerEventMockHandler implements EventHandler { - private final static Logger logger = LoggerFactory.getLogger(ApiBusinessPartnerEventMockHandler.class); - - @After(event = CqnService.EVENT_UPDATE, entity = ABusinessPartnerAddress_.CDS_NAME) - public void businessPartnerChanged(CdsUpdateEventContext context) { - // Get BusinessPartner ID - CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); - String businessPartner = (String) analyzer.analyze(context.getCqn().ref()).targetKeys().get(ABusinessPartnerAddress.BUSINESS_PARTNER); - - // Construct S/4 HANA Payload - BusinessPartnerChanged payload = BusinessPartnerChanged.create(); - payload.setBusinessPartner(businessPartner); - - // Emit Changed Event - logger.info("<< emitting: " + payload); - BusinessPartnerChangedContext changed = BusinessPartnerChangedContext.create(); - changed.setData(payload); - context.getService().emit(changed); - } - - + private static final Logger logger = + LoggerFactory.getLogger(ApiBusinessPartnerEventMockHandler.class); + + @After(event = CqnService.EVENT_UPDATE, entity = ABusinessPartnerAddress_.CDS_NAME) + public void businessPartnerChanged(CdsUpdateEventContext context) { + // Get BusinessPartner ID + CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); + String businessPartner = + (String) + analyzer + .analyze(context.getCqn().ref()) + .targetKeys() + .get(ABusinessPartnerAddress.BUSINESS_PARTNER); + + // Construct S/4 HANA Payload + BusinessPartnerChanged payload = BusinessPartnerChanged.create(); + payload.setBusinessPartner(businessPartner); + + // Emit Changed Event + logger.info("<< emitting: " + payload); + BusinessPartnerChangedContext changed = BusinessPartnerChangedContext.create(); + changed.setData(payload); + context.getService().emit(changed); + } } diff --git a/srv/src/main/java/my/bookshop/health/AppActuator.java b/srv/src/main/java/my/bookshop/health/AppActuator.java index ac48833d..4a80fd72 100644 --- a/srv/src/main/java/my/bookshop/health/AppActuator.java +++ b/srv/src/main/java/my/bookshop/health/AppActuator.java @@ -7,20 +7,16 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.stereotype.Component; - -/** - * Custom app actuator implementation. - */ +/** Custom app actuator implementation. */ @Component @ConditionalOnClass(Endpoint.class) @Endpoint(id = "bookshop") public class AppActuator { - @ReadOperation - public Map info() { - Map info = new LinkedHashMap<>(); - info.put("Version", "1.0.0"); - return info; - } - + @ReadOperation + public Map info() { + Map info = new LinkedHashMap<>(); + info.put("Version", "1.0.0"); + return info; + } } diff --git a/srv/src/main/java/my/bookshop/health/CustomHealthIndicator.java b/srv/src/main/java/my/bookshop/health/CustomHealthIndicator.java index ba702f7f..4ecd1295 100644 --- a/srv/src/main/java/my/bookshop/health/CustomHealthIndicator.java +++ b/srv/src/main/java/my/bookshop/health/CustomHealthIndicator.java @@ -5,24 +5,21 @@ import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; -/** - * Custom health indicator implementation. - */ +/** Custom health indicator implementation. */ @Component("myhealth") @ConditionalOnEnabledHealthIndicator("myhealth") public class CustomHealthIndicator implements HealthIndicator { - @Override - public Health health() { - if (check() != 0) { - return Health.down().build(); - } - return Health.up().build(); - } - - private int check() { - // perform some health check - return 0; - } + @Override + public Health health() { + if (check() != 0) { + return Health.down().build(); + } + return Health.up().build(); + } + private int check() { + // perform some health check + return 0; + } } diff --git a/srv/src/test/java/my/bookshop/AdminServiceAddressITestBase.java b/srv/src/test/java/my/bookshop/AdminServiceAddressITestBase.java index ccc1e415..03f8a958 100644 --- a/srv/src/test/java/my/bookshop/AdminServiceAddressITestBase.java +++ b/srv/src/test/java/my/bookshop/AdminServiceAddressITestBase.java @@ -1,114 +1,162 @@ package my.bookshop; +import cds.gen.adminservice.Orders; +import cds.gen.api_business_partner.ABusinessPartnerAddress; +import cds.gen.api_business_partner.ApiBusinessPartner; +import cds.gen.api_business_partner.ApiBusinessPartner_; +import cds.gen.api_business_partner.BusinessPartnerChangedContext; +import com.sap.cds.services.changeset.ChangeSetListener; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.test.web.reactive.server.WebTestClient; -import com.sap.cds.services.changeset.ChangeSetListener; - -import cds.gen.adminservice.Orders; -import cds.gen.api_business_partner.ABusinessPartnerAddress; -import cds.gen.api_business_partner.ApiBusinessPartner; -import cds.gen.api_business_partner.ApiBusinessPartner_; -import cds.gen.api_business_partner.BusinessPartnerChangedContext; - public class AdminServiceAddressITestBase { - private static final String ordersURI = "/api/admin/Orders"; - private static final String orderURI = ordersURI + "(IsActiveEntity=true,ID=%s)"; - private static final String addressesURI = "/api/admin/Addresses"; - private static final String remoteAddressURI = "/api/API_BUSINESS_PARTNER/A_BusinessPartnerAddress(BusinessPartner='%s',AddressID='%s')"; - - @Autowired - private WebTestClient client; - - @Autowired - @Qualifier(ApiBusinessPartner_.CDS_NAME) - private ApiBusinessPartner bupa; - - public void testAddressesValueHelp() { - client.get().uri(addressesURI).headers(this::adminCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.['@context']").isEqualTo("$metadata#Addresses") - .jsonPath("$.value[0].ID").isEqualTo("100") - .jsonPath("$.value[0].businessPartner").isEqualTo("10401010") - .jsonPath("$.value[1].ID").isEqualTo("200") - .jsonPath("$.value[1].businessPartner").isEqualTo("10401010") - .jsonPath("$.value[2].ID").isEqualTo("300") - .jsonPath("$.value[2].businessPartner").isEqualTo("10401010"); - } - - public void testOrderWithAddress() throws InterruptedException { - Orders order = Orders.create(); - order.setOrderNo("1337"); - order.setShippingAddressId("100"); - - String id = UUID.randomUUID().toString(); - client.put().uri(orderURI.formatted(id)) - .headers(this::adminCredentials) - .header("Content-Type", "application/json") - .bodyValue(order.toJson()) - .exchange() - .expectStatus().isCreated(); - - client.get().uri(orderURI.formatted(id) + "?$expand=shippingAddress").headers(this::adminCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.ID").isEqualTo(id) - .jsonPath("$.OrderNo").isEqualTo(order.getOrderNo()) - .jsonPath("$.shippingAddress.ID").isEqualTo("100") - .jsonPath("$.shippingAddress.businessPartner").isEqualTo("10401010") - .jsonPath("$.shippingAddress.houseNumber").isEqualTo("16"); - - client.get().uri(orderURI.formatted(id) + "/shippingAddress").headers(this::adminCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.ID").isEqualTo("100") - .jsonPath("$.businessPartner").isEqualTo("10401010") - .jsonPath("$.houseNumber").isEqualTo("16"); - - // react on remote address update - CountDownLatch latch = new CountDownLatch(1); - bupa.on(BusinessPartnerChangedContext.CDS_NAME, null, (context) -> context.getChangeSetContext().register(new ChangeSetListener(){ - - @Override - public void afterClose(boolean completed) { - latch.countDown(); - } - - })); - - // update remote address - ABusinessPartnerAddress address = ABusinessPartnerAddress.create(); - address.setHouseNumber("17"); - - client.patch().uri(remoteAddressURI.formatted("10401010", "100")).headers(this::authenticatedCredentials) - .header("Content-Type", "application/json") - .bodyValue(address.toJson()) - .exchange() - .expectStatus().isOk(); - - // wait for remote address update - latch.await(30, TimeUnit.SECONDS); - client.get().uri(orderURI.formatted(id) + "/shippingAddress").headers(this::adminCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.ID").isEqualTo("100") - .jsonPath("$.businessPartner").isEqualTo("10401010") - .jsonPath("$.houseNumber").isEqualTo("17"); - } - - private void adminCredentials(HttpHeaders headers) { - headers.setBasicAuth("admin", "admin"); - } - - private void authenticatedCredentials(HttpHeaders headers) { - headers.setBasicAuth("authenticated", ""); - } + private static final String ordersURI = "/api/admin/Orders"; + private static final String orderURI = ordersURI + "(IsActiveEntity=true,ID=%s)"; + private static final String addressesURI = "/api/admin/Addresses"; + private static final String remoteAddressURI = + "/api/API_BUSINESS_PARTNER/A_BusinessPartnerAddress(BusinessPartner='%s',AddressID='%s')"; + + @Autowired private WebTestClient client; + + @Autowired + @Qualifier(ApiBusinessPartner_.CDS_NAME) + private ApiBusinessPartner bupa; + + public void testAddressesValueHelp() { + client + .get() + .uri(addressesURI) + .headers(this::adminCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.['@context']") + .isEqualTo("$metadata#Addresses") + .jsonPath("$.value[0].ID") + .isEqualTo("100") + .jsonPath("$.value[0].businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[1].ID") + .isEqualTo("200") + .jsonPath("$.value[1].businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[2].ID") + .isEqualTo("300") + .jsonPath("$.value[2].businessPartner") + .isEqualTo("10401010"); + } + + public void testOrderWithAddress() throws InterruptedException { + Orders order = Orders.create(); + order.setOrderNo("1337"); + order.setShippingAddressId("100"); + + String id = UUID.randomUUID().toString(); + client + .put() + .uri(orderURI.formatted(id)) + .headers(this::adminCredentials) + .header("Content-Type", "application/json") + .bodyValue(order.toJson()) + .exchange() + .expectStatus() + .isCreated(); + + client + .get() + .uri(orderURI.formatted(id) + "?$expand=shippingAddress") + .headers(this::adminCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.ID") + .isEqualTo(id) + .jsonPath("$.OrderNo") + .isEqualTo(order.getOrderNo()) + .jsonPath("$.shippingAddress.ID") + .isEqualTo("100") + .jsonPath("$.shippingAddress.businessPartner") + .isEqualTo("10401010") + .jsonPath("$.shippingAddress.houseNumber") + .isEqualTo("16"); + + client + .get() + .uri(orderURI.formatted(id) + "/shippingAddress") + .headers(this::adminCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.ID") + .isEqualTo("100") + .jsonPath("$.businessPartner") + .isEqualTo("10401010") + .jsonPath("$.houseNumber") + .isEqualTo("16"); + + // react on remote address update + CountDownLatch latch = new CountDownLatch(1); + bupa.on( + BusinessPartnerChangedContext.CDS_NAME, + null, + (context) -> + context + .getChangeSetContext() + .register( + new ChangeSetListener() { + + @Override + public void afterClose(boolean completed) { + latch.countDown(); + } + })); + + // update remote address + ABusinessPartnerAddress address = ABusinessPartnerAddress.create(); + address.setHouseNumber("17"); + + client + .patch() + .uri(remoteAddressURI.formatted("10401010", "100")) + .headers(this::authenticatedCredentials) + .header("Content-Type", "application/json") + .bodyValue(address.toJson()) + .exchange() + .expectStatus() + .isOk(); + + // wait for remote address update + latch.await(30, TimeUnit.SECONDS); + client + .get() + .uri(orderURI.formatted(id) + "/shippingAddress") + .headers(this::adminCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.ID") + .isEqualTo("100") + .jsonPath("$.businessPartner") + .isEqualTo("10401010") + .jsonPath("$.houseNumber") + .isEqualTo("17"); + } + + private void adminCredentials(HttpHeaders headers) { + headers.setBasicAuth("admin", "admin"); + } + + private void authenticatedCredentials(HttpHeaders headers) { + headers.setBasicAuth("authenticated", ""); + } } diff --git a/srv/src/test/java/my/bookshop/AdminServiceAddress_default_ITest.java b/srv/src/test/java/my/bookshop/AdminServiceAddress_default_ITest.java index db791125..63eef15f 100644 --- a/srv/src/test/java/my/bookshop/AdminServiceAddress_default_ITest.java +++ b/srv/src/test/java/my/bookshop/AdminServiceAddress_default_ITest.java @@ -6,24 +6,23 @@ import org.springframework.test.context.ActiveProfiles; /** - * Runs tests defined in {@link AdminServiceAddressITestBase} with the default profile. - * The default profile doesn't create any remote services, so the application behaves as if - * the AdminService and the API_BUSINESS_PARTNER service were provided by the same application. + * Runs tests defined in {@link AdminServiceAddressITestBase} with the default profile. The default + * profile doesn't create any remote services, so the application behaves as if the AdminService and + * the API_BUSINESS_PARTNER service were provided by the same application. */ @ActiveProfiles("default") @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class AdminServiceAddress_default_ITest extends AdminServiceAddressITestBase { - @Test - @Override - public void testAddressesValueHelp() { - super.testAddressesValueHelp(); - } - - @Test - @Override - public void testOrderWithAddress() throws InterruptedException { - super.testOrderWithAddress(); - } + @Test + @Override + public void testAddressesValueHelp() { + super.testAddressesValueHelp(); + } + @Test + @Override + public void testOrderWithAddress() throws InterruptedException { + super.testOrderWithAddress(); + } } diff --git a/srv/src/test/java/my/bookshop/AdminServiceAddress_mocked_ITest.java b/srv/src/test/java/my/bookshop/AdminServiceAddress_mocked_ITest.java index 28dc7a72..bd43bb1b 100644 --- a/srv/src/test/java/my/bookshop/AdminServiceAddress_mocked_ITest.java +++ b/srv/src/test/java/my/bookshop/AdminServiceAddress_mocked_ITest.java @@ -7,24 +7,26 @@ /** * Runs tests defined in {@link AdminServiceAddressITestBase} with the default and mocked profile. - * The mocked profile creates a remote services for the API_BUSINESS_PARTNER service (which is however mocked by our own application), - * so the application behaves as if the AdminService and the API_BUSINESS_PARTNER service were provided by two different applications. + * The mocked profile creates a remote services for the API_BUSINESS_PARTNER service (which is + * however mocked by our own application), so the application behaves as if the AdminService and the + * API_BUSINESS_PARTNER service were provided by two different applications. */ @ActiveProfiles({"default", "mocked"}) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = "cds.remote.services.'[API_BUSINESS_PARTNER]'.destination.name=myself-AdminServiceAddressITest") +@SpringBootTest( + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = + "cds.remote.services.'[API_BUSINESS_PARTNER]'.destination.name=myself-AdminServiceAddressITest") class AdminServiceAddress_mocked_ITest extends AdminServiceAddressITestBase { - @Test - @Override - public void testAddressesValueHelp() { - super.testAddressesValueHelp(); - } - - @Test - @Override - public void testOrderWithAddress() throws InterruptedException { - super.testOrderWithAddress(); - } + @Test + @Override + public void testAddressesValueHelp() { + super.testAddressesValueHelp(); + } + @Test + @Override + public void testOrderWithAddress() throws InterruptedException { + super.testOrderWithAddress(); + } } diff --git a/srv/src/test/java/my/bookshop/AdminServiceTest.java b/srv/src/test/java/my/bookshop/AdminServiceTest.java index b8988bb1..341fdc34 100644 --- a/srv/src/test/java/my/bookshop/AdminServiceTest.java +++ b/srv/src/test/java/my/bookshop/AdminServiceTest.java @@ -5,90 +5,95 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import cds.gen.adminservice.AdminService; +import cds.gen.adminservice.Authors; +import cds.gen.adminservice.OrderItems; +import cds.gen.adminservice.Orders; +import com.sap.cds.Result; +import com.sap.cds.ql.Insert; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.utils.CdsErrorStatuses; import java.math.BigDecimal; import java.util.Collections; - import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; -import com.sap.cds.Result; -import com.sap.cds.ql.Insert; -import com.sap.cds.services.ServiceException; -import com.sap.cds.services.utils.CdsErrorStatuses; - -import cds.gen.adminservice.AdminService; -import cds.gen.adminservice.Authors; -import cds.gen.adminservice.OrderItems; -import cds.gen.adminservice.Orders; - @SpringBootTest class AdminServiceTest { - @Autowired - private AdminService.Draft adminService; - - @Test - @WithMockUser(username = "user") - void unauthorizedAccess() { - assertThrows(ServiceException.class, () -> { - adminService.newDraft(Insert.into(AUTHORS).entry(Collections.emptyMap())); - }); - } + @Autowired private AdminService.Draft adminService; - @Test - @WithMockUser(username = "admin") - void invalidAuthorName() { - assertThrows(ServiceException.class, () -> { - Authors author = Authors.create(); - author.setName("little Joey"); - adminService.run(Insert.into(AUTHORS).entry(author)); - }); - } + @Test + @WithMockUser(username = "user") + void unauthorizedAccess() { + assertThrows( + ServiceException.class, + () -> { + adminService.newDraft(Insert.into(AUTHORS).entry(Collections.emptyMap())); + }); + } - @Test - @WithMockUser(username = "admin") - void validAuthorName() { - Authors author = Authors.create(); - author.setName("Big Joey"); - Result result = adminService.run(Insert.into(AUTHORS).entry(author)); - assertEquals(1, result.rowCount()); - } + @Test + @WithMockUser(username = "admin") + void invalidAuthorName() { + assertThrows( + ServiceException.class, + () -> { + Authors author = Authors.create(); + author.setName("little Joey"); + adminService.run(Insert.into(AUTHORS).entry(author)); + }); + } - @Test - @WithMockUser(username = "admin") - void createOrderWithoutBook() { - Orders order = Orders.create(); - order.setOrderNo("324"); - order.setShippingAddressId("100"); - OrderItems item = OrderItems.create(); - item.setQuantity(1); - item.setAmount(BigDecimal.valueOf(12.12)); - order.setItems(Collections.singletonList(item)); + @Test + @WithMockUser(username = "admin") + void validAuthorName() { + Authors author = Authors.create(); + author.setName("Big Joey"); + Result result = adminService.run(Insert.into(AUTHORS).entry(author)); + assertEquals(1, result.rowCount()); + } - // Runtime ensures that book is present in the order item, when it is created. - ServiceException exception = - assertThrows(ServiceException.class, () -> adminService.run(Insert.into(ORDERS).entry(order))); - assertEquals(CdsErrorStatuses.VALUE_REQUIRED.getCodeString(), exception.getErrorStatus().getCodeString()); - } + @Test + @WithMockUser(username = "admin") + void createOrderWithoutBook() { + Orders order = Orders.create(); + order.setOrderNo("324"); + order.setShippingAddressId("100"); + OrderItems item = OrderItems.create(); + item.setQuantity(1); + item.setAmount(BigDecimal.valueOf(12.12)); + order.setItems(Collections.singletonList(item)); - @Test - @WithMockUser(username = "admin") - void createOrderWithNonExistingBook() { - Orders order = Orders.create(); - order.setOrderNo("324"); - order.setShippingAddressId("100"); - OrderItems item = OrderItems.create(); - item.setQuantity(1); - item.setAmount(BigDecimal.valueOf(12.12)); - item.setBookId("4a519e61-3c3a-4bd9-ab12-d7e0c5ddaabb"); - order.setItems(Collections.singletonList(item)); + // Runtime ensures that book is present in the order item, when it is created. + ServiceException exception = + assertThrows( + ServiceException.class, () -> adminService.run(Insert.into(ORDERS).entry(order))); + assertEquals( + CdsErrorStatuses.VALUE_REQUIRED.getCodeString(), + exception.getErrorStatus().getCodeString()); + } - // Runtime ensures that book exists when order item is created. - ServiceException exception = - assertThrows(ServiceException.class, () -> adminService.run(Insert.into(ORDERS).entry(order))); - assertEquals(CdsErrorStatuses.TARGET_ENTITY_MISSING.getCodeString(), exception.getErrorStatus().getCodeString()); - } + @Test + @WithMockUser(username = "admin") + void createOrderWithNonExistingBook() { + Orders order = Orders.create(); + order.setOrderNo("324"); + order.setShippingAddressId("100"); + OrderItems item = OrderItems.create(); + item.setQuantity(1); + item.setAmount(BigDecimal.valueOf(12.12)); + item.setBookId("4a519e61-3c3a-4bd9-ab12-d7e0c5ddaabb"); + order.setItems(Collections.singletonList(item)); + // Runtime ensures that book exists when order item is created. + ServiceException exception = + assertThrows( + ServiceException.class, () -> adminService.run(Insert.into(ORDERS).entry(order))); + assertEquals( + CdsErrorStatuses.TARGET_ENTITY_MISSING.getCodeString(), + exception.getErrorStatus().getCodeString()); + } } diff --git a/srv/src/test/java/my/bookshop/CatalogServiceITest.java b/srv/src/test/java/my/bookshop/CatalogServiceITest.java index b60aba58..ee71ea3e 100644 --- a/srv/src/test/java/my/bookshop/CatalogServiceITest.java +++ b/srv/src/test/java/my/bookshop/CatalogServiceITest.java @@ -8,6 +8,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import cds.gen.catalogservice.Reviews; +import com.sap.cds.ql.Delete; +import com.sap.cds.services.persistence.PersistenceService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,77 +20,76 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -import com.sap.cds.ql.Delete; -import com.sap.cds.services.persistence.PersistenceService; - -import cds.gen.catalogservice.Reviews; - @SpringBootTest @AutoConfigureMockMvc class CatalogServiceITest { - private static final String booksURI = "/api/browse/Books"; - private static final String addReviewURI = "%s(ID=%s)/CatalogService.addReview".formatted(booksURI, "f846b0b9-01d4-4f6d-82a4-d79204f62278"); - - private static final String USER_USER_STRING = "user"; - private static final String ADMIN_USER_STRING = "admin"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private PersistenceService db; - - @AfterEach - void cleanup() { - db.run(Delete.from(REVIEWS)); - } - - @Test - void discountApplied() throws Exception { - mockMvc.perform(get(booksURI + "?$filter=stock gt 200&top=1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].title").value(containsString("11% discount"))); - } - - @Test - void discountNotApplied() throws Exception { - mockMvc.perform(get(booksURI + "?$filter=stock lt 100&top=1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].title").value(not(containsString("11% discount")))); - } - - @Test - void createReviewNotAuthenticated() throws Exception { - String payload = createTestReview().toJson(); - mockMvc.perform(post(addReviewURI).contentType(MediaType.APPLICATION_JSON).content(payload)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser(USER_USER_STRING) - void createReviewByUser() throws Exception { - String payload = createTestReview().toJson(); - mockMvc.perform(post(addReviewURI).contentType(MediaType.APPLICATION_JSON).content(payload)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.createdBy").value(USER_USER_STRING)); - } - - @Test - @WithMockUser(ADMIN_USER_STRING) - void createReviewByAdmin() throws Exception { - String payload = createTestReview().toJson(); - mockMvc.perform(post(addReviewURI).contentType(MediaType.APPLICATION_JSON).content(payload)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.createdBy").value(ADMIN_USER_STRING)); - } - - private Reviews createTestReview() { - Reviews review = Reviews.create(); - review.setRating(1); - review.setTitle("title"); - review.setText("text"); - return review; - } - + private static final String booksURI = "/api/browse/Books"; + private static final String addReviewURI = + "%s(ID=%s)/CatalogService.addReview" + .formatted(booksURI, "f846b0b9-01d4-4f6d-82a4-d79204f62278"); + + private static final String USER_USER_STRING = "user"; + private static final String ADMIN_USER_STRING = "admin"; + + @Autowired private MockMvc mockMvc; + + @Autowired private PersistenceService db; + + @AfterEach + void cleanup() { + db.run(Delete.from(REVIEWS)); + } + + @Test + void discountApplied() throws Exception { + mockMvc + .perform(get(booksURI + "?$filter=stock gt 200&top=1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].title").value(containsString("11% discount"))); + } + + @Test + void discountNotApplied() throws Exception { + mockMvc + .perform(get(booksURI + "?$filter=stock lt 100&top=1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].title").value(not(containsString("11% discount")))); + } + + @Test + void createReviewNotAuthenticated() throws Exception { + String payload = createTestReview().toJson(); + mockMvc + .perform(post(addReviewURI).contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(USER_USER_STRING) + void createReviewByUser() throws Exception { + String payload = createTestReview().toJson(); + mockMvc + .perform(post(addReviewURI).contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.createdBy").value(USER_USER_STRING)); + } + + @Test + @WithMockUser(ADMIN_USER_STRING) + void createReviewByAdmin() throws Exception { + String payload = createTestReview().toJson(); + mockMvc + .perform(post(addReviewURI).contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.createdBy").value(ADMIN_USER_STRING)); + } + + private Reviews createTestReview() { + Reviews review = Reviews.create(); + review.setRating(1); + review.setTitle("title"); + review.setText("text"); + return review; + } } diff --git a/srv/src/test/java/my/bookshop/CatalogServiceTest.java b/srv/src/test/java/my/bookshop/CatalogServiceTest.java index 323c95fe..3b2458d6 100644 --- a/srv/src/test/java/my/bookshop/CatalogServiceTest.java +++ b/srv/src/test/java/my/bookshop/CatalogServiceTest.java @@ -6,133 +6,154 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import cds.gen.catalogservice.Books_; +import cds.gen.catalogservice.CatalogService; +import cds.gen.catalogservice.Reviews; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Delete; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.persistence.PersistenceService; import java.util.stream.Stream; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; -import com.sap.cds.ql.CQL; -import com.sap.cds.ql.Delete; -import com.sap.cds.services.ServiceException; -import com.sap.cds.services.persistence.PersistenceService; - -import cds.gen.catalogservice.Books_; -import cds.gen.catalogservice.CatalogService; -import cds.gen.catalogservice.Reviews; - @SpringBootTest class CatalogServiceTest { - @Autowired - private CatalogService catalogService; - - @Autowired - private PersistenceService db; - - @AfterEach - void cleanup() { - db.run(Delete.from(REVIEWS)); - } - - @Test - @WithMockUser(username = "user") - void createReviewHandler() { - Stream bookReviews = Stream.of( - createReview("f846b0b9-01d4-4f6d-82a4-d79204f62278", 1, "quite bad", "disappointing..."), - createReview("aebdfc8a-0dfa-4468-bd36-48aabd65e663", 5, "great read", "just amazing...")); - - bookReviews.forEach(bookReview -> { - - Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(bookReview.getBookId())); - Reviews result = catalogService.addReview(ref, bookReview.getRating(), bookReview.getTitle(), bookReview.getText()); - - assertEquals(bookReview.getBookId(), result.getBookId()); - assertEquals(bookReview.getRating(), result.getRating()); - assertEquals(bookReview.getTitle(), result.getTitle()); - assertEquals(bookReview.getText(), result.getText()); - }); - } - - @Test - @WithMockUser(username = "user") - void addReviewWithInvalidRating() { - Stream bookReviews = Stream.of( - // lt 1 is invalid - createReview("f846b0b9-01d4-4f6d-82a4-d79204f62278", 0, "quite bad", "disappointing..."), - // gt 5 is invalid - createReview("9b084139-0b1e-43b6-b12a-7b3669d75f02", 6, "great read", "just amazing...")); - - String message = "Valid rating range needs to be within 1 and 5"; - - bookReviews.forEach(bookReview -> { - Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(bookReview.getBookId())); - assertThrows(ServiceException.class, () -> catalogService.addReview(ref, bookReview.getRating(), - bookReview.getTitle(), bookReview.getText()), message); - }); - } - - @Test - @WithMockUser(username = "user") - void addReviewForNonExistingBook() { - - String nonExistingBookId = "non-existing"; - String exMessage1 = "You have to specify the book to review"; - String exMessage2 = "A book with the specified ID '%s' does not exist".formatted(nonExistingBookId); - - Stream testCases = Stream.of( - // no book provided - new BookReviewTestFixture(createReview(null, 1, "quite bad", "disappointing..."), exMessage1), - // invalid book id - new BookReviewTestFixture(createReview(nonExistingBookId, 5, "great read", "just amazing..."), - exMessage2)); - - testCases.forEach(testCase -> { - Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(testCase.review.getBookId())); - assertThrows(ServiceException.class, () -> catalogService.addReview(ref, testCase.review.getRating(), - testCase.review.getTitle(), testCase.review.getText()), testCase.exceptionMessage); - }); - } - - @Test - @WithMockUser(username = "user") - void addReviewSameBookMoreThanOnceBySameUser() { - - String bookId = "4a519e61-3c3a-4bd9-ab12-d7e0c5329933"; - Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(bookId)); - - assertDoesNotThrow(() -> catalogService.addReview(ref, 1, "quite bad", "disappointing...")); - assertThrows(ServiceException.class, () -> catalogService.addReview(ref, 5, "great read", "just amazing..."), - "User not allowed to add more than one review for a given book"); - - String anotherBookId = "9b084139-0b1e-43b6-b12a-7b3669d75f02"; - Books_ anotherRef = CQL.entity(BOOKS).filter(b -> b.ID().eq(anotherBookId)); - - assertDoesNotThrow(() -> catalogService.addReview(anotherRef, 4, "very good", "entertaining...")); - } - - private Reviews createReview(String bookId, Integer rating, String title, String text) { - Reviews review = Reviews.create(); - review.setBookId(bookId); - review.setRating(rating); - review.setTitle(title); - review.setText(text); - return review; - } - - /* - * Holder class for a book review test case. - */ - private class BookReviewTestFixture { - Reviews review; - String exceptionMessage; - - BookReviewTestFixture(Reviews review, String exceptionMessage) { - this.review = review; - this.exceptionMessage = exceptionMessage; - } - } - + @Autowired private CatalogService catalogService; + + @Autowired private PersistenceService db; + + @AfterEach + void cleanup() { + db.run(Delete.from(REVIEWS)); + } + + @Test + @WithMockUser(username = "user") + void createReviewHandler() { + Stream bookReviews = + Stream.of( + createReview( + "f846b0b9-01d4-4f6d-82a4-d79204f62278", 1, "quite bad", "disappointing..."), + createReview( + "aebdfc8a-0dfa-4468-bd36-48aabd65e663", 5, "great read", "just amazing...")); + + bookReviews.forEach( + bookReview -> { + Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(bookReview.getBookId())); + Reviews result = + catalogService.addReview( + ref, bookReview.getRating(), bookReview.getTitle(), bookReview.getText()); + + assertEquals(bookReview.getBookId(), result.getBookId()); + assertEquals(bookReview.getRating(), result.getRating()); + assertEquals(bookReview.getTitle(), result.getTitle()); + assertEquals(bookReview.getText(), result.getText()); + }); + } + + @Test + @WithMockUser(username = "user") + void addReviewWithInvalidRating() { + Stream bookReviews = + Stream.of( + // lt 1 is invalid + createReview( + "f846b0b9-01d4-4f6d-82a4-d79204f62278", 0, "quite bad", "disappointing..."), + // gt 5 is invalid + createReview( + "9b084139-0b1e-43b6-b12a-7b3669d75f02", 6, "great read", "just amazing...")); + + String message = "Valid rating range needs to be within 1 and 5"; + + bookReviews.forEach( + bookReview -> { + Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(bookReview.getBookId())); + assertThrows( + ServiceException.class, + () -> + catalogService.addReview( + ref, bookReview.getRating(), bookReview.getTitle(), bookReview.getText()), + message); + }); + } + + @Test + @WithMockUser(username = "user") + void addReviewForNonExistingBook() { + + String nonExistingBookId = "non-existing"; + String exMessage1 = "You have to specify the book to review"; + String exMessage2 = + "A book with the specified ID '%s' does not exist".formatted(nonExistingBookId); + + Stream testCases = + Stream.of( + // no book provided + new BookReviewTestFixture( + createReview(null, 1, "quite bad", "disappointing..."), exMessage1), + // invalid book id + new BookReviewTestFixture( + createReview(nonExistingBookId, 5, "great read", "just amazing..."), exMessage2)); + + testCases.forEach( + testCase -> { + Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(testCase.review.getBookId())); + assertThrows( + ServiceException.class, + () -> + catalogService.addReview( + ref, + testCase.review.getRating(), + testCase.review.getTitle(), + testCase.review.getText()), + testCase.exceptionMessage); + }); + } + + @Test + @WithMockUser(username = "user") + void addReviewSameBookMoreThanOnceBySameUser() { + + String bookId = "4a519e61-3c3a-4bd9-ab12-d7e0c5329933"; + Books_ ref = CQL.entity(BOOKS).filter(b -> b.ID().eq(bookId)); + + assertDoesNotThrow(() -> catalogService.addReview(ref, 1, "quite bad", "disappointing...")); + assertThrows( + ServiceException.class, + () -> catalogService.addReview(ref, 5, "great read", "just amazing..."), + "User not allowed to add more than one review for a given book"); + + String anotherBookId = "9b084139-0b1e-43b6-b12a-7b3669d75f02"; + Books_ anotherRef = CQL.entity(BOOKS).filter(b -> b.ID().eq(anotherBookId)); + + assertDoesNotThrow( + () -> catalogService.addReview(anotherRef, 4, "very good", "entertaining...")); + } + + private Reviews createReview(String bookId, Integer rating, String title, String text) { + Reviews review = Reviews.create(); + review.setBookId(bookId); + review.setRating(rating); + review.setTitle(title); + review.setText(text); + return review; + } + + /* + * Holder class for a book review test case. + */ + private class BookReviewTestFixture { + Reviews review; + String exceptionMessage; + + BookReviewTestFixture(Reviews review, String exceptionMessage) { + this.review = review; + this.exceptionMessage = exceptionMessage; + } + } } diff --git a/srv/src/test/java/my/bookshop/GenreHierarchyTest.java b/srv/src/test/java/my/bookshop/GenreHierarchyTest.java index 9669f07b..d6d374bb 100644 --- a/srv/src/test/java/my/bookshop/GenreHierarchyTest.java +++ b/srv/src/test/java/my/bookshop/GenreHierarchyTest.java @@ -5,7 +5,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.net.URI; - import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -18,186 +17,214 @@ @AutoConfigureMockMvc class GenreHierarchyTest { - @Autowired - private MockMvc client; - - private static final String genresURI = "/api/browse/GenreHierarchy"; - - @Test - @WithMockUser(username = "admin") - void getAll() throws Exception { - client.perform(get(genresURI)).andExpect(status().isOk()); - } - - @Test - @WithMockUser(username = "admin") - void countAll() throws Exception { - client.perform(get(genresURI + "/$count")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").value(269)); - } - - @Test - @WithMockUser(username = "admin") - void startOneLevel() throws Exception { - client.perform(get(genresURI - + "?$select=DrillState,ID,name,DistanceFromRoot" - + "&$apply=orderby(name)/" - + "com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1)" - + "&$count=true")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].ID").value("8bbf14c6-b378-4e35-9b4f-05a9c8878001")) - .andExpect(jsonPath("$.value[0].name").value("Fiction")) - .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[0].DrillState").value("collapsed")) - .andExpect(jsonPath("$.value[1].ID").value("8bbf14c6-b378-4e35-9b4f-05a9c8878002")) - .andExpect(jsonPath("$.value[1].name").value("Non-Fiction")) - .andExpect(jsonPath("$.value[1].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[1].DrillState").value("collapsed")) - .andExpect(jsonPath("$.value[2]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void startTwoLevels() throws Exception { - client.perform(get(genresURI - + "?$select=DrillState,ID,name,DistanceFromRoot" - + "&$apply=orderby(name)/" - + "com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=2)" - + "&$count=true")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) - .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[1].name").value("Action & Adventure")) - .andExpect(jsonPath("$.value[1].DrillState").value("leaf")) - .andExpect(jsonPath("$.value[1].DistanceFromRoot").value(1)) - .andExpect(jsonPath("$.value[182].name").value("True Crime")) - .andExpect(jsonPath("$.value[182].DrillState").value("leaf")) - .andExpect(jsonPath("$.value[182].DistanceFromRoot").value(1)) - .andExpect(jsonPath("$.value[183]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void expandNonFiction() throws Exception { - client.perform(get(genresURI - + "?$select=DrillState,ID,name" - + "&$apply=descendants($root/GenreHierarchy,GenreHierarchy,ID,filter(ID eq 8bbf14c6-b378-4e35-9b4f-05a9c8878021),1)" - + "/orderby(ID)")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Detective Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("leaf")) - .andExpect(jsonPath("$.value[1]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void collapseAll() throws Exception { - client.perform(get(genresURI - + "?$select=DrillState,ID,name" - + "&$apply=orderby(name)/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1)" - + "&$count=true&$skip=0&$top=238")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("collapsed")) - .andExpect(jsonPath("$.value[1].name").value("Non-Fiction")) - .andExpect(jsonPath("$.value[1].DrillState").value("collapsed")) - .andExpect(jsonPath("$.value[2]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void expandAllTop100() throws Exception { - String url = genresURI - + "?$select=DistanceFromRoot,DrillState,ID,LimitedDescendantCount,name" - + "&$apply=orderby(name)/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID')" - + "&$count=true&$skip=0&$top=100"; - - client.perform(get(url)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) - .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[99].name").value("New Weird")) - .andExpect(jsonPath("$.value[99].DrillState").value("leaf")) - .andExpect(jsonPath("$.value[100]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void search() throws Exception { - client.perform(get(genresURI - + "?$select=DistanceFromRoot,DrillState,ID,LimitedDescendantCount,name" - + "&$apply=ancestors($root/GenreHierarchy,GenreHierarchy,ID,search(\"true\"),keep start)" - + "/orderby(name)" - + "/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID')" - + "&$count=true")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) - .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[1].name").value("Adventure")) - .andExpect(jsonPath("$.value[1].DrillState").value("expanded")) - .andExpect(jsonPath("$.value[1].DistanceFromRoot").value(1)) - .andExpect(jsonPath("$.value[2].name").value("True Adventure")) - .andExpect(jsonPath("$.value[2].DrillState").value("leaf")) - .andExpect(jsonPath("$.value[2].DistanceFromRoot").value(2)) - .andExpect(jsonPath("$.value[3].name").value("Non-Fiction")) - .andExpect(jsonPath("$.value[3].DrillState").value("expanded")) - .andExpect(jsonPath("$.value[3].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[4].name").value("True Crime")) - .andExpect(jsonPath("$.value[4].DrillState").value("leaf")) - .andExpect(jsonPath("$.value[4].DistanceFromRoot").value(1)) - .andExpect(jsonPath("$.value[5]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void filterNotExpanded() throws Exception { - client.perform(get(genresURI - + "?$select=DrillState,ID,name,DistanceFromRoot" - + "&$apply=ancestors($root/GenreHierarchy,GenreHierarchy,ID,filter(name eq 'Autobiography'),keep start)/orderby(name)" - + "/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1)")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Non-Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("collapsed")) - .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[1]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void filterExpandLevels() throws Exception { - String expandLevelsJson = """ + @Autowired private MockMvc client; + + private static final String genresURI = "/api/browse/GenreHierarchy"; + + @Test + @WithMockUser(username = "admin") + void getAll() throws Exception { + client.perform(get(genresURI)).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "admin") + void countAll() throws Exception { + client + .perform(get(genresURI + "/$count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").value(269)); + } + + @Test + @WithMockUser(username = "admin") + void startOneLevel() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DrillState,ID,name,DistanceFromRoot" + + "&$apply=orderby(name)/" + + "com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1)" + + "&$count=true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].ID").value("8bbf14c6-b378-4e35-9b4f-05a9c8878001")) + .andExpect(jsonPath("$.value[0].name").value("Fiction")) + .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[0].DrillState").value("collapsed")) + .andExpect(jsonPath("$.value[1].ID").value("8bbf14c6-b378-4e35-9b4f-05a9c8878002")) + .andExpect(jsonPath("$.value[1].name").value("Non-Fiction")) + .andExpect(jsonPath("$.value[1].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[1].DrillState").value("collapsed")) + .andExpect(jsonPath("$.value[2]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void startTwoLevels() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DrillState,ID,name,DistanceFromRoot" + + "&$apply=orderby(name)/" + + "com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=2)" + + "&$count=true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) + .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[1].name").value("Action & Adventure")) + .andExpect(jsonPath("$.value[1].DrillState").value("leaf")) + .andExpect(jsonPath("$.value[1].DistanceFromRoot").value(1)) + .andExpect(jsonPath("$.value[182].name").value("True Crime")) + .andExpect(jsonPath("$.value[182].DrillState").value("leaf")) + .andExpect(jsonPath("$.value[182].DistanceFromRoot").value(1)) + .andExpect(jsonPath("$.value[183]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void expandNonFiction() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DrillState,ID,name" + + "&$apply=descendants($root/GenreHierarchy,GenreHierarchy,ID,filter(ID eq 8bbf14c6-b378-4e35-9b4f-05a9c8878021),1)" + + "/orderby(ID)")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Detective Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("leaf")) + .andExpect(jsonPath("$.value[1]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void collapseAll() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DrillState,ID,name" + + "&$apply=orderby(name)/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1)" + + "&$count=true&$skip=0&$top=238")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("collapsed")) + .andExpect(jsonPath("$.value[1].name").value("Non-Fiction")) + .andExpect(jsonPath("$.value[1].DrillState").value("collapsed")) + .andExpect(jsonPath("$.value[2]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void expandAllTop100() throws Exception { + String url = + genresURI + + "?$select=DistanceFromRoot,DrillState,ID,LimitedDescendantCount,name" + + "&$apply=orderby(name)/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID')" + + "&$count=true&$skip=0&$top=100"; + + client + .perform(get(url)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) + .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[99].name").value("New Weird")) + .andExpect(jsonPath("$.value[99].DrillState").value("leaf")) + .andExpect(jsonPath("$.value[100]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void search() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DistanceFromRoot,DrillState,ID,LimitedDescendantCount,name" + + "&$apply=ancestors($root/GenreHierarchy,GenreHierarchy,ID,search(\"true\"),keep start)" + + "/orderby(name)" + + "/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID')" + + "&$count=true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) + .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[1].name").value("Adventure")) + .andExpect(jsonPath("$.value[1].DrillState").value("expanded")) + .andExpect(jsonPath("$.value[1].DistanceFromRoot").value(1)) + .andExpect(jsonPath("$.value[2].name").value("True Adventure")) + .andExpect(jsonPath("$.value[2].DrillState").value("leaf")) + .andExpect(jsonPath("$.value[2].DistanceFromRoot").value(2)) + .andExpect(jsonPath("$.value[3].name").value("Non-Fiction")) + .andExpect(jsonPath("$.value[3].DrillState").value("expanded")) + .andExpect(jsonPath("$.value[3].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[4].name").value("True Crime")) + .andExpect(jsonPath("$.value[4].DrillState").value("leaf")) + .andExpect(jsonPath("$.value[4].DistanceFromRoot").value(1)) + .andExpect(jsonPath("$.value[5]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void filterNotExpanded() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DrillState,ID,name,DistanceFromRoot" + + "&$apply=ancestors($root/GenreHierarchy,GenreHierarchy,ID,filter(name eq 'Autobiography'),keep start)/orderby(name)" + + "/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1)")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Non-Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("collapsed")) + .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[1]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void filterExpandLevels() throws Exception { + String expandLevelsJson = + """ [{"NodeID":"8bbf14c6-b378-4e35-9b4f-05a9c8878002","Levels":1},{"NodeID":"8bbf14c6-b378-4e35-9b4f-05a9c8878031","Levels":1}]\ """; - String unencoded = genresURI + "?$select=DistanceFromRoot,DrillState,ID,LimitedDescendantCount,name" - + "&$apply=ancestors($root/GenreHierarchy,GenreHierarchy,ID,filter(name eq 'Autobiography'),keep start)/orderby(name)" - + "/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1,ExpandLevels=" - + expandLevelsJson + ")&$count=true"; - String uriString = UriComponentsBuilder.fromUriString(unencoded).toUriString(); - URI uri = URI.create(uriString); - client.perform(get(uri)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Non-Fiction")) - .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) - .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) - .andExpect(jsonPath("$.value[2]").doesNotExist()); - } - - @Test - @WithMockUser(username = "admin") - void startTwoLevelsOrderByDesc() throws Exception { - client.perform(get(genresURI - + "?$select=DrillState,ID,name,DistanceFromRoot" - + "&$apply=orderby(name desc)/" - + "com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=2)" - + "&$count=true")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value[0].name").value("Non-Fiction")) - .andExpect(jsonPath("$.value[1].name").value("True Crime")) - .andExpect(jsonPath("$.value[182].name").value("Action & Adventure")) - .andExpect(jsonPath("$.value[183]").doesNotExist()); - } + String unencoded = + genresURI + + "?$select=DistanceFromRoot,DrillState,ID,LimitedDescendantCount,name" + + "&$apply=ancestors($root/GenreHierarchy,GenreHierarchy,ID,filter(name eq 'Autobiography'),keep start)/orderby(name)" + + "/com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=1,ExpandLevels=" + + expandLevelsJson + + ")&$count=true"; + String uriString = UriComponentsBuilder.fromUriString(unencoded).toUriString(); + URI uri = URI.create(uriString); + client + .perform(get(uri)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Non-Fiction")) + .andExpect(jsonPath("$.value[0].DrillState").value("expanded")) + .andExpect(jsonPath("$.value[0].DistanceFromRoot").value(0)) + .andExpect(jsonPath("$.value[2]").doesNotExist()); + } + + @Test + @WithMockUser(username = "admin") + void startTwoLevelsOrderByDesc() throws Exception { + client + .perform( + get( + genresURI + + "?$select=DrillState,ID,name,DistanceFromRoot" + + "&$apply=orderby(name desc)/" + + "com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/GenreHierarchy,HierarchyQualifier='GenreHierarchy',NodeProperty='ID',Levels=2)" + + "&$count=true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].name").value("Non-Fiction")) + .andExpect(jsonPath("$.value[1].name").value("True Crime")) + .andExpect(jsonPath("$.value[182].name").value("Action & Adventure")) + .andExpect(jsonPath("$.value[183]").doesNotExist()); + } } diff --git a/srv/src/test/java/my/bookshop/NotesServiceITest.java b/srv/src/test/java/my/bookshop/NotesServiceITest.java index 9fb2fe84..cc6c68b2 100644 --- a/srv/src/test/java/my/bookshop/NotesServiceITest.java +++ b/srv/src/test/java/my/bookshop/NotesServiceITest.java @@ -8,185 +8,333 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.reactive.server.WebTestClient; -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = "cds.remote.services.'[API_BUSINESS_PARTNER]'.destination.name=myself-NotesServiceITest") +@SpringBootTest( + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = + "cds.remote.services.'[API_BUSINESS_PARTNER]'.destination.name=myself-NotesServiceITest") @ActiveProfiles({"default", "mocked"}) class NotesServiceITest { - private static final String notesURI = "/api/notes/Notes"; - private static final String addressesURI = "/api/notes/Addresses"; + private static final String notesURI = "/api/notes/Notes"; + private static final String addressesURI = "/api/notes/Addresses"; - @Autowired - private WebTestClient client; + @Autowired private WebTestClient client; - @Test - void getNotes() { - client.get().uri(notesURI).headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.['@context']").isEqualTo("$metadata#Notes") - .jsonPath("$.value[0].ID").isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") - .jsonPath("$.value[0].note").isEqualTo("Ring at building 8") - .jsonPath("$.value[0].address_businessPartner").isEqualTo("1000020") - .jsonPath("$.value[0].address_ID").isEqualTo("500") - .jsonPath("$.value[1].ID").isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") - .jsonPath("$.value[1].note").isEqualTo("Packages can be dropped off at the reception") - .jsonPath("$.value[1].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[1].address_ID").isEqualTo("100") - .jsonPath("$.value[2].ID").isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") - .jsonPath("$.value[2].note").isEqualTo("Don't deliver packages after 5pm") - .jsonPath("$.value[2].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[2].address_ID").isEqualTo("100"); - } + @Test + void getNotes() { + client + .get() + .uri(notesURI) + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.['@context']") + .isEqualTo("$metadata#Notes") + .jsonPath("$.value[0].ID") + .isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") + .jsonPath("$.value[0].note") + .isEqualTo("Ring at building 8") + .jsonPath("$.value[0].address_businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[0].address_ID") + .isEqualTo("500") + .jsonPath("$.value[1].ID") + .isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") + .jsonPath("$.value[1].note") + .isEqualTo("Packages can be dropped off at the reception") + .jsonPath("$.value[1].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[1].address_ID") + .isEqualTo("100") + .jsonPath("$.value[2].ID") + .isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") + .jsonPath("$.value[2].note") + .isEqualTo("Don't deliver packages after 5pm") + .jsonPath("$.value[2].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[2].address_ID") + .isEqualTo("100"); + } - @Test - void getAddresses() { - client.get().uri(addressesURI + "?$filter=businessPartner eq '10401010'").headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.['@context']").isEqualTo("$metadata#Addresses") - .jsonPath("$.value[0].ID").isEqualTo("100") - .jsonPath("$.value[0].postalCode").isEqualTo("68199") - .jsonPath("$.value[1].ID").isEqualTo("200") - .jsonPath("$.value[1].postalCode").isEqualTo("68789") - .jsonPath("$.value[2].ID").isEqualTo("300") - .jsonPath("$.value[2].postalCode").isEqualTo("14469"); - } + @Test + void getAddresses() { + client + .get() + .uri(addressesURI + "?$filter=businessPartner eq '10401010'") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.['@context']") + .isEqualTo("$metadata#Addresses") + .jsonPath("$.value[0].ID") + .isEqualTo("100") + .jsonPath("$.value[0].postalCode") + .isEqualTo("68199") + .jsonPath("$.value[1].ID") + .isEqualTo("200") + .jsonPath("$.value[1].postalCode") + .isEqualTo("68789") + .jsonPath("$.value[2].ID") + .isEqualTo("300") + .jsonPath("$.value[2].postalCode") + .isEqualTo("14469"); + } - @Test - void getNoteWithAddress() { - client.get().uri(notesURI + "?$expand=address").headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.['@context']").isEqualTo("$metadata#Notes(address())") - .jsonPath("$.value[0].ID").isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") - .jsonPath("$.value[0].note").isEqualTo("Ring at building 8") - .jsonPath("$.value[0].address_businessPartner").isEqualTo("1000020") - .jsonPath("$.value[0].address_ID").isEqualTo("500") - .jsonPath("$.value[0].address.businessPartner").isEqualTo("1000020") - .jsonPath("$.value[0].address.ID").isEqualTo("500") - .jsonPath("$.value[0].address.postalCode").isEqualTo("94304") - .jsonPath("$.value[1].ID").isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") - .jsonPath("$.value[1].note").isEqualTo("Packages can be dropped off at the reception") - .jsonPath("$.value[1].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[1].address_ID").isEqualTo("100") - .jsonPath("$.value[1].address.businessPartner").isEqualTo("10401010") - .jsonPath("$.value[1].address.ID").isEqualTo("100") - .jsonPath("$.value[1].address.postalCode").isEqualTo("68199") - .jsonPath("$.value[2].ID").isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") - .jsonPath("$.value[2].note").isEqualTo("Don't deliver packages after 5pm") - .jsonPath("$.value[2].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[2].address_ID").isEqualTo("100") - .jsonPath("$.value[2].address.businessPartner").isEqualTo("10401010") - .jsonPath("$.value[2].address.ID").isEqualTo("100") - .jsonPath("$.value[2].address.postalCode").isEqualTo("68199"); - } + @Test + void getNoteWithAddress() { + client + .get() + .uri(notesURI + "?$expand=address") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.['@context']") + .isEqualTo("$metadata#Notes(address())") + .jsonPath("$.value[0].ID") + .isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") + .jsonPath("$.value[0].note") + .isEqualTo("Ring at building 8") + .jsonPath("$.value[0].address_businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[0].address_ID") + .isEqualTo("500") + .jsonPath("$.value[0].address.businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[0].address.ID") + .isEqualTo("500") + .jsonPath("$.value[0].address.postalCode") + .isEqualTo("94304") + .jsonPath("$.value[1].ID") + .isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") + .jsonPath("$.value[1].note") + .isEqualTo("Packages can be dropped off at the reception") + .jsonPath("$.value[1].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[1].address_ID") + .isEqualTo("100") + .jsonPath("$.value[1].address.businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[1].address.ID") + .isEqualTo("100") + .jsonPath("$.value[1].address.postalCode") + .isEqualTo("68199") + .jsonPath("$.value[2].ID") + .isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") + .jsonPath("$.value[2].note") + .isEqualTo("Don't deliver packages after 5pm") + .jsonPath("$.value[2].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[2].address_ID") + .isEqualTo("100") + .jsonPath("$.value[2].address.businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[2].address.ID") + .isEqualTo("100") + .jsonPath("$.value[2].address.postalCode") + .isEqualTo("68199"); + } - @Test - void getSuppliersWithNotes() { - client.get().uri(addressesURI + "?$expand=notes($orderby=ID)&$filter=businessPartner eq '10401010'").headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.['@context']").isEqualTo("$metadata#Addresses(notes())") - .jsonPath("$.value[0].ID").isEqualTo("100") - .jsonPath("$.value[0].postalCode").isEqualTo("68199") - .jsonPath("$.value[0].notes[0].ID").isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") - .jsonPath("$.value[0].notes[0].note").isEqualTo("Packages can be dropped off at the reception") - .jsonPath("$.value[0].notes[0].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[0].notes[0].address_ID").isEqualTo("100") - .jsonPath("$.value[0].notes[1].ID").isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") - .jsonPath("$.value[0].notes[1].note").isEqualTo("Don't deliver packages after 5pm") - .jsonPath("$.value[0].notes[1].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[0].notes[1].address_ID").isEqualTo("100") - .jsonPath("$.value[0].notes[2]").doesNotExist() - .jsonPath("$.value[1].ID").isEqualTo("200") - .jsonPath("$.value[1].postalCode").isEqualTo("68789") - .jsonPath("$.value[2].notes").isEmpty() - .jsonPath("$.value[2].ID").isEqualTo("300") - .jsonPath("$.value[2].postalCode").isEqualTo("14469") - .jsonPath("$.value[2].notes").isEmpty(); - } + @Test + void getSuppliersWithNotes() { + client + .get() + .uri(addressesURI + "?$expand=notes($orderby=ID)&$filter=businessPartner eq '10401010'") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.['@context']") + .isEqualTo("$metadata#Addresses(notes())") + .jsonPath("$.value[0].ID") + .isEqualTo("100") + .jsonPath("$.value[0].postalCode") + .isEqualTo("68199") + .jsonPath("$.value[0].notes[0].ID") + .isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") + .jsonPath("$.value[0].notes[0].note") + .isEqualTo("Packages can be dropped off at the reception") + .jsonPath("$.value[0].notes[0].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[0].notes[0].address_ID") + .isEqualTo("100") + .jsonPath("$.value[0].notes[1].ID") + .isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") + .jsonPath("$.value[0].notes[1].note") + .isEqualTo("Don't deliver packages after 5pm") + .jsonPath("$.value[0].notes[1].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[0].notes[1].address_ID") + .isEqualTo("100") + .jsonPath("$.value[0].notes[2]") + .doesNotExist() + .jsonPath("$.value[1].ID") + .isEqualTo("200") + .jsonPath("$.value[1].postalCode") + .isEqualTo("68789") + .jsonPath("$.value[2].notes") + .isEmpty() + .jsonPath("$.value[2].ID") + .isEqualTo("300") + .jsonPath("$.value[2].postalCode") + .isEqualTo("14469") + .jsonPath("$.value[2].notes") + .isEmpty(); + } - @Test - void getNotesToSupplier() { - client.get().uri(notesURI + "(ID=5efc842c-c70d-4ee2-af1d-81c7d257aff7,IsActiveEntity=true)/address").headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.['@context']").isEqualTo("$metadata#Addresses/$entity") - .jsonPath("$.businessPartner").isEqualTo("1000020") - .jsonPath("$.ID").isEqualTo("500") - .jsonPath("$.postalCode").isEqualTo("94304"); - } + @Test + void getNotesToSupplier() { + client + .get() + .uri(notesURI + "(ID=5efc842c-c70d-4ee2-af1d-81c7d257aff7,IsActiveEntity=true)/address") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.['@context']") + .isEqualTo("$metadata#Addresses/$entity") + .jsonPath("$.businessPartner") + .isEqualTo("1000020") + .jsonPath("$.ID") + .isEqualTo("500") + .jsonPath("$.postalCode") + .isEqualTo("94304"); + } - @Test - void getSupplierToNotes() { - client.get().uri(addressesURI + "(businessPartner='10401010',ID='100')/notes").headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.value[0].ID").isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") - .jsonPath("$.value[0].note").isEqualTo("Packages can be dropped off at the reception") - .jsonPath("$.value[0].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[0].address_ID").isEqualTo("100") - .jsonPath("$.value[1].ID").isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") - .jsonPath("$.value[1].note").isEqualTo("Don't deliver packages after 5pm") - .jsonPath("$.value[1].address_businessPartner").isEqualTo("10401010") - .jsonPath("$.value[1].address_ID").isEqualTo("100") - .jsonPath("$.value[2]").doesNotExist(); - } + @Test + void getSupplierToNotes() { + client + .get() + .uri(addressesURI + "(businessPartner='10401010',ID='100')/notes") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.value[0].ID") + .isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") + .jsonPath("$.value[0].note") + .isEqualTo("Packages can be dropped off at the reception") + .jsonPath("$.value[0].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[0].address_ID") + .isEqualTo("100") + .jsonPath("$.value[1].ID") + .isEqualTo("880147b0-8d2d-4ef8-bb52-ae5ae6002fc5") + .jsonPath("$.value[1].note") + .isEqualTo("Don't deliver packages after 5pm") + .jsonPath("$.value[1].address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.value[1].address_ID") + .isEqualTo("100") + .jsonPath("$.value[2]") + .doesNotExist(); + } - @Test - void getSupplierToSpecificNote() { - client.get().uri(addressesURI + "(businessPartner='10401010',ID='100')/notes(ID=83e2643b-aecc-47d3-9f85-a8ba14eff07d,IsActiveEntity=true)") - .headers(this::authenticatedCredentials) - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.ID").isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") - .jsonPath("$.note").isEqualTo("Packages can be dropped off at the reception") - .jsonPath("$.address_businessPartner").isEqualTo("10401010") - .jsonPath("$.address_ID").isEqualTo("100"); - } + @Test + void getSupplierToSpecificNote() { + client + .get() + .uri( + addressesURI + + "(businessPartner='10401010',ID='100')/notes(ID=83e2643b-aecc-47d3-9f85-a8ba14eff07d,IsActiveEntity=true)") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.ID") + .isEqualTo("83e2643b-aecc-47d3-9f85-a8ba14eff07d") + .jsonPath("$.note") + .isEqualTo("Packages can be dropped off at the reception") + .jsonPath("$.address_businessPartner") + .isEqualTo("10401010") + .jsonPath("$.address_ID") + .isEqualTo("100"); + } - @Test - void getNotesWithNestedExpands() { - client.get().uri(notesURI + "?$select=note&$expand=address($select=postalCode;$expand=notes($select=note))&$top=1").headers(this::authenticatedCredentials).exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.value[0].ID").isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") - .jsonPath("$.value[0].note").isEqualTo("Ring at building 8") - .jsonPath("$.value[0].address.businessPartner").isEqualTo("1000020") - .jsonPath("$.value[0].address.ID").isEqualTo("500") - .jsonPath("$.value[0].address.postalCode").isEqualTo("94304") - .jsonPath("$.value[0].address.notes[0].ID").isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") - .jsonPath("$.value[0].address.notes[0].note").isEqualTo("Ring at building 8") - .jsonPath("$.value[0].address.notes[1]").doesNotExist() - .jsonPath("$.value[1]").doesNotExist(); - } + @Test + void getNotesWithNestedExpands() { + client + .get() + .uri( + notesURI + + "?$select=note&$expand=address($select=postalCode;$expand=notes($select=note))&$top=1") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.value[0].ID") + .isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") + .jsonPath("$.value[0].note") + .isEqualTo("Ring at building 8") + .jsonPath("$.value[0].address.businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[0].address.ID") + .isEqualTo("500") + .jsonPath("$.value[0].address.postalCode") + .isEqualTo("94304") + .jsonPath("$.value[0].address.notes[0].ID") + .isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") + .jsonPath("$.value[0].address.notes[0].note") + .isEqualTo("Ring at building 8") + .jsonPath("$.value[0].address.notes[1]") + .doesNotExist() + .jsonPath("$.value[1]") + .doesNotExist(); + } - @Test - void getAddressesWithNestedExpands() { - client.get().uri(addressesURI + "?$select=postalCode&$expand=notes($select=note;$expand=address($select=postalCode))&$filter=businessPartner eq '1000020'") - .headers(this::authenticatedCredentials) - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.value[0].businessPartner").isEqualTo("1000020") - .jsonPath("$.value[0].ID").isEqualTo("400") - .jsonPath("$.value[0].postalCode").isEqualTo("19073") - .jsonPath("$.value[0].notes").isEmpty() - .jsonPath("$.value[1].businessPartner").isEqualTo("1000020") - .jsonPath("$.value[1].ID").isEqualTo("500") - .jsonPath("$.value[1].postalCode").isEqualTo("94304") - .jsonPath("$.value[1].notes[0].ID").isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") - .jsonPath("$.value[1].notes[0].note").isEqualTo("Ring at building 8") - .jsonPath("$.value[1].notes[0].address.businessPartner").isEqualTo("1000020") - .jsonPath("$.value[1].notes[0].address.ID").isEqualTo("500") - .jsonPath("$.value[1].notes[0].address.postalCode").isEqualTo("94304") - .jsonPath("$.value[1].notes[1]").doesNotExist() - .jsonPath("$.value[2]").doesNotExist(); - } + @Test + void getAddressesWithNestedExpands() { + client + .get() + .uri( + addressesURI + + "?$select=postalCode&$expand=notes($select=note;$expand=address($select=postalCode))&$filter=businessPartner eq '1000020'") + .headers(this::authenticatedCredentials) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.value[0].businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[0].ID") + .isEqualTo("400") + .jsonPath("$.value[0].postalCode") + .isEqualTo("19073") + .jsonPath("$.value[0].notes") + .isEmpty() + .jsonPath("$.value[1].businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[1].ID") + .isEqualTo("500") + .jsonPath("$.value[1].postalCode") + .isEqualTo("94304") + .jsonPath("$.value[1].notes[0].ID") + .isEqualTo("5efc842c-c70d-4ee2-af1d-81c7d257aff7") + .jsonPath("$.value[1].notes[0].note") + .isEqualTo("Ring at building 8") + .jsonPath("$.value[1].notes[0].address.businessPartner") + .isEqualTo("1000020") + .jsonPath("$.value[1].notes[0].address.ID") + .isEqualTo("500") + .jsonPath("$.value[1].notes[0].address.postalCode") + .isEqualTo("94304") + .jsonPath("$.value[1].notes[1]") + .doesNotExist() + .jsonPath("$.value[2]") + .doesNotExist(); + } - private void authenticatedCredentials(HttpHeaders headers) { - headers.setBasicAuth("authenticated", ""); - } + private void authenticatedCredentials(HttpHeaders headers) { + headers.setBasicAuth("authenticated", ""); + } } diff --git a/srv/src/test/java/my/bookshop/RatingCalculatorTest.java b/srv/src/test/java/my/bookshop/RatingCalculatorTest.java index dea8ea05..60034fd3 100644 --- a/srv/src/test/java/my/bookshop/RatingCalculatorTest.java +++ b/srv/src/test/java/my/bookshop/RatingCalculatorTest.java @@ -4,34 +4,34 @@ import java.math.BigDecimal; import java.util.stream.Stream; - import org.junit.jupiter.api.Test; class RatingCalculatorTest { - /* - * Holder class for a book rating calculation test case. - */ - private static class RatingTestFixture { - Stream ratings; - double expectedAvg; - - RatingTestFixture(Stream ratings, double expectedAvg) { - this.ratings = ratings; - this.expectedAvg = expectedAvg; - } - } - - @Test - void getAvgRating() { - RatingTestFixture f1 = new RatingTestFixture(Stream.of(1.0, 2.0, 3.0, 4.0, 5.0), 3.0); - RatingTestFixture f2 = new RatingTestFixture(Stream.of(1.3, 2.4, 3.5, 4.9, 5.1), 3.4); - RatingTestFixture f3 = new RatingTestFixture(Stream.of(2.1, 4.0, 2.7, 3.8, 4.9), 3.5); - - Stream.of(f1, f2, f3).forEach(f -> { - BigDecimal avgRating = RatingCalculator.getAvgRating(f.ratings); - assertEquals(f.expectedAvg, avgRating.doubleValue()); - }); - } - + /* + * Holder class for a book rating calculation test case. + */ + private static class RatingTestFixture { + Stream ratings; + double expectedAvg; + + RatingTestFixture(Stream ratings, double expectedAvg) { + this.ratings = ratings; + this.expectedAvg = expectedAvg; + } + } + + @Test + void getAvgRating() { + RatingTestFixture f1 = new RatingTestFixture(Stream.of(1.0, 2.0, 3.0, 4.0, 5.0), 3.0); + RatingTestFixture f2 = new RatingTestFixture(Stream.of(1.3, 2.4, 3.5, 4.9, 5.1), 3.4); + RatingTestFixture f3 = new RatingTestFixture(Stream.of(2.1, 4.0, 2.7, 3.8, 4.9), 3.5); + + Stream.of(f1, f2, f3) + .forEach( + f -> { + BigDecimal avgRating = RatingCalculator.getAvgRating(f.ratings); + assertEquals(f.expectedAvg, avgRating.doubleValue()); + }); + } } diff --git a/srv/src/test/java/my/bookshop/handlers/CatalogServiceHandlerTest.java b/srv/src/test/java/my/bookshop/handlers/CatalogServiceHandlerTest.java index a5df2c73..2ffda80f 100644 --- a/srv/src/test/java/my/bookshop/handlers/CatalogServiceHandlerTest.java +++ b/srv/src/test/java/my/bookshop/handlers/CatalogServiceHandlerTest.java @@ -2,29 +2,27 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import cds.gen.catalogservice.Books; +import com.sap.cds.services.request.FeatureTogglesInfo; import java.util.stream.Stream; - import org.junit.jupiter.api.Test; -import com.sap.cds.services.request.FeatureTogglesInfo; - -import cds.gen.catalogservice.Books; - class CatalogServiceHandlerTest { - @Test - void discountHandler() { - Books book1 = Books.create(); - book1.setTitle("Book 1"); - book1.setStock(10); - Books book2 = Books.create(); - book2.setTitle("Book 2"); - book2.setStock(200); - - CatalogServiceHandler handler = new CatalogServiceHandler(null, null, null, FeatureTogglesInfo.create(), null, null); - handler.discountBooks(Stream.of(book1, book2)); - - assertEquals("Book 1", book1.getTitle(), "Book 1 was discounted"); - assertEquals("Book 2 -- 11% discount", book2.getTitle(), "Book 2 was not discounted"); - } + @Test + void discountHandler() { + Books book1 = Books.create(); + book1.setTitle("Book 1"); + book1.setStock(10); + Books book2 = Books.create(); + book2.setTitle("Book 2"); + book2.setStock(200); + + CatalogServiceHandler handler = + new CatalogServiceHandler(null, null, null, FeatureTogglesInfo.create(), null, null); + handler.discountBooks(Stream.of(book1, book2)); + + assertEquals("Book 1", book1.getTitle(), "Book 1 was discounted"); + assertEquals("Book 2 -- 11% discount", book2.getTitle(), "Book 2 was not discounted"); + } }