diff --git a/converter/converter/cisu/resources_info/resources_info_cisu_converter.py b/converter/converter/cisu/resources_info/resources_info_cisu_converter.py index 2beefad90..5f3c6c54e 100644 --- a/converter/converter/cisu/resources_info/resources_info_cisu_converter.py +++ b/converter/converter/cisu/resources_info/resources_info_cisu_converter.py @@ -6,13 +6,25 @@ from converter.cisu.resources_info.resources_info_cisu_constants import ( ResourcesInfoCISUConstants, ) -from converter.repositories.message_repository import get_last_rc_ri_by_case_id +from converter.cisu.resources_info.resources_info_cisu_helper import ( + enrich_rs_ri_with_rs_srs, +) +from converter.repositories.message_repository import ( + get_last_rc_ri_by_case_id, + get_rs_messages_by_case_id, +) from converter.utils import get_field_value, set_value, delete_paths import logging logger = logging.getLogger(__name__) +class ConversionError(ValueError): + def __init__(self, message): + self.message = message + super().__init__(self.message) + + class ResourceUpdateResult(TypedDict): engaged_resources_updated: bool modified_status_resources: List[Dict[str, Any]] @@ -27,6 +39,11 @@ def get_rs_message_type(cls) -> str: def get_cisu_message_type(cls) -> str: return "resourcesInfoCisu" + @classmethod + def _get_latest_state(cls, states: list[dict]) -> dict: + """Return the state with the most recent datetime from a list of states.""" + return sorted(states, key=lambda x: x.get("datetime", ""))[-1] + @classmethod def _build_rs_ri_from_cisu(cls, edxl_json: Dict[str, Any]) -> Dict[str, Any]: """Convert a RC-RI to a RS-RI, removing the position field from each resource.""" @@ -202,16 +219,41 @@ def from_cisu_to_rs(cls, edxl_json: Dict[str, Any]) -> List[Dict[str, Any]]: @classmethod def from_rs_to_cisu(cls, edxl_json: Dict[str, Any]) -> Dict[str, Any]: + """ + Mother function that takes a RS-RI message and returns a RC-RI message + This functions manages: + - taking the RS-RI as param + - fetches potential peristed RS-SR in the db + - merges different resources (following métier rules) + - makes the conversion from rs to cisu + - returns the converted message + """ + logger.info("Converting from RS to CISU format for Resources Info message.") logger.debug(f"Message content: {edxl_json}") output_json = cls.copy_rs_input_content(edxl_json) - output_use_case_json = cls.copy_rs_input_use_case_content(edxl_json) + current_use_case = cls.copy_rs_input_use_case_content(edxl_json) + + case_id = get_field_value(current_use_case, "caseId") + + _, persisted_rs_sr = get_rs_messages_by_case_id(case_id) + rs_sr_use_cases = [ + # Hardcoded RS_SR use case to avoid circular dependency + # TODO: Fix + cls._copy_input_use_case_content(pm.payload, "resourcesStatus") for pm in persisted_rs_sr + ] + enriched = enrich_rs_ri_with_rs_srs(current_use_case, rs_sr_use_cases) + + return cls.convert_single_rs_ri(output_json, enriched) + @classmethod + def convert_single_rs_ri( + cls, output_json: Dict[str, Any], output_use_case_json: Dict[str, Any] + ): resources = get_field_value( output_use_case_json, ResourcesInfoCISUConstants.RESOURCE_PATH ) - - converted_resources = cls.convert_resources_to_cisu(resources) + converted_resources = cls._convert_resources_to_cisu(resources) if len(converted_resources) < 1: raise ValueError( @@ -228,26 +270,24 @@ def from_rs_to_cisu(cls, edxl_json: Dict[str, Any]) -> Dict[str, Any]: return cls.format_cisu_output_json(output_json, output_use_case_json) @classmethod - def convert_resources_to_cisu( + def _convert_resources_to_cisu( cls, resources: list[Dict[str, Any]] ) -> list[Dict[str, Any]]: converted_resources = [] for index, resource in enumerate(resources): - logger.debug(f"Processing resource: {resource}") - rs_vehicle_type = get_field_value( - resource, ResourcesInfoCISUConstants.VEHICLE_TYPE_PATH - ) - - cisu_vehicle_type = cls.translate_to_cisu_vehicle_type(rs_vehicle_type) + logger.debug("Processing resource: {resource}") - if not cisu_vehicle_type: # if we couldn't map the vehicleType on a SIS known type, we continue to filter the whole resource out + vehicle_type = cls.translate_to_cisu_vehicle_type( + get_field_value(resource, ResourcesInfoCISUConstants.VEHICLE_TYPE_PATH) + ) + if vehicle_type is None: continue set_value( resource, ResourcesInfoCISUConstants.VEHICLE_TYPE_PATH, - cisu_vehicle_type, + vehicle_type, ) current_resource_path = ( @@ -261,33 +301,31 @@ def convert_resources_to_cisu( current_state_path, ) cls.keep_last_state(resource) - delete_paths(resource, [ResourcesInfoCISUConstants.PATIENT_ID_KEY]) - converted_resources.append(resource) return converted_resources @classmethod def translate_to_cisu_vehicle_type(cls, rs_vehicle_type: str) -> str | None: + """Translate a RS vehicle type to its CISU equivalent, or None if not mappable.""" if rs_vehicle_type.startswith(ResourcesInfoCISUConstants.VEHICLE_TYPE_SIS): return ResourcesInfoCISUConstants.VEHICLE_TYPE_SIS - elif rs_vehicle_type.startswith(ResourcesInfoCISUConstants.VEHICLE_TYPE_SMUR): + if rs_vehicle_type.startswith(ResourcesInfoCISUConstants.VEHICLE_TYPE_SMUR): return ResourcesInfoCISUConstants.VEHICLE_TYPE_SMUR - else: - logger.info( - "Removing resource because vehicleType '%s' is not supported", - rs_vehicle_type, - ) - return None + logger.info("vehicleType '%s' is not mappable to CISU", rs_vehicle_type) + return None @classmethod def keep_last_state(cls, resource: Dict[str, Any]) -> None: states = get_field_value(resource, ResourcesInfoCISUConstants.STATE_PATH) if not states or len(states) == 0: - raise ValueError( + raise ConversionError( "No states found in resource, mandatory for CISU conversion." ) - latest_state = sorted(states, key=lambda x: x.get("datetime", ""))[-1] - set_value(resource, ResourcesInfoCISUConstants.STATE_PATH, latest_state) + set_value( + resource, + ResourcesInfoCISUConstants.STATE_PATH, + cls._get_latest_state(states), + ) diff --git a/converter/converter/cisu/resources_info/resources_info_cisu_helper.py b/converter/converter/cisu/resources_info/resources_info_cisu_helper.py index 3c77a8b86..bdc0c7295 100644 --- a/converter/converter/cisu/resources_info/resources_info_cisu_helper.py +++ b/converter/converter/cisu/resources_info/resources_info_cisu_helper.py @@ -1,38 +1,39 @@ -def merge_info_and_resources( - resources: list[dict], - resources_status_list: list[dict], -) -> list[dict]: - """ - Enrichit une liste de resources avec les états provenant des resources_status. +from converter.utils import get_field_value, set_value +from converter.cisu.resources_info.resources_info_cisu_constants import ( + ResourcesInfoCISUConstants, +) - Args: - resources: liste de resources (issues du RS-RI déjà extraites) - resources_status_list: liste de resourceStatus (issues des RS-SR déjà extraites) - Returns: - - resources enrichies si un resource_status a été trouvé ; resource inchangée dans le cas contraire - """ +def enrich_rs_ri_with_rs_srs(rs_ri: dict, rs_sr_list: list[dict]) -> dict: + """Enrich RS-RI resources with state from a list of RS-SR messages (in-place + return).""" + + if not rs_sr_list: + return rs_ri - resource_state_by_resource_id: dict[str, dict] = {} - - for resource_status in resources_status_list: - resource_id = resource_status.get("resourceId") - state = resource_status.get("state") - - if resource_id is not None and state is not None: - resource_state_by_resource_id[resource_id] = state + resources = get_field_value(rs_ri, ResourcesInfoCISUConstants.RESOURCE_PATH) + sr_by_resource_id = {sr.get("resourceId"): sr for sr in rs_sr_list} for resource in resources: resource_id = resource.get("resourceId") - if isinstance(resource_id, str): - rs_sr_state = resource_state_by_resource_id.get(resource_id) + persisted_sr = sr_by_resource_id.get(resource_id) + if persisted_sr is None: + continue + + persisted_state = persisted_sr.get("state") + if persisted_state is None: + continue + + current_states = get_field_value( + resource, ResourcesInfoCISUConstants.STATE_PATH + ) + if current_states is None: + set_value( + resource, + ResourcesInfoCISUConstants.STATE_PATH, + [persisted_state], + ) else: - rs_sr_state = None - - # override or set state from RS-SR - if rs_sr_state is not None: - resource["state"] = [ - rs_sr_state - ] # we override the RS-RI state array by a single array with last state only + if persisted_state not in current_states: + current_states.append(persisted_state) - return resources + return rs_ri \ No newline at end of file diff --git a/converter/converter/cisu/resources_status/resources_status_converter.py b/converter/converter/cisu/resources_status/resources_status_converter.py index 83777582c..b29e3fe3b 100644 --- a/converter/converter/cisu/resources_status/resources_status_converter.py +++ b/converter/converter/cisu/resources_status/resources_status_converter.py @@ -1,10 +1,9 @@ from converter.cisu.base_cisu_converter import BaseCISUConverter from converter.repositories.message_repository import ( - get_last_rs_ri_by_case_id, - get_last_rs_sr_per_resource_by_case_id, + get_rs_messages_by_case_id, ) from converter.cisu.resources_info.resources_info_cisu_helper import ( - merge_info_and_resources, + enrich_rs_ri_with_rs_srs, ) from converter.cisu.resources_info.resources_info_cisu_converter import ( ResourcesInfoCISUConverter, @@ -12,13 +11,9 @@ from converter.cisu.resources_status.resources_status_constants import ( ResourcesStatusConstants, ) -from converter.cisu.resources_info.resources_info_cisu_constants import ( - ResourcesInfoCISUConstants, -) - from typing import Any, Dict -from converter.utils import get_field_value, set_value +from converter.utils import get_field_value class ResourcesStatusConverter(BaseCISUConverter): @@ -34,34 +29,24 @@ def get_cisu_message_type(cls) -> str: def from_rs_to_cisu( cls, edxl_json: Dict[str, Any] ) -> Dict[str, Any] | list[Dict[str, Any]]: - content = cls.copy_rs_input_use_case_content(edxl_json) - case_id = get_field_value(content, ResourcesStatusConstants.CASE_ID) + current_use_case = cls.copy_rs_input_use_case_content(edxl_json) + case_id = get_field_value(current_use_case, ResourcesStatusConstants.CASE_ID) - persisted_rs_ri = get_last_rs_ri_by_case_id(case_id) - if persisted_rs_ri is None: # No RS-RI persisted yet, we return an empty list + rs_ri_msg, persisted_rs_sr = get_rs_messages_by_case_id(case_id) + if rs_ri_msg is None: return [] - persisted_rs_sr_list = get_last_rs_sr_per_resource_by_case_id(case_id) + rs_ri = rs_ri_msg.payload - rs_ri = persisted_rs_ri.payload - rs_ri_content = ResourcesInfoCISUConverter.copy_rs_input_use_case_content(rs_ri) - rs_sr_content_list = [ - cls.copy_rs_input_use_case_content(msg.payload) - for msg in persisted_rs_sr_list + rs_sr_use_cases = [ + cls.copy_rs_input_use_case_content(pm.payload) for pm in persisted_rs_sr ] + rs_sr_use_cases.append(current_use_case) - # merge RS-SRs in RS-RI - resources = get_field_value( - rs_ri_content, ResourcesInfoCISUConstants.RESOURCE_PATH - ) - - merged_resources = merge_info_and_resources(resources, rs_sr_content_list) - - set_value( - rs_ri_content, ResourcesInfoCISUConstants.RESOURCE_PATH, merged_resources - ) - merged_rs_ri = ResourcesInfoCISUConverter.format_rs_output_json( - rs_ri, rs_ri_content + output_json = ResourcesInfoCISUConverter.copy_rs_input_content(rs_ri) + rs_ri_use_case = ResourcesInfoCISUConverter.copy_rs_input_use_case_content( + rs_ri ) + enriched = enrich_rs_ri_with_rs_srs(rs_ri_use_case, rs_sr_use_cases) - return ResourcesInfoCISUConverter.from_rs_to_cisu(merged_rs_ri) + return ResourcesInfoCISUConverter.convert_single_rs_ri(output_json, enriched) diff --git a/converter/tests/cisu/helpers.py b/converter/tests/cisu/helpers.py new file mode 100644 index 000000000..affaf6c36 --- /dev/null +++ b/converter/tests/cisu/helpers.py @@ -0,0 +1,128 @@ +"""Shared helpers for CISU converter tests (RS-RI, RS-SR, RC-RI).""" + +import copy +import json +from datetime import datetime +from pathlib import Path + +from converter.models.persisted_message import PersistedMessage +from tests.constants import TestConstants +from tests.test_helpers import TestHelper + + +RS_RI_SAMPLE_PAYLOAD = json.load( + Path("tests/fixtures/RS-RI/sample_rs_ri_payload.json").open() +) +RS_SR_SAMPLE_PAYLOAD = json.load( + Path("tests/fixtures/RS-SR/sample_rs_sr_payload.json").open() +) + +RS_RI_EDXL = TestHelper.create_edxl_json_from_sample( + TestConstants.EDXL_HEALTH_TO_FIRE_ENVELOPE_PATH, + "tests/fixtures/RS-RI/RS-RI_V3.0_exhaustive_fill.json", +) + +RS_SR_EDXL = TestHelper.create_edxl_json_from_sample( + TestConstants.EDXL_HEALTH_TO_FIRE_ENVELOPE_PATH, + "tests/fixtures/RS-SR/RS-SR_V3.0_exhaustive_fill.json", +) + +RC_RI_WITH_POSITION_EDXL = TestHelper.create_edxl_json_from_sample( + TestConstants.EDXL_FIRE_TO_HEALTH_ENVELOPE_PATH, + "tests/fixtures/RC-RI/RC-RI_V3.0_with_position.json", +) + +RS_SR_RESOURCE_ID = "fr.health.samu440.resource.VLM2" + + +def get_edxl_message(edxl: dict) -> dict: + """Extract the message dict from an EDXL envelope.""" + return edxl["content"][0]["jsonContent"]["embeddedJsonContent"]["message"] + + +def get_cisu_content(edxl: dict) -> dict: + """Extract the resourcesInfoCisu use-case content from an EDXL envelope.""" + return get_edxl_message(edxl)["resourcesInfoCisu"] + + +def get_cisu_resources(edxl: dict) -> list[dict]: + """Extract resource list from resourcesInfoCisu.""" + return get_cisu_content(edxl)["resource"] + + +def make_rs_ri_from_sample(case_id: str) -> dict: + """Create a RS-RI EDXL from sample_rs_ri_payload.json with the given caseId.""" + edxl = copy.deepcopy(RS_RI_SAMPLE_PAYLOAD) + get_edxl_message(edxl)["resourcesInfo"]["caseId"] = case_id + return edxl + + +def make_rs_sr_from_sample(case_id: str, resource_id: str, status: str) -> dict: + """Create a RS-SR EDXL from sample_rs_sr_payload.json with the given fields.""" + edxl = copy.deepcopy(RS_SR_SAMPLE_PAYLOAD) + rs = get_edxl_message(edxl)["resourcesStatus"] + rs["caseId"] = case_id + rs["resourceId"] = resource_id + rs["state"]["status"] = status + return edxl + + +def make_rs_ri_edxl( + *, + remove_state: bool = False, + rs_sr_datetime: str | None = None, + resource_overrides: list[dict] | None = None, +) -> tuple[dict, dict, dict]: + """Build a (edxl, rs_ri, rs_sr_edxl) tuple for from_rs_to_cisu tests. + + - Always deep-copies the module-level fixtures. + - Overrides the resources resourceId to match the RS-SR fixture. + - Optionally removes the first resource's state. + - Optionally overrides the RS-SR state datetime. + """ + edxl = copy.deepcopy(RS_RI_EDXL) + rs_ri = get_edxl_message(edxl)["resourcesInfo"] + + for res in rs_ri["resource"]: + res["resourceId"] = RS_SR_RESOURCE_ID + if remove_state: + del res["state"] + + rs_sr_edxl = copy.deepcopy(RS_SR_EDXL) + if rs_sr_datetime is not None: + get_edxl_message(rs_sr_edxl)["resourcesStatus"]["state"]["datetime"] = ( + rs_sr_datetime + ) + + if resource_overrides is not None: + rs_ri["resource"] = resource_overrides + + return edxl, rs_ri, rs_sr_edxl + + +def make_rc_ri_with_resources(resources: list[dict]) -> dict: + """Return a copy of the RC-RI fixture with the given resource list.""" + edxl = copy.deepcopy(RC_RI_WITH_POSITION_EDXL) + get_cisu_content(edxl)["resource"] = resources + return edxl + + +_DEFAULT_ARRIVED_AT = datetime(2024, 8, 1, 14, 0, 0) + + +def persisted(edxl: dict, message_type: str = "RC-RI") -> PersistedMessage: + """Wrap an EDXL dict in a PersistedMessage as the repository would return it.""" + return PersistedMessage( + message_type=message_type, + payload=edxl, + arrived_at=_DEFAULT_ARRIVED_AT, + ) + + +def persisted_rs_ri_and_rs_sr( + rs_ri_edxl: dict, rs_sr_edxls: list[dict] +) -> tuple[PersistedMessage, list[PersistedMessage]]: + """Wrap RS-RI + RS-SR list in PersistedMessages.""" + rs_ri = persisted(rs_ri_edxl, message_type="RS-RI") + rs_sr = [persisted(edxl, message_type="RS-SR") for edxl in rs_sr_edxls] + return rs_ri, rs_sr diff --git a/converter/tests/cisu/test_resources_info_cisu_helper.py b/converter/tests/cisu/test_resources_info_cisu_helper.py index cb18b7ed0..a52b8bd16 100644 --- a/converter/tests/cisu/test_resources_info_cisu_helper.py +++ b/converter/tests/cisu/test_resources_info_cisu_helper.py @@ -1,98 +1,169 @@ -import pytest - from converter.cisu.resources_info.resources_info_cisu_helper import ( - merge_info_and_resources, + enrich_rs_ri_with_rs_srs, ) -@pytest.fixture(autouse=True) -def mock_converter(monkeypatch): - monkeypatch.setattr( - "converter.cisu.resources_info.resources_info_cisu_converter.ResourcesInfoCISUConverter.from_rs_to_cisu", - lambda x: x, - ) +def _make_rs_ri(resources: list[dict]) -> dict: + """Wrap a resource list in a minimal RS-RI use-case dict.""" + return {"resource": resources} -def test_merge_success(): - resources = [ - {"resourceId": "r1"}, - {"resourceId": "r2"}, - ] +def test_enrich_success(): + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1"}, + {"resourceId": "r2"}, + ] + ) - rs_status_list = [ + rs_sr_list = [ {"resourceId": "r1", "state": {"status": "OK"}}, {"resourceId": "r2", "state": {"status": "KO"}}, ] - result = merge_info_and_resources(resources, rs_status_list) + result = enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) assert result is not None - - assert resources[0]["state"] == [{"status": "OK"}] - assert resources[1]["state"] == [{"status": "KO"}] + assert result["resource"][0]["state"] == [{"status": "OK"}] + assert result["resource"][1]["state"] == [{"status": "KO"}] def test_missing_state_for_resource(): - resources = [ - {"resourceId": "r1"}, - {"resourceId": "r2"}, - ] + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1"}, + {"resourceId": "r2"}, + ] + ) - resources_status_list = [ + rs_sr_list = [ {"resourceId": "r1", "state": {"status": "OK"}}, # r2 manquant ] - expected_result = [ + result = enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) + + assert result["resource"] == [ {"resourceId": "r1", "state": [{"status": "OK"}]}, {"resourceId": "r2"}, ] - result = merge_info_and_resources(resources, resources_status_list) - - assert result == expected_result - def test_missing_resource_id(): - resources = [ - {"resourceId": "r1"}, - {}, # pas de resourceId - ] + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1"}, + {}, # pas de resourceId + ] + ) - resources_status_list = [ + rs_sr_list = [ {"resourceId": "r1", "state": {"status": "OK"}}, ] - expected_result = [{"resourceId": "r1", "state": [{"status": "OK"}]}, {}] - result = merge_info_and_resources(resources, resources_status_list) - assert result == expected_result + result = enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) + + assert result["resource"] == [ + {"resourceId": "r1", "state": [{"status": "OK"}]}, + {}, + ] def test_invalid_rs_status_ignored(): - resources = [ - {"resourceId": "r1"}, - ] + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1"}, + ] + ) - resources_status_list = [ + rs_sr_list = [ {}, # invalide {"resourceId": "r1", "state": {"status": "OK"}}, ] - result = merge_info_and_resources(resources, resources_status_list) + result = enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) assert result is not None def test_duplicate_resource_id_last_wins(): - resources = [ - {"resourceId": "r1"}, - ] + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1"}, + ] + ) - resources_status_list = [ + rs_sr_list = [ {"resourceId": "r1", "state": {"status": "OLD"}}, {"resourceId": "r1", "state": {"status": "NEW"}}, ] - resources = merge_info_and_resources(resources, resources_status_list) + result = enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) + + assert result["resource"][0]["state"] == [{"status": "NEW"}] + + +def test_existing_state_is_preserved_and_new_state_appended(): + """When a resource already has states, the RS-SR state is appended (not overwritten).""" + rs_ri = _make_rs_ri( + [ + { + "resourceId": "r1", + "state": [{"status": "INITIAL", "datetime": "2025-01-01T10:00:00Z"}], + }, + ] + ) + + rs_sr_list = [ + { + "resourceId": "r1", + "state": {"status": "UPDATED", "datetime": "2025-01-02T12:00:00Z"}, + }, + ] + + enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) + + assert len(rs_ri["resource"][0]["state"]) == 2 + assert rs_ri["resource"][0]["state"][0] == { + "status": "INITIAL", + "datetime": "2025-01-01T10:00:00Z", + } + assert rs_ri["resource"][0]["state"][1] == { + "status": "UPDATED", + "datetime": "2025-01-02T12:00:00Z", + } + + +def test_duplicate_state_is_not_appended(): + """When the RS-SR state already exists in the resource states, it should not be duplicated.""" + existing_state = {"status": "OK", "datetime": "2025-01-01T10:00:00Z"} + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1", "state": [existing_state]}, + ] + ) + + rs_sr_list = [ + { + "resourceId": "r1", + "state": {"status": "OK", "datetime": "2025-01-01T10:00:00Z"}, + }, + ] + + enrich_rs_ri_with_rs_srs(rs_ri, rs_sr_list) + + assert len(rs_ri["resource"][0]["state"]) == 1 + + +def test_empty_rs_sr_list_returns_rs_ri_unchanged(): + """When rs_sr_list is empty, the RS-RI is returned as-is.""" + rs_ri = _make_rs_ri( + [ + {"resourceId": "r1"}, + ] + ) + + result = enrich_rs_ri_with_rs_srs(rs_ri, []) - assert resources[0]["state"] == [{"status": "NEW"}] + assert result is rs_ri + assert "state" not in result["resource"][0] diff --git a/converter/tests/cisu/test_resources_info_converter.py b/converter/tests/cisu/test_resources_info_converter.py index c06fc080f..46ebadcdb 100644 --- a/converter/tests/cisu/test_resources_info_converter.py +++ b/converter/tests/cisu/test_resources_info_converter.py @@ -1,5 +1,4 @@ import copy -from datetime import datetime from unittest import TestCase from unittest.mock import patch @@ -8,13 +7,24 @@ from converter.cisu.resources_info.resources_info_cisu_converter import ( ResourcesInfoCISUConverter, ) -from converter.models.persisted_message import PersistedMessage from tests.constants import TestConstants from tests.test_helpers import TestHelper, get_file_endpoint from jsonschema import validate +from tests.cisu.helpers import ( + RC_RI_WITH_POSITION_EDXL, + get_cisu_content, + get_edxl_message, + make_rc_ri_with_resources, + make_rs_ri_edxl, + persisted, + persisted_rs_ri_and_rs_sr, +) RS_RI_SCHEMA = TestHelper.load_schema("RS-RI.schema.json") +_PATCH_GET_LAST_RC_RI_BY_CASE_ID = "converter.cisu.resources_info.resources_info_cisu_converter.get_last_rc_ri_by_case_id" +_PATCH_GET_RS_MESSAGES_BY_CASE_ID = "converter.cisu.resources_info.resources_info_cisu_converter.get_rs_messages_by_case_id" + usecase_files_with_empty_state = [ "RS-RI_FuiteDeGaz_AliceGregoireNORMAND.06.json", "RS-RI_Incendie_RaymondeLECCIA.04.json", @@ -61,28 +71,26 @@ def test_rs_to_cisu(file_name, expected_exception, expected_message): edxl_json = TestHelper.create_edxl_json_from_sample( TestConstants.EDXL_HEALTH_TO_FIRE_ENVELOPE_PATH, usecase_file["path"] ) + with patch(_PATCH_GET_RS_MESSAGES_BY_CASE_ID, return_value=(None, [])): + if expected_exception is None: + # Cas nominal + rc_schema_endpoint = get_file_endpoint( + TestConstants.V3_GITHUB_TAG, + TestConstants.RC_RI_TAG, + ) + rc_schema = TestHelper.load_json_file_online(rc_schema_endpoint) - if expected_exception is None: - # Cas nominal - rc_schema_endpoint = get_file_endpoint( - TestConstants.V3_GITHUB_TAG, - TestConstants.RC_RI_TAG, - ) - rc_schema = TestHelper.load_json_file_online(rc_schema_endpoint) + result = ResourcesInfoCISUConverter.from_rs_to_cisu(edxl_json) - result = ResourcesInfoCISUConverter.from_rs_to_cisu(edxl_json) + usecase_name = rc_schema["title"] + converted_message = get_edxl_message(result)[usecase_name] - usecase_name = rc_schema["title"] - converted_message = result["content"][0]["jsonContent"]["embeddedJsonContent"][ - "message" - ][usecase_name] + validate(instance=converted_message, schema=rc_schema) - validate(instance=converted_message, schema=rc_schema) - - else: - # Cas d'erreur attendu - with pytest.raises(expected_exception, match=expected_message): - ResourcesInfoCISUConverter.from_rs_to_cisu(edxl_json) + else: + # Cas d'erreur attendu + with pytest.raises(expected_exception, match=expected_message): + ResourcesInfoCISUConverter.from_rs_to_cisu(edxl_json) def test_rs_to_cisu_should_delete_patient_id(): @@ -90,7 +98,8 @@ def test_rs_to_cisu_should_delete_patient_id(): TestConstants.EDXL_HEALTH_TO_FIRE_ENVELOPE_PATH, "tests/fixtures/RS-RI/RS-RI_V3.0_patient_id_deletion.json", ) - cisu_raw_message = ResourcesInfoCISUConverter.from_rs_to_cisu(rs_raw_message) + with patch(_PATCH_GET_RS_MESSAGES_BY_CASE_ID, return_value=(None, [])): + cisu_raw_message = ResourcesInfoCISUConverter.from_rs_to_cisu(rs_raw_message) cisu_message = ResourcesInfoCISUConverter.copy_cisu_input_use_case_content( cisu_raw_message ) @@ -169,16 +178,7 @@ def test_translate_vehicule_type_to_cisu(rs_vehicule_type, expected): # RC-RI → RS (from_cisu_to_rs) — split logic # --------------------------------------------------------------------------- -_PATCH_TARGET = "converter.cisu.resources_info.resources_info_cisu_converter.get_last_rc_ri_by_case_id" - -_RC_RI_WITH_POSITION_EDXL = TestHelper.create_edxl_json_from_sample( - TestConstants.EDXL_FIRE_TO_HEALTH_ENVELOPE_PATH, - "tests/fixtures/RC-RI/RC-RI_V3.0_with_position.json", -) - -_CASE_ID = _RC_RI_WITH_POSITION_EDXL["content"][0]["jsonContent"][ - "embeddedJsonContent" -]["message"]["resourcesInfoCisu"]["caseId"] +_CASE_ID = get_cisu_content(RC_RI_WITH_POSITION_EDXL)["caseId"] class TestBuildRsSrFromResource: @@ -193,11 +193,9 @@ class TestBuildRsSrFromResource: def test_rs_sr_content_is_correct(self): result = ResourcesInfoCISUConverter._build_rs_sr_from_resource( - _RC_RI_WITH_POSITION_EDXL, self._RESOURCE, _CASE_ID + RC_RI_WITH_POSITION_EDXL, self._RESOURCE, _CASE_ID ) - rs_sr = result["content"][0]["jsonContent"]["embeddedJsonContent"]["message"][ - "resourcesStatus" - ] + rs_sr = get_edxl_message(result)["resourcesStatus"] assert rs_sr["caseId"] == _CASE_ID assert rs_sr["resourceId"] == self._RESOURCE["resourceId"] assert rs_sr["state"] == self._RESOURCE["state"] @@ -205,16 +203,16 @@ def test_rs_sr_content_is_correct(self): def test_rs_sr_does_not_contain_cisu_key(self): """The RS-SR message envelope must not contain the original CISU key.""" result = ResourcesInfoCISUConverter._build_rs_sr_from_resource( - _RC_RI_WITH_POSITION_EDXL, self._RESOURCE, _CASE_ID + RC_RI_WITH_POSITION_EDXL, self._RESOURCE, _CASE_ID ) - message = result["content"][0]["jsonContent"]["embeddedJsonContent"]["message"] + message = get_edxl_message(result) assert "resourcesInfoCisu" not in message def test_from_cisu_to_rs_new_case_id(): """With an unknown caseId, from_cisu_to_rs must return 1 RS-RI + 1 RS-SR per resource.""" - with patch(_PATCH_TARGET, return_value=None): - results = ResourcesInfoCISUConverter.from_cisu_to_rs(_RC_RI_WITH_POSITION_EDXL) + with patch(_PATCH_GET_LAST_RC_RI_BY_CASE_ID, return_value=None): + results = ResourcesInfoCISUConverter.from_cisu_to_rs(RC_RI_WITH_POSITION_EDXL) # fixture has 2 resources → 1 RS-RI + 2 RS-SR = 3 messages assert isinstance(results, list), "result must be a list" @@ -222,15 +220,13 @@ def test_from_cisu_to_rs_new_case_id(): f"expected 3 messages (1 RS-RI + 2 RS-SR), got {len(results)}" ) - first_message = results[0]["content"][0]["jsonContent"]["embeddedJsonContent"][ - "message" - ] + first_message = get_edxl_message(results[0]) assert "resourcesInfo" in first_message, ( "first message must be a RS-RI (resourcesInfo key expected)" ) for i, rs_sr in enumerate(results[1:], start=1): - message = rs_sr["content"][0]["jsonContent"]["embeddedJsonContent"]["message"] + message = get_edxl_message(rs_sr) assert "resourcesStatus" in message, ( f"message {i} must be a RS-SR (resourcesStatus key expected)" ) @@ -238,9 +234,7 @@ def test_from_cisu_to_rs_new_case_id(): dist_ids = [msg["distributionID"] for msg in results] assert len(dist_ids) == len(set(dist_ids)), "all distributionIDs must be unique" - resources_info = results[0]["content"][0]["jsonContent"]["embeddedJsonContent"][ - "message" - ]["resourcesInfo"] + resources_info = get_edxl_message(results[0])["resourcesInfo"] for resource in resources_info["resource"]: assert "position" not in resource, ( f"RS-RI resource {resource.get('resourceId')} must not contain a position field" @@ -271,19 +265,10 @@ def test_from_cisu_to_rs_new_case_id(): } -def _make_rc_ri_with_resources(resources): - """Return a copy of the RC-RI fixture with the given resource list.""" - edxl = copy.deepcopy(_RC_RI_WITH_POSITION_EDXL) - edxl["content"][0]["jsonContent"]["embeddedJsonContent"]["message"][ - "resourcesInfoCisu" - ]["resource"] = resources - return edxl - - class TestHasResourcesBeenUpdated: def test_no_change(self): """Identical resource lists → no flag raised, no modified resources.""" - edxl = _make_rc_ri_with_resources( + edxl = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) result = ResourcesInfoCISUConverter._has_resources_been_updated(edxl, edxl) @@ -297,15 +282,13 @@ def test_no_change(self): def test_status_changed(self): """A status change → flag stays False, changed resource appears in modified_status_resources in its new version.""" - ref = _make_rc_ri_with_resources( + ref = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) updated_vlm1 = copy.deepcopy(_RESOURCE_VLM1) updated_vlm1["state"]["status"] = "DISP" updated_vlm1["state"]["datetime"] = "2024-08-01T18:00:00+02:00" - cmp = _make_rc_ri_with_resources( - [updated_vlm1, copy.deepcopy(_RESOURCE_VSAV3A)] - ) + cmp = make_rc_ri_with_resources([updated_vlm1, copy.deepcopy(_RESOURCE_VSAV3A)]) result = ResourcesInfoCISUConverter._has_resources_been_updated(ref, cmp) @@ -328,10 +311,10 @@ def test_status_changed(self): def test_resource_added(self): """A new resource → flag raised and new resource appears in modified_status_resources.""" - ref = _make_rc_ri_with_resources( + ref = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) - cmp = _make_rc_ri_with_resources( + cmp = make_rc_ri_with_resources( [ copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A), @@ -352,10 +335,10 @@ def test_resource_added(self): def test_resource_removed(self): """A removed resource → flag raised, removed resource absent from modified_status_resources.""" - ref = _make_rc_ri_with_resources( + ref = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) - cmp = _make_rc_ri_with_resources([copy.deepcopy(_RESOURCE_VLM1)]) + cmp = make_rc_ri_with_resources([copy.deepcopy(_RESOURCE_VLM1)]) result = ResourcesInfoCISUConverter._has_resources_been_updated(ref, cmp) @@ -373,32 +356,23 @@ def test_resource_removed(self): # --------------------------------------------------------------------------- -def _persisted(edxl: dict) -> PersistedMessage: - """Wrap an EDXL dict in a PersistedMessage as the repository would return it.""" - return PersistedMessage( - message_type="RC-RI", - payload=edxl, - arrived_at=datetime(2024, 8, 1, 14, 0, 0), - ) - - def test_from_cisu_to_rs_known_case_id_status_changed_only(): """Known caseId + only a status change → exactly one RS-SR, no RS-RI.""" - ref_edxl = _make_rc_ri_with_resources( + ref_edxl = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) updated_vlm1 = copy.deepcopy(_RESOURCE_VLM1) updated_vlm1["state"]["status"] = "DISP" - incoming_edxl = _make_rc_ri_with_resources( + incoming_edxl = make_rc_ri_with_resources( [updated_vlm1, copy.deepcopy(_RESOURCE_VSAV3A)] ) - with patch(_PATCH_TARGET, return_value=_persisted(ref_edxl)): + with patch(_PATCH_GET_LAST_RC_RI_BY_CASE_ID, return_value=persisted(ref_edxl)): results = ResourcesInfoCISUConverter.from_cisu_to_rs(incoming_edxl) assert isinstance(results, list), "from_cisu_to_rs must return a list" assert len(results) == 1, f"expected 1 RS-SR, got {len(results)}" - message = results[0]["content"][0]["jsonContent"]["embeddedJsonContent"]["message"] + message = get_edxl_message(results[0]) assert "resourcesStatus" in message, "expected RS-SR (resourcesStatus key)" assert "resourcesInfo" not in message, ( "a status-only change must not produce a RS-RI" @@ -410,10 +384,10 @@ def test_from_cisu_to_rs_known_case_id_status_changed_only(): def test_from_cisu_to_rs_known_case_id_resource_added(): """Known caseId + new resource → one RS-RI and one RS-SR for the new resource.""" - ref_edxl = _make_rc_ri_with_resources( + ref_edxl = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) - incoming_edxl = _make_rc_ri_with_resources( + incoming_edxl = make_rc_ri_with_resources( [ copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A), @@ -421,22 +395,18 @@ def test_from_cisu_to_rs_known_case_id_resource_added(): ] ) - with patch(_PATCH_TARGET, return_value=_persisted(ref_edxl)): + with patch(_PATCH_GET_LAST_RC_RI_BY_CASE_ID, return_value=persisted(ref_edxl)): results = ResourcesInfoCISUConverter.from_cisu_to_rs(incoming_edxl) assert isinstance(results, list), "from_cisu_to_rs must return a list" assert len(results) == 2, f"expected RS-RI + RS-SR, got {len(results)}" - first_message = results[0]["content"][0]["jsonContent"]["embeddedJsonContent"][ - "message" - ] + first_message = get_edxl_message(results[0]) assert "resourcesInfo" in first_message, ( "first message must be RS-RI when the engaged resource list has changed" ) - second_message = results[1]["content"][0]["jsonContent"]["embeddedJsonContent"][ - "message" - ] + second_message = get_edxl_message(results[1]) assert "resourcesStatus" in second_message, ( "second message must be a RS-SR for the newly added resource" ) @@ -447,19 +417,19 @@ def test_from_cisu_to_rs_known_case_id_resource_added(): def test_from_cisu_to_rs_known_case_id_resource_removed(): """Known caseId + resource removed → one RS-RI, no RS-SR.""" - ref_edxl = _make_rc_ri_with_resources( + ref_edxl = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) - incoming_edxl = _make_rc_ri_with_resources([copy.deepcopy(_RESOURCE_VLM1)]) + incoming_edxl = make_rc_ri_with_resources([copy.deepcopy(_RESOURCE_VLM1)]) - with patch(_PATCH_TARGET, return_value=_persisted(ref_edxl)): + with patch(_PATCH_GET_LAST_RC_RI_BY_CASE_ID, return_value=persisted(ref_edxl)): results = ResourcesInfoCISUConverter.from_cisu_to_rs(incoming_edxl) assert isinstance(results, list), "from_cisu_to_rs must return a list" assert len(results) == 1, ( f"expected exactly 1 RS-RI (no RS-SR for a removed resource), got {len(results)}" ) - message = results[0]["content"][0]["jsonContent"]["embeddedJsonContent"]["message"] + message = get_edxl_message(results[0]) assert "resourcesInfo" in message, ( "when a resource is removed the RS-RI must be produced to reflect the new engaged resource list" ) @@ -467,14 +437,79 @@ def test_from_cisu_to_rs_known_case_id_resource_removed(): def test_from_cisu_to_rs_known_case_id_no_change(): """Known caseId + no resource or status change → empty list.""" - ref_edxl = _make_rc_ri_with_resources( + ref_edxl = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) - incoming_edxl = _make_rc_ri_with_resources( + incoming_edxl = make_rc_ri_with_resources( [copy.deepcopy(_RESOURCE_VLM1), copy.deepcopy(_RESOURCE_VSAV3A)] ) - with patch(_PATCH_TARGET, return_value=_persisted(ref_edxl)): + with patch(_PATCH_GET_LAST_RC_RI_BY_CASE_ID, return_value=persisted(ref_edxl)): results = ResourcesInfoCISUConverter.from_cisu_to_rs(incoming_edxl) assert results == [], f"expected no messages, got {len(results)}" + + +# --------------------------------------------------------------------------- +# from_rs_to_cisu — RS-RI + RS-SR merge logic +# --------------------------------------------------------------------------- + + +def test_from_rs_to_cisu_no_persisted_rs_sr(): + """No persisted RS-SR should return original state.""" + edxl, _, _ = make_rs_ri_edxl() + + with patch(_PATCH_GET_RS_MESSAGES_BY_CASE_ID, return_value=(None, [])): + result = ResourcesInfoCISUConverter.from_rs_to_cisu(edxl) + + rc_ri = get_cisu_content(result) + assert rc_ri["resource"][0]["state"]["status"] == "RET-BASE" + # Two resources present originaly but only one valid vehicle type, so 1 resource expected + assert (len(rc_ri["resource"])) == 1 + + +def test_from_rs_to_cisu_no_state_but_persisted_rs_sr(): + """A persisted RS-SR should return persisted state if no original state.""" + edxl, rs_ri, rs_sr_edxl = make_rs_ri_edxl(remove_state=True) + + with patch( + _PATCH_GET_RS_MESSAGES_BY_CASE_ID, + return_value=persisted_rs_ri_and_rs_sr(rs_ri, [rs_sr_edxl]), + ): + result = ResourcesInfoCISUConverter.from_rs_to_cisu(edxl) + + assert "state" not in rs_ri["resource"][0] + assert get_cisu_content(result)["resource"][0]["state"]["status"] == "RETOUR" + + +def test_from_rs_to_cisu_latest_persisted_state(): + """A persisted RS-SR should return the latest state (persisted state is more recent).""" + later_datetime = "2025-05-18T18:46:00+02:00" + edxl, rs_ri, rs_sr_edxl = make_rs_ri_edxl(rs_sr_datetime=later_datetime) + + with patch( + _PATCH_GET_RS_MESSAGES_BY_CASE_ID, + return_value=persisted_rs_ri_and_rs_sr(rs_ri, [rs_sr_edxl]), + ): + result = ResourcesInfoCISUConverter.from_rs_to_cisu(edxl) + + rc_ri = get_cisu_content(result) + assert rc_ri["resource"][0]["state"]["status"] == "RETOUR" + assert rc_ri["resource"][0]["state"]["datetime"] == later_datetime + + +def test_from_rs_to_cisu_latest_original_state(): + """A persisted RS-SR should return the latest state (original state is more recent).""" + earlier_datetime = "2023-05-18T18:46:00+02:00" + edxl, rs_ri, rs_sr_edxl = make_rs_ri_edxl(rs_sr_datetime=earlier_datetime) + current_datetime = rs_ri["resource"][0]["state"][0]["datetime"] + + with patch( + _PATCH_GET_RS_MESSAGES_BY_CASE_ID, + return_value=persisted_rs_ri_and_rs_sr(rs_ri, [rs_sr_edxl]), + ): + result = ResourcesInfoCISUConverter.from_rs_to_cisu(edxl) + + rc_ri = get_cisu_content(result) + assert rc_ri["resource"][0]["state"]["status"] == "RET-BASE" + assert rc_ri["resource"][0]["state"]["datetime"] == current_datetime diff --git a/converter/tests/cisu/test_resources_status_converter.py b/converter/tests/cisu/test_resources_status_converter.py index 7ded84f37..f665c6c09 100644 --- a/converter/tests/cisu/test_resources_status_converter.py +++ b/converter/tests/cisu/test_resources_status_converter.py @@ -1,83 +1,50 @@ -import copy -import json -from pathlib import Path from unittest.mock import patch from converter.cisu.resources_status.resources_status_converter import ( ResourcesStatusConverter, ) +from tests.cisu.helpers import ( + get_cisu_content, + get_cisu_resources, + make_rs_ri_edxl, + make_rs_ri_from_sample, + make_rs_sr_from_sample, + persisted_rs_ri_and_rs_sr, +) -RS_RI_PAYLOAD = json.load(Path("tests/fixtures/RS-RI/sample_rs_ri_payload.json").open()) -RS_SR_PAYLOAD = json.load(Path("tests/fixtures/RS-SR/sample_rs_sr_payload.json").open()) _CASE_ID = "CASE123" _RESOURCE_ID_1 = "fr.fire.sis076.cgo-076.resource.VLM1" _RESOURCE_ID_2 = "fr.fire.sis076.cgo-076.resource.VLM2" - -def make_rs_ri(case_id: str): - edxl = copy.deepcopy(RS_RI_PAYLOAD) - edxl["content"][0]["jsonContent"]["embeddedJsonContent"]["message"][ - "resourcesInfo" - ]["caseId"] = case_id - return edxl - - -def make_rs_sr(case_id: str, resource_id: str, status: str): - edxl = copy.deepcopy(RS_SR_PAYLOAD) - rs = edxl["content"][0]["jsonContent"]["embeddedJsonContent"]["message"][ - "resourcesStatus" - ] - - rs["caseId"] = case_id - rs["resourceId"] = resource_id - rs["state"]["status"] = status - - return edxl - - -def persisted(edxl): - return type("Msg", (), {"payload": edxl}) - - -def extract_resources_from_result(result): - return result["content"][0]["jsonContent"]["embeddedJsonContent"]["message"][ - "resourcesInfoCisu" - ]["resource"] +_PATCH_GET_RS_MESSAGES = "converter.cisu.resources_status.resources_status_converter.get_rs_messages_by_case_id" def test_from_rs_to_cisu_real_data(): - rs_ri = make_rs_ri(_CASE_ID) + rs_ri = make_rs_ri_from_sample(_CASE_ID) - rs_sr_old_1 = make_rs_sr( + rs_sr_old_1 = make_rs_sr_from_sample( _CASE_ID, _RESOURCE_ID_1, "DECISION", ) - rs_sr_old_2 = make_rs_sr( + rs_sr_old_2 = make_rs_sr_from_sample( _CASE_ID, _RESOURCE_ID_2, "DECISION", ) - rs_sr_new = make_rs_sr( + rs_sr_new = make_rs_sr_from_sample( _CASE_ID, _RESOURCE_ID_1, "ARRIVEE", ) - with ( - patch( - "converter.cisu.resources_status.resources_status_converter.get_last_rs_ri_by_case_id", - return_value=persisted(rs_ri), - ), - patch( - "converter.cisu.resources_status.resources_status_converter.get_last_rs_sr_per_resource_by_case_id", - return_value=[ - persisted(rs_sr_old_1), - persisted(rs_sr_old_2), - persisted(rs_sr_new), - ], + with patch( + _PATCH_GET_RS_MESSAGES, + return_value=persisted_rs_ri_and_rs_sr( + rs_ri, + [rs_sr_old_1, rs_sr_old_2, rs_sr_new], ), ): result = ResourcesStatusConverter.from_rs_to_cisu(rs_sr_new) @@ -85,7 +52,7 @@ def test_from_rs_to_cisu_real_data(): assert result is not None assert result != [] - resources = extract_resources_from_result(result) + resources = get_cisu_resources(result) assert len(resources) == 2 @@ -97,15 +64,15 @@ def test_from_rs_to_cisu_real_data(): def test_from_rs_to_cisu_no_rs_ri(): - rs_sr_new = make_rs_sr( + rs_sr_new = make_rs_sr_from_sample( _CASE_ID, _RESOURCE_ID_1, "ARRIVEE", ) with patch( - "converter.cisu.resources_status.resources_status_converter.get_last_rs_ri_by_case_id", - return_value=None, + _PATCH_GET_RS_MESSAGES, + return_value=(None, []), ): result = ResourcesStatusConverter.from_rs_to_cisu(rs_sr_new) assert result == []