From d24d9a3a634dd051e5d29a97e5464a028f1891c3 Mon Sep 17 00:00:00 2001 From: Pavlo Smahin Date: Thu, 22 Feb 2024 15:08:17 +0200 Subject: [PATCH] test: create base for reference data APIs integration tests (#984) Closes: MODINVSTOR-1164 --- NEWS.md | 1 + pom.xml | 20 +- .../rest/impl/AlternativeTitleTypesIT.java | 69 +++++ .../folio/rest/impl/BaseIntegrationTest.java | 165 +++++++++++ .../BaseReferenceDataIntegrationTest.java | 256 ++++++++++++++++++ 5 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/folio/rest/impl/AlternativeTitleTypesIT.java create mode 100644 src/test/java/org/folio/rest/impl/BaseIntegrationTest.java create mode 100644 src/test/java/org/folio/rest/impl/BaseReferenceDataIntegrationTest.java diff --git a/NEWS.md b/NEWS.md index bcb5806a5..815158a5c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -16,6 +16,7 @@ ### Tech Dept * Prevent virtual fields populating for holdings records ([MODINVSTOR-1094](https://issues.folio.org/browse/MODINVSTOR-1094)) +* Create base for reference data APIs integration tests ([MODINVSTOR-1164](https://issues.folio.org/browse/MODINVSTOR-1164)) --- diff --git a/pom.xml b/pom.xml index 7481787aa..199fc4e26 100644 --- a/pom.xml +++ b/pom.xml @@ -31,9 +31,10 @@ 4.13.2 5.2.0 1.1.1 - 2.27.2 + 3.4.0 5.4.0 4.2.0 + 3.25.3 3.12.1 3.5.0 @@ -132,6 +133,10 @@ + + io.vertx + vertx-junit5 + org.folio testing @@ -229,11 +234,22 @@ test - com.github.tomakehurst + org.wiremock wiremock ${wiremock.version} test + + org.assertj + assertj-core + ${assertj.version} + test + + + org.testcontainers + junit-jupiter + test + org.apache.logging.log4j log4j-slf4j2-impl diff --git a/src/test/java/org/folio/rest/impl/AlternativeTitleTypesIT.java b/src/test/java/org/folio/rest/impl/AlternativeTitleTypesIT.java new file mode 100644 index 000000000..aeae48ae5 --- /dev/null +++ b/src/test/java/org/folio/rest/impl/AlternativeTitleTypesIT.java @@ -0,0 +1,69 @@ +package org.folio.rest.impl; + +import static org.folio.rest.impl.AlternativeTitleTypeApi.REFERENCE_TABLE; + +import java.util.List; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import org.folio.rest.jaxrs.model.AlternativeTitleType; +import org.folio.rest.jaxrs.model.AlternativeTitleTypes; +import org.folio.rest.jaxrs.model.Metadata; + +class AlternativeTitleTypesIT extends BaseReferenceDataIntegrationTest { + + @Override + protected String referenceTable() { + return REFERENCE_TABLE; + } + + @Override + protected String resourceUrl() { + return "/alternative-title-types"; + } + + @Override + protected Class targetClass() { + return AlternativeTitleType.class; + } + + @Override + protected Class collectionClass() { + return AlternativeTitleTypes.class; + } + + @Override + protected AlternativeTitleType sampleRecord() { + return new AlternativeTitleType().withName("test-type").withSource("test-source"); + } + + @Override + protected Function> collectionRecordsExtractor() { + return AlternativeTitleTypes::getAlternativeTitleTypes; + } + + @Override + protected List> recordFieldExtractors() { + return List.of(AlternativeTitleType::getName, AlternativeTitleType::getSource); + } + + @Override + protected Function idExtractor() { + return AlternativeTitleType::getId; + } + + @Override + protected Function metadataExtractor() { + return AlternativeTitleType::getMetadata; + } + + @Override + protected UnaryOperator recordModifyingFunction() { + return alternativeTitleType -> alternativeTitleType.withName("name-updated").withSource("source-updated"); + } + + @Override + protected List queries() { + return List.of("name==test-type", "source=test-source"); + } + +} diff --git a/src/test/java/org/folio/rest/impl/BaseIntegrationTest.java b/src/test/java/org/folio/rest/impl/BaseIntegrationTest.java new file mode 100644 index 000000000..08a889b31 --- /dev/null +++ b/src/test/java/org/folio/rest/impl/BaseIntegrationTest.java @@ -0,0 +1,165 @@ +package org.folio.rest.impl; + +import static java.lang.String.format; +import static javax.ws.rs.core.HttpHeaders.ACCEPT; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.MediaType.TEXT_PLAIN; +import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.folio.utility.RestUtility.TENANT_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.vertx.core.DeploymentOptions; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import java.net.MalformedURLException; +import java.net.URI; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.folio.HttpStatus; +import org.folio.okapi.common.XOkapiHeaders; +import org.folio.rest.RestVerticle; +import org.folio.rest.tools.utils.Envs; +import org.folio.rest.tools.utils.NetworkUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers(parallel = true) +@ExtendWith(VertxExtension.class) +public class BaseIntegrationTest { + + public static final String USER_ID = UUID.randomUUID().toString(); + + @Container + private static final PostgreSQLContainer POSTGRESQL_CONTAINER = new PostgreSQLContainer<>("postgres:12-alpine") + .withDatabaseName("okapi_modules") + .withUsername("admin_user") + .withPassword("admin_password"); + @Container + private static final KafkaContainer KAFKA_CONTAINER + = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3")); + private static int port; + + protected static Future doGet(HttpClient client, String requestUri) { + return doRequest(client, HttpMethod.GET, requestUri, null); + } + + protected static Future doPost(HttpClient client, String requestUri, JsonObject body) { + return doRequest(client, HttpMethod.POST, requestUri, body); + } + + protected static Future doPut(HttpClient client, String requestUri, JsonObject body) { + return doRequest(client, HttpMethod.PUT, requestUri, body); + } + + protected static Future doDelete(HttpClient client, String requestUri) { + return doRequest(client, HttpMethod.DELETE, requestUri, null); + } + + protected static Future doRequest(HttpClient client, HttpMethod method, + String requestUri, JsonObject body) { + return client.request(method, port, "localhost", requestUri) + .compose(req -> { + var request = addDefaultHeaders(req, TENANT_ID); + return (body == null ? request.send() : request.send(body.toBuffer())) + .compose(resp -> resp.body().map(respBody -> new TestResponse(resp.statusCode(), respBody))); + }); + } + + @BeforeAll + static void beforeAll(Vertx vertx, VertxTestContext ctx) throws Throwable { + port = NetworkUtils.nextFreePort(); + System.setProperty("kafka-port", String.valueOf(KAFKA_CONTAINER.getFirstMappedPort())); + System.setProperty("kafka-host", KAFKA_CONTAINER.getHost()); + Envs.setEnv(POSTGRESQL_CONTAINER.getHost(), + POSTGRESQL_CONTAINER.getFirstMappedPort(), + POSTGRESQL_CONTAINER.getUsername(), + POSTGRESQL_CONTAINER.getPassword(), + POSTGRESQL_CONTAINER.getDatabaseName()); + DeploymentOptions options = new DeploymentOptions(); + options.setConfig(new JsonObject().put("http.port", port)); + HttpClient client = vertx.createHttpClient(); + + vertx.deployVerticle(RestVerticle.class, options) + .compose(s -> enableTenant(ctx, client)); + + assertTrue(ctx.awaitCompletion(65, TimeUnit.SECONDS)); + if (ctx.failed()) { + throw ctx.causeOfFailure(); + } + } + + private static Future enableTenant(VertxTestContext ctx, HttpClient client) { + return BaseIntegrationTest.doPost(client, "/_/tenant", BaseIntegrationTest.getJob(false)) + .map(buffer -> buffer.jsonBody().getString("id")) + .compose(id -> BaseIntegrationTest.doGet(client, "/_/tenant/" + id + "?wait=60000")) + .onComplete(ctx.succeeding(response -> ctx.verify(() -> { + assertEquals(HttpStatus.HTTP_OK.toInt(), response.status()); + assertFalse(response.body().toJsonObject().containsKey("error")); + ctx.completeNow(); + }))); + } + + private static JsonObject getJob(String moduleFrom, String moduleTo, boolean loadSample) { + JsonArray ar = new JsonArray(); + ar.add(new JsonObject().put("key", "loadReference").put("value", "false")); + ar.add(new JsonObject().put("key", "loadSample").put("value", Boolean.toString(loadSample))); + + JsonObject jo = new JsonObject(); + jo.put("parameters", ar); + if (moduleFrom != null) { + jo.put("module_from", moduleFrom); + } + jo.put("module_to", moduleTo); + return jo; + } + + private static JsonObject getJob(boolean loadSample) { + return BaseIntegrationTest.getJob(null, "mod-inventory-storage-1.0.0", loadSample); + } + + private static HttpClientRequest addDefaultHeaders(HttpClientRequest request, String tenantId) { + if (isNotBlank(tenantId)) { + request.putHeader(XOkapiHeaders.TENANT, tenantId); + request.putHeader(XOkapiHeaders.TOKEN, "TEST_TOKEN"); + request.putHeader(XOkapiHeaders.USER_ID, USER_ID); + } + String baseUrl; + try { + var url = URI.create(request.absoluteURI()).toURL(); + baseUrl = format("%s://%s", url.getProtocol(), url.getAuthority()); + } catch (MalformedURLException e) { + baseUrl = "http://localhost:" + port; + } + request.putHeader(XOkapiHeaders.URL, baseUrl); + request.putHeader(XOkapiHeaders.URL_TO, baseUrl); + request.putHeader(ACCEPT, APPLICATION_JSON + ", " + TEXT_PLAIN); + + return request; + } + + public record TestResponse(int status, Buffer body) { + + public JsonObject jsonBody() { + return body.toJsonObject(); + } + + public T bodyAsClass(Class targetClass) { + return jsonBody().mapTo(targetClass); + } + } +} diff --git a/src/test/java/org/folio/rest/impl/BaseReferenceDataIntegrationTest.java b/src/test/java/org/folio/rest/impl/BaseReferenceDataIntegrationTest.java new file mode 100644 index 000000000..3a6303744 --- /dev/null +++ b/src/test/java/org/folio/rest/impl/BaseReferenceDataIntegrationTest.java @@ -0,0 +1,256 @@ +package org.folio.rest.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.folio.HttpStatus.HTTP_CREATED; +import static org.folio.HttpStatus.HTTP_NO_CONTENT; +import static org.folio.HttpStatus.HTTP_OK; +import static org.folio.utility.RestUtility.TENANT_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClient; +import io.vertx.core.json.JsonObject; +import io.vertx.junit5.VertxTestContext; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.folio.HttpStatus; +import org.folio.rest.jaxrs.model.Metadata; +import org.folio.rest.persist.PostgresClient; +import org.folio.rest.persist.cql.CQLWrapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +abstract class BaseReferenceDataIntegrationTest extends BaseIntegrationTest { + + protected abstract String referenceTable(); + + protected abstract String resourceUrl(); + + protected abstract Class targetClass(); + + protected abstract Class collectionClass(); + + protected abstract T sampleRecord(); + + protected abstract Function> collectionRecordsExtractor(); + + protected abstract List> recordFieldExtractors(); + + protected abstract Function idExtractor(); + + protected abstract Function metadataExtractor(); + + protected abstract UnaryOperator recordModifyingFunction(); + + protected abstract List queries(); + + protected String resourceUrlById(String id) { + return resourceUrl() + "/" + id; + } + + protected void verifyRecordHasSameId(T actual, String expectedId, String description) { + assertThat(actual) + .as(description) + .isNotNull() + .extracting(idExtractor()) + .isEqualTo(expectedId); + } + + protected void verifyRecordFields(T actual, T expected, List> fieldExtractors, + String description) { + for (Function method : fieldExtractors) { + assertThat(actual) + .as(description) + .extracting(method) + .isEqualTo(method.apply(expected)); + } + } + + protected static Handler> verifyStatus(VertxTestContext ctx, HttpStatus expectedStatus) { + return ctx.succeeding(response -> ctx.verify(() -> assertEquals(expectedStatus.toInt(), response.status()))); + } + + protected Metadata getMetadata(T createdRecord) { + return metadataExtractor().apply(createdRecord); + } + + protected String getRecordId(T record) { + return idExtractor().apply(record); + } + + protected String getRecordId(TestResponse response) { + return getRecordId(response.bodyAsClass(targetClass())); + } + + @AfterEach + void tearDown(Vertx vertx, VertxTestContext ctx) { + var postgresClient = PostgresClient.getInstance(vertx, TENANT_ID); + postgresClient.delete(referenceTable(), (CQLWrapper) null) + .onComplete(event -> ctx.completeNow()); + } + + @Test + void getCollection_shouldReturn200AndEmptyCollection(Vertx vertx, VertxTestContext ctx) { + HttpClient client = vertx.createHttpClient(); + doGet(client, resourceUrl()) + .onComplete(verifyStatus(ctx, HTTP_OK)) + .onComplete(ctx.succeeding(response -> ctx.verify(() -> { + var collection = response.bodyAsClass(collectionClass()); + + assertThat(collection) + .isNotNull() + .hasFieldOrPropertyWithValue("totalRecords", 0) + .extracting(collectionRecordsExtractor()).asInstanceOf(InstanceOfAssertFactories.COLLECTION) + .isEmpty(); + ctx.completeNow(); + }))); + } + + @Test + void getCollection_shouldReturn200AndRecordCollectionBasedOnQuery(Vertx vertx, VertxTestContext ctx) { + HttpClient client = vertx.createHttpClient(); + + var postgresClient = PostgresClient.getInstance(vertx, TENANT_ID); + + var newRecord = sampleRecord(); + + postgresClient.save(referenceTable(), newRecord) + .compose(s -> { + List> futures = new ArrayList<>(); + for (String query : queries()) { + var testResponseFuture = doGet(client, resourceUrl() + "?query=" + query) + .onComplete(verifyStatus(ctx, HTTP_OK)) + .andThen(ctx.succeeding(response -> ctx.verify(() -> { + var collection = response.bodyAsClass(collectionClass()); + assertThat(collection) + .as("verify collection for query: %s", query) + .isNotNull() + .hasFieldOrPropertyWithValue("totalRecords", 1) + .extracting(collectionRecordsExtractor()).asInstanceOf(InstanceOfAssertFactories.COLLECTION) + .hasSize(1); + + var collectionRecord = collectionRecordsExtractor().apply(collection).get(0); + + verifyRecordFields(collectionRecord, newRecord, recordFieldExtractors(), + String.format("verify collection's record for query: %s", query)); + }))); + futures.add(testResponseFuture); + } + return Future.all(futures); + }) + .onFailure(ctx::failNow) + .onSuccess(event -> ctx.completeNow()); + } + + @Test + void get_shouldReturn200AndRecordById(Vertx vertx, VertxTestContext ctx) { + HttpClient client = vertx.createHttpClient(); + + var postgresClient = PostgresClient.getInstance(vertx, TENANT_ID); + + var newRecord = sampleRecord(); + + postgresClient.save(referenceTable(), newRecord) + .compose(id -> doGet(client, resourceUrlById(id)) + .onComplete(verifyStatus(ctx, HTTP_OK)) + .onComplete(ctx.succeeding(response -> ctx.verify(() -> { + var fetchedRecord = response.bodyAsClass(targetClass()); + verifyRecordHasSameId(fetchedRecord, id, "verify record by id"); + + verifyRecordFields(fetchedRecord, newRecord, recordFieldExtractors(), + "verify record by id has same values as saved record"); + ctx.completeNow(); + })))); + } + + @Test + void post_shouldReturn201AndCreatedRecord(Vertx vertx, VertxTestContext ctx) { + HttpClient client = vertx.createHttpClient(); + + var postgresClient = PostgresClient.getInstance(vertx, TENANT_ID); + + var newRecord = sampleRecord(); + + doPost(client, resourceUrl(), JsonObject.mapFrom(newRecord)) + .onComplete(verifyStatus(ctx, HTTP_CREATED)) + .onComplete(ctx.succeeding(response -> ctx.verify(() -> { + var createdRecord = response.bodyAsClass(targetClass()); + + assertNotNull(createdRecord); + assertNotNull(getRecordId(createdRecord)); + + assertThat(getMetadata(createdRecord)) + .as("Verify created record metadata") + .isNotNull() + .hasNoNullFieldsOrPropertiesExcept("createdByUsername", "updatedByUsername") + .extracting(Metadata::getCreatedByUserId, Metadata::getUpdatedByUserId) + .containsExactly(USER_ID, USER_ID); + + for (Function method : recordFieldExtractors()) { + assertEquals(method.apply(newRecord), method.apply(createdRecord)); + } + }))) + .compose(testResponse -> postgresClient.getById(referenceTable(), getRecordId(testResponse), targetClass()) + .onComplete(ctx.succeeding(dbRecord -> ctx.verify(() -> { + var recordFromResponse = testResponse.bodyAsClass(targetClass()); + verifyRecordHasSameId(dbRecord, getRecordId(recordFromResponse), "Verify created record exists in database"); + + verifyRecordFields(dbRecord, recordFromResponse, + recordFieldExtractors(), "Verify created record in database has same values as in response"); + ctx.completeNow(); + })))); + } + + @Test + void put_shouldReturn204AndRecordIsUpdated(Vertx vertx, VertxTestContext ctx) { + HttpClient client = vertx.createHttpClient(); + + var postgresClient = PostgresClient.getInstance(vertx, TENANT_ID); + + var newRecord = sampleRecord(); + + postgresClient.save(referenceTable(), newRecord) + .compose(id -> { + var updatedRecord = recordModifyingFunction().apply(newRecord); + return doPut(client, resourceUrlById(id), JsonObject.mapFrom(updatedRecord)) + .onComplete(verifyStatus(ctx, HTTP_NO_CONTENT)) + .compose(r -> postgresClient.getById(referenceTable(), id, targetClass()) + .onComplete(ctx.succeeding(dbRecord -> ctx.verify(() -> { + verifyRecordHasSameId(dbRecord, id, "Verify updated record exists in database"); + + verifyRecordFields(dbRecord, updatedRecord, recordFieldExtractors(), + "Verify update record in database has same values as in request"); + ctx.completeNow(); + })))); + }); + } + + @Test + void delete_shouldReturn204AndRecordIsDeleted(Vertx vertx, VertxTestContext ctx) { + HttpClient client = vertx.createHttpClient(); + + var postgresClient = PostgresClient.getInstance(vertx, TENANT_ID); + + var newRecord = sampleRecord(); + + postgresClient.save(referenceTable(), newRecord) + .compose(id -> doDelete(client, resourceUrlById(id)) + .onComplete(verifyStatus(ctx, HTTP_NO_CONTENT)) + .compose(response -> postgresClient.getById(referenceTable(), id, targetClass())) + .onComplete(ctx.succeeding(dbRecord -> ctx.verify(() -> { + assertThat(dbRecord) + .as("Verify deleted record doesn't exists in database") + .isNull(); + + ctx.completeNow(); + })))); + } + +}