Skip to content

Commit

Permalink
Merge pull request #760 from conda-incubator/add-user-journey-test
Browse files Browse the repository at this point in the history
Add user journey test
  • Loading branch information
dcmcand authored Feb 22, 2024
2 parents d0dba0f + ea00d0a commit 2b77071
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 1 deletion.
6 changes: 5 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ✅"
Expand Down Expand Up @@ -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: |
Expand Down
9 changes: 9 additions & 0 deletions conda-store-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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",
]
83 changes: 83 additions & 0 deletions conda-store-server/tests/user_journeys/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.

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 environment
variables:

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'`.

**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

* 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 complex environment in a shared namespace and, once
the environment is built, can delete the environment

* 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 failing environment in a shared namespace and, once
the environment has failed, can get the logs for the failed build.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: simple-test-environment
channels:
- conda-forge
dependencies:
- python ==3.10
- fastapi
43 changes: 43 additions & 0 deletions conda-store-server/tests/user_journeys/test_user_journeys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""User journey tests for the API."""
import os

import pytest
import utils.api_utils as utils


@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(
"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 = utils.API.gen_random_namespace()
api = utils.API(base_url=base_url, token=token)
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 = api.wait_for_successful_build(build_id)
environment_name = build.json()["data"]["specification"]["name"]
api.delete_environment(namespace, environment_name)
api.delete_namespace(namespace)
Empty file.
133 changes: 133 additions & 0 deletions conda-store-server/tests/user_journeys/utils/api_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Helper functions for user journeys."""
import time
import uuid
from enum import Enum

import requests
import utils.time_utils as 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. """
json_data = {
"primary_namespace": default_namespace,
"expiration": time_utils.get_iso8601_time(1),
"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"]
assert status != BuildStatus.FAILED.value, "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
20 changes: 20 additions & 0 deletions conda-store-server/tests/user_journeys/utils/time_utils.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 2b77071

Please sign in to comment.