diff --git a/Makefile b/Makefile index ca90304a0db2d..fe47217554037 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ cellguide-pipeline-unittest: .PHONY: functional-test functional-test: - python3 -m pytest tests/functional/ --rootdir=. --verbose + python3 -m pytest tests/functional/ --rootdir=. --verbose -n auto .PHONY: prod-performance-test prod-performance-test: diff --git a/scripts/smoke_tests/setup.py b/scripts/smoke_tests/setup.py index 1e744a8aca4a1..33492d996d7a0 100644 --- a/scripts/smoke_tests/setup.py +++ b/scripts/smoke_tests/setup.py @@ -1,10 +1,19 @@ #!/usr/bin/env python import json +import os import sys import threading from backend.common.constants import DATA_SUBMISSION_POLICY_VERSION -from tests.functional.backend.common import BaseFunctionalTestCase +from backend.common.corpora_config import CorporaAuthConfig +from tests.functional.backend.constants import API_URL +from tests.functional.backend.utils import ( + get_auth_token, + make_cookie, + make_proxy_auth_token, + make_session, + upload_and_wait, +) # Amount to reduce chance of collision where multiple test instances select the same collection to test against NUM_TEST_DATASETS = 3 @@ -12,10 +21,16 @@ TEST_ACCT_CONTACT_NAME = "Smoke Test User" -class SmokeTestsInitializer(BaseFunctionalTestCase): +class SmokeTestsInitializer: def __init__(self): - super().setUpClass(smoke_tests=True) - super().setUp() + self.deployment_stage = os.environ["DEPLOYMENT_STAGE"] + self.config = CorporaAuthConfig() + proxy_auth_token = make_proxy_auth_token(self.config, self.deployment_stage) + self.session = make_session(proxy_auth_token) + self.api = API_URL.get(self.deployment_stage) + username, password = self.config.test_account_username, self.config.test_account_password + auth_token = get_auth_token(username, password, self.session, self.config, self.deployment_stage) + self.curator_cookie = make_cookie(auth_token) self.headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} def get_collection_count(self): @@ -33,7 +48,7 @@ def get_collection_count(self): def create_and_publish_collection(self, dropbox_url): collection_id = self.create_collection() for _ in range(NUM_TEST_DATASETS): - self.upload_and_wait(collection_id, dropbox_url, cleanup=False) + upload_and_wait(collection_id, dropbox_url, cleanup=False) self.publish_collection(collection_id) print(f"created and published collection {collection_id}") diff --git a/tests/functional/backend/common.py b/tests/functional/backend/common.py deleted file mode 100644 index 09ab41f37b813..0000000000000 --- a/tests/functional/backend/common.py +++ /dev/null @@ -1,175 +0,0 @@ -import base64 -import json -import os -import time -import unittest -from typing import Optional - -import requests -from requests.adapters import HTTPAdapter, Response -from requests.packages.urllib3.util import Retry - -from backend.common.corpora_config import CorporaAuthConfig - -API_URL = { - "prod": "https://api.cellxgene.cziscience.com", - "staging": "https://api.cellxgene.staging.single-cell.czi.technology", - "dev": "https://api.cellxgene.dev.single-cell.czi.technology", - "test": "https://localhost:5000", - "rdev": f"https://{os.getenv('STACK_NAME', '')}-backend.rdev.single-cell.czi.technology", -} - -AUDIENCE = { - "prod": "api.cellxgene.cziscience.com", - "staging": "api.cellxgene.staging.single-cell.czi.technology", - "test": "api.cellxgene.dev.single-cell.czi.technology", - "dev": "api.cellxgene.dev.single-cell.czi.technology", - "rdev": "api.cellxgene.dev.single-cell.czi.technology", -} - - -class BaseFunctionalTestCase(unittest.TestCase): - session: requests.Session - config: CorporaAuthConfig - deployment_stage: str - - @classmethod - def setUpClass(cls, smoke_tests: bool = False): - super().setUpClass() - cls.deployment_stage = os.environ["DEPLOYMENT_STAGE"] - cls.config = CorporaAuthConfig() - cls.test_dataset_uri = ( - "https://www.dropbox.com/scl/fi/y50umqlcrbz21a6jgu99z/" - "5_0_0_example_valid.h5ad?rlkey=s7p6ybyx082hswix26hbl11pm&dl=0" - ) - cls.session = requests.Session() - # apply retry config to idempotent http methods we use + POST requests, which are currently all either - # idempotent (wmg queries) or low risk to rerun in dev/staging. Update if this changes in functional tests. - retry_config = Retry( - total=7, - backoff_factor=2, - status_forcelist=[500, 502, 503, 504], - allowed_methods={"DELETE", "GET", "HEAD", "PUT" "POST"}, - ) - cls.session.mount("https://", HTTPAdapter(max_retries=retry_config)) - if cls.deployment_stage == "rdev": - cls.get_oauth2_proxy_access_token() - if smoke_tests: - username, password = cls.config.test_account_username, cls.config.test_account_password - else: - username, password = cls.config.functest_account_username, cls.config.functest_account_password - token = cls.get_auth_token(username, password) - cls.curator_cookie = cls.make_cookie(token) - cls.api = API_URL.get(cls.deployment_stage) - cls.test_collection_id = "005d611a-14d5-4fbf-846e-571a1f874f70" - cls.test_file_id = "7c93775542b056e048aa474535b8e5c2" - cls.bad_collection_id = "DNE" - cls.bad_file_id = "DNE" - cls.curation_api_access_token = cls.get_curation_api_access_token() - - @classmethod - def tearDownClass(cls): - super().tearDownClass() - cls.session.close() - - @classmethod - def get_curation_api_access_token(cls): - response = cls.session.post( - f"{cls.api}/curation/v1/auth/token", - headers={"x-api-key": cls.config.super_curator_api_key}, - ) - response.raise_for_status() - return response.json()["access_token"] - - @classmethod - def get_oauth2_proxy_access_token(cls): - payload = { - "client_id": cls.config.test_app_id, - "client_secret": cls.config.test_app_secret, - "grant_type": "client_credentials", - "audience": "https://api.cellxgene.dev.single-cell.czi.technology/dp/v1/curator", - } - headers = {"content-type": "application/json"} - - res = cls.session.post("https://czi-cellxgene-dev.us.auth0.com/oauth/token", json=payload, headers=headers) - res.raise_for_status() - cls.proxy_access_token = res.json()["access_token"] - cls.session.headers["Authorization"] = f"Bearer {cls.proxy_access_token}" - - @classmethod - def get_auth_token(cls, username: str, password: str, additional_claims: Optional[list] = None): - standard_claims = "openid profile email offline" - if additional_claims: - additional_claims.append(standard_claims) - claims = " ".join(additional_claims) - else: - claims = standard_claims - response = cls.session.post( - "https://czi-cellxgene-dev.us.auth0.com/oauth/token", - headers={"content-type": "application/x-www-form-urlencoded"}, - data=dict( - grant_type="password", - username=username, - password=password, - audience=AUDIENCE.get(cls.deployment_stage), - scope=claims, - client_id=cls.config.client_id, - client_secret=cls.config.client_secret, - ), - ) - response.raise_for_status() - access_token = response.json()["access_token"] - id_token = response.json()["id_token"] - token = {"access_token": access_token, "id_token": id_token} - return token - - @staticmethod - def make_cookie(token: dict) -> str: - return base64.b64encode(json.dumps(dict(token)).encode("utf-8")).decode() - - def upload_and_wait(self, collection_id, dropbox_url, existing_dataset_id=None, cleanup=True): - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - if self.deployment_stage == "rdev": - headers["Authorization"] = f"Bearer {self.proxy_access_token}" - body = {"url": dropbox_url} - - if existing_dataset_id is None: - res = self.session.post( - f"{self.api}/dp/v1/collections/{collection_id}/upload-links", data=json.dumps(body), headers=headers - ) - else: - body["id"] = existing_dataset_id - res = self.session.put( - f"{self.api}/dp/v1/collections/{collection_id}/upload-links", data=json.dumps(body), headers=headers - ) - - res.raise_for_status() - dataset_id = json.loads(res.content)["dataset_id"] - - if cleanup: - self.addCleanup(requests.delete, f"{self.api}/dp/v1/datasets/{dataset_id}", headers=headers) - - keep_trying = True - timer = time.time() - while keep_trying: - res = requests.get(f"{self.api}/dp/v1/datasets/{dataset_id}/status", headers=headers) - res.raise_for_status() - data = json.loads(res.content) - upload_status = data["upload_status"] - if upload_status == "UPLOADED": - cxg_status = data.get("cxg_status") - rds_status = data.get("rds_status") - h5ad_status = data.get("h5ad_status") - processing_status = data.get("processing_status") - if cxg_status == rds_status == h5ad_status == "UPLOADED" and processing_status == "SUCCESS": - keep_trying = False - if time.time() >= timer + 600: - raise TimeoutError( - f"Dataset upload or conversion timed out after 10 min. Check logs for dataset: {dataset_id}" - ) - time.sleep(10) - return dataset_id - - def assertStatusCode(self, actual: int, expected_response: Response): - request_id = expected_response.headers.get("X-Request-Id") - assert actual == expected_response.status_code, f"{request_id=}" diff --git a/tests/functional/backend/conftest.py b/tests/functional/backend/conftest.py new file mode 100644 index 0000000000000..0ca9d0b0fffd3 --- /dev/null +++ b/tests/functional/backend/conftest.py @@ -0,0 +1,103 @@ +import os + +import pytest + +from backend.common.corpora_config import CorporaAuthConfig +from tests.functional.backend.constants import API_URL +from tests.functional.backend.distributed import distributed_singleton +from tests.functional.backend.utils import ( + get_auth_token, + make_cookie, + make_proxy_auth_token, + make_session, + upload_and_wait, +) + + +@pytest.fixture(scope="session") +def config(): + return CorporaAuthConfig() + + +@pytest.fixture(scope="session") +def deployment_stage(): + return os.environ["DEPLOYMENT_STAGE"] + + +@pytest.fixture(scope="session") +def proxy_auth_token(config, deployment_stage, tmp_path_factory, worker_id) -> dict: + """ + Generate a proxy token for rdev. If running in parallel mode this will be shared across workers to avoid rate + limiting + """ + + def _proxy_auth_token() -> dict: + return make_proxy_auth_token(config, deployment_stage) + + return distributed_singleton(tmp_path_factory, worker_id, _proxy_auth_token) + + +@pytest.fixture(scope="session") +def session(proxy_auth_token): + session = make_session(proxy_auth_token) + yield session + session.close() + + +@pytest.fixture(scope="session") +def functest_auth_token(config, session, deployment_stage, tmp_path_factory, worker_id): + def _functest_auth_token() -> dict[str, str]: + username = config.functest_account_username + password = config.functest_account_password + return get_auth_token(username, password, session, config, deployment_stage) + + return distributed_singleton(tmp_path_factory, worker_id, _functest_auth_token) + + +@pytest.fixture(scope="session") +def curator_cookie(functest_auth_token): + return make_cookie(functest_auth_token) + + +@pytest.fixture(scope="session") +def api_url(deployment_stage): + return API_URL.get(deployment_stage) + + +@pytest.fixture(scope="session") +def curation_api_access_token(session, api_url, config, tmp_path_factory, worker_id): + def _curation_api_access_token() -> str: + response = session.post( + f"{api_url}/curation/v1/auth/token", + headers={"x-api-key": config.super_curator_api_key}, + ) + response.raise_for_status() + return response.json()["access_token"] + + return distributed_singleton(tmp_path_factory, worker_id, _curation_api_access_token) + + +@pytest.fixture(scope="session") +def upload_dataset(session, api_url, curator_cookie, request): + def _upload_dataset(collection_id, dropbox_url, existing_dataset_id=None): + result = upload_and_wait(session, api_url, curator_cookie, collection_id, dropbox_url, existing_dataset_id) + dataset_id = result["dataset_id"] + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + request.addfinalizer(lambda: session.delete(f"{api_url}/dp/v1/datasets/{dataset_id}", headers=headers)) + if result["errors"]: + raise pytest.fail(str(result["errors"])) + return dataset_id + + return _upload_dataset + + +@pytest.fixture() +def collection_data(request): + return { + "contact_email": "lisbon@gmail.com", + "contact_name": "Madrid Sparkle", + "curator_name": "John Smith", + "description": "Well here are some words", + "links": [{"link_name": "a link to somewhere", "link_type": "PROTOCOL", "link_url": "https://protocol.com"}], + "name": request.function.__name__, + } diff --git a/tests/functional/backend/constants.py b/tests/functional/backend/constants.py new file mode 100644 index 0000000000000..267e3e5b96a1f --- /dev/null +++ b/tests/functional/backend/constants.py @@ -0,0 +1,21 @@ +import os + +API_URL = { + "prod": "https://api.cellxgene.cziscience.com", + "staging": "https://api.cellxgene.staging.single-cell.czi.technology", + "dev": "https://api.cellxgene.dev.single-cell.czi.technology", + "test": "https://localhost:5000", + "rdev": f"https://{os.getenv('STACK_NAME', '')}-backend.rdev.single-cell.czi.technology", +} +AUDIENCE = { + "prod": "api.cellxgene.cziscience.com", + "staging": "api.cellxgene.staging.single-cell.czi.technology", + "test": "api.cellxgene.dev.single-cell.czi.technology", + "dev": "api.cellxgene.dev.single-cell.czi.technology", + "rdev": "api.cellxgene.dev.single-cell.czi.technology", +} + +DATASET_URI = ( + "https://www.dropbox.com/scl/fi/y50umqlcrbz21a6jgu99z/5_0_0_example_valid.h5ad?rlkey" + "=s7p6ybyx082hswix26hbl11pm&dl=0" +) diff --git a/tests/functional/backend/corpora/test_api.py b/tests/functional/backend/corpora/test_api.py index a836850b0849d..08fd80b5bb857 100644 --- a/tests/functional/backend/corpora/test_api.py +++ b/tests/functional/backend/corpora/test_api.py @@ -1,272 +1,179 @@ import json -import os -import time -import unittest +import pytest import requests from requests import HTTPError from backend.common.constants import DATA_SUBMISSION_POLICY_VERSION -from tests.functional.backend.common import BaseFunctionalTestCase - - -class TestApi(BaseFunctionalTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - def test_version(self): - res = self.session.get(f"{self.api}/dp/v1/deployed_version") - res.raise_for_status() - self.assertStatusCode(requests.codes.ok, res) - self.assertTrue(len(res.json()["Data Portal"]) > 0) - - def test_auth(self): - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/userinfo", headers=headers) - res.raise_for_status() - self.assertStatusCode(requests.codes.ok, res) - data = json.loads(res.content) - self.assertEqual(data["email"], "functest@example.com") - - def test_root_route(self): - res = self.session.get(f"{self.api}/") - - res.raise_for_status() - self.assertStatusCode(requests.codes.ok, res) - - def test_get_collections(self): - res = self.session.get(f"{self.api}/dp/v1/collections") - - res.raise_for_status() - self.assertStatusCode(requests.codes.ok, res) - data = json.loads(res.content) - for collection in data["collections"]: - self.assertIsInstance(collection["id"], str) - self.assertIsInstance(collection["created_at"], float) - - @unittest.skipIf(os.environ["DEPLOYMENT_STAGE"] == "prod", "Do not make test collections public in prod") - def test_collection_flow(self): - # create collection - data = { - "contact_email": "lisbon@gmail.com", - "contact_name": "Madrid Sparkle", - "curator_name": "John Smith", - "description": "Well here are some words", - "links": [ - {"link_name": "a link to somewhere", "link_type": "PROTOCOL", "link_url": "https://protocol.com"} - ], - "name": "my2collection", - } - - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - res = self.session.post(f"{self.api}/dp/v1/collections", data=json.dumps(data), headers=headers) - res.raise_for_status() - data = json.loads(res.content) - collection_id = data["collection_id"] - self.assertStatusCode(requests.codes.created, res) - self.assertIn("collection_id", data) - - with self.subTest("Test created collection is private"): - res = self.session.get(f"{self.api}/dp/v1/collections", headers=headers) - data = json.loads(res.content) - private_collection_ids = [] - for collection in data["collections"]: - if collection["visibility"] == "PRIVATE": - private_collection_ids.append(collection["id"]) - self.assertIn(collection_id, private_collection_ids) - - with self.subTest("Test update collection info"): - updated_data = { - "contact_email": "person@random.com", - "contact_name": "Doctor Who", - "description": "These are different words", - "links": [ - {"link_name": "The Source", "link_type": "DATA_SOURCE", "link_url": "https://datasource.com"} - ], - "name": "lots of cells", - } - res = self.session.put( - f"{self.api}/dp/v1/collections/{collection_id}", data=json.dumps(updated_data), headers=headers - ) - res.raise_for_status() - data = json.loads(res.content) - data.pop("access_type") - for key in updated_data: - self.assertEqual(updated_data[key], data[key]) - - self.upload_and_wait(collection_id, self.test_dataset_uri) - - # make collection public - with self.subTest("Test make collection public"): - body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} - res = self.session.post( - f"{self.api}/dp/v1/collections/{collection_id}/publish", headers=headers, data=json.dumps(body) - ) - res.raise_for_status() - self.assertStatusCode(requests.codes.accepted, res) - - # get canonical collection_id - res = self.session.get(f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - data = json.loads(res.content) - canonical_collection_id = data["id"] - - # check collection returns as public - res = self.session.get(f"{self.api}/dp/v1/collections", headers=headers) - data = json.loads(res.content) - public_collection_ids = [] - for collection in data["collections"]: - if collection["visibility"] == "PUBLIC": - public_collection_ids.append(collection["id"]) - - self.assertIn(canonical_collection_id, public_collection_ids) - - with self.subTest("Test everyone can retrieve a public collection"): - no_auth_headers = {"Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/collections", headers=no_auth_headers) - data = json.loads(res.content) - collection_ids = [x["id"] for x in data["collections"]] - self.assertIn(canonical_collection_id, collection_ids) - - with self.subTest("Test a public collection cannot be tombstoned"): - res = self.session.delete(f"{self.api}/dp/v1/collections/{canonical_collection_id}", headers=headers) - self.assertStatusCode(requests.codes.method_not_allowed, res) - - res = self.session.get(f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - self.assertStatusCode(requests.codes.ok, res) - - @unittest.skipIf(os.environ["DEPLOYMENT_STAGE"] == "prod", "Do not make test collections public in prod") - def test_delete_private_collection(self): - # create collection - data = { - "contact_email": "lisbon@gmail.com", - "contact_name": "Madrid Sparkle", - "curator_name": "John Smith", - "description": "Well here are some words", - "links": [ - {"link_name": "a link to somewhere", "link_type": "PROTOCOL", "link_url": "https://protocol.com"} - ], - "name": "my2collection", - } - - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - res = self.session.post(f"{self.api}/dp/v1/collections", data=json.dumps(data), headers=headers) - res.raise_for_status() - data = json.loads(res.content) - collection_id = data["collection_id"] - self.addCleanup(self.session.delete, f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - self.assertStatusCode(requests.codes.created, res) - self.assertIn("collection_id", data) - - # check created collection returns as private - res = self.session.get(f"{self.api}/dp/v1/collections", headers=headers) - data = json.loads(res.content) - private_collection_ids = [] - for collection in data["collections"]: - if collection["visibility"] == "PRIVATE": - private_collection_ids.append(collection["id"]) - self.assertIn(collection_id, private_collection_ids) - - # delete collection - res = self.session.delete(f"{self.api}/dp/v1/collections/{collection_id}?visibility=PRIVATE", headers=headers) - res.raise_for_status() - self.assertStatusCode(requests.codes.no_content, res) - - # check collection gone - no_auth_headers = {"Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/collections?visibility=PRIVATE", headers=no_auth_headers) - data = json.loads(res.content) - collection_ids = [x["id"] for x in data["collections"]] - self.assertNotIn(collection_id, collection_ids) - - @unittest.skipIf(os.environ["DEPLOYMENT_STAGE"] == "prod", "Do not make test collections public in prod") - def test_dataset_upload_flow(self): - body = { - "contact_email": "lisbon@gmail.com", - "contact_name": "Madrid Sparkle", - "curator_name": "John Smith", - "description": "Well here are some words", - "links": [ - {"link_name": "a link to somewhere", "link_type": "PROTOCOL", "link_url": "https://protocol.com"} - ], - "name": "my2collection", - } - - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - res = self.session.post(f"{self.api}/dp/v1/collections", data=json.dumps(body), headers=headers) +from tests.functional.backend.constants import DATASET_URI +from tests.functional.backend.skip_reason import skip_creation_on_prod +from tests.functional.backend.utils import assertStatusCode, create_test_collection + + +@skip_creation_on_prod +def test_version(session, api_url): + res = session.get(f"{api_url}/dp/v1/deployed_version") + res.raise_for_status() + assert res.status_code == requests.codes.ok + assert len(res.json()["Data Portal"]) > 0 + + +def test_auth(session, api_url, curator_cookie): + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/userinfo", headers=headers) + res.raise_for_status() + assert res.status_code == requests.codes.ok + data = json.loads(res.content) + assert data["email"] == "functest@example.com" + + +def test_root_route(session, api_url): + res = session.get(f"{api_url}/") + res.raise_for_status() + assert res.status_code == requests.codes.ok + + +def test_get_collections(session, api_url): + res = session.get(f"{api_url}/dp/v1/collections") + res.raise_for_status() + assert res.status_code == requests.codes.ok + data = json.loads(res.content) + for collection in data["collections"]: + assert isinstance(collection["id"], str) + assert isinstance(collection["created_at"], float) + + +@skip_creation_on_prod +def test_collection_flow(session, api_url, curator_cookie, upload_dataset, collection_data): + # create collection + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + res = session.post(f"{api_url}/dp/v1/collections", data=json.dumps(collection_data), headers=headers) + res.raise_for_status() + data = json.loads(res.content) + collection_id = data["collection_id"] + assertStatusCode(requests.codes.created, res) + assert "collection_id" in data + + # Test created collection is private + res = session.get(f"{api_url}/dp/v1/collections", headers=headers) + data = json.loads(res.content) + private_collection_ids = [] + for collection in data["collections"]: + if collection["visibility"] == "PRIVATE": + private_collection_ids.append(collection["id"]) + assert collection_id in private_collection_ids + + # Test update collection info + updated_data = { + "contact_email": "person@random.com", + "contact_name": "Doctor Who", + "description": "These are different words", + "links": [{"link_name": "The Source", "link_type": "DATA_SOURCE", "link_url": "https://datasource.com"}], + "name": "lots of cells", + } + res = session.put(f"{api_url}/dp/v1/collections/{collection_id}", data=json.dumps(updated_data), headers=headers) + res.raise_for_status() + data = json.loads(res.content) + data.pop("access_type") + for key in updated_data: + assert updated_data[key] == data[key] + + upload_dataset(collection_id, DATASET_URI) + + # make collection public + body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} + res = session.post(f"{api_url}/dp/v1/collections/{collection_id}/publish", headers=headers, data=json.dumps(body)) + res.raise_for_status() + assertStatusCode(requests.codes.accepted, res) + + # get canonical collection_id + res = session.get(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers) + data = json.loads(res.content) + canonical_collection_id = data["id"] + + # check collection returns as public + res = session.get(f"{api_url}/dp/v1/collections", headers=headers) + data = json.loads(res.content) + public_collection_ids = [] + for collection in data["collections"]: + if collection["visibility"] == "PUBLIC": + public_collection_ids.append(collection["id"]) + + assert canonical_collection_id in public_collection_ids + + # Test everyone can retrieve a public collection + no_auth_headers = {"Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/collections", headers=no_auth_headers) + data = json.loads(res.content) + collection_ids = [x["id"] for x in data["collections"]] + assert canonical_collection_id in collection_ids + + # Test a public collection cannot be tombstoned + res = session.delete(f"{api_url}/dp/v1/collections/{canonical_collection_id}", headers=headers) + assertStatusCode(requests.codes.method_not_allowed, res) + + res = session.get(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers) + assertStatusCode(requests.codes.ok, res) + + +@skip_creation_on_prod +def test_delete_private_collection(session, api_url, curator_cookie, collection_data, request): + # create collection + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + res = session.post(f"{api_url}/dp/v1/collections", data=json.dumps(collection_data), headers=headers) + res.raise_for_status() + data = json.loads(res.content) + collection_id = data["collection_id"] + request.addfinalizer(lambda: session.delete(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers)) + assertStatusCode(requests.codes.created, res) + assert "collection_id" in data + + # check created collection returns as private + res = session.get(f"{api_url}/dp/v1/collections", headers=headers) + data = json.loads(res.content) + private_collection_ids = [] + for collection in data["collections"]: + if collection["visibility"] == "PRIVATE": + private_collection_ids.append(collection["id"]) + + assert collection_id in private_collection_ids + + # delete collection + res = session.delete(f"{api_url}/dp/v1/collections/{collection_id}?visibility=PRIVATE", headers=headers) + res.raise_for_status() + assertStatusCode(requests.codes.no_content, res) + + # check collection gone + no_auth_headers = {"Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/collections?visibility=PRIVATE", headers=no_auth_headers) + data = json.loads(res.content) + collection_ids = [x["id"] for x in data["collections"]] + assert collection_id not in collection_ids + + +@skip_creation_on_prod +def test_dataset_upload_flow_with_dataset(session, curator_cookie, api_url, upload_dataset, request, collection_data): + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + collection_id = create_test_collection(headers, request, session, api_url, collection_data) + _verify_upload_and_delete_succeeded(collection_id, headers, DATASET_URI, session, api_url, upload_dataset) + + +def _verify_upload_and_delete_succeeded(collection_id, headers, dataset_uri, session, api_url, upload_and_wait): + dataset_id = upload_and_wait(collection_id, dataset_uri) + # test non owner cant retrieve status + no_auth_headers = {"Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/datasets/{dataset_id}/status", headers=no_auth_headers) + with pytest.raises(HTTPError): res.raise_for_status() - data = json.loads(res.content) - collection_id = data["collection_id"] - self.addCleanup(self.session.delete, f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - self.assertStatusCode(requests.codes.created, res) - self.assertIn("collection_id", data) - body = {"url": self.test_dataset_uri} - - res = self.session.post( - f"{self.api}/dp/v1/collections/{collection_id}/upload-links", data=json.dumps(body), headers=headers - ) - res.raise_for_status() - dataset_id = json.loads(res.content)["dataset_id"] - self.addCleanup(self.session.delete, f"{self.api}/dp/v1/datasets/{dataset_id}", headers=headers) - - self.assertStatusCode(requests.codes.accepted, res) - - res = self.session.get(f"{self.api}/dp/v1/datasets/{dataset_id}/status", headers=headers) - res.raise_for_status() - data = json.loads(res.content) - self.assertStatusCode(requests.codes.ok, res) - self.assertEqual(data["upload_status"], "WAITING") - - with self.subTest("Test dataset conversion"): - keep_trying = True - expected_upload_statuses = ["WAITING", "UPLOADING", "UPLOADED"] - # conversion statuses can be `None` when/if we hit the status endpoint too early after an upload - expected_conversion_statuses = ["CONVERTING", "CONVERTED", "FAILED", "UPLOADING", "UPLOADED", "NA", None] - timer = time.time() - while keep_trying: - data = None - res = self.session.get(f"{self.api}/dp/v1/datasets/{dataset_id}/status", headers=headers) - res.raise_for_status() - data = json.loads(res.content) - upload_status = data["upload_status"] - if upload_status: - self.assertIn(upload_status, expected_upload_statuses) - - # conversion statuses only returned once uploaded - if upload_status == "UPLOADED": - cxg_status = data.get("cxg_status") - rds_status = data.get("rds_status") - h5ad_status = data.get("h5ad_status") - self.assertIn(data.get("cxg_status"), expected_conversion_statuses) - if cxg_status == "FAILED": - self.fail(f"CXG CONVERSION FAILED. Status: {data}, Check logs for dataset: {dataset_id}") - if rds_status == "FAILED": - self.fail(f"RDS CONVERSION FAILED. Status: {data}, Check logs for dataset: {dataset_id}") - if h5ad_status == "FAILED": - self.fail(f"Anndata CONVERSION FAILED. Status: {data}, Check logs for dataset: {dataset_id}") - if cxg_status == rds_status == h5ad_status == "UPLOADED": - keep_trying = False - if time.time() >= timer + 600: - raise TimeoutError( - f"Dataset upload or conversion timed out after 10 min. Check logs for dataset: {dataset_id}" - ) - time.sleep(10) - - with self.subTest("test non owner cant retrieve status"): - no_auth_headers = {"Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/datasets/{dataset_id}/status", headers=no_auth_headers) - with self.assertRaises(HTTPError): - res.raise_for_status() - - with self.subTest("Test dataset deletion"): - res = self.session.delete(f"{self.api}/dp/v1/datasets/{dataset_id}", headers=headers) - res.raise_for_status() - self.assertStatusCode(requests.codes.accepted, res) - - # Check that the dataset is gone from collection version - res = self.session.get(f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - data = json.loads(res.content) - datasets = data["datasets"] - dataset_ids = [dataset.get("id") for dataset in datasets] - self.assertNotIn(dataset_id, dataset_ids) + # Test dataset deletion + res = session.delete(f"{api_url}/dp/v1/datasets/{dataset_id}", headers=headers) + res.raise_for_status() + assertStatusCode(requests.codes.accepted, res) + + # Check that the dataset is gone from collection version + res = session.get(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers) + data = json.loads(res.content) + datasets = data["datasets"] + dataset_ids = [dataset.get("id") for dataset in datasets] + assert dataset_id not in dataset_ids diff --git a/tests/functional/backend/corpora/test_api_key.py b/tests/functional/backend/corpora/test_api_key.py index 6eb6a115bb1c9..30f0cd925f1ed 100644 --- a/tests/functional/backend/corpora/test_api_key.py +++ b/tests/functional/backend/corpora/test_api_key.py @@ -2,72 +2,68 @@ import time import unittest +import pytest from tenacity import retry, stop_after_attempt, wait_incrementing -from tests.functional.backend.common import BaseFunctionalTestCase +from tests.functional.backend.utils import assertStatusCode -@unittest.skipIf( +@pytest.mark.skipif( os.environ["DEPLOYMENT_STAGE"] == "rdev", - "Skipping for the rdev environment to avoid a flakey race condition. Uncomment if developing this " + reason="Skipping for the rdev environment to avoid a flakey race condition. Uncomment if developing this " "feature to run in rdev. Restore this comment before merging to main. See " "https://github.com/chanzuckerberg/single-cell-data-portal/issues/6198", ) -class TestApiKey(BaseFunctionalTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() +def test_api_key_crud(session, api_url, curator_cookie, request): + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} - def test_api_key_crud(self): - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} + def _cleanup(): + session.delete(f"{api_url}/dp/v1/auth/key", headers=headers) - def _cleanup(): - self.session.delete(f"{self.api}/dp/v1/auth/key", headers=headers) + request.addfinalizer(_cleanup) - self.addCleanup(_cleanup) + response = session.get(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(404, response) - response = self.session.get(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(404, response) + response = session.post(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(201, response) + key_1 = response.json()["key"] - response = self.session.post(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(201, response) - key_1 = response.json()["key"] + response = session.post( + f"{api_url}/curation/v1/auth/token", + headers={"x-api-key": f"{key_1}", "Content-Type": "application/json"}, + ) + assertStatusCode(201, response) + access_token = response.json()["access_token"] + assert access_token - response = self.session.post( - f"{self.api}/curation/v1/auth/token", - headers={"x-api-key": f"{key_1}", "Content-Type": "application/json"}, - ) - self.assertStatusCode(201, response) - access_token = response.json()["access_token"] - self.assertTrue(access_token) + # wait for auth0 User-Api-Key link to update + @retry(wait=wait_incrementing(0, 10, 30), stop=stop_after_attempt(4)) + def get_key(): + response = session.get(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(200, response) - # wait for auth0 User-Api-Key link to update - @retry(wait=wait_incrementing(0, 10, 30), stop=stop_after_attempt(4)) - def get_key(): - response = self.session.get(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(200, response) + get_key() # wait for auth0 User-Api-Key link to update - get_key() # wait for auth0 User-Api-Key link to update + response = session.post(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(201, response) + key_2 = response.json()["key"] + assert key_1 != key_2 - response = self.session.post(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(201, response) - key_2 = response.json()["key"] - self.assertNotEqual(key_1, key_2) + # wait for auth0 User-Api-Key link to update + time.sleep(30) - # wait for auth0 User-Api-Key link to update - time.sleep(30) + response = session.get(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(200, response) - response = self.session.get(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(200, response) + response = session.delete(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(202, response) - response = self.session.delete(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(202, response) + response = session.delete(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(404, response) - response = self.session.delete(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(404, response) - - response = self.session.get(f"{self.api}/dp/v1/auth/key", headers=headers) - self.assertStatusCode(404, response) + response = session.get(f"{api_url}/dp/v1/auth/key", headers=headers) + assertStatusCode(404, response) if __name__ == "__main__": diff --git a/tests/functional/backend/corpora/test_collection_access.py b/tests/functional/backend/corpora/test_collection_access.py index 2fc431cf70470..a33b7ba676054 100644 --- a/tests/functional/backend/corpora/test_collection_access.py +++ b/tests/functional/backend/corpora/test_collection_access.py @@ -1,77 +1,102 @@ import unittest +import pytest import requests from jose import jwt -from tests.functional.backend.common import BaseFunctionalTestCase +from tests.functional.backend.distributed import distributed_singleton +from tests.functional.backend.utils import assertStatusCode, get_auth_token, make_cookie -class TestCollectionAccess(BaseFunctionalTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.supercurator_token = cls.get_auth_token( +@pytest.fixture(scope="session") +def supercurator_token(session, config, deployment_stage, tmp_path_factory, worker_id): + def _supercurator_token(): + return get_auth_token( "supercurator@example.com", - cls.config.test_auth0_user_account_password, + config.test_auth0_user_account_password, + config=config, + session=session, + deployment_stage=deployment_stage, additional_claims=["write:collections"], ) - cls.supercurator_cookie = cls.make_cookie(cls.supercurator_token) - cls.nocollection_token = cls.get_auth_token( - "nocollection@example.com", - cls.config.test_auth0_user_account_password, - additional_claims=["write:collections"], - ) - cls.nocollection_cookie = cls.make_cookie(cls.nocollection_token) - cls.curator_token = cls.get_auth_token( + + return distributed_singleton(tmp_path_factory, worker_id, _supercurator_token) + + +@pytest.fixture(scope="session") +def nocollection_token(session, config, deployment_stage, tmp_path_factory, worker_id): + def _nocollection_token(): + return get_auth_token( "nocollection@example.com", - cls.config.test_auth0_user_account_password, + password=config.test_auth0_user_account_password, + config=config, + session=session, + deployment_stage=deployment_stage, additional_claims=["write:collections"], ) - cls.curator_cookie = cls.make_cookie(cls.curator_token) - - def test_collection_access(self): - """Test that only a super curator has access to all of the collections""" - # get collections for nocollection user - headers = {"Cookie": f"cxguser={self.nocollection_cookie}", "Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/collections", headers=headers) - self.assertStatusCode(requests.codes.ok, res) - # length should be 0 - collections = res.json()["collections"] - private_collections = [c for c in collections if c["visibility"] == "PRIVATE"] - self.assertEqual(len(private_collections), 0) - - # get collection for supercurator user - headers = {"Cookie": f"cxguser={self.supercurator_cookie}", "Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/collections", headers=headers) - self.assertStatusCode(requests.codes.ok, res) - # len should be a lot - superuser_collections = [c for c in res.json()["collections"] if c["visibility"] == "PRIVATE"] - - # get collection for curator user - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - res = self.session.get(f"{self.api}/dp/v1/collections", headers=headers) - self.assertStatusCode(requests.codes.ok, res) - - # len should be less than super curator - curator_collections = [c for c in res.json()["collections"] if c["visibility"] == "PRIVATE"] - - self.assertLess(len(curator_collections), len(superuser_collections)) - - def test_claims(self): - access_token = self.supercurator_token["access_token"] - token = jwt.get_unverified_claims(access_token) - claims = token["scope"] - self.assertIn("write:collections", claims) - - access_token = self.curator_token["access_token"] - token = jwt.get_unverified_claims(access_token) - claims = token["scope"] - self.assertNotIn("write:collections", claims) - - access_token = self.nocollection_token["access_token"] - token = jwt.get_unverified_claims(access_token) - claims = token["scope"] - self.assertNotIn("write:collections", claims) + + return distributed_singleton(tmp_path_factory, worker_id, _nocollection_token) + + +@pytest.fixture(scope="session") +def supercurator_cookie(supercurator_token): + return make_cookie(supercurator_token) + + +@pytest.fixture(scope="session") +def nocollection_cookie(nocollection_token): + return make_cookie(nocollection_token) + + +def test_nocollection_access(session, api_url, nocollection_cookie): + """Test that a user with no private collections sees no private collections""" + headers = {"Cookie": f"cxguser={nocollection_cookie}", "Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/collections", headers=headers) + assertStatusCode(requests.codes.ok, res) + collections = res.json()["collections"] + private_collections = [c for c in collections if c["visibility"] == "PRIVATE"] + assert len(private_collections) == 0 + + +def test_collection_access(session, api_url, supercurator_cookie, curator_cookie): + """Test that only a super curator has access to all of the collections""" + # get collection for supercurator user + headers = {"Cookie": f"cxguser={supercurator_cookie}", "Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/collections", headers=headers) + assertStatusCode(requests.codes.ok, res) + # len should be a lot + superuser_collections = [c for c in res.json()["collections"] if c["visibility"] == "PRIVATE"] + + # get collection for curator user + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + res = session.get(f"{api_url}/dp/v1/collections", headers=headers) + assertStatusCode(requests.codes.ok, res) + + # len should be less than super curator + curator_collections = [c for c in res.json()["collections"] if c["visibility"] == "PRIVATE"] + + assert len(curator_collections) < len(superuser_collections) + + +def test_super_curator_claims(supercurator_token): + access_token = supercurator_token["access_token"] + token = jwt.get_unverified_claims(access_token) + claims = token["scope"] + assert "write:collections" in claims + + +def test_curator_claims(functest_auth_token): + access_token = functest_auth_token["access_token"] + token = jwt.get_unverified_claims(access_token) + claims = token["scope"] + assert "write:collections" not in claims + + +def test_nocollection_claims(nocollection_token): + access_token = nocollection_token["access_token"] + token = jwt.get_unverified_claims(access_token) + claims = token["scope"] + assert "write:collections" not in claims if __name__ == "__main__": diff --git a/tests/functional/backend/corpora/test_revisions.py b/tests/functional/backend/corpora/test_revisions.py index 475ba1d7ea678..8a8687765c384 100644 --- a/tests/functional/backend/corpora/test_revisions.py +++ b/tests/functional/backend/corpora/test_revisions.py @@ -7,244 +7,192 @@ from tenacity import retry, stop_after_attempt, wait_fixed from backend.common.constants import DATA_SUBMISSION_POLICY_VERSION -from tests.functional.backend.common import BaseFunctionalTestCase - - -class TestRevisions(BaseFunctionalTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - def create_collection(self, headers): - data = { - "contact_email": "lisbon@gmail.com", - "contact_name": "Madrid Sparkle", - "curator_name": "John Smith", - "description": "Well here are some words", - "links": [{"link_name": "a link to somewhere", "link_type": "PROTOCOL", "link_url": "http://protocol.com"}], - "name": "my2collection", - } - - res = self.session.post(f"{self.api}/dp/v1/collections", data=json.dumps(data), headers=headers) - res.raise_for_status() - data = json.loads(res.content) - collection_id = data["collection_id"] - - # Doesn't work since the collection is published. See issue #1375 - # Should work now via cxg-admin role thru Curation API - curation_api_headers = {"Authorization": f"Bearer {self.curation_api_access_token}"} - self.addCleanup( - self.session.delete, - f"{self.api}/curation/v1/collections/{collection_id}?delete_published=true", - headers=curation_api_headers, - ) - self.assertStatusCode(requests.codes.created, res) - self.assertIn("collection_id", data) - return collection_id - - def create_explorer_url(self, dataset_id): - return f"https://cellxgene.{self.deployment_stage}.single-cell.czi.technology/e/{dataset_id}.cxg/" - - # TODO: Remove rdev from skip list. Rdev Explorer is required for this test to pass. - @unittest.skipIf(os.environ["DEPLOYMENT_STAGE"] in ["prod", "rdev"], "Do not make test collections public in prod") - def test_revision_flow(self): - - headers = {"Cookie": f"cxguser={self.curator_cookie}", "Content-Type": "application/json"} - - collection_id = self.create_collection(headers) - - dataset_1_dropbox_url = self.test_dataset_uri - dataset_2_dropbox_url = self.test_dataset_uri - - # Uploads a dataset - self.upload_and_wait(collection_id, dataset_1_dropbox_url) - - # make collection public - with self.subTest("Test make collection public"): - body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} - res = self.session.post( - f"{self.api}/dp/v1/collections/{collection_id}/publish", headers=headers, data=json.dumps(body) - ) - res.raise_for_status() - self.assertStatusCode(requests.codes.accepted, res) - - # get canonical collection id, post-publish - res = self.session.get(f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - data = json.loads(res.content) - canonical_collection_id = data["id"] - - dataset_response = self.session.get(f"{self.api}/dp/v1/collections/{canonical_collection_id}").json()[ - "datasets" - ][0] - dataset_id = dataset_response["id"] - explorer_url = dataset_response["dataset_deployments"][0]["url"] - - meta_payload_before_revision_res = self.session.get(f"{self.api}/dp/v1/datasets/meta?url={explorer_url}") - meta_payload_before_revision_res.raise_for_status() - meta_payload_before_revision = meta_payload_before_revision_res.json() - - # Endpoint is eventually consistent - schema_before_revision = self.get_schema_with_retries(dataset_id).json() - - # Start a revision - res = self.session.post(f"{self.api}/dp/v1/collections/{canonical_collection_id}", headers=headers) - self.assertStatusCode(201, res) - data = json.loads(res.content) - revision_id = data["id"] - - with self.subTest("Test updating a dataset in a revision does not effect the published dataset"): - private_dataset_id = res.json()["datasets"][0]["id"] - - meta_payload_res = self.session.get(f"{self.api}/dp/v1/datasets/meta?url={explorer_url}") - meta_payload_res.raise_for_status() - meta_payload = meta_payload_res.json() - - self.assertDictEqual(meta_payload_before_revision, meta_payload) - - # Upload a new dataset - self.upload_and_wait( - revision_id, - dataset_2_dropbox_url, - existing_dataset_id=private_dataset_id, - ) - - # Check that the published dataset is still the same - meta_payload_after_revision = self.session.get(f"{self.api}/dp/v1/datasets/meta?url={explorer_url}").json() - self.assertDictEqual(meta_payload_before_revision, meta_payload_after_revision) - schema_after_revision = self.get_schema_with_retries(dataset_id).json() - self.assertDictEqual(schema_before_revision, schema_after_revision) - - with self.subTest("Publishing a revised dataset replaces the original dataset"): - # Publish the revision - body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} - res = self.session.post( - f"{self.api}/dp/v1/collections/{revision_id}/publish", headers=headers, data=json.dumps(body) - ) - res.raise_for_status() - self.assertStatusCode(requests.codes.accepted, res) - - dataset_meta_payload = self.session.get(f"{self.api}/dp/v1/datasets/meta?url={explorer_url}").json() - self.assertTrue( - dataset_meta_payload["s3_uri"].startswith(f"s3://hosted-cellxgene-{os.environ['DEPLOYMENT_STAGE']}/") - ) - self.assertTrue(dataset_meta_payload["s3_uri"].endswith(".cxg/")) - self.assertIn( - dataset_meta_payload["dataset_id"], - dataset_meta_payload["s3_uri"], - "The id of the S3_URI should be the revised dataset id.", - ) - - # TODO: add `And the explorer url redirects appropriately` - - # Start a new revision - res = self.session.post(f"{self.api}/dp/v1/collections/{canonical_collection_id}", headers=headers) - self.assertStatusCode(201, res) - revision_id = res.json()["id"] - - # Get datasets for the collection (before uploading) - public_datasets_before = self.session.get(f"{self.api}/dp/v1/collections/{canonical_collection_id}").json()[ - "datasets" - ] - - # Upload a new dataset - another_dataset_id = self.upload_and_wait(revision_id, dataset_1_dropbox_url) - - with self.subTest("Adding a dataset to a revision does not impact public datasets in that collection"): - # Get datasets for the collection (after uploading) - public_datasets_after = self.session.get(f"{self.api}/dp/v1/collections/{canonical_collection_id}").json()[ - "datasets" - ] - self.assertCountEqual(public_datasets_before, public_datasets_after) - - # Publish the revision - body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} - res = self.session.post( - f"{self.api}/dp/v1/collections/{revision_id}/publish", headers=headers, data=json.dumps(body) - ) - res.raise_for_status() - self.assertStatusCode(requests.codes.accepted, res) - - with self.subTest( - "Publishing a revision that contains a new dataset updates " - "the collection page for the data portal (with the new dataset)" - ): - # Check if the last updated dataset_id is among the public datasets - public_datasets = self.session.get(f"{self.api}/dp/v1/collections/{canonical_collection_id}").json()[ - "datasets" - ] - self.assertEqual(len(public_datasets), 2) - ids = [dataset["id"] for dataset in public_datasets] - self.assertIn(another_dataset_id, ids) - - # Start a revision - res = self.session.post(f"{self.api}/dp/v1/collections/{canonical_collection_id}", headers=headers) - self.assertStatusCode(201, res) - revision_id = res.json()["id"] - - # This only works if you pick the non replaced dataset. - dataset_to_delete = res.json()["datasets"][1] - revision_deleted_dataset_id = dataset_to_delete["id"] - published_explorer_url = self.create_explorer_url(revision_deleted_dataset_id) - - # Delete (tombstone) a dataset (using admin privileges) within the revision - revision_datasets = self.session.get(f"{self.api}/curation/v1/collections/{revision_id}").json()["datasets"] - dataset_id_to_delete = None - for dataset in revision_datasets: - if dataset["dataset_version_id"] == revision_deleted_dataset_id: - dataset_id_to_delete = dataset["dataset_id"] - - curation_api_headers = {"Authorization": f"Bearer {self.curation_api_access_token}"} - res = self.session.delete( - f"{self.api}/curation/v1/collections/{revision_id}/datasets/{dataset_id_to_delete}?delete_published=true", - headers=curation_api_headers, - ) - self.assertStatusCode(202, res) - - with self.subTest("Deleting a dataset does not effect the published dataset"): - # Check if the dataset is still available - res = self.session.get(f"{self.api}/dp/v1/datasets/meta?url={published_explorer_url}") - self.assertStatusCode(200, res) - - # Endpoint is eventually consistent - res = self.get_schema_with_retries(revision_deleted_dataset_id) - self.assertStatusCode(200, res) - - with self.subTest("Publishing a revision that deletes a dataset removes it from the data portal"): - # Publish the revision - body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} - res = self.session.post( - f"{self.api}/dp/v1/collections/{revision_id}/publish", headers=headers, data=json.dumps(body) - ) - res.raise_for_status() - self.assertStatusCode(requests.codes.accepted, res) - - # Check that the dataset doesn't exist anymore - res = self.session.get(f"{self.api}/dp/v1/collections/{collection_id}", headers=headers) - res.raise_for_status() - datasets = [dataset["id"] for dataset in res.json()["datasets"]] - self.assertEqual(1, len(datasets)) - self.assertNotIn(revision_deleted_dataset_id, datasets) - - def get_schema_with_retries(self, dataset_id, desired_http_status_code=requests.codes.ok): - @retry(wait=wait_fixed(1), stop=stop_after_attempt(50)) - def get_s3_uri(): - s3_uri_res = self.session.get( - f"{self.api}/cellxgene/e/{dataset_id}.cxg/api/v0.3/s3_uri", allow_redirects=False - ) - assert s3_uri_res.status_code == desired_http_status_code - return s3_uri_res - - @retry(wait=wait_fixed(1), stop=stop_after_attempt(50)) - def get_schema(s3_uri_response_object): - # parse s3_uri_response_object content - s3_path = s3_uri_response_object.content.decode("utf-8").strip().strip('"') - # s3_uri endpoints use double-encoded s3 uri path parameters - s3_path_url = quote(quote(s3_path, safe="")) - schema_res = self.session.get( - f"{self.api}/cellxgene/s3_uri/{s3_path_url}/api/v0.3/schema", allow_redirects=False - ) - assert schema_res.status_code == requests.codes.ok - return schema_res - - s3_uri_response = get_s3_uri() - return get_schema(s3_uri_response) +from tests.functional.backend.constants import DATASET_URI +from tests.functional.backend.skip_reason import skip_creation_on_prod, skip_no_explorer_in_rdev +from tests.functional.backend.utils import assertStatusCode, create_explorer_url, create_test_collection + + +@skip_creation_on_prod +@skip_no_explorer_in_rdev +def test_revision_flow( + curator_cookie, + session, + api_url, + upload_dataset, + curation_api_access_token, + deployment_stage, + request, + collection_data, +): + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + + # create a test collection + collection_id = create_test_collection(headers, request, session, api_url, collection_data) + + dataset_1_dropbox_url = dataset_2_dropbox_url = DATASET_URI + + # Uploads a dataset + upload_dataset(collection_id, dataset_1_dropbox_url) + + # make collection public + body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} + res = session.post(f"{api_url}/dp/v1/collections/{collection_id}/publish", headers=headers, data=json.dumps(body)) + res.raise_for_status() + assertStatusCode(requests.codes.accepted, res) + + # get canonical collection id, post-publish + res = session.get(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers) + data = json.loads(res.content) + canonical_collection_id = data["id"] + + dataset_response = session.get(f"{api_url}/dp/v1/collections/{canonical_collection_id}").json()["datasets"][0] + dataset_id = dataset_response["id"] + explorer_url = dataset_response["dataset_deployments"][0]["url"] + + meta_payload_before_revision_res = session.get(f"{api_url}/dp/v1/datasets/meta?url={explorer_url}") + meta_payload_before_revision_res.raise_for_status() + meta_payload_before_revision = meta_payload_before_revision_res.json() + + # Endpoint is eventually consistent + schema_before_revision = get_schema_with_retries(dataset_id, api_url, session).json() + + # Start a revision + res = session.post(f"{api_url}/dp/v1/collections/{canonical_collection_id}", headers=headers) + assertStatusCode(201, res) + data = json.loads(res.content) + revision_id = data["id"] + + # "Test updating a dataset in a revision does not effect the published dataset" + private_dataset_id = res.json()["datasets"][0]["id"] + + meta_payload_res = session.get(f"{api_url}/dp/v1/datasets/meta?url={explorer_url}") + meta_payload_res.raise_for_status() + meta_payload = meta_payload_res.json() + + assert meta_payload_before_revision == meta_payload + + # Upload a new dataset + upload_dataset( + revision_id, + dataset_2_dropbox_url, + existing_dataset_id=private_dataset_id, + ) + + # Check that the published dataset is still the same + meta_payload_after_revision = session.get(f"{api_url}/dp/v1/datasets/meta?url={explorer_url}").json() + assert meta_payload_before_revision == meta_payload_after_revision + schema_after_revision = get_schema_with_retries(dataset_id, api_url, session).json() + assert schema_before_revision == schema_after_revision + + # Publishing a revised dataset replaces the original dataset + body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} + res = session.post(f"{api_url}/dp/v1/collections/{revision_id}/publish", headers=headers, data=json.dumps(body)) + res.raise_for_status() + assertStatusCode(requests.codes.accepted, res) + + dataset_meta_payload = session.get(f"{api_url}/dp/v1/datasets/meta?url={explorer_url}").json() + assert dataset_meta_payload["s3_uri"].startswith(f"s3://hosted-cellxgene-{os.environ['DEPLOYMENT_STAGE']}/") + assert dataset_meta_payload["s3_uri"].endswith(".cxg/") + assert ( + dataset_meta_payload["dataset_id"] in dataset_meta_payload["s3_uri"] + ), "The id of the S3_URI should be the revised dataset id." + + # TODO: add `And the explorer url redirects appropriately` + + # Start a new revision + res = session.post(f"{api_url}/dp/v1/collections/{canonical_collection_id}", headers=headers) + assertStatusCode(201, res) + revision_id = res.json()["id"] + + # Get datasets for the collection (before uploading) + public_datasets_before = session.get(f"{api_url}/dp/v1/collections/{canonical_collection_id}").json()["datasets"] + + # Upload a new dataset + another_dataset_id = upload_dataset(revision_id, dataset_1_dropbox_url) + + # Adding a dataset to a revision does not impact public datasets in that collection + # Get datasets for the collection (after uploading) + public_datasets_after = session.get(f"{api_url}/dp/v1/collections/{canonical_collection_id}").json()["datasets"] + unittest.TestCase().assertCountEqual(public_datasets_before, public_datasets_after) + + # Publish the revision + body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} + res = session.post(f"{api_url}/dp/v1/collections/{revision_id}/publish", headers=headers, data=json.dumps(body)) + res.raise_for_status() + assertStatusCode(requests.codes.accepted, res) + + # Publishing a revision that contains a new dataset updates the collection page for the data portal (with the new + # dataset) + # Check if the last updated dataset_id is among the public datasets + public_datasets = session.get(f"{api_url}/dp/v1/collections/{canonical_collection_id}").json()["datasets"] + assert len(public_datasets) == 2 + ids = [dataset["id"] for dataset in public_datasets] + assert another_dataset_id in ids + + # Start a revision + res = session.post(f"{api_url}/dp/v1/collections/{canonical_collection_id}", headers=headers) + assertStatusCode(201, res) + revision_id = res.json()["id"] + + # This only works if you pick the non replaced dataset. + dataset_to_delete = res.json()["datasets"][1] + revision_deleted_dataset_id = dataset_to_delete["id"] + published_explorer_url = create_explorer_url(revision_deleted_dataset_id, deployment_stage) + + # Delete (tombstone) a dataset (using admin privileges) within the revision + revision_datasets = session.get(f"{api_url}/curation/v1/collections/{revision_id}").json()["datasets"] + dataset_id_to_delete = None + for dataset in revision_datasets: + if dataset["dataset_version_id"] == revision_deleted_dataset_id: + dataset_id_to_delete = dataset["dataset_id"] + + curation_api_headers = {"Authorization": f"Bearer {curation_api_access_token}"} + res = session.delete( + f"{api_url}/curation/v1/collections/{revision_id}/datasets/{dataset_id_to_delete}?delete_published=true", + headers=curation_api_headers, + ) + assertStatusCode(202, res) + + # Deleting a dataset does not effect the published dataset" + # Check if the dataset is still available + res = session.get(f"{api_url}/dp/v1/datasets/meta?url={published_explorer_url}") + assertStatusCode(200, res) + + # Endpoint is eventually consistent + res = get_schema_with_retries(revision_deleted_dataset_id, api_url, session) + assertStatusCode(200, res) + + # Publishing a revision that deletes a dataset removes it from the data portal + # Publish the revision + body = {"data_submission_policy_version": DATA_SUBMISSION_POLICY_VERSION} + res = session.post(f"{api_url}/dp/v1/collections/{revision_id}/publish", headers=headers, data=json.dumps(body)) + res.raise_for_status() + assertStatusCode(requests.codes.accepted, res) + + # Check that the dataset doesn't exist anymore + res = session.get(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers) + res.raise_for_status() + datasets = [dataset["id"] for dataset in res.json()["datasets"]] + assert len(datasets) == 1 + assert revision_deleted_dataset_id not in datasets + + +def get_schema_with_retries(dataset_id, api_url, session, desired_http_status_code=requests.codes.ok): + @retry(wait=wait_fixed(2), stop=stop_after_attempt(50)) + def get_s3_uri(): + s3_uri_res = session.get(f"{api_url}/cellxgene/e/{dataset_id}.cxg/api/v0.3/s3_uri", allow_redirects=False) + assert s3_uri_res.status_code == desired_http_status_code + return s3_uri_res + + @retry(wait=wait_fixed(2), stop=stop_after_attempt(50)) + def get_schema(s3_uri_response_object): + # parse s3_uri_response_object content + s3_path = s3_uri_response_object.content.decode("utf-8").strip().strip('"') + # s3_uri endpoints use double-encoded s3 uri path parameters + s3_path_url = quote(quote(s3_path, safe="")) + schema_res = session.get(f"{api_url}/cellxgene/s3_uri/{s3_path_url}/api/v0.3/schema", allow_redirects=False) + assert schema_res.status_code == requests.codes.ok + return schema_res + + s3_uri_response = get_s3_uri() + return get_schema(s3_uri_response) diff --git a/tests/functional/backend/distributed.py b/tests/functional/backend/distributed.py new file mode 100644 index 0000000000000..a3993b07b9e16 --- /dev/null +++ b/tests/functional/backend/distributed.py @@ -0,0 +1,26 @@ +import json +from typing import Callable + +from filelock import FileLock + + +def distributed_singleton(tmp_path_factory, worker_id: str, func: Callable) -> dict: + """ + This function wraps a pytest fixture so it is only instantiated once and shared across all workers in a distributed + test run. + """ + if worker_id == "master": + # not executing with multiple workers, just produce the data and let + # pytest's fixture caching do its job + return func() + # get the temp directory shared by all workers + root_tmp_dir = tmp_path_factory.getbasetemp().parent + + fn = root_tmp_dir.joinpath(func.__name__ + ".json") + with FileLock(str(fn) + ".lock"): + if fn.is_file(): + data = json.loads(fn.read_text()) + else: + data = func() + fn.write_text(json.dumps(data)) + return data diff --git a/tests/functional/backend/skip_reason.py b/tests/functional/backend/skip_reason.py new file mode 100644 index 0000000000000..64cd013486eb7 --- /dev/null +++ b/tests/functional/backend/skip_reason.py @@ -0,0 +1,10 @@ +import os + +import pytest + +skip_creation_on_prod = pytest.mark.skipif( + os.environ["DEPLOYMENT_STAGE"] == "prod", reason="Do not make test collections public in prod" +) +skip_no_explorer_in_rdev = pytest.mark.skipif( + os.environ["DEPLOYMENT_STAGE"] == "rdev", reason="Explorer is not available in rdev" +) diff --git a/tests/functional/backend/utils.py b/tests/functional/backend/utils.py new file mode 100644 index 0000000000000..54f130269d265 --- /dev/null +++ b/tests/functional/backend/utils.py @@ -0,0 +1,154 @@ +import base64 +import json +import time +from typing import Optional + +import requests +from requests import Session +from requests.adapters import HTTPAdapter, Retry + +from backend.common.corpora_config import CorporaAuthConfig +from tests.functional.backend.constants import AUDIENCE + + +def get_auth_token( + username: str, + password: str, + session: Session, + config: CorporaAuthConfig, + deployment_stage: str, + additional_claims: Optional[list] = None, +) -> dict[str, str]: + standard_claims = "openid profile email offline" + if additional_claims: + additional_claims.append(standard_claims) + claims = " ".join(additional_claims) + else: + claims = standard_claims + response = session.post( + "https://czi-cellxgene-dev.us.auth0.com/oauth/token", # hardcoded becasue this is only needed for dev and rdev + headers={"content-type": "application/x-www-form-urlencoded"}, + data=dict( + grant_type="password", + username=username, + password=password, + audience=AUDIENCE.get(deployment_stage), + scope=claims, + client_id=config.client_id, + client_secret=config.client_secret, + ), + ) + response.raise_for_status() + access_token = response.json()["access_token"] + id_token = response.json()["id_token"] + token = {"access_token": access_token, "id_token": id_token} + return token + + +def make_cookie(auth_token: dict) -> str: + return base64.b64encode(json.dumps(auth_token).encode("utf-8")).decode() + + +def assertStatusCode(actual: int, expected_response: requests.Response): + request_id = expected_response.headers.get("X-Request-Id") + assert actual == expected_response.status_code, f"{request_id=}" + + +def create_test_collection(headers, request, session, api_url, body): + res = session.post(f"{api_url}/dp/v1/collections", data=json.dumps(body), headers=headers) + res.raise_for_status() + data = json.loads(res.content) + collection_id = data["collection_id"] + request.addfinalizer(lambda: session.delete(f"{api_url}/dp/v1/collections/{collection_id}", headers=headers)) + assertStatusCode(requests.codes.created, res) + return collection_id + + +def create_explorer_url(dataset_id: str, deployment_stage: str) -> str: + return f"https://cellxgene.{deployment_stage}.single-cell.czi.technology/e/{dataset_id}.cxg/" + + +def upload_and_wait(session, api_url, curator_cookie, collection_id, dropbox_url, existing_dataset_id=None): + headers = {"Cookie": f"cxguser={curator_cookie}", "Content-Type": "application/json"} + body = {"url": dropbox_url} + errors = [] + if existing_dataset_id is None: + res = session.post( + f"{api_url}/dp/v1/collections/{collection_id}/upload-links", data=json.dumps(body), headers=headers + ) + else: + body["id"] = existing_dataset_id + res = session.put( + f"{api_url}/dp/v1/collections/{collection_id}/upload-links", data=json.dumps(body), headers=headers + ) + + res.raise_for_status() + dataset_id = json.loads(res.content)["dataset_id"] + assert res.status_code == requests.codes.accepted + + keep_trying = True + expected_upload_statuses = ["WAITING", "UPLOADING", "UPLOADED"] + expected_conversion_statuses = ["CONVERTING", "CONVERTED", "FAILED", "UPLOADING", "UPLOADED", "NA", None] + timer = time.time() + while keep_trying: + res = session.get(f"{api_url}/dp/v1/datasets/{dataset_id}/status", headers=headers) + res.raise_for_status() + data = json.loads(res.content) + upload_status = data["upload_status"] + if upload_status: + assert upload_status in expected_upload_statuses + + if upload_status == "UPLOADED": + cxg_status = data.get("cxg_status") + rds_status = data.get("rds_status") + h5ad_status = data.get("h5ad_status") + assert data.get("cxg_status") in expected_conversion_statuses + if cxg_status == "FAILED": + errors.append(f"CXG CONVERSION FAILED. Status: {data}, Check logs for dataset: {dataset_id}") + if rds_status == "FAILED": + errors.append(f"RDS CONVERSION FAILED. Status: {data}, Check logs for dataset: {dataset_id}") + if h5ad_status == "FAILED": + errors.append(f"Anndata CONVERSION FAILED. Status: {data}, Check logs for dataset: {dataset_id}") + if cxg_status == rds_status == h5ad_status == "UPLOADED" or errors: + keep_trying = False + if time.time() >= timer + 1200: + raise TimeoutError( + f"Dataset upload or conversion timed out after 10 min. Check logs for dataset: {dataset_id}" + ) + time.sleep(10) + return {"dataset_id": dataset_id, "errors": errors} + + +def make_session(proxy_auth_token): + retry_strategy = Retry( + total=7, + backoff_factor=2, + status_forcelist=[500, 502, 503, 504], + allowed_methods={"DELETE", "GET", "HEAD", "PUT", "POST"}, + ) + http_adapter = HTTPAdapter(max_retries=retry_strategy) + session = requests.Session() + session.mount("https://", http_adapter) + session.headers.update(**proxy_auth_token) + return session + + +def make_proxy_auth_token(config, deployment_stage) -> dict: + """ + Generate a proxy token for rdev. If running in parallel mode this will be shared across workers to avoid rate + limiting + """ + + if deployment_stage == "rdev": + payload = { + "client_id": config.test_app_id, + "client_secret": config.test_app_secret, + "grant_type": "client_credentials", + "audience": "https://api.cellxgene.dev.single-cell.czi.technology/dp/v1/curator", + } + headers = {"content-type": "application/json"} + res = requests.post("https://czi-cellxgene-dev.us.auth0.com/oauth/token", json=payload, headers=headers) + res.raise_for_status() + access_token = res.json()["access_token"] + return {"Authorization": f"Bearer {access_token}"} + return {} diff --git a/tests/functional/backend/wmg/test_wmg_api.py b/tests/functional/backend/wmg/test_wmg_api.py index e4ae99c38ab7c..5a753ec550557 100644 --- a/tests/functional/backend/wmg/test_wmg_api.py +++ b/tests/functional/backend/wmg/test_wmg_api.py @@ -1,9 +1,8 @@ import json -import unittest +import pytest import requests -from tests.functional.backend.common import BaseFunctionalTestCase from tests.functional.backend.wmg.fixtures import ( markers_happy_path, markers_missing_tissue, @@ -12,106 +11,112 @@ secondary_filter_extreme_case_request_data, ) -# Note that these tests share fixtures and general test paths with the wmg api performance tests + +@pytest.fixture(scope="module") +def setup(api_url): + api = f"{api_url}/wmg/v1" + res = requests.get(f"{api}/primary_filter_dimensions") + primary_filter_dimensions = json.loads(res.content) + return api, primary_filter_dimensions -@unittest.skip("Skipping WMG V1 Functional Tests. WMG V1 API is deprecated. These tests will be ported to WMG V2") -class TestWmgApi(BaseFunctionalTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.api = f"{cls.api}/wmg/v1" +# Note that these tests share fixtures and general test paths with the wmg api performance tests - # get snapshot id - res = requests.get(f"{cls.api}/primary_filter_dimensions") - cls.data = json.loads(res.content) - def test_primary_filters(self): +@pytest.mark.skip("Skipping WMG V1 Functional Tests. WMG V1 API is deprecated. These tests will be ported to WMG V2") +class TestWmgApi: + def test_primary_filters(self, setup, session): """ Load primary filters in less than 1.5 seconds """ - res = self.session.get(f"{self.api}/primary_filter_dimensions") - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + api, _ = setup + res = session.get(f"{api}/primary_filter_dimensions") + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_query_endpoint_common_case(self): + def test_query_endpoint_common_case(self, setup, session): """ 1 tissue w/50 cell types, 20 genes, 3 secondary filters specified Returns in less than 10 seconds """ + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} - data = secondary_filter_common_case_request_data.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/query", data=json.dumps(data), headers=headers) - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{api}/query", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_query_endpoint_extreme_case(self): + def test_query_endpoint_extreme_case(self, setup, session): """ 4 tissues w/largest cell type counts, 400 genes, no secondary filtering Returns in less than 15 seconds """ + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} data = secondary_filter_extreme_case_request_data.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/query", data=json.dumps(data), headers=headers) - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{api}/query", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_filter_endpoint_common_case(self): + def test_filter_endpoint_common_case(self, setup, session): """ /v1/filters should support the common case /v1/queries supports """ + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} - data = secondary_filter_common_case_request_data.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/filters", data=json.dumps(data), headers=headers) - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{api}/filters", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_filter_endpoint_extreme_case(self): + def test_filter_endpoint_extreme_case(self, setup, session): """ /v1/filters should support the extreme case /v1/queries supports """ + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} data = secondary_filter_extreme_case_request_data.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/filters", data=json.dumps(data), headers=headers) - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{self.api}/filters", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_filter_endpoint_supports_ontology_term_ids(self): + def test_filter_endpoint_supports_ontology_term_ids(self, setup, session): """ /v1/filters differs from /v1/query in that it supports the cell_type_ontology_term_ids filter Ensure that hitting this endpoint with cell_type_ontology_term_ids is a valid request """ + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} data = secondary_filter_data_with_ontology_term_ids.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/filters", data=json.dumps(data), headers=headers) - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{api}/filters", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_markers_happy_path(self): + def test_markers_happy_path(self, setup, session): + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} data = markers_happy_path.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/markers", data=json.dumps(data), headers=headers) - self.assertStatusCode(requests.codes.ok, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{api}/markers", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.ok + assert len(res.content) > 10 - def test_markers_missing_tissue(self): + def test_markers_missing_tissue(self, setup, session): """ requests missing required params should fail """ + api, primary_filter_dimensions = setup headers = {"Content-Type": "application/json"} data = markers_missing_tissue.copy() - data["snapshot_id"] = self.data["snapshot_id"] - res = self.session.post(f"{self.api}/markers", data=json.dumps(data), headers=headers) - self.assertStatusCode(400, res) - self.assertGreater(len(res.content), 10) + data["snapshot_id"] = primary_filter_dimensions["snapshot_id"] + res = session.post(f"{api}/markers", data=json.dumps(data), headers=headers) + assert res.status_code == requests.codes.bad_request + assert len(res.content) > 10 diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt index 89b36a569a3a1..9dbec80128e9b 100644 --- a/tests/functional/requirements.txt +++ b/tests/functional/requirements.txt @@ -1,5 +1,7 @@ tenacity -pytest-subtests +pytest +pytest-xdist python-jose[cryptography]>=3.1.0 requests>=2.22.0 boto3==1.28.7 +filelock diff --git a/tests/performance/test_wmg_apis.py b/tests/performance/test_wmg_apis.py index 04d668f5445c1..f13bd00694229 100644 --- a/tests/performance/test_wmg_apis.py +++ b/tests/performance/test_wmg_apis.py @@ -16,8 +16,10 @@ logger = logging.getLogger(__name__) -@unittest.skipIf(os.getenv("DEPLOYMENT_STAGE") != "prod", "this test should only run in prod") -@unittest.skip("Skipping WMG V1 Performance Tests. WMG V1 API is deprecated. These tests will be ported to WMG V2") +@unittest.skipif(os.getenv("DEPLOYMENT_STAGE") != "prod", reason="this test should only run in prod") +@unittest.skip( + reason="Skipping WMG V1 Performance Tests. WMG V1 API is deprecated. These tests will be ported to WMG V2" +) class TestWmgApiPerformanceProd(unittest.TestCase): @classmethod def setUpClass(cls):