<?php
/*
 +--------------------------------------------------------------------+
 | Copyright CiviCRM LLC. All rights reserved.                        |
 |                                                                    |
 | This work is published under the GNU AGPLv3 license with some      |
 | permitted exceptions and without any warranty. For full license    |
 | and copyright information, see https://civicrm.org/licensing       |
 +--------------------------------------------------------------------+
 */

/**
 * Test that the core actions for APIv3 entities comply with standard syntax+behavior.
 *
 * By default, this tests all API entities. To only test specific entities, call phpunit with
 * environment variable SYNTAX_CONFORMANCE_ENTITIES, e.g.
 *
 * env SYNTAX_CONFORMANCE_ENTITIES="Contact Event" ./scripts/phpunit api_v3_SyntaxConformanceTest
 *
 * @package CiviCRM_APIv3
 * @subpackage API_Core
 * @group headless
 */
class api_v3_SyntaxConformanceTest extends CiviUnitTestCase {
  protected $_apiversion = 3;

  /**
   * Do not check financial entities in this test class.
   *
   * The class uses lots of crud to do things by-passing
   * BAO logic & entities are often not valid as a result.
   *
   * @var bool
   */
  protected $isValidateFinancialsOnPostAssert = FALSE;

  /**
   * @var array
   * e.g. $this->deletes['CRM_Contact_DAO_Contact'][] = $contactID;
   */
  protected $deletableTestObjects;

  protected $_entity;

  /**
   * Should location types be checked to ensure primary addresses are correctly assigned after each test.
   *
   * Turn off for this class as we use DAO methods that bypass business logic. Also, this test class
   * takes a long time so might be good not to add another check.
   *
   * @var bool
   */
  protected $isLocationTypesOnPostAssert = FALSE;

  /**
   * Map custom group entities to civicrm components.
   * @var array
   */
  protected static $componentMap = [
    'Contribution' => 'CiviContribute',
    'Membership' => 'CiviMember',
    'Participant' => 'CiviEvent',
    'Event' => 'CiviEvent',
    'Case' => 'CiviCase',
    'Pledge' => 'CiviPledge',
    'Campaign' => 'CiviCampaign',
    'Survey' => 'CiviCampaign',
  ];

  /**
   * Entities actions not yet implemented.
   *
   * @var array
   */
  private $toBeImplemented = [];

  /**
   * Set up function.
   *
   * There are two types of missing APIs:
   * Those that are to be implemented
   * (in some future version when someone steps in -hint hint-). List the entities in toBeImplemented[ {$action} ]
   * Those that don't exist
   * and that will never exist (eg an obsoleted Entity
   * they need to be returned by the function toBeSkipped_{$action} (because it has to be a static method and therefore couldn't access a this->toBeSkipped)
   */
  public function setUp(): void {
    parent::setUp();
    $this->enableCiviCampaign();
    $this->toBeImplemented['get'] = [
      // Not apiv3.
      'Afform',
      'Profile',
      'CustomValue',
      'CustomSearch',
      'Extension',
      'ReportTemplate',
      'System',
      'Setting',
      'Payment',
      'Logging',
    ];
    $this->toBeImplemented['create'] = [
      'Afform',
      'OptionGroup',
      'MailingRecipients',
      'UFMatch',
      'CustomSearch',
      'Extension',
      'ReportTemplate',
      'System',
      'User',
      'Payment',
      'Order',
      //work fine in local
      'Logging',
    ];
    $this->toBeImplemented['delete'] = [
      'MembershipPayment',
      'OptionGroup',
      'UFJoin',
      'UFMatch',
      'Extension',
      'System',
      'Payment',
      'Order',
    ];
    $this->deletableTestObjects = [];
  }

  public function getDeprecatedAPIs() : array {
    return [];
  }

  public function tearDown(): void {
    foreach ($this->deletableTestObjects as $entityName => $entities) {
      foreach ($entities as $entityID) {
        CRM_Core_DAO::deleteTestObjects($entityName, ['id' => $entityID]);
      }
    }
    $this->deletableTestObjects = NULL;
  }

