From acaf3a3c24a714c80260bec475a5981397ed1b98 Mon Sep 17 00:00:00 2001 From: jasquat Date: Wed, 17 May 2023 16:35:04 -0400 Subject: [PATCH] support call activities in process model test runner w/ burnettk --- .../process_model_test_runner_service.py | 188 ++++++++++++++---- .../basic_call_activity.bpmn | 40 ++++ .../basic_call_activity/process_model.json | 9 + .../test_basic_call_activity.json | 5 + .../basic_manual_task/basic_manual_task.bpmn | 39 ++++ .../basic_manual_task/process_model.json | 9 + .../test_basic_manual_task.json | 10 + .../test_process_model_test_runner_service.py | 17 +- 8 files changed, 280 insertions(+), 37 deletions(-) create mode 100644 spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn create mode 100644 spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json create mode 100644 spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json create mode 100644 spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn create mode 100644 spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json create mode 100644 spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py index df6e76ef2..4093e2f6c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_test_runner_service.py @@ -39,16 +39,26 @@ class MissingBpmnFileForTestCaseError(Exception): @dataclass class TestCaseResult: passed: bool + bpmn_file: str test_case_name: str error: Optional[str] = None +DEFAULT_NSMAP = { + 'bpmn': 'http://www.omg.org/spec/BPMN/20100524/MODEL', + 'bpmndi': 'http://www.omg.org/spec/BPMN/20100524/DI', + 'dc': 'http://www.omg.org/spec/DD/20100524/DC', +} + + # input: # json_file: # { # [TEST_CASE_NAME]: { # "tasks": { -# [BPMN_TASK_IDENTIIFER]: [DATA] +# [BPMN_TASK_IDENTIIFER]: { +# "data": [DATA] +# } # }, # "expected_output_json": [DATA] # } @@ -62,17 +72,23 @@ class ProcessModelTestRunner: def __init__( self, process_model_directory_path: str, - instantiate_executer_callback: Callable[[str], Any], - execute_task_callback: Callable[[Any, Optional[dict]], Any], - get_next_task_callback: Callable[[Any], Any], + process_model_directory_for_test_discovery: Optional[str] = None, + instantiate_executer_callback: Optional[Callable[[str], Any]] = None, + execute_task_callback: Optional[Callable[[Any, Optional[dict]], Any]] = None, + get_next_task_callback: Optional[Callable[[Any], Any]] = None, ) -> None: self.process_model_directory_path = process_model_directory_path - self.test_mappings = self._discover_process_model_directories() + self.process_model_directory_for_test_discovery = process_model_directory_for_test_discovery or process_model_directory_path self.instantiate_executer_callback = instantiate_executer_callback self.execute_task_callback = execute_task_callback self.get_next_task_callback = get_next_task_callback self.test_case_results: list[TestCaseResult] = [] + self.bpmn_processes_to_file_mappings: dict[str, str] = {} + self.bpmn_files_to_called_element_mappings: dict[str, list[str]] = {} + + self.test_mappings = self._discover_process_model_test_cases() + self._discover_process_model_processes() def all_test_cases_passed(self) -> bool: failed_tests = [t for t in self.test_case_results if t.passed is False] @@ -87,59 +103,161 @@ class ProcessModelTestRunner: try: self.run_test_case(bpmn_file, test_case_name, test_case_contents) except Exception as ex: - self.test_case_results.append( - TestCaseResult( - passed=False, - test_case_name=test_case_name, - error=f"Syntax error: {str(ex)}", - ) - ) + self._add_test_result(False, bpmn_file, test_case_name, f"Syntax error: {str(ex)}") def run_test_case(self, bpmn_file: str, test_case_name: str, test_case_contents: dict) -> None: - bpmn_process_instance = self.instantiate_executer_callback(bpmn_file) - next_task = self.get_next_task_callback(bpmn_process_instance) + bpmn_process_instance = self._instantiate_executer(bpmn_file) + next_task = self._get_next_task(bpmn_process_instance) while next_task is not None: - test_case_json = None + test_case_task_properties = None if "tasks" in test_case_contents: if next_task.task_spec.bpmn_id in test_case_contents["tasks"]: - test_case_json = test_case_contents["tasks"][next_task.task_spec.bpmn_id] + test_case_task_properties = test_case_contents["tasks"][next_task.task_spec.bpmn_id] task_type = next_task.task_spec.__class__.__name__ - if task_type in ["ServiceTask", "UserTask"] and test_case_json is None: + if task_type in ["ServiceTask", "UserTask", "CallActivity"] and test_case_task_properties is None: raise UnrunnableTestCaseError( f"Cannot run test case '{test_case_name}'. It requires task data for" f" {next_task.task_spec.bpmn_id} because it is of type '{task_type}'" ) - self.execute_task_callback(next_task, test_case_json) - next_task = self.get_next_task_callback(bpmn_process_instance) + self._execute_task(next_task, test_case_task_properties) + next_task = self._get_next_task(bpmn_process_instance) test_passed = test_case_contents["expected_output_json"] == bpmn_process_instance.data - self.test_case_results.append( - TestCaseResult( - passed=test_passed, - test_case_name=test_case_name, + error_message = None + if test_passed is False: + error_message = ( + f"Expected output did not match actual output:" + f"\nexpected: {test_case_contents['expected_output_json']}" + f"\nactual: {bpmn_process_instance.data}" ) - ) + self._add_test_result(test_passed, bpmn_file, test_case_name, error_message) - def _discover_process_model_directories( + def _discover_process_model_test_cases( self, ) -> dict[str, str]: test_mappings = {} - json_test_file_glob = os.path.join(self.process_model_directory_path, "**", "test_*.json") + json_test_file_glob = os.path.join(self.process_model_directory_for_test_discovery, "**", "test_*.json") for file in glob.glob(json_test_file_glob, recursive=True): - file_dir = os.path.dirname(file) - json_file_name = os.path.basename(file) + file_norm = os.path.normpath(file) + file_dir = os.path.dirname(file_norm) + json_file_name = os.path.basename(file_norm) bpmn_file_name = re.sub(r"^test_(.*)\.json", r"\1.bpmn", json_file_name) bpmn_file_path = os.path.join(file_dir, bpmn_file_name) if os.path.isfile(bpmn_file_path): - test_mappings[file] = bpmn_file_path + test_mappings[file_norm] = bpmn_file_path else: raise MissingBpmnFileForTestCaseError( - f"Cannot find a matching bpmn file for test case json file: '{file}'" + f"Cannot find a matching bpmn file for test case json file: '{file_norm}'" ) return test_mappings + def _discover_process_model_processes( + self, + ) -> None: + process_model_bpmn_file_glob = os.path.join(self.process_model_directory_path, "**", "*.bpmn") + + for file in glob.glob(process_model_bpmn_file_glob, recursive=True): + file_norm = os.path.normpath(file) + if file_norm not in self.bpmn_files_to_called_element_mappings: + self.bpmn_files_to_called_element_mappings[file_norm] = [] + with open(file_norm, 'rb') as f: + file_contents = f.read() + etree_xml_parser = etree.XMLParser(resolve_entities=False) + root = etree.fromstring(file_contents, parser=etree_xml_parser) + call_activities = root.findall('.//bpmn:callActivity', namespaces=DEFAULT_NSMAP) + for call_activity in call_activities: + called_element = call_activity.attrib['calledElement'] + self.bpmn_files_to_called_element_mappings[file_norm].append(called_element) + bpmn_process_element = root.find('.//bpmn:process[@isExecutable="true"]', namespaces=DEFAULT_NSMAP) + bpmn_process_identifier = bpmn_process_element.attrib['id'] + self.bpmn_processes_to_file_mappings[bpmn_process_identifier] = file_norm + + def _execute_task(self, spiff_task: SpiffTask, test_case_task_properties: Optional[dict]) -> None: + if self.execute_task_callback: + self.execute_task_callback(spiff_task, test_case_task_properties) + self._default_execute_task(spiff_task, test_case_task_properties) + + def _get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]: + if self.get_next_task_callback: + return self.get_next_task_callback(bpmn_process_instance) + return self._default_get_next_task(bpmn_process_instance) + + def _instantiate_executer(self, bpmn_file: str) -> BpmnWorkflow: + if self.instantiate_executer_callback: + return self.instantiate_executer_callback(bpmn_file) + return self._default_instantiate_executer(bpmn_file) + + def _get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]: + tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual]) + if len(tasks) > 0: + tasks = [tasks[0]] + + return tasks + + def _default_get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]: + engine_steps = self._get_ready_engine_steps(bpmn_process_instance) + if len(engine_steps) > 0: + return engine_steps[0] + return None + + def _default_execute_task(self, spiff_task: SpiffTask, test_case_task_properties: Optional[dict]) -> None: + if spiff_task.task_spec.manual: + if test_case_task_properties and 'data' in test_case_task_properties: + spiff_task.update_data(test_case_task_properties['data']) + spiff_task.complete() + else: + spiff_task.run() + + def _find_related_bpmn_files(self, bpmn_file: str) -> list[str]: + related_bpmn_files = [] + if bpmn_file in self.bpmn_files_to_called_element_mappings: + for bpmn_process_identifier in self.bpmn_files_to_called_element_mappings[bpmn_file]: + if bpmn_process_identifier in self.bpmn_processes_to_file_mappings: + new_file = self.bpmn_processes_to_file_mappings[bpmn_process_identifier] + related_bpmn_files.append(new_file) + related_bpmn_files.extend(self._find_related_bpmn_files(new_file)) + return related_bpmn_files + + def _get_etree_from_bpmn_file(self, bpmn_file: str) -> etree.Element: + data = None + with open(bpmn_file, "rb") as f_handle: + data = f_handle.read() + etree_xml_parser = etree.XMLParser(resolve_entities=False) + return etree.fromstring(data, parser=etree_xml_parser) + + def _default_instantiate_executer(self, bpmn_file: str) -> BpmnWorkflow: + parser = MyCustomParser() + bpmn_file_etree = self._get_etree_from_bpmn_file(bpmn_file) + parser.add_bpmn_xml(bpmn_file_etree, filename=os.path.basename(bpmn_file)) + all_related = self._find_related_bpmn_files(bpmn_file) + for related_file in all_related: + related_file_etree = self._get_etree_from_bpmn_file(related_file) + parser.add_bpmn_xml(related_file_etree, filename=os.path.basename(related_file)) + sub_parsers = list(parser.process_parsers.values()) + executable_process = None + for sub_parser in sub_parsers: + if sub_parser.process_executable: + executable_process = sub_parser.bpmn_id + if executable_process is None: + raise BpmnFileMissingExecutableProcessError( + f"Executable process cannot be found in {bpmn_file}. Test cannot run." + ) + bpmn_process_spec = parser.get_spec(executable_process) + bpmn_process_instance = BpmnWorkflow(bpmn_process_spec) + return bpmn_process_instance + + def _add_test_result(self, passed: bool, bpmn_file: str, test_case_name: str, error: Optional[str] = None) -> None: + bpmn_file_relative = os.path.relpath(bpmn_file, start=self.process_model_directory_path) + test_result = TestCaseResult( + passed=passed, + bpmn_file=bpmn_file_relative, + test_case_name=test_case_name, + error=error, + ) + self.test_case_results.append(test_result) + class BpmnFileMissingExecutableProcessError(Exception): pass @@ -149,9 +267,9 @@ class ProcessModelTestRunnerService: def __init__(self, process_model_directory_path: str) -> None: self.process_model_test_runner = ProcessModelTestRunner( process_model_directory_path, - instantiate_executer_callback=self._instantiate_executer_callback, - execute_task_callback=self._execute_task_callback, - get_next_task_callback=self._get_next_task_callback, + # instantiate_executer_callback=self._instantiate_executer_callback, + # execute_task_callback=self._execute_task_callback, + # get_next_task_callback=self._get_next_task_callback, ) def run(self) -> None: @@ -168,7 +286,6 @@ class ProcessModelTestRunnerService: def _get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]: tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual]) - if len(tasks) > 0: tasks = [tasks[0]] @@ -179,7 +296,8 @@ class ProcessModelTestRunnerService: data = None with open(bpmn_file, "rb") as f_handle: data = f_handle.read() - bpmn: etree.Element = SpecFileService.get_etree_from_xml_bytes(data) + etree_xml_parser = etree.XMLParser(resolve_entities=False) + bpmn = etree.fromstring(data, parser=etree_xml_parser) parser.add_bpmn_xml(bpmn, filename=os.path.basename(bpmn_file)) sub_parsers = list(parser.process_parsers.values()) executable_process = None diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn new file mode 100644 index 000000000..ab7961463 --- /dev/null +++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/basic_call_activity.bpmn @@ -0,0 +1,40 @@ + + + + + Flow_0ext5lt + + + + Flow_1hzwssi + + + + + Flow_0ext5lt + Flow_1hzwssi + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json new file mode 100644 index 000000000..8f4e4a044 --- /dev/null +++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/process_model.json @@ -0,0 +1,9 @@ +{ + "description": "", + "display_name": "Basic Call Activity", + "exception_notification_addresses": [], + "fault_or_suspend_on_exception": "fault", + "files": [], + "primary_file_name": "basic_call_activity.bpmn", + "primary_process_id": "BasicCallActivityProcess" +} diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json new file mode 100644 index 000000000..0a7f76929 --- /dev/null +++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_call_activity/test_basic_call_activity.json @@ -0,0 +1,5 @@ +{ + "test_case_one": { + "expected_output_json": {} + } +} diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn new file mode 100644 index 000000000..5d0bf395a --- /dev/null +++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/basic_manual_task.bpmn @@ -0,0 +1,39 @@ + + + + + Flow_0gz6i84 + + + + Flow_0ikklg6 + + + + Flow_0gz6i84 + Flow_0ikklg6 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json new file mode 100644 index 000000000..743dd104d --- /dev/null +++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/process_model.json @@ -0,0 +1,9 @@ +{ + "description": "Baisc Manual Task", + "display_name": "Baisc Manual Task", + "exception_notification_addresses": [], + "fault_or_suspend_on_exception": "fault", + "files": [], + "primary_file_name": "baisc_manual_task.bpmn", + "primary_process_id": "BasicManualTaskProcess" +} diff --git a/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json new file mode 100644 index 000000000..d82f5c7cd --- /dev/null +++ b/spiffworkflow-backend/tests/data/bpmn_unit_test_process_models/basic_manual_task/test_basic_manual_task.json @@ -0,0 +1,10 @@ +{ + "test_case_one": { + "tasks": { + "manual_task_one": { + "data": {} + } + }, + "expected_output_json": {} + } +} diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py index a993c9458..89ed9ec77 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model_test_runner_service.py @@ -9,7 +9,7 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.services.file_system_service import FileSystemService -from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunnerService +from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunner, ProcessModelTestRunnerService class TestProcessModelTestRunnerService(BaseTest): @@ -23,7 +23,7 @@ class TestProcessModelTestRunnerService(BaseTest): os.path.join(FileSystemService.root_path(), "basic_script_task") ) test_runner_service.run() - assert test_runner_service.process_model_test_runner.all_test_cases_passed() + assert test_runner_service.process_model_test_runner.all_test_cases_passed(), test_runner_service.process_model_test_runner.test_case_results def test_can_test_multiple_process_models( self, @@ -35,6 +35,19 @@ class TestProcessModelTestRunnerService(BaseTest): test_runner_service.run() assert test_runner_service.process_model_test_runner.all_test_cases_passed() is False + def test_can_test_process_model_call_activity( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + with_mocked_root_path: Any, + ) -> None: + test_runner_service = ProcessModelTestRunner( + process_model_directory_path=FileSystemService.root_path(), + process_model_directory_for_test_discovery=os.path.join(FileSystemService.root_path(), "basic_call_activity") + ) + test_runner_service.run() + assert test_runner_service.all_test_cases_passed() is True, test_runner_service.test_case_results + @pytest.fixture() def with_mocked_root_path(self, mocker: MockerFixture) -> None: path = os.path.join(