From 2a004e8753bc282911d9a35b0dfeb9d0598048dd Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sun, 15 Aug 2021 21:33:57 -0400 Subject: [PATCH] Afform - support file uploads --- CRM/Core/BAO/CustomValueTable.php | 3 +- .../API/Subscriber/DynamicFKAuthorization.php | 3 - .../admin/ang/afGuiEditor/inputType/File.html | 3 + .../Civi/Afform/Event/AfformSubmitEvent.php | 19 ++- .../Api4/Action/Afform/AbstractProcessor.php | 5 +- .../core/Civi/Api4/Action/Afform/Submit.php | 74 ++++++-- .../Civi/Api4/Action/Afform/SubmitFile.php | 140 +++++++++++++++ ext/afform/core/Civi/Api4/Afform.php | 9 + ext/afform/core/ang/af/afField.component.js | 21 +++ ext/afform/core/ang/af/afForm.component.js | 60 +++++-- ext/afform/core/ang/af/afRepeat.directive.js | 11 +- ext/afform/core/ang/af/fields/File.html | 3 + ext/afform/core/ang/afCore.ang.php | 2 +- .../phpunit/api/v4/AfformFileUploadTest.php | 161 ++++++++++++++++++ 14 files changed, 470 insertions(+), 44 deletions(-) create mode 100644 ext/afform/admin/ang/afGuiEditor/inputType/File.html create mode 100644 ext/afform/core/Civi/Api4/Action/Afform/SubmitFile.php create mode 100644 ext/afform/core/ang/af/fields/File.html create mode 100644 ext/afform/mock/tests/phpunit/api/v4/AfformFileUploadTest.php diff --git a/CRM/Core/BAO/CustomValueTable.php b/CRM/Core/BAO/CustomValueTable.php index 714c9c7b43a5..f158a0f19d50 100644 --- a/CRM/Core/BAO/CustomValueTable.php +++ b/CRM/Core/BAO/CustomValueTable.php @@ -155,7 +155,8 @@ public static function create($customParams, $parentOperation = NULL) { case 'File': if (!$field['file_id']) { - throw new CRM_Core_Exception('Missing parameter file_id'); + $value = 'null'; + break; } // need to add/update civicrm_entity_file diff --git a/Civi/API/Subscriber/DynamicFKAuthorization.php b/Civi/API/Subscriber/DynamicFKAuthorization.php index 3aaa1a89c62f..a4b92bd3ef92 100644 --- a/Civi/API/Subscriber/DynamicFKAuthorization.php +++ b/Civi/API/Subscriber/DynamicFKAuthorization.php @@ -174,9 +174,6 @@ public function onApiAuthorize(\Civi\API\Event\AuthorizeEvent $event) { } if (isset($apiRequest['params']['entity_table'])) { - if (!\CRM_Core_DAO_AllCoreTables::isCoreTable($apiRequest['params']['entity_table'])) { - throw new \API_Exception("Unrecognized target entity table {$apiRequest['params']['entity_table']}"); - } $this->authorizeDelegate( $apiRequest['action'], $apiRequest['params']['entity_table'], diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/File.html b/ext/afform/admin/ang/afGuiEditor/inputType/File.html new file mode 100644 index 000000000000..e38db3a51cb8 --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/inputType/File.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php b/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php index afe7e1cb1a89..1277cf66db21 100644 --- a/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php +++ b/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php @@ -3,6 +3,7 @@ use Civi\Afform\FormDataModel; use Civi\Api4\Action\Afform\Submit; +use Civi\Api4\Utils\CoreUtil; /** * Handle submission of an "" entity (or set of entities in the case of ``). @@ -96,12 +97,24 @@ public function getSecureApi4() { } /** - * @param $index - * @param $entityId + * @param int $index + * @param int|string $entityId * @return $this */ public function setEntityId($index, $entityId) { - $this->entityIds[$this->entityName][$index]['id'] = $entityId; + $idField = CoreUtil::getIdFieldName($this->entityName); + $this->entityIds[$this->entityName][$index][$idField] = $entityId; + return $this; + } + + /** + * @param int $index + * @param string $joinEntity + * @param array $joinIds + * @return $this + */ + public function setJoinIds($index, $joinEntity, $joinIds) { + $this->entityIds[$this->entityName][$index]['joins'][$joinEntity] = $joinIds; return $this; } diff --git a/ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php b/ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php index f497f0aef8a1..3fcde4e9c4cb 100644 --- a/ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php +++ b/ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php @@ -4,6 +4,7 @@ use Civi\Afform\FormDataModel; use Civi\Api4\Generic\Result; +use Civi\Api4\Utils\CoreUtil; /** * Shared functionality for form submission pre & post processing. @@ -64,7 +65,7 @@ public function _run(Result $result) { /** * Load all entities */ - private function loadEntities() { + protected function loadEntities() { foreach ($this->_formDataModel->getEntities() as $entityName => $entity) { $this->_entityIds[$entityName] = []; if (!empty($entity['actions']['update'])) { @@ -149,7 +150,7 @@ protected static function getJoinWhereClause($mainEntityName, $joinEntityName, $ if (self::getEntityField($joinEntityName, 'entity_id')) { $params[] = ['entity_id', '=', $mainEntityId]; if (self::getEntityField($joinEntityName, 'entity_table')) { - $params[] = ['entity_table', '=', 'civicrm_' . \CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($mainEntityName)]; + $params[] = ['entity_table', '=', CoreUtil::getTableName($mainEntityName)]; } } else { diff --git a/ext/afform/core/Civi/Api4/Action/Afform/Submit.php b/ext/afform/core/Civi/Api4/Action/Afform/Submit.php index c33cb29bb470..ef4a679802fd 100644 --- a/ext/afform/core/Civi/Api4/Action/Afform/Submit.php +++ b/ext/afform/core/Civi/Api4/Action/Afform/Submit.php @@ -3,6 +3,7 @@ namespace Civi\Api4\Action\Afform; use Civi\Afform\Event\AfformSubmitEvent; +use Civi\Api4\Utils\CoreUtil; /** * Class Submit @@ -59,8 +60,12 @@ protected function processForm() { \Civi::dispatcher()->dispatch('civi.afform.submit', $event); } - // What should I return? - return []; + // Return ids and a token for uploading files + return [ + [ + 'token' => $this->generatePostSubmitToken(), + ], + ]; } /** @@ -136,7 +141,7 @@ public static function processGenericEntity(AfformSubmitEvent $event) { try { $saved = $api4($event->getEntityType(), 'save', ['records' => [$record['fields']]])->first(); $event->setEntityId($index, $saved['id']); - self::saveJoins($event->getEntityType(), $saved['id'], $record['joins'] ?? []); + self::saveJoins($event, $index, $saved['id'], $record['joins'] ?? []); } catch (\API_Exception $e) { // What to do here? Sometimes we should silently ignore errors, e.g. an optional entity @@ -148,25 +153,27 @@ public static function processGenericEntity(AfformSubmitEvent $event) { /** * This saves joins (sub-entities) such as Email, Address, Phone, etc. * - * @param $mainEntityName - * @param $entityId - * @param $joins + * @param \Civi\Afform\Event\AfformSubmitEvent $event + * @param int $index + * @param int|string $entityId + * @param array $joins * @throws \API_Exception - * @throws \Civi\API\Exception\NotImplementedException */ - protected static function saveJoins($mainEntityName, $entityId, $joins) { + protected static function saveJoins(AfformSubmitEvent $event, $index, $entityId, $joins) { foreach ($joins as $joinEntityName => $join) { $values = self::filterEmptyJoins($joinEntityName, $join); // TODO: REPLACE works for creating or updating contacts, but different logic would be needed if // the contact was being auto-updated via a dedupe rule; in that case we would not want to // delete any existing records. if ($values) { - civicrm_api4($joinEntityName, 'replace', [ + $result = civicrm_api4($joinEntityName, 'replace', [ // Disable permission checks because the main entity has already been vetted 'checkPermissions' => FALSE, - 'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId), + 'where' => self::getJoinWhereClause($event->getEntityType(), $joinEntityName, $entityId), 'records' => $values, - ]); + ], ['id']); + $indexedResult = array_combine(array_keys($values), (array) $result); + $event->setJoinIds($index, $joinEntityName, $indexedResult); } // REPLACE doesn't work if there are no records, have to use DELETE else { @@ -174,25 +181,41 @@ protected static function saveJoins($mainEntityName, $entityId, $joins) { civicrm_api4($joinEntityName, 'delete', [ // Disable permission checks because the main entity has already been vetted 'checkPermissions' => FALSE, - 'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId), + 'where' => self::getJoinWhereClause($event->getEntityType(), $joinEntityName, $entityId), ]); } catch (\API_Exception $e) { // No records to delete } + $event->setJoinIds($index, $joinEntityName, []); } } } /** - * Filter out joins that have been left blank on the form + * Filter out join entities that have been left blank on the form * * @param $entity * @param $join * @return array */ private static function filterEmptyJoins($entity, $join) { - return array_filter($join, function($item) use($entity) { + $idField = CoreUtil::getIdFieldName($entity); + $fileFields = (array) civicrm_api4($entity, 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['fk_entity', '=', 'File']], + ], ['name']); + // Files will be uploaded later, fill with empty values for now + // TODO: Somehow check if a file has actually been selected for upload + foreach ($join as &$item) { + if (empty($item[$idField]) && $fileFields) { + $item += array_fill_keys($fileFields, ''); + } + } + return array_filter($join, function($item) use($entity, $idField, $fileFields) { + if (!empty($item[$idField]) || $fileFields) { + return TRUE; + } switch ($entity) { case 'Email': return !empty($item['email']); @@ -207,7 +230,7 @@ private static function filterEmptyJoins($entity, $join) { return !empty($item['url']); default: - \CRM_Utils_Array::remove($item, 'id', 'is_primary', 'location_type_id', 'entity_id', 'contact_id', 'entity_table'); + \CRM_Utils_Array::remove($item, 'is_primary', 'location_type_id', 'entity_id', 'contact_id', 'entity_table'); return (bool) array_filter($item); } }); @@ -241,4 +264,25 @@ private function fillIdFields(array &$records, string $entityName): void { } } + /** + * Generates token returned from submit action + * + * @return string + * @throws \Civi\Crypto\Exception\CryptoException + */ + private function generatePostSubmitToken(): string { + // 1 hour should be more than sufficient to upload files + $expires = \CRM_Utils_Time::time() + (60 * 60); + + /** @var \Civi\Crypto\CryptoJwt $jwt */ + $jwt = \Civi::service('crypto.jwt'); + + return $jwt->encode([ + 'exp' => $expires, + // Note: Scope is not the same as "authx" scope. "Authx" tokens are user-login tokens. This one is a more limited access token. + 'scope' => 'afformPostSubmit', + 'civiAfformSubmission' => ['name' => $this->name, 'data' => $this->_entityIds], + ]); + } + } diff --git a/ext/afform/core/Civi/Api4/Action/Afform/SubmitFile.php b/ext/afform/core/Civi/Api4/Action/Afform/SubmitFile.php new file mode 100644 index 000000000000..bd463b02c26a --- /dev/null +++ b/ext/afform/core/Civi/Api4/Action/Afform/SubmitFile.php @@ -0,0 +1,140 @@ +_formDataModel->getEntity($this->entityName); + $apiEntity = $this->joinEntity ?: $afformEntity['type']; + $entityIndex = (int) $this->entityIndex; + $joinIndex = (int) $this->joinIndex; + if ($this->joinEntity) { + $entityId = $this->_entityIds[$this->entityName][$entityIndex]['joins'][$this->joinEntity][$joinIndex] ?? NULL; + } + else { + $entityId = $this->_entityIds[$this->entityName][$entityIndex]['id'] ?? NULL; + } + + if (!$entityId) { + throw new \API_Exception('Entity not found'); + } + + $attachmentParams = [ + 'entity_id' => $entityId, + 'mime_type' => $_FILES['file']['type'], + 'name' => $_FILES['file']['name'], + 'options' => [ + 'move-file' => $_FILES['file']['tmp_name'], + ], + ]; + + if (strpos($this->fieldName, '.')) { + $attachmentParams['field_name'] = $this->convertFieldNameToApi3($apiEntity, $this->fieldName); + } + else { + $attachmentParams['entity_table'] = CoreUtil::getTableName($apiEntity); + } + + $file = civicrm_api3('Attachment', 'create', $attachmentParams); + + // Update multi-record custom field with value + if (strpos($apiEntity, 'Custom_') === 0) { + civicrm_api4($apiEntity, 'update', [ + 'values' => [ + 'id' => $entityId, + $this->fieldName => $file['id'], + ], + ]); + } + + return []; + } + + /** + * Load entityIds from web token + */ + protected function loadEntities() { + /** @var \Civi\Crypto\CryptoJwt $jwt */ + $jwt = \Civi::service('crypto.jwt'); + + // Double-decode is needed to convert PHP objects to arrays + $info = json_decode(json_encode($jwt->decode($this->token)), TRUE); + + if ($info['civiAfformSubmission']['name'] !== $this->getName()) { + throw new UnauthorizedException('Name mismatch'); + } + + $this->_entityIds = $info['civiAfformSubmission']['data']; + } + + /** + * @param string $apiEntity + * @param string $fieldName + * @return string + */ + private function convertFieldNameToApi3($apiEntity, $fieldName) { + if (strpos($fieldName, '.')) { + $fields = civicrm_api4($apiEntity, 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['name', '=', $fieldName]], + ]); + return 'custom_' . $fields[0]['custom_field_id']; + } + return $fieldName; + } + +} diff --git a/ext/afform/core/Civi/Api4/Afform.php b/ext/afform/core/Civi/Api4/Afform.php index 5aa33eb1f501..0756b8e06535 100644 --- a/ext/afform/core/Civi/Api4/Afform.php +++ b/ext/afform/core/Civi/Api4/Afform.php @@ -84,6 +84,15 @@ public static function submit($checkPermissions = TRUE) { ->setCheckPermissions($checkPermissions); } + /** + * @param bool $checkPermissions + * @return Action\Afform\SubmitFile + */ + public static function submitFile($checkPermissions = TRUE) { + return (new Action\Afform\SubmitFile('Afform', __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + /** * @param bool $checkPermissions * @return Generic\BasicBatchAction diff --git a/ext/afform/core/ang/af/afField.component.js b/ext/afform/core/ang/af/afField.component.js index 545351ca4aa3..22ba460deb61 100644 --- a/ext/afform/core/ang/af/afField.component.js +++ b/ext/afform/core/ang/af/afField.component.js @@ -96,6 +96,27 @@ }; + // Get the repeat index of the entity fieldset (not the join) + ctrl.getEntityIndex = function() { + // If already in a join repeat, look up the outer repeat + if ('repeatIndex' in $scope.dataProvider && $scope.dataProvider.afRepeat.getRepeatType() === 'join') { + return $scope.dataProvider.outerRepeatItem ? $scope.dataProvider.outerRepeatItem.repeatIndex : 0; + } else { + return ctrl.afRepeatItem ? ctrl.afRepeatItem.repeatIndex : 0; + } + }; + + // Params for the Afform.submitFile API when uploading a file field + ctrl.getFileUploadParams = function() { + return { + entityName: ctrl.afFieldset.modelName, + fieldName: ctrl.fieldName, + joinEntity: ctrl.afJoin ? ctrl.afJoin.entity : null, + entityIndex: ctrl.getEntityIndex(), + joinIndex: ctrl.afJoin && $scope.dataProvider.repeatIndex || null + }; + }; + $scope.getOptions = function () { return ctrl.defn.options || (ctrl.fieldName === 'is_primary' && ctrl.defn.input_type === 'Radio' ? noOptions : boolOptions); }; diff --git a/ext/afform/core/ang/af/afForm.component.js b/ext/afform/core/ang/af/afForm.component.js index b39a297ad2c5..0b3c2d44538a 100644 --- a/ext/afform/core/ang/af/afForm.component.js +++ b/ext/afform/core/ang/af/afForm.component.js @@ -4,9 +4,10 @@ bindings: { ctrl: '@' }, - controller: function($scope, $timeout, crmApi4, crmStatus, $window, $location) { + controller: function($scope, $timeout, crmApi4, crmStatus, $window, $location, FileUploader) { var schema = {}, data = {}, + status, ctrl = this; this.$onInit = function() { @@ -53,21 +54,54 @@ } }; - this.submit = function submit() { - var submission = crmApi4('Afform', 'submit', {name: ctrl.getFormMeta().name, args: $scope.$parent.routeParams || {}, values: data}); + // Used when submitting file fields + this.fileUploader = new FileUploader({ + url: CRM.url('civicrm/ajax/api4/Afform/submitFile'), + headers: {'X-Requested-With': 'XMLHttpRequest'}, + onCompleteAll: postProcess, + onBeforeUploadItem: function(item) { + status.resolve(); + status = CRM.status({start: ts('Uploading %1', {1: item.file.name})}); + } + }); + + // Called after form is submitted and files are uploaded + function postProcess() { var metaData = ctrl.getFormMeta(); + if (metaData.redirect) { - submission.then(function() { - var url = metaData.redirect; - if (url.indexOf('civicrm/') === 0) { - url = CRM.url(url); - } else if (url.indexOf('/') === 0) { - url = $location.protocol() + '://' + $location.host() + url; - } - $window.location.href = url; - }); + var url = metaData.redirect; + if (url.indexOf('civicrm/') === 0) { + url = CRM.url(url); + } else if (url.indexOf('/') === 0) { + url = $location.protocol() + '://' + $location.host() + url; + } + $window.location.href = url; } - return crmStatus({start: ts('Saving'), success: ts('Saved')}, submission); + status.resolve(); + } + + this.submit = function() { + status = CRM.status({}); + crmApi4('Afform', 'submit', { + name: ctrl.getFormMeta().name, + args: $scope.$parent.routeParams || {}, + values: data} + ).then(function(response) { + if (ctrl.fileUploader.getNotUploadedItems().length) { + _.each(ctrl.fileUploader.getNotUploadedItems(), function(file) { + file.formData.push({ + params: JSON.stringify(_.extend({ + token: response[0].token, + name: ctrl.getFormMeta().name + }, file.crmApiParams())) + }); + }); + ctrl.fileUploader.uploadAll(); + } else { + postProcess(); + } + }); }; } }); diff --git a/ext/afform/core/ang/af/afRepeat.directive.js b/ext/afform/core/ang/af/afRepeat.directive.js index 133897bf481d..f294c2063097 100644 --- a/ext/afform/core/ang/af/afRepeat.directive.js +++ b/ext/afform/core/ang/af/afRepeat.directive.js @@ -58,16 +58,15 @@ .directive('afRepeatItem', function() { return { restrict: 'A', - require: ['afRepeatItem', '^^afRepeat'], + require: { + afRepeat: '^^', + outerRepeatItem: '?^^afRepeatItem' + }, bindToController: { item: '=afRepeatItem', repeatIndex: '=' }, - link: function($scope, $el, $attr, ctrls) { - var self = ctrls[0]; - self.afRepeat = ctrls[1]; - }, - controller: function($scope) { + controller: function() { this.getFieldData = function() { return this.afRepeat.getRepeatType() === 'join' ? this.item : this.item.fields; }; diff --git a/ext/afform/core/ang/af/fields/File.html b/ext/afform/core/ang/af/fields/File.html new file mode 100644 index 000000000000..1412e8013834 --- /dev/null +++ b/ext/afform/core/ang/af/fields/File.html @@ -0,0 +1,3 @@ + diff --git a/ext/afform/core/ang/afCore.ang.php b/ext/afform/core/ang/afCore.ang.php index 9b23b737b200..7f2a44691e57 100644 --- a/ext/afform/core/ang/afCore.ang.php +++ b/ext/afform/core/ang/afCore.ang.php @@ -7,7 +7,7 @@ 'ang/afCore/*/*.js', ], 'css' => ['ang/afCore.css'], - 'requires' => ['crmUi', 'crmUtil', 'api4', 'checklist-model'], + 'requires' => ['crmUi', 'crmUtil', 'api4', 'checklist-model', 'angularFileUpload'], 'partials' => ['ang/afCore'], 'settings' => [], 'basePages' => [], diff --git a/ext/afform/mock/tests/phpunit/api/v4/AfformFileUploadTest.php b/ext/afform/mock/tests/phpunit/api/v4/AfformFileUploadTest.php new file mode 100644 index 000000000000..5bb6ab1df031 --- /dev/null +++ b/ext/afform/mock/tests/phpunit/api/v4/AfformFileUploadTest.php @@ -0,0 +1,161 @@ + + +
+ Individual 1 + + +
+ +
+
+ +
+EOHTML; + } + + public function tearDown(): void { + parent::tearDown(); + $_FILES = []; + } + + /** + * Test the submitFile api action + */ + public function testSubmitFile(): void { + // Single-value set + \Civi\Api4\CustomGroup::create(FALSE) + ->addValue('name', 'MyInfo') + ->addValue('title', 'My Info') + ->addValue('extends', 'Contact') + ->addChain('fields', \Civi\Api4\CustomField::save() + ->addDefault('custom_group_id', '$id') + ->setRecords([ + ['name' => 'single_file_field', 'label' => 'A File', 'data_type' => 'File', 'html_type' => 'File'], + ]) + ) + ->execute(); + + // Multi-record set + \Civi\Api4\CustomGroup::create(FALSE) + ->addValue('name', 'MyFiles') + ->addValue('title', 'My Files') + ->addValue('style', 'Tab with table') + ->addValue('extends', 'Contact') + ->addValue('is_multiple', TRUE) + ->addValue('max_multiple', 3) + ->addChain('fields', \Civi\Api4\CustomField::save() + ->addDefault('custom_group_id', '$id') + ->setRecords([ + ['name' => 'my_file', 'label' => 'My File', 'data_type' => 'File', 'html_type' => 'File'], + ]) + ) + ->execute(); + + $this->useValues([ + 'layout' => self::$layouts['customFiles'], + 'permission' => CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION, + ]); + + $lastName = uniqid(__FUNCTION__); + $values = [ + 'Individual1' => [ + [ + 'fields' => [ + 'first_name' => 'First', + 'last_name' => $lastName, + ], + 'joins' => [ + 'Custom_MyFiles' => [ + [], + [], + ], + ], + ], + [ + 'fields' => [ + 'first_name' => 'Second', + 'last_name' => $lastName, + ], + 'joins' => [ + 'Custom_MyFiles' => [ + [], + [], + ], + ], + ], + ], + ]; + $submission = Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute()->first(); + + foreach ([0, 1] as $entityIndex) { + $this->mockUploadFile(); + Civi\Api4\Afform::submitFile() + ->setName($this->formName) + ->setToken($submission['token']) + ->setEntityName('Individual1') + ->setFieldName('MyInfo.single_file_field') + ->setEntityIndex($entityIndex) + ->execute(); + + foreach ([0, 1] as $joinIndex) { + $this->mockUploadFile(); + Civi\Api4\Afform::submitFile() + ->setName($this->formName) + ->setToken($submission['token']) + ->setEntityName('Individual1') + ->setFieldName('my_file') + ->setEntityIndex($entityIndex) + ->setJoinEntity('Custom_MyFiles') + ->setJoinIndex($joinIndex) + ->execute(); + } + } + + $contacts = \Civi\Api4\Contact::get(FALSE) + ->addWhere('last_name', '=', $lastName) + ->addJoin('Custom_MyFiles AS MyFiles', 'LEFT', ['id', '=', 'MyFiles.entity_id']) + ->addSelect('first_name', 'MyInfo.single_file_field', 'MyFiles.my_file') + ->addOrderBy('id') + ->addOrderBy('MyFiles.my_file') + ->execute(); + $fileId = $contacts[0]['MyInfo.single_file_field']; + $this->assertEquals(++$fileId, $contacts[0]['MyFiles.my_file']); + $this->assertEquals(++$fileId, $contacts[1]['MyFiles.my_file']); + $this->assertEquals(++$fileId, $contacts[2]['MyInfo.single_file_field']); + $this->assertEquals(++$fileId, $contacts[2]['MyFiles.my_file']); + $this->assertEquals(++$fileId, $contacts[3]['MyFiles.my_file']); + } + + /** + * Mock a file being uploaded + */ + protected function mockUploadFile() { + $tmpDir = sys_get_temp_dir(); + $this->assertTrue($tmpDir && is_dir($tmpDir), 'Tmp dir must exist: ' . $tmpDir); + $fileName = uniqid() . '.txt'; + $filePath = $tmpDir . '/' . $fileName; + file_put_contents($filePath, 'Hello'); + $_FILES['file'] = [ + 'name' => $fileName, + 'tmp_name' => $filePath, + 'type' => 'text/plain', + ]; + } + +}