  /**
   * Generate list of all entities.
   *
   * @param array $skip
   *   Entities to skip.
   *
   * @return array
   */
  public static function entities($skip = []) {
    // The order of operations in here is screwy. In the case where SYNTAX_CONFORMANCE_ENTITIES is
    // defined, we should be able to parse+return it immediately. However, some weird dependency
    // crept into the system where civicrm_api('Entity','get') must be called as part of entities()
    // (even if its return value is ignored).

    if (getenv('SYNTAX_CONFORMANCE_ENTITIES')) {
      $tmp = [
        'values' => explode(' ', getenv('SYNTAX_CONFORMANCE_ENTITIES')),
      ];
    }
    elseif (isset(\Civi\Test::$statics[__CLASS__]['entities'])) {
      $tmp = \Civi\Test::$statics[__CLASS__]['entities'];
    }
    else {
      \Civi\Test::asPreInstall([static::CLASS, 'buildEnvironment'])->apply();
      $tmp = civicrm_api('Entity', 'Get', ['version' => 3]);
      \Civi\Test::$statics[__CLASS__]['entities'] = $tmp;
    }

    if (!is_array($skip)) {
      $skip = [];
    }
    $tmp = array_diff($tmp['values'], $skip);
    $entities = [];
    foreach ($tmp as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * Get list of entities for get test.
   *
   * @return array
   */
  public static function entities_get() {
    // all the entities, beside the ones flagged
    return static::entities(static::toBeSkipped_get(TRUE));
  }

  /**
   * Get entities for create tests.
   *
   * @return array
   */
  public static function entities_create() {
    return static::entities(static::toBeSkipped_create(TRUE));
  }

  /**
   * @return array
   */
  public static function entities_updatesingle() {
    return static::entities(static::toBeSkipped_updatesingle(TRUE));
  }

  /**
   * @return array
   */
  public static function entities_getlimit() {
    return static::entities(static::toBeSkipped_getlimit());
  }

  /**
   * Generate list of entities that can be retrieved using SQL operator syntax.
   *
   * @return array
   */
  public static function entities_getSqlOperators() {
    return static::entities(static::toBeSkipped_getSqlOperators());
  }

  /**
   * @return array
   */
  public static function entities_delete() {
    return static::entities(static::toBeSkipped_delete(TRUE));
  }

  /**
   * @return array
   */
  public static function entitiesGetfields(): array {
    return static::entities(static::toBeSkipped_getfields(TRUE));
  }

  /**
   * @return array
   */
  public static function custom_data_entities_get() {
    return static::custom_data_entities();
  }

  /**
   * @return array
   */
  public static function custom_data_entities() {
    $entities = CRM_Core_BAO_CustomQuery::$extendsMap;
    $enabledComponents = Civi::settings()->get('enable_components');
    $customDataEntities = [];
    $invalidEntities = ['Individual', 'Organization', 'Household'];
    $entitiesToFix = ['Case', 'Relationship'];
    foreach ($entities as $entityName => $entity) {
      if (!in_array($entityName, $invalidEntities)
        && !in_array($entityName, $entitiesToFix)
      ) {
        if (!empty(self::$componentMap[$entityName]) && empty($enabledComponents[self::$componentMap[$entityName]])) {
          CRM_Core_BAO_ConfigSetting::enableComponent(self::$componentMap[$entityName]);
        }
        $customDataEntities[] = [$entityName];
      }
    }
    return $customDataEntities;
  }

  /**
   * Add a smattering of entities that don't normally have custom data.
   *
   * @return array
   */
  public static function custom_data_incl_non_std_entities_get() {
    return static::entities(static::toBeSkipped_custom_data_creatable(TRUE));
  }

  /**
   * Get entities to be skipped on get tests.
   *
   * @param bool $sequential
   *
   * @return array
   */
  public static function toBeSkipped_get($sequential = FALSE) {
    $entitiesWithoutGet = [
      'MailingEventResubscribe',
    ];
    if ($sequential === TRUE) {
      return $entitiesWithoutGet;
    }
    $entities = [];
    foreach ($entitiesWithoutGet as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * Get entities to be skipped for get call.
   *
   * Mailing Contact Just doesn't support id. We have always insisted on finding a way to
   * support id in API but in this case the underlying tables are crying out for a restructure
   * & it just doesn't make sense.
   *
   * User doesn't support get By ID because the user id is actually the CMS user ID & is not part of
   *   CiviCRM - so can only be tested through UserTest - not SyntaxConformanceTest.
   *
   * Entity doesn't support get By ID because it simply gives the result of string Entites in CiviCRM
   *
   * @return array
   *   Entities that cannot be retrieved by ID
   */
  public function toBeSkippedGetByID(): array {
    return ['MailingContact', 'User', 'Attachment', 'Entity'];
  }

  /**
   * @param bool $sequential
   *
   * @return array
   */
  public static function toBeSkipped_create($sequential = FALSE) {
    $entitiesWithoutCreate = ['Entity', 'Profile', 'MailingRecipients'];
    if ($sequential === TRUE) {
      return $entitiesWithoutCreate;
    }
    $entities = [];
    foreach ($entitiesWithoutCreate as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * @param bool $sequential
   *
   * @return array
   */
  public static function toBeSkipped_delete($sequential = FALSE) {
    $entitiesWithout = [
      'MailingContact',
      'MailingEventConfirm',
      'MailingEventResubscribe',
      'MailingEventSubscribe',
      'MailingEventUnsubscribe',
      'MailingRecipients',
      'Entity',
      'Domain',
      'Profile',
      'CustomValue',
      'Setting',
      'User',
      'Logging',
    ];
    if ($sequential === TRUE) {
      return $entitiesWithout;
    }
    $entities = [];
    foreach ($entitiesWithout as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * @param bool $sequential
   *
   * @return array
   */
  public static function toBeSkipped_custom_data_creatable($sequential = FALSE) {
    $entitiesWithout = [
      // Ones to fix.
      'CaseContact',
      'CustomField',
      'CustomGroup',
      'DashboardContact',
      'Domain',
      'File',
      'FinancialType',
      'LocBlock',
      'MailingEventConfirm',
      'MailingEventResubscribe',
      'MailingEventSubscribe',
      'MailingEventUnsubscribe',
      'MembershipPayment',
      'SavedSearch',
      'UFJoin',
      'UFField',
      'PriceFieldValue',
      'GroupContact',
      'EntityTag',
      'PledgePayment',
      'Relationship',

      // ones that are not real entities hence not extendable.
      'Entity',
      'Attachment',
      'CustomSearch',
      'CustomValue',
      'Extension',
      'MailingContact',
      'User',
      'System',
      'Setting',
      'SystemLog',
      'ReportTemplate',
      'MailingRecipients',
      'Profile',
      'Payment',
      'Order',
      'OptionGroup',
      'MailingGroup',
      'Logging',
    ];
    if ($sequential === TRUE) {
      return $entitiesWithout;
    }
    $entities = [];
    foreach ($entitiesWithout as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * @param bool $sequential
   *
   * @return array
   * @todo add metadata for ALL these entities
   */
  public static function toBeSkipped_getfields($sequential = FALSE) {
    $entitiesWithMetadataNotYetFixed = ['ReportTemplate', 'CustomSearch'];
    if ($sequential === TRUE) {
      return $entitiesWithMetadataNotYetFixed;
    }
    $entities = [];
    foreach ($entitiesWithMetadataNotYetFixed as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * Generate list of entities to test for get by id functions.
   * @param bool $sequential
   * @return array
   *   Entities to be skipped
   */
  public static function toBeSkipped_automock($sequential = FALSE) {
    $entitiesWithoutGet = [
      'MailingContact',
      'EntityTag',
      'Participant',
      'Setting',
      'MailingRecipients',
      'CustomSearch',
      'Extension',
      'ReportTemplate',
      'System',
      'Logging',
      'Payment',
    ];
    if ($sequential === TRUE) {
      return $entitiesWithoutGet;
    }
    $entities = [];
    foreach ($entitiesWithoutGet as $e) {
      $entities[] = [$e];
    }
    return $entities;
  }

  /**
   * At this stage exclude the ones that don't pass & add them as we can troubleshoot them
   * @param bool $sequential
   * @return array
   */
  public static function toBeSkipped_updatesingle($sequential = FALSE) {
    $entitiesWithout = [
      'Attachment',
      // This one confuses the test by unsetting some fields based on the values of others
      'ActionSchedule',
      // pseudo-entity; testUpdateSingleValueAlter doesn't introspect properly on it. Multiple magic fields
      'Mailing',
      'MailingEventUnsubscribe',
      'MailingEventSubscribe',
      'Entity',
      'Profile',
      'CustomValue',
      'UFJoin',
      'Relationship',
      'RelationshipType',
      'Membership',
      'Group',
      'File',
      'EntityTag',
      'CustomField',
      'CustomGroup',
      'Contribution',
      'MailingEventConfirm',
      'Case',
      'CaseContact',
      'Contact',
      'ContactType',
      'MailingEventResubscribe',
      'UFGroup',
      'Activity',
      'Event',
      'GroupContact',
      'MembershipPayment',
      'Participant',
      'LineItem',
      'ContributionPage',
      'Phone',
      'PaymentProcessor',
      'Setting',
      'MailingContact',
      'SystemLog',
      //skip this because it doesn't make sense to update logs,
      'Logging',
      // Skip message template because workflow_id/workflow_name are sync'd.
      'MessageTemplate',
      // Skip survey because the pseudoconstant will have no options.
      'Survey',
    ];
    if ($sequential === TRUE) {
      return $entitiesWithout;
    }
    return ['pledge', 'MessageTemplate'];
  }

  /**
   * At this stage exclude the ones that don't pass & add them as we can troubleshoot them
   */
  public static function toBeSkipped_getlimit() {
    $entitiesWithout = [
      'Case',
      //case api has non-std mandatory fields one of (case_id, contact_id, activity_id, contact_id)
      'EntityTag',
      // non-standard api - has inappropriate mandatory fields & doesn't implement limit
      'Event',
      // failed 'check that a 5 limit returns 5' - probably is_template field is wrong or something, or could be limit doesn't work right
      'Extension',
      // can't handle creating 25
      'Note',
      // fails on 5 limit - probably a set up problem
      'Setting',
      //a bit of a pseudoapi - keys by domain
      'Payment',
      // pseudoapi - problems with creating required sub  entities.
    ];
    return $entitiesWithout;
  }

  /**
   * At this stage exclude the ones that don't pass & add them as we can troubleshoot them
   */
  public static function toBeSkipped_getSqlOperators() {
    $entitiesWithout = [
      //case api has non-std mandatory fields one of (case_id, contact_id, activity_id, contact_id)
      'Case',
      // on the todo list!
      'Contact',
      // non-standard api - has inappropriate mandatory fields & doesn't implement limit
      'EntityTag',
      // can't handle creating 25
      'Extension',
      // note has a default get that isn't implemented in createTestObject -meaning you don't 'get' them
      'Note',
      //a bit of a pseudoapi - keys by domain
      'Setting',
    ];

    // The testSqlOperators fails sporadically on MySQL 5.5, which is deprecated anyway.
    // Re:^^^ => the failure was probably correct behavior, and test is now fixed, but yeah 5.5 is deprecated, and don't care enough to verify.
    // Test data providers should be able to run in pre-boot environment, so we connect directly to SQL server.
    require_once 'DB.php';
    $db = CRM_Utils_SQL::connect(CIVICRM_DSN);
    if ($db->connection instanceof mysqli && $db->connection->server_version < 50600) {
      $entitiesWithout[] = 'Dedupe';
    }

    return $entitiesWithout;
  }

  /**
   * @param $entity
   * @param $key
   *
   * @return array
   */
  public function getKnownUnworkablesUpdateSingle($entity, $key) {
    // can't update values are values for which updates don't result in the value being changed
    $knownFailures = [
      'ActionSchedule' => [
        'cant_update' => [
          'group_id',
        ],
      ],
      'ActivityContact' => [
        'cant_update' => [
          'activity_id',
          //we have an FK on activity_id + contact_id + record id so if we don't leave this one distinct we get an FK constraint error
        ],
      ],
      'Address' => [
        'cant_update' => [
          //issues with country id - need to ensure same country
          'state_province_id',
          'world_region',
          //creates relationship
          'master_id',
        ],
        'cant_return' => ['street_parsing', 'skip_geocode', 'fix_address'],
      ],
      'Batch' => [
        'cant_update' => [
          // believe this field is defined in error
          'entity_table',
        ],
        'cant_return' => [
          'entity_table',
        ],
      ],
      'CaseType' => [
        'cant_update' => [
          'definition',
        ],
      ],
      'ContributionRecur' => [
        'break_return' => [
          'contribution_recur_modified_date',
        ],
      ],
      'Domain' => ['cant_update' => ['domain_version']],
      'MembershipBlock' => [
        'cant_update' => [
          // The fake/auto-generated values leave us unable to properly cleanup fake data
          'entity_type',
          'entity_id',
        ],
      ],
      'MailingJob' => ['cant_update' => ['parent_id']],
      'ContributionSoft' => [
        'cant_update' => [
          // can't be changed through api
          'pcp_id',
        ],
      ],
      'Email' => [
        'cant_update' => [
          // This is being legitimately manipulated to always have a valid primary - skip.
          'is_primary',
        ],
      ],
      'FinancialTrxn' => [
        'cant_update' => [
          // Altering fee amount will also cause net_amount to be recalculated.
          'fee_amount',
        ],
      ],
      'Navigation' => [
        'cant_update' => [
          // Weight is deliberately altered when this is changed - skip.
          'parent_id',
        ],
      ],
      'LocationType' => [
        'cant_update' => [
          // I'm on the fence about whether the test should skip or the behaviour is wrong.
          // display_name is set to match name if display_name is not provided. It would be more 'normal'
          // to only calculate a default IF id is not set - but perhaps the current behaviour is kind
          // of what someone updating the name expects..
          'name',
        ],
      ],
      'Pledge' => [
        'cant_update' => [
          'pledge_original_installment_amount',
          'installments',
          'original_installment_amount',
          'next_pay_date',
          // can't be changed through API,
          'amount',
        ],
        // if these are passed in they are retrieved from the wrong table
        'break_return' => [
          'honor_contact_id',
          'cancel_date',
          'contribution_page_id',
          'financial_account_id',
          'financial_type_id',
          'currency',
        ],
        // can't be retrieved from api
        'cant_return' => [
          //due to uniquename missing
          'honor_type_id',
          'end_date',
          'modified_date',
          'acknowledge_date',
          'start_date',
          'frequency_day',
          'currency',
          'max_reminders',
          'initial_reminder_day',
          'additional_reminder_day',
          'frequency_unit',
          'pledge_contribution_page_id',
          'pledge_status_id',
          'pledge_campaign_id',
          'pledge_financial_type_id',
        ],
      ],
      'PaymentProcessorType' => [
        'cant_update' => [
          'billing_mode',
        ],
        'break_return' => [],
        'cant_return' => [],
      ],
      'PriceFieldValue' => [
        'cant_update' => [
          //won't update as there is no 1 in the same price set
          'weight',
        ],
      ],
      'ReportInstance' => [
        // View mode is part of the navigation which is not retrieved by the api.
        'cant_return' => ['view_mode'],
      ],
      'StatusPreference' => [
        'break_return' => [
          'ignore_severity',
        ],
      ],
      'UFField' => [
        'cant_update' => [
          // These fields get auto-adjusted by the BAO prior to saving
          'weight',
          'location_type_id',
          'phone_type_id',
          'website_type_id',
          // Not a real field
          'option.autoweight',
          'field_name',
        ],
        'break_return' => [
          // These fields get auto-adjusted by the BAO prior to saving
          'weight',
          'field_type',
          'location_type_id',
          'phone_type_id',
          'website_type_id',
          // Not a real field
          'option.autoweight',
        ],
      ],
      'JobLog' => [
        // For better or worse triggers override.
        'break_return' => ['run_time'],
        'cant_update' => ['run_time'],
      ],
    ];
    if (empty($knownFailures[$entity]) || empty($knownFailures[$entity][$key])) {
      return [];
    }
    return $knownFailures[$entity][$key];
  }

  /* ----- testing the _get  ----- */

  /**
   * @dataProvider toBeSkipped_get
   *   Entities that don't need a get action
   * @param $Entity
   */
  public function testNotImplemented_get($Entity) {
    $result = civicrm_api($Entity, 'Get', ['version' => 3]);
    $this->assertEquals(1, $result['is_error']);
    // $this->assertStringContainsString("API ($Entity, Get) does not exist", $result['error_message']);
    $this->assertMatchesRegularExpression('/API (.*) does not exist/', $result['error_message']);
  }

  /**
   * @dataProvider entities
   *
   * @param string $entity
   */
  public function testGetFields(string $entity): void {
    if ($entity === 'Entity' || $entity === 'CustomValue' || in_array($entity, $this->getDeprecatedAPIs(), TRUE)) {
      return;
    }

    $result = civicrm_api($entity, 'getfields', ['version' => 3]);
    $this->assertIsArray($result['values'], "$entity ::get fields doesn't return values array");
    foreach ($result['values'] as $key => $value) {
      $this->assertIsArray($value, $entity . "::" . $key . ' is not an array');
    }
  }

  /**
   * @dataProvider entities_get
   * @Xdepends testEmptyParam_get // no need to test the simple if the empty doesn't work/is skipped. doesn't seem to work
   *
   * @param string $entity
   */
  public function testSimpleGet(string $entity): void {
    // $this->markTestSkipped("test gives core error on test server (but not on our locals). Skip until we can get server to pass");
    if (in_array($entity, $this->toBeImplemented['get'], TRUE)) {
      return;
    }
    $result = civicrm_api($entity, 'Get', ['version' => 3]);
    // @TODO: list the get that have mandatory params
    if ($result['is_error']) {
      $this->assertStringContainsString('Mandatory key(s) missing from params array', $result['error_message']);
      // either id or contact_id or entity_id is one of the field missing
      $this->assertStringContainsString('id', $result['error_message']);
    }
    else {
      $this->assertEquals(3, $result['version']);
      $this->assertArrayHasKey('count', $result);
      $this->assertArrayHasKey('values', $result);
    }
  }

  /**
   * As the test is slow we mark it ornery to suppress in PR runs.
   *
   * @group ornery
   *
   * @dataProvider custom_data_incl_non_std_entities_get
   *
   * @param string $entityName
   */
  public function testCustomDataGet(string $entityName): void {
    if ($entityName === 'Note') {
      $this->markTestIncomplete('Note can not be processed here because of a vagary in the note api, it adds entity_table=contact to the get params when id is not present - which makes sense almost always but kills this test');
    }
    elseif ($entityName === 'Afform') {
      $this->markTestSkipped('Not necessary.');
    }
    $this->quickCleanup(['civicrm_uf_match']);
    // so subsidiary activities are created
    $this->createLoggedInUser();

    $entitiesWithNamingIssues = [
      'Acl' => 'ACL',
      'AclRole' => 'ACLEntityRole',
      'Im' => 'IM',
      'Dedupe' => 'PrevNextCache',
      'Exception' => 'DedupeException',
      'Pcp' => 'PCP',
      'Rule' => 'DedupeRule',
      'RuleGroup' => 'DedupeRuleGroup',
    ];

    $usableName = $entitiesWithNamingIssues[$entityName] ?? $entityName;
    $optionName = CRM_Core_DAO_AllCoreTables::getTableForEntityName($usableName);

    if (!isset(CRM_Core_BAO_CustomQuery::$extendsMap[$entityName])) {
      $createdValue = $this->callAPISuccess('OptionValue', 'create', [
        'option_group_id' => 'cg_extend_objects',
        'label' => $usableName,
        'value' => $usableName,
        'name' => $optionName,
      ]);
    }
    // We are not passing 'check_permissions' so the the more limited permissions *should* be
    // ignored but per CRM-17700 there is a history of custom data applying permissions when it shouldn't.
    CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'view my contact'];
    $objects = $this->getMockableBAOObjects($entityName, 1);

    // simple custom field
    $ids = $this->entityCustomGroupWithSingleFieldCreate(__FUNCTION__, $usableName . 'Test.php');
    $customFieldName = 'custom_' . $ids['custom_field_id'];
    $params = ['id' => $objects[0]->id, 'custom_' . $ids['custom_field_id'] => "custom string"];
    $result = $this->callAPISuccess($entityName, 'create', $params);
    $this->assertTrue(isset($result['id']), 'no id on ' . $entityName);
    $getParams = ['id' => $result['id'], 'return' => [$customFieldName]];
    $check = $this->callAPISuccess($entityName, 'get', $getParams);
    $this->assertTrue(!empty($check['values'][$check['id']][$customFieldName]), 'Custom data not present for ' . $entityName);
    $this->assertEquals("custom string", $check['values'][$check['id']][$customFieldName], 'Custom data not present for ' . $entityName);
    $this->customFieldDelete($ids['custom_field_id']);
    $this->customGroupDelete($ids['custom_group_id']);

    $ids2 = $this->entityCustomGroupWithSingleStringMultiSelectFieldCreate(__FUNCTION__, $usableName . 'Test.php');
    $customFieldNameMultiSelect = 'custom_' . $ids2['custom_field_id'];
    // String custom field, Multi-Select html type
    foreach ($ids2['custom_field_group_options'] as $option_value => $option_label) {
      $params = ['id' => $objects[0]->id, 'custom_' . $ids2['custom_field_id'] => $option_value];
      $result = $this->callAPISuccess($entityName, 'create', $params);
      $getParams = [$customFieldNameMultiSelect => $option_value, 'return' => [$customFieldNameMultiSelect]];
      $this->callAPISuccessGetCount($entityName, $getParams, 1);
    }

    // cleanup
    $this->customFieldDelete($ids2['custom_field_id']);
    $this->customGroupDelete($ids2['custom_group_id']);

    $this->callAPISuccess($entityName, 'delete', ['id' => $result['id']]);
    $this->quickCleanup(['civicrm_uf_match']);
    if (!empty($createdValue)) {
      $this->callAPISuccess('OptionValue', 'delete', ['id' => $createdValue['id']]);
    }
  }

  /**
   * @dataProvider entities_get
   * @param $Entity
   */
  public function testAcceptsOnlyID_get($Entity) {
    // big random number. fun fact: if you multiply it by pi^e, the result is another random number, but bigger ;)
    $nonExistantID = 30867307034;
    if (in_array($Entity, $this->toBeImplemented['get'])
      || in_array($Entity, $this->toBeSkippedGetByID())
    ) {
      return;
    }

    // FIXME
    // the below function returns different values and hence an early return
    // we'll fix this once beta1 is released
    //        return;

    $result = civicrm_api($Entity, 'Get', ['version' => 3, 'id' => $nonExistantID]);

    if ($result['is_error']) {
      // just to get a clearer message in the log
      $this->assertEquals("only id should be enough", $result['error_message']);
    }
    if (!in_array($Entity, $this->getOnlyIDNonZeroCount(), TRUE)) {
      $this->assertEquals(0, $result['count']);
    }
  }

  /**
   * Test getlist works
   * @dataProvider entities_get
   * @param $Entity
   */
  public function testGetList($Entity): void {
    if (in_array($Entity, $this->toBeImplemented['get'])
      || in_array($Entity, $this->toBeSkippedGetByID())
    ) {
      return;
    }
    $this->callAPISuccess($Entity, 'getlist', ['label_field' => 'id']);
  }

  /**
   * Test getlist works when entity is lowercase
   *
   * @dataProvider entities_get
   *
   * @param string $entity
   */
  public function testGetListLowerCaseEntity(string $entity) {
    if (in_array($entity, $this->toBeImplemented['get'])
      || in_array($entity, $this->toBeSkippedGetByID())
    ) {
      return;
    }
    if ($entity === 'UFGroup') {
      $entity = 'ufgroup';
    }
    $this->callAPISuccess($entity, 'getlist', ['label_field' => 'id']);
  }

  /**
   * Create two entities and make sure we can fetch them individually by ID.
   *
   * @dataProvider entities_get
   *
   * limitations include the problem with avoiding loops when creating test objects -
   * hence FKs only set by createTestObject when required. e.g parent_id on campaign is not being followed through
   * Currency - only seems to support US
   *
   * @param string $entityName
   */
  public function testByIDGet(string $entityName): void {
    if (in_array($entityName, self::toBeSkipped_automock(TRUE), TRUE)) {
      // $this->markTestIncomplete("civicrm_api3_{$Entity}_create to be implemented");
      return;
    }

    [$baoObj1, $baoObj2] = $this->getMockableBAOObjects($entityName);

    // fetch first by ID
    $result = $this->callAPISuccess($entityName, 'get', [
      'id' => $baoObj1->id,
    ]);

    $this->assertNotEmpty($result['values'][$baoObj1->id], 'Should find first object by id');
    $this->assertEquals($baoObj1->id, $result['values'][$baoObj1->id]['id'], 'Should find id on first object');
    $this->assertCount(1, $result['values']);

    // fetch second by ID
    $result = $this->callAPISuccess($entityName, 'get', [
      'id' => $baoObj2->id,
    ]);
    $this->assertNotEmpty($result['values'][$baoObj2->id], 'Should find second object by id');
    $this->assertEquals($baoObj2->id, $result['values'][$baoObj2->id]['id'], 'Should find id on second object');
    $this->assertCount(1, $result['values']);
  }

  /**
   * Ensure that the "get" operation accepts limiting the #result records.
   *
   * TODO Consider making a separate entity list ("entities_getlimit")
   * For the moment, the "entities_updatesingle" list should give a good
   * sense for which entities support createTestObject
   *
   * @dataProvider entities_getlimit
   *
   * @param string $entityName
   */
  public function testLimit(string $entityName): void {
    // each case is array(0 => $inputtedApiOptions, 1 => $expectedResultCount)
    $cases = [];
    $cases[] = [
      ['options' => ['limit' => NULL]],
      30,
      'check that a NULL limit returns unlimited',
    ];
    $cases[] = [
      ['options' => ['limit' => FALSE]],
      30,
      'check that a FALSE limit returns unlimited',
    ];
    $cases[] = [
      ['options' => ['limit' => 0]],
      30,
      'check that a 0 limit returns unlimited',
    ];
    $cases[] = [
      ['options' => ['limit' => 5]],
      5,
      'check that a 5 limit returns 5',
    ];
    $cases[] = [
      [],
      25,
      'check that no limit returns 25',
    ];

    $baoString = _civicrm_api3_get_BAO($entityName);
    if (empty($baoString)) {
      $this->markTestIncomplete("Entity [$entityName] cannot be mocked - no known DAO");
      return;
    }

    // make 30 test items -- 30 > 25 (the default limit)
    $ids = [];
    for ($i = 0; $i < 30; $i++) {
      $baoObj = CRM_Core_DAO::createTestObject($baoString, ['currency' => 'USD']);
      $ids[] = $baoObj->id;
    }

    // each case is array(0 => $inputtedApiOptions, 1 => $expectedResultCount)
    foreach ($cases as $case) {
      $this->checkLimitAgainstExpected($entityName, $case[0], $case[1], $case[2]);

      //non preferred / legacy syntax
      if (isset($case[0]['options']['limit'])) {
        $this->checkLimitAgainstExpected($entityName, ['rowCount' => $case[0]['options']['limit']], $case[1], $case[2]);
        $this->checkLimitAgainstExpected($entityName, ['option_limit' => $case[0]['options']['limit']], $case[1], $case[2]);
        $this->checkLimitAgainstExpected($entityName, ['option.limit' => $case[0]['options']['limit']], $case[1], $case[2]);
      }
    }
    foreach ($ids as $id) {
      CRM_Core_DAO::deleteTestObjects($baoString, ['id' => $id]);
    }
  }

  /**
   * Ensure that the "get" operation accepts limiting the #result records.
   *
   * As the test is slow we mark it ornery to suppress in PR runs.
   *
   * @group ornery
   *
   * @dataProvider entities_getSqlOperators
   *
   * @param string $entityName
   */
  public function testSqlOperators($entityName) {
    $toBeIgnored = array_merge($this->toBeImplemented['get'],
      $this->getDeprecatedAPIs(),
      $this->toBeSkipped_get(TRUE),
      $this->toBeSkippedGetByID(),
      ['Afform']
    );
    if (in_array($entityName, $toBeIgnored)) {
      return;
    }

    $baoString = _civicrm_api3_get_BAO($entityName);

    $entities = $this->callAPISuccess($entityName, 'get', ['options' => ['limit' => 0], 'return' => 'id']);
    $entities = array_keys($entities['values']);
    $totalEntities = count($entities);
    if ($totalEntities < 3) {
      for ($i = 0; $i < 3 - $totalEntities; $i++) {
        $baoObj = CRM_Core_DAO::createTestObject($baoString, ['currency' => 'USD']);
        $this->deletableTestObjects[$baoString][] = $baoObj->id;
      }
      $totalEntities = 3;
    }
    $entities = $this->callAPISuccess($entityName, 'get', ['options' => ['limit' => 0, 'sort' => 'id']]);
    $entities = array_keys($entities['values']);
    $this->assertGreaterThan(2, $totalEntities);
    $this->callAPISuccess($entityName, 'getsingle', ['id' => ['IN' => [$entities[0]]]]);
    $this->callAPISuccessGetCount($entityName, ['id' => ['NOT IN' => [$entities[0]]]], $totalEntities - 1);
    $this->callAPISuccessGetCount($entityName, ['id' => ['>' => $entities[0]]], $totalEntities - 1);
  }

  /**
   * Check that get fetches an appropriate number of results.
   *
   * @param string $entityName
   *   Name of entity to test.
   * @param array $params
   * @param int $limit
   * @param string $message
   */
  public function checkLimitAgainstExpected($entityName, $params, $limit, $message) {
    $result = $this->callAPISuccess($entityName, 'get', $params);
    if ($limit == 30) {
      $this->assertGreaterThanOrEqual($limit, $result['count'], $message);
      $this->assertGreaterThanOrEqual($limit, $result['count'], $message);
    }
    else {
      $this->assertEquals($limit, $result['count'], $message);
      $this->assertEquals($limit, count($result['values']), $message);
    }
  }

  /**
   * Create two entities and make sure we can fetch them individually by ID (e.g. using "contact_id=>2"
   * or "group_id=>4")
   *
   * @dataProvider entities_get
   *
   * limitations include the problem with avoiding loops when creating test objects -
   * hence FKs only set by createTestObject when required. e.g parent_id on campaign is not being followed through
   * Currency - only seems to support US
   * @param $entityName
   * @throws \PHPUnit\Framework\IncompleteTestError
   */
  public function testByIDAlias_get($entityName) {
    if (in_array($entityName, self::toBeSkipped_automock(TRUE))) {
      // $this->markTestIncomplete("civicrm_api3_{$Entity}_create to be implemented");
      return;
    }

    $baoString = _civicrm_api3_get_BAO($entityName);
    if (empty($baoString)) {
      $this->markTestIncomplete("Entity [$entityName] cannot be mocked - no known DAO");
      return;
    }

    $idFieldName = _civicrm_api_get_entity_name_from_camel($entityName) . '_id';

    // create entities
    $baoObj1 = CRM_Core_DAO::createTestObject($baoString, ['currency' => 'USD']);
    $this->assertTrue(is_int($baoObj1->id), 'check first id');
    $this->deletableTestObjects[$baoString][] = $baoObj1->id;
    $baoObj2 = CRM_Core_DAO::createTestObject($baoString, ['currency' => 'USD']);
    $this->assertTrue(is_int($baoObj2->id), 'check second id');
    $this->deletableTestObjects[$baoString][] = $baoObj2->id;

    // fetch first by ID
    $result = civicrm_api($entityName, 'get', [
      'version' => 3,
      $idFieldName => $baoObj1->id,
    ]);
    $this->assertAPISuccess($result);
    $this->assertTrue(!empty($result['values'][$baoObj1->id]), 'Should find first object by id');
    $this->assertEquals($baoObj1->id, $result['values'][$baoObj1->id]['id'], 'Should find id on first object');
    $this->assertEquals(1, count($result['values']));

    // fetch second by ID
    $result = civicrm_api($entityName, 'get', [
      'version' => 3,
      $idFieldName => $baoObj2->id,
    ]);
    $this->assertAPISuccess($result);
    $this->assertTrue(!empty($result['values'][$baoObj2->id]), 'Should find second object by id');
    $this->assertEquals($baoObj2->id, $result['values'][$baoObj2->id]['id'], 'Should find id on second object');
    $this->assertEquals(1, count($result['values']));
  }

  /**
   * @dataProvider entities_get
   * @param $Entity
   */
  public function testNonExistantID_get($Entity) {
    // cf testAcceptsOnlyID_get
    $nonExistantID = 30867307034;
    if (in_array($Entity, $this->toBeImplemented['get'])) {
      return;
    }

    $result = civicrm_api($Entity, 'Get', ['version' => 3, 'id' => $nonExistantID]);

    // redundant with testAcceptsOnlyID_get
    if ($result['is_error']) {
      return;
    }

    $this->assertArrayHasKey('version', $result);
    $this->assertEquals(3, $result['version']);
    if (!in_array($Entity, $this->getOnlyIDNonZeroCount(), TRUE)) {
      $this->assertEquals(0, $result['count']);
    }
  }

  /* ---- testing the _create ---- */

  /**
   * As the test is slow we mark it ornery to suppress in PR runs.
   *
   * @group ornery
   *
   * @dataProvider toBeSkipped_create
   * entities that don't need a create action
   *
   * @param $Entity
   */
  public function testNotImplementedCreate($Entity) {
    $result = civicrm_api($Entity, 'Create', ['version' => 3]);
    $this->assertEquals(1, $result['is_error']);
    $this->assertStringContainsString(strtolower("API ($Entity, Create) does not exist"), strtolower($result['error_message']));
  }

  /**
   * @dataProvider entities_create
   *
   * Check that create doesn't work with an invalid
   * @param $Entity
   * @throws \PHPUnit\Framework\IncompleteTestError
   */
  public function testInvalidSort_get($Entity) {
    $invalidEntitys = ['Setting', 'System'];
    if (in_array($Entity, $invalidEntitys)) {
      $this->markTestSkipped('It seems OK for ' . $Entity . ' to skip here as it silently sips invalid params');
    }
    $result = $this->callAPIFailure($Entity, 'get', ['options' => ['sort' => 'sleep(1)']]);
  }

  /**
   * @dataProvider entities_create
   *
   * Check that create doesn't work with an invalid
   * @param $Entity
   * @throws \PHPUnit\Framework\IncompleteTestError
   */
  public function testValidSortSingleArrayById_get($Entity) {
    $invalidEntitys = ['Setting', 'System'];
    $tests = [
      'id' => '_id',
      'id desc' => '_id desc',
      'id DESC' => '_id DESC',
      'id ASC' => '_id ASC',
      'id asc' => '_id asc',
    ];
    foreach ($tests as $test => $expected) {
      if (in_array($Entity, $invalidEntitys)) {
        $this->markTestSkipped('It seems OK for ' . $Entity . ' to skip here as it silently ignores passed in params');
      }
      $params = ['sort' => [$test]];
      $result = _civicrm_api3_get_options_from_params($params, FALSE, $Entity, 'get');
      $lowercase_entity = _civicrm_api_get_entity_name_from_camel($Entity);
      $this->assertEquals($lowercase_entity . $expected, $result['sort']);
    }
  }

  /**
   * @dataProvider entities_updatesingle
   *
   * limitations include the problem with avoiding loops when creating test objects -
   * hence FKs only set by createTestObject when required. e.g parent_id on campaign is not being followed through
   * Currency - only seems to support US.
   *
   * As the test is slow we mark it ornery to suppress in PR runs.
   *
   * @param string $entityName
   *
   * @group ornery
   *
   * @throws \CRM_Core_Exception
   */
  public function testCreateSingleValueAlter(string $entityName): void {
    if (in_array($entityName, $this->toBeImplemented['create'], TRUE)) {
      // $this->markTestIncomplete("civicrm_api3_{$Entity}_create to be implemented");
      return;
    }

    $floatFields = [];
    $baoString = _civicrm_api3_get_BAO($entityName);
    $this->assertNotEmpty($baoString, $entityName);
    $this->assertNotEmpty($entityName, $entityName);
    $fieldsGet = $fields = $this->callAPISuccess($entityName, 'getfields', ['action' => 'get', 'options' => ['get_options' => 'all']]);
    if ($entityName !== 'Pledge') {
      $fields = $this->callAPISuccess($entityName, 'getfields', ['action' => 'create', 'options' => ['get_options' => 'all']]);
    }
    $fields = $fields['values'];
    $return = array_keys($fieldsGet['values']);
    $valuesNotToReturn = $this->getKnownUnworkablesUpdateSingle($entityName, 'break_return');
    $valuesNotToReturn[] = 'modified_date';
    $valuesNotToReturn[] = 'create_date';
    // these can't be requested as return values
    $entityValuesThatDoNotWork = array_merge(
      $this->getKnownUnworkablesUpdateSingle($entityName, 'cant_update'),
      $this->getKnownUnworkablesUpdateSingle($entityName, 'cant_return'),
      $valuesNotToReturn
    );

    $return = array_diff($return, $valuesNotToReturn);
    $baoObj = new CRM_Core_DAO();
    $baoObj::createTestObject($baoString, ['currency' => 'USD'], 2, 0);

    $getEntities = $this->callAPISuccess($entityName, 'get', [
      'sequential' => 1,
      'return' => $return,
      'options' => [
        'sort' => 'id DESC',
        'limit' => 2,
      ],
    ]);

    // lets use first rather than assume only one exists
    $entity = $getEntities['values'][0];
    $entity2 = $getEntities['values'][1];
    $this->deletableTestObjects[$baoString][] = $entity['id'];
    $this->deletableTestObjects[$baoString][] = $entity2['id'];
    // Skip these fields that we never really expect to update well.
    $genericFieldsToSkip = ['currency', 'modified_date', 'create_date', 'id', strtolower($entityName) . '_id', 'is_primary'];
    foreach ($fields as $field => $specs) {
      $resetFKTo = NULL;
      $fieldName = $field;

      if (in_array($field, $genericFieldsToSkip)
        || in_array($field, $entityValuesThatDoNotWork)
      ) {
        //@todo id & entity_id are correct but we should fix currency & frequency_day
        continue;
      }
      $this->assertArrayHasKey('type', $specs, "the _spec function for $entityName field $field does not specify the type");
      switch ($specs['type']) {
        case CRM_Utils_Type::T_DATE:
          $entity[$fieldName] = '2012-05-20';
          break;

        case CRM_Utils_Type::T_TIMESTAMP:
        case 12:
          $entity[$fieldName] = '2012-05-20 03:05:20';
          break;

        case CRM_Utils_Type::T_STRING:
        case CRM_Utils_Type::T_BLOB:
        case CRM_Utils_Type::T_MEDIUMBLOB:
        case CRM_Utils_Type::T_TEXT:
        case CRM_Utils_Type::T_LONGTEXT:
        case CRM_Utils_Type::T_EMAIL:
          if ($fieldName === 'form_values' && $entityName === 'SavedSearch') {
            // This is a hack for the SavedSearch API.
            // It expects form_values to be an array.
            // If you want to fix this, you should definitely read this forum
            // post.
            // http://forum.civicrm.org/index.php/topic,33990.0.html
            // See also my question on the CiviCRM Stack Exchange:
            // https://civicrm.stackexchange.com/questions/3437
            $entity[$fieldName] = ['sort_name' => "SortName2"];
          }
          else {
            $entity[$fieldName] = substr('New String', 0, $specs['maxlength'] ?? 100);
            if ($fieldName == 'email') {
              $entity[$fieldName] = strtolower($entity[$fieldName]);
            }
            // typecast with array to satisfy changes made in CRM-13160
            if ($entityName == 'MembershipType' && in_array($fieldName, ['relationship_type_id', 'relationship_direction'])) {
              $entity[$fieldName] = (array) $entity[$fieldName];
            }
          }
          break;

        case CRM_Utils_Type::T_INT:
          // probably created with a 1
          if ($fieldName == 'weight') {
            $entity[$fieldName] = 2;
          }
          elseif (!empty($specs['FKClassName'])) {
            if ($specs['FKClassName'] == $baoString) {
              $entity[$fieldName] = (string) $entity2['id'];
            }
            else {
              if (!empty($entity[$fieldName])) {
                $resetFKTo = [$fieldName => $entity[$fieldName]];
              }
              $entity[$fieldName] = (string) empty($entity2[$field]) ? '' : $entity2[$field];
              //todo - there isn't always something set here - & our checking on unset values is limited
              if (empty($entity[$field])) {
                unset($entity[$field]);
              }
            }
          }
          else {
            $entity[$fieldName] = '6';
          }
          break;

        case CRM_Utils_Type::T_BOOLEAN:
          // probably created with a 1
          $entity[$fieldName] = '0';
          break;

        case CRM_Utils_Type::T_FLOAT:
        case CRM_Utils_Type::T_MONEY:
          $floatFields[] = $field;
          $entity[$field] = '22.75';
          break;

        case CRM_Utils_Type::T_URL:
          $entity[$field] = 'warm.beer.com';
      }
      if (empty($specs['FKClassName']) && (!empty($specs['pseudoconstant']) || !empty($specs['options']))) {
        $options = $specs['options'] ?? [];
        if (!$options) {
          //eg. pdf_format id doesn't ship with any
          if (isset($specs['pseudoconstant']['optionGroupName'])) {
            $optionValue = $this->callAPISuccess('option_value', 'create', [
              'option_group_id' => $specs['pseudoconstant']['optionGroupName'],
              'label' => 'new option value',
              'sequential' => 1,
            ]);
            $optionValue = $optionValue['values'];
            $keyColumn = $specs['pseudoconstant']['keyColumn'] ?? 'value';
            $options[$optionValue[0][$keyColumn]] = 'new option value';
          }
        }
        if ($options) {
          $entity[$field] = array_rand($options);
        }
      }
      if (!empty($specs['FKClassName']) && !empty($specs['pseudoconstant'])) {
        // in the weird situation where a field has both an fk and pseudoconstant defined,
        // e.g. campaign_id field, need to flush caches.
        // FIXME: Why doesn't creating a campaign clear caches?
        civicrm_api3($entityName, 'getfields', ['cache_clear' => 1]);
      }
      if (!empty($specs['serialize'])) {
        $entity[$field] = (array) $entity[$field];
      }
      $updateParams = [
        'id' => $entity['id'],
        $field => $entity[$field] ?? NULL,
      ];
      if (isset($updateParams['next_sched_contribution_date']) && in_array($entityName, ['ContributionRecur'])) {
        //api has special handling on these 2 fields for backward compatibility reasons
        $entity['next_sched_contribution'] = $updateParams['next_sched_contribution_date'];
      }
      if (isset($updateParams['image'])) {
        // Image field is passed through simplifyURL function so may be different, do the same here for comparison
        $entity['image'] = CRM_Utils_String::simplifyURL($updateParams['image'], TRUE);
      }
      if (isset($updateParams['thumbnail'])) {
        // Thumbnail field is passed through simplifyURL function so may be different, do the same here for comparison
        $entity['thumbnail'] = CRM_Utils_String::simplifyURL($updateParams['thumbnail'], TRUE);
      }

      $update = $this->callAPISuccess($entityName, 'create', $updateParams);
      $checkParams = [
        'id' => $entity['id'],
        'sequential' => 1,
        'return' => $return,
        'options' => [
          'sort' => 'id DESC',
          'limit' => 2,
        ],
      ];

      $checkEntity = $this->callAPISuccess($entityName, 'getsingle', $checkParams);
      if (!empty($specs['serialize']) && !is_array($checkEntity[$field])) {
        // Put into serialized format for comparison if 'get' has not returned serialized.
        $entity[$field] = CRM_Core_DAO::serializeField($checkEntity[$field], $specs['serialize']);
      }

      foreach ($floatFields as $floatField) {
        $checkEntity[$floatField] = rtrim($checkEntity[$floatField], "0");
      }
      unset($entity['xdebug']);
      unset($checkEntity['xdebug']);
      unset($update['xdebug']);
      $this->assertAPIArrayComparison($entity, $checkEntity, [], "checking if $fieldName was correctly updated\n" . print_r([
        'update-params' => $updateParams,
        'update-result' => $update,
        'getsingle-params' => $checkParams,
        'getsingle-result' => $checkEntity,
        'expected entity' => $entity,
      ], TRUE));
      if ($resetFKTo) {
        //reset the foreign key fields because otherwise our cleanup routine fails & some other unexpected stuff can kick in
        $entity = array_merge($entity, $resetFKTo);
        $updateParams = array_merge($updateParams, $resetFKTo);
        $this->callAPISuccess($entityName, 'create', $updateParams);
        if (isset($updateParams['next_sched_contribution_date']) && in_array($entityName, ['ContributionRecur'])) {
          //api has special handling on these 2 fields for backward compatibility reasons
          $entity['next_sched_contribution'] = $updateParams['next_sched_contribution_date'];
        }
      }
    }
  }

  /* ---- testing the _getFields ---- */

  /* ---- testing the _delete ---- */

  /**
   * @dataProvider toBeSkipped_delete
   * entities that don't need a delete action
   * @param $Entity
   */
  public function testNotImplemented_delete($Entity) {
    $nonExistantID = 151416349;
    $result = civicrm_api($Entity, 'Delete', ['version' => 3, 'id' => $nonExistantID]);
    $this->assertEquals(1, $result['is_error']);
    $this->assertStringContainsString(strtolower("API ($Entity, Delete) does not exist"), strtolower($result['error_message']));
  }

  /**
   *  As the test is slow we mark it ornery to suppress in PR runs.
   *
   * @group ornery
   *
   * @dataProvider entities_delete
   *
   * @param $Entity
   */
  public function testEmptyParamDelete($Entity) {
    if (in_array($Entity, $this->toBeImplemented['delete'])) {
      // $this->markTestIncomplete("civicrm_api3_{$Entity}_delete to be implemented");
      return;
    }
    $result = civicrm_api($Entity, 'Delete', []);
    $this->assertEquals(1, $result['is_error']);
    $this->assertStringContainsString("Unknown api version", $result['error_message']);
  }

  /**
   * @dataProvider entities_delete
   * @param $Entity
   * @throws \PHPUnit\Framework\IncompleteTestError
   */
  public function testInvalidID_delete($Entity) {
    $result = $this->callAPIFailure($Entity, 'Delete', ['id' => 999999]);
  }

  /**
   * Create two entities and make sure delete action only deletes one!
   *
   * @dataProvider entitiesGetfields
   * @param string $entity
   */
  public function testGetfieldsHasTitle(string $entity): void {
    $entities = $this->getEntitiesSupportingCustomFields();
    if (in_array($entity, $entities, TRUE)) {
      $ids = $this->entityCustomGroupWithSingleFieldCreate(__FUNCTION__, $entity . 'Test.php');
    }
    $actions = $this->callAPISuccess($entity, 'getactions', []);
    foreach ($actions['values'] as $action) {
      if (substr($action, -7) == '_create' || substr($action, -4) == '_get' || substr($action, -7) == '_delete') {
        //getactions can't distinguish between contribution_page.create & contribution_page.create
        continue;
      }
      $fields = $this->callAPISuccess($entity, 'getfields', ['action' => $action]);
      if (!empty($ids) && in_array($action, ['create', 'get'])) {
        $this->assertArrayHasKey('custom_' . $ids['custom_field_id'], $fields['values']);
      }

      foreach ($fields['values'] as $fieldName => $fieldSpec) {
        $this->assertArrayHasKey('title', $fieldSpec, "no title for $entity - $fieldName on action $action");
        $this->assertNotEmpty($fieldSpec['title'], "empty title for $entity - $fieldName");
      }
    }
    if (!empty($ids)) {
      $this->customFieldDelete($ids['custom_field_id']);
      $this->customGroupDelete($ids['custom_group_id']);
    }
  }

  /**
   * @return array
   */
  public function getEntitiesSupportingCustomFields(): array {
    $entities = self::custom_data_entities_get();
    $returnEntities = [];
    foreach ($entities as $entityArray) {
      $returnEntities[] = $entityArray[0];
    }
    return $returnEntities;
  }

  /**
   * @param string $entityName
   * @param int $count
   *
   * @return array
   */
  private function getMockableBAOObjects($entityName, $count = 2): array {
    $baoString = _civicrm_api3_get_BAO($entityName);
    if (empty($baoString)) {
      $this->markTestIncomplete("Entity [$entityName] cannot be mocked - no known DAO");
      return [];
    }
    $objects = [];
    $i = 0;
    while ($i < $count) {
      // create entities
      $baoObj = CRM_Core_DAO::createTestObject($baoString, ['currency' => 'USD']);
      $this->assertIsInt($baoObj->id, 'check first id');
      $this->deletableTestObjects[$baoString][] = $baoObj->id;
      $objects[] = $baoObj;
      $i++;
    }
    return $objects;
  }

  /**
   * Verify that HTML metacharacters provided as inputs appear consistently.
   * as outputs.
   *
   * At time of writing, the encoding scheme requires (for example) that an
   * event title be partially-HTML-escaped before writing to DB.  To provide
   * consistency, the API must perform extra encoding and decoding on some
   * fields.
   *
   * In this example, the event 'title' is subject to encoding, but the
   * event 'description' is not.
   */
  public function testEncodeDecodeConsistency(): void {
    // Create example
    $createResult = civicrm_api('Event', 'Create', [
      'version' => 3,
      'title' => 'CiviCRM <> TheRest',
      'description' => 'TheRest <> CiviCRM',
      'event_type_id' => 1,
      'is_public' => 1,
      'start_date' => 20081021,
    ]);
    $this->assertAPISuccess($createResult);
    $eventId = $createResult['id'];
    $this->assertEquals('CiviCRM <> TheRest', $createResult['values'][$eventId]['title']);
    $this->assertEquals('TheRest <> CiviCRM', $createResult['values'][$eventId]['description']);

    // Verify "get" handles decoding in result value
    $getByIdResult = civicrm_api('Event', 'Get', [
      'version' => 3,
      'id' => $eventId,
    ]);
    $this->assertAPISuccess($getByIdResult);
    $this->assertEquals('CiviCRM <> TheRest', $getByIdResult['values'][$eventId]['title']);
    $this->assertEquals('TheRest <> CiviCRM', $getByIdResult['values'][$eventId]['description']);

    // Verify "get" handles encoding in search value
    $getByTitleResult = civicrm_api('Event', 'Get', [
      'version' => 3,
      'title' => 'CiviCRM <> TheRest',
    ]);
    $this->assertAPISuccess($getByTitleResult);
    $this->assertEquals('CiviCRM <> TheRest', $getByTitleResult['values'][$eventId]['title']);
    $this->assertEquals('TheRest <> CiviCRM', $getByTitleResult['values'][$eventId]['description']);

    // Verify that "getSingle" handles decoding
    $getSingleResult = $this->callAPISuccess('Event', 'GetSingle', [
      'id' => $eventId,
    ]);

    $this->assertEquals('CiviCRM <> TheRest', $getSingleResult['title']);
    $this->assertEquals('TheRest <> CiviCRM', $getSingleResult['description']);

    // Verify that chaining handles decoding
    $chainResult = $this->callAPISuccess('Event', 'Get', [
      'id' => $eventId,
      'api.event.get' => [],
    ]);
    $this->assertEquals('CiviCRM <> TheRest', $chainResult['values'][$eventId]['title']);
    $this->assertEquals('TheRest <> CiviCRM', $chainResult['values'][$eventId]['description']);
    $this->assertEquals('CiviCRM <> TheRest', $chainResult['values'][$eventId]['api.event.get']['values'][0]['title']);
    $this->assertEquals('TheRest <> CiviCRM', $chainResult['values'][$eventId]['api.event.get']['values'][0]['description']);

    // Verify that "setvalue" handles encoding for updates
    $setValueTitleResult = civicrm_api('Event', 'setvalue', [
      'version' => 3,
      'id' => $eventId,
      'field' => 'title',
      'value' => 'setValueTitle: CiviCRM <> TheRest',
    ]);
    $this->assertAPISuccess($setValueTitleResult);
    $this->assertEquals('setValueTitle: CiviCRM <> TheRest', $setValueTitleResult['values']['title']);
    $setValueDescriptionResult = civicrm_api('Event', 'setvalue', [
      'version' => 3,
      'id' => $eventId,
      'field' => 'description',
      'value' => 'setValueDescription: TheRest <> CiviCRM',
    ]);
    //$this->assertTrue((bool)$setValueDescriptionResult['is_error']); // not supported by setValue
    $this->assertEquals('setValueDescription: TheRest <> CiviCRM', $setValueDescriptionResult['values']['description']);
  }

  /**
   * Verify that write operations (create/update) use partial HTML-encoding
   *
   * In this example, the event 'title' is subject to encoding, but the
   * event 'description' is not.
   */
  public function testEncodeWrite(): void {
    // Create example
    $createResult = civicrm_api('Event', 'Create', [
      'version' => 3,
      'title' => 'createNew: CiviCRM <> TheRest',
      'description' => 'createNew: TheRest <> CiviCRM',
      'event_type_id' => 1,
      'is_public' => 1,
      'start_date' => 20081021,
    ]);
    $this->assertAPISuccess($createResult);
    $eventId = $createResult['id'];
    $this->assertDBQuery('createNew: CiviCRM &lt;&gt; TheRest', 'SELECT title FROM civicrm_event WHERE id = %1', [
      1 => [$eventId, 'Integer'],
    ]);
    $this->assertDBQuery('createNew: TheRest <> CiviCRM', 'SELECT description FROM civicrm_event WHERE id = %1', [
      1 => [$eventId, 'Integer'],
    ]);

    // Verify that "create" handles encoding for updates
    $createWithIdResult = civicrm_api('Event', 'Create', [
      'version' => 3,
      'id' => $eventId,
      'title' => 'createWithId:  CiviCRM <> TheRest',
      'description' => 'createWithId:  TheRest <> CiviCRM',
    ]);
    $this->assertAPISuccess($createWithIdResult);
    $this->assertDBQuery('createWithId:  CiviCRM &lt;&gt; TheRest', 'SELECT title FROM civicrm_event WHERE id = %1', [
      1 => [$eventId, 'Integer'],
    ]);
    $this->assertDBQuery('createWithId:  TheRest <> CiviCRM', 'SELECT description FROM civicrm_event WHERE id = %1', [
      1 => [$eventId, 'Integer'],
    ]);

    // Verify that "setvalue" handles encoding for updates
    $setValueTitleResult = civicrm_api('Event', 'setvalue', [
      'version' => 3,
      'id' => $eventId,
      'field' => 'title',
      'value' => 'setValueTitle: CiviCRM <> TheRest',
    ]);
    $this->assertAPISuccess($setValueTitleResult);
    $this->assertDBQuery('setValueTitle: CiviCRM &lt;&gt; TheRest', 'SELECT title FROM civicrm_event WHERE id = %1', [
      1 => [$eventId, 'Integer'],
    ]);
    $setValueDescriptionResult = civicrm_api('Event', 'setvalue', [
      'version' => 3,
      'id' => $eventId,
      'field' => 'description',
      'value' => 'setValueDescription: TheRest <> CiviCRM',
    ]);
    //$this->assertTrue((bool)$setValueDescriptionResult['is_error']); // not supported by setValue
    $this->assertAPISuccess($setValueDescriptionResult);
    $this->assertDBQuery('setValueDescription: TheRest <> CiviCRM', 'SELECT description FROM civicrm_event WHERE id = %1', [
      1 => [$eventId, 'Integer'],
    ]);
  }

  /**
   * Get entities that have non-zero counts already.
   *
   * @return string[]
   */
  protected function getOnlyIDNonZeroCount(): array {
    return [
      'Entity',
      'Domain',
      'Setting',
      'User',
    ];
  }

}
