diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..52d460d1 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/test-state-manager.yml b/.github/workflows/test-state-manager.yml new file mode 100644 index 00000000..028eb6a5 --- /dev/null +++ b/.github/workflows/test-state-manager.yml @@ -0,0 +1,67 @@ +name: State Manager Unit Tests + +on: + push: + branches: [main] + paths: + - 'state-manager/**' + pull_request: + branches: [main] + paths: + - 'state-manager/**' + +jobs: + test: + runs-on: ubuntu-latest + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.runCommand(\"ping\")'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v2 + with: + cache: true + + - name: Install dev dependencies with uv + working-directory: state-manager + run: | + uv sync --group dev + + - name: Run unit tests with pytest and coverage + working-directory: state-manager + run: | + uv run pytest tests/unit/ --cov=app --cov-report=xml --cov-report=term-missing -v --junitxml=pytest-report.xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: exospherehost/exospherehost + files: state-manager/coverage.xml + flags: state-manager-unittests + name: state-manager-coverage-report + fail_ci_if_error: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: state-manager-test-results + path: state-manager/pytest-report.xml + retention-days: 30 diff --git a/.vscode/settings.json b/.vscode/settings.json index 87523f95..ec7a2185 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,10 @@ "tag:yaml.org,2002:python/object/apply:pymdownx.slugs.slugify mapping" ], "python.analysis.typeCheckingMode": "basic", - "python.analysis.autoImportCompletions": true + "python.analysis.autoImportCompletions": true, + "python.testing.pytestArgs": [ + "state-manager" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/state-manager/app/controller/create_states.py b/state-manager/app/controller/create_states.py index 77d5cc81..5a2362fd 100644 --- a/state-manager/app/controller/create_states.py +++ b/state-manager/app/controller/create_states.py @@ -1,14 +1,15 @@ from fastapi import HTTPException from app.singletons.logs_manager import LogsManager -from app.models.create_models import CreateRequestModel, CreateResponseModel, ResponseStateModel +from app.models.create_models import CreateRequestModel, CreateResponseModel, ResponseStateModel, TriggerGraphRequestModel, TriggerGraphResponseModel from app.models.state_status_enum import StateStatusEnum from app.models.db.state import State from app.models.db.graph_template_model import GraphTemplate from app.models.node_template_model import NodeTemplate from beanie.operators import In -from bson import ObjectId +from beanie import PydanticObjectId +import uuid logger = LogsManager().get_logger() @@ -20,6 +21,33 @@ def get_node_template(graph_template: GraphTemplate, identifier: str) -> NodeTem return node +async def trigger_graph(namespace_name: str, graph_name: str, body: TriggerGraphRequestModel, x_exosphere_request_id: str) -> TriggerGraphResponseModel: + try: + # Generate a new run ID for this graph execution + run_id = str(uuid.uuid4()) + logger.info(f"Triggering graph {graph_name} with run_id {run_id}", x_exosphere_request_id=x_exosphere_request_id) + + # Create a CreateRequestModel with the generated run_id + create_request = CreateRequestModel( + run_id=run_id, + states=body.states + ) + + # Call the existing create_states function + create_response = await create_states(namespace_name, graph_name, create_request, x_exosphere_request_id) + + # Return the trigger response with the generated run_id + return TriggerGraphResponseModel( + run_id=run_id, + status=create_response.status, + states=create_response.states + ) + + except Exception as e: + logger.error(f"Error triggering graph {graph_name} for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise e + + async def create_states(namespace_name: str, graph_name: str, body: CreateRequestModel, x_exosphere_request_id: str) -> CreateResponseModel: try: states = [] @@ -39,6 +67,7 @@ async def create_states(namespace_name: str, graph_name: str, body: CreateReques node_name=node_template.node_name, namespace_name=node_template.namespace, graph_name=graph_name, + run_id=body.run_id, status=StateStatusEnum.CREATED, inputs=state.inputs, outputs={}, @@ -51,12 +80,12 @@ async def create_states(namespace_name: str, graph_name: str, body: CreateReques logger.info(f"Created states: {inserted_states.inserted_ids}", x_exosphere_request_id=x_exosphere_request_id) newStates = await State.find( - In(State.id, [ObjectId(id) for id in inserted_states.inserted_ids]) + In(State.id, [PydanticObjectId(id) for id in inserted_states.inserted_ids]) ).to_list() return CreateResponseModel( status=StateStatusEnum.CREATED, - states=[ResponseStateModel(state_id=str(state.id), identifier=state.identifier, node_name=state.node_name, graph_name=state.graph_name, inputs=state.inputs, created_at=state.created_at) for state in newStates] + states=[ResponseStateModel(state_id=str(state.id), identifier=state.identifier, node_name=state.node_name, graph_name=state.graph_name, run_id=state.run_id, inputs=state.inputs, created_at=state.created_at) for state in newStates] ) except Exception as e: diff --git a/state-manager/app/controller/errored_state.py b/state-manager/app/controller/errored_state.py index 830742ce..b59e2573 100644 --- a/state-manager/app/controller/errored_state.py +++ b/state-manager/app/controller/errored_state.py @@ -1,6 +1,6 @@ from app.models.errored_models import ErroredRequestModel, ErroredResponseModel -from bson import ObjectId from fastapi import HTTPException, status +from beanie import PydanticObjectId from app.models.db.state import State from app.models.state_status_enum import StateStatusEnum @@ -8,7 +8,7 @@ logger = LogsManager().get_logger() -async def errored_state(namespace_name: str, state_id: ObjectId, body: ErroredRequestModel, x_exosphere_request_id: str) -> ErroredResponseModel: +async def errored_state(namespace_name: str, state_id: PydanticObjectId, body: ErroredRequestModel, x_exosphere_request_id: str) -> ErroredResponseModel: try: logger.info(f"Errored state {state_id} for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) @@ -23,9 +23,9 @@ async def errored_state(namespace_name: str, state_id: ObjectId, body: ErroredRe if state.status == StateStatusEnum.EXECUTED: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="State is already executed") - await State.find_one(State.id == state_id).set( - {"status": StateStatusEnum.ERRORED, "error": body.error} - ) + state.status = StateStatusEnum.ERRORED + state.error = body.error + await state.save() return ErroredResponseModel(status=StateStatusEnum.ERRORED) diff --git a/state-manager/app/controller/executed_state.py b/state-manager/app/controller/executed_state.py index f6f8ac9d..b1fba7ce 100644 --- a/state-manager/app/controller/executed_state.py +++ b/state-manager/app/controller/executed_state.py @@ -1,5 +1,6 @@ +from beanie import PydanticObjectId from app.models.executed_models import ExecutedRequestModel, ExecutedResponseModel -from bson import ObjectId + from fastapi import HTTPException, status, BackgroundTasks from app.models.db.state import State @@ -9,7 +10,7 @@ logger = LogsManager().get_logger() -async def executed_state(namespace_name: str, state_id: ObjectId, body: ExecutedRequestModel, x_exosphere_request_id: str, background_tasks: BackgroundTasks) -> ExecutedResponseModel: +async def executed_state(namespace_name: str, state_id: PydanticObjectId, body: ExecutedRequestModel, x_exosphere_request_id: str, background_tasks: BackgroundTasks) -> ExecutedResponseModel: try: logger.info(f"Executed state {state_id} for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) @@ -22,17 +23,18 @@ async def executed_state(namespace_name: str, state_id: ObjectId, body: Executed raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="State is not queued") if len(body.outputs) == 0: - await State.find_one(State.id == state_id).set( - {"status": StateStatusEnum.EXECUTED, "outputs": {}, "parents": {**state.parents, state.identifier: state.id}} - ) + state.status = StateStatusEnum.EXECUTED + state.outputs = {} + state.parents = {**state.parents, state.identifier: state.id} + await state.save() background_tasks.add_task(create_next_state, state) - else: - + else: state.outputs = body.outputs[0] state.status = StateStatusEnum.EXECUTED state.parents = {**state.parents, state.identifier: state.id} + await state.save() background_tasks.add_task(create_next_state, state) @@ -43,6 +45,7 @@ async def executed_state(namespace_name: str, state_id: ObjectId, body: Executed namespace_name=state.namespace_name, identifier=state.identifier, graph_name=state.graph_name, + run_id=state.run_id, status=StateStatusEnum.CREATED, inputs=state.inputs, outputs=output, diff --git a/state-manager/app/controller/get_current_states.py b/state-manager/app/controller/get_current_states.py new file mode 100644 index 00000000..a28139a7 --- /dev/null +++ b/state-manager/app/controller/get_current_states.py @@ -0,0 +1,37 @@ +""" +Controller for fetching current states in a namespace +""" +from typing import List + +from ..models.db.state import State +from ..singletons.logs_manager import LogsManager + + +async def get_current_states(namespace: str, request_id: str) -> List[State]: + """ + Get all current states in a namespace + + Args: + namespace: The namespace to search in + request_id: Request ID for logging + + Returns: + List of all states in the namespace + """ + logger = LogsManager().get_logger() + + try: + logger.info(f"Fetching current states for namespace: {namespace}", x_exosphere_request_id=request_id) + + # Find all states in the namespace + states = await State.find( + State.namespace_name == namespace + ).to_list() + + logger.info(f"Found {len(states)} states for namespace: {namespace}", x_exosphere_request_id=request_id) + + return states + + except Exception as e: + logger.error(f"Error fetching current states for namespace {namespace}: {str(e)}", x_exosphere_request_id=request_id) + raise diff --git a/state-manager/app/controller/get_states_by_run_id.py b/state-manager/app/controller/get_states_by_run_id.py new file mode 100644 index 00000000..2c43b839 --- /dev/null +++ b/state-manager/app/controller/get_states_by_run_id.py @@ -0,0 +1,39 @@ +""" +Controller for fetching states by run ID +""" +from typing import List + +from ..models.db.state import State +from ..singletons.logs_manager import LogsManager + + +async def get_states_by_run_id(namespace: str, run_id: str, request_id: str) -> List[State]: + """ + Get all states for a given run ID in a namespace + + Args: + namespace: The namespace to search in + run_id: The run ID to filter by + request_id: Request ID for logging + + Returns: + List of states for the given run ID + """ + logger = LogsManager().get_logger() + + try: + logger.info(f"Fetching states for run ID: {run_id} in namespace: {namespace}", x_exosphere_request_id=request_id) + + # Find all states for the run ID in the namespace + states = await State.find( + State.run_id == run_id, + State.namespace_name == namespace + ).to_list() + + logger.info(f"Found {len(states)} states for run ID: {run_id}", x_exosphere_request_id=request_id) + + return states + + except Exception as e: + logger.error(f"Error fetching states for run ID {run_id} in namespace {namespace}: {str(e)}", x_exosphere_request_id=request_id) + raise diff --git a/state-manager/app/controller/list_graph_templates.py b/state-manager/app/controller/list_graph_templates.py new file mode 100644 index 00000000..700971f2 --- /dev/null +++ b/state-manager/app/controller/list_graph_templates.py @@ -0,0 +1,37 @@ +""" +Controller for listing graph templates by namespace +""" +from typing import List + +from ..models.db.graph_template_model import GraphTemplate +from ..singletons.logs_manager import LogsManager + + +async def list_graph_templates(namespace: str, request_id: str) -> List[GraphTemplate]: + """ + List all graph templates for a given namespace + + Args: + namespace: The namespace to list graph templates for + request_id: Request ID for logging + + Returns: + List of graph templates + """ + logger = LogsManager().get_logger() + + try: + logger.info(f"Listing graph templates for namespace: {namespace}", x_exosphere_request_id=request_id) + + # Find all graph templates for the namespace + templates = await GraphTemplate.find( + GraphTemplate.namespace == namespace + ).to_list() + + logger.info(f"Found {len(templates)} graph templates for namespace: {namespace}", x_exosphere_request_id=request_id) + + return templates + + except Exception as e: + logger.error(f"Error listing graph templates for namespace {namespace}: {str(e)}", x_exosphere_request_id=request_id) + raise diff --git a/state-manager/app/controller/list_registered_nodes.py b/state-manager/app/controller/list_registered_nodes.py new file mode 100644 index 00000000..8ce05d81 --- /dev/null +++ b/state-manager/app/controller/list_registered_nodes.py @@ -0,0 +1,37 @@ +""" +Controller for listing registered nodes by namespace +""" +from typing import List + +from ..models.db.registered_node import RegisteredNode +from ..singletons.logs_manager import LogsManager + + +async def list_registered_nodes(namespace: str, request_id: str) -> List[RegisteredNode]: + """ + List all registered nodes for a given namespace + + Args: + namespace: The namespace to list nodes for + request_id: Request ID for logging + + Returns: + List of registered nodes + """ + logger = LogsManager().get_logger() + + try: + logger.info(f"Listing registered nodes for namespace: {namespace}", x_exosphere_request_id=request_id) + + # Find all registered nodes for the namespace + nodes = await RegisteredNode.find( + RegisteredNode.namespace == namespace + ).to_list() + + logger.info(f"Found {len(nodes)} registered nodes for namespace: {namespace}", x_exosphere_request_id=request_id) + + return nodes + + except Exception as e: + logger.error(f"Error listing registered nodes for namespace {namespace}: {str(e)}", x_exosphere_request_id=request_id) + raise diff --git a/state-manager/app/models/create_models.py b/state-manager/app/models/create_models.py index 063dbf39..c34d490c 100644 --- a/state-manager/app/models/create_models.py +++ b/state-manager/app/models/create_models.py @@ -14,14 +14,26 @@ class ResponseStateModel(BaseModel): node_name: str = Field(..., description="Name of the node of the state") identifier: str = Field(..., description="Identifier of the node for which state is created") graph_name: str = Field(..., description="Name of the graph template for this state") + run_id: str = Field(..., description="Unique run ID for grouping states from the same graph execution") inputs: dict[str, Any] = Field(..., description="Inputs of the state") created_at: datetime = Field(..., description="Date and time when the state was created") class CreateRequestModel(BaseModel): + run_id: str = Field(..., description="Unique run ID for grouping states from the same graph execution") states: list[RequestStateModel] = Field(..., description="List of states") class CreateResponseModel(BaseModel): status: StateStatusEnum = Field(..., description="Status of the state") - states: list[ResponseStateModel] = Field(..., description="List of states") \ No newline at end of file + states: list[ResponseStateModel] = Field(..., description="List of states") + + +class TriggerGraphRequestModel(BaseModel): + states: list[RequestStateModel] = Field(..., description="List of states to create for the graph execution") + + +class TriggerGraphResponseModel(BaseModel): + run_id: str = Field(..., description="Unique run ID generated for this graph execution") + status: StateStatusEnum = Field(..., description="Status of the states") + states: list[ResponseStateModel] = Field(..., description="List of created states") \ No newline at end of file diff --git a/state-manager/app/models/db/state.py b/state-manager/app/models/db/state.py index dcf5757c..7f7c75d7 100644 --- a/state-manager/app/models/db/state.py +++ b/state-manager/app/models/db/state.py @@ -6,11 +6,11 @@ class State(BaseDatabaseModel): - node_name: str = Field(..., description="Name of the node of the state") namespace_name: str = Field(..., description="Name of the namespace of the state") identifier: str = Field(..., description="Identifier of the node for which state is created") graph_name: str = Field(..., description="Name of the graph template for this state") + run_id: str = Field(..., description="Unique run ID for grouping states from the same graph execution") status: StateStatusEnum = Field(..., description="Status of the state") inputs: dict[str, Any] = Field(..., description="Inputs of the state") outputs: dict[str, Any] = Field(..., description="Outputs of the state") diff --git a/state-manager/app/models/list_models.py b/state-manager/app/models/list_models.py new file mode 100644 index 00000000..0b1292f1 --- /dev/null +++ b/state-manager/app/models/list_models.py @@ -0,0 +1,32 @@ +""" +Response models for listing operations +""" +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from .db.registered_node import RegisteredNode +from .db.graph_template_model import GraphTemplate + + +class ListRegisteredNodesResponse(BaseModel): + """Response model for listing registered nodes""" + namespace: str = Field(..., description="The namespace") + count: int = Field(..., description="Number of registered nodes") + nodes: List[RegisteredNode] = Field(..., description="List of registered nodes") + + +class ListGraphTemplatesResponse(BaseModel): + """Response model for listing graph templates""" + namespace: str = Field(..., description="The namespace") + count: int = Field(..., description="Number of graph templates") + templates: List[GraphTemplate] = Field(..., description="List of graph templates") + + +class NamespaceSummaryResponse(BaseModel): + """Response model for namespace summary""" + namespace: str = Field(..., description="The namespace") + registered_nodes_count: int = Field(..., description="Number of registered nodes") + graph_templates_count: int = Field(..., description="Number of graph templates") + total_states_count: int = Field(..., description="Total number of states") + last_updated: Optional[datetime] = Field(None, description="Last update timestamp") diff --git a/state-manager/app/models/state_list_models.py b/state-manager/app/models/state_list_models.py new file mode 100644 index 00000000..003c7018 --- /dev/null +++ b/state-manager/app/models/state_list_models.py @@ -0,0 +1,42 @@ +""" +Response models for state listing operations +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Any +from datetime import datetime +from beanie import PydanticObjectId + +from .state_status_enum import StateStatusEnum + + +class StateListItem(BaseModel): + """Model for a single state in a list""" + id: PydanticObjectId = Field(..., description="State ID") + node_name: str = Field(..., description="Name of the node") + namespace_name: str = Field(..., description="Namespace name") + identifier: str = Field(..., description="Node identifier") + graph_name: str = Field(..., description="Graph name") + run_id: str = Field(..., description="Run ID") + status: StateStatusEnum = Field(..., description="State status") + inputs: dict[str, Any] = Field(..., description="State inputs") + outputs: dict[str, Any] = Field(..., description="State outputs") + error: Optional[str] = Field(None, description="Error message") + parents: dict[str, PydanticObjectId] = Field(default_factory=dict, description="Parent state IDs") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +class StatesByRunIdResponse(BaseModel): + """Response model for fetching states by run ID""" + namespace: str = Field(..., description="The namespace") + run_id: str = Field(..., description="The run ID") + count: int = Field(..., description="Number of states") + states: List[StateListItem] = Field(..., description="List of states") + + +class CurrentStatesResponse(BaseModel): + """Response model for fetching current states""" + namespace: str = Field(..., description="The namespace") + count: int = Field(..., description="Number of states") + states: List[StateListItem] = Field(..., description="List of states") + run_ids: List[str] = Field(..., description="List of unique run IDs") diff --git a/state-manager/app/routes.py b/state-manager/app/routes.py index 3b9f0e0b..c434ae0b 100644 --- a/state-manager/app/routes.py +++ b/state-manager/app/routes.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, status, Request, Depends, HTTPException, BackgroundTasks from uuid import uuid4 -from bson import ObjectId +from beanie import PydanticObjectId from app.utils.check_secret import check_api_key from app.singletons.logs_manager import LogsManager @@ -9,8 +9,8 @@ from .models.enqueue_request import EnqueueRequestModel from .controller.enqueue_states import enqueue_states -from .models.create_models import CreateRequestModel, CreateResponseModel -from .controller.create_states import create_states +from .models.create_models import CreateRequestModel, CreateResponseModel, TriggerGraphRequestModel, TriggerGraphResponseModel +from .controller.create_states import create_states, trigger_graph from .models.executed_models import ExecutedRequestModel, ExecutedResponseModel from .controller.executed_state import executed_state @@ -29,6 +29,14 @@ from .models.secrets_response import SecretsResponseModel from .controller.get_secrets import get_secrets +from .models.list_models import ListRegisteredNodesResponse, ListGraphTemplatesResponse +from .controller.list_registered_nodes import list_registered_nodes +from .controller.list_graph_templates import list_graph_templates + +from .models.state_list_models import StatesByRunIdResponse, CurrentStatesResponse +from .controller.get_states_by_run_id import get_states_by_run_id +from .controller.get_current_states import get_current_states + logger = LogsManager().get_logger() router = APIRouter(prefix="/v0/namespace/{namespace_name}") @@ -54,6 +62,26 @@ async def enqueue_state(namespace_name: str, body: EnqueueRequestModel, request: return await enqueue_states(namespace_name, body, x_exosphere_request_id) +@router.post( + "/graph/{graph_name}/trigger", + response_model=TriggerGraphResponseModel, + status_code=status.HTTP_200_OK, + response_description="Graph triggered successfully with new run ID", + tags=["graph"] +) +async def trigger_graph_route(namespace_name: str, graph_name: str, body: TriggerGraphRequestModel, request: Request, api_key: str = Depends(check_api_key)): + + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4())) + + if api_key: + logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + else: + logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + return await trigger_graph(namespace_name, graph_name, body, x_exosphere_request_id) + + @router.post( "/graph/{graph_name}/states/create", response_model=CreateResponseModel, @@ -91,7 +119,7 @@ async def executed_state_route(namespace_name: str, state_id: str, body: Execute logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - return await executed_state(namespace_name, ObjectId(state_id), body, x_exosphere_request_id, background_tasks) + return await executed_state(namespace_name, PydanticObjectId(state_id), body, x_exosphere_request_id, background_tasks) @router.post( @@ -111,7 +139,7 @@ async def errored_state_route(namespace_name: str, state_id: str, body: ErroredR logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - return await errored_state(namespace_name, ObjectId(state_id), body, x_exosphere_request_id) + return await errored_state(namespace_name, PydanticObjectId(state_id), body, x_exosphere_request_id) @router.put( @@ -188,4 +216,152 @@ async def get_secrets_route(namespace_name: str, state_id: str, request: Request raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - return await get_secrets(namespace_name, state_id, x_exosphere_request_id) \ No newline at end of file + return await get_secrets(namespace_name, state_id, x_exosphere_request_id) + + +@router.get( + "/nodes/", + response_model=ListRegisteredNodesResponse, + status_code=status.HTTP_200_OK, + response_description="Registered nodes listed successfully", + tags=["nodes"] +) +async def list_registered_nodes_route(namespace_name: str, request: Request, api_key: str = Depends(check_api_key)): + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4())) + + if api_key: + logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + else: + logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + nodes = await list_registered_nodes(namespace_name, x_exosphere_request_id) + return ListRegisteredNodesResponse( + namespace=namespace_name, + count=len(nodes), + nodes=nodes + ) + + +@router.get( + "/graphs/", + response_model=ListGraphTemplatesResponse, + status_code=status.HTTP_200_OK, + response_description="Graph templates listed successfully", + tags=["graph"] +) +async def list_graph_templates_route(namespace_name: str, request: Request, api_key: str = Depends(check_api_key)): + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4())) + + if api_key: + logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + else: + logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + templates = await list_graph_templates(namespace_name, x_exosphere_request_id) + return ListGraphTemplatesResponse( + namespace=namespace_name, + count=len(templates), + templates=templates + ) + + +@router.get( + "/states/", + response_model=CurrentStatesResponse, + status_code=status.HTTP_200_OK, + response_description="Current states listed successfully", + tags=["state"] +) +async def get_current_states_route(namespace_name: str, request: Request, api_key: str = Depends(check_api_key)): + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4())) + + if api_key: + logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + else: + logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + states = await get_current_states(namespace_name, x_exosphere_request_id) + + # Convert states to response format + state_items = [] + run_ids = set() + + for state in states: + # Convert ObjectId parents to strings + parents_dict = {k: str(v) for k, v in state.parents.items()} + + state_items.append({ + "id": str(state.id), + "node_name": state.node_name, + "namespace_name": state.namespace_name, + "identifier": state.identifier, + "graph_name": state.graph_name, + "run_id": state.run_id, + "status": state.status, + "inputs": state.inputs, + "outputs": state.outputs, + "error": state.error, + "parents": parents_dict, + "created_at": state.created_at, + "updated_at": state.updated_at + }) + run_ids.add(state.run_id) + + return CurrentStatesResponse( + namespace=namespace_name, + count=len(states), + states=state_items, + run_ids=list(run_ids) + ) + + +@router.get( + "/states/run/{run_id}", + response_model=StatesByRunIdResponse, + status_code=status.HTTP_200_OK, + response_description="States by run ID listed successfully", + tags=["state"] +) +async def get_states_by_run_id_route(namespace_name: str, run_id: str, request: Request, api_key: str = Depends(check_api_key)): + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4())) + + if api_key: + logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + else: + logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + states = await get_states_by_run_id(namespace_name, run_id, x_exosphere_request_id) + + # Convert states to response format + state_items = [] + + for state in states: + # Convert ObjectId parents to strings + parents_dict = {k: str(v) for k, v in state.parents.items()} + + state_items.append({ + "id": str(state.id), + "node_name": state.node_name, + "namespace_name": state.namespace_name, + "identifier": state.identifier, + "graph_name": state.graph_name, + "run_id": state.run_id, + "status": state.status, + "inputs": state.inputs, + "outputs": state.outputs, + "error": state.error, + "parents": parents_dict, + "created_at": state.created_at, + "updated_at": state.updated_at + }) + + return StatesByRunIdResponse( + namespace=namespace_name, + run_id=run_id, + count=len(states), + states=state_items + ) \ No newline at end of file diff --git a/state-manager/app/tasks/create_next_state.py b/state-manager/app/tasks/create_next_state.py index d72611c2..e7d67238 100644 --- a/state-manager/app/tasks/create_next_state.py +++ b/state-manager/app/tasks/create_next_state.py @@ -136,6 +136,7 @@ async def create_next_state(state: State): namespace_name=next_node_template.namespace, identifier=next_node_template.identifier, graph_name=state.graph_name, + run_id=state.run_id, status=StateStatusEnum.CREATED, inputs=next_node_input_data, outputs={}, diff --git a/state-manager/pyproject.toml b/state-manager/pyproject.toml index 139d831c..5d1d93a3 100644 --- a/state-manager/pyproject.toml +++ b/state-manager/pyproject.toml @@ -8,7 +8,9 @@ dependencies = [ "beanie>=2.0.0", "cryptography>=45.0.5", "fastapi>=0.116.1", + "httpx>=0.28.1", "json-schema-to-pydantic>=0.4.1", + "pytest-cov>=6.2.1", "python-dotenv>=1.1.1", "structlog>=25.4.0", "uvicorn>=0.35.0", @@ -17,4 +19,6 @@ dependencies = [ [dependency-groups] dev = [ "ruff>=0.12.5", + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", ] diff --git a/state-manager/pytest.ini b/state-manager/pytest.ini new file mode 100644 index 00000000..67aa1ad0 --- /dev/null +++ b/state-manager/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +markers = + unit: marks a test as a unit test + integration: marks a test as an integration test +asyncio_mode = auto + diff --git a/state-manager/tests/README.md b/state-manager/tests/README.md new file mode 100644 index 00000000..aafe1dff --- /dev/null +++ b/state-manager/tests/README.md @@ -0,0 +1,182 @@ +# State Manager Tests + +This directory contains comprehensive unit tests for the state-manager application. + +## Test Structure + +``` +tests/ +├── unit/ +│ └── controller/ +│ ├── test_create_states.py +│ ├── test_enqueue_states.py +│ ├── test_executed_state.py +│ ├── test_errored_state.py +│ ├── test_get_graph_template.py +│ ├── test_get_secrets.py +│ ├── test_register_nodes.py +│ └── test_upsert_graph_template.py +└── README.md +``` + +## Test Coverage + +The unit tests cover all controller functions in the state-manager: + +### 1. `create_states.py` +- ✅ Successful state creation +- ✅ Graph template not found scenarios +- ✅ Node template not found scenarios +- ✅ Database error handling +- ✅ Multiple states creation + +### 2. `enqueue_states.py` +- ✅ Successful state enqueuing +- ✅ No states found scenarios +- ✅ Multiple states enqueuing +- ✅ Database error handling +- ✅ Different batch sizes + +### 3. `executed_state.py` +- ✅ Successful state execution with single output +- ✅ Multiple outputs handling +- ✅ State not found scenarios +- ✅ Invalid state status scenarios +- ✅ Empty outputs handling +- ✅ Database error handling + +### 4. `errored_state.py` +- ✅ Successful error marking for queued states +- ✅ Successful error marking for executed states +- ✅ State not found scenarios +- ✅ Invalid state status scenarios +- ✅ Different error messages +- ✅ Database error handling + +### 5. `get_graph_template.py` +- ✅ Successful template retrieval +- ✅ Template not found scenarios +- ✅ Validation errors handling +- ✅ Pending validation scenarios +- ✅ Empty nodes handling +- ✅ Complex secrets structure +- ✅ Database error handling + +### 6. `get_secrets.py` +- ✅ Successful secrets retrieval +- ✅ State not found scenarios +- ✅ Namespace mismatch scenarios +- ✅ Graph template not found scenarios +- ✅ Empty secrets handling +- ✅ Complex secrets structure +- ✅ Nested secrets handling +- ✅ Database error handling + +### 7. `register_nodes.py` +- ✅ New node registration +- ✅ Existing node updates +- ✅ Multiple nodes registration +- ✅ Empty secrets handling +- ✅ Complex schema handling +- ✅ Database error handling + +### 8. `upsert_graph_template.py` +- ✅ Existing template updates +- ✅ New template creation +- ✅ Empty nodes handling +- ✅ Complex node structures +- ✅ Validation errors handling +- ✅ Database error handling + +## Running Tests + +### Prerequisites + +Make sure you have the development dependencies installed: + +```bash +uv sync --group dev +``` + +### Run All Tests + +```bash +pytest +``` + +### Run Unit Tests Only + +```bash +pytest tests/unit/ +``` + +### Run Specific Test File + +```bash +pytest tests/unit/controller/test_create_states.py +``` + +### Run Tests with Coverage + +```bash +pytest --cov=app tests/ +``` + +### Run Tests with Verbose Output + +```bash +pytest -v +``` + +### Run Tests and Generate HTML Coverage Report + +```bash +pytest --cov=app --cov-report=html tests/ +``` + +## Test Patterns + +### Async Testing +All controller functions are async, so tests use `pytest-asyncio` and the `async def` pattern. + +### Mocking +Tests use `unittest.mock` to mock: +- Database operations (Beanie ODM) +- External dependencies +- Background tasks +- Logging + +### Fixtures +Common test fixtures are defined for: +- Mock request IDs +- Mock namespaces +- Mock data structures +- Mock database objects + +### Error Handling +Tests cover both success and error scenarios: +- HTTP exceptions (404, 400, etc.) +- Database errors +- Validation errors +- Business logic errors + +## Adding New Tests + +When adding new tests: + +1. Follow the existing naming convention: `test_*.py` +2. Use descriptive test method names +3. Include both success and error scenarios +4. Mock external dependencies +5. Use fixtures for common test data +6. Add proper docstrings explaining test purpose + +## Test Quality Standards + +- Each test should be independent +- Tests should be fast and reliable +- Use meaningful assertions +- Mock external dependencies +- Test both happy path and error scenarios +- Include edge cases and boundary conditions + diff --git a/state-manager/tests/__init__.py b/state-manager/tests/__init__.py new file mode 100644 index 00000000..73308411 --- /dev/null +++ b/state-manager/tests/__init__.py @@ -0,0 +1,2 @@ +# Tests package for state-manager + diff --git a/state-manager/tests/integration/peinding_test_full_workflow_integration.py b/state-manager/tests/integration/peinding_test_full_workflow_integration.py new file mode 100644 index 00000000..d180343a --- /dev/null +++ b/state-manager/tests/integration/peinding_test_full_workflow_integration.py @@ -0,0 +1,348 @@ +""" +Integration tests for the complete state-manager workflow. + +These tests cover the full happy path: +1. Register nodes with the state-manager +2. Create a graph template with the registered nodes +3. Create states for the graph +4. Execute states and verify the workflow + +Prerequisites: +- A running MongoDB instance +- A running Redis instance (if used by the system) +- The state-manager service running on localhost:8000 +""" + +import sys +import os +import pytest +import httpx +from typing import List +import uuid + +# Add the state-manager app to the path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) + +from app.models.register_nodes_request import RegisterNodesRequestModel, NodeRegistrationModel +from app.models.graph_models import UpsertGraphTemplateRequest, NodeTemplate +from app.models.create_models import CreateRequestModel, RequestStateModel +from app.models.executed_models import ExecutedRequestModel +from app.models.enqueue_request import EnqueueRequestModel +from app.models.state_status_enum import StateStatusEnum + +# Mark all tests as integration tests +pytestmark = pytest.mark.integration + + +class TestFullWorkflowIntegration: + """Integration tests for the complete state-manager workflow.""" + + @pytest.fixture + async def state_manager_client(self): + """Create an HTTP client for the state-manager.""" + async with httpx.AsyncClient(base_url="http://localhost:8000") as client: + yield client + + @pytest.fixture + def test_namespace(self) -> str: + """Generate a unique test namespace.""" + return f"test-namespace-{uuid.uuid4().hex[:8]}" + + @pytest.fixture + def test_api_key(self) -> str: + """Get the test API key from environment.""" + return os.environ.get("TEST_API_KEY", "API-KEY") + + @pytest.fixture + def test_graph_name(self) -> str: + """Generate a unique test graph name.""" + return f"test-graph-{uuid.uuid4().hex[:8]}" + + @pytest.fixture + def test_runtime_name(self) -> str: + """Generate a unique test runtime name.""" + return f"test-runtime-{uuid.uuid4().hex[:8]}" + + @pytest.fixture + def sample_node_registration(self) -> NodeRegistrationModel: + """Create a sample node registration for testing.""" + return NodeRegistrationModel( + name="TestNode", + inputs_schema={ + "type": "object", + "properties": { + "input1": {"type": "string"}, + "input2": {"type": "number"} + }, + "required": ["input1", "input2"] + }, + outputs_schema={ + "type": "object", + "properties": { + "output1": {"type": "string"}, + "output2": {"type": "number"} + } + }, + secrets=["test_secret"] + ) + + @pytest.fixture + def sample_graph_nodes(self, test_namespace: str) -> List[NodeTemplate]: + """Create sample graph nodes for testing.""" + return [ + NodeTemplate( + node_name="TestNode", + namespace=test_namespace, + identifier="node1", + inputs={ + "input1": "test_value", + "input2": 42 + }, + next_nodes=["node2"], + unites=None + ), + NodeTemplate( + node_name="TestNode", + namespace=test_namespace, + identifier="node2", + inputs={ + "input1": "{{node1.output1}}", + "input2": "{{node1.output2}}" + }, + next_nodes=[], + unites=None + ) + ] + + async def test_register_nodes(self, state_manager_client, test_namespace: str, + test_api_key: str, test_runtime_name: str, + sample_node_registration: NodeRegistrationModel): + """Test registering nodes with the state-manager.""" + + # Prepare the request + request_data = RegisterNodesRequestModel( + runtime_name=test_runtime_name, + nodes=[sample_node_registration] + ) + + # Make the request + response = await state_manager_client.put( + f"/v0/namespace/{test_namespace}/nodes/", + json=request_data.model_dump(), + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "runtime_name" in response_data + assert response_data["runtime_name"] == test_runtime_name + assert "registered_nodes" in response_data + assert len(response_data["registered_nodes"]) == 1 + assert response_data["registered_nodes"][0]["name"] == "TestNode" + + async def test_upsert_graph_template(self, state_manager_client, test_namespace: str, + test_api_key: str, test_graph_name: str, + sample_graph_nodes: List[NodeTemplate]): + """Test creating a graph template.""" + + # Prepare the request + request_data = UpsertGraphTemplateRequest( + secrets={"test_secret": "secret_value"}, + nodes=sample_graph_nodes + ) + + # Make the request + response = await state_manager_client.put( + f"/v0/namespace/{test_namespace}/graph/{test_graph_name}", + json=request_data.model_dump(), + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 201 + response_data = response.json() + assert "nodes" in response_data + assert "secrets" in response_data + assert "created_at" in response_data + assert "updated_at" in response_data + assert "validation_status" in response_data + assert len(response_data["nodes"]) == 2 + + async def test_get_graph_template(self, state_manager_client, test_namespace: str, + test_api_key: str, test_graph_name: str): + """Test retrieving a graph template.""" + + # Make the request + response = await state_manager_client.get( + f"/v0/namespace/{test_namespace}/graph/{test_graph_name}", + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "nodes" in response_data + assert "secrets" in response_data + assert "created_at" in response_data + assert "updated_at" in response_data + assert "validation_status" in response_data + assert len(response_data["nodes"]) == 2 + + async def test_create_states(self, state_manager_client, test_namespace: str, + test_api_key: str, test_graph_name: str): + """Test creating states for a graph.""" + + # Prepare the request + request_data = CreateRequestModel( + run_id=str(uuid.uuid4()), + states=[ + RequestStateModel( + identifier="node1", + inputs={ + "input1": "test_value", + "input2": 42 + } + ) + ] + ) + + # Make the request + response = await state_manager_client.post( + f"/v0/namespace/{test_namespace}/graph/{test_graph_name}/states/create", + json=request_data.model_dump(), + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "status" in response_data + assert "states" in response_data + assert len(response_data["states"]) == 1 + + # Store the state ID for later tests + state_id = response_data["states"][0]["state_id"] + return state_id + + async def test_queued_state(self, state_manager_client, test_namespace: str, + test_api_key: str): + # Prepare the request + request_data = EnqueueRequestModel( + nodes=["TestNode"], + batch_size=1 + ) + + # Make the request + response = await state_manager_client.post( + f"/v0/namespace/{test_namespace}/states/enqueue", + json=request_data.model_dump(), + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "status" in response_data + assert "namespace" in response_data + assert "count" in response_data + assert "states" in response_data + assert len(response_data["states"]) == 1 + assert response_data["states"][0]["node_name"] == "TestNode" + assert response_data["states"][0]["identifier"] == "node1" + assert response_data["states"][0]["inputs"] == {"input1": "test_value", "input2": 42} + + async def test_execute_state(self, state_manager_client, test_namespace: str, + test_api_key: str, state_id: str): + """Test executing a state.""" + + # Prepare the request + request_data = ExecutedRequestModel( + outputs=[ + { + "output1": "executed_value", + "output2": 100 + } + ] + ) + + # Make the request + response = await state_manager_client.post( + f"/v0/namespace/{test_namespace}/states/{state_id}/executed", + json=request_data.model_dump(), + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "status" in response_data + assert response_data["status"] == StateStatusEnum.EXECUTED + + async def test_get_secrets(self, state_manager_client, test_namespace: str, + test_api_key: str, state_id: str): + """Test retrieving secrets for a state.""" + + # Make the request + response = await state_manager_client.get( + f"/v0/namespace/{test_namespace}/state/{state_id}/secrets", + headers={"X-API-Key": test_api_key} + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "secrets" in response_data + assert "test_secret" in response_data["secrets"] + assert response_data["secrets"]["test_secret"] == "secret_value" + + async def test_full_workflow_happy_path(self, state_manager_client, test_namespace: str, + test_api_key: str, test_graph_name: str, + test_runtime_name: str, sample_node_registration: NodeRegistrationModel, + sample_graph_nodes: List[NodeTemplate]): + """Test the complete happy path workflow.""" + + # Step 1: Register nodes + await self.test_register_nodes( + state_manager_client, test_namespace, test_api_key, + test_runtime_name, sample_node_registration + ) + + # Step 2: Create graph template + await self.test_upsert_graph_template( + state_manager_client, test_namespace, test_api_key, + test_graph_name, sample_graph_nodes + ) + + # Step 3: Get graph template to verify it was created + await self.test_get_graph_template( + state_manager_client, test_namespace, test_api_key, test_graph_name + ) + + # Step 4: Create states + state_id = await self.test_create_states( + state_manager_client, test_namespace, test_api_key, test_graph_name + ) + + # Step 5: Get secrets for the state + await self.test_get_secrets( + state_manager_client, test_namespace, test_api_key, state_id + ) + + await self.test_queued_state( + state_manager_client, test_namespace, test_api_key + ) + + # Step 6: Execute the state + await self.test_execute_state( + state_manager_client, test_namespace, test_api_key, state_id + ) + + # Step 7: Verify the complete workflow by checking the state was processed + # This would typically involve checking the database or making additional API calls + # to verify the state transitioned correctly through the workflow + + print(f"✅ Full workflow completed successfully for namespace: {test_namespace}") + print(f" - Graph: {test_graph_name}") + print(f" - State ID: {state_id}") + print(f" - Runtime: {test_runtime_name}") \ No newline at end of file diff --git a/state-manager/tests/unit/__init__.py b/state-manager/tests/unit/__init__.py new file mode 100644 index 00000000..04a283c1 --- /dev/null +++ b/state-manager/tests/unit/__init__.py @@ -0,0 +1,2 @@ +# Unit tests package + diff --git a/state-manager/tests/unit/controller/__init__.py b/state-manager/tests/unit/controller/__init__.py new file mode 100644 index 00000000..09192663 --- /dev/null +++ b/state-manager/tests/unit/controller/__init__.py @@ -0,0 +1,2 @@ +# Controller unit tests package + diff --git a/state-manager/tests/unit/controller/test_create_states.py b/state-manager/tests/unit/controller/test_create_states.py new file mode 100644 index 00000000..50bb4776 --- /dev/null +++ b/state-manager/tests/unit/controller/test_create_states.py @@ -0,0 +1,301 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi import HTTPException +from beanie import PydanticObjectId +from datetime import datetime + +from app.controller.create_states import create_states, get_node_template +from app.models.create_models import CreateRequestModel, RequestStateModel +from app.models.state_status_enum import StateStatusEnum +from app.models.node_template_model import NodeTemplate + + +class TestGetNodeTemplate: + """Test cases for get_node_template function""" + + def test_get_node_template_success(self): + """Test successful retrieval of node template""" + # Arrange + mock_node = NodeTemplate( + node_name="test_node", + namespace="test_namespace", + identifier="test_identifier", + inputs={}, + next_nodes=[], + unites=None + ) + mock_graph_template = MagicMock() + mock_graph_template.get_node_by_identifier.return_value = mock_node + + # Act + result = get_node_template(mock_graph_template, "test_identifier") + + # Assert + assert result == mock_node + mock_graph_template.get_node_by_identifier.assert_called_once_with("test_identifier") + + def test_get_node_template_not_found(self): + """Test when node template is not found""" + # Arrange + mock_graph_template = MagicMock() + mock_graph_template.get_node_by_identifier.return_value = None + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + get_node_template(mock_graph_template, "non_existent_identifier") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Node template not found" + + +class TestCreateStates: + """Test cases for create_states function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_graph_name(self): + return "test_graph" + + @pytest.fixture + def mock_node_template(self): + return NodeTemplate( + node_name="test_node", + namespace="test_namespace", + identifier="test_identifier", + inputs={}, + next_nodes=[], + unites=None + ) + + @pytest.fixture + def mock_graph_template(self, mock_node_template, mock_graph_name, mock_namespace): + mock_template = MagicMock() + mock_template.name = mock_graph_name + mock_template.namespace = mock_namespace + mock_template.get_node_by_identifier.return_value = mock_node_template + return mock_template + + @pytest.fixture + def mock_create_request(self): + return CreateRequestModel( + run_id="test_run_id", + states=[ + RequestStateModel( + identifier="test_identifier", + inputs={"key": "value"} + ) + ] + ) + + @pytest.fixture + def mock_state(self): + state = MagicMock() + state.id = PydanticObjectId() + state.identifier = "test_identifier" + state.node_name = "test_node" + state.run_id = "test_run_id" + state.graph_name = "test_graph" + state.inputs = {"key": "value"} + state.created_at = datetime.now() + return state + + @patch('app.controller.create_states.GraphTemplate') + @patch('app.controller.create_states.State') + async def test_create_states_success( + self, + mock_state_class, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_create_request, + mock_graph_template, + mock_state, + mock_request_id + ): + """Test successful creation of states""" + # Arrange + # Mock the GraphTemplate class and its find_one method + mock_graph_template_class.find_one = AsyncMock(return_value=mock_graph_template) + + # Mock State.insert_many + mock_insert_result = MagicMock() + mock_insert_result.inserted_ids = [PydanticObjectId()] + mock_state_class.insert_many = AsyncMock(return_value=mock_insert_result) + + # Mock State.find().to_list() + mock_state_find = MagicMock() + mock_state_find.to_list = AsyncMock(return_value=[mock_state]) + mock_state_class.find = MagicMock(return_value=mock_state_find) + + # Act + result = await create_states( + mock_namespace, + mock_graph_name, + mock_create_request, + mock_request_id + ) + + # Assert + assert result.status == StateStatusEnum.CREATED + assert len(result.states) == 1 + assert result.states[0].identifier == "test_identifier" + assert result.states[0].node_name == "test_node" + assert result.states[0].inputs == {"key": "value"} + + # Verify find_one was called (with any arguments) + assert mock_graph_template_class.find_one.called + mock_state_class.insert_many.assert_called_once() + mock_state_class.find.assert_called_once() + + @patch('app.controller.create_states.GraphTemplate') + async def test_create_states_graph_template_not_found( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_create_request, + mock_request_id + ): + """Test when graph template is not found""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await create_states( + mock_namespace, + mock_graph_name, + mock_create_request, + mock_request_id + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Graph template not found" + assert mock_graph_template_class.find_one.called + + @patch('app.controller.create_states.GraphTemplate') + async def test_create_states_node_template_not_found( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_create_request, + mock_request_id + ): + """Test when node template is not found in graph template""" + # Arrange + mock_graph_template = MagicMock() + mock_graph_template.get_node_by_identifier.return_value = None + mock_graph_template_class.find_one = AsyncMock(return_value=mock_graph_template) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await create_states( + mock_namespace, + mock_graph_name, + mock_create_request, + mock_request_id + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Node template not found" + assert mock_graph_template_class.find_one.called + + @patch('app.controller.create_states.GraphTemplate') + async def test_create_states_database_error( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_create_request, + mock_graph_template, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await create_states( + mock_namespace, + mock_graph_name, + mock_create_request, + mock_request_id + ) + + assert str(exc_info.value) == "Database error" + assert mock_graph_template_class.find_one.called + + @patch('app.controller.create_states.GraphTemplate') + @patch('app.controller.create_states.State') + async def test_create_states_multiple_states( + self, + mock_state_class, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_graph_template, + mock_request_id + ): + """Test creation of multiple states""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(return_value=mock_graph_template) + + mock_insert_result = MagicMock() + mock_insert_result.inserted_ids = [PydanticObjectId(), PydanticObjectId()] + mock_state_class.insert_many = AsyncMock(return_value=mock_insert_result) + + # Mock State.find().to_list() for multiple states + mock_state1 = MagicMock() + mock_state1.id = PydanticObjectId() + mock_state1.identifier = "node1" + mock_state1.node_name = "test_node" + mock_state1.run_id = "test_run_id" + mock_state1.graph_name = "test_graph" + mock_state1.inputs = {"input1": "value1"} + mock_state1.created_at = datetime.now() + + mock_state2 = MagicMock() + mock_state2.id = PydanticObjectId() + mock_state2.identifier = "node2" + mock_state2.node_name = "test_node" + mock_state2.run_id = "test_run_id" + mock_state2.graph_name = "test_graph" + mock_state2.inputs = {"input2": "value2"} + mock_state2.created_at = datetime.now() + + mock_state_find = MagicMock() + mock_state_find.to_list = AsyncMock(return_value=[mock_state1, mock_state2]) + mock_state_class.find = MagicMock(return_value=mock_state_find) + + create_request = CreateRequestModel( + run_id="test_run_id", + states=[ + RequestStateModel(identifier="node1", inputs={"input1": "value1"}), + RequestStateModel(identifier="node2", inputs={"input2": "value2"}) + ] + ) + + # Act + result = await create_states( + mock_namespace, + mock_graph_name, + create_request, + mock_request_id + ) + + # Assert + assert result.status == StateStatusEnum.CREATED + assert mock_graph_template_class.find_one.called + mock_state_class.insert_many.assert_called_once() + # Verify that insert_many was called with 2 states + call_args = mock_state_class.insert_many.call_args[0][0] + assert len(call_args) == 2 diff --git a/state-manager/tests/unit/controller/test_enqueue_states.py b/state-manager/tests/unit/controller/test_enqueue_states.py new file mode 100644 index 00000000..41f767c7 --- /dev/null +++ b/state-manager/tests/unit/controller/test_enqueue_states.py @@ -0,0 +1,216 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from beanie import PydanticObjectId +from datetime import datetime + +from app.controller.enqueue_states import enqueue_states +from app.models.enqueue_request import EnqueueRequestModel +from app.models.state_status_enum import StateStatusEnum + + +class TestEnqueueStates: + """Test cases for enqueue_states function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_enqueue_request(self): + return EnqueueRequestModel( + nodes=["node1", "node2"], + batch_size=10 + ) + + @pytest.fixture + def mock_state(self): + state = MagicMock() + state.id = PydanticObjectId() + state.node_name = "node1" + state.identifier = "test_identifier" + state.inputs = {"key": "value"} + state.created_at = datetime.now() + return state + + @patch('app.controller.enqueue_states.State') + async def test_enqueue_states_success( + self, + mock_state_class, + mock_namespace, + mock_enqueue_request, + mock_state, + mock_request_id + ): + """Test successful enqueuing of states""" + # Arrange + # Mock State.find().limit().to_list() chain + mock_query = MagicMock() + mock_query.limit = MagicMock(return_value=mock_query) + mock_query.to_list = AsyncMock(return_value=[mock_state]) + + # Mock State.find().set() chain for updating states + mock_update_query = MagicMock() + mock_update_query.set = AsyncMock() + + # Configure State.find to return different mocks based on call + mock_state_class.find = MagicMock() + mock_state_class.find.side_effect = [mock_query, mock_update_query] + + # Act + result = await enqueue_states( + mock_namespace, + mock_enqueue_request, + mock_request_id + ) + + # Assert + assert result.count == 1 + assert result.namespace == mock_namespace + assert result.status == StateStatusEnum.QUEUED + assert len(result.states) == 1 + assert result.states[0].state_id == str(mock_state.id) + assert result.states[0].node_name == "node1" + assert result.states[0].identifier == "test_identifier" + assert result.states[0].inputs == {"key": "value"} + + # Verify the find query was called correctly + assert mock_state_class.find.call_count == 2 # Called twice: once for finding, once for updating + mock_query.limit.assert_called_once_with(10) + mock_update_query.set.assert_called_once() + + @patch('app.controller.enqueue_states.State') + async def test_enqueue_states_no_states_found( + self, + mock_state_class, + mock_namespace, + mock_enqueue_request, + mock_request_id + ): + """Test when no states are found to enqueue""" + # Arrange + mock_query = MagicMock() + mock_query.limit = MagicMock(return_value=mock_query) + mock_query.to_list = AsyncMock(return_value=[]) + + # When no states are found, the second State.find() call won't happen + mock_state_class.find = MagicMock(return_value=mock_query) + + # Act + result = await enqueue_states( + mock_namespace, + mock_enqueue_request, + mock_request_id + ) + + # Assert + assert result.count == 0 + assert result.namespace == mock_namespace + assert result.status == StateStatusEnum.QUEUED + assert len(result.states) == 0 + + @patch('app.controller.enqueue_states.State') + async def test_enqueue_states_multiple_states( + self, + mock_state_class, + mock_namespace, + mock_enqueue_request, + mock_request_id + ): + """Test enqueuing multiple states""" + # Arrange + state1 = MagicMock() + state1.id = PydanticObjectId() + state1.node_name = "node1" + state1.identifier = "identifier1" + state1.inputs = {"input1": "value1"} + state1.created_at = datetime.now() + + state2 = MagicMock() + state2.id = PydanticObjectId() + state2.node_name = "node2" + state2.identifier = "identifier2" + state2.inputs = {"input2": "value2"} + state2.created_at = datetime.now() + + mock_query = MagicMock() + mock_query.limit = MagicMock(return_value=mock_query) + mock_query.to_list = AsyncMock(return_value=[state1, state2]) + + # Mock State.find().set() chain for updating states + mock_update_query = MagicMock() + mock_update_query.set = AsyncMock() + + # Configure State.find to return different mocks based on call + mock_state_class.find = MagicMock() + mock_state_class.find.side_effect = [mock_query, mock_update_query] + + # Act + result = await enqueue_states( + mock_namespace, + mock_enqueue_request, + mock_request_id + ) + + # Assert + assert result.count == 2 + assert len(result.states) == 2 + assert result.states[0].node_name == "node1" + assert result.states[1].node_name == "node2" + + @patch('app.controller.enqueue_states.State') + async def test_enqueue_states_database_error( + self, + mock_state_class, + mock_namespace, + mock_enqueue_request, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_state_class.find = MagicMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await enqueue_states( + mock_namespace, + mock_enqueue_request, + mock_request_id + ) + + assert str(exc_info.value) == "Database error" + + @patch('app.controller.enqueue_states.State') + async def test_enqueue_states_with_different_batch_size( + self, + mock_state_class, + mock_namespace, + mock_request_id + ): + """Test enqueuing with different batch sizes""" + # Arrange + enqueue_request = EnqueueRequestModel( + nodes=["node1"], + batch_size=5 + ) + + mock_query = MagicMock() + mock_query.limit = MagicMock(return_value=mock_query) + mock_query.to_list = AsyncMock(return_value=[]) + + # When no states are found, the second State.find() call won't happen + mock_state_class.find = MagicMock(return_value=mock_query) + + # Act + result = await enqueue_states( + mock_namespace, + enqueue_request, + mock_request_id + ) + + # Assert + assert result.count == 0 + mock_query.limit.assert_called_once_with(5) diff --git a/state-manager/tests/unit/controller/test_errored_state.py b/state-manager/tests/unit/controller/test_errored_state.py new file mode 100644 index 00000000..b1fc7df5 --- /dev/null +++ b/state-manager/tests/unit/controller/test_errored_state.py @@ -0,0 +1,271 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi import HTTPException, status +from beanie import PydanticObjectId + +from app.controller.errored_state import errored_state +from app.models.errored_models import ErroredRequestModel +from app.models.state_status_enum import StateStatusEnum + + +class TestErroredState: + """Test cases for errored_state function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_state_id(self): + return PydanticObjectId() + + @pytest.fixture + def mock_errored_request(self): + return ErroredRequestModel( + error="Test error message" + ) + + @pytest.fixture + def mock_state_queued(self): + state = MagicMock() + state.id = PydanticObjectId() + state.status = StateStatusEnum.QUEUED + return state + + @pytest.fixture + def mock_state_executed(self): + state = MagicMock() + state.id = PydanticObjectId() + state.status = StateStatusEnum.EXECUTED + return state + + @patch('app.controller.errored_state.State') + async def test_errored_state_success_queued( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_state_queued, + mock_request_id + ): + """Test successful error marking of queued state""" + + mock_state_queued.save = AsyncMock() + + mock_state_queued.status = StateStatusEnum.QUEUED + mock_state_queued.save = AsyncMock() + mock_state_class.find_one = AsyncMock(return_value=mock_state_queued) + + # Act + result = await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + # Assert + assert result.status == StateStatusEnum.ERRORED + assert mock_state_class.find_one.call_count == 1 # Called once for finding + + + @patch('app.controller.errored_state.State') + async def test_errored_state_success_executed( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_state_executed, + mock_request_id + ): + """Test successful error marking of executed state""" + + mock_state_executed.save = AsyncMock() + + mock_state_executed.status = StateStatusEnum.QUEUED + mock_state_executed.save = AsyncMock() + mock_state_class.find_one = AsyncMock(return_value=mock_state_executed) + + # Act + result = await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + # Assert + assert result.status == StateStatusEnum.ERRORED + assert mock_state_class.find_one.call_count == 1 # Called once for finding + + + @patch('app.controller.errored_state.State') + async def test_errored_state_not_found( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ): + """Test when state is not found""" + # Arrange + mock_state_class.find_one = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + assert exc_info.value.detail == "State not found" + + @patch('app.controller.errored_state.State') + async def test_errored_state_invalid_status_created( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ): + """Test when state is in CREATED status (invalid for error marking)""" + # Arrange + mock_state = MagicMock() + mock_state.status = StateStatusEnum.CREATED + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "State is not queued or executed" + + @patch('app.controller.errored_state.State') + async def test_errored_state_invalid_status_error( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ): + """Test when state is already in ERRORED status""" + # Arrange + mock_state = MagicMock() + mock_state.status = StateStatusEnum.ERRORED + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "State is not queued or executed" + + @patch('app.controller.errored_state.State') + async def test_errored_state_already_executed( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ): + """Test when state is already executed (should not allow error marking)""" + # Arrange + mock_state = MagicMock() + mock_state.status = StateStatusEnum.EXECUTED + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "State is already executed" + + @patch('app.controller.errored_state.State') + async def test_errored_state_database_error( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_state_class.find_one = MagicMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await errored_state( + mock_namespace, + mock_state_id, + mock_errored_request, + mock_request_id + ) + + assert str(exc_info.value) == "Database error" + + @patch('app.controller.errored_state.State') + async def test_errored_state_with_different_error_message( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state_queued, + mock_request_id + ): + """Test error marking with different error message""" + # Arrange + errored_request = ErroredRequestModel( + error="Different error message" + ) + + mock_state_queued.save = AsyncMock() + + mock_state_queued.status = StateStatusEnum.QUEUED + mock_state_queued.set = AsyncMock() + mock_state_class.find_one = AsyncMock(return_value=mock_state_queued) + + # Act + result = await errored_state( + mock_namespace, + mock_state_id, + errored_request, + mock_request_id + ) + + # Assert + assert result.status == StateStatusEnum.ERRORED + assert mock_state_class.find_one.call_count == 1 # Called once for finding + assert mock_state_queued.error == "Different error message" + diff --git a/state-manager/tests/unit/controller/test_executed_state.py b/state-manager/tests/unit/controller/test_executed_state.py new file mode 100644 index 00000000..be7fbb82 --- /dev/null +++ b/state-manager/tests/unit/controller/test_executed_state.py @@ -0,0 +1,266 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi import HTTPException, status +from beanie import PydanticObjectId + +from app.controller.executed_state import executed_state +from app.models.executed_models import ExecutedRequestModel +from app.models.state_status_enum import StateStatusEnum + + +class TestExecutedState: + """Test cases for executed_state function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_state_id(self): + return PydanticObjectId() + + @pytest.fixture + def mock_background_tasks(self): + return MagicMock() + + @pytest.fixture + def mock_state(self): + state = MagicMock() + state.id = PydanticObjectId() + state.status = StateStatusEnum.QUEUED + state.node_name = "test_node" + state.namespace_name = "test_namespace" + state.identifier = "test_identifier" + state.graph_name = "test_graph" + state.inputs = {"key": "value"} + state.parents = {} + return state + + @pytest.fixture + def mock_executed_request(self): + return ExecutedRequestModel( + outputs=[{"result": "success"}] + ) + + @patch('app.controller.executed_state.State') + @patch('app.controller.executed_state.create_next_state') + async def test_executed_state_success_single_output( + self, + mock_create_next_state, + mock_state_class, + mock_namespace, + mock_state_id, + mock_executed_request, + mock_state, + mock_background_tasks, + mock_request_id + ): + """Test successful execution of state with single output""" + # Arrange + # Mock State.find_one() for finding the state + # Mock State.find_one().set() for updating the state + mock_update_query = MagicMock() + mock_update_query.set = AsyncMock() + + mock_state.save = AsyncMock() + + mock_state.status = StateStatusEnum.QUEUED + mock_state.save = AsyncMock() + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + # Act + result = await executed_state( + mock_namespace, + mock_state_id, + mock_executed_request, + mock_request_id, + mock_background_tasks + ) + + # Assert + assert result.status == StateStatusEnum.EXECUTED + assert mock_state_class.find_one.call_count == 1 # Called once for finding + mock_background_tasks.add_task.assert_called_once_with(mock_create_next_state, mock_state) + + @patch('app.controller.executed_state.State') + @patch('app.controller.executed_state.create_next_state') + async def test_executed_state_success_multiple_outputs( + self, + mock_create_next_state, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state, + mock_background_tasks, + mock_request_id + ): + """Test successful execution of state with multiple outputs""" + # Arrange + executed_request = ExecutedRequestModel( + outputs=[ + {"result": "success1"}, + {"result": "success2"}, + {"result": "success3"} + ] + ) + + # Mock State.find_one() for finding the state + # Mock State.find_one().set() for updating the state + mock_update_query = MagicMock() + mock_update_query.set = AsyncMock() + + # Configure State.find_one to return different values based on call + # First call returns the state object, second call returns a query object with set method + # Additional calls in the loop also return query objects with set method + mock_state_class.find_one = AsyncMock(return_value=mock_state) + mock_state.save = AsyncMock() + + # Mock State.save() for new states + mock_new_state = MagicMock() + mock_new_state.save = AsyncMock() + mock_state_class.return_value = mock_new_state + + # Act + result = await executed_state( + mock_namespace, + mock_state_id, + executed_request, + mock_request_id, + mock_background_tasks + ) + + # Assert + assert result.status == StateStatusEnum.EXECUTED + # Should create 2 additional states (3 outputs total, 1 for main state, 2 new states) + assert mock_state_class.call_count == 2 + # Should add 3 background tasks (1 for main state + 2 for new states) + assert mock_background_tasks.add_task.call_count == 3 + # State.find_one should be called multiple times: once for finding, once for updating main state, and twice in the loop + assert mock_state_class.find_one.call_count == 1 + + @patch('app.controller.executed_state.State') + async def test_executed_state_not_found( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_executed_request, + mock_background_tasks, + mock_request_id + ): + """Test when state is not found""" + # Arrange + mock_state_class.find_one = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await executed_state( + mock_namespace, + mock_state_id, + mock_executed_request, + mock_request_id, + mock_background_tasks + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + assert exc_info.value.detail == "State not found" + + @patch('app.controller.executed_state.State') + async def test_executed_state_not_queued( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_executed_request, + mock_background_tasks, + mock_request_id + ): + """Test when state is not in QUEUED status""" + # Arrange + mock_state = MagicMock() + mock_state.status = StateStatusEnum.CREATED # Not QUEUED + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await executed_state( + mock_namespace, + mock_state_id, + mock_executed_request, + mock_request_id, + mock_background_tasks + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "State is not queued" + + @patch('app.controller.executed_state.State') + @patch('app.controller.executed_state.create_next_state') + async def test_executed_state_empty_outputs( + self, + mock_create_next_state, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state, + mock_background_tasks, + mock_request_id + ): + """Test execution with empty outputs""" + # Arrange + executed_request = ExecutedRequestModel(outputs=[]) + + # Mock State.find_one() for finding the state + # Mock State.find_one().set() for updating the state + mock_update_query = MagicMock() + mock_update_query.set = AsyncMock() + + # Configure State.find_one to return different values based on call + # First call returns the state object, second call returns a query object with set method + mock_state_class.find_one = AsyncMock(return_value=mock_state) + mock_state.save = AsyncMock() + + # Act + result = await executed_state( + mock_namespace, + mock_state_id, + executed_request, + mock_request_id, + mock_background_tasks + ) + + # Assert + assert result.status == StateStatusEnum.EXECUTED + assert mock_state.outputs == {} + mock_background_tasks.add_task.assert_called_once_with(mock_create_next_state, mock_state) + + @patch('app.controller.executed_state.State') + async def test_executed_state_database_error( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_executed_request, + mock_background_tasks, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_state_class.find_one = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await executed_state( + mock_namespace, + mock_state_id, + mock_executed_request, + mock_request_id, + mock_background_tasks + ) + + assert str(exc_info.value) == "Database error" + diff --git a/state-manager/tests/unit/controller/test_get_graph_template.py b/state-manager/tests/unit/controller/test_get_graph_template.py new file mode 100644 index 00000000..35d5fc09 --- /dev/null +++ b/state-manager/tests/unit/controller/test_get_graph_template.py @@ -0,0 +1,292 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi import HTTPException, status +from datetime import datetime + +from app.controller.get_graph_template import get_graph_template +from app.models.graph_template_validation_status import GraphTemplateValidationStatus +from app.models.node_template_model import NodeTemplate + + +class TestGetGraphTemplate: + """Test cases for get_graph_template function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_graph_name(self): + return "test_graph" + + @pytest.fixture + def mock_graph_template(self): + template = MagicMock() + template.nodes = [ + NodeTemplate( + identifier="node1", + node_name="Test Node 1", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + ), + NodeTemplate( + identifier="node2", + node_name="Test Node 2", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + ) + ] + template.validation_status = GraphTemplateValidationStatus.VALID + template.validation_errors = [] + template.secrets = {"secret1": "encrypted_value1", "secret2": "encrypted_value2"} + template.created_at = datetime(2023, 1, 1, 12, 0, 0) + template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + template.get_secrets.return_value = {"secret1": "encrypted_value1", "secret2": "encrypted_value2"} + return template + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_success( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_graph_template, + mock_request_id + ): + """Test successful retrieval of graph template""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(return_value=mock_graph_template) + + # Act + result = await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + # Assert + assert result.validation_status == GraphTemplateValidationStatus.VALID + assert result.validation_errors == [] + assert result.secrets == {"secret1": True, "secret2": True} + assert result.created_at == mock_graph_template.created_at + assert result.updated_at == mock_graph_template.updated_at + + mock_graph_template_class.find_one.assert_called_once() + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_not_found( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_request_id + ): + """Test when graph template is not found""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + assert exc_info.value.detail == f"Graph template {mock_graph_name} not found in namespace {mock_namespace}" + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_with_validation_errors( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_request_id + ): + """Test retrieval of graph template with validation errors""" + # Arrange + template = MagicMock() + template.nodes = [NodeTemplate( + identifier="node1", + node_name="Test Node", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + )] + template.validation_status = GraphTemplateValidationStatus.INVALID + template.validation_errors = ["Error 1", "Error 2"] + template.secrets = {"secret1": "encrypted_value1"} + template.created_at = datetime(2023, 1, 1, 12, 0, 0) + template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + template.get_secrets.return_value = {"secret1": "encrypted_value1"} + + mock_graph_template_class.find_one = AsyncMock(return_value=template) + + # Act + result = await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + # Assert + assert result.validation_status == GraphTemplateValidationStatus.INVALID + assert result.validation_errors == ["Error 1", "Error 2"] + assert result.secrets == {"secret1": True} + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_with_pending_validation( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_request_id + ): + """Test retrieval of graph template with pending validation""" + # Arrange + template = MagicMock() + template.nodes = [NodeTemplate( + identifier="node1", + node_name="Test Node", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + )] + template.validation_status = GraphTemplateValidationStatus.PENDING + template.validation_errors = [] + template.secrets = {} + template.created_at = datetime(2023, 1, 1, 12, 0, 0) + template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + template.get_secrets.return_value = {} + + mock_graph_template_class.find_one = AsyncMock(return_value=template) + + # Act + result = await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + # Assert + assert result.validation_status == GraphTemplateValidationStatus.PENDING + assert result.validation_errors == [] + assert result.secrets == {} + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_with_empty_nodes( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_request_id + ): + """Test retrieval of graph template with empty nodes""" + # Arrange + template = MagicMock() + template.nodes = [] + template.validation_status = GraphTemplateValidationStatus.VALID + template.validation_errors = [] + template.secrets = {} + template.created_at = datetime(2023, 1, 1, 12, 0, 0) + template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + template.get_secrets.return_value = {} + + mock_graph_template_class.find_one = AsyncMock(return_value=template) + + # Act + result = await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + # Assert + assert result.nodes == [] + assert result.validation_status == GraphTemplateValidationStatus.VALID + assert result.secrets == {} + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_database_error( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + assert str(exc_info.value) == "Database error" + + @patch('app.controller.get_graph_template.GraphTemplate') + async def test_get_graph_template_with_complex_secrets( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_request_id + ): + """Test retrieval of graph template with complex secrets structure""" + # Arrange + template = MagicMock() + template.nodes = [NodeTemplate( + identifier="node1", + node_name="Test Node", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + )] + template.validation_status = GraphTemplateValidationStatus.VALID + template.validation_errors = [] + template.secrets = { + "api_key": "encrypted_api_key", + "database_url": "encrypted_db_url", + "aws_credentials": "encrypted_aws_creds" + } + template.created_at = datetime(2023, 1, 1, 12, 0, 0) + template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + template.get_secrets.return_value = { + "api_key": "encrypted_api_key", + "database_url": "encrypted_db_url", + "aws_credentials": "encrypted_aws_creds" + } + + mock_graph_template_class.find_one = AsyncMock(return_value=template) + + # Act + result = await get_graph_template( + mock_namespace, + mock_graph_name, + mock_request_id + ) + + # Assert + expected_secrets = { + "api_key": True, + "database_url": True, + "aws_credentials": True + } + assert result.secrets == expected_secrets + diff --git a/state-manager/tests/unit/controller/test_get_secrets.py b/state-manager/tests/unit/controller/test_get_secrets.py new file mode 100644 index 00000000..db9818d8 --- /dev/null +++ b/state-manager/tests/unit/controller/test_get_secrets.py @@ -0,0 +1,239 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from beanie import PydanticObjectId + +from app.controller.get_secrets import get_secrets +from app.models.secrets_response import SecretsResponseModel + + +class TestGetSecrets: + """Test cases for get_secrets function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_state_id(self): + return PydanticObjectId() + + @pytest.fixture + def mock_state(self): + state = MagicMock() + state.id = PydanticObjectId() + state.namespace_name = "test_namespace" + state.graph_name = "test_graph" + return state + + @pytest.fixture + def mock_graph_template(self): + template = MagicMock() + template.get_secrets.return_value = { + "api_key": "encrypted_api_key", + "database_url": "encrypted_db_url" + } + return template + + @patch('app.controller.get_secrets.State') + @patch('app.controller.get_secrets.GraphTemplate') + async def test_get_secrets_success( + self, + mock_graph_template_class, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state, + mock_graph_template, + mock_request_id + ): + """Test successful retrieval of secrets""" + # Arrange + mock_state_class.get = AsyncMock(return_value=mock_state) + mock_graph_template_class.find_one = AsyncMock(return_value=mock_graph_template) + + # Act + result = await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + # Assert + assert isinstance(result, SecretsResponseModel) + assert result.secrets == { + "api_key": "encrypted_api_key", + "database_url": "encrypted_db_url" + } + + mock_state_class.get.assert_called_once_with(mock_state_id) + mock_graph_template_class.find_one.assert_called_once() + + @patch('app.controller.get_secrets.State') + async def test_get_secrets_state_not_found( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_request_id + ): + """Test when state is not found""" + # Arrange + mock_state_class.get = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + assert str(exc_info.value) == f"State {mock_state_id} not found" + + @patch('app.controller.get_secrets.State') + async def test_get_secrets_namespace_mismatch( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_request_id + ): + """Test when state belongs to different namespace""" + # Arrange + mock_state = MagicMock() + mock_state.namespace_name = "different_namespace" + mock_state_class.get = AsyncMock(return_value=mock_state) + + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + assert str(exc_info.value) == f"State {mock_state_id} does not belong to namespace {mock_namespace}" + + @patch('app.controller.get_secrets.State') + @patch('app.controller.get_secrets.GraphTemplate') + async def test_get_secrets_graph_template_not_found( + self, + mock_graph_template_class, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state, + mock_request_id + ): + """Test when graph template is not found""" + # Arrange + mock_state_class.get = AsyncMock(return_value=mock_state) + mock_graph_template_class.find_one = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + assert str(exc_info.value) == f"Graph template {mock_state.graph_name} not found in namespace {mock_namespace}" + + @patch('app.controller.get_secrets.State') + @patch('app.controller.get_secrets.GraphTemplate') + async def test_get_secrets_empty_secrets( + self, + mock_graph_template_class, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state, + mock_request_id + ): + """Test retrieval when graph template has no secrets""" + # Arrange + mock_state_class.get = AsyncMock(return_value=mock_state) + + template = MagicMock() + template.get_secrets.return_value = {} + mock_graph_template_class.find_one = AsyncMock(return_value=template) + + # Act + result = await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + # Assert + assert isinstance(result, SecretsResponseModel) + assert result.secrets == {} + + @patch('app.controller.get_secrets.State') + @patch('app.controller.get_secrets.GraphTemplate') + async def test_get_secrets_complex_secrets( + self, + mock_graph_template_class, + mock_state_class, + mock_namespace, + mock_state_id, + mock_state, + mock_request_id + ): + """Test retrieval of complex secrets structure""" + # Arrange + mock_state_class.get = AsyncMock(return_value=mock_state) + + template = MagicMock() + template.get_secrets.return_value = { + "aws_access_key": "encrypted_aws_key", + "aws_secret_key": "encrypted_aws_secret", + "database_password": "encrypted_db_password", + "api_token": "encrypted_api_token", + "ssl_certificate": "encrypted_ssl_cert" + } + mock_graph_template_class.find_one = AsyncMock(return_value=template) + + # Act + result = await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + # Assert + expected_secrets = { + "aws_access_key": "encrypted_aws_key", + "aws_secret_key": "encrypted_aws_secret", + "database_password": "encrypted_db_password", + "api_token": "encrypted_api_token", + "ssl_certificate": "encrypted_ssl_cert" + } + assert result.secrets == expected_secrets + + @patch('app.controller.get_secrets.State') + async def test_get_secrets_database_error( + self, + mock_state_class, + mock_namespace, + mock_state_id, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_state_class.get = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await get_secrets( + mock_namespace, + mock_state_id, + mock_request_id + ) + + assert str(exc_info.value) == "Database error" \ No newline at end of file diff --git a/state-manager/tests/unit/controller/test_trigger_graph.py b/state-manager/tests/unit/controller/test_trigger_graph.py new file mode 100644 index 00000000..a33c961d --- /dev/null +++ b/state-manager/tests/unit/controller/test_trigger_graph.py @@ -0,0 +1,99 @@ +from datetime import datetime +import pytest +from unittest.mock import patch, MagicMock +from fastapi import HTTPException + +from app.controller.create_states import trigger_graph +from app.models.create_models import TriggerGraphRequestModel, RequestStateModel, ResponseStateModel +from app.models.state_status_enum import StateStatusEnum + + +@pytest.fixture +def mock_request(): + return TriggerGraphRequestModel( + states=[ + RequestStateModel( + identifier="test_node_1", + inputs={"input1": "value1"} + ), + RequestStateModel( + identifier="test_node_2", + inputs={"input2": "value2"} + ) + ] + ) + + +@pytest.mark.asyncio +async def test_trigger_graph_success(mock_request): + """Test successful graph triggering""" + namespace_name = "test_namespace" + graph_name = "test_graph" + x_exosphere_request_id = "test_request_id" + + # Mock the create_states function + with patch('app.controller.create_states.create_states') as mock_create_states: + mock_response = MagicMock() + mock_response.status = StateStatusEnum.CREATED + mock_response.states = [ + ResponseStateModel( + state_id="state_1", + identifier="test_node_1", + node_name="TestNode1", + graph_name=graph_name, + run_id="generated_run_id", + inputs={"input1": "value1"}, + created_at=datetime(2024, 1, 1, 0, 0, 0) + ), + ResponseStateModel( + state_id="state_2", + identifier="test_node_2", + node_name="TestNode2", + graph_name=graph_name, + run_id="generated_run_id", + inputs={"input2": "value2"}, + created_at=datetime(2024, 1, 1, 0, 0, 0) + ) + ] + mock_create_states.return_value = mock_response + + # Call the function + result = await trigger_graph(namespace_name, graph_name, mock_request, x_exosphere_request_id) + + # Verify the result + assert result.run_id is not None + assert result.status == StateStatusEnum.CREATED + assert len(result.states) == 2 + assert result.states[0].identifier == "test_node_1" + assert result.states[1].identifier == "test_node_2" + + # Verify create_states was called with the correct parameters + mock_create_states.assert_called_once() + call_args = mock_create_states.call_args + assert call_args[0][0] == namespace_name # namespace_name + assert call_args[0][1] == graph_name # graph_name + assert call_args[0][3] == x_exosphere_request_id # x_exosphere_request_id + + # Verify the CreateRequestModel was created with a generated run_id + create_request = call_args[0][2] # body parameter + assert create_request.run_id is not None + assert create_request.states == mock_request.states + + +@pytest.mark.asyncio +async def test_trigger_graph_create_states_error(mock_request): + """Test error handling when create_states fails""" + namespace_name = "test_namespace" + graph_name = "test_graph" + x_exosphere_request_id = "test_request_id" + + # Mock create_states to raise an exception + with patch('app.controller.create_states.create_states') as mock_create_states: + mock_create_states.side_effect = HTTPException(status_code=404, detail="Graph template not found") + + # Call the function and expect it to raise the same exception + with pytest.raises(HTTPException) as exc_info: + await trigger_graph(namespace_name, graph_name, mock_request, x_exosphere_request_id) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Graph template not found" diff --git a/state-manager/tests/unit/controller/test_upsert_graph_template.py b/state-manager/tests/unit/controller/test_upsert_graph_template.py new file mode 100644 index 00000000..757f9e4f --- /dev/null +++ b/state-manager/tests/unit/controller/test_upsert_graph_template.py @@ -0,0 +1,289 @@ +from time import sleep +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime + +from app.controller.upsert_graph_template import upsert_graph_template +from app.models.graph_models import UpsertGraphTemplateRequest +from app.models.graph_template_validation_status import GraphTemplateValidationStatus +from app.models.node_template_model import NodeTemplate + + +class TestUpsertGraphTemplate: + """Test cases for upsert_graph_template function""" + + @pytest.fixture + def mock_request_id(self): + return "test-request-id" + + @pytest.fixture + def mock_namespace(self): + return "test_namespace" + + @pytest.fixture + def mock_graph_name(self): + return "test_graph" + + @pytest.fixture + def mock_background_tasks(self): + return MagicMock() + + @pytest.fixture + def mock_nodes(self): + return [ + NodeTemplate( + identifier="node1", + node_name="Test Node 1", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + ), + NodeTemplate( + identifier="node2", + node_name="Test Node 2", + namespace="test_namespace", + inputs={}, + next_nodes=[], + unites=None + ) + ] + + @pytest.fixture + def mock_secrets(self): + return { + "api_key": "encrypted_api_key", + "database_url": "encrypted_db_url" + } + + @pytest.fixture + def mock_upsert_request(self, mock_nodes, mock_secrets): + return UpsertGraphTemplateRequest( + nodes=mock_nodes, + secrets=mock_secrets + ) + + @pytest.fixture + def mock_existing_template(self, mock_nodes, mock_secrets): + template = MagicMock() + template.nodes = mock_nodes + template.validation_status = GraphTemplateValidationStatus.VALID + template.validation_errors = [] + template.secrets = mock_secrets + template.created_at = datetime(2023, 1, 1, 12, 0, 0) + template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + template.get_secrets.return_value = mock_secrets + template.set_secrets.return_value = template + return template + + @patch('app.controller.upsert_graph_template.GraphTemplate') + @patch('app.controller.upsert_graph_template.verify_graph') + async def test_upsert_graph_template_update_existing( + self, + mock_verify_graph, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_existing_template, + mock_background_tasks, + mock_request_id + ): + """Test successful update of existing graph template""" + # Arrange + + mock_existing_template.update = AsyncMock() + mock_existing_template.set_secrets = MagicMock(return_value=mock_existing_template) + mock_graph_template_class.find_one = AsyncMock(return_value=mock_existing_template) + + # Act + result = await upsert_graph_template( + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_request_id, + mock_background_tasks + ) + + sleep(1) # wait for the background task to complete + + # Assert + assert result.nodes == mock_upsert_request.nodes + assert result.validation_status == GraphTemplateValidationStatus.VALID + assert result.validation_errors == [] + assert result.secrets == {"api_key": True, "database_url": True} + assert result.created_at == mock_existing_template.created_at + assert result.updated_at == mock_existing_template.updated_at + + # Verify template was updated + mock_existing_template.set_secrets.assert_called_once_with(mock_upsert_request.secrets) + mock_existing_template.update.assert_called_once() + + # Verify background task was added + mock_background_tasks.add_task.assert_called_once_with(mock_verify_graph, mock_existing_template) + + @patch('app.controller.upsert_graph_template.GraphTemplate') + @patch('app.controller.upsert_graph_template.verify_graph') + async def test_upsert_graph_template_create_new( + self, + mock_verify_graph, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_background_tasks, + mock_request_id + ): + """Test successful creation of new graph template""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(return_value=None) # Template doesn't exist + + mock_new_template = MagicMock() + mock_new_template.nodes = mock_upsert_request.nodes + mock_new_template.validation_status = GraphTemplateValidationStatus.PENDING + mock_new_template.validation_errors = [] + mock_new_template.secrets = mock_upsert_request.secrets + mock_new_template.created_at = datetime(2023, 1, 1, 12, 0, 0) + mock_new_template.updated_at = datetime(2023, 1, 1, 12, 0, 0) + mock_new_template.get_secrets.return_value = mock_upsert_request.secrets + mock_new_template.set_secrets.return_value = mock_new_template + + mock_graph_template_class.insert = AsyncMock(return_value=mock_new_template) + + # Act + result = await upsert_graph_template( + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_request_id, + mock_background_tasks + ) + + # Assert + assert result.nodes == mock_upsert_request.nodes + assert result.validation_status == GraphTemplateValidationStatus.PENDING + assert result.validation_errors == [] + assert result.secrets == {"api_key": True, "database_url": True} + + # Verify new template was created + mock_graph_template_class.insert.assert_called_once() + + # Verify background task was added + mock_background_tasks.add_task.assert_called_once_with(mock_verify_graph, mock_new_template) + + @patch('app.controller.upsert_graph_template.GraphTemplate') + async def test_upsert_graph_template_database_error( + self, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_background_tasks, + mock_request_id + ): + """Test handling of database errors""" + # Arrange + mock_graph_template_class.find_one = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception) as exc_info: + await upsert_graph_template( + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_request_id, + mock_background_tasks + ) + + assert str(exc_info.value) == "Database error" + + @patch('app.controller.upsert_graph_template.GraphTemplate') + @patch('app.controller.upsert_graph_template.verify_graph') + async def test_upsert_graph_template_with_empty_nodes( + self, + mock_verify_graph, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_background_tasks, + mock_request_id + ): + """Test upsert with empty nodes list""" + # Arrange + upsert_request = UpsertGraphTemplateRequest( + nodes=[], + secrets={} + ) + + mock_existing_template = MagicMock() + mock_existing_template.nodes = [] + mock_existing_template.validation_status = GraphTemplateValidationStatus.VALID + mock_existing_template.validation_errors = [] + mock_existing_template.secrets = {} + mock_existing_template.created_at = datetime(2023, 1, 1, 12, 0, 0) + mock_existing_template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + mock_existing_template.get_secrets.return_value = {} + mock_existing_template.set_secrets.return_value = mock_existing_template + + mock_existing_template.update = AsyncMock() + + mock_graph_template_class.find_one = AsyncMock(return_value=mock_existing_template) + + # Act + result = await upsert_graph_template( + mock_namespace, + mock_graph_name, + upsert_request, + mock_request_id, + mock_background_tasks + ) + + sleep(1) # wait for the background task to complete + # Assert + assert result.nodes == [] + assert result.validation_status == GraphTemplateValidationStatus.VALID + assert result.validation_errors == [] + assert result.secrets == {} + + @patch('app.controller.upsert_graph_template.GraphTemplate') + @patch('app.controller.upsert_graph_template.verify_graph') + async def test_upsert_graph_template_with_validation_errors( + self, + mock_verify_graph, + mock_graph_template_class, + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_background_tasks, + mock_request_id + ): + """Test upsert with existing validation errors""" + # Arrange + mock_existing_template = MagicMock() + mock_existing_template.nodes = mock_upsert_request.nodes + mock_existing_template.validation_status = GraphTemplateValidationStatus.INVALID + mock_existing_template.validation_errors = ["Previous error 1", "Previous error 2"] + mock_existing_template.secrets = mock_upsert_request.secrets + mock_existing_template.created_at = datetime(2023, 1, 1, 12, 0, 0) + mock_existing_template.updated_at = datetime(2023, 1, 2, 12, 0, 0) + mock_existing_template.get_secrets.return_value = mock_upsert_request.secrets + mock_existing_template.set_secrets.return_value = mock_existing_template + + mock_existing_template.update = AsyncMock() + + mock_graph_template_class.find_one = AsyncMock(return_value=mock_existing_template) + + # Act + result = await upsert_graph_template( + mock_namespace, + mock_graph_name, + mock_upsert_request, + mock_request_id, + mock_background_tasks + ) + + sleep(1) # wait for the background task to complete + + # Assert + assert result.validation_status == GraphTemplateValidationStatus.INVALID + assert result.validation_errors == ["Previous error 1", "Previous error 2"] # Should be reset to empty diff --git a/state-manager/uv.lock b/state-manager/uv.lock index 57a3b9c9..7821105c 100644 --- a/state-manager/uv.lock +++ b/state-manager/uv.lock @@ -41,6 +41,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/36/c40577bc8e3564639b89db32aff1e9e8af14c990e3a7ed85a79b74ec4b78/beanie-2.0.0-py3-none-any.whl", hash = "sha256:0d5c0e0de09f2a316c74d17bbba1ceb68ebcbfd3046ae5be69038b2023682372", size = 87051, upload-time = "2025-07-20T06:55:25.944Z" }, ] +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + [[package]] name = "cffi" version = "1.17.1" @@ -95,6 +104,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/2c/253cc41cd0f40b84c1c34c5363e0407d73d4a1cae005fed6db3b823175bd/coverage-7.10.3.tar.gz", hash = "sha256:812ba9250532e4a823b070b0420a36499859542335af3dca8f47fc6aa1a05619", size = 822936, upload-time = "2025-08-10T21:27:39.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/62/13c0b66e966c43d7aa64dadc8cd2afa1f5a2bf9bb863bdabc21fb94e8b63/coverage-7.10.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:449c1e2d3a84d18bd204258a897a87bc57380072eb2aded6a5b5226046207b42", size = 216262, upload-time = "2025-08-10T21:25:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/59fdf79be7ac2f0206fc739032f482cfd3f66b18f5248108ff192741beae/coverage-7.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d4f9ce50b9261ad196dc2b2e9f1fbbee21651b54c3097a25ad783679fd18294", size = 216496, upload-time = "2025-08-10T21:25:56.759Z" }, + { url = "https://files.pythonhosted.org/packages/34/b1/bc83788ba31bde6a0c02eb96bbc14b2d1eb083ee073beda18753fa2c4c66/coverage-7.10.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dd4564207b160d0d45c36a10bc0a3d12563028e8b48cd6459ea322302a156d7", size = 247989, upload-time = "2025-08-10T21:25:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/f8bdf88357956c844bd872e87cb16748a37234f7f48c721dc7e981145eb7/coverage-7.10.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ca3c9530ee072b7cb6a6ea7b640bcdff0ad3b334ae9687e521e59f79b1d0437", size = 250738, upload-time = "2025-08-10T21:25:59.406Z" }, + { url = "https://files.pythonhosted.org/packages/ae/df/6396301d332b71e42bbe624670af9376f63f73a455cc24723656afa95796/coverage-7.10.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6df359e59fa243c9925ae6507e27f29c46698359f45e568fd51b9315dbbe587", size = 251868, upload-time = "2025-08-10T21:26:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/d760b2df6139b6ef62c9cc03afb9bcdf7d6e36ed4d078baacffa618b4c1c/coverage-7.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a181e4c2c896c2ff64c6312db3bda38e9ade2e1aa67f86a5628ae85873786cea", size = 249790, upload-time = "2025-08-10T21:26:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/69/91/5dcaa134568202397fa4023d7066d4318dc852b53b428052cd914faa05e1/coverage-7.10.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a374d4e923814e8b72b205ef6b3d3a647bb50e66f3558582eda074c976923613", size = 247907, upload-time = "2025-08-10T21:26:03.757Z" }, + { url = "https://files.pythonhosted.org/packages/38/ed/70c0e871cdfef75f27faceada461206c1cc2510c151e1ef8d60a6fedda39/coverage-7.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:daeefff05993e5e8c6e7499a8508e7bd94502b6b9a9159c84fd1fe6bce3151cb", size = 249344, upload-time = "2025-08-10T21:26:05.11Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/c8a273ed503cedc07f8a00dcd843daf28e849f0972e4c6be4c027f418ad6/coverage-7.10.3-cp312-cp312-win32.whl", hash = "sha256:187ecdcac21f9636d570e419773df7bd2fda2e7fa040f812e7f95d0bddf5f79a", size = 218693, upload-time = "2025-08-10T21:26:06.534Z" }, + { url = "https://files.pythonhosted.org/packages/94/58/dd3cfb2473b85be0b6eb8c5b6d80b6fc3f8f23611e69ef745cef8cf8bad5/coverage-7.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:4a50ad2524ee7e4c2a95e60d2b0b83283bdfc745fe82359d567e4f15d3823eb5", size = 219501, upload-time = "2025-08-10T21:26:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/56/af/7cbcbf23d46de6f24246e3f76b30df099d05636b30c53c158a196f7da3ad/coverage-7.10.3-cp312-cp312-win_arm64.whl", hash = "sha256:c112f04e075d3495fa3ed2200f71317da99608cbb2e9345bdb6de8819fc30571", size = 218135, upload-time = "2025-08-10T21:26:09.584Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/239e4de9cc149c80e9cc359fab60592365b8c4cbfcad58b8a939d18c6898/coverage-7.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b99e87304ffe0eb97c5308447328a584258951853807afdc58b16143a530518a", size = 216298, upload-time = "2025-08-10T21:26:10.973Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/28717da68f8ba68f14b9f558aaa8f3e39ada8b9a1ae4f4977c8f98b286d5/coverage-7.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4af09c7574d09afbc1ea7da9dcea23665c01f3bc1b1feb061dac135f98ffc53a", size = 216546, upload-time = "2025-08-10T21:26:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/e1ade16b9e3f2d6c323faeb6bee8e6c23f3a72760a5d9af102ef56a656cb/coverage-7.10.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:488e9b50dc5d2aa9521053cfa706209e5acf5289e81edc28291a24f4e4488f46", size = 247538, upload-time = "2025-08-10T21:26:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2f/6ae1db51dc34db499bfe340e89f79a63bd115fc32513a7bacdf17d33cd86/coverage-7.10.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:913ceddb4289cbba3a310704a424e3fb7aac2bc0c3a23ea473193cb290cf17d4", size = 250141, upload-time = "2025-08-10T21:26:15.787Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ed/33efd8819895b10c66348bf26f011dd621e804866c996ea6893d682218df/coverage-7.10.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1f91cbc78c7112ab84ed2a8defbccd90f888fcae40a97ddd6466b0bec6ae8a", size = 251415, upload-time = "2025-08-10T21:26:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/cb83826f313d07dc743359c9914d9bc460e0798da9a0e38b4f4fabc207ed/coverage-7.10.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0bac054d45af7cd938834b43a9878b36ea92781bcb009eab040a5b09e9927e3", size = 249575, upload-time = "2025-08-10T21:26:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/ae963c7a8e9581c20fa4355ab8940ca272554d8102e872dbb932a644e410/coverage-7.10.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fe72cbdd12d9e0f4aca873fa6d755e103888a7f9085e4a62d282d9d5b9f7928c", size = 247466, upload-time = "2025-08-10T21:26:20.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/b68d1487c6af370b8d5ef223c6d7e250d952c3acfbfcdbf1a773aa0da9d2/coverage-7.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1e2e927ab3eadd7c244023927d646e4c15c65bb2ac7ae3c3e9537c013700d21", size = 249084, upload-time = "2025-08-10T21:26:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/66/4d/a0bcb561645c2c1e21758d8200443669d6560d2a2fb03955291110212ec4/coverage-7.10.3-cp313-cp313-win32.whl", hash = "sha256:24d0c13de473b04920ddd6e5da3c08831b1170b8f3b17461d7429b61cad59ae0", size = 218735, upload-time = "2025-08-10T21:26:23.009Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c3/78b4adddbc0feb3b223f62761e5f9b4c5a758037aaf76e0a5845e9e35e48/coverage-7.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:3564aae76bce4b96e2345cf53b4c87e938c4985424a9be6a66ee902626edec4c", size = 219531, upload-time = "2025-08-10T21:26:24.474Z" }, + { url = "https://files.pythonhosted.org/packages/70/1b/1229c0b2a527fa5390db58d164aa896d513a1fbb85a1b6b6676846f00552/coverage-7.10.3-cp313-cp313-win_arm64.whl", hash = "sha256:f35580f19f297455f44afcd773c9c7a058e52eb6eb170aa31222e635f2e38b87", size = 218162, upload-time = "2025-08-10T21:26:25.847Z" }, + { url = "https://files.pythonhosted.org/packages/fc/26/1c1f450e15a3bf3eaecf053ff64538a2612a23f05b21d79ce03be9ff5903/coverage-7.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07009152f497a0464ffdf2634586787aea0e69ddd023eafb23fc38267db94b84", size = 217003, upload-time = "2025-08-10T21:26:27.231Z" }, + { url = "https://files.pythonhosted.org/packages/29/96/4b40036181d8c2948454b458750960956a3c4785f26a3c29418bbbee1666/coverage-7.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd2ba5f0c7e7e8cc418be2f0c14c4d9e3f08b8fb8e4c0f83c2fe87d03eb655e", size = 217238, upload-time = "2025-08-10T21:26:28.83Z" }, + { url = "https://files.pythonhosted.org/packages/62/23/8dfc52e95da20957293fb94d97397a100e63095ec1e0ef5c09dd8c6f591a/coverage-7.10.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ae22b97003c74186e034a93e4f946c75fad8c0ce8d92fbbc168b5e15ee2841f", size = 258561, upload-time = "2025-08-10T21:26:30.475Z" }, + { url = "https://files.pythonhosted.org/packages/59/95/00e7fcbeda3f632232f4c07dde226afe3511a7781a000aa67798feadc535/coverage-7.10.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb329f1046888a36b1dc35504d3029e1dd5afe2196d94315d18c45ee380f67d5", size = 260735, upload-time = "2025-08-10T21:26:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4c/f4666cbc4571804ba2a65b078ff0de600b0b577dc245389e0bc9b69ae7ca/coverage-7.10.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce01048199a91f07f96ca3074b0c14021f4fe7ffd29a3e6a188ac60a5c3a4af8", size = 262960, upload-time = "2025-08-10T21:26:33.701Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a5/8a9e8a7b12a290ed98b60f73d1d3e5e9ced75a4c94a0d1a671ce3ddfff2a/coverage-7.10.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:08b989a06eb9dfacf96d42b7fb4c9a22bafa370d245dc22fa839f2168c6f9fa1", size = 260515, upload-time = "2025-08-10T21:26:35.16Z" }, + { url = "https://files.pythonhosted.org/packages/86/11/bb59f7f33b2cac0c5b17db0d9d0abba9c90d9eda51a6e727b43bd5fce4ae/coverage-7.10.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:669fe0d4e69c575c52148511029b722ba8d26e8a3129840c2ce0522e1452b256", size = 258278, upload-time = "2025-08-10T21:26:36.539Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/3646f8903743c07b3e53fded0700fed06c580a980482f04bf9536657ac17/coverage-7.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3262d19092771c83f3413831d9904b1ccc5f98da5de4ffa4ad67f5b20c7aaf7b", size = 259408, upload-time = "2025-08-10T21:26:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/6375e9d905da22ddea41cd85c30994b8b6f6c02e44e4c5744b76d16b026f/coverage-7.10.3-cp313-cp313t-win32.whl", hash = "sha256:cc0ee4b2ccd42cab7ee6be46d8a67d230cb33a0a7cd47a58b587a7063b6c6b0e", size = 219396, upload-time = "2025-08-10T21:26:39.426Z" }, + { url = "https://files.pythonhosted.org/packages/33/3b/7da37fd14412b8c8b6e73c3e7458fef6b1b05a37f990a9776f88e7740c89/coverage-7.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:03db599f213341e2960430984e04cf35fb179724e052a3ee627a068653cf4a7c", size = 220458, upload-time = "2025-08-10T21:26:40.905Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/59a9a70f17edab513c844ee7a5c63cf1057041a84cc725b46a51c6f8301b/coverage-7.10.3-cp313-cp313t-win_arm64.whl", hash = "sha256:46eae7893ba65f53c71284585a262f083ef71594f05ec5c85baf79c402369098", size = 218722, upload-time = "2025-08-10T21:26:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/2d/84/bb773b51a06edbf1231b47dc810a23851f2796e913b335a0fa364773b842/coverage-7.10.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:bce8b8180912914032785850d8f3aacb25ec1810f5f54afc4a8b114e7a9b55de", size = 216280, upload-time = "2025-08-10T21:26:44.132Z" }, + { url = "https://files.pythonhosted.org/packages/92/a8/4d8ca9c111d09865f18d56facff64d5fa076a5593c290bd1cfc5dceb8dba/coverage-7.10.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07790b4b37d56608536f7c1079bd1aa511567ac2966d33d5cec9cf520c50a7c8", size = 216557, upload-time = "2025-08-10T21:26:45.598Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b2/eb668bfc5060194bc5e1ccd6f664e8e045881cfee66c42a2aa6e6c5b26e8/coverage-7.10.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e79367ef2cd9166acedcbf136a458dfe9a4a2dd4d1ee95738fb2ee581c56f667", size = 247598, upload-time = "2025-08-10T21:26:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b0/9faa4ac62c8822219dd83e5d0e73876398af17d7305968aed8d1606d1830/coverage-7.10.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:419d2a0f769f26cb1d05e9ccbc5eab4cb5d70231604d47150867c07822acbdf4", size = 250131, upload-time = "2025-08-10T21:26:48.65Z" }, + { url = "https://files.pythonhosted.org/packages/4e/90/203537e310844d4bf1bdcfab89c1e05c25025c06d8489b9e6f937ad1a9e2/coverage-7.10.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee221cf244757cdc2ac882e3062ab414b8464ad9c884c21e878517ea64b3fa26", size = 251485, upload-time = "2025-08-10T21:26:50.368Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/9d894b26bc53c70a1fe503d62240ce6564256d6d35600bdb86b80e516e7d/coverage-7.10.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c2079d8cdd6f7373d628e14b3357f24d1db02c9dc22e6a007418ca7a2be0435a", size = 249488, upload-time = "2025-08-10T21:26:52.045Z" }, + { url = "https://files.pythonhosted.org/packages/b4/28/af167dbac5281ba6c55c933a0ca6675d68347d5aee39cacc14d44150b922/coverage-7.10.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bd8df1f83c0703fa3ca781b02d36f9ec67ad9cb725b18d486405924f5e4270bd", size = 247419, upload-time = "2025-08-10T21:26:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1c/9a4ddc9f0dcb150d4cd619e1c4bb39bcf694c6129220bdd1e5895d694dda/coverage-7.10.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b4e25e0fa335c8aa26e42a52053f3786a61cc7622b4d54ae2dad994aa754fec", size = 248917, upload-time = "2025-08-10T21:26:55.11Z" }, + { url = "https://files.pythonhosted.org/packages/92/27/c6a60c7cbe10dbcdcd7fc9ee89d531dc04ea4c073800279bb269954c5a9f/coverage-7.10.3-cp314-cp314-win32.whl", hash = "sha256:d7c3d02c2866deb217dce664c71787f4b25420ea3eaf87056f44fb364a3528f5", size = 218999, upload-time = "2025-08-10T21:26:56.637Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/a94c1369964ab31273576615d55e7d14619a1c47a662ed3e2a2fe4dee7d4/coverage-7.10.3-cp314-cp314-win_amd64.whl", hash = "sha256:9c8916d44d9e0fe6cdb2227dc6b0edd8bc6c8ef13438bbbf69af7482d9bb9833", size = 219801, upload-time = "2025-08-10T21:26:58.207Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/f5cd2a80f401c01cf0f3add64a7b791b7d53fd6090a4e3e9ea52691cf3c4/coverage-7.10.3-cp314-cp314-win_arm64.whl", hash = "sha256:1007d6a2b3cf197c57105cc1ba390d9ff7f0bee215ced4dea530181e49c65ab4", size = 218381, upload-time = "2025-08-10T21:26:59.707Z" }, + { url = "https://files.pythonhosted.org/packages/73/3d/89d65baf1ea39e148ee989de6da601469ba93c1d905b17dfb0b83bd39c96/coverage-7.10.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ebc8791d346410d096818788877d675ca55c91db87d60e8f477bd41c6970ffc6", size = 217019, upload-time = "2025-08-10T21:27:01.242Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7d/d9850230cd9c999ce3a1e600f85c2fff61a81c301334d7a1faa1a5ba19c8/coverage-7.10.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f4e4d8e75f6fd3c6940ebeed29e3d9d632e1f18f6fb65d33086d99d4d073241", size = 217237, upload-time = "2025-08-10T21:27:03.442Z" }, + { url = "https://files.pythonhosted.org/packages/36/51/b87002d417202ab27f4a1cd6bd34ee3b78f51b3ddbef51639099661da991/coverage-7.10.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:24581ed69f132b6225a31b0228ae4885731cddc966f8a33fe5987288bdbbbd5e", size = 258735, upload-time = "2025-08-10T21:27:05.124Z" }, + { url = "https://files.pythonhosted.org/packages/1c/02/1f8612bfcb46fc7ca64a353fff1cd4ed932bb6e0b4e0bb88b699c16794b8/coverage-7.10.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec151569ddfccbf71bac8c422dce15e176167385a00cd86e887f9a80035ce8a5", size = 260901, upload-time = "2025-08-10T21:27:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3a/fe39e624ddcb2373908bd922756384bb70ac1c5009b0d1674eb326a3e428/coverage-7.10.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ae8e7c56290b908ee817200c0b65929b8050bc28530b131fe7c6dfee3e7d86b", size = 263157, upload-time = "2025-08-10T21:27:08.398Z" }, + { url = "https://files.pythonhosted.org/packages/5e/89/496b6d5a10fa0d0691a633bb2b2bcf4f38f0bdfcbde21ad9e32d1af328ed/coverage-7.10.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fb742309766d7e48e9eb4dc34bc95a424707bc6140c0e7d9726e794f11b92a0", size = 260597, upload-time = "2025-08-10T21:27:10.237Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a6/8b5bf6a9e8c6aaeb47d5fe9687014148efc05c3588110246d5fdeef9b492/coverage-7.10.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c65e2a5b32fbe1e499f1036efa6eb9cb4ea2bf6f7168d0e7a5852f3024f471b1", size = 258353, upload-time = "2025-08-10T21:27:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6d/ad131be74f8afd28150a07565dfbdc86592fd61d97e2dc83383d9af219f0/coverage-7.10.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d48d2cb07d50f12f4f18d2bb75d9d19e3506c26d96fffabf56d22936e5ed8f7c", size = 259504, upload-time = "2025-08-10T21:27:13.254Z" }, + { url = "https://files.pythonhosted.org/packages/ec/30/fc9b5097092758cba3375a8cc4ff61774f8cd733bcfb6c9d21a60077a8d8/coverage-7.10.3-cp314-cp314t-win32.whl", hash = "sha256:dec0d9bc15ee305e09fe2cd1911d3f0371262d3cfdae05d79515d8cb712b4869", size = 219782, upload-time = "2025-08-10T21:27:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/27fbf79451b1fac15c4bda6ec6e9deae27cf7c0648c1305aa21a3454f5c4/coverage-7.10.3-cp314-cp314t-win_amd64.whl", hash = "sha256:424ea93a323aa0f7f01174308ea78bde885c3089ec1bef7143a6d93c3e24ef64", size = 220898, upload-time = "2025-08-10T21:27:16.297Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/a32bbf92869cbf0b7c8b84325327bfc718ad4b6d2c63374fef3d58e39306/coverage-7.10.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f5983c132a62d93d71c9ef896a0b9bf6e6828d8d2ea32611f58684fba60bba35", size = 218922, upload-time = "2025-08-10T21:27:18.22Z" }, + { url = "https://files.pythonhosted.org/packages/84/19/e67f4ae24e232c7f713337f3f4f7c9c58afd0c02866fb07c7b9255a19ed7/coverage-7.10.3-py3-none-any.whl", hash = "sha256:416a8d74dc0adfd33944ba2f405897bab87b7e9e84a391e09d241956bd953ce1", size = 207921, upload-time = "2025-08-10T21:27:38.254Z" }, +] + [[package]] name = "cryptography" version = "45.0.6" @@ -162,6 +235,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -171,6 +272,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "json-schema-to-pydantic" version = "0.4.1" @@ -195,6 +305,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/a4/55bb305df9fe0d343ff8f0dd4da25b2cc33ba65f8596238aa7a4ecbe9777/lazy_model-0.3.0-py3-none-any.whl", hash = "sha256:67c112cad3fbc1816d32c070bf3b3ac1f48aefeb4e46e9eb70e12acc92c6859d", size = 13719, upload-time = "2025-04-22T17:03:34.764Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -261,6 +389,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pymongo" version = "4.13.2" @@ -299,6 +436,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/9c/00301a6df26f0f8d5c5955192892241e803742e7c3da8c2c222efabc0df6/pymongo-4.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c38168263ed94a250fc5cf9c6d33adea8ab11c9178994da1c3481c2a49d235f8", size = 1011057, upload-time = "2025-06-16T18:16:07.917Z" }, ] +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -364,7 +543,9 @@ dependencies = [ { name = "beanie" }, { name = "cryptography" }, { name = "fastapi" }, + { name = "httpx" }, { name = "json-schema-to-pydantic" }, + { name = "pytest-cov" }, { name = "python-dotenv" }, { name = "structlog" }, { name = "uvicorn" }, @@ -372,6 +553,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -380,14 +563,20 @@ requires-dist = [ { name = "beanie", specifier = ">=2.0.0" }, { name = "cryptography", specifier = ">=45.0.5" }, { name = "fastapi", specifier = ">=0.116.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "json-schema-to-pydantic", specifier = ">=0.4.1" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "structlog", specifier = ">=25.4.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.12.5" }] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "ruff", specifier = ">=0.12.5" }, +] [[package]] name = "structlog"