diff --git a/Build/phpunit/UnitTests.xml b/Build/phpunit/UnitTests.xml index e2cfe4a4..e799bc05 100644 --- a/Build/phpunit/UnitTests.xml +++ b/Build/phpunit/UnitTests.xml @@ -8,6 +8,7 @@ displayDetailsOnTestsThatTriggerErrors="true" displayDetailsOnTestsThatTriggerNotices="true" displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnPhpunitDeprecations="true" failOnDeprecation="true" failOnNotice="true" failOnRisky="true" diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index b6be4565..e177d77e 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -17,6 +17,17 @@ * The TYPO3 project - inspiring people to share! */ +use Doctrine\DBAL\Types\BooleanType; +use Doctrine\DBAL\Types\DateTimeImmutableType; +use Doctrine\DBAL\Types\DateTimeType; +use Doctrine\DBAL\Types\DateType; +use Doctrine\DBAL\Types\EnumType; +use Doctrine\DBAL\Types\JsonType; +use Doctrine\DBAL\Types\StringType; +use Doctrine\DBAL\Types\TextType; +use Doctrine\DBAL\Types\TimeImmutableType; +use Doctrine\DBAL\Types\TimeType; +use Doctrine\DBAL\Types\Type; use PHPUnit\Framework\ExpectationFailedException; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; @@ -29,6 +40,10 @@ use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Schema\Types\DateTimeType as Typo3DateTimeType; +use TYPO3\CMS\Core\Database\Schema\Types\DateType as Typo3DateType; +use TYPO3\CMS\Core\Database\Schema\Types\SetType as Typo3SetType; +use TYPO3\CMS\Core\Database\Schema\Types\TimeType as Typo3TimeType; use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\Http\Stream; @@ -715,10 +730,135 @@ protected function assertCSVDataSet(string $fileName): void } if (!empty($failMessages)) { + if ((bool)((int)getenv('TYPO3_TESTING_EXPORT_DATASETS_ON_FAILED_ASSERTION'))) { + $exportTables = []; + foreach ($dataSet->getTableNames() as $tableName) { + $exportTables[$tableName] = $dataSet->getFields($tableName) ?? []; + } + $exportPath = preg_replace('/\.csv$/', '_actual.csv', $fileName); + $this->exportCSVDataSet($exportPath, $exportTables); + $failMessages[] = 'Actual database state exported to: ' . $exportPath; + } self::fail(implode(LF, $failMessages)); } } + /** + * Export current database state of given tables to a CSV file. + * + * The output format matches the CSV fixture format used by importCSVDataSet() + * and assertCSVDataSet(), so exported files can be used directly as test fixtures. + * + * @param string $path Absolute path for the output CSV file + * @param array $tables Tables to export. Supports two styles: + * - Simple list: ['pages', 'tt_content'] exports all columns + * - With field spec: ['pages' => ['uid', 'pid', 'title']] exports only listed columns + * - Mixed: both styles in one array + */ + protected function exportCSVDataSet(string $path, array $tables): void + { + $targetDirectory = dirname($path); + if (!is_dir($targetDirectory)) { + throw new \RuntimeException( + 'Target directory "' . $targetDirectory . '" does not exist.', + 1732006381 + ); + } + + // Normalize $tables into ['tableName' => ['field1', ...] | []] structure + $normalizedTables = []; + foreach ($tables as $key => $value) { + if (is_int($key)) { + // Simple list style: ['pages', 'tt_content'] + $normalizedTables[$value] = []; + } else { + // With field spec: ['pages' => ['uid', 'pid', 'title']] + $normalizedTables[$key] = $value; + } + } + + $output = ''; + $firstTable = true; + foreach ($normalizedTables as $tableName => $fields) { + $connection = $this->getConnectionPool()->getConnectionForTable($tableName); + $tableColumns = []; + foreach ($connection->createSchemaManager()->listTableColumns($tableName) as $column) { + $tableColumns[$column->getName()] = $column; + } + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $statement = $queryBuilder->select('*')->from($tableName)->executeQuery(); + $records = $statement->fetchAllAssociative(); + + // Determine fields to export + if ($fields === []) { + if ($records !== []) { + $fields = array_keys($records[0]); + } else { + // Empty table with no field spec: use schema introspection + $fields = array_keys($tableColumns); + } + } + + // Sort records by uid if available, otherwise by hash + if (in_array('uid', $fields, true)) { + usort($records, static fn(array $a, array $b) => $a['uid'] <=> $b['uid']); + } elseif (in_array('hash', $fields, true)) { + usort($records, static fn(array $a, array $b) => $a['hash'] <=> $b['hash']); + } + + if (!$firstTable) { + $output .= LF; + } + $firstTable = false; + + // Table name line with trailing commas + $output .= '"' . $tableName . '",' . LF; + // Field names line with leading comma + $output .= ',' . implode(',', $fields) . LF; + // Data rows + foreach ($records as $record) { + $values = []; + foreach ($fields as $field) { + $values[] = $this->formatCsvValue($record[$field] ?? null, ($tableColumns[$field] ?? null)?->getType()); + } + $output .= ',' . implode(',', $values) . LF; + } + } + + file_put_contents($path, $output); + } + + /** + * Format a single value for CSV export. + * + * Handles NULL values, quoting of special characters, and plain string casting. + */ + protected function formatCsvValue(mixed $value, ?Type $columnDoctrineType = null): string + { + return match (true) { + // Simply escape NULL value + $value === null => '\\NULL', + // TYPO3 custom types + $columnDoctrineType instanceof Typo3DateTimeType, + $columnDoctrineType instanceof Typo3DateType, + $columnDoctrineType instanceof Typo3TimeType, + $columnDoctrineType instanceof Typo3SetType, + // Native Doctrine DBAL types + $columnDoctrineType instanceof EnumType, + $columnDoctrineType instanceof StringType, + $columnDoctrineType instanceof TextType, + $columnDoctrineType instanceof JsonType, + $columnDoctrineType instanceof DateTimeType, + $columnDoctrineType instanceof DateTimeImmutableType, + $columnDoctrineType instanceof DateType, + $columnDoctrineType instanceof TimeType, + $columnDoctrineType instanceof TimeImmutableType => '"' . str_replace('"', '""', (string)$value) . '"', + $columnDoctrineType instanceof BooleanType => (string)(int)($value), + default => (string)$value, + }; + } + /** * Check if $expectedRecord is present in $actualRecords array * and compares if all column values from matches diff --git a/Resources/Core/Build/FunctionalTests.xml b/Resources/Core/Build/FunctionalTests.xml index abc26606..c16c0b25 100644 --- a/Resources/Core/Build/FunctionalTests.xml +++ b/Resources/Core/Build/FunctionalTests.xml @@ -42,5 +42,13 @@ + + diff --git a/Tests/Unit/Core/Functional/FunctionalTestCaseExportTest.php b/Tests/Unit/Core/Functional/FunctionalTestCaseExportTest.php new file mode 100644 index 00000000..ed79de2c --- /dev/null +++ b/Tests/Unit/Core/Functional/FunctionalTestCaseExportTest.php @@ -0,0 +1,231 @@ +getAccessibleMock( + FunctionalTestCase::class, + null, + [], + '', + false + ); + $result = $subject->_call('formatCsvValue', $input, $columnType); + self::assertSame($expected, $result); + } + + public static function formatCsvValueDataProvider(): array + { + return [ + 'null value becomes \\NULL' => [ + 'input' => null, + 'columnType' => new StringType(), + 'expected' => '\\NULL', + ], + 'empty string becomes quoted empty' => [ + 'input' => '', + 'columnType' => new StringType(), + 'expected' => '""', + ], + 'plain integer' => [ + 'input' => 42, + 'columnType' => new IntegerType(), + 'expected' => '42', + ], + 'plain string without special chars' => [ + 'input' => 'hello', + 'columnType' => new StringType(), + 'expected' => '"hello"', + ], + 'string with comma is quoted' => [ + 'input' => 'hello,world', + 'columnType' => new StringType(), + 'expected' => '"hello,world"', + ], + 'string with double quote is quoted and escaped' => [ + 'input' => 'say "hello"', + 'columnType' => new StringType(), + 'expected' => '"say ""hello"""', + ], + 'string with newline is quoted' => [ + 'input' => "line1\nline2", + 'columnType' => new StringType(), + 'expected' => "\"line1\nline2\"", + ], + 'string with carriage return is quoted' => [ + 'input' => "line1\rline2", + 'columnType' => new StringType(), + 'expected' => "\"line1\rline2\"", + ], + 'string with backslash is quoted' => [ + 'input' => 'path\\to\\file', + 'columnType' => new StringType(), + 'expected' => '"path\\to\\file"', + ], + 'zero as integer' => [ + 'input' => 0, + 'columnType' => new SmallIntType(), + 'expected' => '0', + ], + 'zero as string' => [ + 'input' => '0', + 'columnType' => new IntegerType(), + 'expected' => '0', + ], + 'plain numeric string' => [ + 'input' => '256', + 'columnType' => new IntegerType(), + 'expected' => '256', + ], + ]; + } + + #[Test] + public function exportedCsvCanBeReadBackByDataSetRead(): void + { + $subject = $this->getAccessibleMock( + FunctionalTestCase::class, + null, + [], + '', + false + ); + + // Simulate CSV output generation using the private formatCsvValue method + $tables = [ + 'pages' => [ + 'tableColumnTypes' => [ + 'uid' => new IntegerType(), + 'pid' => new IntegerType(), + 'title' => new StringType(), + 'deleted' => new BooleanType(), + ], + 'records' => [ + ['uid' => 1, 'pid' => 0, 'title' => 'Root Page', 'deleted' => 0], + ['uid' => 2, 'pid' => 1, 'title' => 'Sub Page', 'deleted' => 0], + ], + ], + 'tt_content' => [ + 'tableColumnTypes' => [ + 'uid' => new IntegerType(), + 'pid' => new IntegerType(), + 'header' => new StringType(), + 'bodytext' => new TextType(), + ], + 'records' => [ + ['uid' => 1, 'pid' => 1, 'header' => 'Element #1', 'bodytext' => null], + ['uid' => 2, 'pid' => 1, 'header' => 'With "quotes"', 'bodytext' => 'Some,text'], + ], + ], + ]; + + // Build CSV output the same way exportCSVDataSet does + $output = ''; + $firstTable = true; + foreach ($tables as $tableName => $tableData) { + $tableColumnTypes = $tableData['tableColumnTypes']; + $fields = array_keys($tableColumnTypes); + $records = $tableData['records']; + + if (!$firstTable) { + $output .= "\n"; + } + $firstTable = false; + + $output .= '"' . $tableName . '"' . str_repeat(',', count($fields)) . "\n"; + $output .= ',' . implode(',', $fields) . "\n"; + foreach ($records as $record) { + $values = []; + foreach ($fields as $field) { + $values[] = $subject->_call('formatCsvValue', $record[$field] ?? null, $tableColumnTypes[$field] ?? null); + } + $output .= ',' . implode(',', $values) . "\n"; + } + } + + // Write to temp file + $tempFile = sys_get_temp_dir() . '/typo3_testing_export_test_' . uniqid() . '.csv'; + file_put_contents($tempFile, $output); + + try { + // Read back with DataSet::read() + $dataSet = DataSet::read($tempFile); + + // Verify table names + self::assertSame(['pages', 'tt_content'], $dataSet->getTableNames()); + + // Verify pages fields + self::assertSame(['uid', 'pid', 'title', 'deleted'], $dataSet->getFields('pages')); + + // Verify pages elements + $pagesElements = $dataSet->getElements('pages'); + self::assertCount(2, $pagesElements); + self::assertSame('1', $pagesElements[1]['uid']); + self::assertSame('0', $pagesElements[1]['pid']); + self::assertSame('Root Page', $pagesElements[1]['title']); + self::assertSame('0', $pagesElements[1]['deleted']); + self::assertSame('2', $pagesElements[2]['uid']); + self::assertSame('Sub Page', $pagesElements[2]['title']); + + // Verify tt_content fields + self::assertSame(['uid', 'pid', 'header', 'bodytext'], $dataSet->getFields('tt_content')); + + // Verify tt_content elements including NULL and special chars + $ttContentElements = $dataSet->getElements('tt_content'); + self::assertCount(2, $ttContentElements); + self::assertNull($ttContentElements[1]['bodytext']); + self::assertSame('With "quotes"', $ttContentElements[2]['header']); + self::assertSame('Some,text', $ttContentElements[2]['bodytext']); + } finally { + @unlink($tempFile); + } + } + + #[Test] + public function exportCsvDataSetThrowsExceptionForNonExistentDirectory(): void + { + $subject = $this->getAccessibleMock( + FunctionalTestCase::class, + null, + [], + '', + false + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionCode(1732006381); + $subject->_call('exportCSVDataSet', '/non/existent/path/export.csv', ['pages']); + } +}