diff --git a/.env.example b/.env.example index 8469ca8..a8297d8 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ RSPACE_URL= -RSPACE_API_KEY= \ No newline at end of file +RSPACE_API_KEY= +RSPACE_SUPPORTS_LINK_FIELDS= + diff --git a/.github/scripts/warmup_sysadmin.py b/.github/scripts/warmup_sysadmin.py new file mode 100644 index 0000000..610603a --- /dev/null +++ b/.github/scripts/warmup_sysadmin.py @@ -0,0 +1,48 @@ +import os +import sys +import time + +import requests + +RSPACE_URL = os.environ["RSPACE_URL"] + +# Public, non-secret bootstrap credentials seeded by rspace-web's own "dev-test" +# Liquibase context (initial-seed-run.sql / initial-seed-devtest.sql) for the +# built-in sysadmin1 account. +SYSADMIN_USERNAME = "sysadmin1" +SYSADMIN_PASSWORD = "sysWisc23!" + +# A user account that is only ever authenticated via its API key (never a real +# login) has its home folder created lazily on first write - a code path that +# throws on that first attempt (though it leaves the account usable afterward). +# A real login runs the same initialization synchronously and correctly, so a +# single login here avoids ever hitting that first-write bug during the test run. +def warm_up(timeout_seconds=300, interval_seconds=5): + deadline = time.monotonic() + timeout_seconds + session = requests.Session() + last_error = None + while time.monotonic() < deadline: + try: + session.get(f"{RSPACE_URL}/login", timeout=10) + resp = session.post( + f"{RSPACE_URL}/login", + data={"username": SYSADMIN_USERNAME, "password": SYSADMIN_PASSWORD}, + timeout=10, + allow_redirects=True, + ) + if resp.status_code == 200 and "loginForm" not in resp.text: + return + last_error = f"HTTP {resp.status_code}, login form still present" + except requests.exceptions.RequestException as exc: + last_error = str(exc) + print(f"Login not successful yet ({last_error}); retrying...", file=sys.stderr) + time.sleep(interval_seconds) + raise RuntimeError(f"Could not log in as {SYSADMIN_USERNAME}: {last_error}") + + +if __name__ == "__main__": + try: + warm_up() + except Exception as exc: + print(f"Error warming up sysadmin1 account: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/.github/workflows/codeql-and-tests.yml b/.github/workflows/codeql-and-tests.yml index 77ae1db..09003fa 100644 --- a/.github/workflows/codeql-and-tests.yml +++ b/.github/workflows/codeql-and-tests.yml @@ -1,14 +1,20 @@ -name: CodeQL and Unit Test +name: codeQL and Test on: + push: + branches: [ master ] pull_request: branches: [ master ] workflow_dispatch: # allow manual trigger +permissions: + contents: read + jobs: analyze: name: CodeQL Analyze - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 20 permissions: actions: read contents: read @@ -20,35 +26,203 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 test: - name: Unit Test - runs-on: ubuntu-latest + name: unit test + runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: analyze + permissions: + contents: read + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Poetry - run: | - curl -sSL https://install.python-poetry.org | python3 - - echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.8.4 + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Run unit tests + run: poetry run pytest -m "not integration" + + integration-test: + name: integration test + runs-on: ubuntu-24.04 + timeout-minutes: 45 + needs: test + permissions: + contents: read + env: + RS_FILE_BASE: /tmp/rspace-filestore + services: + db: + image: mariadb:lts-jammy + env: + MARIADB_ROOT_PASSWORD: rspacedbpwd + MARIADB_DATABASE: rspace + MARIADB_USER: rspacedbuser + MARIADB_PASSWORD: rspacedbpwd + ports: + - 3306:3306 + options: >- + --tmpfs /var/lib/mysql:rw,noexec,nosuid,size=2g + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=40 + --health-start-period=30s + # Required even though these are API-only tests: some backend startup + # paths check that the chemistry service is reachable. + chemistry: + image: rspaceops/oss-chemistry:latest + ports: + - 8090:8090 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.8.4 + virtualenvs-create: true + virtualenvs-in-project: true - name: Install dependencies - run: poetry install + run: poetry install --no-interaction + + - name: Determine latest rspace-web release tag + id: rspace_web_release + run: | + set -euo pipefail + latest_tag=$(curl -fsSL --retry 3 --retry-delay 2 https://api.github.com/repos/rspace-os/rspace-web/releases/latest | jq -r '.tag_name') + if [ -z "$latest_tag" ] || [ "$latest_tag" = "null" ]; then + echo "Unable to determine latest release tag for rspace-web" + exit 1 + fi + echo "Latest rspace-web tag: $latest_tag" + echo "tag=$latest_tag" >> "$GITHUB_OUTPUT" + + - name: Checkout rspace-web + uses: actions/checkout@v4 + with: + repository: rspace-os/rspace-web + ref: ${{ steps.rspace_web_release.outputs.tag }} + path: rspace-web + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + cache-dependency-path: rspace-web/pom.xml + + - name: Configure MariaDB + run: | + mysql -h127.0.0.1 -uroot -prspacedbpwd <<'SQL' + SET GLOBAL character_set_server = 'utf8mb4'; + SET GLOBAL collation_server = 'utf8mb4_unicode_ci'; + SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; + GRANT CREATE, DROP ON *.* TO 'rspacedbuser'@'%'; + FLUSH PRIVILEGES; + SQL + + - name: Setup filestore + run: | + rm -rf "$RS_FILE_BASE" + mkdir -p "$RS_FILE_BASE" + + # Backend only - no -DgenerateReactDist, so no Node/pnpm/Vite build. + # The Python client only exercises the REST API, not the UI. + - name: Build RSpace backend + working-directory: rspace-web + run: ./mvnw clean package -DskipTests -Dtimestamp=${{ github.run_id }} -Djava-version=17 -Djava-vendor=temurin + + - name: Start RSpace and wait for readiness + working-directory: rspace-web + timeout-minutes: 15 + run: | + set -euo pipefail + nohup ./mvnw jetty:run-war \ + -DskipTests=true -Dmaven.war.skip=true \ + -Dtimestamp=${{ github.run_id }} \ + -Djava-version=17 -Djava-vendor=temurin \ + -Denvironment=drop-recreate-db \ + -Dspring.profiles.active=run \ + -DreactDevMode=false \ + -Dchemistry.provider=indigo \ + -Dchemistry.service.url=http://localhost:8090 \ + -Djdbc.url=jdbc:mysql://localhost:3306/rspace \ + -Djdbc.db.maven=rspace \ + -DRS_FILE_BASE="$RS_FILE_BASE" \ + > /tmp/jetty.log 2>&1 & + for i in $(seq 1 180); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/login || true)" = "200" ]; then + echo "Ready after ~$((i*5))s" + exit 0 + fi + sleep 5 + done + echo "RSpace did not become ready in time" + tail -200 /tmp/jetty.log + exit 1 + + # sysadmin1's account is a raw SQL fixture row - authenticating purely via + # its API key without ever logging in hits a lazy-initialization bug on + # its first write (home folder isn't created yet). One real login avoids + # it entirely by running the same initialization synchronously and correctly. + - name: Warm up sysadmin1 account + timeout-minutes: 5 + run: poetry run python .github/scripts/warmup_sysadmin.py + env: + RSPACE_URL: http://localhost:8080 + + - name: Run integration tests + timeout-minutes: 20 + env: + RSPACE_URL: http://localhost:8080 + # Bootstrap sysadmin1 API key seeded by rspace-web's own "dev-test" + # Liquibase context (initial-seed-devtest.sql). Public, non-secret + # dev fixture value - not a credential that protects anything + # outside this ephemeral, job-local RSpace instance. + RSPACE_API_KEY: abcdefghijklmnop12 + run: poetry run pytest -m integration -v - - name: Run Unit Test - run: poetry run pytest rspace_client/tests + - name: Dump RSpace server log on failure + if: failure() + run: tail -300 /tmp/jetty.log || true \ No newline at end of file diff --git a/DEVELOPING.md b/DEVELOPING.md index 1152ce0..bef4d7a 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -1,6 +1,6 @@ ## Development -Python 3.7 or later is required. We aim to support only active versions of Python. +Python 3.9 or later is required. We aim to support only active versions of Python. ### Setup @@ -21,23 +21,36 @@ to install all project dependencies into your virtual environment. ### Running tests -Tests are a mixture of plain unit tests and integration tests making calls to an RSpace server. -To run all tests, set these environment variables,replacing with your own values +Tests are a mixture of plain unit tests and integration tests that make calls to a live RSpace server. + +#### Unit tests only ``` -bash> export RSPACE_URL=https:/ -bash> export RSPACE_API_KEY=abcdefgh... +poetry run pytest -m "not integration" ``` -If these aren't set, integration tests will be skipped. +#### Integration tests + +Integration tests require credentials for a live RSpace instance. Create a `.env` file in the project root: + +``` +RSPACE_URL=https:// +RSPACE_API_KEY= +``` -Tests can be invoked: +Then run: ``` -poetry run pytest rspace_client/tests +poetry run pytest -m integration ``` -They should be run with a new RSpace account that does not belong to any groups. +Integration tests should be run with a new RSpace account that does not belong to any groups. + +#### How CI runs integration tests + +CI doesn't use a long-lived RSpace deployment or your credentials. Each run builds `rspace-web` from source and starts it fresh (Maven/Jetty, seeded database). That gives a known built-in `sysadmin1` account and API key (seeded by rspace-web's own dev/test fixtures), but authenticating purely via API key without ever logging in hits a lazy-initialization bug on that account's first write (its home folder isn't created yet). So CI does one plain HTTP login first (`.github/scripts/warmup_sysadmin.py`), which runs the same initialization correctly, then uses the account's API key directly for the whole suite. See `.github/workflows/codeql-and-tests.yml`. + +If you want to reproduce this locally against your own from-source RSpace build (rather than any existing account), log in once as `sysadmin1` / `sysWisc23!` (e.g. run `warmup_sysadmin.py` against your instance) before pointing `RSPACE_API_KEY` at `abcdefghijklmnop12` in your `.env` - otherwise the first document-creation call will 500. ### Writing Tests diff --git a/README.md b/README.md index f03351b..28a4076 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,22 @@ Interactive notebooks in [`jupyter_notebooks/`](jupyter_notebooks): (`proteins.csv` and `temp_data.csv` in the same folder are sample data used by these notebooks.) +## Testing + +CI (`.github/workflows/codeql-and-tests.yml`) runs unit tests across supported Python versions, then integration tests against a real RSpace instance: it builds `rspace-web` from source and starts it with a freshly seeded database (no Docker image, no browser automation), logs in once to initialize the built-in test account, then runs the suite against it. + +To run tests locally: + +```bash +# unit tests only +poetry run pytest -m "not integration" + +# integration tests, against your own RSpace instance +export RSPACE_URL=https:// +export RSPACE_API_KEY= +poetry run pytest -m integration +``` + ## Community projects See what others have built on top of RSpace and RSpace Python SDK on the **[Community Projects](https://github.com/rspace-os/community/blob/main/community-projects/community-projects.md)** page. Built something with this client? Share it in an [office hour](https://github.com/rspace-os/community/blob/main/the%20rspace%20project/Guide/calendar.md) or open a PR to add it. diff --git a/pyproject.toml b/pyproject.toml index 0f848a8..ed05d22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,14 @@ setuptools = "<82" [tool.poetry.group.dev.dependencies] python-dotenv = "^1.1.1" black = "^21.6b0" -pytest = "^6.2.4" +pytest = "^8.0.0" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +testpaths = ["rspace_client/tests"] +markers = [ + "integration: marks tests as integration tests requiring a live RSpace server (deselect with '-m not integration')", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index de19c9f..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -testpaths = tests \ No newline at end of file diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py index fbf9479..5ea0b9d 100644 --- a/rspace_client/inv/inv.py +++ b/rspace_client/inv/inv.py @@ -980,7 +980,8 @@ def __init__( subsample_count: int = None, total_quantity: Quantity = None, attachments=None, - barcodes: Optional[List[Barcode]] = None + barcodes: Optional[List[Barcode]] = None, + location: "TargetLocation" = None, ): super().__init__(name, "SAMPLE", tags, description, extra_fields) ## converts arguments into JSON POST syntax @@ -999,10 +1000,11 @@ def __init__( self.data["templateId"] = sample_template_id if fields is not None: self.data["fields"] = fields + if location is not None: + self.data.update(location.data) ## fail early - if attachments is not None: - if not isinstance(attachments, list): - raise ValueError("attachments must be a list of open files") + if attachments is not None and not isinstance(attachments, list): + raise ValueError("attachments must be a list of open files") if barcodes is not None: self.data["barcodes"] = [barcode.to_dict() for barcode in barcodes] @@ -2859,88 +2861,107 @@ def barcode( fd.write(content) return content - def get_datacite_settings(self) -> dict: + @staticmethod + def _find_identifier_provider_settings(result: dict, provider: str) -> dict: + for group in result.get("identifiersSettings", {}).values(): + for entry in group: + if entry.get("provider") == provider: + return entry + raise ValueError(f"No settings found for provider '{provider}' in /system/settings response") + + def get_datacite_settings(self, provider: str = "IGSN_DATACITE") -> dict: """ - Gets the current DataCite settings. + Gets the current settings for the given identifier provider. + + Parameters + ---------- + provider : str, optional + One of "IGSN_DATACITE", "PIDINST_DATACITE", "PIDINST_B2INST". Defaults to "IGSN_DATACITE". Returns ------- dict - The current DataCite settings + The current settings for the given provider """ - return self.retrieve_api_results( - "/system/settings", - request_type="GET" - ) + result = self.retrieve_api_results("/system/settings", request_type="GET") + return self._find_identifier_provider_settings(result, provider) - def update_datacite_settings(self, enabled: bool, server_url: str = None, username: str = None, password: str = None, repository_prefix: str = None) -> dict: + def update_datacite_settings( + self, + enabled: bool, + provider: str = "IGSN_DATACITE", + server_url: str = None, + username: str = None, + password: str = None, + repository_prefix: str = None, + ) -> dict: """ - Updates the DataCite settings. + Updates the settings for the given identifier provider. Parameters ---------- enabled : bool - Whether DataCite is enabled (True or False) + Whether this provider is enabled (True or False) + provider : str, optional + One of "IGSN_DATACITE", "PIDINST_DATACITE", "PIDINST_B2INST". Defaults to "IGSN_DATACITE". server_url : str, optional - DataCite server URL. Required when enabled=True + Server URL. Required when enabled=True username : str, optional - DataCite username. Required when enabled=True + Username. Required when enabled=True password : str, optional - DataCite password. Required when enabled=True + Password. Required when enabled=True repository_prefix : str, optional - DataCite repository prefix. Required when enabled=True + Repository prefix. Required when enabled=True Returns ------- dict - The updated settings + The updated settings for the given provider """ if enabled and (server_url is None or username is None or password is None or repository_prefix is None): raise ValueError("server_url, username, password, and repository_prefix are required when enabled=True") - - settings = { - "datacite": { - "enabled": str(enabled).lower() - } - } - - # Only include other settings if enabling DataCite - if enabled: - settings["datacite"].update({ - "serverUrl": server_url, - "username": username, - "password": password, - "repositoryPrefix": repository_prefix - }) - - return self.retrieve_api_results( + + body = {"provider": provider, "enabled": str(enabled).lower()} + if server_url is not None: + body["serverUrl"] = server_url + if username is not None: + body["username"] = username + if password is not None: + body["password"] = password + if repository_prefix is not None: + body["repositoryPrefix"] = repository_prefix + + result = self.retrieve_api_results( "/system/settings", request_type="PUT", - params=settings + params=body ) + return self._find_identifier_provider_settings(result, provider) - def test_datacite_connection(self) -> bool: + def test_datacite_connection(self, provider: str = "IGSN_DATACITE") -> bool: """ - Tests the connection to the configured DataCite server. + Tests the connection to the configured server for the given identifier provider. - This method calls the DataCite test connection endpoint to verify that: - - DataCite client is properly configured and initialized - - The connection to the DataCite API server can be established - - The stored credentials are valid + Parameters + ---------- + provider : str, optional + One of "IGSN_DATACITE", "PIDINST_DATACITE", "PIDINST_B2INST". Defaults to "IGSN_DATACITE". Returns ------- bool True if the connection test passes, False otherwise """ - - url = self._get_api_url() + "/identifiers/testDataCiteConnection" + endpoint_name = "testIgsnConnection" if provider == "IGSN_DATACITE" else "testPidinstConnection" + url = self._get_api_url() + f"/identifiers/{endpoint_name}" headers = self._get_headers("application/json") try: response = requests.get(url, headers=headers) - return response.status_code == 200 - except requests.exceptions.RequestException: + if response.status_code != 200: + return False + return bool(response.json()) + except (requests.exceptions.RequestException, ValueError): return False def _calculate_start_index( diff --git a/rspace_client/tests/elnapi_test.py b/rspace_client/tests/elnapi_test.py index 0ddff33..1f6cc81 100755 --- a/rspace_client/tests/elnapi_test.py +++ b/rspace_client/tests/elnapi_test.py @@ -6,6 +6,9 @@ @author: richard """ import os, os.path +import tempfile + +import pytest import rspace_client.eln.eln as cli from rspace_client.eln.dcs import DocumentCreationStrategy @@ -15,6 +18,7 @@ from rspace_client.client_base import Pagination +@pytest.mark.integration class ELNClientAPIIntegrationTest(BaseApiTest): def setUp(self): self.assertClientCredentials() @@ -26,29 +30,34 @@ def test_get_status(self): def test_upload_downloadfile(self): file = get_datafile("fish_method.doc") + with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp: + tmp_path = tmp.name try: with open(file, "rb") as to_upload: rs_file = self.api.upload_file(to_upload) - rs_get = self.api.download_file(rs_file["id"], "out.doc") + self.api.download_file(rs_file["id"], tmp_path) finally: - os.remove(os.path.join(os.getcwd(), "out.doc")) + if os.path.exists(tmp_path): + os.remove(tmp_path) def test_get_documents(self): + self.api.create_document(name=random_string(10)) resp = self.api.get_documents() self.assertTrue(resp["totalHits"] > 0) self.assertTrue(len(resp["documents"]) > 0) def test_stream_documents(self): + self.api.create_document(name=random_string(10)) + self.api.create_document(name=random_string(10)) doc_gen = self.api.stream_documents(pagination=Pagination(page_size=1)) d1 = next(doc_gen) d2 = next(doc_gen) self.assertNotEqual(d1["id"], d2["id"]) def test_get_documents_by_id(self): - resp = self.api.get_documents() - first_id = resp["documents"][0]["id"] - doc = self.api.get_document(first_id) - self.assertEqual(first_id, doc["id"]) + created = self.api.create_document(name=random_string(10)) + doc = self.api.get_document(created["id"]) + self.assertEqual(created["id"], doc["id"]) def test_create_document(self): nameStr = random_string(10) @@ -59,22 +68,25 @@ def test_create_document(self): def test_import_tree(self): tree_dir = get_datafile("tree") - res = self.api.import_tree(tree_dir) + folder = self.api.create_folder("tree-" + random_string(6)) + res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"]) self.assertEqual("OK", res["status"]) ## f, 2sf, and 3files in each sf self.assertEqual(9, len(res["path2Id"].keys())) def test_import_tree_include_dot_files(self): tree_dir = get_datafile("tree") - res = self.api.import_tree(tree_dir, ignore_hidden_folders=False) + folder = self.api.create_folder("tree-" + random_string(6)) + res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"], ignore_hidden_folders=False) self.assertEqual("OK", res["status"]) ## f, 2sf, and 3files in each sf + hidden self.assertTrue(len(res["path2Id"].keys()) >= 9) def test_import_tree_summary_doc_only(self): tree_dir = get_datafile("tree") + folder = self.api.create_folder("tree-" + random_string(6)) res = self.api.import_tree( - tree_dir, doc_creation=DocumentCreationStrategy.SUMMARY_DOC + tree_dir, parent_folder_id=folder["id"], doc_creation=DocumentCreationStrategy.SUMMARY_DOC ) self.assertEqual("OK", res["status"]) ## original folder + summary doc @@ -82,15 +94,16 @@ def test_import_tree_summary_doc_only(self): def test_import_tree_summary_doc_per_subfolder(self): tree_dir = get_datafile("tree") + folder = self.api.create_folder("tree-" + random_string(6)) res = self.api.import_tree( - tree_dir, doc_creation=DocumentCreationStrategy.DOC_PER_SUBFOLDER + tree_dir, parent_folder_id=folder["id"], doc_creation=DocumentCreationStrategy.DOC_PER_SUBFOLDER ) self.assertEqual("OK", res["status"]) ## original folder + 2 sf + 2 summary docs self.assertEqual(5, len(res["path2Id"].keys())) def test_import_tree_into_subfolder(self): - folder = self.api.create_folder("tree-root") + folder = self.api.create_folder("tree-root-" + random_string(6)) tree_dir = get_datafile("tree") res = self.api.import_tree(tree_dir, parent_folder_id=folder["id"]) self.assertEqual("OK", res["status"]) @@ -98,26 +111,31 @@ def test_import_tree_into_subfolder(self): self.assertEqual(9, len(res["path2Id"].keys())) def test_export_all_work_with_log_file(self): - file_path = "tmp-export.zip" - log_file = "tmp-log.txt" - try: + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f: + file_path = f.name + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + log_file = f.name + try: self.api.export_and_download("html", "user", file_path, progress_log=log_file, wait_between_requests=5) self.assertTrue(os.path.getsize(log_file) > 0) self.assertTrue(os.path.getsize(file_path) > 0) - except BaseException as e: - self.fail("Unexpected exception" + str(e)) + except Exception as e: + self.fail("Unexpected exception" + str(e)) finally: - os.remove(file_path) - os.remove(log_file) - + for p in (file_path, log_file): + if os.path.exists(p): + os.remove(p) + def test_export_all_work_with_no_file(self): - file_path = "tmp-export.zip" - try: - self.api.export_and_download("html", "user", file_path, wait_between_requests=5) - self.assertTrue(os.path.getsize(file_path) > 0) - except BaseException as e: - self.fail("Unexpected exception" + str(e)) - finally: - os.remove(file_path) + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f: + file_path = f.name + try: + self.api.export_and_download("html", "user", file_path, wait_between_requests=5) + self.assertTrue(os.path.getsize(file_path) > 0) + except Exception as e: + self.fail("Unexpected exception" + str(e)) + finally: + if os.path.exists(file_path): + os.remove(file_path) diff --git a/rspace_client/tests/inv_lom_test.py b/rspace_client/tests/inv_lom_test.py index 8c18cfc..fdc75d8 100644 --- a/rspace_client/tests/inv_lom_test.py +++ b/rspace_client/tests/inv_lom_test.py @@ -5,11 +5,14 @@ @author: richard """ +import pytest + import rspace_client.tests.base_test as base from rspace_client.inv import inv from rspace_client.eln import eln +@pytest.mark.integration class LomApiTest(base.BaseApiTest): def setUp(self): """ diff --git a/rspace_client/tests/invapi_test.py b/rspace_client/tests/invapi_test.py index ec66bac..dd0d883 100644 --- a/rspace_client/tests/invapi_test.py +++ b/rspace_client/tests/invapi_test.py @@ -18,6 +18,7 @@ from rspace_client.inv import quantity_unit as qu +@pytest.mark.integration class InventoryApiTest(base.BaseApiTest): def setUp(self): """ @@ -58,7 +59,7 @@ def test_create_sample(self): def test_set_image_sample(self): sample = self.invapi.create_sample(base.random_string(5)) - file = base.get_datafile("AntibodySample150.png") + file = base.get_datafile("antibodySample150.png") with open(file, "rb") as f: updated_sample = self.invapi.set_image(sample, f) @@ -111,10 +112,11 @@ def test_rename_item(self): self.assertEqual(new_name, updated["name"]) def test_list_samples(self): + self.invapi.create_sample(base.random_string(5)) samples = self.invapi.list_samples(inv.Pagination(sort_order="desc")) self.assertEqual(0, samples["pageNumber"]) - self.assertEqual(10, len(samples["samples"])) + self.assertGreaterEqual(len(samples["samples"]), 1) def test_add_note_to_subsample(self): note = " a note about a subsample " + base.random_string() @@ -125,6 +127,9 @@ def test_add_note_to_subsample(self): self.assertEqual(note, updated["notes"][0]["content"]) def test_paginated_samples(self): + # Create 2 samples so page 1 (0-indexed) has results + self.invapi.create_sample(base.random_string(5)) + self.invapi.create_sample(base.random_string(5)) pag = inv.Pagination(page_number=1, page_size=1, sort_order="desc") samples = self.invapi.list_samples(pag) self.assertEqual(1, samples["pageNumber"]) @@ -137,9 +142,10 @@ def test_paginated_containers(self): c = self.invapi.set_as_top_level_container(c) containers = self.invapi.list_top_level_containers(pag) self.assertEqual(0, containers["pageNumber"]) - self.assertEqual(1, len(containers["containers"])) + self.assertGreaterEqual(len(containers["containers"]), 1) def test_paginated_subsamples(self): + self.invapi.create_sample(base.random_string(5), subsample_count=1) pag = inv.Pagination(page_number=0, page_size=1) ss = self.invapi.list_subsamples(pag) self.assertEqual(0, ss["pageNumber"]) @@ -163,6 +169,9 @@ def test_stream_containers(self): self.assertEqual(c2["id"], c2_l["id"]) def test_stream_samples(self): + # Ensure at least 2 samples exist + self.invapi.create_sample(base.random_string(5)) + self.invapi.create_sample(base.random_string(5)) onePerPage = inv.Pagination(page_size=1) gen = self.invapi.stream_samples(onePerPage) # get 2 items @@ -287,7 +296,7 @@ def test_duplicate(self): def test_get_benches(self): benches = self.invapi.get_workbenches() - self.assertEqual(2, len(benches)) + self.assertGreaterEqual(len(benches), 1) bench_ob = inv.Container.of(benches[0]) self.assertTrue(bench_ob.is_workbench()) @@ -680,12 +689,21 @@ def test_calculate_grid_start_validation(self): def test_barcode(self): barcode_bytes = self.invapi.barcode("SA14567") - self.assertEqual(99, len(barcode_bytes)) + self.assertTrue(len(barcode_bytes) > 0) + self.assertTrue(barcode_bytes.startswith(b"\x89PNG\r\n\x1a\n")) - qr_bytes = self.invapi.barcode( - "SA12345", outfile="out10.png", barcode_type=inv.Barcode.QR - ) - self.assertEqual(293, len(qr_bytes)) + outfile = "out10.png" + try: + qr_bytes = self.invapi.barcode( + "SA12345", outfile=outfile, barcode_type=inv.BarcodeFormat.QR + ) + self.assertTrue(len(qr_bytes) > 0) + self.assertTrue(qr_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + self.assertTrue(os.path.exists(outfile)) + self.assertTrue(os.path.getsize(outfile) > 0) + finally: + if os.path.exists(outfile): + os.remove(outfile) def test_delete_samples(self): new_sample = self.invapi.create_sample("to_delete") @@ -908,7 +926,7 @@ def test_rename_instrument(self): def test_set_image_instrument(self): instrument = self.invapi.create_instrument(base.random_string(5)) - file = base.get_datafile("AntibodySample150.png") + file = base.get_datafile("antibodySample150.png") with open(file, "rb") as f: updated_instrument = self.invapi.set_image(instrument, f) @@ -985,28 +1003,24 @@ def test_update_datacite_settings_enabled(self): # Verify the response structure self.assertIsInstance(result, dict) - self.assertIn("datacite", result) - datacite_settings = result["datacite"] - self.assertEqual(datacite_settings["serverUrl"], server_url) - self.assertEqual(datacite_settings["username"], username) - self.assertEqual(datacite_settings["password"], password) - self.assertEqual(datacite_settings["repositoryPrefix"], repository_prefix) - self.assertEqual(datacite_settings["enabled"], "true") + self.assertEqual(result["serverUrl"], server_url) + self.assertEqual(result["username"], username) + self.assertEqual(result["password"], password) + self.assertEqual(result["repositoryPrefix"], repository_prefix) + self.assertEqual(result["enabled"], "true") finally: # Restore original settings - if "datacite" in original_settings: - orig_datacite = original_settings["datacite"] - if orig_datacite.get("enabled") == "true": - self.invapi.update_datacite_settings( - enabled=True, - server_url=orig_datacite.get("serverUrl", ""), - username=orig_datacite.get("username", ""), - password=orig_datacite.get("password", ""), - repository_prefix=orig_datacite.get("repositoryPrefix", "") - ) - else: - self.invapi.update_datacite_settings(enabled=False) + if original_settings.get("enabled") == "true": + self.invapi.update_datacite_settings( + enabled=True, + server_url=original_settings.get("serverUrl", ""), + username=original_settings.get("username", ""), + password=original_settings.get("password", ""), + repository_prefix=original_settings.get("repositoryPrefix", "") + ) + else: + self.invapi.update_datacite_settings(enabled=False) def test_update_datacite_settings_disabled(self): """ @@ -1020,23 +1034,20 @@ def test_update_datacite_settings_disabled(self): result = self.invapi.update_datacite_settings(enabled=False) self.assertIsInstance(result, dict) - self.assertIn("datacite", result) - self.assertEqual(result["datacite"]["enabled"], "false") + self.assertEqual(result["enabled"], "false") finally: # Restore original settings - if "datacite" in original_settings: - orig_datacite = original_settings["datacite"] - if orig_datacite.get("enabled") == "true": - self.invapi.update_datacite_settings( - enabled=True, - server_url=orig_datacite.get("serverUrl", ""), - username=orig_datacite.get("username", ""), - password=orig_datacite.get("password", ""), - repository_prefix=orig_datacite.get("repositoryPrefix", "") - ) - else: - self.invapi.update_datacite_settings(enabled=False) + if original_settings.get("enabled") == "true": + self.invapi.update_datacite_settings( + enabled=True, + server_url=original_settings.get("serverUrl", ""), + username=original_settings.get("username", ""), + password=original_settings.get("password", ""), + repository_prefix=original_settings.get("repositoryPrefix", "") + ) + else: + self.invapi.update_datacite_settings(enabled=False) def test_datacite_connection_with_valid_settings(self): """ @@ -1065,18 +1076,16 @@ def test_datacite_connection_with_valid_settings(self): finally: # Restore original settings - if "datacite" in original_settings: - orig_datacite = original_settings["datacite"] - if orig_datacite.get("enabled") == "true": - self.invapi.update_datacite_settings( - enabled=True, - server_url=orig_datacite.get("serverUrl", ""), - username=orig_datacite.get("username", ""), - password=orig_datacite.get("password", ""), - repository_prefix=orig_datacite.get("repositoryPrefix", "") - ) - else: - self.invapi.update_datacite_settings(enabled=False) + if original_settings.get("enabled") == "true": + self.invapi.update_datacite_settings( + enabled=True, + server_url=original_settings.get("serverUrl", ""), + username=original_settings.get("username", ""), + password=original_settings.get("password", ""), + repository_prefix=original_settings.get("repositoryPrefix", "") + ) + else: + self.invapi.update_datacite_settings(enabled=False) def test_datacite_connection_with_invalid_settings(self): """ @@ -1103,18 +1112,16 @@ def test_datacite_connection_with_invalid_settings(self): finally: # Restore original settings - if "datacite" in original_settings: - orig_datacite = original_settings["datacite"] - if orig_datacite.get("enabled") == "true": - self.invapi.update_datacite_settings( - enabled=True, - server_url=orig_datacite.get("serverUrl", ""), - username=orig_datacite.get("username", ""), - password=orig_datacite.get("password", ""), - repository_prefix=orig_datacite.get("repositoryPrefix", "") - ) - else: - self.invapi.update_datacite_settings(enabled=False) + if original_settings.get("enabled") == "true": + self.invapi.update_datacite_settings( + enabled=True, + server_url=original_settings.get("serverUrl", ""), + username=original_settings.get("username", ""), + password=original_settings.get("password", ""), + repository_prefix=original_settings.get("repositoryPrefix", "") + ) + else: + self.invapi.update_datacite_settings(enabled=False) def test_get_datacite_settings(self): """ @@ -1124,8 +1131,11 @@ def test_get_datacite_settings(self): # Should return a dictionary self.assertIsInstance(result, dict) - # Should contain datacite section (even if empty/default) - self.assertIn("datacite", result) + # Should contain the provider's settings fields + self.assertIn("provider", result) + self.assertIn("enabled", result) + self.assertIn("serverUrl", result) + self.assertIn("repositoryPrefix", result) def _require_link_field_support(self): """ diff --git a/rspace_client/tests/sample_post_test.py b/rspace_client/tests/sample_post_test.py new file mode 100644 index 0000000..be5f95b --- /dev/null +++ b/rspace_client/tests/sample_post_test.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import unittest + +from rspace_client.inv import inv + + +class SamplePostTest(unittest.TestCase): + def test_sample_post_without_location_has_no_parent_fields(self): + post = inv.SamplePost("sample") + + self.assertNotIn("parentContainers", post.data) + self.assertNotIn("parentLocation", post.data) + self.assertNotIn("removeFromParentContainerRequest", post.data) + + def test_sample_post_with_list_container_location(self): + location = inv.ListContainerTargetLocation(123) + post = inv.SamplePost("sample", location=location) + + self.assertEqual([{"id": 123}], post.data["parentContainers"]) + self.assertNotIn("parentLocation", post.data) + + def test_sample_post_with_grid_location(self): + location = inv.GridContainerTargetLocation(123, 2, 3) + post = inv.SamplePost("sample", location=location) + + self.assertEqual([{"id": 123}], post.data["parentContainers"]) + self.assertEqual({"coordX": 2, "coordY": 3}, post.data["parentLocation"]) + + def test_sample_post_with_barcode_format_included_when_set(self): + barcode = inv.Barcode( + data="SA123", + format=inv.BarcodeFormat.QR, + description="test", + newBarcodeRequest=True, + ) + post = inv.SamplePost("sample", barcodes=[barcode]) + + self.assertEqual( + [ + { + "data": "SA123", + "format": "QR", + "description": "test", + "newBarcodeRequest": True, + } + ], + post.data["barcodes"], + )