[TASK] Move CSV fixtures handling to FunctionalTestCase
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Tests / FunctionalTestCase.php
1 <?php
2 namespace TYPO3\CMS\Core\Tests;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Tests\Functional\DataHandling\Framework\DataSet;
18 use TYPO3\CMS\Core\Tests\Functional\Framework\Frontend\Response;
19 use TYPO3\CMS\Core\Utility\GeneralUtility;
20
21 /**
22 * Base test case class for functional tests, all TYPO3 CMS
23 * functional tests should extend from this class!
24 *
25 * If functional tests need additional setUp() and tearDown() code,
26 * they *must* call parent::setUp() and parent::tearDown() to properly
27 * set up and destroy the test system.
28 *
29 * The functional test system creates a full new TYPO3 CMS instance
30 * within typo3temp/ of the base system and the bootstraps this TYPO3 instance.
31 * This abstract class takes care of creating this instance with its
32 * folder structure and a LocalConfiguration, creates an own database
33 * for each test run and imports tables of loaded extensions.
34 *
35 * Functional tests must be run standalone (calling native phpunit
36 * directly) and can not be executed by eg. the ext:phpunit backend module.
37 * Additionally, the script must be called from the document root
38 * of the instance, otherwise path calculation is not successfully.
39 *
40 * Call whole functional test suite, example:
41 * - cd /var/www/t3master/foo # Document root of CMS instance, here is index.php of frontend
42 * - typo3/../bin/phpunit -c typo3/sysext/core/Build/FunctionalTests.xml
43 *
44 * Call single test case, example:
45 * - cd /var/www/t3master/foo # Document root of CMS instance, here is index.php of frontend
46 * - typo3/../bin/phpunit \
47 * --process-isolation \
48 * --bootstrap typo3/sysext/core/Build/FunctionalTestsBootstrap.php \
49 * typo3/sysext/core/Tests/Functional/DataHandling/DataHandlerTest.php
50 */
51 abstract class FunctionalTestCase extends BaseTestCase
52 {
53 /**
54 * Core extensions to load.
55 *
56 * If the test case needs additional core extensions as requirement,
57 * they can be noted here and will be added to LocalConfiguration
58 * extension list and ext_tables.sql of those extensions will be applied.
59 *
60 * This property will stay empty in this abstract, so it is possible
61 * to just overwrite it in extending classes. Extensions noted here will
62 * be loaded for every test of a test case and it is not possible to change
63 * the list of loaded extensions between single tests of a test case.
64 *
65 * A default list of core extensions is always loaded.
66 *
67 * @see FunctionalTestCaseUtility $defaultActivatedCoreExtensions
68 * @var array
69 */
70 protected $coreExtensionsToLoad = [];
71
72 /**
73 * Array of test/fixture extensions paths that should be loaded for a test.
74 *
75 * This property will stay empty in this abstract, so it is possible
76 * to just overwrite it in extending classes. Extensions noted here will
77 * be loaded for every test of a test case and it is not possible to change
78 * the list of loaded extensions between single tests of a test case.
79 *
80 * Given path is expected to be relative to your document root, example:
81 *
82 * array(
83 * 'typo3conf/ext/some_extension/Tests/Functional/Fixtures/Extensions/test_extension',
84 * 'typo3conf/ext/base_extension',
85 * );
86 *
87 * Extensions in this array are linked to the test instance, loaded
88 * and their ext_tables.sql will be applied.
89 *
90 * @var array
91 */
92 protected $testExtensionsToLoad = [];
93
94 /**
95 * Array of test/fixture folder or file paths that should be linked for a test.
96 *
97 * This property will stay empty in this abstract, so it is possible
98 * to just overwrite it in extending classes. Path noted here will
99 * be linked for every test of a test case and it is not possible to change
100 * the list of folders between single tests of a test case.
101 *
102 * array(
103 * 'link-source' => 'link-destination'
104 * );
105 *
106 * Given paths are expected to be relative to the test instance root.
107 * The array keys are the source paths and the array values are the destination
108 * paths, example:
109 *
110 * array(
111 * 'typo3/sysext/impext/Tests/Functional/Fixtures/Folders/fileadmin/user_upload' =>
112 * 'fileadmin/user_upload',
113 * 'typo3conf/ext/my_own_ext/Tests/Functional/Fixtures/Folders/uploads/tx_myownext' =>
114 * 'uploads/tx_myownext'
115 * );
116 *
117 * To be able to link from my_own_ext the extension path needs also to be registered in
118 * property $testExtensionsToLoad
119 *
120 * @var array
121 */
122 protected $pathsToLinkInTestInstance = [];
123
124 /**
125 * This configuration array is merged with TYPO3_CONF_VARS
126 * that are set in default configuration and factory configuration
127 *
128 * @var array
129 */
130 protected $configurationToUseInTestInstance = [];
131
132 /**
133 * Array of folders that should be created inside the test instance document root.
134 *
135 * This property will stay empty in this abstract, so it is possible
136 * to just overwrite it in extending classes. Path noted here will
137 * be linked for every test of a test case and it is not possible to change
138 * the list of folders between single tests of a test case.
139 *
140 * Per default the following folder are created
141 * /fileadmin
142 * /typo3temp
143 * /typo3conf
144 * /typo3conf/ext
145 * /uploads
146 *
147 * To create additional folders add the paths to this array. Given paths are expected to be
148 * relative to the test instance root and have to begin with a slash. Example:
149 *
150 * array(
151 * 'fileadmin/user_upload'
152 * );
153 *
154 * @var array
155 */
156 protected $additionalFoldersToCreate = [];
157
158 /**
159 * The fixture which is used when initializing a backend user
160 *
161 * @var string
162 */
163 protected $backendUserFixture = 'typo3/sysext/core/Tests/Functional/Fixtures/be_users.xml';
164
165 /**
166 * Private utility class used in setUp() and tearDown(). Do NOT use in test cases!
167 *
168 * @var \TYPO3\CMS\Core\Tests\FunctionalTestCaseBootstrapUtility
169 */
170 private $bootstrapUtility = null;
171
172 /**
173 * Calculate a "unique" identifier for the test database and the
174 * instance patch based on the given test case class name.
175 *
176 * @return string
177 */
178 protected function getInstanceIdentifier()
179 {
180 return FunctionalTestCaseBootstrapUtility::getInstanceIdentifier(get_class($this));
181 }
182
183 /**
184 * Calculates path to TYPO3 CMS test installation for this test case.
185 *
186 * @return string
187 */
188 protected function getInstancePath()
189 {
190 return FunctionalTestCaseBootstrapUtility::getInstancePath(get_class($this));
191 }
192
193 /**
194 * Set up creates a test instance and database.
195 *
196 * This method should be called with parent::setUp() in your test cases!
197 *
198 * @return void
199 */
200 protected function setUp()
201 {
202 if (!defined('ORIGINAL_ROOT')) {
203 $this->markTestSkipped('Functional tests must be called through phpunit on CLI');
204 }
205 $this->bootstrapUtility = new FunctionalTestCaseBootstrapUtility();
206 $this->bootstrapUtility->setUp(
207 get_class($this),
208 $this->coreExtensionsToLoad,
209 $this->testExtensionsToLoad,
210 $this->pathsToLinkInTestInstance,
211 $this->configurationToUseInTestInstance,
212 $this->additionalFoldersToCreate
213 );
214 }
215
216 /**
217 * Get DatabaseConnection instance - $GLOBALS['TYPO3_DB']
218 *
219 * This method should be used instead of direct access to
220 * $GLOBALS['TYPO3_DB'] for easy IDE auto completion.
221 *
222 * @return \TYPO3\CMS\Core\Database\DatabaseConnection
223 */
224 protected function getDatabaseConnection()
225 {
226 return $GLOBALS['TYPO3_DB'];
227 }
228
229 /**
230 * Initialize backend user
231 *
232 * @param int $userUid uid of the user we want to initialize. This user must exist in the fixture file
233 * @return \TYPO3\CMS\Core\Authentication\BackendUserAuthentication
234 * @throws Exception
235 */
236 protected function setUpBackendUserFromFixture($userUid)
237 {
238 $this->importDataSet(ORIGINAL_ROOT . $this->backendUserFixture);
239 $database = $this->getDatabaseConnection();
240 $userRow = $database->exec_SELECTgetSingleRow('*', 'be_users', 'uid = ' . (int)$userUid);
241
242 /** @var $backendUser \TYPO3\CMS\Core\Authentication\BackendUserAuthentication */
243 $backendUser = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Authentication\BackendUserAuthentication::class);
244 $sessionId = $backendUser->createSessionId();
245 $_COOKIE['be_typo_user'] = $sessionId;
246 $backendUser->id = $sessionId;
247 $backendUser->sendNoCacheHeaders = false;
248 $backendUser->dontSetCookie = true;
249 $backendUser->createUserSession($userRow);
250
251 $GLOBALS['BE_USER'] = $backendUser;
252 $GLOBALS['BE_USER']->start();
253 if (!is_array($GLOBALS['BE_USER']->user) || !$GLOBALS['BE_USER']->user['uid']) {
254 throw new Exception(
255 'Can not initialize backend user',
256 1377095807
257 );
258 }
259 $GLOBALS['BE_USER']->backendCheckLogin();
260
261 return $backendUser;
262 }
263
264 /**
265 * Imports a data set represented as XML into the test database,
266 *
267 * @param string $path Absolute path to the XML file containing the data set to load
268 * @return void
269 * @throws Exception
270 */
271 protected function importDataSet($path)
272 {
273 if (!is_file($path)) {
274 throw new Exception(
275 'Fixture file ' . $path . ' not found',
276 1376746261
277 );
278 }
279
280 $database = $this->getDatabaseConnection();
281
282 $fileContent = file_get_contents($path);
283 // Disables the functionality to allow external entities to be loaded when parsing the XML, must be kept
284 $previousValueOfEntityLoader = libxml_disable_entity_loader(true);
285 $xml = simplexml_load_string($fileContent);
286 libxml_disable_entity_loader($previousValueOfEntityLoader);
287 $foreignKeys = [];
288
289 /** @var $table \SimpleXMLElement */
290 foreach ($xml->children() as $table) {
291 $insertArray = [];
292
293 /** @var $column \SimpleXMLElement */
294 foreach ($table->children() as $column) {
295 $columnName = $column->getName();
296 $columnValue = null;
297
298 if (isset($column['ref'])) {
299 list($tableName, $elementId) = explode('#', $column['ref']);
300 $columnValue = $foreignKeys[$tableName][$elementId];
301 } elseif (isset($column['is-NULL']) && ($column['is-NULL'] === 'yes')) {
302 $columnValue = null;
303 } else {
304 $columnValue = (string)$table->$columnName;
305 }
306
307 $insertArray[$columnName] = $columnValue;
308 }
309
310 $tableName = $table->getName();
311 $result = $database->exec_INSERTquery($tableName, $insertArray);
312 if ($result === false) {
313 throw new Exception(
314 'Error when processing fixture file: ' . $path . ' Can not insert data to table ' . $tableName . ': ' . $database->sql_error(),
315 1376746262
316 );
317 }
318 if (isset($table['id'])) {
319 $elementId = (string)$table['id'];
320 $foreignKeys[$tableName][$elementId] = $database->sql_insert_id();
321 }
322 }
323 }
324
325 /**
326 * Import data from a CSV file to database
327 * Single file can contain data from multiple tables
328 *
329 * @param string $path absolute path to the CSV file containing the data set to load
330 */
331 public function importCSVDataSet($path)
332 {
333 $dataSet = DataSet::read($path, true);
334
335 foreach ($dataSet->getTableNames() as $tableName) {
336 foreach ($dataSet->getElements($tableName) as $element) {
337 $this->getDatabaseConnection()->exec_INSERTquery(
338 $tableName,
339 $element
340 );
341 $sqlError = $this->getDatabaseConnection()->sql_error();
342 if (!empty($sqlError)) {
343 $this->fail('SQL Error for table "' . $tableName . '": ' . LF . $sqlError);
344 }
345 }
346 }
347 }
348
349 /**
350 * Compare data in database with CSV file
351 *
352 * @param string $path absolute path to the CSV file
353 */
354 protected function assertCSVDataSet($path)
355 {
356 $dataSet = DataSet::read($path);
357 $failMessages = [];
358
359 foreach ($dataSet->getTableNames() as $tableName) {
360 $hasUidField = ($dataSet->getIdIndex($tableName) !== null);
361 $records = $this->getAllRecords($tableName, $hasUidField);
362 foreach ($dataSet->getElements($tableName) as $assertion) {
363 $result = $this->assertInRecords($assertion, $records);
364 if ($result === false) {
365 if ($hasUidField && empty($records[$assertion['uid']])) {
366 $failMessages[] = 'Record "' . $tableName . ':' . $assertion['uid'] . '" not found in database';
367 continue;
368 }
369 $recordIdentifier = $tableName . ($hasUidField ? ':' . $assertion['uid'] : '');
370 $additionalInformation = ($hasUidField ? $this->renderRecords($assertion, $records[$assertion['uid']]) : $this->arrayToString($assertion));
371 $failMessages[] = 'Assertion in data-set failed for "' . $recordIdentifier . '":' . LF . $additionalInformation;
372 // Unset failed asserted record
373 if ($hasUidField) {
374 unset($records[$assertion['uid']]);
375 }
376 } else {
377 // Unset asserted record
378 unset($records[$result]);
379 // Increase assertion counter
380 $this->assertTrue($result !== false);
381 }
382 }
383 if (!empty($records)) {
384 foreach ($records as $record) {
385 $recordIdentifier = $tableName . ':' . $record['uid'];
386 $emptyAssertion = array_fill_keys($dataSet->getFields($tableName), '[none]');
387 $reducedRecord = array_intersect_key($record, $emptyAssertion);
388 $additionalInformation = ($hasUidField ? $this->renderRecords($emptyAssertion, $reducedRecord) : $this->arrayToString($reducedRecord));
389 $failMessages[] = 'Not asserted record found for "' . $recordIdentifier . '":' . LF . $additionalInformation;
390 }
391 }
392 }
393
394 if (!empty($failMessages)) {
395 $this->fail(implode(LF, $failMessages));
396 }
397 }
398
399 /**
400 * Check if $expectedRecord is present in $actualRecords array
401 * and compares if all column values from matches
402 *
403 * @param array $expectedRecord
404 * @param array $actualRecords
405 * @return bool|int|string false if record is not found or some column value doesn't match
406 */
407 protected function assertInRecords(array $expectedRecord, array $actualRecords)
408 {
409 foreach ($actualRecords as $index => $record) {
410 $differentFields = $this->getDifferentFields($expectedRecord, $record);
411
412 if (empty($differentFields)) {
413 return $index;
414 }
415 }
416
417 return false;
418 }
419
420 /**
421 * Fetches all records from a database table
422 * Helper method for assertCSVDataSet
423 *
424 * @param string $tableName
425 * @param bool $hasUidField
426 * @return array
427 */
428 protected function getAllRecords($tableName, $hasUidField = false)
429 {
430 $allRecords = [];
431
432 $records = $this->getDatabaseConnection()->exec_SELECTgetRows(
433 '*',
434 $tableName,
435 '1=1',
436 '',
437 '',
438 '',
439 ($hasUidField ? 'uid' : '')
440 );
441
442 if (!empty($records)) {
443 $allRecords = $records;
444 }
445
446 return $allRecords;
447 }
448
449 /**
450 * Format array as human readable string. Used to format verbose error messages in assertCSVDataSet
451 *
452 * @param array $array
453 * @return string
454 */
455 protected function arrayToString(array $array)
456 {
457 $elements = [];
458 foreach ($array as $key => $value) {
459 if (is_array($value)) {
460 $value = $this->arrayToString($value);
461 }
462 $elements[] = "'" . $key . "' => '" . $value . "'";
463 }
464 return 'array(' . PHP_EOL . ' ' . implode(', ' . PHP_EOL . ' ', $elements) . PHP_EOL . ')' . PHP_EOL;
465 }
466
467 /**
468 * Format output showing difference between expected and actual db row in a human readable way
469 * Used to format verbose error messages in assertCSVDataSet
470 *
471 * @param array $assertion
472 * @param array $record
473 * @return string
474 */
475 protected function renderRecords(array $assertion, array $record)
476 {
477 $differentFields = $this->getDifferentFields($assertion, $record);
478 $columns = [
479 'fields' => ['Fields'],
480 'assertion' => ['Assertion'],
481 'record' => ['Record'],
482 ];
483 $lines = [];
484 $linesFromXmlValues = [];
485 $result = '';
486
487 foreach ($differentFields as $differentField) {
488 $columns['fields'][] = $differentField;
489 $columns['assertion'][] = ($assertion[$differentField] === null ? 'NULL' : $assertion[$differentField]);
490 $columns['record'][] = ($record[$differentField] === null ? 'NULL' : $record[$differentField]);
491 }
492
493 foreach ($columns as $columnIndex => $column) {
494 $columnLength = null;
495 foreach ($column as $value) {
496 if (strpos($value, '<?xml') === 0) {
497 $value = '[see diff]';
498 }
499 $valueLength = strlen($value);
500 if (empty($columnLength) || $valueLength > $columnLength) {
501 $columnLength = $valueLength;
502 }
503 }
504 foreach ($column as $valueIndex => $value) {
505 if (strpos($value, '<?xml') === 0) {
506 if ($columnIndex === 'assertion') {
507 try {
508 $this->assertXmlStringEqualsXmlString((string)$value, (string)$record[$columns['fields'][$valueIndex]]);
509 } catch (\PHPUnit_Framework_ExpectationFailedException $e) {
510 $linesFromXmlValues[] = 'Diff for field "' . $columns['fields'][$valueIndex] . '":' . PHP_EOL . $e->getComparisonFailure()->getDiff();
511 }
512 }
513 $value = '[see diff]';
514 }
515 $lines[$valueIndex][$columnIndex] = str_pad($value, $columnLength, ' ');
516 }
517 }
518
519 foreach ($lines as $line) {
520 $result .= implode('|', $line) . PHP_EOL;
521 }
522
523 foreach ($linesFromXmlValues as $lineFromXmlValues) {
524 $result .= PHP_EOL . $lineFromXmlValues . PHP_EOL;
525 }
526
527 return $result;
528 }
529
530 /**
531 * Compares two arrays containing db rows and returns array containing column names which don't match
532 * It's a helper method used in assertCSVDataSet
533 *
534 * @param array $assertion
535 * @param array $record
536 * @return array
537 */
538 protected function getDifferentFields(array $assertion, array $record)
539 {
540 $differentFields = [];
541
542 foreach ($assertion as $field => $value) {
543 if (strpos($value, '\\*') === 0) {
544 continue;
545 } elseif (strpos($value, '<?xml') === 0) {
546 try {
547 $this->assertXmlStringEqualsXmlString((string)$value, (string)$record[$field]);
548 } catch (\PHPUnit_Framework_ExpectationFailedException $e) {
549 $differentFields[] = $field;
550 }
551 } elseif ($value === null && $record[$field] !== $value) {
552 $differentFields[] = $field;
553 } elseif ((string)$record[$field] !== (string)$value) {
554 $differentFields[] = $field;
555 }
556 }
557
558 return $differentFields;
559 }
560
561 /**
562 * @param int $pageId
563 * @param array $typoScriptFiles
564 */
565 protected function setUpFrontendRootPage($pageId, array $typoScriptFiles = [])
566 {
567 $pageId = (int)$pageId;
568 $page = $this->getDatabaseConnection()->exec_SELECTgetSingleRow('*', 'pages', 'uid=' . $pageId);
569
570 if (empty($page)) {
571 $this->fail('Cannot set up frontend root page "' . $pageId . '"');
572 }
573
574 $pagesFields = [
575 'is_siteroot' => 1
576 ];
577
578 $this->getDatabaseConnection()->exec_UPDATEquery('pages', 'uid=' . $pageId, $pagesFields);
579
580 $templateFields = [
581 'pid' => $pageId,
582 'title' => '',
583 'config' => '',
584 'clear' => 3,
585 'root' => 1,
586 ];
587
588 foreach ($typoScriptFiles as $typoScriptFile) {
589 $templateFields['config'] .= '<INCLUDE_TYPOSCRIPT: source="FILE:' . $typoScriptFile . '">' . LF;
590 }
591
592 $this->getDatabaseConnection()->exec_DELETEquery('sys_template', 'pid = ' . $pageId);
593 $this->getDatabaseConnection()->exec_INSERTquery('sys_template', $templateFields);
594 }
595
596 /**
597 * @param int $pageId
598 * @param int $languageId
599 * @param int $backendUserId
600 * @param int $workspaceId
601 * @param bool $failOnFailure
602 * @param int $frontendUserId
603 * @return Response
604 */
605 protected function getFrontendResponse($pageId, $languageId = 0, $backendUserId = 0, $workspaceId = 0, $failOnFailure = true, $frontendUserId = 0)
606 {
607 $pageId = (int)$pageId;
608 $languageId = (int)$languageId;
609
610 $additionalParameter = '';
611
612 if (!empty($frontendUserId)) {
613 $additionalParameter .= '&frontendUserId=' . (int)$frontendUserId;
614 }
615 if (!empty($backendUserId)) {
616 $additionalParameter .= '&backendUserId=' . (int)$backendUserId;
617 }
618 if (!empty($workspaceId)) {
619 $additionalParameter .= '&workspaceId=' . (int)$workspaceId;
620 }
621
622 $arguments = [
623 'documentRoot' => $this->getInstancePath(),
624 'requestUrl' => 'http://localhost/?id=' . $pageId . '&L=' . $languageId . $additionalParameter,
625 ];
626
627 $template = new \Text_Template(ORIGINAL_ROOT . 'typo3/sysext/core/Tests/Functional/Fixtures/Frontend/request.tpl');
628 $template->setVar(
629 [
630 'arguments' => var_export($arguments, true),
631 'originalRoot' => ORIGINAL_ROOT,
632 ]
633 );
634
635 $php = \PHPUnit_Util_PHP::factory();
636 $response = $php->runJob($template->render());
637 $result = json_decode($response['stdout'], true);
638
639 if ($result === null) {
640 $this->fail('Frontend Response is empty');
641 }
642
643 if ($failOnFailure && $result['status'] === Response::STATUS_Failure) {
644 $this->fail('Frontend Response has failure:' . LF . $result['error']);
645 }
646
647 $response = new Response($result['status'], $result['content'], $result['error']);
648 return $response;
649 }
650 }