APIv4 - Add `@since` annotation to each entity
[civicrm-core.git] / tests / phpunit / api / v4 / Entity / ConformanceTest.php
CommitLineData
19b53e5b
C
1<?php
2
380f3545
TO
3/*
4 +--------------------------------------------------------------------+
7d61e75f 5 | Copyright CiviCRM LLC. All rights reserved. |
380f3545 6 | |
7d61e75f
TO
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
380f3545
TO
10 +--------------------------------------------------------------------+
11 */
12
13/**
14 *
15 * @package CRM
ca5cec67 16 * @copyright CiviCRM LLC https://civicrm.org/licensing
380f3545
TO
17 */
18
19
19b53e5b
C
20namespace api\v4\Entity;
21
63ee6673 22use Civi\API\Exception\UnauthorizedException;
19b53e5b 23use Civi\Api4\Entity;
19b53e5b 24use api\v4\UnitTestCase;
4bf92107 25use Civi\Api4\Event\ValidateValuesEvent;
eb378b8a 26use Civi\Api4\Utils\CoreUtil;
63ee6673 27use Civi\Test\HookInterface;
19b53e5b
C
28
29/**
30 * @group headless
31 */
63ee6673 32class ConformanceTest extends UnitTestCase implements HookInterface {
19b53e5b 33
63ee6673 34 use \api\v4\Traits\CheckAccessTrait;
30755fcb 35 use \api\v4\Traits\TableDropperTrait;
19b53e5b
C
36 use \api\v4\Traits\OptionCleanupTrait {
37 setUp as setUpOptionCleanup;
38 }
39
40 /**
41 * @var \api\v4\Service\TestCreationParameterProvider
42 */
43 protected $creationParamProvider;
44
45 /**
46 * Set up baseline for testing
47 */
0b49aa04 48 public function setUp(): void {
19b53e5b 49 $tablesToTruncate = [
96f09dda 50 'civicrm_case_type',
19b53e5b
C
51 'civicrm_custom_group',
52 'civicrm_custom_field',
53 'civicrm_group',
54 'civicrm_event',
55 'civicrm_participant',
56 ];
57 $this->dropByPrefix('civicrm_value_myfavorite');
58 $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
59 $this->setUpOptionCleanup();
baf63a69 60 $this->loadDataSet('CaseType');
19b53e5b
C
61 $this->loadDataSet('ConformanceTest');
62 $this->creationParamProvider = \Civi::container()->get('test.param_provider');
63 parent::setUp();
63ee6673 64 $this->resetCheckAccess();
19b53e5b
C
65 }
66
5acc6183 67 /**
68 * Get entities to test.
69 *
8868b7fc
TO
70 * This is the hi-tech list as generated via Civi's runtime services. It
71 * is canonical, but relies on services that may not be available during
72 * early parts of PHPUnit lifecycle.
73 *
5acc6183 74 * @return array
75 *
76 * @throws \API_Exception
77 * @throws \Civi\API\Exception\UnauthorizedException
78 */
8868b7fc 79 public function getEntitiesHitech() {
d31fb4e3 80 // Ensure all components are enabled so their entities show up
a62d97f3
CW
81 foreach (array_keys(\CRM_Core_Component::getComponents()) as $component) {
82 \CRM_Core_BAO_ConfigSetting::enableComponent($component);
83 }
fe806431 84 return $this->toDataProviderArray(Entity::get(FALSE)->execute()->column('name'));
19b53e5b
C
85 }
86
87 /**
8868b7fc
TO
88 * Get entities to test.
89 *
90 * This is the low-tech list as generated by manual-overrides and direct inspection.
91 * It may be summoned at any time during PHPUnit lifecycle, but it may require
92 * occasional twiddling to give correct results.
93 *
94 * @return array
19b53e5b 95 */
8868b7fc
TO
96 public function getEntitiesLotech() {
97 $manual['add'] = [];
98 $manual['remove'] = ['CustomValue'];
a62d97f3 99 $manual['transform'] = ['CiviCase' => 'Case'];
8868b7fc
TO
100
101 $scanned = [];
155ea29a 102 $srcDir = dirname(__DIR__, 5);
8868b7fc 103 foreach ((array) glob("$srcDir/Civi/Api4/*.php") as $name) {
a62d97f3
CW
104 $fileName = basename($name, '.php');
105 $scanned[] = $manual['transform'][$fileName] ?? $fileName;
19b53e5b 106 }
8868b7fc
TO
107
108 $names = array_diff(
109 array_unique(array_merge($scanned, $manual['add'])),
110 $manual['remove']
111 );
112
113 return $this->toDataProviderArray($names);
114 }
115
116 /**
117 * Ensure that "getEntitiesLotech()" (which is the 'dataProvider') is up to date
118 * with "getEntitiesHitech()" (which is a live feed available entities).
119 */
120 public function testEntitiesProvider() {
121 $this->assertEquals($this->getEntitiesHitech(), $this->getEntitiesLotech(), "The lo-tech list of entities does not match the hi-tech list. You probably need to update getEntitiesLotech().");
122 }
123
124 /**
125 * @param string $entity
126 * Ex: 'Contact'
155ea29a 127 *
8868b7fc 128 * @dataProvider getEntitiesLotech
155ea29a 129 *
130 * @throws \API_Exception
8868b7fc 131 */
155ea29a 132 public function testConformance($entity): void {
eb378b8a 133 $entityClass = CoreUtil::getApiClass($entity);
8868b7fc 134
30755fcb 135 $this->checkEntityInfo($entityClass);
8868b7fc
TO
136 $actions = $this->checkActions($entityClass);
137
138 // Go no further if it's not a CRUD entity
139 if (array_diff(['get', 'create', 'update', 'delete'], array_keys($actions))) {
30755fcb 140 $this->markTestSkipped("The API \"$entity\" does not implement CRUD actions");
8868b7fc
TO
141 }
142
143 $this->checkFields($entityClass, $entity);
63ee6673 144 $this->checkCreationDenied($entity, $entityClass);
8868b7fc
TO
145 $id = $this->checkCreation($entity, $entityClass);
146 $this->checkGet($entityClass, $id, $entity);
63ee6673 147 $this->checkGetAllowed($entityClass, $id, $entity);
8868b7fc
TO
148 $this->checkGetCount($entityClass, $id, $entity);
149 $this->checkUpdateFailsFromCreate($entityClass, $id);
150 $this->checkWrongParamType($entityClass);
151 $this->checkDeleteWithNoId($entityClass);
63ee6673
TO
152 $this->checkDeletionDenied($entityClass, $id, $entity);
153 $this->checkDeletionAllowed($entityClass, $id, $entity);
8868b7fc 154 $this->checkPostDelete($entityClass, $id, $entity);
19b53e5b
C
155 }
156
157 /**
30755fcb
CW
158 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
159 */
155ea29a 160 protected function checkEntityInfo($entityClass): void {
30755fcb
CW
161 $info = $entityClass::getInfo();
162 $this->assertNotEmpty($info['name']);
163 $this->assertNotEmpty($info['title']);
9813ae79 164 $this->assertNotEmpty($info['title_plural']);
30755fcb
CW
165 $this->assertNotEmpty($info['type']);
166 $this->assertNotEmpty($info['description']);
d44cc3cb 167 $this->assertRegExp(';^\d\.\d\d$;', $info['since']);
c5076889 168 $this->assertContains($info['searchable'], ['primary', 'secondary', 'bridge', 'none']);
27d31a0f
CW
169 // Bridge must be between exactly 2 entities
170 if (in_array('EntityBridge', $info['type'], TRUE)) {
171 $this->assertCount(2, $info['bridge']);
172 }
30755fcb
CW
173 }
174
175 /**
176 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
5acc6183 177 * @param string $entity
155ea29a 178 *
179 * @throws \API_Exception
19b53e5b
C
180 */
181 protected function checkFields($entityClass, $entity) {
fe806431 182 $fields = $entityClass::getFields(FALSE)
a1415a02 183 ->addWhere('type', '=', 'Field')
19b53e5b
C
184 ->execute()
185 ->indexBy('name');
186
187 $errMsg = sprintf('%s is missing required ID field', $entity);
188 $subset = ['data_type' => 'Integer'];
189
df347a8c
SL
190 $this->assertArrayHasKey('data_type', $fields['id'], $errMsg);
191 $this->assertEquals('Integer', $fields['id']['data_type']);
19b53e5b
C
192 }
193
194 /**
30755fcb 195 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
5acc6183 196 *
197 * @return array
155ea29a 198 *
199 * @throws \API_Exception
19b53e5b 200 */
155ea29a 201 protected function checkActions($entityClass): array {
fe806431 202 $actions = $entityClass::getActions(FALSE)
19b53e5b
C
203 ->execute()
204 ->indexBy('name');
205
206 $this->assertNotEmpty($actions);
207 return (array) $actions;
208 }
209
210 /**
211 * @param string $entity
212 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
213 *
214 * @return mixed
215 */
216 protected function checkCreation($entity, $entityClass) {
f818ece6 217 $isReadOnly = $this->isReadOnly($entityClass);
cb72c80e 218
4bf92107
TO
219 $hookLog = [];
220 $onValidate = function(ValidateValuesEvent $e) use (&$hookLog) {
63ee6673 221 $hookLog[$e->getEntityName()][$e->getActionName()] = 1 + ($hookLog[$e->getEntityName()][$e->getActionName()] ?? 0);
4bf92107
TO
222 };
223 \Civi::dispatcher()->addListener('civi.api4.validate', $onValidate);
224 \Civi::dispatcher()->addListener('civi.api4.validate::' . $entity, $onValidate);
225
63ee6673
TO
226 $this->setCheckAccessGrants(["{$entity}::create" => TRUE]);
227 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]);
228
19b53e5b
C
229 $requiredParams = $this->creationParamProvider->getRequired($entity);
230 $createResult = $entityClass::create()
231 ->setValues($requiredParams)
cb72c80e 232 ->setCheckPermissions(!$isReadOnly)
19b53e5b
C
233 ->execute()
234 ->first();
235
236 $this->assertArrayHasKey('id', $createResult, "create missing ID");
237 $id = $createResult['id'];
19b53e5b 238 $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");
cb72c80e
TO
239 if (!$isReadOnly) {
240 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]);
241 }
63ee6673 242 $this->resetCheckAccess();
19b53e5b 243
4bf92107
TO
244 $this->assertEquals(2, $hookLog[$entity]['create']);
245 \Civi::dispatcher()->removeListener('civi.api4.validate', $onValidate);
246 \Civi::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate);
19b53e5b
C
247
248 return $id;
249 }
250
63ee6673
TO
251 /**
252 * @param string $entity
253 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
254 *
255 * @return mixed
256 */
257 protected function checkCreationDenied($entity, $entityClass) {
258 $this->setCheckAccessGrants(["{$entity}::create" => FALSE]);
259 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]);
260
261 $requiredParams = $this->creationParamProvider->getRequired($entity);
262
263 try {
264 $entityClass::create()
265 ->setValues($requiredParams)
266 ->setCheckPermissions(TRUE)
267 ->execute()
268 ->first();
269 $this->fail("{$entityClass}::create() should throw an authorization failure.");
270 }
271 catch (UnauthorizedException $e) {
272 // OK, expected exception
273 }
f818ece6 274 if (!$this->isReadOnly($entityClass)) {
cb72c80e
TO
275 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]);
276 }
63ee6673
TO
277 $this->resetCheckAccess();
278 }
279
19b53e5b
C
280 /**
281 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
282 * @param int $id
283 */
155ea29a 284 protected function checkUpdateFailsFromCreate($entityClass, $id): void {
19b53e5b
C
285 $exceptionThrown = '';
286 try {
fe806431 287 $entityClass::create(FALSE)
19b53e5b
C
288 ->addValue('id', $id)
289 ->execute();
290 }
291 catch (\API_Exception $e) {
292 $exceptionThrown = $e->getMessage();
293 }
df347a8c 294 $this->assertStringContainsString('id', $exceptionThrown);
19b53e5b
C
295 }
296
297 /**
298 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
299 * @param int $id
300 * @param string $entity
301 */
302 protected function checkGet($entityClass, $id, $entity) {
fe806431 303 $getResult = $entityClass::get(FALSE)
19b53e5b
C
304 ->addWhere('id', '=', $id)
305 ->execute();
306
307 $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
308 $this->assertEquals($id, $getResult->first()['id'], $errMsg);
309 $this->assertEquals(1, $getResult->count(), $errMsg);
310 }
311
63ee6673
TO
312 /**
313 * Use a permissioned request for `get()`, with access grnted
314 * via checkAccess event.
315 *
316 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
317 * @param int $id
318 * @param string $entity
319 */
320 protected function checkGetAllowed($entityClass, $id, $entity) {
321 $this->setCheckAccessGrants(["{$entity}::get" => TRUE]);
322 $getResult = $entityClass::get()
323 ->addWhere('id', '=', $id)
324 ->execute();
325
326 $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
327 $this->assertEquals($id, $getResult->first()['id'], $errMsg);
328 $this->assertEquals(1, $getResult->count(), $errMsg);
329 $this->resetCheckAccess();
330 }
331
19b53e5b
C
332 /**
333 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
334 * @param int $id
335 * @param string $entity
336 */
155ea29a 337 protected function checkGetCount($entityClass, $id, $entity): void {
fe806431 338 $getResult = $entityClass::get(FALSE)
19b53e5b
C
339 ->addWhere('id', '=', $id)
340 ->selectRowCount()
341 ->execute();
342 $errMsg = sprintf('%s getCount failed', $entity);
343 $this->assertEquals(1, $getResult->count(), $errMsg);
344
fe806431 345 $getResult = $entityClass::get(FALSE)
19b53e5b
C
346 ->selectRowCount()
347 ->execute();
19b53e5b
C
348 $this->assertGreaterThanOrEqual(1, $getResult->count(), $errMsg);
349 }
350
351 /**
352 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
353 */
354 protected function checkDeleteWithNoId($entityClass) {
19b53e5b
C
355 try {
356 $entityClass::delete()
357 ->execute();
f818ece6 358 $this->fail("$entityClass should require ID to delete.");
19b53e5b
C
359 }
360 catch (\API_Exception $e) {
f818ece6 361 // OK
19b53e5b 362 }
19b53e5b
C
363 }
364
365 /**
366 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
367 */
368 protected function checkWrongParamType($entityClass) {
369 $exceptionThrown = '';
370 try {
371 $entityClass::get()
6764a9d3 372 ->setDebug('not a bool')
19b53e5b
C
373 ->execute();
374 }
375 catch (\API_Exception $e) {
376 $exceptionThrown = $e->getMessage();
377 }
df347a8c
SL
378 $this->assertStringContainsString('debug', $exceptionThrown);
379 $this->assertStringContainsString('type', $exceptionThrown);
19b53e5b
C
380 }
381
382 /**
63ee6673
TO
383 * Delete an entity - while having a targeted grant (hook_civirm_checkAccess).
384 *
19b53e5b
C
385 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
386 * @param int $id
63ee6673 387 * @param string $entity
19b53e5b 388 */
63ee6673
TO
389 protected function checkDeletionAllowed($entityClass, $id, $entity) {
390 $this->setCheckAccessGrants(["{$entity}::delete" => TRUE]);
391 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]);
f818ece6 392 $isReadOnly = $this->isReadOnly($entityClass);
63ee6673
TO
393
394 $deleteResult = $entityClass::delete()
f818ece6 395 ->setCheckPermissions(!$isReadOnly)
19b53e5b
C
396 ->addWhere('id', '=', $id)
397 ->execute();
398
399 // should get back an array of deleted id
400 $this->assertEquals([['id' => $id]], (array) $deleteResult);
f818ece6
CW
401 if (!$isReadOnly) {
402 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]);
403 }
63ee6673
TO
404 $this->resetCheckAccess();
405 }
406
407 /**
408 * Attempt to delete an entity while having explicitly denied permission (hook_civicrm_checkAccess).
409 *
410 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
411 * @param int $id
412 * @param string $entity
413 */
414 protected function checkDeletionDenied($entityClass, $id, $entity) {
415 $this->setCheckAccessGrants(["{$entity}::delete" => FALSE]);
416 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]);
417
418 try {
419 $entityClass::delete()
420 ->addWhere('id', '=', $id)
421 ->execute();
422 $this->fail("{$entity}::delete should throw an authorization failure.");
423 }
424 catch (UnauthorizedException $e) {
425 // OK
426 }
427
f818ece6
CW
428 if (!$this->isReadOnly($entityClass)) {
429 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]);
430 }
63ee6673 431 $this->resetCheckAccess();
19b53e5b
C
432 }
433
434 /**
435 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
436 * @param int $id
437 * @param string $entity
438 */
439 protected function checkPostDelete($entityClass, $id, $entity) {
fe806431 440 $getDeletedResult = $entityClass::get(FALSE)
19b53e5b
C
441 ->addWhere('id', '=', $id)
442 ->execute();
443
444 $errMsg = sprintf('Entity "%s" was not deleted', $entity);
445 $this->assertEquals(0, count($getDeletedResult), $errMsg);
446 }
447
8868b7fc
TO
448 /**
449 * @param array $names
450 * List of entity names.
451 * Ex: ['Foo', 'Bar']
452 * @return array
453 * List of data-provider arguments, one for each entity-name.
454 * Ex: ['Foo' => ['Foo'], 'Bar' => ['Bar']]
455 */
456 protected function toDataProviderArray($names) {
457 sort($names);
458
459 $result = [];
460 foreach ($names as $name) {
461 $result[$name] = [$name];
462 }
463 return $result;
464 }
465
f818ece6
CW
466 /**
467 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
468 * @return bool
469 */
470 protected function isReadOnly($entityClass) {
471 return in_array('ReadOnly', $entityClass::getInfo()['type'], TRUE);
472 }
473
19b53e5b 474}