From 92f656cb4c523cdbfed98d49a726bb90e6e26017 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 5 Jul 2021 20:49:53 -0700 Subject: [PATCH] WorkflowMessage - Add base class, interface, test, field-spec A `WorkflowMessage` is a data-model which describes the parameters required to send an automated worklow message. Formally, a `WorkflowMessage` is an interface and does not need to be modeled as a class. (Thus, `getFields()`, `import()`, `export()` work with arrays.) However, the standard implementations are based on class models (based on examining the properties/annotations). --- .../Exception/WorkflowMessageException.php | 18 ++ Civi/WorkflowMessage/FieldSpec.php | 98 ++++++ .../GenericWorkflowMessage.php | 53 +++ .../Traits/ReflectiveWorkflowTrait.php | 306 ++++++++++++++++++ Civi/WorkflowMessage/WorkflowMessage.php | 173 ++++++++++ .../WorkflowMessageInterface.php | 59 ++++ .../ExampleWorkflowMessageTest.php | 262 +++++++++++++++ .../Civi/WorkflowMessage/FieldSpecTest.php | 61 ++++ 8 files changed, 1030 insertions(+) create mode 100644 Civi/WorkflowMessage/Exception/WorkflowMessageException.php create mode 100644 Civi/WorkflowMessage/FieldSpec.php create mode 100644 Civi/WorkflowMessage/GenericWorkflowMessage.php create mode 100644 Civi/WorkflowMessage/Traits/ReflectiveWorkflowTrait.php create mode 100644 Civi/WorkflowMessage/WorkflowMessage.php create mode 100644 Civi/WorkflowMessage/WorkflowMessageInterface.php create mode 100644 tests/phpunit/Civi/WorkflowMessage/ExampleWorkflowMessageTest.php create mode 100644 tests/phpunit/Civi/WorkflowMessage/FieldSpecTest.php diff --git a/Civi/WorkflowMessage/Exception/WorkflowMessageException.php b/Civi/WorkflowMessage/Exception/WorkflowMessageException.php new file mode 100644 index 0000000000..f0e3f71c54 --- /dev/null +++ b/Civi/WorkflowMessage/Exception/WorkflowMessageException.php @@ -0,0 +1,18 @@ + 'smarty_name'] + */ + public $scope; + + /** + * @return bool + */ + public function isRequired(): ?bool { + return $this->required; + } + + /** + * @param bool|null $required + * @return $this + */ + public function setRequired(?bool $required) { + $this->required = $required; + return $this; + } + + /** + * @return array|NULL + */ + public function getScope(): ?array { + return $this->scope; + } + + /** + * Enable export/import in alternative scopes. + * + * @param string|array|NULL $scope + * Ex: 'tplParams' + * Ex: 'tplParams as foo_bar' + * Ex: 'tplParams as contact_id, TokenProcessor as contactId' + * Ex: ['tplParams' => 'foo_bar'] + * @return $this + */ + public function setScope($scope) { + if (is_array($scope)) { + $this->scope = $scope; + } + else { + $parts = explode(',', $scope); + $this->scope = []; + foreach ($parts as $part) { + if (preg_match('/^\s*(\S+) as (\S+)\s*$/', $part, $m)) { + $this->scope[trim($m[1])] = trim($m[2]); + } + else { + $this->scope[trim($part)] = $this->getName(); + } + } + } + return $this; + } + +} diff --git a/Civi/WorkflowMessage/GenericWorkflowMessage.php b/Civi/WorkflowMessage/GenericWorkflowMessage.php new file mode 100644 index 0000000000..5c9aaa6f0f --- /dev/null +++ b/Civi/WorkflowMessage/GenericWorkflowMessage.php @@ -0,0 +1,53 @@ + [...tplValues...], 'tokenContext' => [...tokenData...]] + * Ex: ['modelProps' => [...classProperties...]] + */ + public function __construct(array $imports = []) { + WorkflowMessage::importAll($this, $imports); + } + + /** + * The contact receiving this message. + * + * @var int + * @scope tokenContext + */ + protected $contactId; + +} diff --git a/Civi/WorkflowMessage/Traits/ReflectiveWorkflowTrait.php b/Civi/WorkflowMessage/Traits/ReflectiveWorkflowTrait.php new file mode 100644 index 0000000000..a19958eb8f --- /dev/null +++ b/Civi/WorkflowMessage/Traits/ReflectiveWorkflowTrait.php @@ -0,0 +1,306 @@ + ['assigned_value' => 'A', 'other_value' => 'B']] + */ + protected $_extras = []; + + /** + * @inheritDoc + * @see \Civi\WorkflowMessage\WorkflowMessageInterface::getFields() + */ + public function getFields(): array { + // Thread-local cache of class metadata. Class metadata is immutable at runtime, so this is strictly write-once. It should ideally be reused across varied test-functions. + static $caches = []; + $cache =& $caches[static::CLASS]; + if ($cache === NULL) { + $cache = []; + foreach (ReflectionUtils::findStandardProperties(static::CLASS) as $property) { + /** @var \ReflectionProperty $property */ + $parsed = ReflectionUtils::getCodeDocs($property, 'Property'); + $field = new \Civi\WorkflowMessage\FieldSpec(); + $field->setName($property->getName())->loadArray($parsed); + $cache[$field->getName()] = $field; + } + } + return $cache; + } + + protected function getFieldsByFormat($format): ?array { + switch ($format) { + case 'modelProps': + return $this->getFields(); + + case 'envelope': + case 'tplParams': + case 'tokenContext': + $matches = []; + foreach ($this->getFields() as $field) { + /** @var \Civi\WorkflowMessage\FieldSpec $field */ + if (isset($field->getScope()[$format])) { + $key = $field->getScope()[$format]; + $matches[$key] = $field; + } + } + return $matches; + + default: + return NULL; + } + } + + /** + * @inheritDoc + * @see \Civi\WorkflowMessage\WorkflowMessageInterface::export() + */ + public function export(string $format = NULL): ?array { + switch ($format) { + case 'modelProps': + case 'envelope': + case 'tokenContext': + case 'tplParams': + $values = $this->_extras[$format] ?? []; + $fieldsByFormat = $this->getFieldsByFormat($format); + foreach ($fieldsByFormat as $key => $field) { + /** @var \Civi\WorkflowMessage\FieldSpec $field */ + $getter = 'get' . ucfirst($field->getName()); + \CRM_Utils_Array::pathSet($values, explode('.', $key), $this->$getter()); + } + + $methods = ReflectionUtils::findMethodHelpers(static::CLASS, 'exportExtra' . ucfirst($format)); + foreach ($methods as $method) { + $this->{$method->getName()}(...[&$values]); + } + return $values; + + default: + return NULL; + } + } + + /** + * @inheritDoc + * @see \Civi\WorkflowMessage\WorkflowMessageInterface::import() + */ + public function import(string $format, array $values) { + $MISSING = new \stdClass(); + + switch ($format) { + case 'modelProps': + case 'envelope': + case 'tokenContext': + case 'tplParams': + $fields = $this->getFieldsByFormat($format); + foreach ($fields as $key => $field) { + /** @var \Civi\WorkflowMessage\FieldSpec $field */ + $path = explode('.', $key); + $value = \CRM_Utils_Array::pathGet($values, $path, $MISSING); + if ($value !== $MISSING) { + $setter = 'set' . ucfirst($field->getName()); + $this->$setter($value); + \CRM_Utils_Array::pathUnset($values, $path, TRUE); + } + } + + $methods = ReflectionUtils::findMethodHelpers(static::CLASS, 'importExtra' . ucfirst($format)); + foreach ($methods as $method) { + $this->{$method->getName()}($values); + } + + if ($format !== 'modelProps' && !empty($values)) { + $this->_extras[$format] = array_merge($this->_extras[$format] ?? [], $values); + $values = []; + } + break; + + } + + return $this; + } + + /** + * Determine if the data for this workflow message is complete/well-formed. + * + * @return array + * A list of errors and warnings. Each record defines + * - severity: string, 'error' or 'warning' + * - fields: string[], list of fields implicated in the error + * - name: string, symbolic name of the error/warning + * - message: string, printable message describing the problem + * @see \Civi\WorkflowMessage\WorkflowMessageInterface::validate() + */ + public function validate(): array { + $props = $this->export('modelProps'); + $fields = $this->getFields(); + + $errors = []; + foreach ($fields as $fieldName => $fieldSpec) { + /** @var \Civi\WorkflowMessage\FieldSpec $fieldSpec */ + $fieldValue = $props[$fieldName] ?? NULL; + if (!$fieldSpec->isRequired() && $fieldValue === NULL) { + continue; + } + if (!\CRM_Utils_Type::validatePhpType($fieldValue, $fieldSpec->getType(), FALSE)) { + $errors[] = [ + 'severity' => 'error', + 'fields' => [$fieldName], + 'name' => 'wrong_type', + 'message' => ts('Field should have type %1.', [1 => implode('|', $fieldSpec->getType())]), + ]; + } + if ($fieldSpec->isRequired() && ($fieldValue === NULL || $fieldValue === '')) { + $errors[] = [ + 'severity' => 'error', + 'fields' => [$fieldName], + 'name' => 'required', + 'message' => ts('Missing required field %1.', [1 => $fieldName]), + ]; + } + } + + $methods = ReflectionUtils::findMethodHelpers(static::CLASS, 'validateExtra'); + foreach ($methods as $method) { + $this->{$method->getName()}($errors); + } + + return $errors; + } + + // All of the methods below are empty placeholders. They may be overridden to customize behavior. + + /** + * Get a list of key-value pairs to include the array-coded version of the class. + * + * @param array $export + * Modifiable list of export-values. + */ + protected function exportExtraModelProps(array &$export): void { + } + + /** + * Get a list of key-value pairs to add to the token-context. + * + * @param array $export + * Modifiable list of export-values. + */ + protected function exportExtraTokenContext(array &$export): void { + $export['controller'] = static::CLASS; + } + + /** + * Get a list of key-value pairs to include the Smarty template context. + * + * Values returned here will override any defaults. + * + * @param array $export + * Modifiable list of export-values. + */ + protected function exportExtraTplParams(array &$export): void { + } + + /** + * Get a list of key-value pairs to include the Smarty template context. + * + * @param array $export + * Modifiable list of export-values. + */ + protected function exportExtraEnvelope(array &$export): void { + if ($wfName = \CRM_Utils_Constant::value(static::CLASS . '::WORKFLOW')) { + $export['valueName'] = $wfName; + } + if ($wfGroup = \CRM_Utils_Constant::value(static::CLASS . '::GROUP')) { + $export['groupName'] = $wfGroup; + } + } + + /** + * Given an import-array (in the class-format), pull out any interesting values. + * + * @param array $values + * List of import-values. Optionally, unset values that you have handled or blocked. + */ + protected function importExtraModelProps(array &$values): void { + } + + /** + * Given an import-array (in the token-context-format), pull out any interesting values. + * + * @param array $values + * List of import-values. Optionally, unset values that you have handled or blocked. + */ + protected function importExtraTokenContext(array &$values): void { + } + + /** + * Given an import-array (in the tpl-format), pull out any interesting values. + * + * @param array $values + * List of import-values. Optionally, unset values that you have handled or blocked. + */ + protected function importExtraTplParams(array &$values): void { + } + + /** + * Given an import-array (in the envelope-format), pull out any interesting values. + * + * @param array $values + * List of import-values. Optionally, unset values that you have handled or blocked. + */ + protected function importExtraEnvelope(array &$values): void { + if ($wfName = \CRM_Utils_Constant::value(static::CLASS . '::WORKFLOW')) { + if (isset($values['valueName']) && $wfName === $values['valueName']) { + unset($values['valueName']); + } + } + if ($wfGroup = \CRM_Utils_Constant::value(static::CLASS . '::GROUP')) { + if (isset($values['groupName']) && $wfGroup === $values['groupName']) { + unset($values['groupName']); + } + } + } + +} diff --git a/Civi/WorkflowMessage/WorkflowMessage.php b/Civi/WorkflowMessage/WorkflowMessage.php new file mode 100644 index 0000000000..3647286ef5 --- /dev/null +++ b/Civi/WorkflowMessage/WorkflowMessage.php @@ -0,0 +1,173 @@ + [...tplValues...]]); + * Ex: $msgWf = new \CRM_Foo_WorkflowMessage_MyAlert(['modelProps' => [...classProperties...]]); + * Ex: $msgWf = WorkflowMessage::create('my_alert_name', ['tplParams' => [...tplValues...]]); + * Ex: $msgWf = WorkflowMessage::create('my_alert_name', ['modelProps' => [...classProperties...]]); + * + * Instantiating by class-name will provide better hinting and inspection. + * However, some workflows may not have specific classes at the time of writing. + * Instantiating by workflow-name will work regardless of whether there is a specific class. + */ +class WorkflowMessage { + + /** + * Create a new instance of the workflow-message context. + * + * @param string $wfName + * Name of the workflow. + * Ex: 'case_activity' + * @param array $imports + * List of data to use when populating the message. + * + * The parameters may be given in a mix of formats. This mix reflects two modes of operation: + * + * - (Informal/Generic) Traditionally, workflow-messages did not have formal parameters. Instead, + * they relied on a mix of un(der)documented/unverifiable inputs -- supplied as a mix of Smarty + * assignments, token-data, and sendTemplate() params. + * - (Formal) More recently, workflow-messages could be defined with a PHP class that lists the + * inputs explicitly. + * + * You may supply inputs using these keys: + * + * - `tplParams` (array): Smarty data. These values go to `$smarty->assign()`. + * - `tokenContext` (array): Token-processing data. These values go to `$tokenProcessor->context`. + * - `envelope` (array): Email delivery data. These values go to `sendTemplate(...)` + * - `modelProps` (array): Formal parameters defined by a class. + * + * Informal workflow-messages ONLY support 'tplParams', 'tokenContext', and/or 'envelope'. + * Formal workflow-messages accept any format. + * + * @return \Civi\WorkflowMessage\WorkflowMessageInterface + * If there is a workflow-message class, then it will return an instance of that class. + * Otherwise, it will return an instance of `GenericWorkflowMessage`. + * @throws \Civi\WorkflowMessage\Exception\WorkflowMessageException + */ + public static function create(string $wfName, array $imports = []) { + $classMap = static::getWorkflowNameClassMap(); + $class = $classMap[$wfName] ?? 'Civi\WorkflowMessage\GenericWorkflowMessage'; + $imports['envelope']['valueName'] = $wfName; + $model = new $class(); + static::importAll($model, $imports); + return $model; + } + + /** + * Import a batch of params, updating the $model. + * + * @param \Civi\WorkflowMessage\WorkflowMessageInterface $model + * @param array $params + * List of parameters, per MessageTemplate::sendTemplate(). + * Ex: Initialize using adhoc data: + * ['tplParams' => [...], 'tokenContext' => [...]] + * Ex: Initialize using properties of the class-model + * ['modelProps' => [...]] + * @return \Civi\WorkflowMessage\WorkflowMessageInterface + * The updated model. + * @throws \Civi\WorkflowMessage\Exception\WorkflowMessageException + */ + public static function importAll(WorkflowMessageInterface $model, array $params) { + // The $params format is defined to match the traditional format of CRM_Core_BAO_MessageTemplate::sendTemplate(). + // At the top level, it is an "envelope", but it also has keys for other sections. + if (isset($params['model'])) { + if ($params['model'] !== $model) { + throw new WorkflowMessageException(sprintf("%s: Cannot apply mismatched model", get_class($model))); + } + unset($params['model']); + } + + \CRM_Utils_Array::pathMove($params, ['contactId'], ['tokenContext', 'contactId']); + + // Core#644 - handle Email ID passed as "From". + if (isset($params['from'])) { + $params['from'] = \CRM_Utils_Mail::formatFromAddress($params['from']); + } + + if (isset($params['tplParams'])) { + $model->import('tplParams', $params['tplParams']); + unset($params['tplParams']); + } + if (isset($params['tokenContext'])) { + $model->import('tokenContext', $params['tokenContext']); + unset($params['tokenContext']); + } + if (isset($params['modelProps'])) { + $model->import('modelProps', $params['modelProps']); + unset($params['modelProps']); + } + if (isset($params['envelope'])) { + $model->import('envelope', $params['envelope']); + unset($params['envelope']); + } + $model->import('envelope', $params); + return $model; + } + + /** + * @param \Civi\WorkflowMessage\WorkflowMessageInterface $model + * @return array + * List of parameters, per MessageTemplate::sendTemplate(). + * Ex: ['tplParams' => [...], 'tokenContext' => [...]] + */ + public static function exportAll(WorkflowMessageInterface $model): array { + // The format is defined to match the traditional format of CRM_Core_BAO_MessageTemplate::sendTemplate(). + // At the top level, it is an "envelope", but it also has keys for other sections. + $values = $model->export('envelope'); + $values['tplParams'] = $model->export('tplParams'); + $values['tokenContext'] = $model->export('tokenContext'); + if (isset($values['tokenContext']['contactId'])) { + $values['contactId'] = $values['tokenContext']['contactId']; + } + return $values; + } + + /** + * @return array + * Array(string $workflowName => string $className). + * Ex: ["case_activity" => "CRM_Case_WorkflowMessage_CaseActivity"] + * @internal + */ + public static function getWorkflowNameClassMap() { + $cache = \Civi::cache('long'); + $cacheKey = 'WorkflowMessage-' . __FUNCTION__; + $map = $cache->get($cacheKey); + if ($map === NULL) { + $map = []; + $map['generic'] = GenericWorkflowMessage::class; + $baseDirs = explode(PATH_SEPARATOR, get_include_path()); + foreach ($baseDirs as $baseDir) { + $baseDir = \CRM_Utils_File::addTrailingSlash($baseDir); + $glob = (array) glob($baseDir . 'CRM/*/WorkflowMessage/*.php'); + $glob = preg_grep('/\.ex\.php$/', $glob, PREG_GREP_INVERT); + foreach ($glob as $file) { + $class = strtr(preg_replace('/\.php$/', '', \CRM_Utils_File::relativize($file, $baseDir)), ['/' => '_', '\\' => '_']); + if (class_exists($class) && (new \ReflectionClass($class))->implementsInterface(WorkflowMessageInterface::class)) { + $map[$class::WORKFLOW] = $class; + } + } + } + $cache->set($cacheKey, $map); + } + return $map; + } + +} diff --git a/Civi/WorkflowMessage/WorkflowMessageInterface.php b/Civi/WorkflowMessage/WorkflowMessageInterface.php new file mode 100644 index 0000000000..25641ed015 --- /dev/null +++ b/Civi/WorkflowMessage/WorkflowMessageInterface.php @@ -0,0 +1,59 @@ +import('tplParams', ['sm_art_stuff' => 123]); + * + * @param string $format + * Ex: 'tplParams', 'tokenContext', 'modelProps', 'envelope' + * @param array $values + * + * @return $this + * @see \Civi\WorkflowMessage\Traits\ReflectiveWorkflowTrait::import() + */ + public function import(string $format, array $values); + + /** + * Determine if the data for this workflow message is complete/well-formed. + * + * @return array + * A list of errors and warnings. Each record defines + * - severity: string, 'error' or 'warning' + * - fields: string[], list of fields implicated in the error + * - name: string, symbolic name of the error/warning + * - message: string, printable message describing the problem + */ + public function validate(): array; + +} diff --git a/tests/phpunit/Civi/WorkflowMessage/ExampleWorkflowMessageTest.php b/tests/phpunit/Civi/WorkflowMessage/ExampleWorkflowMessageTest.php new file mode 100644 index 0000000000..0d3c96e62c --- /dev/null +++ b/tests/phpunit/Civi/WorkflowMessage/ExampleWorkflowMessageTest.php @@ -0,0 +1,262 @@ +useTransaction(); + parent::setUp(); + } + + /** + * @return \Civi\WorkflowMessage\WorkflowMessageInterface + */ + protected static function createExample() { + return new class() extends GenericWorkflowMessage { + + const WORKFLOW = 'my_example_wf'; + + const GROUP = 'my_example_grp'; + + /** + * @var string + * @scope tplParams as my_public_string + */ + public $myPublicString; + + /** + * @var int + * @scope tplParams as my_int + */ + protected $myProtectedInt; + + /** + * @var string[] + */ + protected $implicitStringArray; + + /** + * @var string[] + * @dataType Text + * @serialize COMMA + */ + protected $explicitStringArray; + + /** + * @var int + * @scope tplParams as some.deep.thing + * @required + */ + protected $deepValue; + + protected function exportExtraTplParams(array &$export): void { + $export['some_extra_tpl_stuff'] = 100; + } + + }; + } + + public function testValidateFail() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + $ex->import('modelProps', [ + 'myPublicString' => 'ok', + 'implicitStringArray' => 'single', + 'myProtectedInt' => 'two', + 'deepValue' => NULL, + ]); + $errors = $ex->validate(); + $expected = []; + $expected[] = ['severity' => 'error', 'fields' => ['contactId', 'contact'], 'name' => 'missingContact', 'message' => 'Message template requires one of these fields (contactId, contact)']; + $expected[] = ['severity' => 'error', 'fields' => ['implicitStringArray'], 'name' => 'wrong_type', 'message' => 'Field should have type string[].']; + $expected[] = ['severity' => 'error', 'fields' => ['myProtectedInt'], 'name' => 'wrong_type', 'message' => 'Field should have type int.']; + $expected[] = ['severity' => 'error', 'fields' => ['deepValue'], 'name' => 'wrong_type', 'message' => 'Field should have type int.']; + $expected[] = ['severity' => 'error', 'fields' => ['deepValue'], 'name' => 'required', 'message' => 'Missing required field deepValue.']; + + $cmp = function($a, $b) { + if ($v = strnatcmp($a['message'], $b['message'])) { + return $v; + } + return strnatcmp(implode(',', $a['fields']), implode(',', $b['fields'])); + }; + usort($errors, $cmp); + usort($expected, $cmp); + $this->assertEquals($expected, $errors); + } + + public function testValidatePass() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + $ex->import('modelProps', [ + 'contactId' => $this->individualCreate(), + 'myPublicString' => 'ok', + 'implicitStringArray' => ['single'], + 'myProtectedInt' => 2, + 'deepValue' => 34, + ]); + $errors = $ex->validate(); + $expected = []; + $this->assertEquals($expected, $errors); + } + + /** + * Assert that "getFields()" provides metadata from properties/docblocks. + */ + public function testGetFields() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + $fields = $ex->getFields(); + /** @var \Civi\WorkflowMessage\FieldSpec $field */ + + $field = $fields['myPublicString']; + $this->assertEquals(['string'], $field->getType()); + $this->assertEquals('String', $field->getDataType()); + $this->assertEquals(NULL, $field->getSerialize()); + + $field = $fields['implicitStringArray']; + $this->assertEquals(['string[]'], $field->getType()); + $this->assertEquals('Blob', $field->getDataType()); + $this->assertEquals(\CRM_Core_DAO::SERIALIZE_JSON, $field->getSerialize()); + + $field = $fields['explicitStringArray']; + $this->assertEquals(['string[]'], $field->getType()); + $this->assertEquals('Text', $field->getDataType()); + $this->assertEquals(\CRM_Core_DAO::SERIALIZE_COMMA, $field->getSerialize()); + + $field = $fields['myProtectedInt']; + $this->assertEquals(['int'], $field->getType()); + $this->assertEquals('Integer', $field->getDataType()); + $this->assertEquals(NULL, $field->getSerialize()); + } + + /** + * Assert that getters/setters work on class fields. + */ + public function testGetSetClassFields() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + + $ex->setmyProtectedInt(5); + $this->assertEquals(5, $ex->getmyProtectedInt()); + $this->assertEquals(5, Invasive::get([$ex, 'myProtectedInt'])); + + $ex->setMyPublicString('hello'); + $this->assertEquals('hello', $ex->getMyPublicString()); + $this->assertEquals('hello', $ex->myPublicString); + } + + /** + * Assert that import()/export() work on standard fields. + */ + public function testImportExportStandardField() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + + $ex->import('tplParams', [ + 'my_public_string' => 'hello world', + 'my_int' => 10, + 'some' => ['deep' => ['thing' => 20]], + ]); + + $this->assertEquals('hello world', $ex->getMyPublicString()); + $this->assertEquals(10, $ex->getMyProtectedInt()); + $this->assertEquals(20, $ex->getDeepValue()); + + $ex->myPublicString .= ' and stuff'; + $ex->setDeepValue(22); + + $tpl = $ex->export('tplParams'); + $this->assertEquals('hello world and stuff', $tpl['my_public_string']); + $this->assertEquals(10, $tpl['my_int']); + $this->assertEquals(22, $tpl['some']['deep']['thing']); + $this->assertEquals(100, $tpl['some_extra_tpl_stuff']); + + $envelope = $ex->export('envelope'); + $this->assertEquals('my_example_wf', $envelope['valueName']); + $this->assertEquals('my_example_grp', $envelope['groupName']); + } + + /** + * Assert that unrecognized fields are preserved in the round-trip from import=>export. + */ + public function testImportExportExtraField() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + + $ex->import('tplParams', [ + 'my.st!er_y' => ['is not mentioned anywhere'], + ]); + + $tpl = $ex->export('tplParams'); + $this->assertEquals(['is not mentioned anywhere'], $tpl['my.st!er_y']); + } + + /** + * Assert that + */ + public function testImportExportUnmappedField() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = static::createExample(); + + $ex->import('tplParams', [ + 'implicitStringArray' => ['is not mapped between class and tpl'], + ]); + + $this->assertEquals(NULL, $ex->getimplicitStringArray()); + $ex->setimplicitStringArray(['this is the real class field']); + + $tpl = $ex->export('tplParams'); + $this->assertEquals(['is not mapped between class and tpl'], $tpl['implicitStringArray']); + + $classData = $ex->export('modelProps'); + $this->assertEquals(['this is the real class field'], $classData['implicitStringArray']); + } + + /** + * Create an impromptu instance of `WorkflowMessage` for a new/unknown workflow. + */ + public function testImpromptuImportExport() { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $ex */ + $ex = WorkflowMessage::create('some_impromptu_wf', [ + 'envelope' => ['from' => 'foo@example.com'], + 'tokenContext' => ['contactId' => 123], + 'tplParams' => [ + 'myImpromputInt' => 456, + 'impromptu_smarty_data' => ['is not mentioned anywhere'], + ], + ]); + $this->assertTrue($ex instanceof GenericWorkflowMessage); + + $tpl = $ex->export('tplParams'); + $this->assertEquals(456, $tpl['myImpromputInt']); + $this->assertEquals(['is not mentioned anywhere'], $tpl['impromptu_smarty_data']); + $this->assertTrue(!isset($tpl['valueName'])); + + $envelope = $ex->export('envelope'); + $this->assertEquals('some_impromptu_wf', $envelope['valueName']); + $this->assertEquals('foo@example.com', $envelope['from']); + $this->assertTrue(!isset($envelope['myProtectedInt'])); + + $tokenCtx = $ex->export('tokenContext'); + $this->assertEquals(123, $tokenCtx['contactId']); + $this->assertTrue(!isset($envelope['myProtectedInt'])); + } + +} diff --git a/tests/phpunit/Civi/WorkflowMessage/FieldSpecTest.php b/tests/phpunit/Civi/WorkflowMessage/FieldSpecTest.php new file mode 100644 index 0000000000..daf7037637 --- /dev/null +++ b/tests/phpunit/Civi/WorkflowMessage/FieldSpecTest.php @@ -0,0 +1,61 @@ +useTransaction(); + parent::setUp(); + } + + public function getScopeExamples() { + // When the naming convention is more established, one might improve readability by inlining these constants. + $tpl = 'tplParams'; + $tok = 'tokenContext'; + + $exs = []; + $exs[] = [$tpl, [$tpl => 'foo']]; + $exs[] = ["$tpl as foo.bar", [$tpl => 'foo.bar']]; + $exs[] = ["$tpl, $tok", [$tpl => 'foo', $tok => 'foo']]; + $exs[] = ["$tok, $tpl as foo.bar", [$tok => 'foo', $tpl => 'foo.bar']]; + $exs[] = ["$tok as fooBar, $tpl", [$tok => 'fooBar', $tpl => 'foo']]; + return $exs; + } + + /** + * Test that the setScope()/getScope() normalization works. + * + * @param mixed $input + * The value to pass into `setScope()` + * @param array $expect + * The resulting value to expect from `getScope()`. + * @dataProvider getScopeExamples + */ + public function testSetScope($input, $expect) { + $f = new FieldSpec(); + $f->setName('foo'); + + // Check that the inputs are translated + $f->setScope($input); + $getScope = $f->getScope(); + $this->assertEquals($expect, $getScope); + + // The output of translation should be stable/convergent. + $f->setScope($getScope); + $this->assertEquals($expect, $f->getScope()); + } + +} -- 2.25.1