From 43f3cba1a8da6e007ce1dd1c98fc98c9e4a588fd Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Wed, 12 Nov 2025 13:18:56 +0100 Subject: [PATCH 1/4] feat: ui program to cb connection ref: N25B-198 --- .../api/v1/endpoints/program.py | 52 +++++++++++++++++++ src/control_backend/api/v1/router.py | 4 +- src/control_backend/schemas/program.py | 38 ++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/control_backend/api/v1/endpoints/program.py create mode 100644 src/control_backend/schemas/program.py 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..fe0fbac --- /dev/null +++ b/src/control_backend/api/v1/endpoints/program.py @@ -0,0 +1,52 @@ +import json +import logging + +from fastapi import APIRouter, HTTPException, Request + +from control_backend.schemas.message import Message +from control_backend.schemas.program import Phase + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/program", status_code=202) +async def receive_message(program: Message, request: Request): + """ + Receives a BehaviorProgram as a stringified JSON list inside `message`. + Converts it into real Phase objects. + """ + logger.info("Received raw program: ") + logger.debug("%s", program) + raw_str = program.message # This is the JSON string + + # Convert Json into dict. + try: + program_list = json.loads(raw_str) + except json.JSONDecodeError as e: + logger.error("Failed to decode program JSON: %s", e) + raise HTTPException(status_code=400, detail="Undecodeable Json string") from None + + # Validate Phases + 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) + raise HTTPException(status_code=400, detail="Non-Phase String") from None + + logger.info(f"Succesfully recieved {len(phases)} Phase(s).") + for p in phases: + logger.info( + f"Phase {p.id}: " + f"{len(p.phaseData.norms)} norms, " + f"{len(p.phaseData.goals)} goals, " + f"{len(p.phaseData.triggers) if hasattr(p.phaseData, 'triggers') else 0} triggers" + ) + + # send away + topic = b"program" + body = json.dumps([p.model_dump() for p in phases]).encode("utf-8") + pub_socket = request.app.state.endpoints_pub_socket + await pub_socket.send_multipart([topic, body]) + + return {"status": "Program parsed", "phase_count": len(phases)} diff --git a/src/control_backend/api/v1/router.py b/src/control_backend/api/v1/router.py index f11dc9c..809b412 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 command, logs, message, sse +from control_backend.api.v1.endpoints import command, logs, message, program, sse api_router = APIRouter() @@ -11,3 +11,5 @@ api_router.include_router(sse.router, tags=["SSE"]) api_router.include_router(command.router, tags=["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] From 79d3bfb3a6567117e3e9464a4b4d36cc45390571 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Wed, 12 Nov 2025 17:36:00 +0100 Subject: [PATCH 2/4] test: added tests for programs and its scheme ref: N25B-198 --- .../api/v1/endpoints/program.py | 2 +- .../api/endpoints/test_program_endpoint.py | 91 +++++++++++++++++++ .../schemas/test_ui_program_message.py | 85 +++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 test/integration/api/endpoints/test_program_endpoint.py create mode 100644 test/integration/schemas/test_ui_program_message.py 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) From 2ed2a84f130017af23882423f279d6b6486cf93d Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Wed, 12 Nov 2025 18:04:39 +0100 Subject: [PATCH 3/4] style: compacted program and reworked tests ref: N25B-198 --- .../api/v1/endpoints/program.py | 37 ++---- .../api/endpoints/test_program_endpoint.py | 106 ++++++++++++------ 2 files changed, 83 insertions(+), 60 deletions(-) diff --git a/src/control_backend/api/v1/endpoints/program.py b/src/control_backend/api/v1/endpoints/program.py index b711a58..e9812ea 100644 --- a/src/control_backend/api/v1/endpoints/program.py +++ b/src/control_backend/api/v1/endpoints/program.py @@ -1,10 +1,10 @@ -import json import logging from fastapi import APIRouter, HTTPException, Request +from pydantic import ValidationError from control_backend.schemas.message import Message -from control_backend.schemas.program import Phase +from control_backend.schemas.program import Program logger = logging.getLogger(__name__) router = APIRouter() @@ -16,37 +16,20 @@ async def receive_message(program: Message, request: Request): Receives a BehaviorProgram as a stringified JSON list inside `message`. Converts it into real Phase objects. """ - logger.info("Received raw program: ") - logger.debug("%s", program) + logger.debug("Received raw program: %s", program) raw_str = program.message # This is the JSON string - # Convert Json into dict. + # Validate program try: - program_list = json.loads(raw_str) - except json.JSONDecodeError as e: - logger.error("Failed to decode program JSON: %s", e) - raise HTTPException(status_code=400, detail="Undecodeable Json string") from None - - # Validate Phases - 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) - raise HTTPException(status_code=400, detail="Non-Phase String") from None - - logger.info(f"Succesfully recieved {len(phases)} Phase(s).") - for p in phases: - logger.info( - f"Phase {p.id}: " - f"{len(p.phaseData.norms)} norms, " - f"{len(p.phaseData.goals)} goals, " - f"{len(p.phaseData.triggers) if hasattr(p.phaseData, 'triggers') else 0} triggers" - ) + program = Program.model_validate_json(raw_str) + except ValidationError as e: + logger.error("Failed to validate program JSON: %s", e) + raise HTTPException(status_code=400, detail="Not a valid program") from None # send away topic = b"program" - body = json.dumps([p.model_dump() for p in phases]).encode("utf-8") + 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", "phase_count": len(phases)} + return {"status": "Program parsed"} diff --git a/test/integration/api/endpoints/test_program_endpoint.py b/test/integration/api/endpoints/test_program_endpoint.py index 689961f..05ce63c 100644 --- a/test/integration/api/endpoints/test_program_endpoint.py +++ b/test/integration/api/endpoints/test_program_endpoint.py @@ -5,8 +5,9 @@ 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.api.v1.endpoints import program from control_backend.schemas.message import Message +from control_backend.schemas.program import Program @pytest.fixture @@ -23,30 +24,40 @@ def client(app): return TestClient(app) -def make_valid_phase_dict(): - """Helper to create a valid Phase JSON structure.""" +def make_valid_program_dict(): + """Helper to create a valid Program 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"]} - ], - }, + "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 string should parse and be sent via the socket.""" - # Arrange + """Valid Program JSON should be parsed and sent through the socket.""" 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) + program_dict = make_valid_program_dict() + message_body = json.dumps(program_dict) msg = Message(message=message_body) # Act @@ -54,38 +65,67 @@ def test_receive_program_success(client): # Assert assert response.status_code == 202 - assert response.json() == {"status": "Program parsed", "phase_count": 1} + assert response.json() == {"status": "Program parsed"} - # 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]) + # Verify socket call (don't compare raw JSON string) + 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] + + # Decode sent bytes and compare actual structures + sent_obj = json.loads(sent_bytes.decode()) + expected_obj = Program.model_validate_json(message_body).model_dump() + + assert sent_obj == expected_obj def test_receive_program_invalid_json(client): - """Malformed JSON string should return 400 with 'Undecodeable Json string'.""" + """Invalid JSON string (not parseable) should trigger HTTP 400.""" 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()) + bad_json_str = "{invalid json}" + msg = Message(message=bad_json_str) + + response = client.post("/program", json=msg.model_dump()) assert response.status_code == 400 - assert response.json()["detail"] == "Undecodeable Json string" + assert response.json()["detail"] == "Not a valid program" 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'.""" +def test_receive_program_invalid_deep_structure(client): + """Valid JSON shape but invalid deep nested data should still raise 400.""" 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)) + # Structurally correct Program, but with missing elements + bad_program = { + "phases": [ + { + "id": "phase1", + "name": "deepfail", + "nextPhaseId": "phase2", + "phaseData": { + "norms": [ + {"id": "n1", "name": "norm"} # 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_message.model_dump()) + msg = Message(message=json.dumps(bad_program)) + response = client.post("/program", json=msg.model_dump()) assert response.status_code == 400 - assert response.json()["detail"] == "Non-Phase String" + assert response.json()["detail"] == "Not a valid program" mock_pub_socket.send_multipart.assert_not_called() From 39c07dd3cf7d08ffb2bd69d8f5e91ac3a2674e6e Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Tue, 18 Nov 2025 12:35:44 +0100 Subject: [PATCH 4/4] refactor: made pydantic check the input. no longer by the code itself. ref: N25B-198 --- .../api/v1/endpoints/program.py | 16 ++----- .../api/endpoints/test_program_endpoint.py | 42 ++++++++----------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/control_backend/api/v1/endpoints/program.py b/src/control_backend/api/v1/endpoints/program.py index e9812ea..a0679d0 100644 --- a/src/control_backend/api/v1/endpoints/program.py +++ b/src/control_backend/api/v1/endpoints/program.py @@ -1,9 +1,7 @@ import logging -from fastapi import APIRouter, HTTPException, Request -from pydantic import ValidationError +from fastapi import APIRouter, Request -from control_backend.schemas.message import Message from control_backend.schemas.program import Program logger = logging.getLogger(__name__) @@ -11,20 +9,12 @@ router = APIRouter() @router.post("/program", status_code=202) -async def receive_message(program: Message, request: Request): +async def receive_message(program: Program, request: Request): """ - Receives a BehaviorProgram as a stringified JSON list inside `message`. + Receives a BehaviorProgram, pydantic checks it. Converts it into real Phase objects. """ logger.debug("Received raw program: %s", program) - raw_str = program.message # This is the JSON string - - # Validate program - try: - program = Program.model_validate_json(raw_str) - except ValidationError as e: - logger.error("Failed to validate program JSON: %s", e) - raise HTTPException(status_code=400, detail="Not a valid program") from None # send away topic = b"program" diff --git a/test/integration/api/endpoints/test_program_endpoint.py b/test/integration/api/endpoints/test_program_endpoint.py index 05ce63c..f6bb261 100644 --- a/test/integration/api/endpoints/test_program_endpoint.py +++ b/test/integration/api/endpoints/test_program_endpoint.py @@ -6,7 +6,6 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from control_backend.api.v1.endpoints import program -from control_backend.schemas.message import Message from control_backend.schemas.program import Program @@ -57,51 +56,48 @@ def test_receive_program_success(client): client.app.state.endpoints_pub_socket = mock_pub_socket program_dict = make_valid_program_dict() - message_body = json.dumps(program_dict) - msg = Message(message=message_body) - # Act - response = client.post("/program", json=msg.model_dump()) + response = client.post("/program", json=program_dict) - # Assert assert response.status_code == 202 assert response.json() == {"status": "Program parsed"} - # Verify socket call (don't compare raw JSON string) + # 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] - - # Decode sent bytes and compare actual structures sent_obj = json.loads(sent_bytes.decode()) - expected_obj = Program.model_validate_json(message_body).model_dump() + expected_obj = Program.model_validate(program_dict).model_dump() assert sent_obj == expected_obj def test_receive_program_invalid_json(client): - """Invalid JSON string (not parseable) should trigger HTTP 400.""" + """ + 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 - bad_json_str = "{invalid json}" - msg = Message(message=bad_json_str) + # FastAPI only accepts valid JSON bodies, so send raw string + response = client.post("/program", content="{invalid json}") - response = client.post("/program", json=msg.model_dump()) - - assert response.status_code == 400 - assert response.json()["detail"] == "Not a valid program" + assert response.status_code == 422 mock_pub_socket.send_multipart.assert_not_called() def test_receive_program_invalid_deep_structure(client): - """Valid JSON shape but invalid deep nested data should still raise 400.""" + """ + Valid JSON but schema invalid -> Pydantic throws validation error -> 422. + """ mock_pub_socket = AsyncMock() client.app.state.endpoints_pub_socket = mock_pub_socket - # Structurally correct Program, but with missing elements + # Missing "value" in norms element bad_program = { "phases": [ { @@ -110,7 +106,7 @@ def test_receive_program_invalid_deep_structure(client): "nextPhaseId": "phase2", "phaseData": { "norms": [ - {"id": "n1", "name": "norm"} # Missing "value" + {"id": "n1", "name": "norm"} # INVALID: missing "value" ], "goals": [ {"id": "g1", "name": "goal", "description": "desc", "achieved": False} @@ -123,9 +119,7 @@ def test_receive_program_invalid_deep_structure(client): ] } - msg = Message(message=json.dumps(bad_program)) - response = client.post("/program", json=msg.model_dump()) + response = client.post("/program", json=bad_program) - assert response.status_code == 400 - assert response.json()["detail"] == "Not a valid program" + assert response.status_code == 422 mock_pub_socket.send_multipart.assert_not_called()