diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df3d97f..8452ea3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,8 +14,18 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - uses: fsfe/reuse-action@v4 - - uses: psf/black@stable + - name: Set up Python + uses: actions/setup-python@v5 with: - options: "--check --verbose" - src: "./src" + python-version: "3.12" + - name: install dependencies + run: pip install -r dev-requirements.txt && pip install -r requirements.txt + - name: run pytest + run: pytest --cov=. --cov-report xml:coverage.xml + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@v3 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + - name: run black + run: black --check --verbose src + - uses: fsfe/reuse-action@v4 diff --git a/.gitignore b/.gitignore index bce3722..bd0ea88 100644 --- a/.gitignore +++ b/.gitignore @@ -2,16 +2,401 @@ # # SPDX-License-Identifier: Apache-2.0 -# generic +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,vim +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,macos,windows,vim + +### macOS ### +# General .DS_Store -.vagrant +.AppleDouble +.LSOverride -# code & config -_service-provider/* -_solr/schema.xml -_src/* -local/* -.env -venv +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries -__pycache__ +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +.idea/caches/build_file_checksums.ser + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# dependencies +app/client/node_modules +app/client/.pnp +app/client.pnp.js +app/client/node_modules/.package-lock.json + +# testing +app/client/coverage + +# production +app/client/dist + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +app/client/npm-debug.log* +app/client/yarn-debug.log* +app/client/yarn-error.log* + +# Ignore the virtual environment directory +app/venv/ +app/.env + +# Ignore Python compiled files +app/__pycache__/ +app/*.pyc +app/*.pyo +app/*.pyd + +# Ignore other unnecessary files +app/*.log +app/*.swp +app/*.bak +*.db +*.db-journal +app/client/.vite + +# Ignore local direnv/nix stuff +.direnv +.envrc +shell.nix +nixnode + +venv/ + +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ +e2e/blob-report/ +e2e/playwright/.cache/ +e2e/.auth/* + +.idea +.env +.scannerwork \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bca0e9f..d7fdb99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +chore: move form and workflow initialization to data/*.json chore: migrate base docker image to UBI 9 doc: update README and CONTRIBUTING.md diff --git a/Dockerfile b/Dockerfile index b15f1b9..54971aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,13 @@ # # SPDX-License-Identifier: Apache-2.0 -FROM registry.access.redhat.com/ubi9/python-312:1-20.1723128194 +FROM registry.access.redhat.com/ubi9/python-312:1-20.1724035315 USER 0 WORKDIR /app COPY src/ /app +COPY data/ /app/data/ COPY requirements.txt /app RUN pip install --no-cache-dir --upgrade -r requirements.txt diff --git a/data/catalogue_item.json b/data/catalogue_item.json new file mode 100644 index 0000000..44fd124 --- /dev/null +++ b/data/catalogue_item.json @@ -0,0 +1,13 @@ +{ + "resid": "{DATASET_IDENTIFIER}", + "wfid": "{WORKFLOW_ID}", + "organization": { + "organization/id": "{ORGANIZATION_ID}" + }, + "localizations": { + "en": { + "title": "{TITLE}" + } + }, + "enabled": true +} \ No newline at end of file diff --git a/data/catalogue_item.json.license b/data/catalogue_item.json.license new file mode 100644 index 0000000..d474cef --- /dev/null +++ b/data/catalogue_item.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 PNED G.I.E. + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/data/form.json b/data/form.json new file mode 100644 index 0000000..37b188a --- /dev/null +++ b/data/form.json @@ -0,0 +1,46 @@ +{ + "form/external-title": { + "en": "DARREV 1.0" + }, + "form/internal-name": "{FORM_CHECKSUM}", + "organization": { + "organization/id": "{WORKFLOW_ORGANIZATION_ID)" + }, + "form/fields": [ + { + "field/type": "attachment", + "field/title": { + "en": "Ethics Approval" + }, + "field/optional": false + }, + { + "field/type": "attachment", + "field/title": { + "en": "Project Description" + }, + "field/optional": false + }, + { + "field/type": "attachment", + "field/title": { + "en": "Data Analysis Plan" + }, + "field/optional": false + }, + { + "field/type": "attachment", + "field/title": { + "en": "Funding Source" + }, + "field/optional": true + }, + { + "field/type": "attachment", + "field/title": { + "en": "Peer Review" + }, + "field/optional": false + } + ] +} \ No newline at end of file diff --git a/data/form.json.license b/data/form.json.license new file mode 100644 index 0000000..d474cef --- /dev/null +++ b/data/form.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 PNED G.I.E. + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/data/resource.json b/data/resource.json new file mode 100644 index 0000000..e32b38b --- /dev/null +++ b/data/resource.json @@ -0,0 +1,7 @@ +{ + "resid": "{DATASET_IDENTIFIER}", + "organization": { + "organization/id": "{ORGANIZATION_ID}" + }, + "licenses": [] +} \ No newline at end of file diff --git a/data/resource.json.license b/data/resource.json.license new file mode 100644 index 0000000..d474cef --- /dev/null +++ b/data/resource.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 PNED G.I.E. + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/data/workflow.json b/data/workflow.json new file mode 100644 index 0000000..33505ec --- /dev/null +++ b/data/workflow.json @@ -0,0 +1,15 @@ +{ + "type": "workflow/default", + "organization": { + "organization/id": "{WORKFLOW_ORGANIZATION_ID)" + }, + "title": "GDI Default Workflow", + "forms": [ + { + "form/id": "{WORKFLOW_FORM_ID}" + } + ], + "voting": { + "type": "reviewers-vote" + } +} \ No newline at end of file diff --git a/data/workflow.json.license b/data/workflow.json.license new file mode 100644 index 0000000..d474cef --- /dev/null +++ b/data/workflow.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 PNED G.I.E. + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/data/workflow_organization.json b/data/workflow_organization.json new file mode 100644 index 0000000..7e9c475 --- /dev/null +++ b/data/workflow_organization.json @@ -0,0 +1,9 @@ +{ + "organization/id": "{ORGANIZATION_ID}", + "organization/name": { + "en": "Genomic Data Infrastructure" + }, + "organization/short-name": { + "en": "GDI" + } +} \ No newline at end of file diff --git a/data/workflow_organization.json.license b/data/workflow_organization.json.license new file mode 100644 index 0000000..d474cef --- /dev/null +++ b/data/workflow_organization.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 PNED G.I.E. + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/dev-requirements.txt b/dev-requirements.txt index 7ccd8b9..4cf3193 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,4 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -black==24.8.0 \ No newline at end of file +black==24.8.0 +pytest==8.3.2 +requests-mock==1.12.1 +pytest-cov==5.0.0 \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..d1d91bf --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +sonar.projectKey=GenomicDataInfrastructure_gdi-userportal-rems-synchronizer +sonar.organization=genomicdatainfrastructure +sonar.qualitygate.wait=true +sonar.python.coverage.reportPaths=coverage.xml +sonar.coverage.exclusions=**__init__**,**__pycache__**,tests/**,*.py +sonar.exclusions=*.xml +sonar.sources=src/ +sonar.test.inclusions= tests/*.py diff --git a/src/rems.py b/src/rems.py index 107b2d0..8190d87 100644 --- a/src/rems.py +++ b/src/rems.py @@ -4,12 +4,27 @@ import requests import hashlib +import json +from typing import Dict +import copy + + +def load_json(path: str) -> Dict: + try: + with open(path, "r") as file: + return json.load(file) + except (IOError, json.JSONDecodeError) as e: + raise ValueError(f"Error loading JSON from {path}: {str(e)}") def create_or_return_organization_in_rems(rems_base_url: str, headers: dict) -> str: - name = "Genomic Data Institute" - short_name = "GDI" + base_workflow_organization = load_json("./data/workflow_organization.json") + name = base_workflow_organization["organization/name"]["en"] organization_id = hashlib.md5(name.encode()).hexdigest() + + workflow_organization = copy.deepcopy(base_workflow_organization) + workflow_organization["organization/id"] = organization_id + response = requests.get( url=f"{rems_base_url}/api/organizations/{organization_id}", headers=headers ) @@ -19,16 +34,12 @@ def create_or_return_organization_in_rems(rems_base_url: str, headers: dict) -> raise RuntimeError(f"Organization retrieval failed: {response.text}") response = requests.post( url=f"{rems_base_url}/api/organizations/create", - json={ - "organization/id": organization_id, - "organization/name": {"en": name}, - "organization/short-name": {"en": short_name}, - }, + json=workflow_organization, headers=headers, ) if response.status_code != 200: raise RuntimeError(f"Organization creation failed: {response.text}") - return organization_id + return workflow_organization["organization/id"] def create_or_return_form_in_rems( @@ -50,40 +61,18 @@ def create_or_return_form_in_rems( if len(result) > 0: return result[0]["form/id"] + base_form = load_json("./data/form.json") + base_form_str = json.dumps(base_form) + form_checksum = hashlib.md5(base_form_str.encode()).hexdigest() + internal_name = base_form["form/external-title"]["en"] + " - " + form_checksum + + form = copy.deepcopy(base_form) + form["organization"]["organization/id"] = organization_id + form["form/internal-name"] = internal_name + response = requests.post( url=f"{rems_base_url}/api/forms/create", - json={ - "form/external-title": {"en": "GDI Default Form"}, - "form/internal-name": "GDI Default Form", - "organization": {"organization/id": organization_id}, - "form/fields": [ - { - "field/type": "attachment", - "field/title": {"en": "Ethics Approval"}, - "field/optional": False, - }, - { - "field/type": "attachment", - "field/title": {"en": "Project Description"}, - "field/optional": False, - }, - { - "field/type": "attachment", - "field/title": {"en": "Data Analysis Plan"}, - "field/optional": False, - }, - { - "field/type": "attachment", - "field/title": {"en": "Funding Source"}, - "field/optional": True, - }, - { - "field/type": "attachment", - "field/title": {"en": "Peer Review"}, - "field/optional": False, - }, - ], - }, + json=form, headers=headers, ) if response.status_code != 200: @@ -110,15 +99,15 @@ def create_or_return_workflow_in_rems( if len(result) > 0: return result[0]["id"] + base_workflow = load_json("./data/workflow.json") + + workflow = copy.deepcopy(base_workflow) + workflow["organization"]["organization/id"] = organization_id + workflow["forms"][0]["form/id"] = form_id + response = requests.post( url=f"{rems_base_url}/api/workflows/create", - json={ - "type": "workflow/default", - "organization": {"organization/id": organization_id}, - "title": "GDI Default Workflow", - "forms": [{"form/id": form_id}], - "voting": {"type": "reviewers-vote"}, - }, + json=workflow, headers=headers, ) if response.status_code != 200: @@ -127,23 +116,26 @@ def create_or_return_workflow_in_rems( def create_or_return_resource_in_rems( - organization_id: str, dataset_id: str, rems_base_url: str, headers: dict + organization_id: str, dataset_identifier: str, rems_base_url: str, headers: dict ) -> str: response = requests.get( - url=f"{rems_base_url}/api/resources?disabled=false&archived=false&resid={dataset_id}", + url=f"{rems_base_url}/api/resources?disabled=false&archived=false&resid={dataset_identifier}", headers=headers, ) if response.status_code != 200 or len(response.json()) > 1: raise RuntimeError(f"Resource retrieval failed: {response.text}") elif len(response.json()) == 1: return response.json()[0]["id"] + + base_resource = load_json("./data/resource.json") + + resource = copy.deepcopy(base_resource) + resource["organization"]["organization/id"] = organization_id + resource["resid"] = dataset_identifier + response = requests.post( url=f"{rems_base_url}/api/resources/create", - json={ - "resid": dataset_id, - "organization": {"organization/id": organization_id}, - "licenses": [], - }, + json=resource, headers=headers, ) if response.status_code != 200: @@ -154,31 +146,35 @@ def create_or_return_resource_in_rems( def create_or_return_catalogue_item_in_rems( organization_id: str, workflow_id: int, - dataset_id: str, + dataset_identifier: str, resource_id: int, title: str, rems_base_url: str, headers: dict, ) -> str: response = requests.get( - url=f"{rems_base_url}/api/catalogue-items?disabled=false&archived=false&resource={dataset_id}", + url=f"{rems_base_url}/api/catalogue-items?disabled=false&archived=false&resource={dataset_identifier}", headers=headers, ) if response.status_code != 200 or len(response.json()) > 1: raise RuntimeError(f"Catalogue Item retrieval failed: {response.text}") elif len(response.json()) == 1: return response.json()[0]["id"] + + base_catalogue_item = load_json("./data/catalogue_item.json") + + catalogue_item = copy.deepcopy(base_catalogue_item) + catalogue_item["organization"]["organization/id"] = organization_id + catalogue_item["resid"] = resource_id + catalogue_item["wfid"] = workflow_id + catalogue_item["localizations"]["en"]["title"] = title + response = requests.post( url=f"{rems_base_url}/api/catalogue-items/create", - json={ - "resid": resource_id, - "wfid": workflow_id, - "organization": {"organization/id": organization_id}, - "localizations": {"en": {"title": title}}, - "enabled": True, - }, + json=catalogue_item, headers=headers, ) + if response.status_code != 200: raise RuntimeError(f"Catalogue Item creation failed: {response.text}") return response.json()["id"] diff --git a/tests/test_ckan_functions.py b/tests/test_ckan_functions.py new file mode 100644 index 0000000..ce7b81e --- /dev/null +++ b/tests/test_ckan_functions.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +from unittest.mock import patch +from ckan import get_packages_count, get_packages + +# Mock data for the API responses +mock_package_count_response = {"result": {"count": 42}} + +mock_packages_response = { + "result": { + "results": [ + {"id": "package1", "name": "Test Package 1"}, + {"id": "package2", "name": "Test Package 2"}, + ] + } +} + + +# Test for get_packages_count +@patch("requests.get") +def test_get_packages_count(mock_get): + # Configure the mock to return a response with the JSON data + mock_get.return_value.json.return_value = mock_package_count_response + + # Call the function with a mock CKAN base URL + ckan_base_url = "http://mock-ckan-instance.com" + count = get_packages_count(ckan_base_url) + + # Assert the function returns the correct count + assert count == 42 + mock_get.assert_called_once_with( + f"{ckan_base_url}/api/3/action/package_search?rows=0" + ) + + +# Test for get_packages +@patch("requests.get") +def test_get_packages(mock_get): + # Configure the mock to return a response with the JSON data + mock_get.return_value.json.return_value = mock_packages_response + + # Call the function with mock parameters + start = 0 + rows = 2 + ckan_base_url = "http://mock-ckan-instance.com" + packages = get_packages(start, rows, ckan_base_url) + + # Assert the function returns the correct packages + assert len(packages) == 2 + assert packages[0]["id"] == "package1" + assert packages[1]["name"] == "Test Package 2" + mock_get.assert_called_once_with( + f"{ckan_base_url}/api/3/action/package_search?rows={rows}&start={start}" + ) diff --git a/tests/test_create_or_return_catalogue_item_in_rems.py b/tests/test_create_or_return_catalogue_item_in_rems.py new file mode 100644 index 0000000..7f32d2a --- /dev/null +++ b/tests/test_create_or_return_catalogue_item_in_rems.py @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) +import pytest +from unittest.mock import patch +from rems import create_or_return_catalogue_item_in_rems + + +# Test when the catalogue item already exists in REMS +@patch("requests.get") +def test_create_or_return_catalogue_item_in_rems_exists(mock_get): + # Mock the GET request to return a response with an existing catalogue item + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [ + { + "id": "catalogue-item-123", + "resid": "dataset-identifier", + "organization": {"organization/id": "test-org-id"}, + } + ] + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + workflow_id = 456 + dataset_identifier = "dataset-identifier" + resource_id = 789 + title = "Test Catalogue Item" + catalogue_item_id = create_or_return_catalogue_item_in_rems( + organization_id, + workflow_id, + dataset_identifier, + resource_id, + title, + rems_base_url, + headers, + ) + + # Assert the function returns the correct catalogue item ID + assert catalogue_item_id == "catalogue-item-123" + + # Assert the GET request was made correctly + mock_get.assert_called_once_with( + url=f"{rems_base_url}/api/catalogue-items?disabled=false&archived=false&resource={dataset_identifier}", + headers=headers, + ) + + +# Test when the catalogue item does not exist and needs to be created +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_catalogue_item_in_rems_create( + mock_load_json, mock_get, mock_post +): + # Mock the GET request to return a 200 status code with no catalogue items + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base catalogue item structure + mock_load_json.return_value = { + "organization": {"organization/id": ""}, + "resid": "", + "wfid": "", + "localizations": {"en": {"title": ""}}, + } + + # Mock the POST request to return a 200 status code and a catalogue item ID + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"id": "catalogue-item-456"} + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + workflow_id = 456 + dataset_identifier = "dataset-identifier" + resource_id = 789 + title = "Test Catalogue Item" + catalogue_item_id = create_or_return_catalogue_item_in_rems( + organization_id, + workflow_id, + dataset_identifier, + resource_id, + title, + rems_base_url, + headers, + ) + + # Assert the function returns the newly created catalogue item ID + assert catalogue_item_id == "catalogue-item-456" + + # Assert the POST request was made correctly + expected_catalogue_item = { + "organization": {"organization/id": organization_id}, + "resid": resource_id, + "wfid": workflow_id, + "localizations": {"en": {"title": title}}, + } + mock_post.assert_called_once_with( + url=f"{rems_base_url}/api/catalogue-items/create", + json=expected_catalogue_item, + headers=headers, + ) + + +# Test when catalogue item retrieval fails with a non-200 status code +@patch("requests.get") +def test_create_or_return_catalogue_item_in_rems_retrieval_fails(mock_get): + # Mock the GET request to return a non-200 status code + mock_get.return_value.status_code = 500 + mock_get.return_value.text = "Internal Server Error" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + workflow_id = 456 + dataset_identifier = "dataset-identifier" + resource_id = 789 + title = "Test Catalogue Item" + with pytest.raises( + RuntimeError, match="Catalogue Item retrieval failed: Internal Server Error" + ): + create_or_return_catalogue_item_in_rems( + organization_id, + workflow_id, + dataset_identifier, + resource_id, + title, + rems_base_url, + headers, + ) + + +# Test when catalogue item creation fails with a non-200 status code +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_catalogue_item_in_rems_creation_fails( + mock_load_json, mock_get, mock_post +): + # Mock the GET request to return a 200 status code with no catalogue items + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base catalogue item structure + mock_load_json.return_value = { + "organization": {"organization/id": ""}, + "resid": "", + "wfid": "", + "localizations": {"en": {"title": ""}}, + } + + # Mock the POST request to return a non-200 status code + mock_post.return_value.status_code = 400 + mock_post.return_value.text = "Bad Request" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + workflow_id = 456 + dataset_identifier = "dataset-identifier" + resource_id = 789 + title = "Test Catalogue Item" + with pytest.raises( + RuntimeError, match="Catalogue Item creation failed: Bad Request" + ): + create_or_return_catalogue_item_in_rems( + organization_id, + workflow_id, + dataset_identifier, + resource_id, + title, + rems_base_url, + headers, + ) diff --git a/tests/test_create_or_return_form_in_rems.py b/tests/test_create_or_return_form_in_rems.py new file mode 100644 index 0000000..762e499 --- /dev/null +++ b/tests/test_create_or_return_form_in_rems.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +import json +import pytest +import hashlib +from unittest.mock import patch +from rems import create_or_return_form_in_rems + + +# Test when the form already exists in REMS +@patch("requests.get") +def test_create_or_return_form_in_rems_exists(mock_get): + # Mock the GET request to return a response with an existing form + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [ + {"form/id": 123, "organization": {"organization/id": "test-org-id"}} + ] + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + form_id = create_or_return_form_in_rems(organization_id, rems_base_url, headers) + + # Assert the function returns the correct form ID + assert form_id == 123 + + # Assert the GET request was made correctly + mock_get.assert_called_once_with( + url=f"{rems_base_url}/api/forms?disabled=false&archived=false", + headers=headers, + ) + + +# Test when the form does not exist and needs to be created +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_form_in_rems_create(mock_load_json, mock_get, mock_post): + # Mock the GET request to return a 200 status code with no forms + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base form structure + mock_load_json.return_value = { + "form/external-title": {"en": "Test Form"}, + "organization": {"organization/id": "test-org-id"}, + } + + # Mock the POST request to return a 200 status code and a form ID + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"id": 456} + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + form_id = create_or_return_form_in_rems(organization_id, rems_base_url, headers) + + # Assert the function returns the newly created form ID + assert form_id == 456 + + # Assert the POST request was made correctly + base_form = { + "form/external-title": {"en": "Test Form"}, + "organization": {"organization/id": organization_id}, + } + base_form_str = json.dumps(base_form) + form_checksum = hashlib.md5(base_form_str.encode()).hexdigest() + internal_name = f"Test Form - {form_checksum}" + + expected_form = base_form.copy() + expected_form["form/internal-name"] = internal_name + mock_post.assert_called_once_with( + url=f"{rems_base_url}/api/forms/create", + json=expected_form, + headers=headers, + ) + + +# Test when form retrieval fails with a non-200 status code +@patch("requests.get") +def test_create_or_return_form_in_rems_retrieval_fails(mock_get): + # Mock the GET request to return a non-200 status code + mock_get.return_value.status_code = 500 + mock_get.return_value.text = "Internal Server Error" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + with pytest.raises( + RuntimeError, match="Workflow retrieval failed: Internal Server Error" + ): + create_or_return_form_in_rems(organization_id, rems_base_url, headers) + + +# Test when form creation fails with a non-200 status code +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_form_in_rems_creation_fails( + mock_load_json, mock_get, mock_post +): + # Mock the GET request to return a 200 status code with no forms + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base form structure + mock_load_json.return_value = { + "form/external-title": {"en": "Test Form"}, + "organization": {"organization/id": ""}, + } + + # Mock the POST request to return a non-200 status code + mock_post.return_value.status_code = 400 + mock_post.return_value.text = "Bad Request" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + with pytest.raises(RuntimeError, match="Workflow creation failed: Bad Request"): + create_or_return_form_in_rems(organization_id, rems_base_url, headers) diff --git a/tests/test_create_or_return_organization_in_rems.py b/tests/test_create_or_return_organization_in_rems.py new file mode 100644 index 0000000..d841a20 --- /dev/null +++ b/tests/test_create_or_return_organization_in_rems.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +import pytest +import hashlib +from unittest.mock import patch, mock_open +from rems import load_json, create_or_return_organization_in_rems + + +# Test for load_json +@patch("builtins.open", new_callable=mock_open, read_data='{"key": "value"}') +def test_load_json(mock_file): + # Call the function with a mock path + path = "mock_path.json" + result = load_json(path) + + # Assert that the file was opened correctly and the result is as expected + mock_file.assert_called_once_with(path, "r") + assert result == {"key": "value"} + + +# Test for create_or_return_organization_in_rems when the organization already exists +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_organization_in_rems_exists(mock_load_json, mock_get): + # Mocking load_json to return specific content + mock_load_json.return_value = {"organization/name": {"en": "Test Organization"}} + + # Mock the get request to return a 200 status code (organization exists) + mock_get.return_value.status_code = 200 + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = create_or_return_organization_in_rems(rems_base_url, headers) + + # Assert that the function returns the correct organization_id + expected_id = hashlib.md5("Test Organization".encode()).hexdigest() + assert organization_id == expected_id + + # Assert that the GET request was made correctly + mock_get.assert_called_once_with( + url=f"{rems_base_url}/api/organizations/{expected_id}", + headers=headers, + ) + + +# Test for create_or_return_organization_in_rems when the organization does not exist and is created +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_organization_in_rems_create( + mock_load_json, mock_get, mock_post +): + # Mocking load_json to return specific content + mock_load_json.return_value = {"organization/name": {"en": "Test Organization"}} + + # Mock the get request to return a 404 status code (organization does not exist) + mock_get.return_value.status_code = 404 + + # Mock the post request to return a 200 status code (organization created successfully) + mock_post.return_value.status_code = 200 + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = create_or_return_organization_in_rems(rems_base_url, headers) + + # Assert that the function returns the correct organization_id + expected_id = hashlib.md5("Test Organization".encode()).hexdigest() + assert organization_id == expected_id + + # Assert that the POST request was made correctly + expected_payload = { + "organization/name": {"en": "Test Organization"}, + "organization/id": expected_id, + } + mock_post.assert_called_once_with( + url=f"{rems_base_url}/api/organizations/create", + json=expected_payload, + headers=headers, + ) + + +# Test for create_or_return_organization_in_rems when retrieval fails for an unexpected reason +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_organization_in_rems_retrieval_fails( + mock_load_json, mock_get +): + # Mocking load_json to return specific content + mock_load_json.return_value = {"organization/name": {"en": "Test Organization"}} + + # Mock the get request to return a non-404 error status code (e.g., 500) + mock_get.return_value.status_code = 500 + mock_get.return_value.text = "Internal Server Error" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + with pytest.raises( + RuntimeError, match="Organization retrieval failed: Internal Server Error" + ): + create_or_return_organization_in_rems(rems_base_url, headers) + + +# Test for create_or_return_organization_in_rems when creation fails +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_organization_in_rems_creation_fails( + mock_load_json, mock_get, mock_post +): + # Mocking load_json to return specific content + mock_load_json.return_value = {"organization/name": {"en": "Test Organization"}} + + # Mock the get request to return a 404 status code (organization does not exist) + mock_get.return_value.status_code = 404 + + # Mock the post request to return a non-200 status code (e.g., 400) + mock_post.return_value.status_code = 400 + mock_post.return_value.text = "Bad Request" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + with pytest.raises(RuntimeError, match="Organization creation failed: Bad Request"): + create_or_return_organization_in_rems(rems_base_url, headers) diff --git a/tests/test_create_or_return_resource_in_rems.py b/tests/test_create_or_return_resource_in_rems.py new file mode 100644 index 0000000..f81a79b --- /dev/null +++ b/tests/test_create_or_return_resource_in_rems.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) +import pytest +from unittest.mock import patch +from rems import create_or_return_resource_in_rems + + +# Test when the resource already exists in REMS +@patch("requests.get") +def test_create_or_return_resource_in_rems_exists(mock_get): + # Mock the GET request to return a response with an existing resource + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [ + { + "id": "resource-123", + "resid": "dataset-identifier", + "organization": {"organization/id": "test-org-id"}, + } + ] + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + dataset_identifier = "dataset-identifier" + resource_id = create_or_return_resource_in_rems( + organization_id, dataset_identifier, rems_base_url, headers + ) + + # Assert the function returns the correct resource ID + assert resource_id == "resource-123" + + # Assert the GET request was made correctly + mock_get.assert_called_once_with( + url=f"{rems_base_url}/api/resources?disabled=false&archived=false&resid={dataset_identifier}", + headers=headers, + ) + + +# Test when the resource does not exist and needs to be created +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_resource_in_rems_create(mock_load_json, mock_get, mock_post): + # Mock the GET request to return a 200 status code with no resources + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base resource structure + mock_load_json.return_value = {"organization": {"organization/id": ""}, "resid": ""} + + # Mock the POST request to return a 200 status code and a resource ID + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"id": "resource-456"} + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + dataset_identifier = "dataset-identifier" + resource_id = create_or_return_resource_in_rems( + organization_id, dataset_identifier, rems_base_url, headers + ) + + # Assert the function returns the newly created resource ID + assert resource_id == "resource-456" + + # Assert the POST request was made correctly + expected_resource = { + "organization": {"organization/id": organization_id}, + "resid": dataset_identifier, + } + mock_post.assert_called_once_with( + url=f"{rems_base_url}/api/resources/create", + json=expected_resource, + headers=headers, + ) + + +# Test when resource retrieval fails with a non-200 status code +@patch("requests.get") +def test_create_or_return_resource_in_rems_retrieval_fails(mock_get): + # Mock the GET request to return a non-200 status code + mock_get.return_value.status_code = 500 + mock_get.return_value.text = "Internal Server Error" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + dataset_identifier = "dataset-identifier" + with pytest.raises( + RuntimeError, match="Resource retrieval failed: Internal Server Error" + ): + create_or_return_resource_in_rems( + organization_id, dataset_identifier, rems_base_url, headers + ) + + +# Test when resource creation fails with a non-200 status code +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_resource_in_rems_creation_fails( + mock_load_json, mock_get, mock_post +): + # Mock the GET request to return a 200 status code with no resources + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base resource structure + mock_load_json.return_value = {"organization": {"organization/id": ""}, "resid": ""} + + # Mock the POST request to return a non-200 status code + mock_post.return_value.status_code = 400 + mock_post.return_value.text = "Bad Request" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + dataset_identifier = "dataset-identifier" + with pytest.raises(RuntimeError, match="Resource creation failed: Bad Request"): + create_or_return_resource_in_rems( + organization_id, dataset_identifier, rems_base_url, headers + ) diff --git a/tests/test_create_or_return_workflow_in_rems.py b/tests/test_create_or_return_workflow_in_rems.py new file mode 100644 index 0000000..23b78b1 --- /dev/null +++ b/tests/test_create_or_return_workflow_in_rems.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2024 PNED G.I.E. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +import pytest +from unittest.mock import patch +from rems import create_or_return_workflow_in_rems + + +# Test when the workflow already exists in REMS +@patch("requests.get") +def test_create_or_return_workflow_in_rems_exists(mock_get): + # Mock the GET request to return a response with an existing workflow + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [ + {"id": 123, "organization": {"organization/id": "test-org-id"}} + ] + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + form_id = 456 + workflow_id = create_or_return_workflow_in_rems( + organization_id, form_id, rems_base_url, headers + ) + + # Assert the function returns the correct workflow ID + assert workflow_id == 123 + + # Assert the GET request was made correctly + mock_get.assert_called_once_with( + url=f"{rems_base_url}/api/workflows?disabled=false&archived=false", + headers=headers, + ) + + +# Test when the workflow does not exist and needs to be created +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_workflow_in_rems_create(mock_load_json, mock_get, mock_post): + # Mock the GET request to return a 200 status code with no workflows + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base workflow structure + mock_load_json.return_value = { + "organization": {"organization/id": ""}, + "forms": [{"form/id": ""}], + } + + # Mock the POST request to return a 200 status code and a workflow ID + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"id": 789} + + # Call the function with mock parameters + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + form_id = 456 + workflow_id = create_or_return_workflow_in_rems( + organization_id, form_id, rems_base_url, headers + ) + + # Assert the function returns the newly created workflow ID + assert workflow_id == 789 + + # Assert the POST request was made correctly + expected_workflow = { + "organization": {"organization/id": organization_id}, + "forms": [{"form/id": form_id}], + } + mock_post.assert_called_once_with( + url=f"{rems_base_url}/api/workflows/create", + json=expected_workflow, + headers=headers, + ) + + +# Test when workflow retrieval fails with a non-200 status code +@patch("requests.get") +def test_create_or_return_workflow_in_rems_retrieval_fails(mock_get): + # Mock the GET request to return a non-200 status code + mock_get.return_value.status_code = 500 + mock_get.return_value.text = "Internal Server Error" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + form_id = 456 + with pytest.raises( + RuntimeError, match="Workflow retrieval failed: Internal Server Error" + ): + create_or_return_workflow_in_rems( + organization_id, form_id, rems_base_url, headers + ) + + +# Test when workflow creation fails with a non-200 status code +@patch("requests.post") +@patch("requests.get") +@patch("rems.load_json") +def test_create_or_return_workflow_in_rems_creation_fails( + mock_load_json, mock_get, mock_post +): + # Mock the GET request to return a 200 status code with no workflows + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [] + + # Mock the JSON file loading to return a base workflow structure + mock_load_json.return_value = { + "organization": {"organization/id": ""}, + "forms": [{"form/id": ""}], + } + + # Mock the POST request to return a non-200 status code + mock_post.return_value.status_code = 400 + mock_post.return_value.text = "Bad Request" + + # Call the function with mock parameters and expect a RuntimeError + rems_base_url = "http://mock-rems-instance.com" + headers = {"Authorization": "Bearer mock_token"} + organization_id = "test-org-id" + form_id = 456 + with pytest.raises(RuntimeError, match="Workflow creation failed: Bad Request"): + create_or_return_workflow_in_rems( + organization_id, form_id, rems_base_url, headers + )