diff --git a/src/control_backend/api/v1/endpoints/program.py b/src/control_backend/api/v1/endpoints/program.py new file mode 100644 index 0000000..a0679d0 --- /dev/null +++ b/src/control_backend/api/v1/endpoints/program.py @@ -0,0 +1,25 @@ +import logging + +from fastapi import APIRouter, Request + +from control_backend.schemas.program import Program + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/program", status_code=202) +async def receive_message(program: Program, request: Request): + """ + Receives a BehaviorProgram, pydantic checks it. + Converts it into real Phase objects. + """ + logger.debug("Received raw program: %s", program) + + # send away + topic = b"program" + body = program.model_dump_json().encode() + pub_socket = request.app.state.endpoints_pub_socket + await pub_socket.send_multipart([topic, body]) + + return {"status": "Program parsed"} diff --git a/src/control_backend/api/v1/router.py b/src/control_backend/api/v1/router.py index 115cd26..ce5a70b 100644 --- a/src/control_backend/api/v1/router.py +++ b/src/control_backend/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi.routing import APIRouter -from control_backend.api.v1.endpoints import logs, message, robot, sse +from control_backend.api.v1.endpoints import logs, message, program, robot, sse api_router = APIRouter() @@ -11,3 +11,5 @@ api_router.include_router(sse.router, tags=["SSE"]) api_router.include_router(robot.router, prefix="/robot", tags=["Pings", "Commands"]) api_router.include_router(logs.router, tags=["Logs"]) + +api_router.include_router(program.router, tags=["Program"]) diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py new file mode 100644 index 0000000..c207757 --- /dev/null +++ b/src/control_backend/schemas/program.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + + +class Norm(BaseModel): + id: str + name: str + value: str + + +class Goal(BaseModel): + id: str + name: str + description: str + achieved: bool + + +class Trigger(BaseModel): + id: str + label: str + type: str + value: list[str] + + +class PhaseData(BaseModel): + norms: list[Norm] + goals: list[Goal] + triggers: list[Trigger] + + +class Phase(BaseModel): + id: str + name: str + nextPhaseId: str + phaseData: PhaseData + + +class Program(BaseModel): + phases: list[Phase] diff --git a/test/integration/api/endpoints/test_program_endpoint.py b/test/integration/api/endpoints/test_program_endpoint.py new file mode 100644 index 0000000..f6bb261 --- /dev/null +++ b/test/integration/api/endpoints/test_program_endpoint.py @@ -0,0 +1,125 @@ +import json +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from control_backend.api.v1.endpoints import program +from control_backend.schemas.program import Program + + +@pytest.fixture +def app(): + """Create a FastAPI app with the /program route and mock socket.""" + app = FastAPI() + app.include_router(program.router) + return app + + +@pytest.fixture +def client(app): + """Create a TestClient.""" + return TestClient(app) + + +def make_valid_program_dict(): + """Helper to create a valid Program JSON structure.""" + return { + "phases": [ + { + "id": "phase1", + "name": "basephase", + "nextPhaseId": "phase2", + "phaseData": { + "norms": [{"id": "n1", "name": "norm", "value": "be nice"}], + "goals": [ + {"id": "g1", "name": "goal", "description": "test goal", "achieved": False} + ], + "triggers": [ + { + "id": "t1", + "label": "trigger", + "type": "keyword", + "value": ["stop", "exit"], + } + ], + }, + } + ] + } + + +def test_receive_program_success(client): + """Valid Program JSON should be parsed and sent through the socket.""" + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + program_dict = make_valid_program_dict() + + response = client.post("/program", json=program_dict) + + assert response.status_code == 202 + assert response.json() == {"status": "Program parsed"} + + # Verify socket call + mock_pub_socket.send_multipart.assert_awaited_once() + args, kwargs = mock_pub_socket.send_multipart.await_args + + assert args[0][0] == b"program" + + sent_bytes = args[0][1] + sent_obj = json.loads(sent_bytes.decode()) + + expected_obj = Program.model_validate(program_dict).model_dump() + assert sent_obj == expected_obj + + +def test_receive_program_invalid_json(client): + """ + Invalid JSON (malformed) -> FastAPI never calls endpoint. + It returns a 422 Unprocessable Entity. + """ + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # FastAPI only accepts valid JSON bodies, so send raw string + response = client.post("/program", content="{invalid json}") + + assert response.status_code == 422 + mock_pub_socket.send_multipart.assert_not_called() + + +def test_receive_program_invalid_deep_structure(client): + """ + Valid JSON but schema invalid -> Pydantic throws validation error -> 422. + """ + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Missing "value" in norms element + bad_program = { + "phases": [ + { + "id": "phase1", + "name": "deepfail", + "nextPhaseId": "phase2", + "phaseData": { + "norms": [ + {"id": "n1", "name": "norm"} # INVALID: missing "value" + ], + "goals": [ + {"id": "g1", "name": "goal", "description": "desc", "achieved": False} + ], + "triggers": [ + {"id": "t1", "label": "trigger", "type": "keyword", "value": ["start"]} + ], + }, + } + ] + } + + response = client.post("/program", json=bad_program) + + assert response.status_code == 422 + mock_pub_socket.send_multipart.assert_not_called() diff --git a/test/integration/schemas/test_ui_program_message.py b/test/integration/schemas/test_ui_program_message.py new file mode 100644 index 0000000..36352d6 --- /dev/null +++ b/test/integration/schemas/test_ui_program_message.py @@ -0,0 +1,85 @@ +import pytest +from pydantic import ValidationError + +from control_backend.schemas.program import Goal, Norm, Phase, PhaseData, Program, Trigger + + +def base_norm() -> Norm: + return Norm( + id="norm1", + name="testNorm", + value="you should act nice", + ) + + +def base_goal() -> Goal: + return Goal( + id="goal1", + name="testGoal", + description="you should act nice", + achieved=False, + ) + + +def base_trigger() -> Trigger: + return Trigger( + id="trigger1", + label="testTrigger", + type="keyword", + value=["Stop", "Exit"], + ) + + +def base_phase_data() -> PhaseData: + return PhaseData( + norms=[base_norm()], + goals=[base_goal()], + triggers=[base_trigger()], + ) + + +def base_phase() -> Phase: + return Phase( + id="phase1", + name="basephase", + nextPhaseId="phase2", + phaseData=base_phase_data(), + ) + + +def base_program() -> Program: + return Program(phases=[base_phase()]) + + +def invalid_program() -> dict: + # wrong types inside phases list (not Phase objects) + return { + "phases": [ + {"id": "phase1"}, # incomplete + {"not_a_phase": True}, + ] + } + + +def test_valid_program(): + program = base_program() + validated = Program.model_validate(program) + assert isinstance(validated, Program) + assert validated.phases[0].phaseData.norms[0].name == "testNorm" + + +def test_valid_deepprogram(): + program = base_program() + validated = Program.model_validate(program) + # validate nested components directly + phase = validated.phases[0] + assert isinstance(phase.phaseData, PhaseData) + assert isinstance(phase.phaseData.goals[0], Goal) + assert isinstance(phase.phaseData.triggers[0], Trigger) + assert isinstance(phase.phaseData.norms[0], Norm) + + +def test_invalid_program(): + bad = invalid_program() + with pytest.raises(ValidationError): + Program.model_validate(bad)