summaryrefslogtreecommitdiff
path: root/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests
diff options
context:
space:
mode:
Diffstat (limited to 'www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests')
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BaseCustomValueTest.php31
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicActionsTest.php154
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicCustomFieldTest.php187
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ChainTest.php53
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ComplexQueryTest.php87
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ContactApiKeyTest.php170
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateCustomValueTest.php64
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateWithOptionGroupTest.php190
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValuePerformanceTest.php95
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValueTest.php174
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/DateTest.php46
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/EvaluateConditionTest.php38
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ExtendFromIndividualTest.php54
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/FkJoinTest.php75
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetExtraFieldsTest.php26
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetFromArrayTest.php163
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/IndexTest.php48
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/NullValueTest.php55
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ReplaceTest.php171
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateContactTest.php34
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateCustomValueTest.php56
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/ConformanceTest.json28
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/DefaultDataSet.json45
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/MultiContactMultiEmail.json42
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/SingleContact.json81
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ConformanceTest.php226
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ContactJoinTest.php103
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/EntityTest.php35
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ParticipantTest.php200
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/Action/MockArrayEntity/Get.php53
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockArrayEntity.php21
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockBasicEntity.php94
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockEntityDataStorage.php31
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionBase.php22
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionChild.php16
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionGrandchild.php17
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryComplexJoinTest.php85
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryTest.php91
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OneToOneJoinTest.php46
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OptionValueJoinTest.php46
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/SelectQueryMultiJoinTest.php74
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapRealTableTest.php22
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapperTest.php90
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/TestCreationParameterProvider.php148
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/RequestSpecTest.php42
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecFormatterTest.php90
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecGathererTest.php96
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/OptionCleanupTrait.php24
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/QueryCounterTrait.php43
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TableDropperTrait.php23
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TestDataLoaderTrait.php69
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/UnitTestCase.php235
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ArrayInsertionServiceTest.php67
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ReflectionUtilsTest.php46
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/bootstrap.php56
-rw-r--r--www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/services.xml10
56 files changed, 4388 insertions, 0 deletions
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BaseCustomValueTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BaseCustomValueTest.php
new file mode 100644
index 00000000..41fe7281
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BaseCustomValueTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Test\Api4\Traits\TableDropperTrait;
+
+abstract class BaseCustomValueTest extends UnitTestCase {
+
+ use \Civi\Test\Api4\Traits\OptionCleanupTrait {
+ setUp as setUpOptionCleanup;
+ }
+ use TableDropperTrait;
+
+ /**
+ * Set up baseline for testing
+ */
+ public function setUp() {
+ $this->setUpOptionCleanup();
+ $cleanup_params = [
+ 'tablesToTruncate' => [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ ],
+ ];
+
+ $this->dropByPrefix('civicrm_value_mycontact');
+ $this->cleanup($cleanup_params);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicActionsTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicActionsTest.php
new file mode 100644
index 00000000..29523389
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicActionsTest.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\MockBasicEntity;
+
+/**
+ * @group headless
+ */
+class BasicActionsTest extends UnitTestCase {
+
+ public function testCrud() {
+ MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+
+ $id1 = MockBasicEntity::create()->addValue('foo', 'one')->execute()->first()['id'];
+
+ $result = MockBasicEntity::get()->execute();
+ $this->assertCount(1, $result);
+
+ $id2 = MockBasicEntity::create()->addValue('foo', 'two')->execute()->first()['id'];
+
+ $result = MockBasicEntity::get()->execute();
+ $this->assertCount(2, $result);
+
+ MockBasicEntity::update()->addWhere('id', '=', $id2)->addValue('foo', 'new')->execute();
+
+ $result = MockBasicEntity::get()->addOrderBy('id', 'DESC')->setLimit(1)->execute();
+ $this->assertCount(1, $result);
+ $this->assertEquals('new', $result->first()['foo']);
+
+ MockBasicEntity::delete()->addWhere('id', '=', $id2);
+ $result = MockBasicEntity::get()->execute();
+ $this->assertEquals('one', $result->first()['foo']);
+ }
+
+ public function testReplace() {
+ MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+
+ $objects = [
+ ['group' => 'one', 'color' => 'red'],
+ ['group' => 'one', 'color' => 'blue'],
+ ['group' => 'one', 'color' => 'green'],
+ ['group' => 'two', 'color' => 'orange'],
+ ];
+
+ foreach ($objects as &$object) {
+ $object['id'] = MockBasicEntity::create()->setValues($object)->execute()->first()['id'];
+ }
+
+ // Keep red, change blue, delete green, and add yellow
+ $replacements = [
+ ['color' => 'red', 'id' => $objects[0]['id']],
+ ['color' => 'not blue', 'id' => $objects[1]['id']],
+ ['color' => 'yellow']
+ ];
+
+ MockBasicEntity::replace()->addWhere('group', '=', 'one')->setRecords($replacements)->execute();
+
+ $newObjects = MockBasicEntity::get()->addOrderBy('id', 'DESC')->execute()->indexBy('id');
+
+ $this->assertCount(4, $newObjects);
+
+ $this->assertEquals('yellow', $newObjects->first()['color']);
+
+ $this->assertEquals('not blue', $newObjects[$objects[1]['id']]['color']);
+
+ // Ensure group two hasn't been altered
+ $this->assertEquals('orange', $newObjects[$objects[3]['id']]['color']);
+ $this->assertEquals('two', $newObjects[$objects[3]['id']]['group']);
+ }
+
+ public function testBatchFrobnicate() {
+ MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+
+ $objects = [
+ ['group' => 'one', 'color' => 'red', 'number' => 10],
+ ['group' => 'one', 'color' => 'blue', 'number' => 20],
+ ['group' => 'one', 'color' => 'green', 'number' => 30],
+ ['group' => 'two', 'color' => 'blue', 'number' => 40],
+ ];
+ foreach ($objects as &$object) {
+ $object['id'] = MockBasicEntity::create()->setValues($object)->execute()->first()['id'];
+ }
+
+ $result = MockBasicEntity::batchFrobnicate()->addWhere('color', '=', 'blue')->execute();
+ $this->assertEquals(2, count($result));
+ $this->assertEquals([400, 1600], \CRM_Utils_Array::collect('frobnication', (array) $result));
+ }
+
+ public function testGetFields() {
+ $getFields = MockBasicEntity::getFields()->execute()->indexBy('name');
+
+ $this->assertCount(6, $getFields);
+ $this->assertEquals('Id', $getFields['id']['title']);
+ // Ensure default data type is "String" when not specified
+ $this->assertEquals('String', $getFields['color']['data_type']);
+
+ // Getfields should default to loadOptions = false and reduce them to bool
+ $this->assertTrue($getFields['group']['options']);
+ $this->assertFalse($getFields['id']['options']);
+
+ // Now load options
+ $getFields = MockBasicEntity::getFields()
+ ->addWhere('name', '=', 'group')
+ ->setLoadOptions(TRUE)
+ ->execute()->indexBy('name');
+
+ $this->assertCount(1, $getFields);
+ $this->assertArrayHasKey('one', $getFields['group']['options']);
+ }
+
+ public function testItemsToGet() {
+ $get = MockBasicEntity::get()
+ ->addWhere('color', 'NOT IN', ['yellow'])
+ ->addWhere('color', 'IN', ['red', 'blue'])
+ ->addWhere('color', '!=', 'green')
+ ->addWhere('group', '=', 'one');
+
+ $this->assertEquals(['red', 'blue'], $get->_itemsToGet('color'));
+ $this->assertEquals(['one'], $get->_itemsToGet('group'));
+ }
+
+ public function testFieldsToGet() {
+ $get = MockBasicEntity::get()
+ ->addWhere('color', '!=', 'green');
+
+ // If no "select" is set, should always return true
+ $this->assertTrue($get->_isFieldSelected('color'));
+ $this->assertTrue($get->_isFieldSelected('shape'));
+ $this->assertTrue($get->_isFieldSelected('size'));
+
+ // With a non-empty "select" fieldsToSelect() will return fields needed to evaluate each clause.
+ $get->addSelect('id');
+ $this->assertTrue($get->_isFieldSelected('color'));
+ $this->assertTrue($get->_isFieldSelected('id'));
+ $this->assertFalse($get->_isFieldSelected('shape'));
+ $this->assertFalse($get->_isFieldSelected('size'));
+ $this->assertFalse($get->_isFieldSelected('weight'));
+ $this->assertFalse($get->_isFieldSelected('group'));
+
+ $get->addClause('OR', ['shape', '=', 'round'], ['AND', [['size', '=', 'big'], ['weight', '!=', 'small']]]);
+ $this->assertTrue($get->_isFieldSelected('color'));
+ $this->assertTrue($get->_isFieldSelected('id'));
+ $this->assertTrue($get->_isFieldSelected('shape'));
+ $this->assertTrue($get->_isFieldSelected('size'));
+ $this->assertTrue($get->_isFieldSelected('weight'));
+ $this->assertFalse($get->_isFieldSelected('group'));
+
+ $get->addOrderBy('group');
+ $this->assertTrue($get->_isFieldSelected('group'));
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicCustomFieldTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicCustomFieldTest.php
new file mode 100644
index 00000000..85687a08
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/BasicCustomFieldTest.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+
+/**
+ * @group headless
+ */
+class BasicCustomFieldTest extends BaseCustomValueTest {
+
+ public function testWithSingleField() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId)
+ ->addWhere('MyContactFields.FavColor', '=', 'Red')
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('MyContactFields', $contact);
+ $contactFields = $contact['MyContactFields'];
+ $this->assertArrayHasKey('FavColor', $contactFields);
+ $this->assertEquals('Red', $contactFields['FavColor']);
+
+ Contact::update()
+ ->addWhere('id', '=', $contactId)
+ ->addValue('MyContactFields.FavColor', 'Blue')
+ ->execute();
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId)
+ ->execute()
+ ->first();
+
+ $contactFields = $contact['MyContactFields'];
+ $this->assertEquals('Blue', $contactFields['FavColor']);
+ }
+
+ public function testWithTwoFields() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavFood')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId1 = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->addValue('MyContactFields.FavFood', 'Cherry')
+ ->execute()
+ ->first()['id'];
+
+ $contactId2 = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'MaryLou')
+ ->addValue('last_name', 'Tester')
+ ->addValue('MyContactFields.FavColor', 'Purple')
+ ->addValue('MyContactFields.FavFood', 'Grapes')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect('MyContactFields.FavColor')
+ ->addSelect('MyContactFields.FavFood')
+ ->addWhere('id', '=', $contactId1)
+ ->addWhere('MyContactFields.FavColor', '=', 'Red')
+ ->addWhere('MyContactFields.FavFood', '=', 'Cherry')
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('MyContactFields', $contact);
+ $contactFields = $contact['MyContactFields'];
+ $this->assertArrayHasKey('FavColor', $contactFields);
+ $this->assertEquals('Red', $contactFields['FavColor']);
+
+ Contact::update()
+ ->addWhere('id', '=', $contactId1)
+ ->addValue('MyContactFields.FavColor', 'Blue')
+ ->execute();
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId1)
+ ->execute()
+ ->first();
+
+ $contactFields = $contact['MyContactFields'];
+ $this->assertEquals('Blue', $contactFields['FavColor']);
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('OR', ['MyContactFields.FavColor', '=', 'Blue'], ['MyContactFields.FavFood', '=', 'Grapes'])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals([$contactId1, $contactId2], array_keys((array) $search));
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('NOT', ['MyContactFields.FavColor', '=', 'Purple'], ['MyContactFields.FavFood', '=', 'Grapes'])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertNotContains($contactId2, array_keys((array) $search));
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('NOT', ['MyContactFields.FavColor', '=', 'Purple'], ['MyContactFields.FavFood', '=', 'Grapes'])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertContains($contactId1, array_keys((array) $search));
+ $this->assertNotContains($contactId2, array_keys((array) $search));
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->setWhere([['NOT', ['OR', [['MyContactFields.FavColor', '=', 'Blue'], ['MyContactFields.FavFood', '=', 'Grapes']]]]])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertNotContains($contactId1, array_keys((array) $search));
+ $this->assertNotContains($contactId2, array_keys((array) $search));
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ChainTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ChainTest.php
new file mode 100644
index 00000000..bbd6a092
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ChainTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ChainTest extends UnitTestCase {
+
+ public function testGetActionsWithFields() {
+ $actions = \Civi\Api4\Activity::getActions()
+ ->addChain('fields', \Civi\Api4\Activity::getFields()->setAction('$name'), 'name')
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertEquals('Array', $actions['getActions']['fields']['params']['data_type']);
+ }
+
+ public function testGetEntityWithActions() {
+ $entities = \Civi\Api4\Entity::get()
+ ->addSelect('name')
+ ->setChain([
+ 'actions' => ['$name', 'getActions', ['select' => ['name']], 'name']
+ ])
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertArrayHasKey('replace', $entities['Contact']['actions']);
+ $this->assertArrayHasKey('getLinks', $entities['Entity']['actions']);
+ $this->assertArrayNotHasKey('replace', $entities['Entity']['actions']);
+ }
+
+ public function testContactCreateWithGroup() {
+ $firstName = uniqid('cwtf');
+ $lastName = uniqid('cwtl');
+
+ $contact = \Civi\Api4\Contact::create()
+ ->addValue('first_name', $firstName)
+ ->addValue('last_name', $lastName)
+ ->addChain('group', \Civi\Api4\Group::create()->addValue('title', '$display_name'), 0)
+ ->addChain('add_to_group', \Civi\Api4\GroupContact::create()->addValue('contact_id', '$id')->addValue('group_id', '$group.id'), 0)
+ ->addChain('check_group', \Civi\Api4\GroupContact::get()->addWhere('group_id', '=', '$group.id'))
+ ->execute()
+ ->first();
+
+ $this->assertCount(1, $contact['check_group']);
+ $this->assertEquals($contact['id'], $contact['check_group'][0]['contact_id']);
+ $this->assertEquals($contact['group']['id'], $contact['check_group'][0]['group_id']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ComplexQueryTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ComplexQueryTest.php
new file mode 100644
index 00000000..0a320100
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ComplexQueryTest.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\Activity;
+
+/**
+ * @group headless
+ *
+ * This class tests a series of complex query situations described in the
+ * initial APIv4 specification
+ */
+class ComplexQueryTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('DefaultDataSet');
+
+ return parent::setUpHeadless();
+ }
+
+ /**
+ * Fetch all phone call activities
+ * Expects at least one activity loaded from the data set.
+ */
+ public function testGetAllHousingSupportActivities() {
+ $results = Activity::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('activity_type.name', '=', 'Phone Call')
+ ->execute();
+
+ $this->assertGreaterThan(0, count($results));
+ }
+
+ /**
+ * Fetch all activities with a blue tag; and return all tags on the activities
+ */
+ public function testGetAllTagsForBlueTaggedActivities() {
+
+ }
+
+ /**
+ * Fetch contacts named 'Bob' and all of their blue activities
+ */
+ public function testGetAllBlueActivitiesForBobs() {
+
+ }
+
+ /**
+ * Get all contacts in a zipcode and return their Home or Work email addresses
+ */
+ public function testGetHomeOrWorkEmailsForContactsWithZipcode() {
+
+ }
+
+ /**
+ * Fetch all activities where Bob is the assignee or source
+ */
+ public function testGetActivitiesWithBobAsAssigneeOrSource() {
+
+ }
+
+ /**
+ * Get all contacts which
+ * (a) have address in zipcode 94117 or 94118 or in city "San Francisco","LA"
+ * and
+ * (b) are not deceased and
+ * (c) have a custom-field "most_important_issue=Environment".
+ */
+ public function testAWholeLotOfConditions() {
+
+ }
+
+ /**
+ * Get participants who attended CiviCon 2012 but not CiviCon 2013.
+ * Return their name and email.
+ */
+ public function testGettingNameAndEmailOfAttendeesOfCiviCon2012Only() {
+
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ContactApiKeyTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ContactApiKeyTest.php
new file mode 100644
index 00000000..63d85d55
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ContactApiKeyTest.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class ContactApiKeyTest extends \Civi\Test\Api4\UnitTestCase {
+
+ public function testGetApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM'];
+ $key = uniqid();
+
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key0')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ $this->assertEquals($result['api_key'], $key);
+
+ $result = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ $this->assertTrue(empty($result['api_key']));
+ }
+
+ public function testCreateWithApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'add contacts'];
+ $key = uniqid();
+
+ $error = '';
+ try {
+ Contact::create()
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key1')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+ }
+ catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+ $this->assertContains('key', $error);
+ }
+
+ public function testUpdateApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM'];
+ $key = uniqid();
+
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key2')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+
+ $error = '';
+ try {
+ // Try to update the key without permissions; nothing should happen
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "NotAllowed")
+ ->execute();
+ }
+ catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ $this->assertContains('key', $error);
+
+ // Assert key is still the same
+ $this->assertEquals($result['api_key'], $key);
+
+ // Now we can update the key
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'administer CiviCRM', 'edit all contacts'];
+
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "IGotThePower!")
+ ->execute();
+
+ $result = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ // Assert key was updated
+ $this->assertEquals($result['api_key'], "IGotThePower!");
+ }
+
+ public function testUpdateOwnApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit own api keys', 'edit my contact'];
+ $key = uniqid();
+
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key3')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+
+ $error = '';
+ try {
+ // Try to update the key without permissions; nothing should happen
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "NotAllowed")
+ ->execute();
+ }
+ catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+
+ $this->assertContains('key', $error);
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ // Assert key is still the same
+ $this->assertEquals($result['api_key'], $key);
+
+ // Now we can update the key
+ \CRM_Core_Session::singleton()->set('userID', $contact['id']);
+
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "MyId!")
+ ->execute();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ // Assert key was updated
+ $this->assertEquals($result['api_key'], "MyId!");
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateCustomValueTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateCustomValueTest.php
new file mode 100644
index 00000000..762b4617
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateCustomValueTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\OptionGroup;
+use Civi\Api4\OptionValue;
+
+/**
+ * @group headless
+ */
+class CreateCustomValueTest extends BaseCustomValueTest {
+
+ public function testGetWithCustomData() {
+ $optionValues = ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'];
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Color')
+ ->addValue('options', $optionValues)
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $customField = CustomField::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('label', '=', 'Color')
+ ->execute()
+ ->first();
+
+ $this->assertNotNull($customField['option_group_id']);
+ $optionGroupId = $customField['option_group_id'];
+
+ $optionGroup = OptionGroup::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $optionGroupId)
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Color', $optionGroup['title']);
+
+ $createdOptionValues = OptionValue::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('option_group_id', '=', $optionGroupId)
+ ->execute()
+ ->getArrayCopy();
+
+ $values = array_column($createdOptionValues, 'value');
+ $labels = array_column($createdOptionValues, 'label');
+ $createdOptionValues = array_combine($values, $labels);
+
+ $this->assertEquals($optionValues, $createdOptionValues);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateWithOptionGroupTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateWithOptionGroupTest.php
new file mode 100644
index 00000000..b4d0af85
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CreateWithOptionGroupTest.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class CreateWithOptionGroupTest extends BaseCustomValueTest {
+
+ /**
+ * Remove the custom tables
+ */
+ public function setUp() {
+ $this->dropByPrefix('civicrm_value_financial');
+ $this->dropByPrefix('civicrm_value_favorite');
+ parent::setUp();
+ }
+
+ public function testGetWithCustomData() {
+ $group = uniqid('fava');
+ $colorField = uniqid('colora');
+ $foodField = uniqid('fooda');
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', $group)
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $colorField)
+ ->addValue('name', $colorField)
+ ->addValue('options', ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $foodField)
+ ->addValue('name', $foodField)
+ ->addValue('options', ['1' => 'Corn', '2' => 'Potatoes', '3' => 'Cheese'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'FinancialStuff')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Salary')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Number')
+ ->addValue('data_type', 'Money')
+ ->execute();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Jerome')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue("$group.$colorField", 'r')
+ ->addValue("$group.$foodField", '1')
+ ->addValue('FinancialStuff.Salary', 50000)
+ ->execute();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect("$group.$colorField.label")
+ ->addSelect("$group.$foodField.label")
+ ->addSelect('FinancialStuff.Salary')
+ ->addWhere("$group.$foodField.label", 'IN', ['Corn', 'Potatoes'])
+ ->addWhere('FinancialStuff.Salary', '>', '10000')
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey($group, $result);
+ $favoriteThings = $result[$group];
+ $favoriteFood = $favoriteThings[$foodField];
+ $favoriteColor = $favoriteThings[$colorField];
+ $financialStuff = $result['FinancialStuff'];
+ $this->assertEquals('Red', $favoriteColor['label']);
+ $this->assertEquals('Corn', $favoriteFood['label']);
+ $this->assertEquals(50000, $financialStuff['Salary']);
+ }
+
+ public function testWithCustomDataForMultipleContacts() {
+ $group = uniqid('favb');
+ $colorField = uniqid('colorb');
+ $foodField = uniqid('foodb');
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', $group)
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $colorField)
+ ->addValue('name', $colorField)
+ ->addValue('options', ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $foodField)
+ ->addValue('name', $foodField)
+ ->addValue('options', ['1' => 'Corn', '2' => 'Potatoes', '3' => 'Cheese'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'FinancialStuff')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Salary')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Number')
+ ->addValue('data_type', 'Money')
+ ->execute();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Corn')
+ ->addValue('contact_type', 'Individual')
+ ->addValue("$group.$colorField", 'r')
+ ->addValue("$group.$foodField", '1')
+ ->addValue('FinancialStuff.Salary', 10000)
+ ->execute();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Blue')
+ ->addValue('last_name', 'Cheese')
+ ->addValue('contact_type', 'Individual')
+ ->addValue("$group.$colorField", 'b')
+ ->addValue("$group.$foodField", '3')
+ ->addValue('FinancialStuff.Salary', 500000)
+ ->execute();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect('last_name')
+ ->addSelect("$group.$colorField.label")
+ ->addSelect("$group.$foodField.label")
+ ->addSelect('FinancialStuff.Salary')
+ ->addWhere("$group.$foodField.label", 'IN', ['Corn', 'Cheese'])
+ ->execute();
+
+ $blueCheese = NULL;
+ foreach ($result as $contact) {
+ if ($contact['first_name'] === 'Blue') {
+ $blueCheese = $contact;
+ }
+ }
+
+ $this->assertEquals('Blue', $blueCheese[$group][$colorField]['label']);
+ $this->assertEquals('Cheese', $blueCheese[$group][$foodField]['label']);
+ $this->assertEquals(500000, $blueCheese['FinancialStuff']['Salary']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValuePerformanceTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValuePerformanceTest.php
new file mode 100644
index 00000000..3fa59ef4
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValuePerformanceTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Test\Api4\Traits\QueryCounterTrait;
+
+/**
+ * @group headless
+ */
+class CustomValuePerformanceTest extends BaseCustomValueTest {
+
+ use QueryCounterTrait;
+
+ public function testQueryCount() {
+
+ $this->markTestIncomplete();
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('title', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('options', ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'])
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavAnimal')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavLetter')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavFood')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $this->beginQueryCount();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'r')
+ ->addValue('MyContactFields.FavAnimal', 'Sheep')
+ ->addValue('MyContactFields.FavLetter', 'z')
+ ->addValue('MyContactFields.FavFood', 'Coconuts')
+ ->execute();
+
+ Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('display_name')
+ ->addSelect('MyContactFields.FavColor.label')
+ ->addSelect('MyContactFields.FavColor.weight')
+ ->addSelect('MyContactFields.FavColor.is_default')
+ ->addSelect('MyContactFields.FavAnimal')
+ ->addSelect('MyContactFields.FavLetter')
+ ->addWhere('MyContactFields.FavColor', '=', 'r')
+ ->addWhere('MyContactFields.FavFood', '=', 'Coconuts')
+ ->addWhere('MyContactFields.FavAnimal', '=', 'Sheep')
+ ->addWhere('MyContactFields.FavLetter', '=', 'z')
+ ->execute()
+ ->first();
+
+ // FIXME: This count is artificially high due to the line
+ // $this->entity = Tables::getBriefName(Tables::getClassForTable($targetTable));
+ // In class Joinable. TODO: Investigate why.
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValueTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValueTest.php
new file mode 100644
index 00000000..da954bc7
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/CustomValueTest.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\CustomValue;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class CustomValueTest extends BaseCustomValueTest {
+
+ protected $contactID;
+
+ /**
+ * Test CustomValue::GetFields/Get/Create/Update/Replace/Delete
+ */
+ public function testCRUD() {
+ $optionValues = ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'];
+
+ $group = uniqid('groupc');
+ $colorField = uniqid('colorc');
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', $group)
+ ->addValue('extends', 'Contact')
+ ->addValue('is_multiple', TRUE)
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $colorField)
+ ->addValue('options', $optionValues)
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $this->contactID = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+
+ // Retrieve and check the fields of CustomValue = Custom_$group
+ $fields = CustomValue::getFields($group)->execute();
+ $expectedResult = [
+ [
+ 'custom_field_id' => 1,
+ 'custom_group' => $group,
+ 'name' => $colorField,
+ 'title' => ts($colorField),
+ 'entity' => "Custom_$group",
+ 'data_type' => 'String',
+ 'fk_entity' => NULL,
+ ],
+ [
+ 'name' => 'id',
+ 'title' => ts('Custom Value ID'),
+ 'entity' => "Custom_$group",
+ 'data_type' => 'Integer',
+ 'fk_entity' => NULL,
+ ],
+ [
+ 'name' => 'entity_id',
+ 'title' => ts('Entity ID'),
+ 'entity' => "Custom_$group",
+ 'data_type' => 'Integer',
+ 'fk_entity' => 'Contact',
+ ],
+ ];
+
+ foreach ($expectedResult as $key => $field) {
+ foreach ($field as $attr => $value) {
+ $this->assertEquals($expectedResult[$key][$attr], $fields[$key][$attr]);
+ }
+ }
+
+ // CASE 1: Test CustomValue::create
+ // Create two records for a single contact and using CustomValue::get ensure that two records are created
+ CustomValue::create($group)
+ ->addValue($colorField, 'Green')
+ ->addValue("entity_id", $this->contactID)
+ ->execute();
+ CustomValue::create($group)
+ ->addValue($colorField, 'Red')
+ ->addValue("entity_id", $this->contactID)
+ ->execute();
+ // fetch custom values using API4 CustomValue::get
+ $result = CustomValue::get($group)->execute();
+
+ // check if two custom values are created
+ $this->assertEquals(2, count($result));
+ $expectedResult = [
+ [
+ 'id' => 1,
+ $colorField => 'Green',
+ 'entity_id' => $this->contactID,
+ ],
+ [
+ 'id' => 2,
+ $colorField => 'Red',
+ 'entity_id' => $this->contactID,
+ ],
+ ];
+ // match the data
+ foreach ($expectedResult as $key => $field) {
+ foreach ($field as $attr => $value) {
+ $this->assertEquals($expectedResult[$key][$attr], $result[$key][$attr]);
+ }
+ }
+
+ // CASE 2: Test CustomValue::update
+ // Update a records whose id is 1 and change the custom field (name = Color) value to 'White' from 'Green'
+ CustomValue::update($group)
+ ->addWhere("id", "=", 1)
+ ->addValue($colorField, 'White')
+ ->execute();
+
+ // ensure that the value is changed for id = 1
+ $color = CustomValue::get($group)
+ ->addWhere("id", "=", 1)
+ ->execute()
+ ->first()[$colorField];
+ $this->assertEquals('White', $color);
+
+ // CASE 3: Test CustomValue::replace
+ // create a second contact which will be used to replace the custom values, created earlier
+ $secondContactID = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Adam')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+ // Replace all the records which was created earlier with entity_id = first contact
+ // with custom record [$colorField => 'Rainbow', 'entity_id' => $secondContactID]
+ CustomValue::replace($group)
+ ->setRecords([[$colorField => 'Rainbow', 'entity_id' => $secondContactID]])
+ ->addWhere('entity_id', '=', $this->contactID)
+ ->execute();
+
+ // Check the two records created earlier is replaced by new contact
+ $result = CustomValue::get($group)->execute();
+ $this->assertEquals(1, count($result));
+
+ $expectedResult = [
+ [
+ 'id' => 3,
+ $colorField => 'Rainbow',
+ 'entity_id' => $secondContactID,
+ ],
+ ];
+ foreach ($expectedResult as $key => $field) {
+ foreach ($field as $attr => $value) {
+ $this->assertEquals($expectedResult[$key][$attr], $result[$key][$attr]);
+ }
+ }
+
+ // CASE 4: Test CustomValue::delete
+ // There is only record left whose id = 3, delete that record on basis of criteria id = 3
+ CustomValue::delete($group)->addWhere("id", "=", 3)->execute();
+ $result = CustomValue::get($group)->execute();
+ // check that there are no custom values present
+ $this->assertEquals(0, count($result));
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/DateTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/DateTest.php
new file mode 100644
index 00000000..8cdbfc7c
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/DateTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\Relationship;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class DateTest extends UnitTestCase {
+
+ public function testRelationshipDate() {
+ $c1 = Contact::create()
+ ->addValue('first_name', 'c')
+ ->addValue('last_name', 'one')
+ ->execute()
+ ->first()['id'];
+ $c2 = Contact::create()
+ ->addValue('first_name', 'c')
+ ->addValue('last_name', 'two')
+ ->execute()
+ ->first()['id'];
+ $r = Relationship::create()
+ ->addValue('contact_id_a', $c1)
+ ->addValue('contact_id_b', $c2)
+ ->addValue('relationship_type_id', 1)
+ ->addValue('start_date', 'now')
+ ->addValue('end_date', 'now + 1 week')
+ ->execute()
+ ->first()['id'];
+ $result = Relationship::get()
+ ->addWhere('start_date', '=', 'now')
+ ->addWhere('end_date', '>', 'now + 1 day')
+ ->execute()
+ ->indexBy('id');
+ $this->assertArrayHasKey($r, $result);
+ $result = Relationship::get()
+ ->addWhere('start_date', '<', 'now')
+ ->execute()
+ ->indexBy('id');
+ $this->assertArrayNotHasKey($r, $result);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/EvaluateConditionTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/EvaluateConditionTest.php
new file mode 100644
index 00000000..3726042f
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/EvaluateConditionTest.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\MockBasicEntity;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class EvaluateConditionTest extends UnitTestCase {
+
+ public function testEvaluateCondition() {
+ $action = MockBasicEntity::get();
+ $reflection = new \ReflectionClass($action);
+ $method = $reflection->getMethod('evaluateCondition');
+ $method->setAccessible(TRUE);
+
+ $data = [
+ 'nada' => 0,
+ 'uno' => 1,
+ 'dos' => 2,
+ 'apple' => 'red',
+ 'banana' => 'yellow',
+ 'values' => ['one' => 1, 'two' => 2, 'three' => 3],
+ ];
+
+ $this->assertFalse($method->invoke($action, '$uno > $dos', $data));
+ $this->assertTrue($method->invoke($action, '$uno < $dos', $data));
+ $this->assertTrue($method->invoke($action, '$apple == "red" && $banana != "red"', $data));
+ $this->assertFalse($method->invoke($action, '$apple == "red" && $banana != "yellow"', $data));
+ $this->assertTrue($method->invoke($action, '$values.one == $uno', $data));
+ $this->assertTrue($method->invoke($action, '$values.one + $dos == $values.three', $data));
+ $this->assertTrue($method->invoke($action, 'empty($nada)', $data));
+ $this->assertFalse($method->invoke($action, 'empty($values)', $data));
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ExtendFromIndividualTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ExtendFromIndividualTest.php
new file mode 100644
index 00000000..2d3be50c
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ExtendFromIndividualTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+
+/**
+ * @group headless
+ */
+class ExtendFromIndividualTest extends BaseCustomValueTest {
+
+ public function testGetWithNonStandardExtends() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Individual') // not Contact
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('display_name')
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId)
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('MyContactFields', $contact);
+ $contactFields = $contact['MyContactFields'];
+ $favColor = $contactFields['FavColor'];
+ $this->assertEquals('Red', $favColor);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/FkJoinTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/FkJoinTest.php
new file mode 100644
index 00000000..c2b044e5
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/FkJoinTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\Activity;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class FkJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_activity',
+ 'civicrm_phone',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('DefaultDataSet');
+
+ return parent::setUpHeadless();
+ }
+
+ /**
+ * Fetch all phone call activities. Expects a single activity
+ * loaded from the data set.
+ */
+ public function testThreeLevelJoin() {
+ $results = Activity::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('activity_type.name', '=', 'Phone Call')
+ ->execute();
+
+ $this->assertCount(1, $results);
+ }
+
+ public function testActivityContactJoin() {
+ $results = Activity::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('assignees.id')
+ ->addSelect('assignees.first_name')
+ ->addSelect('assignees.display_name')
+ ->addWhere('assignees.first_name', '=', 'Phoney')
+ ->execute();
+
+ $firstResult = $results->first();
+
+ $this->assertCount(1, $results);
+ $this->assertTrue(is_array($firstResult['assignees']));
+
+ $firstAssignee = array_shift($firstResult['assignees']);
+ $this->assertEquals($firstAssignee['first_name'], 'Phoney');
+ }
+
+ public function testContactPhonesJoin() {
+ $testContact = $this->getReference('test_contact_1');
+ $testPhone = $this->getReference('test_phone_1');
+
+ $results = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('phones.phone')
+ ->addWhere('id', '=', $testContact['id'])
+ ->addWhere('phones.location_type.name', '=', 'Home')
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('phones', $results);
+ $this->assertCount(1, $results['phones']);
+ $firstPhone = array_shift($results['phones']);
+ $this->assertEquals($testPhone['phone'], $firstPhone['phone']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetExtraFieldsTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetExtraFieldsTest.php
new file mode 100644
index 00000000..bc9b10e7
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetExtraFieldsTest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class GetExtraFieldsTest extends UnitTestCase {
+
+ public function testBAOFieldsWillBeReturned() {
+ $returnedFields = Contact::getFields()
+ ->execute()
+ ->getArrayCopy();
+
+ $baseFields = \CRM_Contact_BAO_Contact::fields();
+ $baseFieldNames = array_column($baseFields, 'name');
+ $returnedFieldNames = array_column($returnedFields, 'name');
+ $notReturned = array_diff($baseFieldNames, $returnedFieldNames);
+
+ $this->assertEmpty($notReturned);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetFromArrayTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetFromArrayTest.php
new file mode 100644
index 00000000..bee6fbf3
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/GetFromArrayTest.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\MockArrayEntity;
+
+/**
+ * @group headless
+ */
+class GetFromArrayTest extends UnitTestCase {
+
+ public function testArrayGetWithLimit() {
+ $result = MockArrayEntity::get()
+ ->setOffset(2)
+ ->setLimit(2)
+ ->execute();
+ $this->assertEquals(3, $result[0]['field1']);
+ $this->assertEquals(4, $result[1]['field1']);
+ $this->assertEquals(2, count($result));
+ }
+
+ public function testArrayGetWithSort() {
+ $result = MockArrayEntity::get()
+ ->addOrderBy('field1', 'DESC')
+ ->execute();
+ $this->assertEquals([5, 4, 3, 2, 1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addOrderBy('field5', 'DESC')
+ ->addOrderBy('field2', 'ASC')
+ ->execute();
+ $this->assertEquals([3, 2, 5, 4, 1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addOrderBy('field3', 'ASC')
+ ->addOrderBy('field2', 'ASC')
+ ->execute();
+ $this->assertEquals([3, 1, 2, 5, 4], array_column((array) $result, 'field1'));
+ }
+
+ public function testArrayGetWithSelect() {
+ $result = MockArrayEntity::get()
+ ->addSelect('field1')
+ ->addSelect('field3')
+ ->setLimit(4)
+ ->execute();
+ $this->assertEquals([
+ [
+ 'field1' => 1,
+ 'field3' => NULL,
+ ],
+ [
+ 'field1' => 2,
+ 'field3' => 0,
+ ],
+ [
+ 'field1' => 3,
+ ],
+ [
+ 'field1' => 4,
+ 'field3' => 1,
+ ],
+ ], (array) $result);
+ }
+
+ public function testArrayGetWithWhere() {
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', '=', 'yack')
+ ->execute();
+ $this->assertEquals([2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field5', '!=', 'banana')
+ ->addWhere('field3', 'IS NOT NULL')
+ ->execute();
+ $this->assertEquals([4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', '>=', '4')
+ ->execute();
+ $this->assertEquals([4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', '<', '2')
+ ->execute();
+ $this->assertEquals([1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'LIKE', '%ra%')
+ ->execute();
+ $this->assertEquals([1, 3], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field3', 'IS NULL')
+ ->execute();
+ $this->assertEquals([1, 3], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field3', '=', '0')
+ ->execute();
+ $this->assertEquals([2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'LIKE', '%ra')
+ ->execute();
+ $this->assertEquals([1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'LIKE', 'ra')
+ ->execute();
+ $this->assertEquals(0, count($result));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'NOT LIKE', '%ra%')
+ ->execute();
+ $this->assertEquals([2, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field6', '=', '0')
+ ->execute();
+ $this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field6', '=', 0)
+ ->execute();
+ $this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', 'BETWEEN', [3, 5])
+ ->execute();
+ $this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', 'NOT BETWEEN', [3, 4])
+ ->execute();
+ $this->assertEquals([1, 2, 5], array_column((array) $result, 'field1'));
+ }
+
+ public function testArrayGetWithNestedWhereClauses() {
+ $result = MockArrayEntity::get()
+ ->addClause('OR', ['field2', 'LIKE', '%ra'], ['field2', 'LIKE', 'x ray'])
+ ->execute();
+ $this->assertEquals([1, 3], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addClause('OR', ['field2', '=', 'zebra'], ['field2', '=', 'yack'])
+ ->addClause('OR', ['field5', '!=', 'apple'], ['field3', 'IS NULL'])
+ ->execute();
+ $this->assertEquals([1, 2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addClause('NOT', ['field2', '!=', 'yack'])
+ ->execute();
+ $this->assertEquals([2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addClause('OR', ['field1', '=', 2], ['AND', [['field5', '=', 'apple'], ['field3', '=', 1]]])
+ ->execute();
+ $this->assertEquals([2, 4, 5], array_column((array) $result, 'field1'));
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/IndexTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/IndexTest.php
new file mode 100644
index 00000000..17356c92
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/IndexTest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class IndexTest extends UnitTestCase {
+
+ public function testIndex() {
+ // Results indexed by name
+ $resultByName = civicrm_api4('Activity', 'getActions', [], 'name');
+ $this->assertInstanceOf('Civi\Api4\Generic\Result', $resultByName);
+ $this->assertEquals('get', $resultByName['get']['name']);
+
+ // Get result at index 0
+ $firstResult = civicrm_api4('Activity', 'getActions', [], 0);
+ $this->assertInstanceOf('Civi\Api4\Generic\Result', $firstResult);
+ $this->assertArrayHasKey('name', $firstResult);
+
+ $this->assertEquals($resultByName->first(), (array) $firstResult);
+ }
+
+ public function testBadIndexInt() {
+ $error = '';
+ try {
+ civicrm_api4('Activity', 'getActions', [], 99);
+ }
+ catch (\API_Exception $e) {
+ $error = $e->getMessage();
+ }
+ $this->assertContains('not found', $error);
+ }
+
+ public function testBadIndexString() {
+ $error = '';
+ try {
+ civicrm_api4('Activity', 'getActions', [], 'xyz');
+ }
+ catch (\API_Exception $e) {
+ $error = $e->getMessage();
+ }
+ $this->assertContains('not found', $error);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/NullValueTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/NullValueTest.php
new file mode 100644
index 00000000..dc4f656a
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/NullValueTest.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class NullValueTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $format = '{contact.first_name}{ }{contact.last_name}';
+ \Civi::settings()->set('display_name_format', $format);
+ return parent::setUpHeadless();
+ }
+
+ public function testStringNull() {
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Joseph')
+ ->addValue('last_name', 'null')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first();
+
+ $this->assertSame('Null', $contact['last_name']);
+ $this->assertSame('Joseph Null', $contact['display_name']);
+ }
+
+ public function testSettingToNull() {
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'ILoveMy')
+ ->addValue('last_name', 'LastName')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first();
+
+ $this->assertSame('ILoveMy LastName', $contact['display_name']);
+ $contactId = $contact['id'];
+
+ $contact = Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contactId)
+ ->addValue('last_name', NULL)
+ ->execute()
+ ->first();
+
+ $this->assertSame(NULL, $contact['last_name']);
+ $this->assertSame('ILoveMy', $contact['display_name']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ReplaceTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ReplaceTest.php
new file mode 100644
index 00000000..097e12b0
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/ReplaceTest.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\CustomValue;
+use Civi\Api4\Email;
+use Civi\Test\Api4\Traits\TableDropperTrait;
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class ReplaceTest extends UnitTestCase {
+ use TableDropperTrait;
+
+ /**
+ * Set up baseline for testing
+ */
+ public function setUp() {
+ $tablesToTruncate = [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ 'civicrm_email',
+ ];
+ $this->dropByPrefix('civicrm_value_replacetest');
+ $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
+ parent::setUp();
+ }
+
+ public function testEmailReplace() {
+ $cid1 = Contact::create()
+ ->addValue('first_name', 'Lotsa')
+ ->addValue('last_name', 'Emails')
+ ->execute()
+ ->first()['id'];
+ $cid2 = Contact::create()
+ ->addValue('first_name', 'Notso')
+ ->addValue('last_name', 'Many')
+ ->execute()
+ ->first()['id'];
+ $e0 = Email::create()
+ ->setValues(['contact_id' => $cid2, 'email' => 'nosomany@example.com', 'location_type_id' => 1])
+ ->execute()
+ ->first()['id'];
+ $e1 = Email::create()
+ ->setValues(['contact_id' => $cid1, 'email' => 'first@example.com', 'location_type_id' => 1])
+ ->execute()
+ ->first()['id'];
+ $e2 = Email::create()
+ ->setValues(['contact_id' => $cid1, 'email' => 'second@example.com', 'location_type_id' => 1])
+ ->execute()
+ ->first()['id'];
+ $replacement = [
+ ['email' => 'firstedited@example.com', 'id' => $e1],
+ ['contact_id' => $cid1, 'email' => 'third@example.com', 'location_type_id' => 1]
+ ];
+ $replaced = Email::replace()
+ ->setRecords($replacement)
+ ->addWhere('contact_id', '=', $cid1)
+ ->execute();
+ // Should have saved 2 records
+ $this->assertEquals(2, $replaced->count());
+ // Should have deleted email2
+ $this->assertEquals([$e2], $replaced->deleted);
+ // Verify contact now has the new email records
+ $results = Email::get()
+ ->addWhere('contact_id', '=', $cid1)
+ ->execute()
+ ->indexBy('id');
+ $this->assertEquals('firstedited@example.com', $results[$e1]['email']);
+ $this->assertEquals(2, $results->count());
+ $this->assertArrayNotHasKey($e2, (array) $results);
+ $this->assertArrayNotHasKey($e0, (array) $results);
+ unset($results[$e1]);
+ foreach ($results as $result) {
+ $this->assertEquals('third@example.com', $result['email']);
+ }
+ // Validate our other contact's email did not get deleted
+ $c2email = Email::get()
+ ->addWhere('contact_id', '=', $cid2)
+ ->execute()
+ ->first();
+ $this->assertEquals('nosomany@example.com', $c2email['email']);
+ }
+
+ public function testCustomValueReplace() {
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'replaceTest')
+ ->addValue('extends', 'Contact')
+ ->addValue('is_multiple', TRUE)
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->addValue('label', 'Custom1')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'String')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Custom2')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'String')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $cid1 = Contact::create()
+ ->addValue('first_name', 'Lotsa')
+ ->addValue('last_name', 'Data')
+ ->execute()
+ ->first()['id'];
+ $cid2 = Contact::create()
+ ->addValue('first_name', 'Notso')
+ ->addValue('last_name', 'Much')
+ ->execute()
+ ->first()['id'];
+
+ // Contact 2 gets one row
+ CustomValue::create('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addValue('Custom1', "2 1")
+ ->addValue('Custom2', "2 1")
+ ->addValue('entity_id', $cid2)
+ ->execute();
+
+ // Create 3 rows for contact 1
+ foreach ([1, 2, 3] as $i) {
+ CustomValue::create('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addValue('Custom1', "1 $i")
+ ->addValue('Custom2', "1 $i")
+ ->addValue('entity_id', $cid1)
+ ->execute();
+ }
+
+ $cid1Records = CustomValue::get('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addWhere('entity_id', '=', $cid1)
+ ->execute();
+
+ $this->assertCount(3, $cid1Records);
+ $this->assertCount(1, CustomValue::get('replaceTest')->setCheckPermissions(FALSE)->addWhere('entity_id', '=', $cid2)->execute());
+
+ $result = CustomValue::replace('replaceTest')
+ ->addWhere('entity_id', '=', $cid1)
+ ->addRecord(['Custom1' => 'new one', 'Custom2' => 'new two'])
+ ->addRecord(['id' => $cid1Records[0]['id'], 'Custom1' => 'changed one', 'Custom2' => 'changed two'])
+ ->execute();
+
+ $this->assertCount(2, $result);
+ $this->assertCount(2, $result->deleted);
+
+ $newRecords = CustomValue::get('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addWhere('entity_id', '=', $cid1)
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals('new one', $newRecords->last()['Custom1']);
+ $this->assertEquals('new two', $newRecords->last()['Custom2']);
+ $this->assertEquals('changed one', $newRecords[$cid1Records[0]['id']]['Custom1']);
+ $this->assertEquals('changed two', $newRecords[$cid1Records[0]['id']]['Custom2']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateContactTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateContactTest.php
new file mode 100644
index 00000000..71265598
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateContactTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * Class UpdateContactTest
+ * @package Civi\Test\Api4\Action
+ * @group headless
+ */
+class UpdateContactTest extends UnitTestCase {
+
+ public function testUpdateWillWork() {
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contactId)
+ ->addValue('first_name', 'Testy')
+ ->execute()
+ ->first();
+ $this->assertEquals('Testy', $contact['first_name']);
+ $this->assertEquals('Tester', $contact['last_name']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateCustomValueTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateCustomValueTest.php
new file mode 100644
index 00000000..99a9f011
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Action/UpdateCustomValueTest.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Civi\Test\Api4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use \CRM_Core_BAO_CustomValueTable as CustomValueTable;
+
+/**
+ * @group headless
+ */
+class UpdateCustomValueTest extends BaseCustomValueTest {
+
+ public function testGetWithCustomData() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->execute()
+ ->first()['id'];
+
+ Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contactId)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Blue')
+ ->execute();
+
+ $result = CustomValueTable::getEntityValues($contactId, 'Contact');
+
+ $this->assertEquals(1, count($result));
+ $this->assertContains('Blue', $result);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/ConformanceTest.json b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/ConformanceTest.json
new file mode 100644
index 00000000..fcaf8966
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/ConformanceTest.json
@@ -0,0 +1,28 @@
+{
+ "Contact": [
+ {
+ "first_name": "Janice",
+ "last_name": "Voss",
+ "contact_type": "Individual"
+ }
+ ],
+ "CustomGroup": [
+ {
+ "name": "MyFavoriteThings",
+ "extends": "Contact"
+ }
+ ],
+ "Event": [
+ {
+ "start_date": "20401010000000",
+ "title": "The Singularity",
+ "event_type_id": "major_historical_event"
+ }
+ ],
+ "Group": [
+ {
+ "name": "the_group",
+ "title": "The Group"
+ }
+ ]
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/DefaultDataSet.json b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/DefaultDataSet.json
new file mode 100644
index 00000000..7d4a91bc
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/DefaultDataSet.json
@@ -0,0 +1,45 @@
+{
+ "Contact": [
+ {
+ "first_name": "Phoney",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "@ref": "test_contact_1"
+ },
+ {
+ "first_name": "Second",
+ "last_name": "Test",
+ "contact_type": "Individual",
+ "@ref": "test_contact_2"
+ }
+ ],
+ "Activity": [
+ {
+ "subject": "Test Phone Activity",
+ "activity_type": "Phone Call",
+ "source_contact_id": "@ref test_contact_1.id"
+ },
+ {
+ "subject": "Another Activity",
+ "activity_type": "Meeting",
+ "source_contact_id": "@ref test_contact_1.id",
+ "assignee_contact_id": [
+ "@ref test_contact_1.id",
+ "@ref test_contact_2.id"
+ ]
+ }
+ ],
+ "Phone": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+35355439483",
+ "location_type_id": "1",
+ "@ref": "test_phone_1"
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+3538733439483",
+ "location_type_id": "2"
+ }
+ ]
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/MultiContactMultiEmail.json b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/MultiContactMultiEmail.json
new file mode 100644
index 00000000..ce3fbcaf
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/MultiContactMultiEmail.json
@@ -0,0 +1,42 @@
+{
+ "Contact": [
+ {
+ "first_name": "First",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "@ref": "test_contact_1"
+ },
+ {
+ "first_name": "Second",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "@ref": "test_contact_2"
+ }
+ ],
+ "Email": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_one_home@fakedomain.com",
+ "location_type_id": 1,
+ "@ref": "test_email_1"
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_one_work@fakedomain.com",
+ "location_type_id": 2,
+ "@ref": "test_email_2"
+ },
+ {
+ "contact_id": "@ref test_contact_2.id",
+ "email": "test_contact_two_home@fakedomain.com",
+ "location_type_id": 1,
+ "@ref": "test_email_3"
+ },
+ {
+ "contact_id": "@ref test_contact_2.id",
+ "email": "test_contact_two_work@fakedomain.com",
+ "location_type_id": 2,
+ "@ref": "test_email_4"
+ }
+ ]
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/SingleContact.json b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/SingleContact.json
new file mode 100644
index 00000000..73e7369e
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/DataSets/SingleContact.json
@@ -0,0 +1,81 @@
+{
+ "Contact": [
+ {
+ "first_name": "Single",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "preferred_communication_method": "1",
+ "@ref": "test_contact_1"
+ }
+ ],
+ "Activity": [
+ {
+ "subject": "Won A Nobel Prize",
+ "activity_type": "Meeting",
+ "source_contact_id": "@ref test_contact_1.id",
+ "@ref": "test_activity_1"
+ },
+ {
+ "subject": "Cleaned The House",
+ "activity_type": "Meeting",
+ "source_contact_id": "@ref test_contact_1.id",
+ "assignee_contact_id": [
+ "@ref test_contact_1.id"
+ ],
+ "@ref": "test_activity_2"
+ }
+ ],
+ "Phone": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+1111111111111",
+ "location_type_id": 1
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+2222222222222",
+ "location_type_id": 2
+ }
+ ],
+ "Email": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_home@fakedomain.com",
+ "location_type_id": 1
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_work@fakedomain.com",
+ "location_type_id": 2
+ }
+ ],
+ "Address": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "street_address": "123 Sesame St.",
+ "location_type_id": 1
+ }
+ ],
+ "Website": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "url": "http://test.com",
+ "website_id": 1
+ }
+ ],
+ "OpenID": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "openid": "123",
+ "allowed_to_login": 1,
+ "location_type_id": 1
+ }
+ ],
+ "IM": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "name": "123",
+ "location_type_id": 1
+ }
+ ]
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ConformanceTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ConformanceTest.php
new file mode 100644
index 00000000..79fe5b30
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ConformanceTest.php
@@ -0,0 +1,226 @@
+<?php
+
+namespace Civi\Test\Api4\Entity;
+
+use Civi\Api4\Generic\AbstractEntity;
+use Civi\Api4\Entity;
+use Civi\Test\Api4\Service\TestCreationParameterProvider;
+use Civi\Test\Api4\Traits\TableDropperTrait;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ConformanceTest extends UnitTestCase {
+
+ use TableDropperTrait;
+ use \Civi\Test\Api4\Traits\OptionCleanupTrait {
+ setUp as setUpOptionCleanup;
+ }
+
+ /**
+ * @var TestCreationParameterProvider
+ */
+ protected $creationParamProvider;
+
+ /**
+ * Set up baseline for testing
+ */
+ public function setUp() {
+ $tablesToTruncate = [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ 'civicrm_group',
+ 'civicrm_event',
+ 'civicrm_participant',
+ ];
+ $this->dropByPrefix('civicrm_value_myfavorite');
+ $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
+ $this->setUpOptionCleanup();
+ $this->loadDataSet('ConformanceTest');
+ $this->creationParamProvider = \Civi::container()->get('test.param_provider');
+ parent::setUp();
+ // calculateTaxAmount() for contribution triggers a deprecation notice
+ \PHPUnit_Framework_Error_Deprecated::$enabled = FALSE;
+ }
+
+ public function getEntities() {
+ $result = [];
+ $entities = Entity::get()->setCheckPermissions(FALSE)->execute();
+ foreach ($entities as $entity) {
+ if ($entity['name'] != 'Entity') {
+ $result[] = [$entity['name']];
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Fixme: This should use getEntities as a dataProvider but that fails for some reason
+ */
+ public function testConformance() {
+ $entities = $this->getEntities();
+ $this->assertNotEmpty($entities);
+
+ foreach ($entities as $data) {
+ $entity = $data[0];
+ $entityClass = 'Civi\Api4\\' . $entity;
+
+ $this->checkActions($entityClass);
+ $this->checkFields($entityClass, $entity);
+ $id = $this->checkCreation($entity, $entityClass);
+ $this->checkGet($entityClass, $id, $entity);
+ $this->checkUpdateFailsFromCreate($entityClass, $id);
+ $this->checkWrongParamType($entityClass);
+ $this->checkDeleteWithNoId($entityClass);
+ $this->checkDeletion($entityClass, $id);
+ $this->checkPostDelete($entityClass, $id, $entity);
+ }
+ }
+
+ /**
+ * @param string $entityClass
+ * @param $entity
+ */
+ protected function checkFields($entityClass, $entity) {
+ $fields = $entityClass::getFields()
+ ->setCheckPermissions(FALSE)
+ ->setIncludeCustom(FALSE)
+ ->execute()
+ ->indexBy('name');
+
+ $errMsg = sprintf('%s is missing required ID field', $entity);
+ $subset = ['data_type' => 'Integer'];
+
+ $this->assertArraySubset($subset, $fields['id'], $errMsg);
+ }
+
+ /**
+ * @param string $entityClass
+ */
+ protected function checkActions($entityClass) {
+ $actions = $entityClass::getActions()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertNotEmpty($actions->getArrayCopy());
+ }
+
+ /**
+ * @param string $entity
+ * @param AbstractEntity|string $entityClass
+ *
+ * @return mixed
+ */
+ protected function checkCreation($entity, $entityClass) {
+ $requiredParams = $this->creationParamProvider->getRequired($entity);
+ $createResult = $entityClass::create()
+ ->setValues($requiredParams)
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('id', $createResult, "create missing ID");
+ $id = $createResult['id'];
+
+ $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");
+
+ return $id;
+ }
+
+ /**
+ * @param AbstractEntity|string $entityClass
+ * @param int $id
+ */
+ protected function checkUpdateFailsFromCreate($entityClass, $id) {
+ $exceptionThrown = '';
+ try {
+ $entityClass::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('id', $id)
+ ->execute();
+ }
+ catch (\API_Exception $e) {
+ $exceptionThrown = $e->getMessage();
+ }
+ $this->assertContains('id', $exceptionThrown);
+ }
+
+ /**
+ * @param AbstractEntity|string $entityClass
+ * @param int $id
+ * @param string $entity
+ */
+ protected function checkGet($entityClass, $id, $entity) {
+ $getResult = $entityClass::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->execute();
+
+ $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
+ $this->assertEquals(1, count($getResult), $errMsg);
+ }
+
+ /**
+ * @param AbstractEntity|string $entityClass
+ */
+ protected function checkDeleteWithNoId($entityClass) {
+ $exceptionThrown = '';
+ try {
+ $entityClass::delete()
+ ->execute();
+ }
+ catch (\API_Exception $e) {
+ $exceptionThrown = $e->getMessage();
+ }
+ $this->assertContains('required', $exceptionThrown);
+ }
+
+ /**
+ * @param AbstractEntity|string $entityClass
+ */
+ protected function checkWrongParamType($entityClass) {
+ $exceptionThrown = '';
+ try {
+ $entityClass::get()
+ ->setCheckPermissions('nada')
+ ->execute();
+ }
+ catch (\API_Exception $e) {
+ $exceptionThrown = $e->getMessage();
+ }
+ $this->assertContains('checkPermissions', $exceptionThrown);
+ $this->assertContains('type', $exceptionThrown);
+ }
+
+ /**
+ * @param AbstractEntity|string $entityClass
+ * @param int $id
+ */
+ protected function checkDeletion($entityClass, $id) {
+ $deleteResult = $entityClass::delete()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->execute();
+
+ // should get back an array of deleted id
+ $this->assertEquals([$id], (array) $deleteResult);
+ }
+
+ /**
+ * @param AbstractEntity|string $entityClass
+ * @param int $id
+ * @param string $entity
+ */
+ protected function checkPostDelete($entityClass, $id, $entity) {
+ $getDeletedResult = $entityClass::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->execute();
+
+ $errMsg = sprintf('Entity "%s" was not deleted', $entity);
+ $this->assertEquals(0, count($getDeletedResult), $errMsg);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ContactJoinTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ContactJoinTest.php
new file mode 100644
index 00000000..392e0466
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ContactJoinTest.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Civi\Test\Api4\Entity;
+
+use Civi\Api4\Contact;
+use Civi\Api4\OptionValue;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ContactJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('SingleContact');
+
+ return parent::setUpHeadless();
+ }
+
+ public function testContactJoin() {
+
+ $contact = $this->getReference('test_contact_1');
+ $entitiesToTest = ['Address', 'OpenID', 'IM', 'Website', 'Email', 'Phone'];
+
+ foreach ($entitiesToTest as $entity) {
+ $results = civicrm_api4($entity, 'get', [
+ 'where' => [['contact_id', '=', $contact['id']]],
+ 'select' => ['contact.display_name', 'contact.id'],
+ ]);
+ foreach ($results as $result) {
+ $this->assertEquals($contact['id'], $result['contact']['id']);
+ $this->assertEquals($contact['display_name'], $result['contact']['display_name']);
+ }
+ }
+ }
+
+ public function testJoinToPCMWillReturnArray() {
+ $contact = Contact::create()->setValues([
+ 'preferred_communication_method' => [1, 2, 3],
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Test',
+ 'last_name' => 'PCM',
+ ])->execute()->first();
+
+ $fetchedContact = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('preferred_communication_method')
+ ->execute()
+ ->first();
+
+ $this->assertCount(3, $fetchedContact["preferred_communication_method"]);
+ }
+
+ public function testJoinToPCMOptionValueWillShowLabel() {
+ $options = OptionValue::get()
+ ->addWhere('option_group.name', '=', 'preferred_communication_method')
+ ->execute()
+ ->getArrayCopy();
+
+ $optionValues = array_column($options, 'value');
+ $labels = array_column($options, 'label');
+
+ $contact = Contact::create()->setValues([
+ 'preferred_communication_method' => $optionValues,
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Test',
+ 'last_name' => 'PCM',
+ ])->execute()->first();
+
+ $contact2 = Contact::create()->setValues([
+ 'preferred_communication_method' => $optionValues,
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Test',
+ 'last_name' => 'PCM2',
+ ])->execute()->first();
+
+ $contactIds = array_column([$contact, $contact2], 'id');
+
+ $fetchedContact = Contact::get()
+ ->addWhere('id', 'IN', $contactIds)
+ ->addSelect('preferred_communication_method.label')
+ ->execute()
+ ->first();
+
+ $preferredMethod = $fetchedContact['preferred_communication_method'];
+ $returnedLabels = array_column($preferredMethod, 'label');
+
+ $this->assertEquals($labels, $returnedLabels);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/EntityTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/EntityTest.php
new file mode 100644
index 00000000..8f7ecd00
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/EntityTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Civi\Test\Api4\Entity;
+
+use Civi\Api4\Entity;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class EntityTest extends UnitTestCase {
+
+ public function testEntityGet() {
+ $result = Entity::get()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+ $this->assertArrayHasKey('Entity', $result,
+ "Entity::get missing itself");
+ $this->assertArrayHasKey('Participant', $result,
+ "Entity::get missing Participant");
+ }
+
+ public function testEntity() {
+ $result = Entity::getActions()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+ $this->assertNotContains(
+ 'create',
+ array_keys((array) $result),
+ "Entity entity has more than basic actions");
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ParticipantTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ParticipantTest.php
new file mode 100644
index 00000000..8236efb0
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Entity/ParticipantTest.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Civi\Test\Api4\Entity;
+
+use Civi\Api4\Participant;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ParticipantTest extends UnitTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ $cleanup_params = [
+ 'tablesToTruncate' => [
+ 'civicrm_event',
+ 'civicrm_participant',
+ ],
+ ];
+ $this->cleanup($cleanup_params);
+ }
+
+ public function testGetActions() {
+ $result = Participant::getActions()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+
+ $getParams = $result['get']['params'];
+ $whereDescription = 'Criteria for selecting items.';
+
+ $this->assertEquals(TRUE, $getParams['checkPermissions']['default']);
+ $this->assertEquals($whereDescription, $getParams['where']['description']);
+ }
+
+ public function testGet() {
+ $rows = $this->getRowCount('civicrm_participant');
+ if ($rows > 0) {
+ $this->markTestSkipped('Participant table must be empty');
+ }
+
+ // With no records:
+ $result = Participant::get()->setCheckPermissions(FALSE)->execute();
+ $this->assertEquals(0, $result->count(), "count of empty get is not 0");
+
+ // Check that the $result knows what the inputs were
+ $this->assertEquals('Participant', $result->entity);
+ $this->assertEquals('get', $result->action);
+ $this->assertEquals(4, $result->version);
+
+ // Create some test related records before proceeding
+ $participantCount = 20;
+ $contactCount = 7;
+ $eventCount = 5;
+
+ // All events will either have this number or one less because of the
+ // rotating participation creation method.
+ $expectedFirstEventCount = ceil($participantCount / $eventCount);
+
+ $dummy = [
+ 'contacts' => $this->createEntity([
+ 'type' => 'Individual',
+ 'count' => $contactCount,
+ 'seq' => 1]),
+ 'events' => $this->createEntity([
+ 'type' => 'Event',
+ 'count' => $eventCount,
+ 'seq' => 1]),
+ 'sources' => ['Paddington', 'Springfield', 'Central'],
+ ];
+
+ // - create dummy participants record
+ for ($i = 0; $i < $participantCount; $i++) {
+ $dummy['participants'][$i] = $this->sample([
+ 'type' => 'Participant',
+ 'overrides' => [
+ 'event_id' => $dummy['events'][$i % $eventCount]['id'],
+ 'contact_id' => $dummy['contacts'][$i % $contactCount]['id'],
+ 'source' => $dummy['sources'][$i % 3], // 3 = number of sources
+ ]])['sample_params'];
+
+ Participant::create()
+ ->setValues($dummy['participants'][$i])
+ ->setCheckPermissions(FALSE)
+ ->execute();
+ }
+ $sqlCount = $this->getRowCount('civicrm_participant');
+ $this->assertEquals($participantCount, $sqlCount, "Unexpected count");
+
+ $firstEventId = $dummy['events'][0]['id'];
+ $secondEventId = $dummy['events'][1]['id'];
+ $firstContactId = $dummy['contacts'][0]['id'];
+
+ $firstOnlyResult = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('AND', ['event_id', '=', $firstEventId])
+ ->execute();
+
+ $this->assertEquals($expectedFirstEventCount, count($firstOnlyResult),
+ "count of first event is not $expectedFirstEventCount");
+
+ // get first two events using different methods
+ $firstTwo = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('event_id', 'IN', [$firstEventId, $secondEventId])
+ ->execute();
+
+ $firstResult = $result->first();
+
+ // verify counts
+ // count should either twice the first event count or one less
+ $this->assertLessThanOrEqual(
+ $expectedFirstEventCount * 2,
+ count($firstTwo),
+ "count is too high"
+ );
+
+ $this->assertGreaterThanOrEqual(
+ $expectedFirstEventCount * 2 - 1,
+ count($firstTwo),
+ "count is too low"
+ );
+
+ $firstParticipantResult = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('event_id', '=', $firstEventId)
+ ->addWhere('contact_id', '=', $firstContactId)
+ ->execute();
+
+ $this->assertEquals(1, count($firstParticipantResult), "more than one registration");
+
+ $firstParticipantId = $firstParticipantResult->first()['id'];
+
+ // get a result which excludes $first_participant
+ $otherParticipantResult = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->setSelect(['id'])
+ ->addClause('NOT', [
+ ['event_id', '=', $firstEventId],
+ ['contact_id', '=', $firstContactId],
+ ]
+ )
+ ->execute()
+ ->indexBy('id');
+
+ // check alternate syntax for NOT
+ $otherParticipantResult2 = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->setSelect(['id'])
+ ->addClause('NOT', 'AND', [
+ ['event_id', '=', $firstEventId],
+ ['contact_id', '=', $firstContactId],
+ ]
+ )
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals($otherParticipantResult, $otherParticipantResult2);
+
+ $this->assertEquals($participantCount - 1,
+ count($otherParticipantResult),
+ "failed to exclude a single record on complex criteria");
+ // check the record we have excluded is the right one:
+
+ $this->assertFalse(
+ $otherParticipantResult->offsetExists($firstParticipantId),
+ 'excluded wrong record');
+
+ // retrieve a participant record and update some records
+ $patchRecord = [
+ 'source' => "not " . $firstResult['source'],
+ ];
+
+ Participant::update()
+ ->addWhere('event_id', '=', $firstEventId)
+ ->setCheckPermissions(FALSE)
+ ->setLimit(20)
+ ->setValues($patchRecord)
+ ->setCheckPermissions(FALSE)
+ ->execute();
+
+ // - delete some records
+ $secondEventId = $dummy['events'][1]['id'];
+ $deleteResult = Participant::delete()
+ ->addWhere('event_id', '=', $secondEventId)
+ ->setCheckPermissions(FALSE)
+ ->execute();
+ $expectedDeletes = [2, 7, 12, 17];
+ $this->assertEquals($expectedDeletes, (array) $deleteResult,
+ "didn't delete every second record as expected");
+
+ $sqlCount = $this->getRowCount('civicrm_participant');
+ $this->assertEquals(
+ $participantCount - count($expectedDeletes),
+ $sqlCount,
+ "records not gone from database after delete");
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/Action/MockArrayEntity/Get.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/Action/MockArrayEntity/Get.php
new file mode 100644
index 00000000..f276baf6
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/Action/MockArrayEntity/Get.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Civi\Api4\Action\MockArrayEntity;
+
+/**
+ * This class demonstrates how the getRecords method of Basic\Get can be overridden.
+ */
+class Get extends \Civi\Api4\Generic\BasicGetAction {
+
+ public function getRecords() {
+ return [
+ [
+ 'field1' => 1,
+ 'field2' => 'zebra',
+ 'field3' => NULL,
+ 'field4' => [1, 2, 3],
+ 'field5' => 'apple',
+ ],
+ [
+ 'field1' => 2,
+ 'field2' => 'yack',
+ 'field3' => 0,
+ 'field4' => [2, 3, 4],
+ 'field5' => 'banana',
+ 'field6' => '',
+ ],
+ [
+ 'field1' => 3,
+ 'field2' => 'x ray',
+ 'field4' => [3, 4, 5],
+ 'field5' => 'banana',
+ 'field6' => 0,
+ ],
+ [
+ 'field1' => 4,
+ 'field2' => 'wildebeest',
+ 'field3' => 1,
+ 'field4' => [4, 5, 6],
+ 'field5' => 'apple',
+ 'field6' => '0',
+ ],
+ [
+ 'field1' => 5,
+ 'field2' => 'vole',
+ 'field3' => 1,
+ 'field4' => [4, 5, 6],
+ 'field5' => 'apple',
+ 'field6' => 0,
+ ],
+ ];
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockArrayEntity.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockArrayEntity.php
new file mode 100644
index 00000000..371df50e
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockArrayEntity.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Civi\Api4;
+use Civi\Api4\Generic\BasicGetFieldsAction;
+
+/**
+ * MockArrayEntity entity.
+ *
+ * @method Generic\BasicGetAction get()
+ *
+ * @package Civi\Api4
+ */
+class MockArrayEntity extends Generic\AbstractEntity {
+
+ public static function getFields() {
+ return new BasicGetFieldsAction(static::class, __FUNCTION__, function() {
+ return [];
+ });
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockBasicEntity.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockBasicEntity.php
new file mode 100644
index 00000000..aed11e45
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/Api4/MockBasicEntity.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * MockBasicEntity entity.
+ *
+ * @package Civi\Api4
+ */
+class MockBasicEntity extends Generic\AbstractEntity {
+
+ const STORAGE_CLASS = '\\Civi\\Test\\Api4\\Mock\\MockEntityDataStorage';
+
+ /**
+ * @return Generic\BasicGetFieldsAction
+ */
+ public static function getFields() {
+ return new Generic\BasicGetFieldsAction(static::class, __FUNCTION__, function() {
+ return [
+ [
+ 'name' => 'id',
+ 'type' => 'Integer',
+ ],
+ [
+ 'name' => 'group',
+ 'options' => [
+ 'one' => 'One',
+ 'two' => 'Two',
+ ]
+ ],
+ [
+ 'name' => 'color',
+ ],
+ [
+ 'name' => 'shape',
+ ],
+ [
+ 'name' => 'size',
+ ],
+ [
+ 'name' => 'weight',
+ ],
+ ];
+ });
+ }
+
+ /**
+ * @return Generic\BasicGetAction
+ */
+ public static function get() {
+ return new Generic\BasicGetAction('MockBasicEntity', __FUNCTION__, [self::STORAGE_CLASS, 'get']);
+ }
+
+ /**
+ * @return Generic\BasicCreateAction
+ */
+ public static function create() {
+ return new Generic\BasicCreateAction(static::class, __FUNCTION__, [self::STORAGE_CLASS, 'write']);
+ }
+
+ /**
+ * @return Generic\BasicUpdateAction
+ */
+ public static function update() {
+ return new Generic\BasicUpdateAction(self::getEntityName(), __FUNCTION__, 'id', [self::STORAGE_CLASS, 'write']);
+ }
+
+ /**
+ * @return Generic\BasicBatchAction
+ */
+ public static function delete() {
+ return new Generic\BasicBatchAction('MockBasicEntity', __FUNCTION__, 'id', [self::STORAGE_CLASS, 'delete']);
+ }
+
+ /**
+ * @return Generic\BasicBatchAction
+ */
+ public static function batchFrobnicate() {
+ return new Generic\BasicBatchAction('MockBasicEntity', __FUNCTION__, ['id', 'number'], function ($item) {
+ return [
+ 'id' => $item['id'],
+ 'frobnication' => $item['number'] * $item['number'],
+ ];
+ });
+ }
+
+ /**
+ * @return Generic\BasicReplaceAction
+ */
+ public static function replace() {
+ return new Generic\BasicReplaceAction('MockBasicEntity', __FUNCTION__);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockEntityDataStorage.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockEntityDataStorage.php
new file mode 100644
index 00000000..fbb10465
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockEntityDataStorage.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Civi\Test\Api4\Mock;
+
+/**
+ * Simple data backend for mock basic api.
+ */
+class MockEntityDataStorage {
+
+ private static $data = [];
+
+ private static $nextId = 1;
+
+ public static function get() {
+ return self::$data;
+ }
+
+ public static function write($record) {
+ if (empty($record['id'])) {
+ $record['id'] = self::$nextId++;
+ }
+ self::$data[$record['id']] = $record;
+ return $record;
+ }
+
+ public static function delete($record) {
+ unset(self::$data[$record['id']]);
+ return $record;
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionBase.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionBase.php
new file mode 100644
index 00000000..e46272d2
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionBase.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Civi\Test\Api4\Mock;
+
+/**
+ * Class TestV4ReflectionBase
+ *
+ * This is the base class.
+ *
+ * @internal
+ */
+class MockV4ReflectionBase {
+ /**
+ * This is the foo property.
+ *
+ * In general, you can do nothing with it.
+ *
+ * @var array
+ */
+ public $foo = [];
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionChild.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionChild.php
new file mode 100644
index 00000000..83966b5c
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionChild.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Civi\Test\Api4\Mock;
+
+/**
+ * @inheritDoc
+ */
+class MockV4ReflectionChild extends MockV4ReflectionBase {
+ /**
+ * @inheritDoc
+ *
+ * In the child class, foo has been barred.
+ */
+ public $foo = ['bar' => 1];
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionGrandchild.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionGrandchild.php
new file mode 100644
index 00000000..a2a93a4f
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Mock/MockV4ReflectionGrandchild.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Civi\Test\Api4\Mock;
+
+
+/**
+ * Grandchild class
+ *
+ * This is an extended description.
+ *
+ * There is a line break in this description.
+ *
+ * @inheritdoc
+ */
+class MockV4ReflectionGrandchild extends MockV4ReflectionChild {
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryComplexJoinTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryComplexJoinTest.php
new file mode 100644
index 00000000..0e68843a
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryComplexJoinTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Civi\Test\Api4\Query;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class Api4SelectQueryComplexJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('SingleContact');
+ return parent::setUpHeadless();
+ }
+
+ public function testWithComplexRelatedEntitySelect() {
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->select[] = 'id';
+ $query->select[] = 'display_name';
+ $query->select[] = 'phones.phone';
+ $query->select[] = 'emails.email';
+ $query->select[] = 'emails.location_type.name';
+ $query->select[] = 'created_activities.contact_id';
+ $query->select[] = 'created_activities.activity.subject';
+ $query->select[] = 'created_activities.activity.activity_type.name';
+ $query->where[] = ['first_name', '=', 'Single'];
+ $query->where[] = ['id', '=', $this->getReference('test_contact_1')['id']];
+ $results = $query->run();
+
+ $testActivities = [
+ $this->getReference('test_activity_1'),
+ $this->getReference('test_activity_2'),
+ ];
+ $activitySubjects = array_column($testActivities, 'subject');
+
+ $this->assertCount(1, $results);
+ $firstResult = array_shift($results);
+ $this->assertArrayHasKey('created_activities', $firstResult);
+ $firstCreatedActivity = array_shift($firstResult['created_activities']);
+ $this->assertArrayHasKey('activity', $firstCreatedActivity);
+ $firstActivity = $firstCreatedActivity['activity'];
+ $this->assertContains($firstActivity['subject'], $activitySubjects);
+ $this->assertArrayHasKey('activity_type', $firstActivity);
+ $activityType = $firstActivity['activity_type'];
+ $this->assertArrayHasKey('name', $activityType);
+ }
+
+ public function testWithSelectOfOrphanDeepValues() {
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ $query->select[] = 'emails.location_type.name'; // emails not selected
+ $results = $query->run();
+ $firstResult = array_shift($results);
+
+ $this->assertEmpty($firstResult['emails']);
+ }
+
+ public function testOrderDoesNotMatter() {
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ $query->select[] = 'emails.location_type.name'; // before emails selection
+ $query->select[] = 'emails.email';
+ $query->where[] = ['emails.email', 'IS NOT NULL'];
+ $results = $query->run();
+ $firstResult = array_shift($results);
+
+ $this->assertNotEmpty($firstResult['emails'][0]['location_type']['name']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryTest.php
new file mode 100644
index 00000000..ca9a5851
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/Api4SelectQueryTest.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Civi\Test\Api4\Query;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class Api4SelectQueryTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('DefaultDataSet');
+ $displayNameFormat = '{contact.first_name}{ }{contact.last_name}';
+ \Civi::settings()->set('display_name_format', $displayNameFormat);
+
+ return parent::setUpHeadless();
+ }
+
+ public function testWithSingleWhereJoin() {
+ $phoneNum = $this->getReference('test_phone_1')['phone'];
+
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->where[] = ['phones.phone', '=', $phoneNum];
+ $results = $query->run();
+
+ $this->assertCount(1, $results);
+ }
+
+ public function testOneToManyJoin() {
+ $phoneNum = $this->getReference('test_phone_1')['phone'];
+
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ $query->select[] = 'phones.phone';
+ $query->where[] = ['phones.phone', '=', $phoneNum];
+ $results = $query->run();
+
+ $this->assertCount(1, $results);
+ $firstResult = array_shift($results);
+ $this->assertArrayHasKey('phones', $firstResult);
+ $firstPhone = array_shift($firstResult['phones']);
+ $this->assertEquals($phoneNum, $firstPhone['phone']);
+ }
+
+ public function testManyToOneJoin() {
+ $phoneNum = $this->getReference('test_phone_1')['phone'];
+ $contact = $this->getReference('test_contact_1');
+
+ $query = new Api4SelectQuery('Phone', FALSE);
+ $query->select[] = 'id';
+ $query->select[] = 'phone';
+ $query->select[] = 'contact.display_name';
+ $query->select[] = 'contact.first_name';
+ $query->where[] = ['phone', '=', $phoneNum];
+ $results = $query->run();
+
+ $this->assertCount(1, $results);
+ $firstResult = array_shift($results);
+ $this->assertArrayHasKey('contact', $firstResult);
+ $resultContact = $firstResult['contact'];
+ $this->assertEquals($contact['display_name'], $resultContact['display_name']);
+ }
+
+ public function testOneToManyMultipleJoin() {
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ $query->select[] = 'phones.phone';
+ $query->where[] = ['first_name', '=', 'Phoney'];
+ $results = $query->run();
+ $result = array_pop($results);
+
+ $this->assertEquals('Phoney', $result['first_name']);
+ $this->assertCount(2, $result['phones']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OneToOneJoinTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OneToOneJoinTest.php
new file mode 100644
index 00000000..ef05f657
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OneToOneJoinTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Civi\Test\Api4\Query;
+
+use Civi\Api4\Contact;
+use Civi\Api4\OptionGroup;
+use Civi\Api4\OptionValue;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * Class OneToOneJoinTest
+ * @package Civi\Test\Api4\Query
+ * @group headless
+ */
+class OneToOneJoinTest extends UnitTestCase {
+
+ public function testOneToOneJoin() {
+ $armenianContact = Contact::create()
+ ->addValue('first_name', 'Contact')
+ ->addValue('last_name', 'One')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('preferred_language', 'hy_AM')
+ ->execute()
+ ->first();
+
+ $basqueContact = Contact::create()
+ ->addValue('first_name', 'Contact')
+ ->addValue('last_name', 'Two')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('preferred_language', 'eu_ES')
+ ->execute()
+ ->first();
+
+ $contacts = Contact::get()
+ ->addWhere('id', 'IN', [$armenianContact['id'], $basqueContact['id']])
+ ->addSelect('preferred_language.label')
+ ->addSelect('last_name')
+ ->execute()
+ ->indexBy('last_name')
+ ->getArrayCopy();
+
+ $this->assertEquals($contacts['One']['preferred_language']['label'], 'Armenian');
+ $this->assertEquals($contacts['Two']['preferred_language']['label'], 'Basque');
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OptionValueJoinTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OptionValueJoinTest.php
new file mode 100644
index 00000000..75d4b977
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/OptionValueJoinTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Civi\Test\Api4\Query;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class OptionValueJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('SingleContact');
+
+ return parent::setUpHeadless();
+ }
+
+ public function testCommunicationMethodJoin() {
+ $query = new Api4SelectQuery('Contact', FALSE);
+ $query->select[] = 'first_name';
+ $query->select[] = 'preferred_communication_method.label';
+ $query->where[] = ['preferred_communication_method', 'IS NOT NULL'];
+ $results = $query->run();
+ $first = array_shift($results);
+ $firstPreferredMethod = array_shift($first['preferred_communication_method']);
+
+ $this->assertEquals(
+ 'Phone',
+ $firstPreferredMethod['label']
+ );
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/SelectQueryMultiJoinTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/SelectQueryMultiJoinTest.php
new file mode 100644
index 00000000..860bd785
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Query/SelectQueryMultiJoinTest.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Civi\Test\Api4\Query;
+
+use Civi\Api4\Contact;
+use Civi\Api4\Email;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * Class SelectQueryMultiJoinTest
+ * @package Civi\Test\Api4\Query
+ * @group headless
+ */
+class SelectQueryMultiJoinTest extends UnitTestCase {
+ public function setUpHeadless() {
+ $this->cleanup(['tablesToTruncate' => ['civicrm_contact', 'civicrm_email']]);
+ $this->loadDataSet('MultiContactMultiEmail');
+ return parent::setUpHeadless();
+ }
+
+ public function testOneToManySelect() {
+ $results = Contact::get()
+ ->addSelect('emails.email')
+ ->execute()
+ ->indexBy('id')
+ ->getArrayCopy();
+
+ $firstContactId = $this->getReference('test_contact_1')['id'];
+ $secondContactId = $this->getReference('test_contact_2')['id'];
+
+ $firstContact = $results[$firstContactId];
+ $secondContact = $results[$secondContactId];
+ $firstContactEmails = array_column($firstContact['emails'], 'email');
+ $secondContactEmails = array_column($secondContact['emails'], 'email');
+
+ $expectedFirstEmails = [
+ 'test_contact_one_home@fakedomain.com',
+ 'test_contact_one_work@fakedomain.com',
+ ];
+ $expectedSecondEmails = [
+ 'test_contact_two_home@fakedomain.com',
+ 'test_contact_two_work@fakedomain.com',
+ ];
+
+ $this->assertEquals($expectedFirstEmails, $firstContactEmails);
+ $this->assertEquals($expectedSecondEmails, $secondContactEmails);
+ }
+
+ public function testManyToOneSelect() {
+ $results = Email::get()
+ ->addSelect('contact.display_name')
+ ->execute()
+ ->indexBy('id')
+ ->getArrayCopy();
+
+ $firstEmail = $this->getReference('test_email_1');
+ $secondEmail = $this->getReference('test_email_2');
+ $thirdEmail = $this->getReference('test_email_3');
+ $fourthEmail = $this->getReference('test_email_4');
+ $firstContactEmailIds = [$firstEmail['id'], $secondEmail['id']];
+ $secondContactEmailIds = [$thirdEmail['id'], $fourthEmail['id']];
+
+ foreach ($results as $id => $email) {
+ $displayName = $email['contact']['display_name'];
+ if (in_array($id, $firstContactEmailIds)) {
+ $this->assertEquals('First Contact', $displayName);
+ }
+ elseif (in_array($id, $secondContactEmailIds)) {
+ $this->assertEquals('Second Contact', $displayName);
+ }
+ }
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapRealTableTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapRealTableTest.php
new file mode 100644
index 00000000..183d34dc
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapRealTableTest.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Civi\Test\Api4\Service\Schema;
+
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SchemaMapRealTableTest extends UnitTestCase {
+ public function testAutoloadWillPopulateTablesByDefault() {
+ $map = \Civi::container()->get('schema_map');
+ $this->assertNotEmpty($map->getTables());
+ }
+
+ public function testSimplePathWillExist() {
+ $map = \Civi::container()->get('schema_map');
+ $path = $map->getPath('civicrm_contact', 'emails');
+ $this->assertCount(1, $path);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapperTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapperTest.php
new file mode 100644
index 00000000..04952f7b
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/Schema/SchemaMapperTest.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Civi\Test\Api4\Service\Schema;
+
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+use Civi\Api4\Service\Schema\SchemaMap;
+use Civi\Api4\Service\Schema\Table;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SchemaMapperTest extends UnitTestCase {
+
+ public function testWillHaveNoPathWithNoTables() {
+ $map = new SchemaMap();
+ $this->assertEmpty($map->getPath('foo', 'bar'));
+ }
+
+ public function testWillHavePathWithSingleJump() {
+ $phoneTable = new Table('civicrm_phone');
+ $locationTable = new Table('civicrm_location_type');
+ $link = new Joinable('civicrm_location_type', 'id', 'location');
+ $phoneTable->addTableLink('location_type_id', $link);
+
+ $map = new SchemaMap();
+ $map->addTables([$phoneTable, $locationTable]);
+
+ $this->assertNotEmpty($map->getPath('civicrm_phone', 'location'));
+ }
+
+ public function testWillHavePathWithDoubleJump() {
+ $activity = new Table('activity');
+ $activityContact = new Table('activity_contact');
+ $middleLink = new Joinable('activity_contact', 'activity_id');
+ $contactLink = new Joinable('contact', 'id');
+ $activity->addTableLink('id', $middleLink);
+ $activityContact->addTableLink('contact_id', $contactLink);
+
+ $map = new SchemaMap();
+ $map->addTables([$activity, $activityContact]);
+
+ $this->assertNotEmpty($map->getPath('activity', 'contact'));
+ }
+
+ public function testPathWithTripleJoin() {
+ $first = new Table('first');
+ $second = new Table('second');
+ $third = new Table('third');
+ $first->addTableLink('id', new Joinable('second', 'id'));
+ $second->addTableLink('id', new Joinable('third', 'id'));
+ $third->addTableLink('id', new Joinable('fourth', 'id'));
+
+ $map = new SchemaMap();
+ $map->addTables([$first, $second, $third]);
+
+ $this->assertNotEmpty($map->getPath('first', 'fourth'));
+ }
+
+ public function testCircularReferenceWillNotBreakIt() {
+ $contactTable = new Table('contact');
+ $carTable = new Table('car');
+ $carLink = new Joinable('car', 'id');
+ $ownerLink = new Joinable('contact', 'id');
+ $contactTable->addTableLink('car_id', $carLink);
+ $carTable->addTableLink('owner_id', $ownerLink);
+
+ $map = new SchemaMap();
+ $map->addTables([$contactTable, $carTable]);
+
+ $this->assertEmpty($map->getPath('contact', 'foo'));
+ }
+
+ public function testCannotGoOverJoinLimit() {
+ $first = new Table('first');
+ $second = new Table('second');
+ $third = new Table('third');
+ $fourth = new Table('fourth');
+ $first->addTableLink('id', new Joinable('second', 'id'));
+ $second->addTableLink('id', new Joinable('third', 'id'));
+ $third->addTableLink('id', new Joinable('fourth', 'id'));
+ $fourth->addTableLink('id', new Joinable('fifth', 'id'));
+
+ $map = new SchemaMap();
+ $map->addTables([$first, $second, $third, $fourth]);
+
+ $this->assertEmpty($map->getPath('first', 'fifth'));
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/TestCreationParameterProvider.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/TestCreationParameterProvider.php
new file mode 100644
index 00000000..ef10cea9
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Service/TestCreationParameterProvider.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Civi\Test\Api4\Service;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\SpecGatherer;
+use \CRM_Utils_String as StringHelper;
+
+class TestCreationParameterProvider {
+
+ /**
+ * @var SpecGatherer
+ */
+ protected $gatherer;
+
+ /**
+ * @param SpecGatherer $gatherer
+ */
+ public function __construct(SpecGatherer $gatherer) {
+ $this->gatherer = $gatherer;
+ }
+
+ /**
+ * @param $entity
+ *
+ * @return array
+ */
+ public function getRequired($entity) {
+ $createSpec = $this->gatherer->getSpec($entity, 'create', FALSE);
+ $requiredFields = array_merge($createSpec->getRequiredFields(), $createSpec->getConditionalRequiredFields());
+
+ if ($entity === 'Contact') {
+ $requiredFields[] = $createSpec->getFieldByName('first_name');
+ $requiredFields[] = $createSpec->getFieldByName('last_name');
+ }
+
+ $requiredParams = [];
+ foreach ($requiredFields as $requiredField) {
+ $value = $this->getRequiredValue($requiredField);
+ $requiredParams[$requiredField->getName()] = $value;
+ }
+
+ unset($requiredParams['id']);
+
+ return $requiredParams;
+ }
+
+ /**
+ * Attempt to get a value using field option, defaults, FKEntity, or a random
+ * value based on the data type.
+ *
+ * @param FieldSpec $field
+ *
+ * @return mixed
+ * @throws \Exception
+ */
+ private function getRequiredValue(FieldSpec $field) {
+
+ if ($field->getOptions()) {
+ return $this->getOption($field);
+ }
+ elseif ($field->getDefaultValue()) {
+ return $field->getDefaultValue();
+ }
+ elseif ($field->getFkEntity()) {
+ return $this->getFkID($field);
+ }
+
+ $randomValue = $this->getRandomValue($field->getDataType());
+
+ if ($randomValue) {
+ return $randomValue;
+ }
+
+ throw new \Exception('Could not provide default value');
+ }
+
+ /**
+ * @param FieldSpec $field
+ *
+ * @return mixed
+ */
+ private function getOption(FieldSpec $field) {
+ $options = $field->getOptions();
+ $useKeyNames = ['data_type', 'html_type'];
+ $shouldUseKey = in_array($field->getName(), $useKeyNames);
+ $isIdField = substr($field->getName(), -3) === '_id';
+
+ if ($isIdField || $shouldUseKey) {
+ return array_rand($options); // return key (ID)
+ }
+ else {
+ return $options[array_rand($options)];
+ }
+ }
+
+ /**
+ * @param FieldSpec $field
+ *
+ * @return mixed
+ * @throws \Exception
+ */
+ private function getFkID(FieldSpec $field) {
+ $fkEntity = $field->getFkEntity();
+ $params = ['checkPermissions' => FALSE];
+ // Be predictable about what type of contact we select
+ if ($fkEntity === 'Contact') {
+ $params['where'] = [['contact_type', '=', 'Individual']];
+ }
+ $entityList = civicrm_api4($fkEntity, 'get', $params);
+ if ($entityList->count() < 1) {
+ $msg = sprintf('At least one %s is required in test', $fkEntity);
+ throw new \Exception($msg);
+ }
+
+ return $entityList->last()['id'];
+ }
+
+ /**
+ * @param $dataType
+ *
+ * @return int|null|string
+ */
+ private function getRandomValue($dataType) {
+ switch ($dataType) {
+ case 'Boolean':
+ return TRUE;
+
+ case 'Integer':
+ return rand(1, 2000);
+
+ case 'String':
+ return StringHelper::createRandom(10, implode('', range('a', 'z')));
+
+ case 'Text':
+ return StringHelper::createRandom(100, implode('', range('a', 'z')));
+
+ case 'Money':
+ return sprintf('%d.%2d', rand(0, 2000), rand(1, 99));
+
+ case 'Date':
+ return '20100102';
+ }
+
+ return NULL;
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/RequestSpecTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/RequestSpecTest.php
new file mode 100644
index 00000000..45aa41b2
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/RequestSpecTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Civi\Test\Api4\Spec;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class RequestSpecTest extends UnitTestCase {
+
+ public function testRequiredFieldFetching() {
+ $spec = new RequestSpec('Contact', 'get');
+ $requiredField = new FieldSpec('name', 'Contact');
+ $requiredField->setRequired(TRUE);
+ $nonRequiredField = new FieldSpec('age', 'Contact', 'Integer');
+ $nonRequiredField->setRequired(FALSE);
+ $spec->addFieldSpec($requiredField);
+ $spec->addFieldSpec($nonRequiredField);
+
+ $requiredFields = $spec->getRequiredFields();
+
+ $this->assertCount(1, $requiredFields);
+ $this->assertEquals('name', array_shift($requiredFields)->getName());
+ }
+
+ public function testGettingFieldNames() {
+ $spec = new RequestSpec('Contact', 'get');
+ $nameField = new FieldSpec('name', 'Contact');
+ $ageField = new FieldSpec('age', 'Contact', 'Integer');
+ $spec->addFieldSpec($nameField);
+ $spec->addFieldSpec($ageField);
+
+ $fieldNames = $spec->getFieldNames();
+
+ $this->assertCount(2, $fieldNames);
+ $this->assertEquals(['name', 'age'], $fieldNames);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecFormatterTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecFormatterTest.php
new file mode 100644
index 00000000..bdb1c459
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecFormatterTest.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Civi\Test\Api4\Spec;
+
+use Civi\Api4\Service\Spec\CustomFieldSpec;
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+use Civi\Api4\Service\Spec\SpecFormatter;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SpecFormatterTest extends UnitTestCase {
+
+ public function testSpecToArray() {
+ $spec = new RequestSpec('Contact', 'get');
+ $fieldName = 'last_name';
+ $field = new FieldSpec($fieldName, 'Contact');
+ $spec->addFieldSpec($field);
+ $arraySpec = SpecFormatter::specToArray($spec->getFields());
+
+ $this->assertEquals('String', $arraySpec[$fieldName]['data_type']);
+ }
+
+ /**
+ * @dataProvider arrayFieldSpecProvider
+ *
+ * @param array $fieldData
+ * @param string $expectedName
+ * @param string $expectedType
+ */
+ public function testArrayToField($fieldData, $expectedName, $expectedType) {
+ $field = SpecFormatter::arrayToField($fieldData, 'TestEntity');
+
+ $this->assertEquals($expectedName, $field->getName());
+ $this->assertEquals($expectedType, $field->getDataType());
+ }
+
+ public function testCustomFieldWillBeReturned() {
+ $customGroupId = 1432;
+ $customFieldId = 3333;
+ $name = 'MyFancyField';
+
+ $data = [
+ 'custom_group_id' => $customGroupId,
+ 'custom_group' => ['name' => 'my_group'],
+ 'id' => $customFieldId,
+ 'name' => $name,
+ 'data_type' => 'String',
+ 'html_type' => 'MultiSelect',
+ ];
+
+ /** @var CustomFieldSpec $field */
+ $field = SpecFormatter::arrayToField($data, 'TestEntity');
+
+ $this->assertInstanceOf(CustomFieldSpec::class, $field);
+ $this->assertEquals('my_group', $field->getCustomGroupName());
+ $this->assertEquals($customFieldId, $field->getCustomFieldId());
+ $this->assertEquals(\CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND, $field->getSerialize());
+ }
+
+ /**
+ * @return array
+ */
+ public function arrayFieldSpecProvider() {
+ return [
+ [
+ [
+ 'name' => 'Foo',
+ 'title' => 'Bar',
+ 'type' => \CRM_Utils_Type::T_STRING
+ ],
+ 'Foo',
+ 'String'
+ ],
+ [
+ [
+ 'name' => 'MyField',
+ 'title' => 'Bar',
+ 'type' => \CRM_Utils_Type::T_STRING,
+ 'data_type' => 'Boolean' // this should take precedence
+ ],
+ 'MyField',
+ 'Boolean'
+ ],
+ ];
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecGathererTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecGathererTest.php
new file mode 100644
index 00000000..bf5b92b9
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Spec/SpecGathererTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Civi\Test\Api4\Spec;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\Provider\SpecProviderInterface;
+use Civi\Api4\Service\Spec\RequestSpec;
+use Civi\Api4\Service\Spec\SpecGatherer;
+use Civi\Test\Api4\Traits\OptionCleanupTrait;
+use Civi\Test\Api4\UnitTestCase;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Test\Api4\Traits\TableDropperTrait;
+use Prophecy\Argument;
+
+/**
+ * @group headless
+ */
+class SpecGathererTest extends UnitTestCase {
+
+ use TableDropperTrait;
+ use OptionCleanupTrait;
+
+ public function setUpHeadless() {
+ $this->dropByPrefix('civicrm_value_favorite');
+ $this->cleanup([
+ 'tablesToTruncate' => [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field'
+ ],
+ ]);
+ return parent::setUpHeadless();
+ }
+
+ public function testBasicFieldsGathering() {
+ $gatherer = new SpecGatherer();
+ $specs = $gatherer->getSpec('Contact', 'get', FALSE);
+ $contactDAO = _civicrm_api3_get_DAO('Contact');
+ $contactFields = $contactDAO::fields();
+ $specFieldNames = $specs->getFieldNames();
+ $contactFieldNames = array_column($contactFields, 'name');
+
+ $this->assertEmpty(array_diff_key($contactFieldNames, $specFieldNames));
+ }
+
+ public function testWithSpecProvider() {
+ $gather = new SpecGatherer();
+
+ $provider = $this->prophesize(SpecProviderInterface::class);
+ $provider->applies('Contact', 'create')->willReturn(TRUE);
+ $provider->modifySpec(Argument::any())->will(function ($args) {
+ /** @var RequestSpec $spec */
+ $spec = $args[0];
+ $spec->addFieldSpec(new FieldSpec('foo', 'Contact'));
+ });
+ $gather->addSpecProvider($provider->reveal());
+
+ $spec = $gather->getSpec('Contact', 'create', FALSE);
+ $fieldNames = $spec->getFieldNames();
+
+ $this->assertContains('foo', $fieldNames);
+ }
+
+ public function testPseudoConstantOptionsWillBeAdded() {
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'FavoriteThings')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ $options = ['r' => 'Red', 'g' => 'Green', 'p' => 'Pink'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('options', $options)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $gatherer = new SpecGatherer();
+ $spec = $gatherer->getSpec('Contact', 'get', TRUE);
+
+ $regularField = $spec->getFieldByName('contact_type');
+ $this->assertNotEmpty($regularField->getOptions());
+ $this->assertContains('Individual', $regularField->getOptions());
+
+ $customField = $spec->getFieldByName('FavoriteThings.FavColor');
+ $this->assertNotEmpty($customField->getOptions());
+ $this->assertContains('Green', $customField->getOptions());
+ $this->assertEquals('Pink', $customField->getOptions()['p']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/OptionCleanupTrait.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/OptionCleanupTrait.php
new file mode 100644
index 00000000..06f43235
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/OptionCleanupTrait.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Civi\Test\Api4\Traits;
+
+trait OptionCleanupTrait {
+
+ protected $optionGroupMaxId;
+ protected $optionValueMaxId;
+
+ public function setUp() {
+ $this->optionGroupMaxId = \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_option_group');
+ $this->optionValueMaxId = \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_option_value');
+ }
+
+ public function tearDown() {
+ if ($this->optionValueMaxId) {
+ \CRM_Core_DAO::executeQuery('DELETE FROM civicrm_option_value WHERE id > ' . $this->optionValueMaxId);
+ }
+ if ($this->optionGroupMaxId) {
+ \CRM_Core_DAO::executeQuery('DELETE FROM civicrm_option_group WHERE id > ' . $this->optionGroupMaxId);
+ }
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/QueryCounterTrait.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/QueryCounterTrait.php
new file mode 100644
index 00000000..c7e10f1b
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/QueryCounterTrait.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Civi\Test\Api4\Traits;
+
+use \CRM_Utils_Array as ArrayHelper;
+
+trait QueryCounterTrait {
+
+ /**
+ * @var int
+ */
+ protected $startCount = 0;
+
+ /**
+ * Start the query counter
+ */
+ protected function beginQueryCount() {
+ $this->startCount = $this->getCurrentGlobalQueryCount();
+ }
+
+ /**
+ * @return int
+ * The number of queries since the counter was started
+ */
+ protected function getQueryCount() {
+ return $this->getCurrentGlobalQueryCount() - $this->startCount;
+ }
+
+ /**
+ * @return int
+ * @throws \Exception
+ */
+ private function getCurrentGlobalQueryCount() {
+ global $_DB_DATAOBJECT;
+
+ if (!$_DB_DATAOBJECT) {
+ throw new \Exception('Database object not set so cannot count queries');
+ }
+
+ return ArrayHelper::value('RESULTSEQ', $_DB_DATAOBJECT, 0);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TableDropperTrait.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TableDropperTrait.php
new file mode 100644
index 00000000..6e543473
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TableDropperTrait.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Civi\Test\Api4\Traits;
+
+trait TableDropperTrait {
+ /**
+ * @param $prefix
+ */
+ protected function dropByPrefix($prefix) {
+ $sql = "SELECT CONCAT( 'DROP TABLE ', GROUP_CONCAT(table_name) , ';' ) " .
+ "AS statement FROM information_schema.tables " .
+ "WHERE table_name LIKE '%s%%' AND table_schema = DATABASE();";
+ $sql = sprintf($sql, $prefix);
+ $dropTableQuery = \CRM_Core_DAO::executeQuery($sql);
+ $dropTableQuery->fetch();
+ $dropTableQuery = $dropTableQuery->statement;
+
+ if ($dropTableQuery) {
+ \CRM_Core_DAO::executeQuery($dropTableQuery);
+ }
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TestDataLoaderTrait.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TestDataLoaderTrait.php
new file mode 100644
index 00000000..1db22090
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Traits/TestDataLoaderTrait.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Civi\Test\Api4\Traits;
+
+/**
+ * This probably should be a separate class
+ */
+trait TestDataLoaderTrait {
+
+ /**
+ * @var array
+ * References to entities used for loading test data
+ */
+ protected $references;
+
+ /**
+ * Creates entities from a JSON data set
+ *
+ * @param $path
+ */
+ protected function loadDataSet($path) {
+ if (!file_exists($path)) {
+ $path = __DIR__ . '/../DataSets/' . $path . '.json';
+ }
+
+ $dataSet = json_decode(file_get_contents($path), TRUE);
+ foreach ($dataSet as $entityName => $entities) {
+ foreach ($entities as $entityValues) {
+
+ $entityValues = $this->replaceReferences($entityValues);
+
+ $params = ['values' => $entityValues, 'checkPermissions' => FALSE];
+ $result = civicrm_api4($entityName, 'create', $params);
+ if (isset($entityValues['@ref'])) {
+ $this->references[$entityValues['@ref']] = $result->first();
+ }
+ }
+ }
+ }
+
+ /**
+ * @param $name
+ *
+ * @return null|mixed
+ */
+ protected function getReference($name) {
+ return isset($this->references[$name]) ? $this->references[$name] : NULL;
+ }
+
+ /**
+ * @param array $entityValues
+ *
+ * @return array
+ */
+ private function replaceReferences($entityValues) {
+ foreach ($entityValues as $name => $value) {
+ if (is_array($value)) {
+ $entityValues[$name] = $this->replaceReferences($value);
+ }
+ elseif (substr($value, 0, 4) === '@ref') {
+ $referenceName = substr($value, 5);
+ list ($reference, $property) = explode('.', $referenceName);
+ $entityValues[$name] = $this->references[$reference][$property];
+ }
+ }
+ return $entityValues;
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/UnitTestCase.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/UnitTestCase.php
new file mode 100644
index 00000000..4f872d12
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/UnitTestCase.php
@@ -0,0 +1,235 @@
+<?php
+
+namespace Civi\Test\Api4;
+
+use Civi\Test\Api4\Traits\TestDataLoaderTrait;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class UnitTestCase extends \PHPUnit_Framework_TestCase implements HeadlessInterface, TransactionalInterface {
+
+ use TestDataLoaderTrait;
+
+ /**
+ * @see CiviUnitTestCase
+ *
+ * @param string $name
+ * @param array $data
+ * @param string $dataName
+ */
+ public function __construct($name = NULL, array $data = [], $dataName = '') {
+ parent::__construct($name, $data, $dataName);
+ error_reporting(E_ALL & ~E_NOTICE);
+ }
+
+ public function setUpHeadless() {
+ return \Civi\Test::headless()->installMe(__DIR__)->apply();
+ }
+
+ /**
+ * Tears down the fixture, for example, closes a network connection.
+ *
+ * This method is called after a test is executed.
+ */
+ public function tearDown() {
+ parent::tearDown();
+ }
+
+ /**
+ * Quick clean by emptying tables created for the test.
+ *
+ * @param array $params
+ */
+ public function cleanup($params) {
+ $params += [
+ 'tablesToTruncate' => [],
+ ];
+ \CRM_Core_DAO::executeQuery("SET FOREIGN_KEY_CHECKS = 0;");
+ foreach ($params['tablesToTruncate'] as $table) {
+ \Civi::log()->info('truncating: ' . $table);
+ $sql = "TRUNCATE TABLE $table";
+ \CRM_Core_DAO::executeQuery($sql);
+ }
+ \CRM_Core_DAO::executeQuery("SET FOREIGN_KEY_CHECKS = 1;");
+ }
+
+ /**
+ * Quick record counter
+ *
+ * @param string $table_name
+ * @returns int record count
+ */
+ public function getRowCount($table_name) {
+ $sql = "SELECT count(id) FROM $table_name";
+ return (int) \CRM_Core_DAO::singleValueQuery($sql);
+ }
+
+ /**
+ * Create sample entities (using V3 for now).
+ *
+ * @param array $params (type, seq, overrides, count)
+ * @return array (either single, or array of array if count >1)
+ */
+ public static function createEntity($params) {
+ $params += [
+ 'count' => 1,
+ 'seq' => 0,
+ ];
+ $entities = [];
+ $entity = NULL;
+ for ($i = 0; $i < $params['count']; $i++) {
+ $params['seq']++;
+ $data = self::sample($params);
+ $api_params = ['sequential' => 1] + $data['sample_params'];
+ $result = civicrm_api3($data['entity'], 'create', $api_params);
+ if ($result['is_error']) {
+ throw new \Exception("creating $data[entity] failed");
+ }
+ $entity = $result['values'][0];
+ if (!($entity['id'] > 0)) {
+ throw new \Exception("created entity is malformed");
+ }
+ $entities[] = $entity;
+ }
+ return $params['count'] == 1 ? $entity : $entities;
+ }
+
+ /**
+ * Helper function for creating sample entities.
+ *
+ * Depending on the supplied sequence integer, plucks values from the dummy data.
+ * Constructs a foreign entity when an ID is required but isn't supplied in the overrides.
+ *
+ * Inspired by CiviUnitTestCase::
+ * @todo - extract this function to own class and share with CiviUnitTestCase?
+ * @param array $params
+ * - type: string roughly matching entity type
+ * - seq: (optional) int sequence number for the values of this type
+ * - overrides: (optional) array of fill in parameters
+ *
+ * @return array
+ * - entity: string API entity type (usually the type supplied except for contact subtypes)
+ * - sample_params: array API sample_params properties of sample entity
+ */
+ public static function sample($params) {
+ $params += [
+ 'seq' => 0,
+ 'overrides' => [],
+ ];
+ $type = $params['type'];
+ // sample data - if field is array then chosed based on `seq`
+ $sample_params = [];
+ if (in_array($type, ['Individual', 'Organization', 'Household'])) {
+ $sample_params['contact_type'] = $type;
+ $entity = 'Contact';
+ }
+ else {
+ $entity = $type;
+ }
+ // use the seq to pluck a set of params out
+ foreach (self::sampleData($type) as $key => $value) {
+ if (is_array($value)) {
+ $sample_params[$key] = $value[$params['seq'] % count($value)];
+ }
+ else {
+ $sample_params[$key] = $value;
+ }
+ }
+ if ($type == 'Individual') {
+ $sample_params['email'] = strtolower(
+ $sample_params['first_name'] . '_' . $sample_params['last_name'] . '@civicrm.org'
+ );
+ $sample_params['prefix_id'] = 3;
+ $sample_params['suffix_id'] = 3;
+ }
+ if (!count($sample_params)) {
+ throw new \Exception("unknown sample type: $type");
+ }
+ $sample_params = $params['overrides'] + $sample_params;
+ // make foreign enitiies if they haven't been supplied
+ foreach ($sample_params as $key => $value) {
+ if (substr($value, 0, 6) === 'dummy.') {
+ $foreign_entity = self::createEntity([
+ 'type' => substr($value, 6),
+ 'seq' => $params['seq']]);
+ $sample_params[$key] = $foreign_entity['id'];
+ }
+ }
+ return compact("entity", "sample_params");
+ }
+
+ /**
+ * Provider of sample data.
+ *
+ * @return array
+ * Array values represent a set of allowable items.
+ * Strings in the form "dummy.Entity" require creating a foreign entity first.
+ */
+ public static function sampleData($type) {
+ $data = [
+ 'Individual' => [
+ // The number of values in each list need to be coprime numbers to not have duplicates
+ 'first_name' => ['Anthony', 'Joe', 'Terrence', 'Lucie', 'Albert', 'Bill', 'Kim'],
+ 'middle_name' => ['J.', 'M.', 'P', 'L.', 'K.', 'A.', 'B.', 'C.', 'D', 'E.', 'Z.'],
+ 'last_name' => ['Anderson', 'Miller', 'Smith', 'Collins', 'Peterson'],
+ 'contact_type' => 'Individual',
+ ],
+ 'Organization' => [
+ 'organization_name' => [
+ 'Unit Test Organization',
+ 'Acme',
+ 'Roberts and Sons',
+ 'Cryo Space Labs',
+ 'Sharper Pens',
+ ],
+ ],
+ 'Household' => [
+ 'household_name' => ['Unit Test household'],
+ ],
+ 'Event' => [
+ 'title' => 'Annual CiviCRM meet',
+ 'summary' => 'If you have any CiviCRM related issues or want to track where CiviCRM is heading, Sign up now',
+ 'description' => 'This event is intended to give brief idea about progess of CiviCRM and giving solutions to common user issues',
+ 'event_type_id' => 1,
+ 'is_public' => 1,
+ 'start_date' => 20081021,
+ 'end_date' => 20081023,
+ 'is_online_registration' => 1,
+ 'registration_start_date' => 20080601,
+ 'registration_end_date' => 20081015,
+ 'max_participants' => 100,
+ 'event_full_text' => 'Sorry! We are already full',
+ 'is_monetary' => 0,
+ 'is_active' => 1,
+ 'is_show_location' => 0,
+ ],
+ 'Participant' => [
+ 'event_id' => 'dummy.Event',
+ 'contact_id' => 'dummy.Individual',
+ 'status_id' => 2,
+ 'role_id' => 1,
+ 'register_date' => 20070219,
+ 'source' => 'Wimbeldon',
+ 'event_level' => 'Payment',
+ ],
+ 'Contribution' => [
+ 'contact_id' => 'dummy.Individual',
+ 'financial_type_id' => 1, // donation, 2 = member, 3 = campaign contribution, 4=event
+ 'total_amount' => 7.3,
+ ],
+ 'Activity' => [
+ //'activity_type_id' => 1,
+ 'subject' => 'unit testing',
+ 'source_contact_id' => 'dummy.Individual',
+ ],
+ ];
+ if ($type == 'Contact') {
+ $type = 'Individual';
+ }
+ return $data[$type];
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ArrayInsertionServiceTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ArrayInsertionServiceTest.php
new file mode 100644
index 00000000..462f07cc
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ArrayInsertionServiceTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Civi\Test\Api4\Utils;
+
+use Civi\Api4\Utils\ArrayInsertionUtil;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ArrayInsertionServiceTest extends UnitTestCase {
+
+ public function testInsertWillWork() {
+ $arr = [];
+ $path = ['foo' => FALSE, 'bar' => FALSE];
+ $inserter = new ArrayInsertionUtil();
+ $inserter::insert($arr, $path, ['LALA']);
+
+ $expected = [
+ 'foo' => [
+ 'bar' => 'LALA'
+ ],
+ ];
+
+ $this->assertEquals($expected, $arr);
+ }
+
+ public function testInsertionOfContactEmailLocation() {
+ $contacts = [
+ [
+ 'id' => 1,
+ 'first_name' => 'Jim'
+ ],
+ [
+ 'id' => 2,
+ 'first_name' => 'Karen'
+ ],
+ ];
+ $emails = [
+ [
+ 'email' => 'jim@jim.com',
+ 'id' => 2,
+ '_parent_id' => 1
+ ],
+ ];
+ $locationTypes = [
+ [
+ 'name' => 'Home',
+ 'id' => 3,
+ '_parent_id' => 2
+ ],
+ ];
+
+ $emailPath = ['emails' => TRUE];
+ $locationPath = ['emails' => TRUE, 'location' => FALSE];
+ $inserter = new ArrayInsertionUtil();
+
+ foreach ($contacts as &$contact) {
+ $inserter::insert($contact, $emailPath, $emails);
+ $inserter::insert($contact, $locationPath, $locationTypes);
+ }
+
+ $locationType = $contacts[0]['emails'][0]['location']['name'];
+ $this->assertEquals('Home', $locationType);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ReflectionUtilsTest.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ReflectionUtilsTest.php
new file mode 100644
index 00000000..d94de337
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/Utils/ReflectionUtilsTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Civi\Test\Api4\Utils;
+
+use Civi\Api4\Utils\ReflectionUtils;
+use Civi\Test\Api4\Mock\MockV4ReflectionGrandchild;
+use Civi\Test\Api4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ReflectionUtilsTest extends UnitTestCase {
+
+ /**
+ * Test that class annotations are returned across @inheritDoc
+ */
+ public function testGetDocBlockForClass() {
+ $grandChild = new MockV4ReflectionGrandchild();
+ $reflection = new \ReflectionClass($grandChild);
+ $doc = ReflectionUtils::getCodeDocs($reflection);
+
+ $this->assertEquals(TRUE, $doc['internal']);
+ $this->assertEquals('Grandchild class', $doc['description']);
+
+ $expectedComment = 'This is an extended description.
+
+There is a line break in this description.
+
+This is the base class.';
+
+ $this->assertEquals($expectedComment, $doc['comment']);
+ }
+
+ /**
+ * Test that property annotations are returned across @inheritDoc
+ */
+ public function testGetDocBlockForProperty() {
+ $grandChild = new MockV4ReflectionGrandchild();
+ $reflection = new \ReflectionClass($grandChild);
+ $doc = ReflectionUtils::getCodeDocs($reflection->getProperty('foo'), 'Property');
+
+ $this->assertEquals('This is the foo property.', $doc['description']);
+ $this->assertEquals("In the child class, foo has been barred.\n\nIn general, you can do nothing with it.", $doc['comment']);
+ }
+
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/bootstrap.php b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/bootstrap.php
new file mode 100644
index 00000000..1c28e611
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/phpunit/bootstrap.php
@@ -0,0 +1,56 @@
+<?php
+
+define('CIVICRM_CONTAINER_CACHE', 'never');
+ini_set('memory_limit', '2G');
+ini_set('safe_mode', 0);
+$bootCode = cv('php:boot --level=classloader', 'phpcode');
+eval($bootCode);
+
+preg_match('/require_once\s*\'(.*)\'/', $bootCode, $matches);
+$loader = require sprintf('%s/vendor/autoload.php', $matches[1]);
+$loader->addPsr4('Civi\\Test\\Api4\\', __DIR__);
+$loader->addPsr4('Civi\\Api4\\', __DIR__ . '/Mock/Api4');
+
+/**
+ * Call the "cv" command.
+ *
+ * @param string $cmd
+ * The rest of the command to send.
+ * @param string $decode
+ * Ex: 'json' or 'phpcode'.
+ * @return string
+ * Response output (if the command executed normally).
+ * @throws \RuntimeException
+ * If the command terminates abnormally.
+ */
+function cv($cmd, $decode = 'json') {
+ $cmd = 'cv ' . $cmd;
+ $descriptorSpec = [0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => STDERR];
+ $oldOutput = getenv('CV_OUTPUT');
+ putenv("CV_OUTPUT=json");
+ $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__);
+ putenv("CV_OUTPUT=$oldOutput");
+ fclose($pipes[0]);
+ $result = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ if (proc_close($process) !== 0) {
+ throw new RuntimeException("Command failed ($cmd):\n$result");
+ }
+ switch ($decode) {
+ case 'raw':
+ return $result;
+
+ case 'phpcode':
+ // If the last output is /*PHPCODE*/, then we managed to complete execution.
+ if (substr(trim($result), 0, 12) !== "/*BEGINPHP*/" || substr(trim($result), -10) !== "/*ENDPHP*/") {
+ throw new \RuntimeException("Command failed ($cmd):\n$result");
+ }
+ return $result;
+
+ case 'json':
+ return json_decode($result, 1);
+
+ default:
+ throw new RuntimeException("Bad decoder format ($decode)");
+ }
+}
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/services.xml b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/services.xml
new file mode 100644
index 00000000..220ba910
--- /dev/null
+++ b/www/crm/wp-content/plugins/civicrm/civicrm/ext/api4/tests/services.xml
@@ -0,0 +1,10 @@
+<container xmlns="http://symfony.com/schema/dic/services"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+
+ <services>
+ <service id="test.param_provider" class="Civi\Test\Api4\Service\TestCreationParameterProvider">
+ <argument type="service" id="spec_gatherer"/>
+ </service>
+ </services>
+</container>