diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index d36a5e6bca..a09a445d73 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -151,3 +151,39 @@ jobs: -F variables[DOCKER_TAG]=development \ -F ref=development \ ${{ secrets.GITLAB_RODA_DEV_TRIGGER }} + + e2e: + runs-on: ubuntu-latest + needs: merge + env: + MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: 21 + distribution: 'temurin' + - name: Set up Maven + uses: stCarolas/setup-maven@v5 + with: + maven-version: 3.9.9 + - name: Cache Maven dependencies + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-maven- + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Pull RODA development image + run: docker pull docker.io/keeps/roda:development + - name: Run E2E API tests + run: mvn $MAVEN_CLI_OPTS -Pe2e test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/roda-api-tests/src/test/java/org/roda/tests/api/AbstractApiTest.java b/roda-api-tests/src/test/java/org/roda/tests/api/AbstractApiTest.java index 5463b28c83..306059c92e 100644 --- a/roda-api-tests/src/test/java/org/roda/tests/api/AbstractApiTest.java +++ b/roda-api-tests/src/test/java/org/roda/tests/api/AbstractApiTest.java @@ -78,6 +78,10 @@ public abstract class AbstractApiTest { */ @BeforeSuite(alwaysRun = true) public void startEnvironment() { + if (COMPOSE != null) { + LOGGER.info("RODA Docker Compose stack already running; skipping re-initialization"); + return; + } LOGGER.info("Starting RODA Docker Compose stack from {}", COMPOSE_FILE.getAbsolutePath()); COMPOSE = new ComposeContainer(COMPOSE_FILE) diff --git a/roda-api-tests/src/test/java/org/roda/tests/api/lifecycle/IngestLifecycleTest.java b/roda-api-tests/src/test/java/org/roda/tests/api/lifecycle/IngestLifecycleTest.java new file mode 100644 index 0000000000..a837a57998 --- /dev/null +++ b/roda-api-tests/src/test/java/org/roda/tests/api/lifecycle/IngestLifecycleTest.java @@ -0,0 +1,514 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.tests.api.lifecycle; + +import static io.restassured.RestAssured.given; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import org.roda.tests.api.AbstractApiTest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.Assert; +import org.testng.annotations.Test; + +import io.restassured.http.ContentType; + +/** + * End-to-end lifecycle tests: SIP ingest → browse → search → edit → events. + * + *
Tests are chained with {@code dependsOnMethods} so each step builds on + * the previous one. Shared state is kept in instance fields. + * + *
Filter JSON uses {@code "type"} as the discriminator (not {@code "@type"}) + * because RODA's {@code FilterParameter} hierarchy declares + * {@code property = "type"} in {@code @JsonTypeInfo}. + * + *
{@code sourceObjects} in job creation uses {@code "@type"} because
+ * {@code SelectedItemsRequest} uses {@code property = "@type"}.
+ */
+@Test(groups = {"e2e", "lifecycle"})
+public class IngestLifecycleTest extends AbstractApiTest {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IngestLifecycleTest.class);
+
+ private static final String EARK_SIP_PATH =
+ "../roda-core/roda-core-tests/src/main/resources/corpora/SIPs/eark_sip.zip";
+
+ private static final String INGEST_PLUGIN = "org.roda.core.plugins.base.ingest.EARKSIPToAIPPlugin";
+
+ private String transferredResourceUuid;
+ private String jobUuid;
+ private String aipId;
+ private String representationId;
+ private String aipMetadataId;
+ private String aipMetadataType;
+ private String aipMetadataVersion;
+
+ // -------------------------------------------------------------------------
+ // Step 1 — upload the SIP
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void uploadSip_createsTransferredResource() {
+ File sipFile = new File(EARK_SIP_PATH);
+ Assert.assertTrue(sipFile.exists(), "SIP file must exist at: " + sipFile.getAbsolutePath());
+
+ Map, ?> resource = given()
+ .multiPart("resource", sipFile, "application/zip")
+ .when()
+ .post("/transfers/create/resource")
+ .then()
+ .statusCode(201)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(resource, "TransferredResource response must not be null");
+ transferredResourceUuid = (String) resource.get("uuid");
+ Assert.assertNotNull(transferredResourceUuid, "TransferredResource UUID must not be null");
+ LOGGER.info("Uploaded SIP; TransferredResource UUID: {}", transferredResourceUuid);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 2 — create the ingest job
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "uploadSip_createsTransferredResource")
+ public void createIngestJob_returnsJobWithCreatedState() {
+ String body = "{"
+ + "\"name\":\"E2E Ingest Test\","
+ + "\"plugin\":\"" + INGEST_PLUGIN + "\","
+ + "\"priority\":\"MEDIUM\","
+ + "\"parallelism\":\"LIMITED\","
+ + "\"sourceObjects\":{"
+ + "\"@type\":\"SelectedItemsListRequest\","
+ + "\"ids\":[\"" + transferredResourceUuid + "\"]"
+ + "},"
+ + "\"sourceObjectsClass\":\"org.roda.core.data.v2.ip.TransferredResource\","
+ + "\"pluginParameters\":{}"
+ + "}";
+
+ Map, ?> job = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/jobs")
+ .then()
+ .statusCode(201)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(job, "Job response must not be null");
+ jobUuid = (String) job.get("id");
+ Assert.assertNotNull(jobUuid, "Job ID must not be null");
+ LOGGER.info("Created ingest job; id: {}", jobUuid);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 3 — poll until the job reaches a terminal state
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "createIngestJob_returnsJobWithCreatedState")
+ public void waitForJobCompletion_jobCompletesSuccessfully() {
+ String state = pollUntilJobTerminal(jobUuid, 60, 10_000);
+ Assert.assertEquals(state, "COMPLETED",
+ "Ingest job must complete successfully, got state: " + state);
+ LOGGER.info("Ingest job {} completed with state: {}", jobUuid, state);
+ }
+
+ private String pollUntilJobTerminal(String uuid, int maxAttempts, long sleepMs) {
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ String state = given()
+ .when()
+ .get("/jobs/find/" + uuid)
+ .then()
+ .statusCode(200)
+ .extract().path("state");
+
+ LOGGER.info("Job {} state (attempt {}): {}", uuid, attempt, state);
+
+ if (isTerminalJobState(state)) {
+ return state;
+ }
+
+ try {
+ Thread.sleep(sleepMs);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return state;
+ }
+ }
+ return given().when().get("/jobs/find/" + uuid).then().statusCode(200).extract().path("state");
+ }
+
+ private boolean isTerminalJobState(String state) {
+ return "COMPLETED".equals(state)
+ || "FAILED_DURING_CREATION".equals(state)
+ || "FAILED_TO_COMPLETE".equals(state)
+ || "STOPPED".equals(state)
+ || "REJECTED".equals(state);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 4 — find the ingested AIP
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "waitForJobCompletion_jobCompletesSuccessfully")
+ public void findAips_afterIngest_returnsAtLeastOneAip() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":[{\"type\":\"AllFilterParameter\"}]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/aips/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "AIP find result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() > 0,
+ "At least one AIP must exist after ingest, got: " + totalCount);
+
+ List> results = (List>) result.get("results");
+ Assert.assertNotNull(results, "Results must not be null when totalCount > 0");
+ Assert.assertFalse(results.isEmpty(), "Results list must not be empty");
+
+ Map, ?> firstAip = (Map, ?>) results.get(0);
+ aipId = (String) firstAip.get("id");
+ Assert.assertNotNull(aipId, "AIP ID must not be null");
+ LOGGER.info("Found AIP; id: {}", aipId);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 5 — retrieve the AIP directly by UUID
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void getAipById_returnsValidAip() {
+ Map, ?> aip = given()
+ .when()
+ .get("/aips/find/" + aipId)
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(aip, "AIP must not be null");
+ Assert.assertEquals(aip.get("id"), aipId, "AIP id must match");
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 6 — list representations for the AIP
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void findRepresentations_forAip_returnsAtLeastOne() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":["
+ + "{\"type\":\"SimpleFilterParameter\",\"name\":\"aipId\",\"value\":\"" + aipId + "\"}"
+ + "]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/representations/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "Representation find result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "Representation totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() > 0,
+ "At least one representation must exist for AIP " + aipId);
+
+ List> results = (List>) result.get("results");
+ Assert.assertNotNull(results, "Representation results must not be null when count > 0");
+ Map, ?> firstRep = (Map, ?>) results.get(0);
+ representationId = (String) firstRep.get("id");
+ LOGGER.info("Found {} representation(s) for AIP {}, first id: {}", totalCount, aipId, representationId);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 7 — list files for the AIP
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void findFiles_forAip_returnsAtLeastOne() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":["
+ + "{\"type\":\"SimpleFilterParameter\",\"name\":\"aipId\",\"value\":\"" + aipId + "\"}"
+ + "]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/files/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "File find result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "File totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() > 0,
+ "At least one file must exist for AIP " + aipId);
+ LOGGER.info("Found {} file(s) for AIP {}", totalCount, aipId);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 8 — search with SimpleFilterParameter on title
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void searchAips_withSimpleFilter_onTitle_returnsResults() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":["
+ + "{\"type\":\"SimpleFilterParameter\",\"name\":\"title\",\"value\":\"Title\"}"
+ + "]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/aips/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "SimpleFilter result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() > 0,
+ "AIP with title 'Title' must exist, got count: " + totalCount);
+ LOGGER.info("SimpleFilterParameter search found {} AIP(s) with title 'Title'", totalCount);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 9 — search with OneOfManyFilterParameter
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void searchAips_withOneOfManyFilter_returns200AndValidStructure() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":["
+ + "{\"type\":\"OneOfManyFilterParameter\",\"name\":\"state\","
+ + "\"values\":[\"ACTIVE\",\"UNDER_APPRAISAL\",\"DESTROYED\"]}"
+ + "]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/aips/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "OneOfManyFilter result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() >= 0, "totalCount must be non-negative");
+ LOGGER.info("OneOfManyFilterParameter search returned {} AIP(s)", totalCount);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 10 — search with DateRangeFilterParameter
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void searchAips_withDateRangeFilter_returns200AndValidStructure() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":["
+ + "{\"type\":\"DateRangeFilterParameter\",\"name\":\"createdOn\","
+ + "\"fromValue\":\"2020-01-01T00:00:00Z\"}"
+ + "]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/aips/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "DateRangeFilter result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() > 0,
+ "AIPs must exist with open date range, got: " + totalCount);
+ LOGGER.info("DateRangeFilterParameter search returned {} AIP(s)", totalCount);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 11 — search with BasicSearchFilterParameter
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void searchAips_withBasicSearchFilter_returns200AndValidStructure() {
+ String body = "{"
+ + "\"filter\":{\"parameters\":["
+ + "{\"type\":\"BasicSearchFilterParameter\",\"name\":\"search\",\"value\":\"Title\"}"
+ + "]},"
+ + "\"sublist\":{\"firstElementIndex\":0,\"maximumElementCount\":10}"
+ + "}";
+
+ Map, ?> result = given()
+ .contentType(ContentType.JSON)
+ .body(body)
+ .when()
+ .post("/aips/find")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(result, "BasicSearchFilter result must not be null");
+ Number totalCount = (Number) result.get("totalCount");
+ Assert.assertNotNull(totalCount, "totalCount must not be null");
+ Assert.assertTrue(totalCount.longValue() >= 0, "totalCount must be non-negative");
+ LOGGER.info("BasicSearchFilterParameter returned {} AIP(s)", totalCount);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 12 — get descriptive metadata info
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "findAips_afterIngest_returnsAtLeastOneAip")
+ public void getDescriptiveMetadataInfo_forAip_returnsMetadataList() {
+ Map, ?> info = given()
+ .when()
+ .get("/aips/" + aipId + "/metadata/descriptive/information")
+ .then()
+ .statusCode(200)
+ .extract().as(Map.class);
+
+ Assert.assertNotNull(info, "Descriptive metadata info must not be null");
+ List> metadataList = (List>) info.get("descriptiveMetadataInfoList");
+ Assert.assertNotNull(metadataList, "Descriptive metadata list must not be null");
+ Assert.assertFalse(metadataList.isEmpty(), "At least one descriptive metadata must exist");
+
+ Map, ?> firstMeta = (Map, ?>) metadataList.get(0);
+ aipMetadataId = (String) firstMeta.get("id");
+ aipMetadataType = (String) firstMeta.get("metadataType");
+ aipMetadataVersion = firstMeta.get("metadataVersion") != null
+ ? (String) firstMeta.get("metadataVersion") : "";
+ Assert.assertNotNull(aipMetadataId, "Descriptive metadata id must not be null");
+ LOGGER.info("AIP {} has {} descriptive metadata file(s); first id={}, type={}", aipId,
+ metadataList.size(), aipMetadataId, aipMetadataType);
+ }
+
+ // -------------------------------------------------------------------------
+ // Step 13 — update descriptive metadata
+ // -------------------------------------------------------------------------
+
+ @Test(dependsOnMethods = "getDescriptiveMetadataInfo_forAip_returnsMetadataList")
+ public void updateDescriptiveMetadata_forAip_returns200() {
+ String updatedXml = ""
+ + "