diff --git a/pom.xml b/pom.xml index 77b1c58..f3dd73c 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,10 @@ io.quarkus quarkus-flyway + + io.quarkus + quarkus-vertx + io.quarkus @@ -203,6 +207,12 @@ 2.30.1 test + + org.awaitility + awaitility + 4.1.0 + test + diff --git a/src/main/java/io/tackle/applicationinventory/dto/BulkReviewDto.java b/src/main/java/io/tackle/applicationinventory/dto/BulkReviewDto.java new file mode 100644 index 0000000..ab6f5eb --- /dev/null +++ b/src/main/java/io/tackle/applicationinventory/dto/BulkReviewDto.java @@ -0,0 +1,51 @@ +package io.tackle.applicationinventory.dto; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BulkReviewDto { + + private Long id; + + @NotNull + private Long sourceReview; + + @NotEmpty + private List targetApplications; + + private boolean completed; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getSourceReview() { + return sourceReview; + } + + public void setSourceReview(Long sourceReview) { + this.sourceReview = sourceReview; + } + + public List getTargetApplications() { + return targetApplications; + } + + public void setTargetApplications(List targetApplications) { + this.targetApplications = targetApplications; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } + +} diff --git a/src/main/java/io/tackle/applicationinventory/dto/utils/EntityToDTO.java b/src/main/java/io/tackle/applicationinventory/dto/utils/EntityToDTO.java new file mode 100644 index 0000000..5e15c5f --- /dev/null +++ b/src/main/java/io/tackle/applicationinventory/dto/utils/EntityToDTO.java @@ -0,0 +1,23 @@ +package io.tackle.applicationinventory.dto.utils; + +import io.tackle.applicationinventory.dto.BulkReviewDto; +import io.tackle.applicationinventory.entities.BulkCopyReview; + +import java.util.stream.Collectors; + +public class EntityToDTO { + + public static BulkReviewDto toDTO(BulkCopyReview entity) { + BulkReviewDto dto = new BulkReviewDto(); + + dto.setId(entity.id); + dto.setCompleted(entity.completed); + dto.setSourceReview(entity.sourceReview.id); + dto.setTargetApplications(entity.targetApplications.stream() + .map(f -> f.application.id) + .collect(Collectors.toList()) + ); + + return dto; + } +} diff --git a/src/main/java/io/tackle/applicationinventory/entities/BulkCopyReview.java b/src/main/java/io/tackle/applicationinventory/entities/BulkCopyReview.java new file mode 100644 index 0000000..1d50c68 --- /dev/null +++ b/src/main/java/io/tackle/applicationinventory/entities/BulkCopyReview.java @@ -0,0 +1,26 @@ +package io.tackle.applicationinventory.entities; + +import io.tackle.commons.entities.AbstractEntity; +import org.hibernate.annotations.ResultCheckStyle; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import javax.persistence.*; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "bulk_copy_review") +@SQLDelete(sql = "UPDATE bulk_copy_review SET deleted = true WHERE id = ?", check = ResultCheckStyle.COUNT) +@Where(clause = "deleted = false") +public class BulkCopyReview extends AbstractEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(foreignKey = @ForeignKey, name = "review_id") + public Review sourceReview; + + @OneToMany(fetch = FetchType.LAZY, cascade = {CascadeType.ALL}, orphanRemoval = true, mappedBy = "bulkCopyReview") + public Set targetApplications = new HashSet<>(); + + public boolean completed = false; +} diff --git a/src/main/java/io/tackle/applicationinventory/entities/BulkCopyReviewDetails.java b/src/main/java/io/tackle/applicationinventory/entities/BulkCopyReviewDetails.java new file mode 100644 index 0000000..c7fb203 --- /dev/null +++ b/src/main/java/io/tackle/applicationinventory/entities/BulkCopyReviewDetails.java @@ -0,0 +1,24 @@ +package io.tackle.applicationinventory.entities; + +import io.tackle.commons.entities.AbstractEntity; +import org.hibernate.annotations.ResultCheckStyle; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import javax.persistence.*; + +@Entity +@Table(name = "bulk_copy_review_details") +@SQLDelete(sql = "UPDATE bulk_copy_review_details SET deleted = true WHERE id = ?", check = ResultCheckStyle.COUNT) +@Where(clause = "deleted = false") +public class BulkCopyReviewDetails extends AbstractEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(foreignKey = @ForeignKey, name = "bulk_copy_review_id") + public BulkCopyReview bulkCopyReview; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(foreignKey = @ForeignKey, name = "application_id") + public Application application; + +} diff --git a/src/main/java/io/tackle/applicationinventory/entities/Review.java b/src/main/java/io/tackle/applicationinventory/entities/Review.java index f3e8fe9..efa1560 100644 --- a/src/main/java/io/tackle/applicationinventory/entities/Review.java +++ b/src/main/java/io/tackle/applicationinventory/entities/Review.java @@ -26,4 +26,8 @@ public class Review extends AbstractEntity { @OneToOne(fetch = FetchType.LAZY, optional = false) @JsonIgnoreProperties(value = {"review"}, allowSetters = true) public Application application; + + // No foreign keys associated to this field since foreign keys might + // complicate DELETE operations. + public Long copiedFromReviewId; } diff --git a/src/main/java/io/tackle/applicationinventory/resources/ReviewBulkCopyResource.java b/src/main/java/io/tackle/applicationinventory/resources/ReviewBulkCopyResource.java new file mode 100644 index 0000000..958ae66 --- /dev/null +++ b/src/main/java/io/tackle/applicationinventory/resources/ReviewBulkCopyResource.java @@ -0,0 +1,99 @@ +package io.tackle.applicationinventory.resources; + +import io.tackle.applicationinventory.dto.BulkReviewDto; +import io.tackle.applicationinventory.dto.utils.EntityToDTO; +import io.tackle.applicationinventory.entities.Application; +import io.tackle.applicationinventory.entities.BulkCopyReview; +import io.tackle.applicationinventory.entities.BulkCopyReviewDetails; +import io.tackle.applicationinventory.entities.Review; +import io.tackle.applicationinventory.services.BulkCopyReviewService; +import io.vertx.core.eventbus.EventBus; + +import javax.inject.Inject; +import javax.transaction.NotSupportedException; +import javax.transaction.*; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import javax.ws.rs.*; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Path("/review/bulk") +@Produces("application/json") +@Consumes("application/json") +public class ReviewBulkCopyResource { + + @Inject + UserTransaction transaction; + + @Inject + EventBus eventBus; + + private void handleSimpleRollback(UserTransaction transaction) { + try { + transaction.rollback(); + } catch (SystemException e) { + throw new IllegalStateException(e); + } + } + + @POST + public BulkReviewDto createBulkCopyReview( + @NotNull @Valid BulkReviewDto bulkReviewInput + ) throws SystemException, NotSupportedException, HeuristicRollbackException, HeuristicMixedException, RollbackException { + transaction.begin(); + + // Find source review + Long sourceReviewId = bulkReviewInput.getSourceReview(); + Review sourceReview = Review.findByIdOptional(sourceReviewId) + .orElseThrow(() -> { + handleSimpleRollback(transaction); + return new BadRequestException("Source review not valid"); + }); + + // Find target applications + List applicationsTarget = bulkReviewInput.getTargetApplications().stream() + .map(appId -> Application.findById(appId)) + .collect(Collectors.toList()); + if (applicationsTarget.stream().anyMatch(Objects::isNull)) { + handleSimpleRollback(transaction); + throw new BadRequestException("One or more target applications is not valid"); + } + + // Prepare JPA object to be persisted + BulkCopyReview bulkCopyReviewOutput = new BulkCopyReview(); + + bulkCopyReviewOutput.sourceReview = sourceReview; + bulkCopyReviewOutput.targetApplications = applicationsTarget.stream() + .map(application -> { + BulkCopyReviewDetails detail = new BulkCopyReviewDetails(); + detail.bulkCopyReview = bulkCopyReviewOutput; + detail.application = application; + + return detail; + }) + .collect(Collectors.toSet()); + bulkCopyReviewOutput.completed = false; + + bulkCopyReviewOutput.persist(); + transaction.commit(); + + // Fire bus event + eventBus.send(BulkCopyReviewService.BUS_EVENT, bulkCopyReviewOutput.id); + + // Generate response + return EntityToDTO.toDTO(bulkCopyReviewOutput); + } + + @GET + @Path("/{id}") + public BulkReviewDto getBulkCopyReview(@PathParam("id") Long id) { + BulkCopyReview entity = BulkCopyReview + .findByIdOptional(id) + .orElseThrow(NotFoundException::new); + + return EntityToDTO.toDTO(entity); + } + +} diff --git a/src/main/java/io/tackle/applicationinventory/services/BulkCopyReviewService.java b/src/main/java/io/tackle/applicationinventory/services/BulkCopyReviewService.java new file mode 100644 index 0000000..f9f4981 --- /dev/null +++ b/src/main/java/io/tackle/applicationinventory/services/BulkCopyReviewService.java @@ -0,0 +1,53 @@ +package io.tackle.applicationinventory.services; + +import io.quarkus.vertx.ConsumeEvent; +import io.smallrye.common.annotation.Blocking; +import io.tackle.applicationinventory.entities.Application; +import io.tackle.applicationinventory.entities.BulkCopyReview; +import io.tackle.applicationinventory.entities.Review; + +import javax.enterprise.context.ApplicationScoped; +import javax.transaction.Transactional; + +@ApplicationScoped +public class BulkCopyReviewService { + + public static final String BUS_EVENT = "process-bulk-review-copy"; + + @Transactional + @ConsumeEvent(BUS_EVENT) + @Blocking + public void processBulkCopyReview(Long bulkId) { + BulkCopyReview bulk = BulkCopyReview.findById(bulkId); + + // Copy review to all apps + bulk.targetApplications.forEach(detail -> { + Application application = Application.findById(detail.application.id); + + Review oldReview = Review.find("application.id", detail.application.id).firstResult(); + if (oldReview != null) { + oldReview = copyReviewFromSourceToTarget(bulk.sourceReview, oldReview); + oldReview.persist(); + } else { + Review newReview = copyReviewFromSourceToTarget(bulk.sourceReview, new Review()); + newReview.application = application; + newReview.persist(); + } + }); + + bulk.completed = true; + bulk.persist(); + } + + private Review copyReviewFromSourceToTarget(Review source, Review target) { + target.proposedAction = source.proposedAction; + target.effortEstimate = source.effortEstimate; + target.businessCriticality = source.businessCriticality; + target.workPriority = source.workPriority; + target.comments = source.comments; + + target.copiedFromReviewId = source.id; + return target; + } + +} diff --git a/src/main/resources/db/migration/V20211008.1__add_bulk_copy_review.sql b/src/main/resources/db/migration/V20211008.1__add_bulk_copy_review.sql new file mode 100644 index 0000000..b9ff475 --- /dev/null +++ b/src/main/resources/db/migration/V20211008.1__add_bulk_copy_review.sql @@ -0,0 +1,39 @@ +create table bulk_copy_review +( + id int8 not null, + + createTime timestamp, + createUser varchar(255), + deleted boolean, + updateTime timestamp, + updateUser varchar(255), + + completed boolean, + review_id int8 not null, + primary key (id) +); + +create table bulk_copy_review_details +( + id int8 not null, + + createTime timestamp, + createUser varchar(255), + deleted boolean, + updateTime timestamp, + updateUser varchar(255), + + bulk_copy_review_id int8 not null, + application_id int8 not null, + primary key (id) +); + +alter table if exists bulk_copy_review_details + add constraint fk_bulk_copy_review_details_application + foreign key (application_id) + references application; + +-- Add audit data + +alter table if exists review + add column copiedFromReviewId int8; diff --git a/src/test/java/io/tackle/applicationinventory/flyway/FlywayMigrationTest.java b/src/test/java/io/tackle/applicationinventory/flyway/FlywayMigrationTest.java index 8456bbe..f615b4e 100644 --- a/src/test/java/io/tackle/applicationinventory/flyway/FlywayMigrationTest.java +++ b/src/test/java/io/tackle/applicationinventory/flyway/FlywayMigrationTest.java @@ -13,16 +13,16 @@ @QuarkusTest @TestProfile(FlywayMigrationProfile.class) public class FlywayMigrationTest { - + @Inject Flyway flyway; - + @Test public void testMigration() { // check the number of migrations applied equals the number of files in resources/db/migration folder - assertEquals(18, flyway.info().applied().length); + assertEquals(19, flyway.info().applied().length); // check the current migration version is the one from the last file in resources/db/migration folder - assertEquals("20210914.1", flyway.info().current().getVersion().toString()); + assertEquals("20211008.1", flyway.info().current().getVersion().toString()); // just a basic test to double check the application started // to prove the flyway scripts ran successfully during startup given() diff --git a/src/test/java/io/tackle/applicationinventory/resources/BulkCopyReviewTest.java b/src/test/java/io/tackle/applicationinventory/resources/BulkCopyReviewTest.java new file mode 100644 index 0000000..1503e48 --- /dev/null +++ b/src/test/java/io/tackle/applicationinventory/resources/BulkCopyReviewTest.java @@ -0,0 +1,347 @@ +package io.tackle.applicationinventory.resources; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.common.ResourceArg; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import io.tackle.applicationinventory.dto.BulkReviewDto; +import io.tackle.applicationinventory.entities.Application; +import io.tackle.applicationinventory.entities.Review; +import io.tackle.commons.testcontainers.KeycloakTestResource; +import io.tackle.commons.testcontainers.PostgreSQLDatabaseTestResource; +import io.tackle.commons.tests.SecuredResourceTest; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +@QuarkusTest +@QuarkusTestResource(value = PostgreSQLDatabaseTestResource.class, + initArgs = { + @ResourceArg(name = PostgreSQLDatabaseTestResource.DB_NAME, value = "application_inventory_db"), + @ResourceArg(name = PostgreSQLDatabaseTestResource.USER, value = "application_inventory"), + @ResourceArg(name = PostgreSQLDatabaseTestResource.PASSWORD, value = "application_inventory") + } +) +@QuarkusTestResource(value = KeycloakTestResource.class, + initArgs = { + @ResourceArg(name = KeycloakTestResource.IMPORT_REALM_JSON_PATH, value = "keycloak/quarkus-realm.json"), + @ResourceArg(name = KeycloakTestResource.REALM_NAME, value = "quarkus") + } +) +public class BulkCopyReviewTest extends SecuredResourceTest { + + @BeforeAll + public static void init() { + PATH = "/review/bulk"; + } + + public void deleteApplication(Application... applications) { + // Clean data + Arrays.asList(applications) + .forEach(app -> { + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .delete("/application/" + app.id) + .then() + .statusCode(204); + }); + } + + @Test + public void createBulkCopyWithNonExistingSource() { + BulkReviewDto bulkCopy = new BulkReviewDto(); + bulkCopy.setSourceReview(Long.MAX_VALUE); // Non existing source + bulkCopy.setTargetApplications(Arrays.asList(1L, 2L, 3L)); // Whether or not the app's id exists is irrelevant since the Source is invalid. + + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(bulkCopy) + .when() + .post(PATH) + .then() + .statusCode(400); + } + + @Test + public void createBulkCopyWithNonExistingTargetApps() { + // Create app1 + Application app1 = new Application(); + app1.name = "app1"; + + app1 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(app1) + .when() + .post("/application") + .then() + .statusCode(201) + .extract().body().as(Application.class); + + // Create review (source) for app1 + Review review1 = new Review(); + review1.workPriority = 1; + review1.businessCriticality = 1; + review1.effortEstimate = "low"; + review1.proposedAction = "rehost"; + review1.application = app1; + + review1 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(review1) + .when() + .post("/review") + .then() + .statusCode(201) + .extract().body().as(Review.class); + + // Create bulk copy using valid source but non existing targets + BulkReviewDto bulkCopy = new BulkReviewDto(); + bulkCopy.setSourceReview(review1.id); + bulkCopy.setTargetApplications(Arrays.asList(Long.MAX_VALUE - 1, Long.MAX_VALUE)); // Non existing apps (target) + + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(bulkCopy) + .when() + .post("/review/bulk") + .then() + .statusCode(400); + + // Clean data + deleteApplication(app1); + } + + @Test + public void createBulkCopy() { + // Case: create app1, app2, app3, and app4. App1 and app2 will have a review manually created. + // TestPhase1: Copy review from app1 to app3 and app4 + // TestPhase2: Copy (override) review from app2 to app3 and app4 + + // Create app1, app2, app3, and app4 + Application app1 = new Application(); + app1.name = "app1"; + + Application app2 = new Application(); + app2.name = "app2"; + + Application app3 = new Application(); + app3.name = "app3"; + + Application app4 = new Application(); + app4.name = "app4"; + + app1 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(app1) + .when() + .post("/application") + .then() + .statusCode(201) + .extract().body().as(Application.class); + + app2 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(app2) + .when() + .post("/application") + .then() + .statusCode(201) + .extract().body().as(Application.class); + + app3 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(app3) + .when() + .post("/application") + .then() + .statusCode(201) + .extract().body().as(Application.class); + + app4 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(app4) + .when() + .post("/application") + .then() + .statusCode(201) + .extract().body().as(Application.class); + + // Create reviews for app1 and app2 + Review review1 = new Review(); + review1.workPriority = 1; + review1.businessCriticality = 1; + review1.effortEstimate = "low"; + review1.proposedAction = "rehost"; + review1.application = app1; + + Review review2 = new Review(); + review2.workPriority = 2; + review2.businessCriticality = 2; + review2.effortEstimate = "high"; + review2.proposedAction = "replatform"; + review2.application = app2; + + review1 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(review1) + .when() + .post("/review") + .then() + .statusCode(201) + .extract().body().as(Review.class); + + review2 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(review2) + .when() + .post("/review") + .then() + .statusCode(201) + .extract().body().as(Review.class); + + // TestPhase1: Copy review from app1 to app3 and app4 + BulkReviewDto bulkCopy = new BulkReviewDto(); + bulkCopy.setSourceReview(review1.id); + bulkCopy.setTargetApplications(Arrays.asList(app3.id, app4.id)); + + BulkReviewDto bulkCopyToWatch1 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(bulkCopy) + .when() + .post("/review/bulk") + .then() + .statusCode(200) + .body("id", is(notNullValue()), + "completed", is(false) + ) + .extract().body().as(BulkReviewDto.class); + + await().atMost(20, TimeUnit.SECONDS).until(() -> given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .get(PATH + "/" + bulkCopyToWatch1.getId()) + .then() + .statusCode(200) + .extract().body().as(BulkReviewDto.class) + .isCompleted() + ); + + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .get("/application/" + app3.id) + .then() + .statusCode(200) + .body("id", is(notNullValue()), + "review", is(notNullValue()), + "review.workPriority", is(review1.workPriority), + "review.businessCriticality", is(review1.businessCriticality), + "review.effortEstimate", is(review1.effortEstimate), + "review.proposedAction", is(review1.proposedAction), + "review.copiedFromReviewId", is(review1.id.intValue()) + ); + + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .get("/application/" + app4.id) + .then() + .statusCode(200) + .body("id", is(notNullValue()), + "review", is(notNullValue()), + "review.workPriority", is(review1.workPriority), + "review.businessCriticality", is(review1.businessCriticality), + "review.effortEstimate", is(review1.effortEstimate), + "review.proposedAction", is(review1.proposedAction), + "review.copiedFromReviewId", is(review1.id.intValue()) + ); + + // TestPhase2: Copy review from app2 to app3 and app4 + bulkCopy = new BulkReviewDto(); + bulkCopy.setSourceReview(review2.id); + bulkCopy.setTargetApplications(Arrays.asList(app3.id, app4.id)); + + BulkReviewDto bulkCopyToWatch2 = given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(bulkCopy) + .when() + .post("/review/bulk") + .then() + .statusCode(200) + .body("id", is(notNullValue()), + "completed", is(false) + ) + .extract().body().as(BulkReviewDto.class); + + await().atMost(20, TimeUnit.SECONDS).until(() -> given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .get(PATH + "/" + bulkCopyToWatch2.getId()) + .then() + .statusCode(200) + .extract().body().as(BulkReviewDto.class) + .isCompleted() + ); + + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .get("/application/" + app3.id) + .then() + .statusCode(200) + .body("id", is(notNullValue()), + "review", is(notNullValue()), + "review.workPriority", is(review2.workPriority), + "review.businessCriticality", is(review2.businessCriticality), + "review.effortEstimate", is(review2.effortEstimate), + "review.proposedAction", is(review2.proposedAction), + "review.copiedFromReviewId", is(review2.id.intValue()) + ); + + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .when() + .get("/application/" + app4.id) + .then() + .statusCode(200) + .body("id", is(notNullValue()), + "review", is(notNullValue()), + "review.workPriority", is(review2.workPriority), + "review.businessCriticality", is(review2.businessCriticality), + "review.effortEstimate", is(review2.effortEstimate), + "review.proposedAction", is(review2.proposedAction), + "review.copiedFromReviewId", is(review2.id.intValue()) + ); + + // Clean data + deleteApplication(app1, app2, app3, app4); + } + +} diff --git a/src/test/java/io/tackle/applicationinventory/resources/NativeBulkCopyReviewIT.java b/src/test/java/io/tackle/applicationinventory/resources/NativeBulkCopyReviewIT.java new file mode 100644 index 0000000..90275c2 --- /dev/null +++ b/src/test/java/io/tackle/applicationinventory/resources/NativeBulkCopyReviewIT.java @@ -0,0 +1,7 @@ +package io.tackle.applicationinventory.resources; + +import io.quarkus.test.junit.NativeImageTest; + +@NativeImageTest +public class NativeBulkCopyReviewIT extends BulkCopyReviewTest { +}