From 62c1f5bddc16796bd033b24a7954e7f676ef42b5 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Mon, 28 Jul 2025 22:54:06 +0100 Subject: [PATCH 01/21] add minio_bucket and root_path to post api --- app/ro_crates/routes/post_routes.py | 13 ++++++- tests/test_post_routes.py | 55 +++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/app/ro_crates/routes/post_routes.py b/app/ro_crates/routes/post_routes.py index 9dcec32..075eb62 100644 --- a/app/ro_crates/routes/post_routes.py +++ b/app/ro_crates/routes/post_routes.py @@ -17,6 +17,8 @@ class ValidateCrate(Schema): + minio_bucket = String(required=True) + root_path = String(required=False) profile_name = String(required=False) webhook_url = String(required=False) @@ -36,6 +38,8 @@ def validate_ro_crate_via_id(json_data, crate_id) -> tuple[Response, int]: - **crate_id**: The RO-Crate ID. _Required_. Request Body Parameters: + - **minio_bucket**: The MinIO bucket containing the RO-Crate. _Required_ + - **root_path**: The root path containing the RO-Crate. _Optional_ - **profile_name**: The profile name for validation. _Optional_. - **webhook_url**: The webhook URL where validation results will be sent. _Optional_. @@ -46,6 +50,13 @@ def validate_ro_crate_via_id(json_data, crate_id) -> tuple[Response, int]: - KeyError: If required parameters (`crate_id` or `webhook_url`) are missing. """ + minio_bucket = json_data["minio_bucket"] + + if "root_path" in json_data: + root_path = json_data["root_path"] + else: + root_path = None + if "webhook_url" in json_data: webhook_url = json_data["webhook_url"] else: @@ -56,7 +67,7 @@ def validate_ro_crate_via_id(json_data, crate_id) -> tuple[Response, int]: else: profile_name = None - return queue_ro_crate_validation_task(crate_id, profile_name, webhook_url) + return queue_ro_crate_validation_task(minio_bucket, crate_id, root_path, profile_name, webhook_url) @post_routes_bp.post("/validate_metadata") diff --git a/tests/test_post_routes.py b/tests/test_post_routes.py index c593a70..5f5d2cc 100644 --- a/tests/test_post_routes.py +++ b/tests/test_post_routes.py @@ -15,6 +15,8 @@ def client(): def test_validate_by_id_success(client): crate_id = "crate-123" payload = { + "minio_bucket": "test_bucket", + "root_path": "base_path", "webhook_url": "https://webhook.example.com", "profile_name": "default" } @@ -26,12 +28,15 @@ def test_validate_by_id_success(client): assert response.status_code == 202 assert response.json == {"message": "Validation in progress"} - mock_queue.assert_called_once_with("crate-123", "default", "https://webhook.example.com") + mock_queue.assert_called_once_with("test_bucket", "crate-123", "base_path", "default", "https://webhook.example.com") -def test_validate_by_id_missing_crate_id(client): +def test_validate_by_id_fails_missing_crate_id(client): payload = { - "webhook_url": "https://webhook.example.com" + "minio_bucket": "test_bucket", + "root_path": "base_path", + "webhook_url": "https://webhook.example.com", + "profile_name": "default" } response = client.post("/v1/ro_crates//validation", json=payload) @@ -39,9 +44,23 @@ def test_validate_by_id_missing_crate_id(client): assert response.status_code == 404 -def test_validate_by_id_missing_profile_name_and_webhook_url(client): +def test_validate_by_id_fails_missing_minio_bucket(client): + crate_id = "crate-123" + payload = { + "root_path": "base_path", + "webhook_url": "https://webhook.example.com", + "profile_name": "default" + } + + response = client.post(f"/v1/ro_crates/{crate_id}/validation", json=payload) + + assert response.status_code == 422 + + +def test_validate_by_id_missing_root_path_and_profile_name_and_webhook_url(client): crate_id = "crate-123" payload = { + "minio_bucket": "test_bucket", } with patch("app.ro_crates.routes.post_routes.queue_ro_crate_validation_task") as mock_queue: @@ -51,12 +70,14 @@ def test_validate_by_id_missing_profile_name_and_webhook_url(client): assert response.status_code == 202 assert response.json == {"message": "Validation in progress"} - mock_queue.assert_called_once_with("crate-123", None, None) + mock_queue.assert_called_once_with("test_bucket", "crate-123", None, None, None) def test_validate_by_id_missing_profile_name(client): crate_id = "crate-123" payload = { + "minio_bucket": "test_bucket", + "root_path": "base_path", "webhook_url": "https://webhook.example.com" } @@ -67,12 +88,14 @@ def test_validate_by_id_missing_profile_name(client): assert response.status_code == 202 assert response.json == {"message": "Validation in progress"} - mock_queue.assert_called_once_with("crate-123", None, "https://webhook.example.com") + mock_queue.assert_called_once_with("test_bucket", "crate-123", "base_path", None, "https://webhook.example.com") def test_validate_by_id_missing_webhook_url(client): crate_id = "crate-123" payload = { + "minio_bucket": "test_bucket", + "root_path": "base_path", "profile_name": "default" } @@ -83,7 +106,25 @@ def test_validate_by_id_missing_webhook_url(client): assert response.status_code == 202 assert response.json == {"message": "Validation in progress"} - mock_queue.assert_called_once_with("crate-123", "default", None) + mock_queue.assert_called_once_with("test_bucket", "crate-123", "base_path", "default", None) + + +def test_validate_by_id_missing_root_path(client): + crate_id = "crate-123" + payload = { + "minio_bucket": "test_bucket", + "profile_name": "default", + "webhook_url": "https://webhook.example.com" + } + + with patch("app.ro_crates.routes.post_routes.queue_ro_crate_validation_task") as mock_queue: + mock_queue.return_value = ({"message": "Validation in progress"}, 202) + + response = client.post(f"/v1/ro_crates/{crate_id}/validation", json=payload) + + assert response.status_code == 202 + assert response.json == {"message": "Validation in progress"} + mock_queue.assert_called_once_with("test_bucket", None, "crate-123", "default", "https://webhook.example.com") # Test API: /v1/ro_crates/validate_metadata From dc87fb75db9e529cf732b1cc5e16de6e6870fa12 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:05:33 +0100 Subject: [PATCH 02/21] minio_bucket and root_path added to queue_ro_crate_validation --- app/services/validation_service.py | 9 ++++++--- tests/test_services.py | 14 +++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/services/validation_service.py b/app/services/validation_service.py index b3a2689..2142426 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -24,12 +24,14 @@ def queue_ro_crate_validation_task( - crate_id, profile_name=None, webhook_url=None + minio_bucket, crate_id, root_path=None, profile_name=None, webhook_url=None ) -> tuple[Response, int]: """ Queues an RO-Crate for validation with Celery. + :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate to validate. + :param root_path: The root path containing the RO-Crate. :param profile_name: The profile to validate against. :param webhook_url: The URL to POST the validation results to. :return: A tuple containing a JSON response and an HTTP status code. @@ -37,15 +39,16 @@ def queue_ro_crate_validation_task( """ logging.info(f"Processing: {crate_id}, {profile_name}, {webhook_url}") + logging.info(f"Minio Bucket: {minio_bucket}; Root path: {root_path}") - if check_ro_crate_exists(crate_id): + if check_ro_crate_exists(minio_bucket, crate_id, root_path): logging.info("RO-Crate exists") else: logging.info("RO-Crate does not exist") raise InvalidAPIUsage(f"No RO-Crate with prefix: {crate_id}", 400) try: - process_validation_task_by_id.delay(crate_id, profile_name, webhook_url) + process_validation_task_by_id.delay(minio_bucket, crate_id, root_path, profile_name, webhook_url) return jsonify({"message": "Validation in progress"}), 202 except Exception as e: diff --git a/tests/test_services.py b/tests/test_services.py index e7740cf..2c00606 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -27,10 +27,10 @@ def test_queue_task_success( mock_delay, flask_app ): - response, status_code = queue_ro_crate_validation_task("crate123", "profileA", "http://webhook.com") + response, status_code = queue_ro_crate_validation_task("test_bucket", "crate123", "base_path", "profileA", "http://webhook.com") - mock_exists.assert_called_once_with("crate123") - mock_delay.assert_called_once_with("crate123", "profileA", "http://webhook.com") + mock_exists.assert_called_once_with("test_bucket", "crate123", "base_path") + mock_delay.assert_called_once_with("test_bucket", "crate123", "base_path", "profileA", "http://webhook.com") assert status_code == 202 assert response.json == {"message": "Validation in progress"} @@ -43,10 +43,10 @@ def test_queue_ro_crate_missing_exception( flask_app ): with pytest.raises(InvalidAPIUsage) as exc_info: - queue_ro_crate_validation_task("crate12z", "profileA", "http://webhook.com") + queue_ro_crate_validation_task("test_bucket", "crate12z", "base_path", "profileA", "http://webhook.com") assert "No RO-Crate with prefix: crate12z" in str(exc_info.value.message) - mock_exists.assert_called_once_with("crate12z") + mock_exists.assert_called_once_with("test_bucket", "crate12z", "base_path") mock_delay.assert_not_called() @@ -57,9 +57,9 @@ def test_queue_task_exception( mock_delay, flask_app ): - response, status_code = queue_ro_crate_validation_task("crate123") + response, status_code = queue_ro_crate_validation_task("test_bucket", "crate123", None) - mock_exists.assert_called_once_with("crate123") + mock_exists.assert_called_once_with("test_bucket", "crate123", None) assert status_code == 500 assert response.json == {"error": "Celery down"} From cbb03b1ef118c3309a853ac493a1eea9ccad5bf1 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:17:17 +0100 Subject: [PATCH 03/21] minio_bucket and root_path for get ro-crate route --- app/ro_crates/routes/get_routes.py | 24 +++++++++++++++++++++--- app/services/validation_service.py | 10 ++++++---- tests/test_services.py | 18 +++++++++--------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/app/ro_crates/routes/get_routes.py b/app/ro_crates/routes/get_routes.py index e31cae8..4298a79 100644 --- a/app/ro_crates/routes/get_routes.py +++ b/app/ro_crates/routes/get_routes.py @@ -4,7 +4,8 @@ # License: MIT # Copyright (c) 2025 eScience Lab, The University of Manchester -from apiflask import APIBlueprint +from apiflask import APIBlueprint, Schema +from apiflask.fields import String from flask import Response from app.services.validation_service import get_ro_crate_validation_task @@ -12,14 +13,24 @@ get_routes_bp = APIBlueprint("get_routes", __name__) +class ValidateResult(Schema): + minio_bucket = String(required=True) + root_path = String(required=False) + + @get_routes_bp.get("/validation") -def get_ro_crate_validation_by_id(crate_id) -> tuple[Response, int]: +@get_routes_bp.input(ValidateResult(partial=False), location='json') +def get_ro_crate_validation_by_id(json_data, crate_id) -> tuple[Response, int]: """ Endpoint to obtain an RO-Crate validation result using its ID from MinIO. Path Parameters: - **crate_id**: The RO-Crate ID. _Required_. + Request Body Parameters: + - **minio_bucket**: The MinIO bucket containing the RO-Crate. _Required_ + - **root_path**: The root path containing the RO-Crate. _Optional_ + Returns: - A tuple containing the validation result and an HTTP status code. @@ -27,4 +38,11 @@ def get_ro_crate_validation_by_id(crate_id) -> tuple[Response, int]: - KeyError: If required parameters (`crate_id`) are missing. """ - return get_ro_crate_validation_task(crate_id) + minio_bucket = json_data["minio_bucket"] + + if "root_path" in json_data: + root_path = json_data["root_path"] + else: + root_path = None + + return get_ro_crate_validation_task(minio_bucket, crate_id, root_path) diff --git a/app/services/validation_service.py b/app/services/validation_service.py index 2142426..f166839 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -97,7 +97,9 @@ def queue_ro_crate_metadata_validation_task( def get_ro_crate_validation_task( - crate_id + minio_bucket: str, + crate_id: str, + root_path: str, ) -> tuple[Response, int]: """ Retrieves an RO-Crate validation result. @@ -108,16 +110,16 @@ def get_ro_crate_validation_task( """ logging.info(f"Retrieving validation for: {crate_id}") - if check_ro_crate_exists(crate_id): + if check_ro_crate_exists(minio_bucket, crate_id, root_path): logging.info("RO-Crate exists") else: logging.info("RO-Crate does not exist") raise InvalidAPIUsage(f"No RO-Crate with prefix: {crate_id}", 400) - if check_validation_exists(crate_id): + if check_validation_exists(minio_bucket, crate_id, root_path): logging.info("Validation result exists") else: logging.info("Validation does not exist") raise InvalidAPIUsage(f"No validation result yet for RO-Crate: {crate_id}", 400) - return return_ro_crate_validation(crate_id), 200 + return return_ro_crate_validation(minio_bucket, crate_id, root_path), 200 diff --git a/tests/test_services.py b/tests/test_services.py index 2c00606..068e487 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -139,11 +139,11 @@ def test_get_validation_success( mock_return, flask_app ): - response, status = get_ro_crate_validation_task("crate123") + response, status = get_ro_crate_validation_task("test_bucket", "crate123", "base_path") - mock_return.assert_called_once_with("crate123") - mock_rocrate.assert_called_once_with("crate123") - mock_validation.assert_called_once_with("crate123") + mock_return.assert_called_once_with("test_bucket", "crate123", "base_path") + mock_rocrate.assert_called_once_with("test_bucket", "crate123", "base_path") + mock_validation.assert_called_once_with("test_bucket", "crate123", "base_path") assert status == 200 assert response == {"status": "valid"} @@ -158,11 +158,11 @@ def test_get_validation_missing_ro_crate( flask_app ): with pytest.raises(InvalidAPIUsage) as exc_info: - get_ro_crate_validation_task("crate123") + get_ro_crate_validation_task("test_bucket", "crate123", "base_path") assert exc_info.value.status_code == 400 assert "No RO-Crate with prefix: crate123" in str(exc_info.value.message) - mock_rocrate.assert_called_once_with("crate123") + mock_rocrate.assert_called_once_with("test_bucket", "crate123", "base_path") mock_validation.assert_not_called() mock_return.assert_not_called() @@ -177,10 +177,10 @@ def test_get_validation_missing_validation( flask_app ): with pytest.raises(InvalidAPIUsage) as exc_info: - get_ro_crate_validation_task("crate123") + get_ro_crate_validation_task("test_bucket", "crate123", "base_path") assert exc_info.value.status_code == 400 assert "No validation result yet for RO-Crate: crate123" in str(exc_info.value.message) - mock_rocrate.assert_called_once_with("crate123") - mock_validation.assert_called_once_with("crate123") + mock_rocrate.assert_called_once_with("test_bucket", "crate123", "base_path") + mock_validation.assert_called_once_with("test_bucket", "crate123", "base_path") mock_return.assert_not_called() From b6dfa7322b32577e8be7b474ab760925f8ff7cf4 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:33:42 +0100 Subject: [PATCH 04/21] added get_route tests --- tests/test_post_routes.py | 61 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/tests/test_post_routes.py b/tests/test_post_routes.py index 5f5d2cc..92ada57 100644 --- a/tests/test_post_routes.py +++ b/tests/test_post_routes.py @@ -10,7 +10,7 @@ def client(): return app.test_client() -# Test API: /v1/ro_crates/{crate_id}/validation +# Test POST API: /v1/ro_crates/{crate_id}/validation def test_validate_by_id_success(client): crate_id = "crate-123" @@ -127,7 +127,7 @@ def test_validate_by_id_missing_root_path(client): mock_queue.assert_called_once_with("test_bucket", None, "crate-123", "default", "https://webhook.example.com") -# Test API: /v1/ro_crates/validate_metadata +# Test POST API: /v1/ro_crates/validate_metadata def test_validate_metadata_with_all_fields(client: FlaskClient): """ @@ -235,3 +235,60 @@ def test_validate_metadata_emptydict_crate_json(client: FlaskClient): response = client.post("/v1/ro_crates/validate_metadata", json=test_data) assert response.status_code == 422 assert "Required parameter crate_json is empty" in response.get_data(as_text=True) + + +# Test GET API: /v1/ro_crates/{crate_id}/validation + +def test_get_validation_by_id_success(client): + crate_id = "crate-123" + payload = { + "minio_bucket": "test_bucket", + "root_path": "base_path" + } + + with patch("app.ro_crates.routes.get_routes.get_ro_crate_validation_task") as mock_get: + mock_get.return_value = ({"status": "valid"}, 200) + + response = client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) + + assert response.status_code == 200 + assert response.json == {"status": "valid"} + mock_get.assert_called_once_with("test_bucket", "crate-123", "base_path") + + +def test_get_validation_by_id_fails_missing_crate_id(client): + payload = { + "minio_bucket": "test_bucket", + "root_path": "base_path" + } + + response = client.get("/v1/ro_crates//validation", json=payload) + + assert response.status_code == 404 + + +def test_get_validation_by_id_fails_missing_minio_bucket(client): + crate_id = "crate-123" + payload = { + "root_path": "base_path" + } + + response = client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) + + assert response.status_code == 422 + + +def test_get_validation_by_id_missing_root_path(client): + crate_id = "crate-123" + payload = { + "minio_bucket": "test_bucket", + } + + with patch("app.ro_crates.routes.get_routes.get_ro_crate_validation_task") as mock_get: + mock_get.return_value = ({"message": "Validation in progress"}, 202) + + response = client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) + + assert response.status_code == 202 + assert response.json == {"message": "Validation in progress"} + mock_get.assert_called_once_with("test_bucket", "crate-123", None) From dd7deef1bfdd8ccb34e79dc1f8f1c5baac259204 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:34:21 +0100 Subject: [PATCH 05/21] rename test_post_routes to test_api_routes --- tests/{test_post_routes.py => test_api_routes.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_post_routes.py => test_api_routes.py} (100%) diff --git a/tests/test_post_routes.py b/tests/test_api_routes.py similarity index 100% rename from tests/test_post_routes.py rename to tests/test_api_routes.py From 22f33b4bab49837f03bab6821f26f28b63aa26c8 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:54:38 +0100 Subject: [PATCH 06/21] add minio_bucket and root_path to checks for ro-crates and validation result --- app/services/validation_service.py | 2 ++ app/tasks/validation_tasks.py | 26 ++++++++++++++------ tests/test_validation_tasks.py | 39 ++++++++++++++++++++++++++---- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/app/services/validation_service.py b/app/services/validation_service.py index f166839..b60ae62 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -104,7 +104,9 @@ def get_ro_crate_validation_task( """ Retrieves an RO-Crate validation result. + :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate to validate. + :param root_path: The root path containing the RO-Crate. :return: A tuple containing a JSON response and an HTTP status code. :raises Exception: If an error occurs whilst retreiving validation result """ diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index e836959..930aadd 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -186,45 +186,55 @@ def perform_ro_crate_validation( def check_ro_crate_exists( + bucket_name: str, crate_id: str, + root_path: str = None, ) -> bool: """ Checks for the existence of an RO-Crate using the provided Crate ID. - :param crate_id: The ID of the RO-Crate that needs validating + :param minio_bucket: The MinIO bucket containing the RO-Crate. + :param crate_id: The ID of the RO-Crate to validate. + :param root_path: The root path containing the RO-Crate. :return: Boolean indicating existence """ logging.info(f"Checking for existence of RO-Crate {crate_id}") - minio_client, bucket_name = get_minio_client_and_bucket() - if find_rocrate_object_on_minio(crate_id, minio_client, bucket_name, storage_path=''): + minio_client, _ = get_minio_client_and_bucket() + if find_rocrate_object_on_minio(crate_id, minio_client, bucket_name, storage_path=root_path): return True else: return False def check_validation_exists( + bucket_name: str, crate_id: str, + root_path: str = None, ) -> bool: """ Checks for the existence of a validation result using the provided Crate ID. - :param crate_id: The ID of the RO-Crate that needs validating + :param minio_bucket: The MinIO bucket containing the RO-Crate. + :param crate_id: The ID of the RO-Crate to validate. + :param root_path: The root path containing the RO-Crate. :return: Boolean indicating existence """ logging.info(f"Checking for existence of RO-Crate {crate_id}") - minio_client, bucket_name = get_minio_client_and_bucket() - if find_validation_object_on_minio(crate_id, minio_client, bucket_name, storage_path=''): + minio_client, _ = get_minio_client_and_bucket() + if find_validation_object_on_minio(crate_id, minio_client, bucket_name, storage_path=root_path): return True else: return False def return_ro_crate_validation( - crate_id: str, + bucket_name: str, + crate_id: str, + root_path: str = None, ) -> dict | str: """ Retrieves the validation result for an RO-Crate using the provided Crate ID. @@ -235,4 +245,4 @@ def return_ro_crate_validation( logging.info(f"Fetching validation result for RO-Crate {crate_id}") - return get_validation_status_from_minio(crate_id) + return get_validation_status_from_minio(bucket_name, crate_id, root_path) diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index 654b92c..5696029 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -6,7 +6,8 @@ perform_ro_crate_validation, return_ro_crate_validation, process_validation_task_by_metadata, - check_ro_crate_exists + check_ro_crate_exists, + check_validation_exists ) from app.utils.minio_utils import InvalidAPIUsage @@ -370,10 +371,10 @@ def test_ro_crate_exists( mock_find_rocrate, mock_get_client ): - result = check_ro_crate_exists("crate123") + result = check_ro_crate_exists("test_bucket", "crate123", "base_path") mock_get_client.assert_called_once() - mock_find_rocrate.assert_called_once_with("crate123", "mock_client", "mock_bucket", storage_path='') + mock_find_rocrate.assert_called_once_with("crate123", "mock_client", "test_bucket", storage_path="base_path") assert result is True @@ -383,8 +384,36 @@ def test_ro_crate_does_not_exist( mock_find_rocrate, mock_get_client ): - result = check_ro_crate_exists("crate12z") + result = check_ro_crate_exists("test_bucket", "crate12z", "base_path") mock_get_client.assert_called_once() - mock_find_rocrate.assert_called_once_with("crate12z", "mock_client", "mock_bucket", storage_path='') + mock_find_rocrate.assert_called_once_with("crate12z", "mock_client", "test_bucket", storage_path="base_path") + assert result is False + + +# Test function: check_validation_exists + +@mock.patch("app.tasks.validation_tasks.get_minio_client_and_bucket", return_value=("mock_client", "mock_bucket")) +@mock.patch("app.tasks.validation_tasks.find_validation_object_on_minio", return_value="crate123") +def test_validation_exists( + mock_find_validation, + mock_get_client +): + result = check_validation_exists("test_bucket", "crate123", "base_path") + + mock_get_client.assert_called_once() + mock_find_validation.assert_called_once_with("crate123", "mock_client", "test_bucket", storage_path="base_path") + assert result is True + + +@mock.patch("app.tasks.validation_tasks.get_minio_client_and_bucket", return_value=("mock_client", "mock_bucket")) +@mock.patch("app.tasks.validation_tasks.find_validation_object_on_minio", return_value=False) +def test_validation_does_not_exist( + mock_find_validation, + mock_get_client +): + result = check_validation_exists("test_bucket", "crate12z", "base_path") + + mock_get_client.assert_called_once() + mock_find_validation.assert_called_once_with("crate12z", "mock_client", "test_bucket", storage_path="base_path") assert result is False From cfdf03b0f60e638f2aaceb57318cc20e0ec57fed Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:12:00 +0100 Subject: [PATCH 07/21] change to get_minio_client in validation tasks --- app/tasks/validation_tasks.py | 6 +++--- tests/test_validation_tasks.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index 930aadd..f495c60 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -17,7 +17,7 @@ fetch_ro_crate_from_minio, update_validation_status_in_minio, get_validation_status_from_minio, - get_minio_client_and_bucket, + get_minio_client, find_rocrate_object_on_minio, find_validation_object_on_minio ) @@ -201,7 +201,7 @@ def check_ro_crate_exists( logging.info(f"Checking for existence of RO-Crate {crate_id}") - minio_client, _ = get_minio_client_and_bucket() + minio_client = get_minio_client() if find_rocrate_object_on_minio(crate_id, minio_client, bucket_name, storage_path=root_path): return True else: @@ -224,7 +224,7 @@ def check_validation_exists( logging.info(f"Checking for existence of RO-Crate {crate_id}") - minio_client, _ = get_minio_client_and_bucket() + minio_client = get_minio_client() if find_validation_object_on_minio(crate_id, minio_client, bucket_name, storage_path=root_path): return True else: diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index 5696029..78d4401 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -365,7 +365,7 @@ def test_return_validation_raises_error(mock_get_status): # Test function: check_ro_crate_exists -@mock.patch("app.tasks.validation_tasks.get_minio_client_and_bucket", return_value=("mock_client", "mock_bucket")) +@mock.patch("app.tasks.validation_tasks.get_minio_client", return_value="mock_client") @mock.patch("app.tasks.validation_tasks.find_rocrate_object_on_minio", return_value="crate123") def test_ro_crate_exists( mock_find_rocrate, @@ -378,7 +378,7 @@ def test_ro_crate_exists( assert result is True -@mock.patch("app.tasks.validation_tasks.get_minio_client_and_bucket", return_value=("mock_client", "mock_bucket")) +@mock.patch("app.tasks.validation_tasks.get_minio_client", return_value="mock_client") @mock.patch("app.tasks.validation_tasks.find_rocrate_object_on_minio", return_value=False) def test_ro_crate_does_not_exist( mock_find_rocrate, @@ -393,7 +393,7 @@ def test_ro_crate_does_not_exist( # Test function: check_validation_exists -@mock.patch("app.tasks.validation_tasks.get_minio_client_and_bucket", return_value=("mock_client", "mock_bucket")) +@mock.patch("app.tasks.validation_tasks.get_minio_client", return_value="mock_client") @mock.patch("app.tasks.validation_tasks.find_validation_object_on_minio", return_value="crate123") def test_validation_exists( mock_find_validation, @@ -406,7 +406,7 @@ def test_validation_exists( assert result is True -@mock.patch("app.tasks.validation_tasks.get_minio_client_and_bucket", return_value=("mock_client", "mock_bucket")) +@mock.patch("app.tasks.validation_tasks.get_minio_client", return_value="mock_client") @mock.patch("app.tasks.validation_tasks.find_validation_object_on_minio", return_value=False) def test_validation_does_not_exist( mock_find_validation, From 1eca2b6f033bf46feae47e6a8597fc575f0fea1d Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:23:30 +0100 Subject: [PATCH 08/21] pass minio_bucket down to minio_utils instead of using get_minio_client_and_bucket --- app/utils/minio_utils.py | 71 ++++++++++++++++---------------- tests/test_minio.py | 87 ++++++++++++++++------------------------ 2 files changed, 69 insertions(+), 89 deletions(-) diff --git a/app/utils/minio_utils.py b/app/utils/minio_utils.py index efb0c6f..401fddd 100644 --- a/app/utils/minio_utils.py +++ b/app/utils/minio_utils.py @@ -18,41 +18,42 @@ logger = logging.getLogger(__name__) -def fetch_ro_crate_from_minio(crate_id: str) -> str: +def fetch_ro_crate_from_minio(minio_bucket: str, crate_id: str) -> str: """ Fetches an RO-Crate from MinIO based on the crate ID. Downloads the crate as a file and returns local file path. + :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate to fetch from MinIO. :return: The local file path where the RO-Crate is saved. """ - minio_client, bucket_name = get_minio_client_and_bucket() + minio_client = get_minio_client() - rocrate_object = find_rocrate_object_on_minio(crate_id, minio_client, bucket_name) + rocrate_object = find_rocrate_object_on_minio(crate_id, minio_client, minio_bucket) rocrate_path = rocrate_object.object_name - rocrate_name = rocrate_path.split('/')[-1] + rocrate_name = rocrate_path.split('/')[-1] temp_dir = tempfile.mkdtemp() root_path = os.path.join(temp_dir, rocrate_name) logging.info( - f"Fetching RO-Crate {rocrate_name} from MinIO bucket {bucket_name}. File path {root_path}" + f"Fetching RO-Crate {rocrate_name} from MinIO bucket {minio_bucket}. File path {root_path}" ) if rocrate_object.is_dir: os.makedirs(os.path.dirname(root_path), exist_ok=True) - objects_list = get_minio_object_list(rocrate_path, minio_client, bucket_name, recursive=True) + objects_list = get_minio_object_list(rocrate_path, minio_client, minio_bucket, recursive=True) for obj in objects_list: relative_path = obj.object_name[len(rocrate_path):].lstrip("/") local_file_path = os.path.join(root_path, relative_path) os.makedirs(os.path.dirname(local_file_path), exist_ok=True) - download_file_from_minio(minio_client, bucket_name, obj.object_name, local_file_path) + download_file_from_minio(minio_client, minio_bucket, obj.object_name, local_file_path) else: file_path = root_path - download_file_from_minio(minio_client, bucket_name, rocrate_path, file_path) + download_file_from_minio(minio_client, minio_bucket, rocrate_path, file_path) logging.info( f"RO-Crate {rocrate_name} fetched successfully and saved to {root_path}." @@ -61,10 +62,11 @@ def fetch_ro_crate_from_minio(crate_id: str) -> str: return root_path -def update_validation_status_in_minio(crate_id: str, validation_status: str) -> None: +def update_validation_status_in_minio(minio_bucket: str, crate_id: str, validation_status: str) -> None: """ Uploads the validation status to the MinIO bucket. + :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate in MinIO :param validation_status: The validation result to upload :raises S3Error: If an error occurs during the MinIO operation @@ -79,10 +81,10 @@ def update_validation_status_in_minio(crate_id: str, validation_status: str) -> try: - minio_client, bucket_name = get_minio_client_and_bucket() + minio_client = get_minio_client() minio_client.put_object( - bucket_name, + minio_bucket, object_name, data=BytesIO(validation_string), length=len(validation_string), @@ -102,15 +104,16 @@ def update_validation_status_in_minio(crate_id: str, validation_status: str) -> raise InvalidAPIUsage(f"Unknown Error: {e}", 500) logging.info( - f"Validation status file uploaded to {bucket_name}/{object_name} successfully." + f"Validation status file uploaded to {minio_bucket}/{object_name} successfully." ) -def get_validation_status_from_minio(crate_id: str) -> dict: +def get_validation_status_from_minio(minio_bucket: str, crate_id: str) -> dict: """ Checks for the existence of a validation report for the given RO-Crate in the MinIO bucket. Returns validation message if it exists, or notification that it is missing if not. + :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate in MinIO :return validation_status: Either the validation status, or note that this does not exist @@ -123,10 +126,10 @@ def get_validation_status_from_minio(crate_id: str) -> dict: try: - minio_client, bucket_name = get_minio_client_and_bucket() + minio_client = get_minio_client() response = minio_client.get_object( - bucket_name, + minio_bucket, object_name, ) @@ -150,12 +153,12 @@ def get_validation_status_from_minio(crate_id: str) -> dict: return validation_message -def download_file_from_minio(minio_client: object, bucket_name: str, object_path: str, file_path: str) -> None: +def download_file_from_minio(minio_client: object, minio_bucket: str, object_path: str, file_path: str) -> None: """ Downloads a file from MinIO :param minio_client: MinIO object - :param bucket_name: name of MinIO bucket, string + :param minio_bucket: name of MinIO bucket, string :param object_path: path to object on MinIO, string :param file_path: local path, string :raises S3Error: If an error occurs during the MinIO operation @@ -164,7 +167,7 @@ def download_file_from_minio(minio_client: object, bucket_name: str, object_path """ try: - minio_client.fget_object(bucket_name, object_path, file_path) + minio_client.fget_object(minio_bucket, object_path, file_path) except S3Error as s3_error: logging.error(f"MinIO S3 Error: {s3_error}") @@ -179,7 +182,7 @@ def download_file_from_minio(minio_client: object, bucket_name: str, object_path raise InvalidAPIUsage(f"Unknown Error: {e}", 500) -def find_validation_object_on_minio(rocrate_id: str, minio_client, bucket_name: str, storage_path: str = None) -> object: +def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str = None) -> object: """ Checks that the requested object exists on the MinIO instance. @@ -189,7 +192,7 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, bucket_name: :param rocrate_id: string containing the name of ro-crate :param storage_path: string containing the path within which the ro-crate should be :param minio_client: minio object - :param bucket_name: string containing bucket on minio + :param minio_bucket: string containing bucket on minio :return return_object: rocrate object we require :raise Exception: If validation result can't be found, 400 """ @@ -201,7 +204,7 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, bucket_name: else: file_path = f"{rocrate_id}_validation/validation_status.txt" - file_list = get_minio_object_list(file_path, minio_client, bucket_name) + file_list = get_minio_object_list(file_path, minio_client, minio_bucket) return_object = False for obj in file_list: @@ -216,7 +219,7 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, bucket_name: return return_object -def find_rocrate_object_on_minio(rocrate_id: str, minio_client, bucket_name: str, storage_path: str = None) -> object | bool: +def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str = None) -> object | bool: """ Checks that the requested object exists on the MinIO instance. @@ -226,7 +229,7 @@ def find_rocrate_object_on_minio(rocrate_id: str, minio_client, bucket_name: str :param rocrate_id: string containing the name of ro-crate :param storage_path: string containing the path within which the ro-crate should be :param minio_client: minio object - :param bucket_name: string containing bucket on minio + :param minio_bucket: string containing bucket on minio :return return_object or False: rocrate object we require, or False result :raise Exception: If RO-Crate can't be found, 400 """ @@ -238,7 +241,7 @@ def find_rocrate_object_on_minio(rocrate_id: str, minio_client, bucket_name: str else: rocrate_path = rocrate_id - rocrate_list = get_minio_object_list(rocrate_path, minio_client, bucket_name) + rocrate_list = get_minio_object_list(rocrate_path, minio_client, minio_bucket) return_object = False for obj in rocrate_list: @@ -254,14 +257,14 @@ def find_rocrate_object_on_minio(rocrate_id: str, minio_client, bucket_name: str return return_object -def get_minio_object_list(object_path: str, minio_client, bucket_name: str, recursive: bool = False) -> list: +def get_minio_object_list(object_path: str, minio_client, minio_bucket: str, recursive: bool = False) -> list: """ Creates a list of objects which match the object_id and path_prefix :param object_path: The object ID, string :param path_prefix: Path prefix, string, optional :param minio_client: MinIO client object - :param bucket_name: string + :param minio_bucket: string :param recursive: boolean, default = False :return object_list: List containing objects of type minio.datatypes.Object :raises S3Error: If an error occurs during the MinIO operation, 500 @@ -271,7 +274,7 @@ def get_minio_object_list(object_path: str, minio_client, bucket_name: str, recu try: response = minio_client.list_objects( - bucket_name, + minio_bucket, object_path, recursive=recursive ) @@ -295,11 +298,11 @@ def get_minio_object_list(object_path: str, minio_client, bucket_name: str, recu return object_list -def get_minio_client_and_bucket() -> [Minio, str]: +def get_minio_client() -> Minio: """ - Initialises the MinIO client and retrieves the bucket name from environment variables. + Initialises the MinIO client from environment variables. - :return: A tuple containing the MinIO client and the bucket name. + :return: The MinIO client. :raises ValueError: If required environment variables are not set. """ load_dotenv() @@ -311,10 +314,4 @@ def get_minio_client_and_bucket() -> [Minio, str]: secure=False, ) - bucket_name = os.environ.get("MINIO_BUCKET_NAME") - if not bucket_name: - raise ValueError( - "RO Crate MINIO_BUCKET_NAME is not set in the environment variables." - ) - - return minio_client, bucket_name + return minio_client diff --git a/tests/test_minio.py b/tests/test_minio.py index c841b9a..b30a9f2 100644 --- a/tests/test_minio.py +++ b/tests/test_minio.py @@ -20,35 +20,21 @@ def __init__(self, name, is_dir=False): self.is_dir = is_dir -# Testing function: get_minio_client_and_bucket +# Testing function: get_minio_client def test_get_minio_client_success(monkeypatch): # Set required env vars monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000") monkeypatch.setenv("MINIO_ROOT_USER", "admin") monkeypatch.setenv("MINIO_ROOT_PASSWORD", "password123") - monkeypatch.setenv("MINIO_BUCKET_NAME", "test-bucket") - from app.utils.minio_utils import get_minio_client_and_bucket - client, bucket = get_minio_client_and_bucket() + from app.utils.minio_utils import get_minio_client + client = get_minio_client() assert isinstance(client, Minio) - assert bucket == "test-bucket" assert client._base_url.host == "localhost:9000" -def test_get_minio_client_missing_bucket_name(monkeypatch): - # Set all except MINIO_BUCKET_NAME - monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000") - monkeypatch.setenv("MINIO_ROOT_USER", "admin") - monkeypatch.setenv("MINIO_ROOT_PASSWORD", "password123") - monkeypatch.setenv("MINIO_BUCKET_NAME", "") - - from app.utils.minio_utils import get_minio_client_and_bucket - with pytest.raises(ValueError, match="MINIO_BUCKET_NAME is not set"): - get_minio_client_and_bucket() - - # Testing function: get_minio_object_list def test_get_minio_object_list_success(): @@ -296,12 +282,11 @@ def test_download_unexpected_error(mock_logging): def test_successful_retrieval(mocker, mock_minio_response): mock_client = MagicMock() - mock_bucket = "test-bucket" mock_client.get_object.return_value = mock_minio_response - mocker.patch("app.utils.minio_utils.get_minio_client_and_bucket", return_value=(mock_client, mock_bucket)) + mocker.patch("app.utils.minio_utils.get_minio_client", return_value=mock_client) from app.utils.minio_utils import get_validation_status_from_minio - result = get_validation_status_from_minio("crate123") + result = get_validation_status_from_minio("test_bucket", "crate123") assert result == {"status": "valid"} mock_minio_response.close.assert_called_once() @@ -310,7 +295,6 @@ def test_successful_retrieval(mocker, mock_minio_response): def test_s3_error_raised(mocker): mock_client = MagicMock() - mock_bucket = "test-bucket" mock_client.get_object.side_effect = S3Error( code="S3 error", message=None, @@ -319,22 +303,22 @@ def test_s3_error_raised(mocker): host_id=None, response=None ) - mocker.patch("app.utils.minio_utils.get_minio_client_and_bucket", return_value=(mock_client, mock_bucket)) + mocker.patch("app.utils.minio_utils.get_minio_client", return_value=mock_client) from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio("crate123") + get_validation_status_from_minio("test_bucket", "crate123") assert exc.value.status_code == 500 assert "S3 Error" in str(exc.value.message) def test_value_error_raised(mocker): - mocker.patch("app.utils.minio_utils.get_minio_client_and_bucket", side_effect=ValueError("Missing env var")) + mocker.patch("app.utils.minio_utils.get_minio_client", side_effect=ValueError("Missing env var")) from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio("crate123") + get_validation_status_from_minio("test_bucket", "crate123") assert exc.value.status_code == 500 assert "Configuration Error" in str(exc.value.message) @@ -342,13 +326,12 @@ def test_value_error_raised(mocker): def test_generic_exception_raised(mocker): mock_client = MagicMock() - mock_bucket = "test-bucket" mock_client.get_object.side_effect = Exception("Unexpected failure") - mocker.patch("app.utils.minio_utils.get_minio_client_and_bucket", return_value=(mock_client, mock_bucket)) + mocker.patch("app.utils.minio_utils.get_minio_client", return_value=mock_client) from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio("crate123") + get_validation_status_from_minio("test_bucket", "crate123") assert exc.value.status_code == 500 assert "Unknown Error" in str(exc.value.message) @@ -356,16 +339,16 @@ def test_generic_exception_raised(mocker): # Testing function: update_validation_status_in_minio -@mock.patch("app.utils.minio_utils.get_minio_client_and_bucket") +@mock.patch("app.utils.minio_utils.get_minio_client") def test_update_validation_status_success(mock_get_client): mock_minio_client = mock.Mock() - mock_get_client.return_value = (mock_minio_client, "test-bucket") + mock_get_client.return_value = mock_minio_client crate_id = "crate123" validation_status = json.dumps({"status": "valid", "errors": []}) from app.utils.minio_utils import update_validation_status_in_minio - update_validation_status_in_minio(crate_id, validation_status) + update_validation_status_in_minio("test_bucket", crate_id, validation_status) expected_object_name = f"{crate_id}_validation/validation_status.txt" expected_data = json.dumps(json.loads(validation_status), indent=None).encode("utf-8") @@ -381,7 +364,7 @@ def test_update_validation_status_success(mock_get_client): actual_data_stream = args[2] if len(args) > 2 else kwargs["data"] length = args[3] if len(args) > 3 else kwargs["length"] - assert bucket_name == "test-bucket" + assert bucket_name == "test_bucket" assert object_name == expected_object_name assert isinstance(actual_data_stream, BytesIO) actual_data_stream.seek(0) @@ -390,10 +373,10 @@ def test_update_validation_status_success(mock_get_client): assert kwargs["content_type"] == "application/json" -@mock.patch("app.utils.minio_utils.get_minio_client_and_bucket") +@mock.patch("app.utils.minio_utils.get_minio_client") def test_update_validation_status_s3_error(mock_get_client): mock_minio_client = mock.Mock() - mock_get_client.return_value = (mock_minio_client, "test-bucket") + mock_get_client.return_value = mock_minio_client mock_minio_client.put_object.side_effect = S3Error( code="S3 error", message=None, @@ -405,31 +388,31 @@ def test_update_validation_status_s3_error(mock_get_client): from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio("crate123", json.dumps({"status": "valid"})) + update_validation_status_in_minio("test_bucket", "crate123", json.dumps({"status": "valid"})) assert exc.value.status_code == 500 assert "S3 Error" in str(exc.value.message) -@mock.patch("app.utils.minio_utils.get_minio_client_and_bucket", side_effect=ValueError("Missing env vars")) +@mock.patch("app.utils.minio_utils.get_minio_client", side_effect=ValueError("Missing env vars")) def test_update_validation_status_value_error(mock_get_client): from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio("crate123", json.dumps({"status": "valid"})) + update_validation_status_in_minio("test_bucket", "crate123", json.dumps({"status": "valid"})) assert exc.value.status_code == 500 assert "Configuration Error" in str(exc.value.message) -@mock.patch("app.utils.minio_utils.get_minio_client_and_bucket") +@mock.patch("app.utils.minio_utils.get_minio_client") def test_update_validation_status_unexpected_error(mock_get_client): mock_minio_client = mock.Mock() - mock_get_client.return_value = (mock_minio_client, "test-bucket") + mock_get_client.return_value = mock_minio_client mock_minio_client.put_object.side_effect = RuntimeError("Unexpected failure") from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio("crate123", json.dumps({"status": "valid"})) + update_validation_status_in_minio("test_bucket", "crate123", json.dumps({"status": "valid"})) assert exc.value.status_code == 500 assert "Unknown Error" in str(exc.value.message) @@ -440,7 +423,7 @@ def test_update_validation_status_unexpected_error(mock_get_client): @patch("app.utils.minio_utils.download_file_from_minio") @patch("app.utils.minio_utils.get_minio_object_list") @patch("app.utils.minio_utils.find_rocrate_object_on_minio") -@patch("app.utils.minio_utils.get_minio_client_and_bucket") +@patch("app.utils.minio_utils.get_minio_client") def test_fetch_rocrate_zip( mock_get_client_and_bucket, mock_find_object, @@ -449,7 +432,7 @@ def test_fetch_rocrate_zip( tmp_path, ): # Setup mocks - mock_get_client_and_bucket.return_value = ("minio_client", "bucket") + mock_get_client_and_bucket.return_value = "minio_client" rocrate_obj = DummyObject("some/path/rocrate123.zip", is_dir=False) mock_find_object.return_value = rocrate_obj @@ -457,20 +440,20 @@ def test_fetch_rocrate_zip( with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): # Execute - result = fetch_ro_crate_from_minio("rocrate123") + result = fetch_ro_crate_from_minio("test_bucket", "rocrate123") # Assert expected_path = tmp_path / "rocrate123.zip" assert result == str(expected_path) mock_download.assert_called_once_with( - "minio_client", "bucket", + "minio_client", "test_bucket", "some/path/rocrate123.zip", str(expected_path)) @patch("app.utils.minio_utils.download_file_from_minio") @patch("app.utils.minio_utils.get_minio_object_list") @patch("app.utils.minio_utils.find_rocrate_object_on_minio") -@patch("app.utils.minio_utils.get_minio_client_and_bucket") +@patch("app.utils.minio_utils.get_minio_client") def test_fetch_rocrate_directory( mock_get_client_and_bucket, mock_find_object, @@ -479,7 +462,7 @@ def test_fetch_rocrate_directory( tmp_path, ): # Setup mocks - mock_get_client_and_bucket.return_value = ("minio_client", "bucket") + mock_get_client_and_bucket.return_value = "minio_client" rocrate_obj = DummyObject("rocrates/rocrate124", is_dir=True) mock_find_object.return_value = rocrate_obj @@ -493,18 +476,18 @@ def test_fetch_rocrate_directory( ] # Execute - result = fetch_ro_crate_from_minio("rocrate124") + result = fetch_ro_crate_from_minio("test_bucket", "rocrate124") # Assert expected_root = tmp_path / "rocrate124" assert result == str(expected_root) mock_download.assert_any_call( - "minio_client", "bucket", + "minio_client", "test_bucket", "rocrates/rocrate124/metadata.json", str(expected_root / "metadata.json") ) mock_download.assert_any_call( - "minio_client", "bucket", + "minio_client", "test_bucket", "rocrates/rocrate124/data/file1.txt", str(expected_root / "data/file1.txt") ) @@ -513,7 +496,7 @@ def test_fetch_rocrate_directory( @patch("app.utils.minio_utils.download_file_from_minio") @patch("app.utils.minio_utils.get_minio_object_list") @patch("app.utils.minio_utils.find_rocrate_object_on_minio") -@patch("app.utils.minio_utils.get_minio_client_and_bucket") +@patch("app.utils.minio_utils.get_minio_client") def test_fetch_rocrate_handles_empty_dir( mock_get_client_and_bucket, mock_find_object, @@ -521,7 +504,7 @@ def test_fetch_rocrate_handles_empty_dir( mock_download, tmp_path, ): - mock_get_client_and_bucket.return_value = ("minio_client", "bucket") + mock_get_client_and_bucket.return_value = "minio_client" rocrate_obj = DummyObject("rocrate456", is_dir=True) mock_find_object.return_value = rocrate_obj mock_get_list.return_value = [] @@ -529,7 +512,7 @@ def test_fetch_rocrate_handles_empty_dir( from app.utils.minio_utils import fetch_ro_crate_from_minio with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): - result = fetch_ro_crate_from_minio("rocrate456") + result = fetch_ro_crate_from_minio("test_bucket", "rocrate456") expected_root = tmp_path / "rocrate456" assert result == str(expected_root) From 41ea50ad6a3cb73e8c1b3bfc569f5930770256ef Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:25:49 +0100 Subject: [PATCH 09/21] correct api test call order --- tests/test_api_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index 92ada57..76df5a4 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -124,7 +124,7 @@ def test_validate_by_id_missing_root_path(client): assert response.status_code == 202 assert response.json == {"message": "Validation in progress"} - mock_queue.assert_called_once_with("test_bucket", None, "crate-123", "default", "https://webhook.example.com") + mock_queue.assert_called_once_with("test_bucket", "crate-123", None, "default", "https://webhook.example.com") # Test POST API: /v1/ro_crates/validate_metadata From d0c066fb84d3492f0c96d2048b419f6d4d8a0dc6 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:28:57 +0100 Subject: [PATCH 10/21] add test_bucket and base_path to validation task tests --- tests/test_validation_tasks.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index 78d4401..e7b45a8 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -334,10 +334,10 @@ def test_return_validation_returns_dict(mock_get_status): # Simulate dict result mock_get_status.return_value = {"status": "passed", "errors": []} - result = return_ro_crate_validation("crate123") + result = return_ro_crate_validation("test_bucket", "crate123", None) assert isinstance(result, dict) assert result["status"] == "passed" - mock_get_status.assert_called_once_with("crate123") + mock_get_status.assert_called_once_with("test_bucket", "crate123", None) @mock.patch("app.tasks.validation_tasks.get_validation_status_from_minio") @@ -345,10 +345,10 @@ def test_return_validation_returns_string(mock_get_status): # Simulate string result mock_get_status.return_value = "Validation result: OK" - result = return_ro_crate_validation("crate456") + result = return_ro_crate_validation("test_bucket", "crate456", None) assert isinstance(result, str) assert "OK" in result - mock_get_status.assert_called_once_with("crate456") + mock_get_status.assert_called_once_with("test_bucket", "crate456", None) @mock.patch("app.tasks.validation_tasks.get_validation_status_from_minio") @@ -357,10 +357,10 @@ def test_return_validation_raises_error(mock_get_status): mock_get_status.side_effect = InvalidAPIUsage("MinIO S3 Error: empty", 500) with pytest.raises(InvalidAPIUsage) as exc_info: - return_ro_crate_validation("crate789") + return_ro_crate_validation("test_bucket", "crate789", None) assert "MinIO S3 Error" in str(exc_info.value.message) - mock_get_status.assert_called_once_with("crate789") + mock_get_status.assert_called_once_with("test_bucket", "crate789", None) # Test function: check_ro_crate_exists From 78d978fa02cd9377f3fe13163b8a386e4badad6f Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:46:37 +0100 Subject: [PATCH 11/21] root_path mandatory for minio_util calls --- app/tasks/validation_tasks.py | 6 +++--- app/utils/minio_utils.py | 11 +++++++---- tests/test_minio.py | 20 ++++++++++---------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index f495c60..601efa9 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -188,7 +188,7 @@ def perform_ro_crate_validation( def check_ro_crate_exists( bucket_name: str, crate_id: str, - root_path: str = None, + root_path: str, ) -> bool: """ Checks for the existence of an RO-Crate using the provided Crate ID. @@ -211,7 +211,7 @@ def check_ro_crate_exists( def check_validation_exists( bucket_name: str, crate_id: str, - root_path: str = None, + root_path: str, ) -> bool: """ Checks for the existence of a validation result using the provided Crate ID. @@ -234,7 +234,7 @@ def check_validation_exists( def return_ro_crate_validation( bucket_name: str, crate_id: str, - root_path: str = None, + root_path: str, ) -> dict | str: """ Retrieves the validation result for an RO-Crate using the provided Crate ID. diff --git a/app/utils/minio_utils.py b/app/utils/minio_utils.py index 401fddd..2181579 100644 --- a/app/utils/minio_utils.py +++ b/app/utils/minio_utils.py @@ -108,7 +108,7 @@ def update_validation_status_in_minio(minio_bucket: str, crate_id: str, validati ) -def get_validation_status_from_minio(minio_bucket: str, crate_id: str) -> dict: +def get_validation_status_from_minio(minio_bucket: str, crate_id: str, root_path: str) -> dict: """ Checks for the existence of a validation report for the given RO-Crate in the MinIO bucket. Returns validation message if it exists, or notification that it is missing if not. @@ -120,7 +120,10 @@ def get_validation_status_from_minio(minio_bucket: str, crate_id: str) -> dict: """ # The object in MinIO is _validation/validation_status.txt - object_name = f"{crate_id}_validation/validation_status.txt" + if root_path: + object_name = f"{root_path}/{crate_id}_validation/validation_status.txt" + else: + object_name = f"{crate_id}_validation/validation_status.txt" logging.info(f"Getting object {object_name}") @@ -182,7 +185,7 @@ def download_file_from_minio(minio_client: object, minio_bucket: str, object_pat raise InvalidAPIUsage(f"Unknown Error: {e}", 500) -def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str = None) -> object: +def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str) -> object: """ Checks that the requested object exists on the MinIO instance. @@ -219,7 +222,7 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: return return_object -def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str = None) -> object | bool: +def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str) -> object | bool: """ Checks that the requested object exists on the MinIO instance. diff --git a/tests/test_minio.py b/tests/test_minio.py index b30a9f2..13c41e3 100644 --- a/tests/test_minio.py +++ b/tests/test_minio.py @@ -122,7 +122,7 @@ def test_rocrate_found_as_zip(mock_get_list): minio_client = MagicMock() from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket") + result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket", None) assert result == obj @@ -136,7 +136,7 @@ def test_rocrate_not_found(mock_get_list): minio_client = MagicMock() from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket") + result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket", None) mock_get_list.assert_called_once() assert not result @@ -150,7 +150,7 @@ def test_storage_path_none(mock_get_list): minio_client = MagicMock() from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio("rocrate456", minio_client, "bucket") + result = find_rocrate_object_on_minio("rocrate456", minio_client, "bucket", None) assert result == obj @@ -193,7 +193,7 @@ def test_validation_object_found_without_storage_path(mock_get_list): from app.utils.minio_utils import find_validation_object_on_minio # Execute - result = find_validation_object_on_minio("rocrate123", MagicMock(), "bucket") + result = find_validation_object_on_minio("rocrate123", MagicMock(), "bucket", None) # Assert assert result == obj @@ -206,7 +206,7 @@ def test_validation_object_not_found(mock_get_list): mock_get_list.return_value = [DummyObject("some/other/object.txt")] from app.utils.minio_utils import find_validation_object_on_minio - result = find_validation_object_on_minio("rocrate999", MagicMock(), "bucket") + result = find_validation_object_on_minio("rocrate999", MagicMock(), "bucket", None) assert result is False @@ -217,7 +217,7 @@ def test_validation_object_empty_list(mock_get_list): mock_get_list.return_value = [] from app.utils.minio_utils import find_validation_object_on_minio - result = find_validation_object_on_minio("rocrate999", MagicMock(), "bucket") + result = find_validation_object_on_minio("rocrate999", MagicMock(), "bucket", None) assert result is False @@ -286,7 +286,7 @@ def test_successful_retrieval(mocker, mock_minio_response): mocker.patch("app.utils.minio_utils.get_minio_client", return_value=mock_client) from app.utils.minio_utils import get_validation_status_from_minio - result = get_validation_status_from_minio("test_bucket", "crate123") + result = get_validation_status_from_minio("test_bucket", "crate123", None) assert result == {"status": "valid"} mock_minio_response.close.assert_called_once() @@ -307,7 +307,7 @@ def test_s3_error_raised(mocker): from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio("test_bucket", "crate123") + get_validation_status_from_minio("test_bucket", "crate123", None) assert exc.value.status_code == 500 assert "S3 Error" in str(exc.value.message) @@ -318,7 +318,7 @@ def test_value_error_raised(mocker): from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio("test_bucket", "crate123") + get_validation_status_from_minio("test_bucket", "crate123", None) assert exc.value.status_code == 500 assert "Configuration Error" in str(exc.value.message) @@ -331,7 +331,7 @@ def test_generic_exception_raised(mocker): from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio("test_bucket", "crate123") + get_validation_status_from_minio("test_bucket", "crate123", None) assert exc.value.status_code == 500 assert "Unknown Error" in str(exc.value.message) From 2a69aa0d825b4d5250e5711d4f4843b25fd82e19 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:59:48 +0100 Subject: [PATCH 12/21] added rocrate_bucket and root_path to all required functions now --- app/tasks/validation_tasks.py | 16 +++++++------ app/utils/minio_utils.py | 45 ++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index 601efa9..f8833de 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -29,12 +29,14 @@ @celery.task def process_validation_task_by_id( - crate_id: str, profile_name: str | None, webhook_url: str | None + minio_bucket: str, crate_id: str, root_path: str, profile_name: str | None, webhook_url: str | None ) -> None: """ Background task to process the RO-Crate validation by ID. + :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate to validate. + :param root_path: The root path containing the RO-Crate. :param profile_name: The name of the validation profile to use. Defaults to None. :param webhook_url: The webhook URL to send notifications to. Defaults to None. :raises Exception: If an error occurs during the validation process. @@ -46,7 +48,7 @@ def process_validation_task_by_id( try: # Fetch the RO-Crate from MinIO using the provided ID: - file_path = fetch_ro_crate_from_minio(crate_id) + file_path = fetch_ro_crate_from_minio(minio_bucket, crate_id, root_path) logging.info(f"Processing validation task for {file_path}") @@ -59,12 +61,12 @@ def process_validation_task_by_id( raise Exception(f"Validation failed: {validation_result}") if not validation_result.has_issues(): - logging.info(f"RO Crate {file_path} is valid.") + logging.info(f"RO Crate {crate_id} is valid.") else: - logging.info(f"RO Crate {file_path} is invalid.") + logging.info(f"RO Crate {crate_id} is invalid.") # Update the validation status in MinIO: - update_validation_status_in_minio(crate_id, validation_result.to_json()) + update_validation_status_in_minio(minio_bucket, crate_id, root_path, validation_result.to_json()) # TODO: Prepare the data to send to the webhook, and send the webhook notification. @@ -202,7 +204,7 @@ def check_ro_crate_exists( logging.info(f"Checking for existence of RO-Crate {crate_id}") minio_client = get_minio_client() - if find_rocrate_object_on_minio(crate_id, minio_client, bucket_name, storage_path=root_path): + if find_rocrate_object_on_minio(crate_id, minio_client, bucket_name, root_path): return True else: return False @@ -225,7 +227,7 @@ def check_validation_exists( logging.info(f"Checking for existence of RO-Crate {crate_id}") minio_client = get_minio_client() - if find_validation_object_on_minio(crate_id, minio_client, bucket_name, storage_path=root_path): + if find_validation_object_on_minio(crate_id, minio_client, bucket_name, root_path): return True else: return False diff --git a/app/utils/minio_utils.py b/app/utils/minio_utils.py index 2181579..5bdb0a6 100644 --- a/app/utils/minio_utils.py +++ b/app/utils/minio_utils.py @@ -18,48 +18,49 @@ logger = logging.getLogger(__name__) -def fetch_ro_crate_from_minio(minio_bucket: str, crate_id: str) -> str: +def fetch_ro_crate_from_minio(minio_bucket: str, crate_id: str, root_path: str) -> str: """ Fetches an RO-Crate from MinIO based on the crate ID. Downloads the crate as a file and returns local file path. :param minio_bucket: The MinIO bucket containing the RO-Crate. :param crate_id: The ID of the RO-Crate to fetch from MinIO. + :param root_path: The root path containing the RO-Crate. :return: The local file path where the RO-Crate is saved. """ minio_client = get_minio_client() - rocrate_object = find_rocrate_object_on_minio(crate_id, minio_client, minio_bucket) + rocrate_object = find_rocrate_object_on_minio(crate_id, minio_client, minio_bucket, root_path) - rocrate_path = rocrate_object.object_name - rocrate_name = rocrate_path.split('/')[-1] + rocrate_minio_path = rocrate_object.object_name + rocrate_name = rocrate_minio_path.split('/')[-1] temp_dir = tempfile.mkdtemp() - root_path = os.path.join(temp_dir, rocrate_name) + local_root_path = os.path.join(temp_dir, rocrate_name) logging.info( - f"Fetching RO-Crate {rocrate_name} from MinIO bucket {minio_bucket}. File path {root_path}" + f"Fetching RO-Crate {rocrate_name} from MinIO bucket {minio_bucket}. File path {local_root_path}" ) if rocrate_object.is_dir: - os.makedirs(os.path.dirname(root_path), exist_ok=True) + os.makedirs(os.path.dirname(local_root_path), exist_ok=True) - objects_list = get_minio_object_list(rocrate_path, minio_client, minio_bucket, recursive=True) + objects_list = get_minio_object_list(rocrate_minio_path, minio_client, minio_bucket, recursive=True) for obj in objects_list: - relative_path = obj.object_name[len(rocrate_path):].lstrip("/") - local_file_path = os.path.join(root_path, relative_path) + relative_path = obj.object_name[len(rocrate_minio_path):].lstrip("/") + local_file_path = os.path.join(local_root_path, relative_path) os.makedirs(os.path.dirname(local_file_path), exist_ok=True) download_file_from_minio(minio_client, minio_bucket, obj.object_name, local_file_path) else: - file_path = root_path - download_file_from_minio(minio_client, minio_bucket, rocrate_path, file_path) + file_path = local_root_path + download_file_from_minio(minio_client, minio_bucket, rocrate_minio_path, file_path) logging.info( - f"RO-Crate {rocrate_name} fetched successfully and saved to {root_path}." + f"RO-Crate {rocrate_name} fetched successfully and saved to {local_root_path}." ) - return root_path + return local_root_path def update_validation_status_in_minio(minio_bucket: str, crate_id: str, validation_status: str) -> None: @@ -185,7 +186,7 @@ def download_file_from_minio(minio_client: object, minio_bucket: str, object_pat raise InvalidAPIUsage(f"Unknown Error: {e}", 500) -def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str) -> object: +def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, root_path: str) -> object: """ Checks that the requested object exists on the MinIO instance. @@ -193,7 +194,7 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: If it does exist then the minio.datatypes.Object is returned. :param rocrate_id: string containing the name of ro-crate - :param storage_path: string containing the path within which the ro-crate should be + :param root_path: string containing the path within which the ro-crate should be :param minio_client: minio object :param minio_bucket: string containing bucket on minio :return return_object: rocrate object we require @@ -202,8 +203,8 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: logging.info(f"Finding Validation result: {rocrate_id}_validation/validation_status.txt") - if storage_path: - file_path = f"{storage_path}/{rocrate_id}_validation/validation_status.txt" + if root_path: + file_path = f"{root_path}/{rocrate_id}_validation/validation_status.txt" else: file_path = f"{rocrate_id}_validation/validation_status.txt" @@ -222,7 +223,7 @@ def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: return return_object -def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, storage_path: str) -> object | bool: +def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, root_path: str) -> object | bool: """ Checks that the requested object exists on the MinIO instance. @@ -230,7 +231,7 @@ def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: st If it does exist then the minio.datatypes.Object is returned. :param rocrate_id: string containing the name of ro-crate - :param storage_path: string containing the path within which the ro-crate should be + :param root_path: string containing the path within which the ro-crate should be :param minio_client: minio object :param minio_bucket: string containing bucket on minio :return return_object or False: rocrate object we require, or False result @@ -239,8 +240,8 @@ def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: st logging.info(f"Finding RO-Crate: {rocrate_id}") - if storage_path: - rocrate_path = f"{storage_path}/{rocrate_id}" + if root_path: + rocrate_path = f"{root_path}/{rocrate_id}" else: rocrate_path = rocrate_id From 05d4e61f0c820eaa58cafabdaa8e475c2058e84a Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:02:56 +0100 Subject: [PATCH 13/21] minio and validation_tasks tests updated --- tests/test_minio.py | 36 +++++++++++++++++----------------- tests/test_validation_tasks.py | 18 ++++++++--------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/test_minio.py b/tests/test_minio.py index 13c41e3..f2bc21f 100644 --- a/tests/test_minio.py +++ b/tests/test_minio.py @@ -110,7 +110,7 @@ def test_rocrate_found_as_directory(mock_get_list): minio_client = MagicMock() from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket", storage_path="my/path") + result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket", root_path="my/path") assert result == obj @@ -162,7 +162,7 @@ def test_storage_path_provided(mock_get_list): minio_client = MagicMock() from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio("rocrate789", minio_client, "bucket", storage_path="data") + result = find_rocrate_object_on_minio("rocrate789", minio_client, "bucket", root_path="data") assert result == obj @@ -177,7 +177,7 @@ def test_validation_object_found_with_storage_path(mock_get_list): from app.utils.minio_utils import find_validation_object_on_minio # Execute - result = find_validation_object_on_minio("rocrate123", MagicMock(), "bucket", storage_path="my/storage") + result = find_validation_object_on_minio("rocrate123", MagicMock(), "bucket", root_path="my/storage") # Assert assert result == obj @@ -425,14 +425,14 @@ def test_update_validation_status_unexpected_error(mock_get_client): @patch("app.utils.minio_utils.find_rocrate_object_on_minio") @patch("app.utils.minio_utils.get_minio_client") def test_fetch_rocrate_zip( - mock_get_client_and_bucket, + mock_get_client, mock_find_object, mock_get_list, mock_download, tmp_path, ): # Setup mocks - mock_get_client_and_bucket.return_value = "minio_client" + mock_get_client.return_value = "minio_client" rocrate_obj = DummyObject("some/path/rocrate123.zip", is_dir=False) mock_find_object.return_value = rocrate_obj @@ -440,14 +440,14 @@ def test_fetch_rocrate_zip( with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): # Execute - result = fetch_ro_crate_from_minio("test_bucket", "rocrate123") + result = fetch_ro_crate_from_minio("test_bucket", "rocrate123", "some/path") - # Assert - expected_path = tmp_path / "rocrate123.zip" - assert result == str(expected_path) - mock_download.assert_called_once_with( - "minio_client", "test_bucket", - "some/path/rocrate123.zip", str(expected_path)) + # Assert + expected_path = tmp_path / "rocrate123.zip" + assert result == str(expected_path) + mock_download.assert_called_once_with( + "minio_client", "test_bucket", + "some/path/rocrate123.zip", str(expected_path)) @patch("app.utils.minio_utils.download_file_from_minio") @@ -455,14 +455,14 @@ def test_fetch_rocrate_zip( @patch("app.utils.minio_utils.find_rocrate_object_on_minio") @patch("app.utils.minio_utils.get_minio_client") def test_fetch_rocrate_directory( - mock_get_client_and_bucket, + mock_get_client, mock_find_object, mock_get_list, mock_download, tmp_path, ): # Setup mocks - mock_get_client_and_bucket.return_value = "minio_client" + mock_get_client.return_value = "minio_client" rocrate_obj = DummyObject("rocrates/rocrate124", is_dir=True) mock_find_object.return_value = rocrate_obj @@ -476,7 +476,7 @@ def test_fetch_rocrate_directory( ] # Execute - result = fetch_ro_crate_from_minio("test_bucket", "rocrate124") + result = fetch_ro_crate_from_minio("test_bucket", "rocrate124", "rocrates") # Assert expected_root = tmp_path / "rocrate124" @@ -498,13 +498,13 @@ def test_fetch_rocrate_directory( @patch("app.utils.minio_utils.find_rocrate_object_on_minio") @patch("app.utils.minio_utils.get_minio_client") def test_fetch_rocrate_handles_empty_dir( - mock_get_client_and_bucket, + mock_get_client, mock_find_object, mock_get_list, mock_download, tmp_path, ): - mock_get_client_and_bucket.return_value = "minio_client" + mock_get_client.return_value = "minio_client" rocrate_obj = DummyObject("rocrate456", is_dir=True) mock_find_object.return_value = rocrate_obj mock_get_list.return_value = [] @@ -512,7 +512,7 @@ def test_fetch_rocrate_handles_empty_dir( from app.utils.minio_utils import fetch_ro_crate_from_minio with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): - result = fetch_ro_crate_from_minio("test_bucket", "rocrate456") + result = fetch_ro_crate_from_minio("test_bucket", "rocrate456", "") expected_root = tmp_path / "rocrate456" assert result == str(expected_root) diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index e7b45a8..a58eac3 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -38,11 +38,11 @@ def test_process_validation_zipfile_success( mock_validation_result.to_json.return_value = '{"status": "valid"}' mock_validate.return_value = mock_validation_result - process_validation_task_by_id("crate123", "profileA", "https://example.com/hook") + process_validation_task_by_id("test_bucket", "crate123", "", "profileA", "https://example.com/hook") - mock_fetch.assert_called_once_with("crate123") + mock_fetch.assert_called_once_with("test_bucket", "crate123", "") mock_validate.assert_called_once_with("/tmp/crate.zip", "profileA") - mock_update.assert_called_once_with("crate123", '{"status": "valid"}') + mock_update.assert_called_once_with("test_bucket", "crate123", "", '{"status": "valid"}') mock_webhook.assert_called_once_with("https://example.com/hook", '{"status": "valid"}') mock_remove.assert_called_once_with("/tmp/crate.zip") @@ -72,11 +72,11 @@ def test_process_validation_directory_success( mock_validation_result.to_json.return_value = '{"status": "valid"}' mock_validate.return_value = mock_validation_result - process_validation_task_by_id("crate123", "profileA", "https://example.com/hook") + process_validation_task_by_id("test_bucket", "crate123", "", "profileA", "https://example.com/hook") - mock_fetch.assert_called_once_with("crate123") + mock_fetch.assert_called_once_with("test_bucket", "crate123", "") mock_validate.assert_called_once_with("/tmp/crate123/", "profileA") - mock_update.assert_called_once_with("crate123", '{"status": "valid"}') + mock_update.assert_called_once_with("test_bucket", "crate123", "", '{"status": "valid"}') mock_webhook.assert_called_once_with("https://example.com/hook", '{"status": "valid"}') mock_rmtree.assert_called_once_with("/tmp/crate123/") @@ -100,7 +100,7 @@ def test_process_validation_fails_with_message( mock_fetch.return_value = "/tmp/crate.zip" mock_validate.return_value = "Validation failed" - process_validation_task_by_id("crate123", "profileA", "https://example.com/hook") + process_validation_task_by_id("test_bucket", "crate123", "", "profileA", "https://example.com/hook") mock_update.assert_not_called() mock_webhook.assert_called_once() @@ -128,7 +128,7 @@ def test_process_validation_exception( ): mock_fetch.return_value = "/tmp/crate.zip" - process_validation_task_by_id("crate123", "profileA", "https://example.com/hook") + process_validation_task_by_id("test_bucket", "crate123", "", "profileA", "https://example.com/hook") mock_update.assert_not_called() mock_webhook.assert_called_once() @@ -150,7 +150,7 @@ def test_process_validation_fetch_error( mock_webhook, mock_exists ): - process_validation_task_by_id("crate123", "profileA", "https://example.com/hook") + process_validation_task_by_id("test_bucket", "crate123", "", "profileA", "https://example.com/hook") mock_validate.assert_not_called() mock_update.assert_not_called() From cbc29b85ad3a2d4946659ef9bb1fb4f63016d069 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:05:06 +0100 Subject: [PATCH 14/21] integration tests will now return docker container logs on failure --- .github/workflows/test_docker.yml | 2 +- tests/test_integration.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 6acfa6e..98ff5f2 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest requests minio + pip install pytest requests minio docker - name: Build Docker Compose Containers run: | diff --git a/tests/test_integration.py b/tests/test_integration.py index 94f781b..f51fb2f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,11 +4,17 @@ import requests import json import os +import docker from minio import Minio +@pytest.fixture(scope="session") +def docker_client(): + return docker.from_env() + + @pytest.fixture(scope="session", autouse=True) -def docker_compose(): +def docker_compose(docker_client): """Start Docker Compose before tests, shut down after.""" print("Starting Docker Compose...") subprocess.run( @@ -21,6 +27,13 @@ def docker_compose(): yield # Run the tests + for container in docker_client.containers.list(): + if "cratey-validator" in container.name: + logs = container.logs().decode("utf-8") + + print(f"\n======= Logs from {container.name} container =======") + print(logs) + print("Stopping Docker Compose...") subprocess.run(["docker", "compose", "down"], check=True) From b6f55065b478a15d2e2a7d1e89a458ab7f0bb37e Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:05:32 +0100 Subject: [PATCH 15/21] integration tests updated --- tests/test_integration.py | 41 ++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index f51fb2f..443d323 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -103,7 +103,9 @@ def test_no_rocrate_for_validation(): } # The API expects the JSON to be passed as a string - payload = {} + payload = { + "minio_bucket" : "ro-crates" + } response = requests.post(url, json=payload, headers=headers) @@ -126,8 +128,13 @@ def test_no_validation_result_for_missing_crate(): "Content-Type": "application/json" } + # The API expects the JSON to be passed as a string + payload = { + "minio_bucket" : "ro-crates" + } + # GET action and tests - response = requests.get(url_get, headers=headers) + response = requests.get(url_get, json=payload, headers=headers) response_result = response.json() # Print response for debugging @@ -147,8 +154,13 @@ def test_get_existing_validation_result(): "Content-Type": "application/json" } + # The API expects the JSON to be passed as a string + payload = { + "minio_bucket" : "ro-crates" + } + # GET action and tests - response = requests.get(url_get, headers=headers) + response = requests.get(url_get, json=payload, headers=headers) response_result = response.json() # Print response for debugging @@ -168,8 +180,13 @@ def test_rocrate_not_validated_yet(): "Content-Type": "application/json" } + # The API expects the JSON to be passed as a string + payload = { + "minio_bucket" : "ro-crates" + } + # GET action and tests - response = requests.get(url_get, headers=headers) + response = requests.get(url_get, json=payload, headers=headers) response_result = response.json() # Print response for debugging @@ -191,7 +208,9 @@ def test_zipped_rocrate_validation(): } # The API expects the JSON to be passed as a string - payload = {} + payload = { + "minio_bucket" : "ro-crates" + } # POST action and tests response = requests.post(url_post, json=payload, headers=headers) @@ -209,7 +228,7 @@ def test_zipped_rocrate_validation(): time.sleep(10) # GET action and tests - response = requests.get(url_get, headers=headers) + response = requests.get(url_get, json=payload, headers=headers) response_result = response.json() # Print response for debugging @@ -231,7 +250,9 @@ def test_directory_rocrate_validation(): } # The API expects the JSON to be passed as a string - payload = {} + payload = { + "minio_bucket" : "ro-crates" + } # POST action and tests response = requests.post(url_post, json=payload, headers=headers) @@ -249,7 +270,7 @@ def test_directory_rocrate_validation(): time.sleep(10) # GET action and tests - response = requests.get(url_get, headers=headers) + response = requests.get(url_get, json=payload, headers=headers) response_result = response.json() # Print response for debugging @@ -270,7 +291,9 @@ def test_ignore_rocrates_not_on_basepath(): } # The API expects the JSON to be passed as a string - payload = {} + payload = { + "minio_bucket" : "ro-crates" + } # POST action and tests response = requests.post(url_post, json=payload, headers=headers) From 8ccc306d544ee1f8e6b344883f60bf335a339876 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:06:00 +0100 Subject: [PATCH 16/21] add formatting to pytest output --- pytest.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..96735eb --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +log_format = %(asctime)s %(levelname)s %(message)s +log_date_format = %Y-%m-%d %H:%M:%S From 47d4f65d86d65a76c09eed6c0220c14fb54d6440 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:49:00 +0100 Subject: [PATCH 17/21] remove last storage_path references in tests --- tests/test_validation_tasks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index a58eac3..51b8063 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -374,7 +374,7 @@ def test_ro_crate_exists( result = check_ro_crate_exists("test_bucket", "crate123", "base_path") mock_get_client.assert_called_once() - mock_find_rocrate.assert_called_once_with("crate123", "mock_client", "test_bucket", storage_path="base_path") + mock_find_rocrate.assert_called_once_with("crate123", "mock_client", "test_bucket", "base_path") assert result is True @@ -387,7 +387,7 @@ def test_ro_crate_does_not_exist( result = check_ro_crate_exists("test_bucket", "crate12z", "base_path") mock_get_client.assert_called_once() - mock_find_rocrate.assert_called_once_with("crate12z", "mock_client", "test_bucket", storage_path="base_path") + mock_find_rocrate.assert_called_once_with("crate12z", "mock_client", "test_bucket", "base_path") assert result is False @@ -402,7 +402,7 @@ def test_validation_exists( result = check_validation_exists("test_bucket", "crate123", "base_path") mock_get_client.assert_called_once() - mock_find_validation.assert_called_once_with("crate123", "mock_client", "test_bucket", storage_path="base_path") + mock_find_validation.assert_called_once_with("crate123", "mock_client", "test_bucket", "base_path") assert result is True @@ -415,5 +415,5 @@ def test_validation_does_not_exist( result = check_validation_exists("test_bucket", "crate12z", "base_path") mock_get_client.assert_called_once() - mock_find_validation.assert_called_once_with("crate12z", "mock_client", "test_bucket", storage_path="base_path") + mock_find_validation.assert_called_once_with("crate12z", "mock_client", "test_bucket", "base_path") assert result is False From cd539296e7825833c4c42786bd96f4a1682dc1fa Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:59:21 +0100 Subject: [PATCH 18/21] added extra wait for integration tests, for GH actions --- tests/test_integration.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 443d323..a770bef 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -235,6 +235,21 @@ def test_zipped_rocrate_validation(): print("Status Code:", response.status_code) print("Response JSON:", response_result) + start_time = time.time() + while response.status_code == 400: + time.sleep(10) + # GET action and tests + response = requests.get(url_get, json=payload, headers=headers) + response_result = response.json() + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + elapsed = time.time() - start_time + if elapsed > 60: + print("60 seconds passed. Exiting loop") + break + # Assertions assert response.status_code == 200 assert response_result["passed"] is False @@ -277,6 +292,21 @@ def test_directory_rocrate_validation(): print("Status Code:", response.status_code) print("Response JSON:", response_result) + start_time = time.time() + while response.status_code == 400: + time.sleep(10) + # GET action and tests + response = requests.get(url_get, json=payload, headers=headers) + response_result = response.json() + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + elapsed = time.time() - start_time + if elapsed > 60: + print("60 seconds passed. Exiting loop") + break + # Assertions assert response.status_code == 200 assert response_result["passed"] is False From 2ba892c2462b81f41d73bd81a5a1bbfa10e5f77f Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:19:58 +0100 Subject: [PATCH 19/21] output more logs from integration tests --- .github/workflows/test_docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 98ff5f2..9af7b23 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -28,7 +28,7 @@ jobs: docker compose -f docker-compose-develop.yml build - name: Spin Up Docker Compose and Run Tests - run: pytest tests/test_integration.py -v + run: pytest -s -v tests/test_integration.py - name: Ensure that Docker Compose is Shutdown if: always() From 794dc45c598d1cb6990a12678f6179c7798ce0dd Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:32:54 +0100 Subject: [PATCH 20/21] add root_path to update_validation_status_in_minio --- app/utils/minio_utils.py | 8 ++++++-- tests/test_minio.py | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/utils/minio_utils.py b/app/utils/minio_utils.py index 5bdb0a6..925af05 100644 --- a/app/utils/minio_utils.py +++ b/app/utils/minio_utils.py @@ -63,7 +63,7 @@ def fetch_ro_crate_from_minio(minio_bucket: str, crate_id: str, root_path: str) return local_root_path -def update_validation_status_in_minio(minio_bucket: str, crate_id: str, validation_status: str) -> None: +def update_validation_status_in_minio(minio_bucket: str, crate_id: str, root_path: str, validation_status: str) -> None: """ Uploads the validation status to the MinIO bucket. @@ -75,7 +75,11 @@ def update_validation_status_in_minio(minio_bucket: str, crate_id: str, validati :raises Exception: If an unexpected error occurs """ - object_name = f"{crate_id}_validation/validation_status.txt" + # The object in MinIO is _validation/validation_status.txt + if root_path: + object_name = f"{root_path}/{crate_id}_validation/validation_status.txt" + else: + object_name = f"{crate_id}_validation/validation_status.txt" # convert pretty string to dictionary, then back to plain utf-8 encoded string validation_string = json.dumps(json.loads(validation_status), indent=None).encode("utf-8") diff --git a/tests/test_minio.py b/tests/test_minio.py index f2bc21f..02cde5a 100644 --- a/tests/test_minio.py +++ b/tests/test_minio.py @@ -348,7 +348,7 @@ def test_update_validation_status_success(mock_get_client): validation_status = json.dumps({"status": "valid", "errors": []}) from app.utils.minio_utils import update_validation_status_in_minio - update_validation_status_in_minio("test_bucket", crate_id, validation_status) + update_validation_status_in_minio("test_bucket", crate_id, "", validation_status) expected_object_name = f"{crate_id}_validation/validation_status.txt" expected_data = json.dumps(json.loads(validation_status), indent=None).encode("utf-8") @@ -388,7 +388,7 @@ def test_update_validation_status_s3_error(mock_get_client): from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio("test_bucket", "crate123", json.dumps({"status": "valid"})) + update_validation_status_in_minio("test_bucket", "crate123", "", json.dumps({"status": "valid"})) assert exc.value.status_code == 500 assert "S3 Error" in str(exc.value.message) @@ -398,7 +398,7 @@ def test_update_validation_status_s3_error(mock_get_client): def test_update_validation_status_value_error(mock_get_client): from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio("test_bucket", "crate123", json.dumps({"status": "valid"})) + update_validation_status_in_minio("test_bucket", "crate123", "", json.dumps({"status": "valid"})) assert exc.value.status_code == 500 assert "Configuration Error" in str(exc.value.message) @@ -412,7 +412,7 @@ def test_update_validation_status_unexpected_error(mock_get_client): from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio("test_bucket", "crate123", json.dumps({"status": "valid"})) + update_validation_status_in_minio("test_bucket", "crate123", "", json.dumps({"status": "valid"})) assert exc.value.status_code == 500 assert "Unknown Error" in str(exc.value.message) From 8a5394baf8d6e4154a79b63c28d0908c5307bca3 Mon Sep 17 00:00:00 2001 From: Douglas Lowe <10961945+douglowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:44:06 +0100 Subject: [PATCH 21/21] integration tests for ro-crates not in base directory --- tests/test_integration.py | 118 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index a770bef..4d7e5ec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -336,3 +336,121 @@ def test_ignore_rocrates_not_on_basepath(): # Assertions assert response.status_code == 400 assert response_result == "No RO-Crate with prefix: ro_crate_4" + + +def test_zipped_rocrate_in_subdirectory_validation(): + ro_crate = "ro_crate_4" + subdir_path = "project_a" + url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" + url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" + headers = { + "accept": "application/json", + "Content-Type": "application/json" + } + + # The API expects the JSON to be passed as a string + payload = { + "minio_bucket" : "ro-crates", + "root_path" : subdir_path + } + + # POST action and tests + response = requests.post(url_post, json=payload, headers=headers) + response_result = response.json()['message'] + + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + # Assertions + assert response.status_code == 202 + assert response_result == "Validation in progress" + + # wait for ro-crate to be validated + time.sleep(10) + + # GET action and tests + response = requests.get(url_get, json=payload, headers=headers) + response_result = response.json() + + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + start_time = time.time() + while response.status_code == 400: + time.sleep(10) + # GET action and tests + response = requests.get(url_get, json=payload, headers=headers) + response_result = response.json() + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + elapsed = time.time() - start_time + if elapsed > 60: + print("60 seconds passed. Exiting loop") + break + + # Assertions + assert response.status_code == 200 + assert response_result["passed"] is False + + +def test_directory_rocrate_in_subdirectory_validation(): + ro_crate = "ro_crate_5" + subdir_path = "project_a" + url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" + url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" + headers = { + "accept": "application/json", + "Content-Type": "application/json" + } + + # The API expects the JSON to be passed as a string + payload = { + "minio_bucket" : "ro-crates", + "root_path" : subdir_path + } + + # POST action and tests + response = requests.post(url_post, json=payload, headers=headers) + response_result = response.json()['message'] + + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + # Assertions + assert response.status_code == 202 + assert response_result == "Validation in progress" + + # wait for ro-crate to be validated + time.sleep(10) + + # GET action and tests + response = requests.get(url_get, json=payload, headers=headers) + response_result = response.json() + + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + start_time = time.time() + while response.status_code == 400: + time.sleep(10) + # GET action and tests + response = requests.get(url_get, json=payload, headers=headers) + response_result = response.json() + # Print response for debugging + print("Status Code:", response.status_code) + print("Response JSON:", response_result) + + elapsed = time.time() - start_time + if elapsed > 60: + print("60 seconds passed. Exiting loop") + break + + # Assertions + assert response.status_code == 200 + assert response_result["passed"] is False