diff --git a/src/control_backend/api/v1/endpoints/program.py b/src/control_backend/api/v1/endpoints/program.py index fe0fbac..b711a58 100644 --- a/src/control_backend/api/v1/endpoints/program.py +++ b/src/control_backend/api/v1/endpoints/program.py @@ -31,7 +31,7 @@ async def receive_message(program: Message, request: Request): try: phases: list[Phase] = [Phase(**phase) for phase in program_list] except Exception as e: - logger.error("❌ Failed to convert to Phase objects: %s", e) + logger.error("Failed to convert to Phase objects: %s", e) raise HTTPException(status_code=400, detail="Non-Phase String") from None logger.info(f"Succesfully recieved {len(phases)} Phase(s).") 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..689961f --- /dev/null +++ b/test/integration/api/endpoints/test_program_endpoint.py @@ -0,0 +1,91 @@ +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 # <-- import your router +from control_backend.schemas.message import Message + + +@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_phase_dict(): + """Helper to create a valid Phase JSON structure.""" + return { + "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 string should parse and be sent via the socket.""" + # Arrange + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + phases_list = [make_valid_phase_dict()] + message_body = json.dumps(phases_list) + msg = Message(message=message_body) + + # Act + response = client.post("/program", json=msg.model_dump()) + + # Assert + assert response.status_code == 202 + assert response.json() == {"status": "Program parsed", "phase_count": 1} + + # Check the mocked socket + expected_body = json.dumps(phases_list).encode("utf-8") + mock_pub_socket.send_multipart.assert_awaited_once_with([b"program", expected_body]) + + +def test_receive_program_invalid_json(client): + """Malformed JSON string should return 400 with 'Undecodeable Json string'.""" + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Not valid JSON + bad_message = Message(message="{not valid json}") + response = client.post("/program", json=bad_message.model_dump()) + + assert response.status_code == 400 + assert response.json()["detail"] == "Undecodeable Json string" + mock_pub_socket.send_multipart.assert_not_called() + + +def test_receive_program_invalid_phase(client): + """Decodable JSON but invalid Phase structure should return 400 with 'Non-Phase String'.""" + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Missing required Phase fields + invalid_phase = [{"id": "only_id"}] + bad_message = Message(message=json.dumps(invalid_phase)) + + response = client.post("/program", json=bad_message.model_dump()) + + assert response.status_code == 400 + assert response.json()["detail"] == "Non-Phase String" + 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)