From 81b489d2f931dde53343bb372c7f25b0e7e42b5a Mon Sep 17 00:00:00 2001 From: Yannick de Jong Date: Tue, 17 Feb 2026 16:26:43 +0100 Subject: [PATCH 1/4] Add v1.2/simulations tests --- pyproject.toml | 1 + src/simdb/remote/apis/v1_2/simulations.py | 2 +- src/simdb/remote/models.py | 191 +++++ tests/remote/test_api.py | 868 +++++++++++++++++++++- 4 files changed, 1058 insertions(+), 4 deletions(-) create mode 100644 src/simdb/remote/models.py diff --git a/pyproject.toml b/pyproject.toml index f45d7dde..6f728cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "email-validator>=1.1", "imas-python", "numpy>=1.14", + "pydantic>=2.10.6", "python-dateutil>=2.6", "pyuda>=2.9.2", "pyyaml>=3.13", diff --git a/src/simdb/remote/apis/v1_2/simulations.py b/src/simdb/remote/apis/v1_2/simulations.py index 01351e47..9de306b2 100644 --- a/src/simdb/remote/apis/v1_2/simulations.py +++ b/src/simdb/remote/apis/v1_2/simulations.py @@ -36,7 +36,7 @@ def _update_simulation_status( ) -> None: old_status = simulation.status simulation.status = status - if status != old_status and len(simulation.watchers) > 0: + if status != old_status and len(list(simulation.watchers)) > 0: server = EmailServer(current_app.simdb_config) msg = f"""\ Simulation status changed from {old_status} to {status}. diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py new file mode 100644 index 00000000..e2b56934 --- /dev/null +++ b/src/simdb/remote/models.py @@ -0,0 +1,191 @@ +from datetime import datetime as dt +from datetime import timezone +from typing import Annotated, Any, Generic, List, Optional, TypeVar, Union +from urllib.parse import urlencode +from uuid import UUID, uuid1 + +from pydantic import ( + BaseModel, + BeforeValidator, + Field, + PlainSerializer, + RootModel, + model_validator, +) + +HexUUID = Annotated[UUID, PlainSerializer(lambda x: x.hex, return_type=str)] + + +def _deserialize_custom_uuid(v: Any) -> UUID: + """Deserialize CustomUUID format back to UUID.""" + if isinstance(v, UUID): + return v + if isinstance(v, dict) and "hex" in v: + return UUID(hex=v["hex"]) + raise ValueError(f"Cannot deserialize {v} to UUID") + + +CustomUUID = Annotated[ + UUID, + BeforeValidator(_deserialize_custom_uuid), + PlainSerializer(lambda x: {"_type": "uuid.UUID", "hex": x.hex}), +] + + +class StatusPatchData(BaseModel): + status: str + + +class DeletedSimulation(BaseModel): + uuid: UUID + files: List[str] + + +class SimulationDeleteResponse(BaseModel): + deleted: DeletedSimulation + + +class FileData(BaseModel): + type: str + uri: str + uuid: CustomUUID = Field(default_factory=lambda: uuid1()) + checksum: str + datetime: dt + usage: Optional[str] = None + purpose: Optional[str] = None + sensitivity: Optional[str] = None + access: Optional[str] = None + embargo: Optional[str] = None + + +class FileDataList(RootModel): + root: List[FileData] = [] + + # Allows indexing: users[0] + def __getitem__(self, item) -> FileData: + return self.root[item] + + +class MetadataData(BaseModel): + element: str + value: Union[CustomUUID, Any] + + def as_dict(self): + return {self.element: self.value} + + def as_querystring(self): + return urlencode(self.as_dict()) + + +class MetadataPatchData(BaseModel): + key: str + value: str + + +class MetadataDeleteData(BaseModel): + key: str + + +class MetadataDataList(RootModel): + root: List[MetadataData] = [] + + def __getitem__(self, item) -> MetadataData: + return self.root[item] + + def as_dict(self): + return {m.element: m.value for m in self.root} + + @model_validator(mode="before") + @classmethod + def parse_dictionary(cls, data: Any): + if isinstance(data, dict): + return [{"element": k, "value": v} for (k, v) in data.items()] + return data + + def as_querystring(self): + return urlencode(self.as_dict()) + + +class SimulationReference(BaseModel): + uuid: CustomUUID + alias: Optional[str] = None + + +class SimulationData(BaseModel): + uuid: CustomUUID = Field(default_factory=lambda: uuid1()) + alias: Optional[str] = None + datetime: dt = Field(default_factory=lambda: dt.now(timezone.utc)) + inputs: FileDataList = FileDataList() + outputs: FileDataList = FileDataList() + metadata: MetadataDataList = MetadataDataList() + + +class SimulationDataResponse(SimulationData): + parents: List[SimulationReference] + children: List[SimulationReference] + + +class SimulationPostData(BaseModel): + simulation: SimulationData + add_watcher: bool + uploaded_by: Optional[str] = None + + +class ValidationResult(BaseModel): + passed: bool + error: Optional[str] = None + + +class SimulationPostResponse(BaseModel): + ingested: HexUUID + error: Optional[str] = None + validation: Optional[ValidationResult] = None + + +class SimulationListItem(BaseModel): + uuid: CustomUUID + alias: Optional[str] = None + datetime: str + metadata: Optional[MetadataDataList] = None + + +T = TypeVar("T") + + +class PaginatedResponse(BaseModel, Generic[T]): + count: int + page: int + limit: int + results: List[T] + + +class PaginationData(BaseModel): + limit: int + page: int + sort_by: str + sort_asc: bool + + @model_validator(mode="before") + @classmethod + def parse_headers(cls, data: Any): + if not isinstance(data, dict): + return data + new_data = { + "limit": data.get("simdb-result-limit", 100), + "page": data.get("simdb-page", 1), + "sort_by": data.get("simdb-sort-by", ""), + "sort_asc": data.get("simdb-sort-asc", False), + } + return new_data + + +class SimulationTraceData(SimulationData): + status: Optional[str] = None + passed_on: Optional[Any] = None + failed_on: Optional[Any] = None + deprecated_on: Optional[Any] = None + accepted_on: Optional[Any] = None + not_validated_on: Optional[Any] = None + deleted_on: Optional[Any] = None + replaces: Optional["SimulationTraceData"] = None + replaces_reason: Optional[Any] = None diff --git a/tests/remote/test_api.py b/tests/remote/test_api.py index 8fc9dfeb..6375ffd1 100644 --- a/tests/remote/test_api.py +++ b/tests/remote/test_api.py @@ -1,7 +1,10 @@ import base64 import importlib import os +import shutil import tempfile +import uuid +from datetime import datetime, timezone from pathlib import Path import pytest @@ -10,6 +13,20 @@ from simdb.config import Config from simdb.database.models import Simulation from simdb.remote.app import create_app +from simdb.remote.models import ( + FileData, + MetadataData, + MetadataDataList, + MetadataDeleteData, + MetadataPatchData, + PaginatedResponse, + SimulationData, + SimulationDataResponse, + SimulationListItem, + SimulationPostData, + SimulationTraceData, + StatusPatchData, +) has_flask = importlib.util.find_spec("flask") is not None @@ -28,10 +45,14 @@ def client(): config = Config() config.load() db_fd, db_file = tempfile.mkstemp() + upload_dir = tempfile.mkdtemp() config.set_option("database.type", "sqlite") config.set_option("database.file", db_file) config.set_option("server.admin_password", TEST_PASSWORD) + config.set_option("server.upload_folder", upload_dir) config.set_option("authentication.type", "None") + config.set_option("server.copy_files", False) + config.set_option("role.admin.users", "admin,admin2") app = create_app(config=config, testing=True, debug=True) app.testing = True @@ -47,6 +68,38 @@ def client(): os.close(db_fd) Path(app.simdb_config.get_option("database.file")).unlink() + shutil.rmtree(upload_dir) + + +def generate_simulation_data( + add_watcher=False, uploaded_by=None, alias=None, **overrides +) -> SimulationPostData: + if alias is None: + alias = uuid.uuid4().hex + simulation_data = SimulationData(alias=alias, **overrides) + data = SimulationPostData( + simulation=simulation_data, add_watcher=add_watcher, uploaded_by=uploaded_by + ) + return data + + +def generate_simulation_file() -> FileData: + return FileData( + type="FILE", + uri="file:///path/to/file", + checksum="fake_checksum", + datetime=datetime.now(timezone.utc), + ) + + +def post_simulation(client, simulation_data, headers=HEADERS): + rv_post = client.post( + "/v1.2/simulations", + json=simulation_data.model_dump(mode="json"), + headers=headers, + content_type="application/json", + ) + return rv_post @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -67,8 +120,817 @@ def test_get_api_root(client): @pytest.mark.skipif(not has_flask, reason="requires flask library") -def test_get_simulations(client): +def test_post_simulations(client): + """Test POST endpoint for creating a new simulation.""" + simulation_data = generate_simulation_data( + alias="test-simulation", + inputs=[generate_simulation_file()], + outputs=[generate_simulation_file()], + ) + + # POST the simulation + rv = post_simulation(client, simulation_data) + + # Verify the response + assert rv.status_code == 200 + assert rv.json["ingested"] == simulation_data.simulation.uuid.hex + + # Verify the simulation was created by fetching it + rv_get = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get.status_code == 200 + assert rv_get.json["alias"] == "test-simulation" + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +@pytest.mark.parametrize("suffix", ["-", "#"]) +def test_post_simulations_with_alias_auto_increment(client, suffix): + """Test POST endpoint with alias ending in dash or hashtag (auto-increment).""" + random_name = uuid.uuid4().hex + simulation_data = generate_simulation_data( + alias=f"{random_name}{suffix}", + ) + + rv = post_simulation(client, simulation_data) + + assert rv.status_code == 200 + assert rv.json["ingested"] == simulation_data.simulation.uuid.hex + + rv_get = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get.status_code == 200 + assert rv_get.json["alias"] == f"{random_name}{suffix}1" + + # Check seqid metadata was added + metadata = rv_get.json["metadata"] + seqid_meta = [m for m in metadata if m["element"] == "seqid"] + assert len(seqid_meta) == 1 + assert seqid_meta[0]["value"] == 1 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_post_simulations_alias_increment_sequence(client): + """Test multiple simulations with incrementing dash alias.""" + # Create first simulation with dash alias + simulation_data_1 = generate_simulation_data( + alias="sequence-", + ) + + rv1 = post_simulation(client, simulation_data_1) + assert rv1.status_code == 200 + + simulation_data_2 = generate_simulation_data( + alias="sequence-", + ) + + rv2 = post_simulation(client, simulation_data_2) + assert rv2.status_code == 200 + + # Verify aliases were incremented + rv_get1 = client.get( + f"/v1.2/simulation/{simulation_data_1.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get1.json["alias"] == "sequence-1" + + rv_get2 = client.get( + f"/v1.2/simulation/{simulation_data_2.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get2.json["alias"] == "sequence-2" + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +@pytest.mark.xfail(reason="Alias is required in current API") +def test_post_simulations_no_alias(client): + """Test POST endpoint with no alias provided (should use uuid.hex).""" + simulation_data = generate_simulation_data() + + rv = post_simulation(client, simulation_data) + + assert rv.status_code == 200 + rv_get = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get.status_code == 200 + assert rv_get.json["alias"] == simulation_data.simulation.uuid.hex + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_post_simulations_with_replaces(client): + """Test POST endpoint with replaces metadata (deprecates old simulation).""" + # Create initial simulation + old_simulation_data = generate_simulation_data(alias="old_simulation") + + rv_old = post_simulation(client, old_simulation_data) + assert rv_old.status_code == 200 + + # Create new simulation that replaces the old one + new_simulation_data = generate_simulation_data( + alias="updated-simulation", + metadata=[ + MetadataData( + element="replaces", value=old_simulation_data.simulation.uuid.hex + ), + MetadataData(element="replaces_reason", value="Test replacement"), + ], + ) + + rv_new = post_simulation(client, new_simulation_data) + assert rv_new.status_code == 200 + + # Verify the old simulation is marked as DEPRECATED + rv_old_get = client.get( + f"/v1.2/simulation/{old_simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_old_get.status_code == 200 + old_metadata = rv_old_get.json["metadata"] + + status_meta = [m for m in old_metadata if m["element"] == "status"] + assert len(status_meta) == 1 + assert status_meta[0]["value"].lower() == "deprecated" + + # Check replaced_by metadata was added + replaced_by_meta = [m for m in old_metadata if m["element"] == "replaced_by"] + assert len(replaced_by_meta) == 1 + assert replaced_by_meta[0]["value"] == new_simulation_data.simulation.uuid + + # Verify the new simulation has replaces metadata + rv_new_get = client.get( + f"/v1.2/simulation/{new_simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_new_get.status_code == 200 + new_metadata = rv_new_get.json["metadata"] + + replaces_meta = [m for m in new_metadata if m["element"] == "replaces"] + assert len(replaces_meta) == 1 + assert replaces_meta[0]["value"] == old_simulation_data.simulation.uuid.hex + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_post_simulations_replaces_nonexistent(client): + """Test POST endpoint with replaces pointing to non-existent simulation.""" + # Create simulation that tries to replace a non-existent simulation + simulation_data = generate_simulation_data( + alias="replaces-nothing", + metadata=[ + MetadataData(element="replaces", value=uuid.uuid1().hex), + MetadataData(element="replaces_reason", value="Test replacement"), + ], + ) + + # Should still succeed (old simulation just doesn't exist to deprecate) + rv = post_simulation(client, simulation_data) + assert rv.status_code == 200 + + # Verify the new simulation was created + rv_get = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get.status_code == 200 + assert rv_get.json["alias"] == "replaces-nothing" + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +@pytest.mark.xfail( + reason="User.email is not set for admin without custom authenticators" +) +def test_post_simulations_with_watcher(client): + """Test POST endpoint with add_watcher set to true.""" + simulation_data = generate_simulation_data( + add_watcher=True, uploaded_by="watcher-user" + ) + + rv = post_simulation(client, simulation_data) + assert rv.status_code == 200 + + # Verify the simulation was created + rv_get = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get.status_code == 200 + + # Note: We can't easily verify watchers were added without accessing the db directly + # but we can verify the request was successful and uploaded_by metadata is present + metadata = rv_get.json["metadata"] + uploaded_by_meta = [m for m in metadata if m["element"] == "uploaded_by"] + assert len(uploaded_by_meta) == 1 + assert uploaded_by_meta[0]["value"] == "watcher-user" + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_post_simulations_uploaded_by(client): + """Test POST endpoint with uploaded_by field.""" + """Test POST endpoint with add_watcher set to true.""" + simulation_data = generate_simulation_data(uploaded_by="test-user") + + rv = post_simulation(client, simulation_data) + assert rv.status_code == 200 + + # Verify the simulation was created + rv_get = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_get.status_code == 200 + + metadata = rv_get.json["metadata"] + uploaded_by_meta = [m for m in metadata if m["element"] == "uploaded_by"] + assert len(uploaded_by_meta) == 1 + assert uploaded_by_meta[0]["value"] == "test-user" + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_post_simulations_trace_with_replaces(client): + """Test the trace endpoint with a simulation that replaces another.""" + # Create original simulation + # Create initial simulation + old_simulation_data = generate_simulation_data(alias="trace-original") + + rv_old = post_simulation(client, old_simulation_data) + assert rv_old.status_code == 200 + + # Create new simulation that replaces the old one + new_simulation_data = generate_simulation_data( + alias="trace-updated", + metadata=[ + MetadataData( + element="replaces", value=old_simulation_data.simulation.uuid.hex + ), + MetadataData(element="replaces_reason", value="New features"), + ], + ) + + rv_new = post_simulation(client, new_simulation_data) + assert rv_new.status_code == 200 + + # Get trace for the new simulation + rv_trace = client.get( + f"/v1.2/trace/{new_simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + assert rv_trace.status_code == 200 + trace_data = rv_trace.json + + # Verify trace includes replaces information + assert "replaces" in trace_data + + replaces_uuid = trace_data["replaces"]["uuid"] + assert replaces_uuid == old_simulation_data.simulation.uuid + assert "replaces_reason" in trace_data + assert trace_data["replaces_reason"] == "New features" + + with pytest.xfail("Deprecated on is not set, because replaced_on is never set"): + assert "deprecated_on" in trace_data["replaces"] + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_basic(client): + """Test basic GET request to /v1.2/simulations endpoint.""" rv = client.get("/v1.2/simulations", headers=HEADERS) - assert rv.json["count"] == 100 - assert len(rv.json["results"]) == len(SIMULATIONS) + + assert rv.status_code == 200 + assert rv.is_json + + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.page == 1 + assert data.limit == 100 + assert data.count >= 100 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_pagination_limit(client): + """Test GET request with custom limit.""" + custom_limit = 10 + headers_with_limit = {**HEADERS, "simdb-result-limit": str(custom_limit)} + + rv = client.get("/v1.2/simulations", headers=headers_with_limit) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.limit == custom_limit + assert len(data.results) <= custom_limit + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_pagination_page(client): + """Test GET request with custom page number.""" + headers_page_2 = {**HEADERS, "simdb-result-limit": "10", "simdb-page": "2"} + + rv = client.get("/v1.2/simulations", headers=headers_page_2) + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.page == 2 + assert data.limit == 10 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_pagination_multiple_pages(client): + """Test pagination across multiple pages.""" + limit = 20 + + # Get first page + headers_page_1 = {**HEADERS, "simdb-result-limit": str(limit), "simdb-page": "1"} + rv1 = client.get("/v1.2/simulations", headers=headers_page_1) + assert rv1.status_code == 200 + page1_data = PaginatedResponse[SimulationListItem].model_validate(rv1.json) + + # Get second page + headers_page_2 = {**HEADERS, "simdb-result-limit": str(limit), "simdb-page": "2"} + rv2 = client.get("/v1.2/simulations", headers=headers_page_2) + assert rv2.status_code == 200 + page2_data = PaginatedResponse[SimulationListItem].model_validate(rv2.json) + + # Both should have same count and limit + assert page1_data.count == page2_data.count + assert page1_data.limit == page2_data.limit == limit + + # Pages should be different + assert page1_data.page == 1 + assert page2_data.page == 2 + + page1_uuids = {item.uuid for item in page1_data.results} + page2_uuids = {item.uuid for item in page2_data.results} + assert page1_uuids != page2_uuids + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_filter_by_alias(client): + """Test filtering simulations by alias.""" + # First create a simulation with a known alias + test_alias = "filter-test-alias" + simulation_data = generate_simulation_data(alias=test_alias) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Now filter by alias + rv = client.get(f"/v1.2/simulations?alias={test_alias}", headers=HEADERS) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.count == 1 + # Check that the filtered result contains our simulation + aliases = [item.alias for item in data.results] + assert test_alias in aliases + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_filter_by_uuid(client): + """Test filtering simulations by UUID.""" + # Create a simulation with a known UUID + simulation_data = generate_simulation_data() + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Filter by UUID + rv = client.get( + f"/v1.2/simulations?uuid={simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.count == 1 + # Check that the filtered result contains our simulation + uuids = [item.uuid for item in data.results] + assert simulation_data.simulation.uuid in uuids + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_filter_by_metadata(client): + """Test filtering simulations by metadata.""" + # Create simulations with specific metadata + test_metadata = MetadataData(element="machine", value="test_machine") + + simulation_data_1 = generate_simulation_data(metadata=[test_metadata]) + simulation_data_2 = generate_simulation_data(metadata=[test_metadata]) + rv_post_1 = post_simulation(client, simulation_data_1) + assert rv_post_1.status_code == 200 + + rv_post_2 = post_simulation(client, simulation_data_2) + assert rv_post_2.status_code == 200 + + # Filter by machine metadata + rv = client.get( + f"/v1.2/simulations?{test_metadata.as_querystring()}", + headers=HEADERS, + ) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.count == 2 + + # Check that both simulations are in the results + results_uuids = [item.uuid for item in data.results] + assert simulation_data_1.simulation.uuid in results_uuids + assert simulation_data_2.simulation.uuid in results_uuids + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_filter_multiple_metadata(client): + """Test filtering simulations by multiple metadata fields.""" + # Create a simulation with multiple metadata fields + test_metadata = {"machine": "multi-filter-machine", "code": "multi-filter-code"} + + simulation_data = generate_simulation_data(metadata=test_metadata) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Filter by both machine and code + rv = client.get( + f"/v1.2/simulations?{simulation_data.simulation.metadata.as_querystring()}", + headers=HEADERS, + ) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.count == 1 + results_uuids = [item.uuid for item in data.results] + assert simulation_data.simulation.uuid in results_uuids + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +@pytest.mark.xfail(reason="Only sorting by metadata keys works for now") +def test_get_simulations_alias_sorting_asc(client): + """Test sorting simulations in ascending order by alias.""" + # Create simulations with sortable aliases + for i in range(3): + simulation_data = generate_simulation_data(alias=f"alias-sort-test-{i:03d}") + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Get simulations sorted by alias ascending + headers_sorted = {**HEADERS, "simdb-sort-by": "alias", "simdb-sort-asc": "true"} + + rv = client.get( + "/v1.2/simulations?alias=IN%3Aalias-sort-test-", headers=headers_sorted + ) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + # Check that results are sorted in ascending order + aliases = [item.alias for item in data.results if item.alias is not None] + assert aliases == sorted(aliases) + assert len(aliases) == 3 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +@pytest.mark.parametrize("ascending", [True, False]) +def test_get_simulations_metadata_sorting(client, ascending): + """Test sorting simulations in ascending order.""" + # Create simulations with sortable aliases + # post them in the order: 2 1 0 5 4 3 + # ascending should result in 0 1 2 3 4 5 + # descending should result in 5 4 3 2 1 0 + for i in reversed(range(3)): + simulation_data = generate_simulation_data( + alias=f"sort-test-{ascending}-{i:03d}", + metadata=[MetadataData(element="sort-test", value=i)], + ) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + for i in reversed(range(3, 6)): + simulation_data = generate_simulation_data( + alias=f"sort-test-{ascending}-{i:03d}", + metadata=[MetadataData(element="sort-test", value=i)], + ) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Get simulations sorted by alias ascending + headers_sorted = { + **HEADERS, + "simdb-sort-by": "sort-test", + "simdb-sort-asc": "true" if ascending else "false", + } + + rv = client.get( + f"/v1.2/simulations?alias=IN%3Asort-test-{ascending}&sort-test", + headers=headers_sorted, + ) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + # Check that results are sorted in the correct order + metadata = [ + item.metadata[0].value for item in data.results if item.metadata is not None + ] + assert metadata == sorted(metadata, reverse=not ascending) + assert len(metadata) == 6 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_empty_result(client): + """Test GET request with filters that return no results.""" + # Use a filter that shouldn't match anything + rv = client.get( + "/v1.2/simulations?alias=non-existent-simulation-12345xyz", headers=HEADERS + ) + + assert rv.status_code == 200 + data = PaginatedResponse[SimulationListItem].model_validate(rv.json) + + assert data.count == 0 + assert len(data.results) == 0 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulations_with_metadata_keys(client): + """Test requesting specific metadata keys in results.""" + # Create a simulation with known metadata + + simulation_data = generate_simulation_data( + alias="meta-keys-test", + metadata={"machine": "machine-x", "code": "code-y"}, + ) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Request simulations with specific metadata keys + rv = client.get( + "/v1.2/simulations?alias=meta-keys-test&machine&code", headers=HEADERS + ) + + assert rv.status_code == 200 + data: PaginatedResponse[SimulationListItem] = PaginatedResponse[ + SimulationListItem + ].model_validate(rv.json) + + assert data.count == 1 + assert len(data.results) == 1 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulation_by_uuid(client): + """Test GET /v1.2/simulation/{simulation_id} endpoint - retrieve by UUID.""" + # Create a simulation with known properties + simulation_data = generate_simulation_data(uploaded_by="test-uploader") + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Test GET by UUID + rv = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + + assert rv.status_code == 200 + assert rv.is_json + + # Validate full data model + SimulationDataResponse.model_validate(rv.json) + + simulation_data_received = SimulationData.model_validate(rv.json, extra="ignore") + simulation_data_check = simulation_data.simulation.model_copy() + + # fill fields that are filled by the server + simulation_data_check.metadata = MetadataDataList.model_validate( + {"uploaded_by": simulation_data.uploaded_by} + ) + + # datetime gets updated by the server + simulation_data_check.datetime = simulation_data_received.datetime + + assert simulation_data_received == simulation_data_check + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulation_by_alias(client): + """Test GET /v1.2/simulation/{simulation_id} endpoint - retrieve by alias.""" + # Create a simulation with a unique alias + simulation_data = generate_simulation_data( + alias="test-get-alias", uploaded_by="test-uploader" + ) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + # Test GET by alias + rv = client.get( + f"/v1.2/simulation/{simulation_data.simulation.alias}", headers=HEADERS + ) + + assert rv.status_code == 200 + assert rv.is_json + + # Validate full data model + SimulationDataResponse.model_validate(rv.json) + + simulation_data_received = SimulationData.model_validate(rv.json, extra="ignore") + simulation_data_check = simulation_data.simulation.model_copy() + + # fill fields that are filled by the server + simulation_data_check.metadata = MetadataDataList.model_validate( + {"uploaded_by": simulation_data.uploaded_by} + ) + + # datetime gets updated by the server + simulation_data_check.datetime = simulation_data_received.datetime + + assert simulation_data_received == simulation_data_check + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulation_not_found(client): + """Test GET /v1.2/simulation/{simulation_id} endpoint - non-existent simulation.""" + # Try to get a non-existent simulation + fake_uuid = uuid.uuid1() + + rv = client.get(f"/v1.2/simulation/{fake_uuid.hex}", headers=HEADERS) + + assert rv.status_code == 400 + data = rv.json + + # Should contain an error message + assert "error" in data or data.get("message") == "Simulation not found" + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_get_simulation_metadata(client): + """Test GET /v1.2/simulation/metadata/{simulation_id} endpoint.""" + simulation_data = generate_simulation_data( + metadata={"metadata-a": "abc", "metadata-b": "123"}, uploaded_by="test-user" + ) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + rv = client.get( + f"/v1.2/simulation/metadata/{simulation_data.simulation.uuid.hex}", + headers=HEADERS, + ) + + assert rv.status_code == 200 + data = MetadataDataList.model_validate(rv.json) + check_data = simulation_data.simulation.metadata.model_copy() + check_data.root.append(MetadataData(element="uploaded_by", value="test-user")) + assert data == simulation_data.simulation.metadata + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_patch_simulation(client): + """Test PATCH /v1.2/simulation/{simulation_id} endpoint.""" + simulation_data = generate_simulation_data() + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + rv = client.patch( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", + json=StatusPatchData(status="failed").model_dump(mode="json"), + headers=HEADERS, + ) + + assert rv.status_code == 200 + + # Status is never returned, so we can't check if it is set + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_delete_simulation(client): + """Test DELETE /v1.2/simulation/{simulation_id} endpoint.""" + simulation_data = generate_simulation_data() + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + rv = client.delete( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", + headers=HEADERS, + ) + + assert rv.status_code == 200 + + rv = client.get( + f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS + ) + + assert rv.status_code == 400 + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_patch_simulation_metadata(client): + """Test PATCH /v1.2/simulation/metadata/{simulation_id} endpoint.""" + simulation_data = generate_simulation_data( + metadata={"metadata-a": "abc"}, uploaded_by="test-user" + ) + + rv_post = post_simulation(client, simulation_data) + assert rv_post.status_code == 200 + + rv = client.patch( + f"/v1.2/simulation/metadata/{simulation_data.simulation.uuid.hex}", + json=MetadataPatchData(key="metadata-a", value="def").model_dump(mode="json"), + headers=HEADERS, + ) + + assert rv.status_code == 200 + + rv = client.get( + f"/v1.2/simulation/metadata/{simulation_data.simulation.uuid.hex}", + headers=HEADERS, + ) + + assert rv.status_code == 200 + data = MetadataDataList.model_validate(rv.json) + check_data = simulation_data.simulation.metadata.model_copy() + check_data[0].value = "def" + check_data.root.append(MetadataData(element="uploaded_by", value="test-user")) + assert data == simulation_data.simulation.metadata + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_delete_simulation_metadata(client): + """Test DELETE /v1.2/simulation/metadata/{simulation_id} endpoint.""" + simulation_data = generate_simulation_data( + metadata={"metadata-a": "abc"}, uploaded_by="test-user" + ) + + rv_post = post_simulation(client, simulation_data) + print(rv_post.data) + print(simulation_data) + assert rv_post.status_code == 200 + + rv = client.delete( + f"/v1.2/simulation/metadata/{simulation_data.simulation.uuid.hex}", + json=MetadataDeleteData(key="metadata-a").model_dump(mode="json"), + headers=HEADERS, + ) + + assert rv.status_code == 200 + + rv = client.get( + f"/v1.2/simulation/metadata/{simulation_data.simulation.uuid.hex}", + headers=HEADERS, + ) + + assert rv.status_code == 200 + data = MetadataDataList.model_validate(rv.json) + check_data = simulation_data.simulation.metadata.model_copy() + check_data.root.pop() + check_data.root.append(MetadataData(element="uploaded_by", value="test-user")) + assert data == simulation_data.simulation.metadata + + +@pytest.mark.skipif(not has_flask, reason="requires flask library") +def test_trace_endpoint(client): + """Test trace endpoint returns valid SimulationTraceData and handles replacement + chains.""" + # Create v1 -> v2 -> v3 replacement chain + sim_v1 = generate_simulation_data(alias="trace-v1") + rv1 = post_simulation(client, sim_v1) + assert rv1.status_code == 200 + + sim_v2 = generate_simulation_data( + alias="trace-v2", + metadata=[ + MetadataData(element="replaces", value=sim_v1.simulation.uuid.hex), + MetadataData(element="replaces_reason", value="Bug fixes"), + ], + ) + rv2 = post_simulation(client, sim_v2) + assert rv2.status_code == 200 + + sim_v3 = generate_simulation_data( + alias="trace-v3", + metadata=[ + MetadataData(element="replaces", value=sim_v2.simulation.uuid.hex), + MetadataData(element="replaces_reason", value="Performance"), + ], + ) + rv3 = post_simulation(client, sim_v3) + assert rv3.status_code == 200 + + # Test trace for v3 (full chain) + rv_trace = client.get(f"/v1.2/trace/{sim_v3.simulation.uuid.hex}", headers=HEADERS) + assert rv_trace.status_code == 200 + + trace = SimulationTraceData.model_validate(rv_trace.json) + + # Verify v3 + assert trace.uuid == sim_v3.simulation.uuid + assert trace.alias == "trace-v3" + assert trace.replaces_reason == "Performance" + + # Verify v2 (nested) + assert trace.replaces.uuid == sim_v2.simulation.uuid + assert trace.replaces.replaces_reason == "Bug fixes" + + # Verify v1 (double nested) + assert trace.replaces.replaces.uuid == sim_v1.simulation.uuid + assert trace.replaces.replaces.replaces is None From bc8891a84a7027237fce6b56adec3105cb84188f Mon Sep 17 00:00:00 2001 From: Yannick de Jong Date: Wed, 18 Feb 2026 10:02:14 +0100 Subject: [PATCH 2/4] Use pydantic model validation in more api tests --- tests/remote/test_api.py | 107 +++++++++++++-------------------------- 1 file changed, 34 insertions(+), 73 deletions(-) diff --git a/tests/remote/test_api.py b/tests/remote/test_api.py index 6375ffd1..0b76c0d0 100644 --- a/tests/remote/test_api.py +++ b/tests/remote/test_api.py @@ -24,6 +24,7 @@ SimulationDataResponse, SimulationListItem, SimulationPostData, + SimulationPostResponse, SimulationTraceData, StatusPatchData, ) @@ -133,14 +134,17 @@ def test_post_simulations(client): # Verify the response assert rv.status_code == 200 - assert rv.json["ingested"] == simulation_data.simulation.uuid.hex + + result = SimulationPostResponse.model_validate(rv.json) + assert result.ingested == simulation_data.simulation.uuid # Verify the simulation was created by fetching it rv_get = client.get( f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_get.status_code == 200 - assert rv_get.json["alias"] == "test-simulation" + result = SimulationDataResponse.model_validate(rv_get.json) + assert result.alias == simulation_data.simulation.alias @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -155,19 +159,18 @@ def test_post_simulations_with_alias_auto_increment(client, suffix): rv = post_simulation(client, simulation_data) assert rv.status_code == 200 - assert rv.json["ingested"] == simulation_data.simulation.uuid.hex + result = SimulationPostResponse.model_validate(rv.json) + assert result.ingested == simulation_data.simulation.uuid rv_get = client.get( f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_get.status_code == 200 - assert rv_get.json["alias"] == f"{random_name}{suffix}1" + result = SimulationDataResponse.model_validate(rv_get.json) + assert result.alias == f"{random_name}{suffix}1" # Check seqid metadata was added - metadata = rv_get.json["metadata"] - seqid_meta = [m for m in metadata if m["element"] == "seqid"] - assert len(seqid_meta) == 1 - assert seqid_meta[0]["value"] == 1 + assert result.metadata.as_dict()["seqid"] == 1 @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -192,12 +195,14 @@ def test_post_simulations_alias_increment_sequence(client): rv_get1 = client.get( f"/v1.2/simulation/{simulation_data_1.simulation.uuid.hex}", headers=HEADERS ) - assert rv_get1.json["alias"] == "sequence-1" + result = SimulationDataResponse.model_validate(rv_get1.json) + assert result.alias == "sequence-1" rv_get2 = client.get( f"/v1.2/simulation/{simulation_data_2.simulation.uuid.hex}", headers=HEADERS ) - assert rv_get2.json["alias"] == "sequence-2" + result = SimulationDataResponse.model_validate(rv_get2.json) + assert result.alias == "sequence-2" @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -213,7 +218,8 @@ def test_post_simulations_no_alias(client): f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_get.status_code == 200 - assert rv_get.json["alias"] == simulation_data.simulation.uuid.hex + result = SimulationDataResponse.model_validate(rv_get.json) + assert result.alias == simulation_data.simulation.uuid @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -230,7 +236,9 @@ def test_post_simulations_with_replaces(client): alias="updated-simulation", metadata=[ MetadataData( - element="replaces", value=old_simulation_data.simulation.uuid.hex + # This needs to be the hex representation + element="replaces", + value=old_simulation_data.simulation.uuid.hex, ), MetadataData(element="replaces_reason", value="Test replacement"), ], @@ -244,27 +252,23 @@ def test_post_simulations_with_replaces(client): f"/v1.2/simulation/{old_simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_old_get.status_code == 200 - old_metadata = rv_old_get.json["metadata"] - - status_meta = [m for m in old_metadata if m["element"] == "status"] - assert len(status_meta) == 1 - assert status_meta[0]["value"].lower() == "deprecated" + result = SimulationDataResponse.model_validate(rv_old_get.json) + metadata = result.metadata.as_dict() + assert metadata["status"].lower() == "deprecated" # Check replaced_by metadata was added - replaced_by_meta = [m for m in old_metadata if m["element"] == "replaced_by"] - assert len(replaced_by_meta) == 1 - assert replaced_by_meta[0]["value"] == new_simulation_data.simulation.uuid + assert metadata["replaced_by"] == new_simulation_data.simulation.uuid # Verify the new simulation has replaces metadata rv_new_get = client.get( f"/v1.2/simulation/{new_simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_new_get.status_code == 200 - new_metadata = rv_new_get.json["metadata"] + result = SimulationDataResponse.model_validate(rv_new_get.json) + metadata = result.metadata.as_dict() - replaces_meta = [m for m in new_metadata if m["element"] == "replaces"] - assert len(replaces_meta) == 1 - assert replaces_meta[0]["value"] == old_simulation_data.simulation.uuid.hex + # This will be the hex representation + assert metadata["replaces"] == old_simulation_data.simulation.uuid.hex @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -288,7 +292,8 @@ def test_post_simulations_replaces_nonexistent(client): f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_get.status_code == 200 - assert rv_get.json["alias"] == "replaces-nothing" + result = SimulationDataResponse.model_validate(rv_get.json) + assert result.alias == "replaces-nothing" @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -332,54 +337,8 @@ def test_post_simulations_uploaded_by(client): f"/v1.2/simulation/{simulation_data.simulation.uuid.hex}", headers=HEADERS ) assert rv_get.status_code == 200 - - metadata = rv_get.json["metadata"] - uploaded_by_meta = [m for m in metadata if m["element"] == "uploaded_by"] - assert len(uploaded_by_meta) == 1 - assert uploaded_by_meta[0]["value"] == "test-user" - - -@pytest.mark.skipif(not has_flask, reason="requires flask library") -def test_post_simulations_trace_with_replaces(client): - """Test the trace endpoint with a simulation that replaces another.""" - # Create original simulation - # Create initial simulation - old_simulation_data = generate_simulation_data(alias="trace-original") - - rv_old = post_simulation(client, old_simulation_data) - assert rv_old.status_code == 200 - - # Create new simulation that replaces the old one - new_simulation_data = generate_simulation_data( - alias="trace-updated", - metadata=[ - MetadataData( - element="replaces", value=old_simulation_data.simulation.uuid.hex - ), - MetadataData(element="replaces_reason", value="New features"), - ], - ) - - rv_new = post_simulation(client, new_simulation_data) - assert rv_new.status_code == 200 - - # Get trace for the new simulation - rv_trace = client.get( - f"/v1.2/trace/{new_simulation_data.simulation.uuid.hex}", headers=HEADERS - ) - assert rv_trace.status_code == 200 - trace_data = rv_trace.json - - # Verify trace includes replaces information - assert "replaces" in trace_data - - replaces_uuid = trace_data["replaces"]["uuid"] - assert replaces_uuid == old_simulation_data.simulation.uuid - assert "replaces_reason" in trace_data - assert trace_data["replaces_reason"] == "New features" - - with pytest.xfail("Deprecated on is not set, because replaced_on is never set"): - assert "deprecated_on" in trace_data["replaces"] + result = SimulationDataResponse.model_validate(rv_get.json) + assert result.metadata.as_dict()["uploaded_by"] == "test-user" @pytest.mark.skipif(not has_flask, reason="requires flask library") @@ -928,9 +887,11 @@ def test_trace_endpoint(client): assert trace.replaces_reason == "Performance" # Verify v2 (nested) + assert trace.replaces is not None assert trace.replaces.uuid == sim_v2.simulation.uuid assert trace.replaces.replaces_reason == "Bug fixes" # Verify v1 (double nested) + assert trace.replaces.replaces is not None assert trace.replaces.replaces.uuid == sim_v1.simulation.uuid assert trace.replaces.replaces.replaces is None From 7042f1e7f3507a2679c2335137af7e907e23b615 Mon Sep 17 00:00:00 2001 From: Yannick de Jong Date: Wed, 18 Feb 2026 16:27:21 +0100 Subject: [PATCH 3/4] Add docstrings to pydantic models --- src/simdb/remote/models.py | 131 ++++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 9 deletions(-) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index e2b56934..b2146048 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -1,6 +1,8 @@ +"""Pydantic models for the SimDB remote API.""" + from datetime import datetime as dt from datetime import timezone -from typing import Annotated, Any, Generic, List, Optional, TypeVar, Union +from typing import Annotated, Any, Generic, List, Literal, Optional, TypeVar, Union from urllib.parse import urlencode from uuid import UUID, uuid1 @@ -14,6 +16,7 @@ ) HexUUID = Annotated[UUID, PlainSerializer(lambda x: x.hex, return_type=str)] +"""UUID serialized as a hex string.""" def _deserialize_custom_uuid(v: Any) -> UUID: @@ -30,144 +33,243 @@ def _deserialize_custom_uuid(v: Any) -> UUID: BeforeValidator(_deserialize_custom_uuid), PlainSerializer(lambda x: {"_type": "uuid.UUID", "hex": x.hex}), ] +"""UUID with custom serialization format.""" + +StatusLiteral = Literal[ + "not validated", "accepted", "failed", "passed", "deprecated", "deleted" +] +"""String representation of a simulation status""" class StatusPatchData(BaseModel): - status: str + """Post data for updating simulation status.""" + + status: StatusLiteral + """New simulation status.""" class DeletedSimulation(BaseModel): + """Reference to a deleted simulation.""" + uuid: UUID + """UUID of the deleted simulation.""" files: List[str] + """List of deleted file paths.""" class SimulationDeleteResponse(BaseModel): + """Response from DELETE v1.2/simulations/{uuid}.""" + deleted: DeletedSimulation + """Reference to the deleted simulation.""" class FileData(BaseModel): - type: str + """Model representing a file in the system.""" + + type: Literal["UNKNOWN", "UUID", "FILE", "IMAS", "UDA"] + """File type.""" uri: str + """URI to the file location.""" uuid: CustomUUID = Field(default_factory=lambda: uuid1()) + """Unique identifier for the file.""" checksum: str + """Checksum of the file.""" datetime: dt + """Timestamp of the file.""" usage: Optional[str] = None + """File usage description.""" purpose: Optional[str] = None + """Purpose of the file.""" sensitivity: Optional[str] = None + """Sensitivity level of the file.""" access: Optional[str] = None + """Access permissions.""" embargo: Optional[str] = None + """Embargo information.""" class FileDataList(RootModel): + """List of FileData items.""" + root: List[FileData] = [] - # Allows indexing: users[0] def __getitem__(self, item) -> FileData: + """Allow indexing on the list.""" return self.root[item] class MetadataData(BaseModel): + """Key-value pair for simulation metadata.""" + element: str + """Metadata key/name.""" value: Union[CustomUUID, Any] + """Metadata value.""" - def as_dict(self): + def as_dict(self) -> dict: + """Convert to dictionary.""" return {self.element: self.value} - def as_querystring(self): + def as_querystring(self) -> str: + """Convert to URL query string.""" return urlencode(self.as_dict()) class MetadataPatchData(BaseModel): + """Data for patching a metadata entry.""" + key: str + """Metadata key to update.""" value: str + """New value for the metadata key.""" class MetadataDeleteData(BaseModel): + """Data for deleting a metadata entry.""" + key: str + """Metadata key to delete.""" class MetadataDataList(RootModel): + """List of MetadataData items.""" + root: List[MetadataData] = [] def __getitem__(self, item) -> MetadataData: + """Allow indexing on the list.""" return self.root[item] - def as_dict(self): + def as_dict(self) -> dict: + """Convert all metadata to dictionary.""" return {m.element: m.value for m in self.root} @model_validator(mode="before") @classmethod def parse_dictionary(cls, data: Any): + """Parse dictionary to list of MetadataData.""" if isinstance(data, dict): return [{"element": k, "value": v} for (k, v) in data.items()] return data - def as_querystring(self): + def as_querystring(self) -> str: + """Convert to URL query string.""" return urlencode(self.as_dict()) class SimulationReference(BaseModel): + """Reference to a simulation.""" + uuid: CustomUUID + """UUID of the simulation.""" alias: Optional[str] = None + """Alias of the simulation.""" class SimulationData(BaseModel): + """Core simulation data.""" + uuid: CustomUUID = Field(default_factory=lambda: uuid1()) + """Unique identifier of the simulation.""" alias: Optional[str] = None + """Human-readable alias.""" datetime: dt = Field(default_factory=lambda: dt.now(timezone.utc)) + """Creation timestamp.""" inputs: FileDataList = FileDataList() + """List of input files.""" outputs: FileDataList = FileDataList() + """List of output files.""" metadata: MetadataDataList = MetadataDataList() + """Simulation metadata.""" class SimulationDataResponse(SimulationData): + """Simulation data with parent/child references.""" + parents: List[SimulationReference] + """Parent simulations.""" children: List[SimulationReference] + """Child simulations.""" class SimulationPostData(BaseModel): + """Data for creating a new simulation.""" + simulation: SimulationData + """The simulation data to create.""" add_watcher: bool + """Whether to add a watcher for this simulation.""" uploaded_by: Optional[str] = None + """User who uploaded the simulation.""" class ValidationResult(BaseModel): + """Result of simulation validation.""" + passed: bool + """Whether validation passed.""" error: Optional[str] = None + """Error message if validation failed.""" class SimulationPostResponse(BaseModel): + """Response from creating a simulation.""" + ingested: HexUUID + """UUID of the ingested simulation.""" error: Optional[str] = None + """Error message if ingestion failed.""" validation: Optional[ValidationResult] = None + """Validation result.""" class SimulationListItem(BaseModel): + """Summary of a simulation for list views.""" + uuid: CustomUUID + """UUID of the simulation.""" alias: Optional[str] = None + """Alias of the simulation.""" datetime: str + """Creation timestamp.""" metadata: Optional[MetadataDataList] = None + """Simulation metadata.""" T = TypeVar("T") +"""Type variable for generic paginated responses.""" class PaginatedResponse(BaseModel, Generic[T]): + """Generic paginated response wrapper.""" + count: int + """Total number of items.""" page: int + """Current page number.""" limit: int + """Number of items per page.""" results: List[T] + """List of results for this page.""" class PaginationData(BaseModel): + """Pagination parameters from request headers.""" + limit: int + """Number of items per page.""" page: int + """Current page number.""" sort_by: str + """Field to sort by.""" sort_asc: bool + """Whether to sort ascending.""" @model_validator(mode="before") @classmethod def parse_headers(cls, data: Any): + """Parse pagination from HTTP headers.""" if not isinstance(data, dict): return data new_data = { @@ -180,12 +282,23 @@ def parse_headers(cls, data: Any): class SimulationTraceData(SimulationData): - status: Optional[str] = None + """Simulation data with status history.""" + + status: Optional[StatusLiteral] = None + """Current status of the simulation.""" passed_on: Optional[Any] = None + """Timestamp when status changed to passed.""" failed_on: Optional[Any] = None + """Timestamp when status changed to failed.""" deprecated_on: Optional[Any] = None + """Timestamp when status changed to deprecated.""" accepted_on: Optional[Any] = None + """Timestamp when status changed to accepted.""" not_validated_on: Optional[Any] = None + """Timestamp when status changed to not validated.""" deleted_on: Optional[Any] = None + """Timestamp when status changed to deleted.""" replaces: Optional["SimulationTraceData"] = None + """Simulation this one replaces.""" replaces_reason: Optional[Any] = None + """Reason for replacement.""" From 2520ee8aa01151cce2eb4930e6fca55956ef27ad Mon Sep 17 00:00:00 2001 From: Yannick de Jong Date: Thu, 19 Feb 2026 12:09:28 +0100 Subject: [PATCH 4/4] Put skip in fixture --- tests/remote/test_api.py | 38 +++++------------------------ tests/remote/test_authentication.py | 3 ++- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/tests/remote/test_api.py b/tests/remote/test_api.py index 0b76c0d0..36f2ab58 100644 --- a/tests/remote/test_api.py +++ b/tests/remote/test_api.py @@ -1,4 +1,5 @@ import base64 +import contextlib import importlib import os import shutil @@ -12,7 +13,9 @@ from simdb.cli.manifest import Manifest from simdb.config import Config from simdb.database.models import Simulation -from simdb.remote.app import create_app + +with contextlib.suppress(ModuleNotFoundError): + from simdb.remote.app import create_app from simdb.remote.models import ( FileData, MetadataData, @@ -43,6 +46,8 @@ @pytest.fixture(scope="session") def client(): + if not has_flask: + pytest.skip("Flask not installed") config = Config() config.load() db_fd, db_file = tempfile.mkstemp() @@ -103,7 +108,6 @@ def post_simulation(client, simulation_data, headers=HEADERS): return rv_post -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_root(client): rv = client.get("/") assert rv.status_code == 200 @@ -114,13 +118,11 @@ def test_get_root(client): ) -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_api_root(client): rv = client.get("/v1.2", headers=HEADERS) assert rv.status_code == 308 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_post_simulations(client): """Test POST endpoint for creating a new simulation.""" simulation_data = generate_simulation_data( @@ -147,7 +149,6 @@ def test_post_simulations(client): assert result.alias == simulation_data.simulation.alias -@pytest.mark.skipif(not has_flask, reason="requires flask library") @pytest.mark.parametrize("suffix", ["-", "#"]) def test_post_simulations_with_alias_auto_increment(client, suffix): """Test POST endpoint with alias ending in dash or hashtag (auto-increment).""" @@ -173,7 +174,6 @@ def test_post_simulations_with_alias_auto_increment(client, suffix): assert result.metadata.as_dict()["seqid"] == 1 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_post_simulations_alias_increment_sequence(client): """Test multiple simulations with incrementing dash alias.""" # Create first simulation with dash alias @@ -205,7 +205,6 @@ def test_post_simulations_alias_increment_sequence(client): assert result.alias == "sequence-2" -@pytest.mark.skipif(not has_flask, reason="requires flask library") @pytest.mark.xfail(reason="Alias is required in current API") def test_post_simulations_no_alias(client): """Test POST endpoint with no alias provided (should use uuid.hex).""" @@ -222,7 +221,6 @@ def test_post_simulations_no_alias(client): assert result.alias == simulation_data.simulation.uuid -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_post_simulations_with_replaces(client): """Test POST endpoint with replaces metadata (deprecates old simulation).""" # Create initial simulation @@ -271,7 +269,6 @@ def test_post_simulations_with_replaces(client): assert metadata["replaces"] == old_simulation_data.simulation.uuid.hex -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_post_simulations_replaces_nonexistent(client): """Test POST endpoint with replaces pointing to non-existent simulation.""" # Create simulation that tries to replace a non-existent simulation @@ -296,7 +293,6 @@ def test_post_simulations_replaces_nonexistent(client): assert result.alias == "replaces-nothing" -@pytest.mark.skipif(not has_flask, reason="requires flask library") @pytest.mark.xfail( reason="User.email is not set for admin without custom authenticators" ) @@ -323,7 +319,6 @@ def test_post_simulations_with_watcher(client): assert uploaded_by_meta[0]["value"] == "watcher-user" -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_post_simulations_uploaded_by(client): """Test POST endpoint with uploaded_by field.""" """Test POST endpoint with add_watcher set to true.""" @@ -341,7 +336,6 @@ def test_post_simulations_uploaded_by(client): assert result.metadata.as_dict()["uploaded_by"] == "test-user" -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_basic(client): """Test basic GET request to /v1.2/simulations endpoint.""" rv = client.get("/v1.2/simulations", headers=HEADERS) @@ -356,7 +350,6 @@ def test_get_simulations_basic(client): assert data.count >= 100 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_pagination_limit(client): """Test GET request with custom limit.""" custom_limit = 10 @@ -371,7 +364,6 @@ def test_get_simulations_pagination_limit(client): assert len(data.results) <= custom_limit -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_pagination_page(client): """Test GET request with custom page number.""" headers_page_2 = {**HEADERS, "simdb-result-limit": "10", "simdb-page": "2"} @@ -385,7 +377,6 @@ def test_get_simulations_pagination_page(client): assert data.limit == 10 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_pagination_multiple_pages(client): """Test pagination across multiple pages.""" limit = 20 @@ -415,7 +406,6 @@ def test_get_simulations_pagination_multiple_pages(client): assert page1_uuids != page2_uuids -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_filter_by_alias(client): """Test filtering simulations by alias.""" # First create a simulation with a known alias @@ -437,7 +427,6 @@ def test_get_simulations_filter_by_alias(client): assert test_alias in aliases -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_filter_by_uuid(client): """Test filtering simulations by UUID.""" # Create a simulation with a known UUID @@ -460,7 +449,6 @@ def test_get_simulations_filter_by_uuid(client): assert simulation_data.simulation.uuid in uuids -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_filter_by_metadata(client): """Test filtering simulations by metadata.""" # Create simulations with specific metadata @@ -491,7 +479,6 @@ def test_get_simulations_filter_by_metadata(client): assert simulation_data_2.simulation.uuid in results_uuids -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_filter_multiple_metadata(client): """Test filtering simulations by multiple metadata fields.""" # Create a simulation with multiple metadata fields @@ -516,7 +503,6 @@ def test_get_simulations_filter_multiple_metadata(client): assert simulation_data.simulation.uuid in results_uuids -@pytest.mark.skipif(not has_flask, reason="requires flask library") @pytest.mark.xfail(reason="Only sorting by metadata keys works for now") def test_get_simulations_alias_sorting_asc(client): """Test sorting simulations in ascending order by alias.""" @@ -543,7 +529,6 @@ def test_get_simulations_alias_sorting_asc(client): assert len(aliases) == 3 -@pytest.mark.skipif(not has_flask, reason="requires flask library") @pytest.mark.parametrize("ascending", [True, False]) def test_get_simulations_metadata_sorting(client, ascending): """Test sorting simulations in ascending order.""" @@ -592,7 +577,6 @@ def test_get_simulations_metadata_sorting(client, ascending): assert len(metadata) == 6 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_empty_result(client): """Test GET request with filters that return no results.""" # Use a filter that shouldn't match anything @@ -607,7 +591,6 @@ def test_get_simulations_empty_result(client): assert len(data.results) == 0 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulations_with_metadata_keys(client): """Test requesting specific metadata keys in results.""" # Create a simulation with known metadata @@ -634,7 +617,6 @@ def test_get_simulations_with_metadata_keys(client): assert len(data.results) == 1 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulation_by_uuid(client): """Test GET /v1.2/simulation/{simulation_id} endpoint - retrieve by UUID.""" # Create a simulation with known properties @@ -668,7 +650,6 @@ def test_get_simulation_by_uuid(client): assert simulation_data_received == simulation_data_check -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulation_by_alias(client): """Test GET /v1.2/simulation/{simulation_id} endpoint - retrieve by alias.""" # Create a simulation with a unique alias @@ -704,7 +685,6 @@ def test_get_simulation_by_alias(client): assert simulation_data_received == simulation_data_check -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulation_not_found(client): """Test GET /v1.2/simulation/{simulation_id} endpoint - non-existent simulation.""" # Try to get a non-existent simulation @@ -719,7 +699,6 @@ def test_get_simulation_not_found(client): assert "error" in data or data.get("message") == "Simulation not found" -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_get_simulation_metadata(client): """Test GET /v1.2/simulation/metadata/{simulation_id} endpoint.""" simulation_data = generate_simulation_data( @@ -741,7 +720,6 @@ def test_get_simulation_metadata(client): assert data == simulation_data.simulation.metadata -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_patch_simulation(client): """Test PATCH /v1.2/simulation/{simulation_id} endpoint.""" simulation_data = generate_simulation_data() @@ -760,7 +738,6 @@ def test_patch_simulation(client): # Status is never returned, so we can't check if it is set -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_delete_simulation(client): """Test DELETE /v1.2/simulation/{simulation_id} endpoint.""" simulation_data = generate_simulation_data() @@ -782,7 +759,6 @@ def test_delete_simulation(client): assert rv.status_code == 400 -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_patch_simulation_metadata(client): """Test PATCH /v1.2/simulation/metadata/{simulation_id} endpoint.""" simulation_data = generate_simulation_data( @@ -813,7 +789,6 @@ def test_patch_simulation_metadata(client): assert data == simulation_data.simulation.metadata -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_delete_simulation_metadata(client): """Test DELETE /v1.2/simulation/metadata/{simulation_id} endpoint.""" simulation_data = generate_simulation_data( @@ -846,7 +821,6 @@ def test_delete_simulation_metadata(client): assert data == simulation_data.simulation.metadata -@pytest.mark.skipif(not has_flask, reason="requires flask library") def test_trace_endpoint(client): """Test trace endpoint returns valid SimulationTraceData and handles replacement chains.""" diff --git a/tests/remote/test_authentication.py b/tests/remote/test_authentication.py index 89ae315c..ca504827 100644 --- a/tests/remote/test_authentication.py +++ b/tests/remote/test_authentication.py @@ -5,13 +5,14 @@ import pytest from simdb.config import Config -from simdb.remote.core.auth import User, check_auth, check_role has_easyad = importlib.util.find_spec("easyad") is not None has_flask = importlib.util.find_spec("flask") is not None if has_flask: from flask import Flask + from simdb.remote.core.auth import User, check_auth, check_role + @mock.patch("simdb.config.Config.get_option") @pytest.mark.skipif(not has_flask, reason="requires flask library")