From ad7d3360cf032fa58e830b891b028f3a921d4220 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:53:51 -0500 Subject: [PATCH 01/11] add initial user journey test --- conda-store-server/pyproject.toml | 9 ++ .../tests/user_journeys/helpers/helpers.py | 145 ++++++++++++++++++ .../test_data/simple_environment.yaml | 6 + .../tests/user_journeys/test_user_journeys.py | 42 +++++ 4 files changed, 202 insertions(+) create mode 100644 conda-store-server/tests/user_journeys/helpers/helpers.py create mode 100644 conda-store-server/tests/user_journeys/test_data/simple_environment.yaml create mode 100644 conda-store-server/tests/user_journeys/test_user_journeys.py diff --git a/conda-store-server/pyproject.toml b/conda-store-server/pyproject.toml index 38cca0ed7..bdce874b6 100644 --- a/conda-store-server/pyproject.toml +++ b/conda-store-server/pyproject.toml @@ -80,6 +80,8 @@ playwright-test = [ ] integration-test = ["pytest ../tests/test_api.py ../tests/test_metrics.py"] +user-journey-test = ["pytest -m user-journey"] + [tool.hatch.build.hooks.custom] [tool.hatch.build.targets.sdist.hooks.custom] @@ -101,3 +103,10 @@ ignore = [ [tool.check-wheel-contents] # ignore alembic migrations https://github.com/jwodder/check-wheel-contents?tab=readme-ov-file#w004--module-is-not-located-at-importable-path ignore = ["W004"] + +[tool.pytest.ini_options] +markers = [ + "playwright: mark a test as a playwright test", + "integration: mark a test as an integration test", + "user-journey: mark a test as a user journey test", +] \ No newline at end of file diff --git a/conda-store-server/tests/user_journeys/helpers/helpers.py b/conda-store-server/tests/user_journeys/helpers/helpers.py new file mode 100644 index 000000000..feb1b0e14 --- /dev/null +++ b/conda-store-server/tests/user_journeys/helpers/helpers.py @@ -0,0 +1,145 @@ +"""Helper functions for user journeys.""" +from datetime import datetime, timedelta, timezone +from enum import Enum + +import requests + +TIMEOUT = 10 + + +class APIBuildStatus(Enum): + """Enum for API build status.""" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + QUEUED = "QUEUED" + BUILDING = "BUILDING" + + +class APIHelper: + """ + Helper class for making requests to the API. + These methods are used to build tests for user journeys + """ + + def __init__(self, + base_url: str, token: str = "", + username: str = "username", password: str = "password" + ) -> None: + self.base_url = base_url + self.token = token + if not token: + # Log in if no token is provided to set the token + self._login(username, password) + + def make_request( + self, endpoint: str, method: str = "GET", json_data: dict = None, + headers: dict = None, timeout: int = TIMEOUT) -> requests.Response: + """ Make a request to the API. """ + url = f"{self.base_url}/{endpoint}" + headers = headers or {} + headers["Authorization"] = f"Bearer {self.token}" + response = requests.request( + method, url, json=json_data, headers=headers, timeout=timeout) + response.raise_for_status() + return response + + def _login(self, username: str, password: str) -> None: + """ Log in to the API and set an access token.""" + json_data = {"username": username, "password": password} + response = requests.post( + f"{self.base_url}/login", json=json_data, timeout=TIMEOUT) + cookies = response.cookies.get_dict() + token_response = requests.post( + f"{self.base_url}/api/v1/token", cookies=cookies, timeout=TIMEOUT) + data = token_response.json() + self.token = data["data"]["token"] + + +def get_current_time() -> datetime: + """ Get the current time. """ + return datetime.now(timezone.utc) + + +def get_time_in_future(hours: int) -> datetime: + """ Get the time in the future.""" + current_time = get_current_time() + future_time = current_time + timedelta(hours=hours) + return future_time + + +def get_iso8601_time(hours: int) -> str: + """ Get the time in the future in ISO 8601 format. """ + future_time = get_time_in_future(hours) + iso_format = future_time.isoformat() + return iso_format + + +def create_namespace(api: APIHelper, namespace: str) -> requests.Response: + """ Create a namespace. """ + return api.make_request( + f"api/v1/namespace/{namespace}", method="POST") + + +def create_token( + api: APIHelper, namespace: str, role: str, + default_namespace: str = "default" + ) -> requests.Response: + """ Create a token with a specified role in a specified namespace. """ + one_hour_in_future = get_time_in_future(1) + json_data = { + "primary_namespace": default_namespace, + "expiration": one_hour_in_future.isoformat(), + "role_bindings": { + f"{namespace}/*": [role] + } + } + return api.make_request( + "api/v1/token", method="POST", json_data=json_data) + + +def create_environment( + api: APIHelper, namespace: str, specification_path: str + ) -> requests.Response: + """ + Create an environment. + The environment specification is read + from a conda environment.yaml file. + """ + with open(specification_path, "r", encoding="utf-8") as file: + specification_content = file.read() + + json_data = { + "namespace": namespace, + "specification": specification_content + } + + return api.make_request( + "api/v1/specification", method="POST", json_data=json_data) + + +def wait_for_successful_build( + api: APIHelper, build_id: str + ) -> requests.Response: + """ Wait for a build to complete.""" + status = APIBuildStatus.QUEUED.value + while status != APIBuildStatus.COMPLETED.value: + response = api.make_request( + f"api/v1/build/{build_id}", method="GET") + status = response.json()["data"]["status"] + if status == APIBuildStatus.FAILED.value: + raise AssertionError("Build failed") + return response + + +def delete_environment( + api: APIHelper, namespace: str, environment_name: str + ) -> requests.Response: + """ Delete an environment.""" + return api.make_request( + f"api/v1/environment/{namespace}/{environment_name}", + method="DELETE") + + +def delete_namespace(api: APIHelper, namespace: str) -> requests.Response: + """ Delete a namespace.""" + return api.make_request(f"api/v1/namespace/{namespace}", method="DELETE") \ No newline at end of file diff --git a/conda-store-server/tests/user_journeys/test_data/simple_environment.yaml b/conda-store-server/tests/user_journeys/test_data/simple_environment.yaml new file mode 100644 index 000000000..faa1ab42e --- /dev/null +++ b/conda-store-server/tests/user_journeys/test_data/simple_environment.yaml @@ -0,0 +1,6 @@ +name: simple-test-environment +channels: + - conda-forge +dependencies: + - python ==3.10 + - fastapi diff --git a/conda-store-server/tests/user_journeys/test_user_journeys.py b/conda-store-server/tests/user_journeys/test_user_journeys.py new file mode 100644 index 000000000..e838b5c51 --- /dev/null +++ b/conda-store-server/tests/user_journeys/test_user_journeys.py @@ -0,0 +1,42 @@ +"""User journey tests for the API.""" +import os +import uuid + +from helpers import helpers as h +import pytest + + +@pytest.fixture(scope="session") +def base_url() -> str: + """Get the base URL for the API.""" + base = os.getenv("CONDA_STORE_BASE_URL", "http://localhost:8080") + return f"{base}/conda-store" + + +@pytest.fixture(scope="session") +def token(base_url) -> str: + """Get the token for the API.""" + return os.getenv("CONDA_STORE_TOKEN", "") + + +@pytest.mark.user_journey +@pytest.mark.parametrize("filename", [ + ("tests/user_journeys/test_data/simple_environment.yaml"), + # ("test_data/complex-environment.yaml") + ]) +def test_admin_user_can_create_environment(base_url, token, filename) -> None: + """Test that an admin user can create an environment.""" + namespace = uuid.uuid4().hex # Generate a random namespace + print(os.path.abspath(filename)) + api = h.APIHelper(base_url=base_url, token=token) + specification_path = f"{filename}" + response = h.create_environment(api, namespace, specification_path) + assert response.status_code == 200 + data = response.json()["data"] + assert "build_id" in data + build_id = data["build_id"] + assert build_id is not None + build = h.wait_for_successful_build(api, build_id) + environment_name = build.json()["data"]["specification"]["name"] + h.delete_environment(api, namespace, environment_name) + h.delete_namespace(api, namespace) From 4d07354f6936ed2cc5c577c70f7f61d5b71769d7 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:58:15 -0500 Subject: [PATCH 02/11] update ci tests --- .github/workflows/tests.yaml | 6 +++++- conda-store-server/pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d5b9ead5d..c078552b3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -62,7 +62,7 @@ jobs: - name: "Unit tests ✅" run: | - pytest -m "not extended_prefix" tests + pytest -m "not extended_prefix and not user_journey" tests # https://github.com/actions/runner-images/issues/1052 - name: "Windows extended prefix unit tests ✅" @@ -125,6 +125,10 @@ jobs: export PYTHONPATH=$PYTHONPATH:$PWD pytest ../tests/test_api.py ../tests/test_metrics.py + - name: "Run user journey tests ✅" + run: | + pytest -m "user_journey" + - name: "Get Docker logs 🔍" if: ${{ failure() }} run: | diff --git a/conda-store-server/pyproject.toml b/conda-store-server/pyproject.toml index bdce874b6..b214e768c 100644 --- a/conda-store-server/pyproject.toml +++ b/conda-store-server/pyproject.toml @@ -108,5 +108,5 @@ ignore = ["W004"] markers = [ "playwright: mark a test as a playwright test", "integration: mark a test as an integration test", - "user-journey: mark a test as a user journey test", + "user_journey: mark a test as a user journey test", ] \ No newline at end of file From 4790b8d50a22b6327bcad3321f2ec0f2f49d57af Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 1 Feb 2024 11:38:45 -0500 Subject: [PATCH 03/11] add readme and lint --- conda-store-server/pyproject.toml | 4 +- .../tests/user_journeys/README.md | 59 +++++++++++++++++++ .../tests/user_journeys/helpers/helpers.py | 2 +- .../tests/user_journeys/test_user_journeys.py | 2 +- 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 conda-store-server/tests/user_journeys/README.md diff --git a/conda-store-server/pyproject.toml b/conda-store-server/pyproject.toml index b214e768c..c5c3e6093 100644 --- a/conda-store-server/pyproject.toml +++ b/conda-store-server/pyproject.toml @@ -80,7 +80,7 @@ playwright-test = [ ] integration-test = ["pytest ../tests/test_api.py ../tests/test_metrics.py"] -user-journey-test = ["pytest -m user-journey"] +user-journey-test = ["pytest -m user_journey"] [tool.hatch.build.hooks.custom] @@ -109,4 +109,4 @@ markers = [ "playwright: mark a test as a playwright test", "integration: mark a test as an integration test", "user_journey: mark a test as a user journey test", -] \ No newline at end of file +] diff --git a/conda-store-server/tests/user_journeys/README.md b/conda-store-server/tests/user_journeys/README.md new file mode 100644 index 000000000..97c3f6ebc --- /dev/null +++ b/conda-store-server/tests/user_journeys/README.md @@ -0,0 +1,59 @@ +# User Journey Tests + +This repository contains user journey tests for the API. User journey tests are end-to-end tests that simulate real user scenarios to ensure the API functions correctly in different scenarios. + +These tests will use the high privileged token to create a randomly named namespace (using a UUID) to prevent conflicts with existing namespaces. At the end of the test, it will delete any environments created and then delete the namespace. + +## Prerequisites + +These tests are blackbox tests and need a running server to test against. This can be a local conda-store instance started using docker compose, or a remote instance. You will need the base url of the server and a token for an admin user to run these tests. + +## Setup + +### Local setup + +To run locally using docker compose all you need to do is start conda store. + +From the project base run `docker compose up` + +### Remote setup + +To run these tests against a remote server, you need to set 2 environmental variables + +1. `CONDA_STORE_BASE_URL` - this is the base url of your conda server. + + For example, if you access your conda-store-server at `https://example.com` you would run `export CONDA_STORE_BASE_URL='https://example.com`. + + Do not include the `/conda-store/` suffix. + + Do include the port if needed. For example: `export CONDA_STORE_BASE_URL='http://localhost:8080` + +2. `CONDA_STORE_TOKEN` - this should be the token of an admin user. + + This token will let the tests create the tokens, permissions, namespaces, and environments needed for these tests to run successfully + + To generate a token, while logged in as a high privileged user, go to `https://your-conda-store-url/conda-store/admin/user/` and click on `Create token`. + + Copy that token value and export it `export CONDA_STORE_TOKEN='my_token_value'` + +## Running the tests + +To run the tests run `pytest -m user_journey` from the `conda-store-server` directory + +## Current scenarios tested + +* An admin user can create a simple environment in a shared namespace and once the environment is built, can delete the environment + +## Planned scenarios to be implemented + +* A admin can create a complex environment in a shared namespace and once the environment is built, can delete the environment + +* A developer can create a simple environment in a shared namespace and once the environment is built, can delete the environment + +* A developer can create a complex environment in a shared namespace and once the environment is built, can delete the environment + +* A developer can create a create environment in a shared namespace and once the environment is built, can modify the environment, then can mark the first build as active. + +* A developer can create a simple environment in a shared namespace and once the environment is built, can get the lockfile for the environment + +* A developer can create a failing environment in a shared namespace and once the environment has failed, can get the logs for the failed build \ No newline at end of file diff --git a/conda-store-server/tests/user_journeys/helpers/helpers.py b/conda-store-server/tests/user_journeys/helpers/helpers.py index feb1b0e14..0576d159a 100644 --- a/conda-store-server/tests/user_journeys/helpers/helpers.py +++ b/conda-store-server/tests/user_journeys/helpers/helpers.py @@ -142,4 +142,4 @@ def delete_environment( def delete_namespace(api: APIHelper, namespace: str) -> requests.Response: """ Delete a namespace.""" - return api.make_request(f"api/v1/namespace/{namespace}", method="DELETE") \ No newline at end of file + return api.make_request(f"api/v1/namespace/{namespace}", method="DELETE") diff --git a/conda-store-server/tests/user_journeys/test_user_journeys.py b/conda-store-server/tests/user_journeys/test_user_journeys.py index e838b5c51..ea96a15d7 100644 --- a/conda-store-server/tests/user_journeys/test_user_journeys.py +++ b/conda-store-server/tests/user_journeys/test_user_journeys.py @@ -2,8 +2,8 @@ import os import uuid -from helpers import helpers as h import pytest +from helpers import helpers as h @pytest.fixture(scope="session") From 7b193dc1fd7ee9fe9d79ec4dc30723a005a21794 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 1 Feb 2024 11:41:06 -0500 Subject: [PATCH 04/11] seperate user journey test into new job --- .github/workflows/tests.yaml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c078552b3..7f49f5354 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -125,6 +125,42 @@ jobs: export PYTHONPATH=$PYTHONPATH:$PWD pytest ../tests/test_api.py ../tests/test_metrics.py + - name: "Get Docker logs 🔍" + if: ${{ failure() }} + run: | + docker-compose logs + + user-journey-test-conda-store-server: + name: "integration-test conda-store-server" + runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} + working-directory: conda-store-server + steps: + - name: "Checkout Repository 🛎" + uses: actions/checkout@v4 + + - name: "Set up env 🐍" + uses: conda-incubator/setup-miniconda@v2 + with: + environment-file: conda-store-server/environment-dev.yaml + miniforge-version: latest + + - name: "Install build dependencies 📦" + run: | + pip install hatch + sudo apt install wait-for-it -y + + - name: "Deploy docker-compose" + run: | + docker-compose up -d + docker ps + + wait-for-it localhost:5432 # postgresql + wait-for-it localhost:9000 # minio + wait-for-it localhost:8080 # conda-store-server + - name: "Run user journey tests ✅" run: | pytest -m "user_journey" From 81881df0ab5ee50fc9cea517def0300e092f7d36 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 1 Feb 2024 11:43:09 -0500 Subject: [PATCH 05/11] fix name of user-journey-test in gha and lint --- .github/workflows/tests.yaml | 2 +- conda-store-server/tests/user_journeys/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7f49f5354..b2eb7d93a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -131,7 +131,7 @@ jobs: docker-compose logs user-journey-test-conda-store-server: - name: "integration-test conda-store-server" + name: "user-journey-test conda-store-server" runs-on: ubuntu-latest defaults: run: diff --git a/conda-store-server/tests/user_journeys/README.md b/conda-store-server/tests/user_journeys/README.md index 97c3f6ebc..8843a6dcc 100644 --- a/conda-store-server/tests/user_journeys/README.md +++ b/conda-store-server/tests/user_journeys/README.md @@ -56,4 +56,4 @@ To run the tests run `pytest -m user_journey` from the `conda-store-server` dire * A developer can create a simple environment in a shared namespace and once the environment is built, can get the lockfile for the environment -* A developer can create a failing environment in a shared namespace and once the environment has failed, can get the logs for the failed build \ No newline at end of file +* A developer can create a failing environment in a shared namespace and once the environment has failed, can get the logs for the failed build From 08548a29d17ad28f40487dde293d13cab4ccf669 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 1 Feb 2024 12:52:10 -0500 Subject: [PATCH 06/11] update gha --- .github/workflows/tests.yaml | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b2eb7d93a..c078552b3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -125,42 +125,6 @@ jobs: export PYTHONPATH=$PYTHONPATH:$PWD pytest ../tests/test_api.py ../tests/test_metrics.py - - name: "Get Docker logs 🔍" - if: ${{ failure() }} - run: | - docker-compose logs - - user-journey-test-conda-store-server: - name: "user-journey-test conda-store-server" - runs-on: ubuntu-latest - defaults: - run: - shell: bash -el {0} - working-directory: conda-store-server - steps: - - name: "Checkout Repository 🛎" - uses: actions/checkout@v4 - - - name: "Set up env 🐍" - uses: conda-incubator/setup-miniconda@v2 - with: - environment-file: conda-store-server/environment-dev.yaml - miniforge-version: latest - - - name: "Install build dependencies 📦" - run: | - pip install hatch - sudo apt install wait-for-it -y - - - name: "Deploy docker-compose" - run: | - docker-compose up -d - docker ps - - wait-for-it localhost:5432 # postgresql - wait-for-it localhost:9000 # minio - wait-for-it localhost:8080 # conda-store-server - - name: "Run user journey tests ✅" run: | pytest -m "user_journey" From e55f8777f3fb9ddcf55119946bac3f86b96adad9 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:44:27 -0500 Subject: [PATCH 07/11] respond to feedback --- .../tests/user_journeys/README.md | 66 +++++--- .../tests/user_journeys/helpers/helpers.py | 145 ------------------ .../tests/user_journeys/test_user_journeys.py | 33 ++-- .../tests/user_journeys/utils/api_utils.py | 136 ++++++++++++++++ .../tests/user_journeys/utils/time_utils.py | 20 +++ 5 files changed, 218 insertions(+), 182 deletions(-) delete mode 100644 conda-store-server/tests/user_journeys/helpers/helpers.py create mode 100644 conda-store-server/tests/user_journeys/utils/api_utils.py create mode 100644 conda-store-server/tests/user_journeys/utils/time_utils.py diff --git a/conda-store-server/tests/user_journeys/README.md b/conda-store-server/tests/user_journeys/README.md index 8843a6dcc..5cd46f8f1 100644 --- a/conda-store-server/tests/user_journeys/README.md +++ b/conda-store-server/tests/user_journeys/README.md @@ -1,59 +1,83 @@ # User Journey Tests -This repository contains user journey tests for the API. User journey tests are end-to-end tests that simulate real user scenarios to ensure the API functions correctly in different scenarios. +This repository contains user journey tests for the API. User journey tests +are end-to-end tests that simulate real user scenarios to ensure the API +functions correctly in different scenarios. -These tests will use the high privileged token to create a randomly named namespace (using a UUID) to prevent conflicts with existing namespaces. At the end of the test, it will delete any environments created and then delete the namespace. +These tests will use the high privileged token to create a randomly named +namespace (using a UUID) to prevent conflicts with existing namespaces. At the +end of the test, it will delete any environments created and then delete the +namespace. ## Prerequisites -These tests are blackbox tests and need a running server to test against. This can be a local conda-store instance started using docker compose, or a remote instance. You will need the base url of the server and a token for an admin user to run these tests. +These tests are blackbox tests and need a running server to test against. This +can be a local conda-store instance started using docker compose, or a remote +instance. You will need the base url of the server and a token for an admin +user to run these tests. ## Setup ### Local setup -To run locally using docker compose all you need to do is start conda store. +To run locally using docker compose all you need to do is start conda-store. -From the project base run `docker compose up` +From the project base, run `docker compose up`. ### Remote setup -To run these tests against a remote server, you need to set 2 environmental variables +To run these tests against a remote server, you need to set 2 environment +variables: -1. `CONDA_STORE_BASE_URL` - this is the base url of your conda server. +1. `CONDA_STORE_BASE_URL` - this is the base url of your conda-store-server. - For example, if you access your conda-store-server at `https://example.com` you would run `export CONDA_STORE_BASE_URL='https://example.com`. + For example, if you access your conda-store-server at `https://example.com`, + you would run `export CONDA_STORE_BASE_URL='https://example.com'`. - Do not include the `/conda-store/` suffix. + **Do not include the `/conda-store/` suffix.** - Do include the port if needed. For example: `export CONDA_STORE_BASE_URL='http://localhost:8080` + Do include the port if needed. + For example: `export CONDA_STORE_BASE_URL='http://localhost:8080'`. 2. `CONDA_STORE_TOKEN` - this should be the token of an admin user. - This token will let the tests create the tokens, permissions, namespaces, and environments needed for these tests to run successfully + This token will let the tests create the tokens, permissions, namespaces, + and environments needed for these tests to run successfully. - To generate a token, while logged in as a high privileged user, go to `https://your-conda-store-url/conda-store/admin/user/` and click on `Create token`. + To generate a token, while logged in as a high-privileged user, go to + `https:///conda-store/admin/user/` and click on + `Create token`. - Copy that token value and export it `export CONDA_STORE_TOKEN='my_token_value'` + Copy that token value and export it: + `export CONDA_STORE_TOKEN='my_token_value'`. ## Running the tests -To run the tests run `pytest -m user_journey` from the `conda-store-server` directory +To run the tests run `pytest -m user_journey` from the `conda-store-server` +directory. ## Current scenarios tested -* An admin user can create a simple environment in a shared namespace and once the environment is built, can delete the environment +* An admin user can create a simple environment in a shared namespace and, once + the environment is built, can delete the environment. ## Planned scenarios to be implemented -* A admin can create a complex environment in a shared namespace and once the environment is built, can delete the environment +* An admin can create a complex environment in a shared namespace and, once the + environment is built, can delete the environment -* A developer can create a simple environment in a shared namespace and once the environment is built, can delete the environment +* A developer can create a simple environment in a shared namespace and, once + the environment is built, can delete the environment -* A developer can create a complex environment in a shared namespace and once the environment is built, can delete the environment +* A developer can create a complex environment in a shared namespace and, once + the environment is built, can delete the environment -* A developer can create a create environment in a shared namespace and once the environment is built, can modify the environment, then can mark the first build as active. +* A developer can create an environment in a shared namespace and, once the + environment is built, can modify the environment, then can mark the first + build as active -* A developer can create a simple environment in a shared namespace and once the environment is built, can get the lockfile for the environment +* A developer can create a simple environment in a shared namespace and, once + the environment is built, can get the lockfile for the environment -* A developer can create a failing environment in a shared namespace and once the environment has failed, can get the logs for the failed build +* A developer can create a failing environment in a shared namespace and, once + the environment has failed, can get the logs for the failed build. diff --git a/conda-store-server/tests/user_journeys/helpers/helpers.py b/conda-store-server/tests/user_journeys/helpers/helpers.py deleted file mode 100644 index 0576d159a..000000000 --- a/conda-store-server/tests/user_journeys/helpers/helpers.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Helper functions for user journeys.""" -from datetime import datetime, timedelta, timezone -from enum import Enum - -import requests - -TIMEOUT = 10 - - -class APIBuildStatus(Enum): - """Enum for API build status.""" - COMPLETED = "COMPLETED" - FAILED = "FAILED" - QUEUED = "QUEUED" - BUILDING = "BUILDING" - - -class APIHelper: - """ - Helper class for making requests to the API. - These methods are used to build tests for user journeys - """ - - def __init__(self, - base_url: str, token: str = "", - username: str = "username", password: str = "password" - ) -> None: - self.base_url = base_url - self.token = token - if not token: - # Log in if no token is provided to set the token - self._login(username, password) - - def make_request( - self, endpoint: str, method: str = "GET", json_data: dict = None, - headers: dict = None, timeout: int = TIMEOUT) -> requests.Response: - """ Make a request to the API. """ - url = f"{self.base_url}/{endpoint}" - headers = headers or {} - headers["Authorization"] = f"Bearer {self.token}" - response = requests.request( - method, url, json=json_data, headers=headers, timeout=timeout) - response.raise_for_status() - return response - - def _login(self, username: str, password: str) -> None: - """ Log in to the API and set an access token.""" - json_data = {"username": username, "password": password} - response = requests.post( - f"{self.base_url}/login", json=json_data, timeout=TIMEOUT) - cookies = response.cookies.get_dict() - token_response = requests.post( - f"{self.base_url}/api/v1/token", cookies=cookies, timeout=TIMEOUT) - data = token_response.json() - self.token = data["data"]["token"] - - -def get_current_time() -> datetime: - """ Get the current time. """ - return datetime.now(timezone.utc) - - -def get_time_in_future(hours: int) -> datetime: - """ Get the time in the future.""" - current_time = get_current_time() - future_time = current_time + timedelta(hours=hours) - return future_time - - -def get_iso8601_time(hours: int) -> str: - """ Get the time in the future in ISO 8601 format. """ - future_time = get_time_in_future(hours) - iso_format = future_time.isoformat() - return iso_format - - -def create_namespace(api: APIHelper, namespace: str) -> requests.Response: - """ Create a namespace. """ - return api.make_request( - f"api/v1/namespace/{namespace}", method="POST") - - -def create_token( - api: APIHelper, namespace: str, role: str, - default_namespace: str = "default" - ) -> requests.Response: - """ Create a token with a specified role in a specified namespace. """ - one_hour_in_future = get_time_in_future(1) - json_data = { - "primary_namespace": default_namespace, - "expiration": one_hour_in_future.isoformat(), - "role_bindings": { - f"{namespace}/*": [role] - } - } - return api.make_request( - "api/v1/token", method="POST", json_data=json_data) - - -def create_environment( - api: APIHelper, namespace: str, specification_path: str - ) -> requests.Response: - """ - Create an environment. - The environment specification is read - from a conda environment.yaml file. - """ - with open(specification_path, "r", encoding="utf-8") as file: - specification_content = file.read() - - json_data = { - "namespace": namespace, - "specification": specification_content - } - - return api.make_request( - "api/v1/specification", method="POST", json_data=json_data) - - -def wait_for_successful_build( - api: APIHelper, build_id: str - ) -> requests.Response: - """ Wait for a build to complete.""" - status = APIBuildStatus.QUEUED.value - while status != APIBuildStatus.COMPLETED.value: - response = api.make_request( - f"api/v1/build/{build_id}", method="GET") - status = response.json()["data"]["status"] - if status == APIBuildStatus.FAILED.value: - raise AssertionError("Build failed") - return response - - -def delete_environment( - api: APIHelper, namespace: str, environment_name: str - ) -> requests.Response: - """ Delete an environment.""" - return api.make_request( - f"api/v1/environment/{namespace}/{environment_name}", - method="DELETE") - - -def delete_namespace(api: APIHelper, namespace: str) -> requests.Response: - """ Delete a namespace.""" - return api.make_request(f"api/v1/namespace/{namespace}", method="DELETE") diff --git a/conda-store-server/tests/user_journeys/test_user_journeys.py b/conda-store-server/tests/user_journeys/test_user_journeys.py index ea96a15d7..54cd90406 100644 --- a/conda-store-server/tests/user_journeys/test_user_journeys.py +++ b/conda-store-server/tests/user_journeys/test_user_journeys.py @@ -1,9 +1,8 @@ """User journey tests for the API.""" import os -import uuid import pytest -from helpers import helpers as h +import utils.api_utils as utils @pytest.fixture(scope="session") @@ -20,23 +19,25 @@ def token(base_url) -> str: @pytest.mark.user_journey -@pytest.mark.parametrize("filename", [ - ("tests/user_journeys/test_data/simple_environment.yaml"), - # ("test_data/complex-environment.yaml") - ]) -def test_admin_user_can_create_environment(base_url, token, filename) -> None: +@pytest.mark.parametrize( + "specification_path", + [ + ("tests/user_journeys/test_data/simple_environment.yaml"), + ], +) +def test_admin_user_can_create_environment( + base_url: str, token: str, specification_path: str +) -> None: """Test that an admin user can create an environment.""" - namespace = uuid.uuid4().hex # Generate a random namespace - print(os.path.abspath(filename)) - api = h.APIHelper(base_url=base_url, token=token) - specification_path = f"{filename}" - response = h.create_environment(api, namespace, specification_path) - assert response.status_code == 200 + namespace = utils.gen_random_namespace() + api = utils.API(base_url=base_url, token=token) + utils.create_namespace(api, namespace) + response = utils.create_environment(api, namespace, specification_path) data = response.json()["data"] assert "build_id" in data build_id = data["build_id"] assert build_id is not None - build = h.wait_for_successful_build(api, build_id) + build = utils.wait_for_successful_build(api, build_id) environment_name = build.json()["data"]["specification"]["name"] - h.delete_environment(api, namespace, environment_name) - h.delete_namespace(api, namespace) + utils.delete_environment(api, namespace, environment_name) + utils.delete_namespace(api, namespace) diff --git a/conda-store-server/tests/user_journeys/utils/api_utils.py b/conda-store-server/tests/user_journeys/utils/api_utils.py new file mode 100644 index 000000000..6d1b2c750 --- /dev/null +++ b/conda-store-server/tests/user_journeys/utils/api_utils.py @@ -0,0 +1,136 @@ +"""Helper functions for user journeys.""" +from enum import Enum +import time + +import uuid + +import requests +import time_utils + +TIMEOUT = 10 + + +class BuildStatus(Enum): + """Enum for API build status.""" + QUEUED = "QUEUED" + BUILDING = "BUILDING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class API: + """ + Helper class for making requests to the API. + These methods are used to build tests for user journeys + """ + + def __init__(self, + base_url: str, token: str = "", + username: str = "username", password: str = "password" + ) -> None: + self.base_url = base_url + self.token = token + if not token: + # Log in if no token is provided to set the token + self._login(username, password) + + def _make_request( + self, endpoint: str, method: str = "GET", json_data: dict = None, + headers: dict = None, timeout: int = TIMEOUT) -> requests.Response: + """ Make a request to the API. """ + url = f"{self.base_url}/{endpoint}" + headers = headers or {} + headers["Authorization"] = f"Bearer {self.token}" + response = requests.request( + method, url, json=json_data, headers=headers, timeout=timeout) + response.raise_for_status() + return response + + def _login(self, username: str, password: str) -> None: + """ Log in to the API and set an access token.""" + json_data = {"username": username, "password": password} + response = requests.post( + f"{self.base_url}/login", json=json_data, timeout=TIMEOUT) + cookies = response.cookies.get_dict() + token_response = requests.post( + f"{self.base_url}/api/v1/token", cookies=cookies, timeout=TIMEOUT) + data = token_response.json() + self.token = data["data"]["token"] + + def create_namespace(self, namespace: str) -> requests.Response: + """ Create a namespace.""" + return self._make_request( + f"api/v1/namespace/{namespace}", method="POST") + + def create_token( + self, namespace: str, role: str, + default_namespace: str = "default" + ) -> requests.Response: + """ Create a token with a specified role in a specified namespace. """ + one_hour_in_future = time_utils.get_time_in_future(1) + json_data = { + "primary_namespace": default_namespace, + "expiration": one_hour_in_future.isoformat(), + "role_bindings": { + f"{namespace}/*": [role] + } + } + return self._make_request( + "api/v1/token", method="POST", json_data=json_data) + + def create_environment( + self, namespace: str, specification_path: str + ) -> requests.Response: + """ + Create an environment. + The environment specification is read + from a conda environment.yaml file. + """ + with open(specification_path, "r", encoding="utf-8") as file: + specification_content = file.read() + + json_data = { + "namespace": namespace, + "specification": specification_content + } + + return self._make_request( + "api/v1/specification", method="POST", json_data=json_data) + + def wait_for_successful_build( + self, build_id: str, + max_iterations: int = 100, sleep_time: int = 5 + ) -> requests.Response: + """ Wait for a build to complete.""" + status = BuildStatus.QUEUED.value + iterations = 0 + while status != BuildStatus.COMPLETED.value: + if iterations > max_iterations: + raise TimeoutError("Timed out waiting for build") + response = self._make_request( + f"api/v1/build/{build_id}", method="GET") + status = response.json()["data"]["status"] + if status == BuildStatus.FAILED.value: + raise AssertionError("Build failed") + iterations += 1 + time.sleep(sleep_time) + return response + + def delete_environment( + self, namespace: str, environment_name: str + ) -> requests.Response: + """ Delete an environment.""" + return self._make_request( + f"api/v1/environment/{namespace}/{environment_name}", + method="DELETE") + + def delete_namespace(self, namespace: str) -> requests.Response: + """ Delete a namespace.""" + return self._make_request( + f"api/v1/namespace/{namespace}", method="DELETE" + ) + + @staticmethod + def gen_random_namespace() -> str: + """ Generate a random namespace.""" + return uuid.uuid4().hex diff --git a/conda-store-server/tests/user_journeys/utils/time_utils.py b/conda-store-server/tests/user_journeys/utils/time_utils.py new file mode 100644 index 000000000..d61c203c8 --- /dev/null +++ b/conda-store-server/tests/user_journeys/utils/time_utils.py @@ -0,0 +1,20 @@ +from datetime import datetime, timedelta, timezone + + +def get_current_time() -> datetime: + """ Get the current time. """ + return datetime.now(timezone.utc) + + +def get_time_in_future(hours: int) -> datetime: + """ Get the time in the future.""" + current_time = get_current_time() + future_time = current_time + timedelta(hours=hours) + return future_time + + +def get_iso8601_time(hours: int) -> str: + """ Get the time in the future in ISO 8601 format. """ + future_time = get_time_in_future(hours) + iso_format = future_time.isoformat() + return iso_format From 8c652b1ef852365c0b5b7aea7d812ff8ecca3788 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:43:03 -0500 Subject: [PATCH 08/11] lint --- .../tests/user_journeys/utils/api_utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/conda-store-server/tests/user_journeys/utils/api_utils.py b/conda-store-server/tests/user_journeys/utils/api_utils.py index 6d1b2c750..5f40147a4 100644 --- a/conda-store-server/tests/user_journeys/utils/api_utils.py +++ b/conda-store-server/tests/user_journeys/utils/api_utils.py @@ -1,8 +1,7 @@ """Helper functions for user journeys.""" -from enum import Enum import time - import uuid +from enum import Enum import requests import time_utils @@ -67,10 +66,9 @@ def create_token( default_namespace: str = "default" ) -> requests.Response: """ Create a token with a specified role in a specified namespace. """ - one_hour_in_future = time_utils.get_time_in_future(1) json_data = { "primary_namespace": default_namespace, - "expiration": one_hour_in_future.isoformat(), + "expiration": time_utils.get_iso8601_time(1), "role_bindings": { f"{namespace}/*": [role] } @@ -110,8 +108,7 @@ def wait_for_successful_build( response = self._make_request( f"api/v1/build/{build_id}", method="GET") status = response.json()["data"]["status"] - if status == BuildStatus.FAILED.value: - raise AssertionError("Build failed") + assert status != BuildStatus.FAILED.value, "Build failed" iterations += 1 time.sleep(sleep_time) return response From a748b34789e0eb65de9a0928e822035ecdb62890 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:12:02 -0500 Subject: [PATCH 09/11] fix test --- .../tests/user_journeys/test_user_journeys.py | 12 ++++++------ .../tests/user_journeys/utils/__init__.py | 0 .../tests/user_journeys/utils/api_utils.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 conda-store-server/tests/user_journeys/utils/__init__.py diff --git a/conda-store-server/tests/user_journeys/test_user_journeys.py b/conda-store-server/tests/user_journeys/test_user_journeys.py index 54cd90406..1d4ee7d23 100644 --- a/conda-store-server/tests/user_journeys/test_user_journeys.py +++ b/conda-store-server/tests/user_journeys/test_user_journeys.py @@ -29,15 +29,15 @@ def test_admin_user_can_create_environment( base_url: str, token: str, specification_path: str ) -> None: """Test that an admin user can create an environment.""" - namespace = utils.gen_random_namespace() + namespace = utils.API.gen_random_namespace() api = utils.API(base_url=base_url, token=token) - utils.create_namespace(api, namespace) - response = utils.create_environment(api, namespace, specification_path) + api.create_namespace(namespace) + response = api.create_environment(namespace, specification_path) data = response.json()["data"] assert "build_id" in data build_id = data["build_id"] assert build_id is not None - build = utils.wait_for_successful_build(api, build_id) + build = api.wait_for_successful_build(build_id) environment_name = build.json()["data"]["specification"]["name"] - utils.delete_environment(api, namespace, environment_name) - utils.delete_namespace(api, namespace) + api.delete_environment(namespace, environment_name) + api.delete_namespace(namespace) diff --git a/conda-store-server/tests/user_journeys/utils/__init__.py b/conda-store-server/tests/user_journeys/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conda-store-server/tests/user_journeys/utils/api_utils.py b/conda-store-server/tests/user_journeys/utils/api_utils.py index 5f40147a4..3968e0c41 100644 --- a/conda-store-server/tests/user_journeys/utils/api_utils.py +++ b/conda-store-server/tests/user_journeys/utils/api_utils.py @@ -4,7 +4,7 @@ from enum import Enum import requests -import time_utils +import utils.time_utils as time_utils TIMEOUT = 10 From 8da5fcb74230b6eb3288c4f21579aa43ee020c44 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 21 Feb 2024 21:47:23 -0500 Subject: [PATCH 10/11] Update conda-store-server/tests/user_journeys/README.md Co-authored-by: Nikita Karetnikov --- conda-store-server/tests/user_journeys/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conda-store-server/tests/user_journeys/README.md b/conda-store-server/tests/user_journeys/README.md index 5cd46f8f1..aec8c1160 100644 --- a/conda-store-server/tests/user_journeys/README.md +++ b/conda-store-server/tests/user_journeys/README.md @@ -4,7 +4,7 @@ This repository contains user journey tests for the API. User journey tests are end-to-end tests that simulate real user scenarios to ensure the API functions correctly in different scenarios. -These tests will use the high privileged token to create a randomly named +These tests will use the high-privileged token to create a randomly-named namespace (using a UUID) to prevent conflicts with existing namespaces. At the end of the test, it will delete any environments created and then delete the namespace. From 46284686944fa2ec3a4f1af97abbb3b75ea4c78f Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 21 Feb 2024 21:47:38 -0500 Subject: [PATCH 11/11] Update conda-store-server/tests/user_journeys/README.md Co-authored-by: Nikita Karetnikov --- conda-store-server/tests/user_journeys/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conda-store-server/tests/user_journeys/README.md b/conda-store-server/tests/user_journeys/README.md index aec8c1160..7e0dbceb6 100644 --- a/conda-store-server/tests/user_journeys/README.md +++ b/conda-store-server/tests/user_journeys/README.md @@ -53,7 +53,7 @@ variables: ## Running the tests -To run the tests run `pytest -m user_journey` from the `conda-store-server` +To run the tests, run `pytest -m user_journey` from the `conda-store-server` directory. ## Current scenarios tested