diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dc98cf..451382d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Parameter types for IDs were fixed in API for attachments, groups, issues, project, users and versions. +- Wiki pages with special characters are now handled correctly ## [v2.5.0](https://github.com/kbsali/php-redmine-api/compare/v2.4.0...v2.5.0) - 2024-02-05 diff --git a/src/Redmine/Api/Project.php b/src/Redmine/Api/Project.php index 4c583edd..d0663fcb 100755 --- a/src/Redmine/Api/Project.php +++ b/src/Redmine/Api/Project.php @@ -9,6 +9,7 @@ use Redmine\Exception\UnexpectedResponseException; use Redmine\Serializer\PathSerializer; use Redmine\Serializer\XmlSerializer; +use SimpleXMLElement; /** * Listing projects, creating, editing. @@ -146,7 +147,7 @@ public function show($id, array $params = []) * * @throws MissingParameterException * - * @return string|false + * @return string|SimpleXMLElement|false */ public function create(array $params = []) { diff --git a/src/Redmine/Api/Wiki.php b/src/Redmine/Api/Wiki.php index 1b2c3002..436d577d 100644 --- a/src/Redmine/Api/Wiki.php +++ b/src/Redmine/Api/Wiki.php @@ -100,9 +100,9 @@ public function show($project, $page, $version = null) ]; if (null === $version) { - $path = '/projects/' . $project . '/wiki/' . $page . '.json'; + $path = '/projects/' . $project . '/wiki/' . urlencode($page) . '.json'; } else { - $path = '/projects/' . $project . '/wiki/' . $page . '/' . $version . '.json'; + $path = '/projects/' . $project . '/wiki/' . urlencode($page) . '/' . $version . '.json'; } return $this->get( @@ -129,7 +129,7 @@ public function create($project, $page, array $params = []) $params = $this->sanitizeParams($defaults, $params); return $this->put( - '/projects/' . $project . '/wiki/' . $page . '.xml', + '/projects/' . $project . '/wiki/' . urlencode($page) . '.xml', XmlSerializer::createFromArray(['wiki_page' => $params])->getEncoded() ); } @@ -160,6 +160,8 @@ public function update($project, $page, array $params = []) */ public function remove($project, $page) { - return $this->delete('/projects/' . $project . '/wiki/' . $page . '.xml'); + return $this->delete( + '/projects/' . $project . '/wiki/' . urlencode($page) . '.xml' + ); } } diff --git a/tests/End2End/Attachment/AttachmentTest.php b/tests/End2End/Attachment/AttachmentTest.php new file mode 100644 index 00000000..4ff1aad8 --- /dev/null +++ b/tests/End2End/Attachment/AttachmentTest.php @@ -0,0 +1,68 @@ +getNativeCurlClient($redmineVersion); + + // Create project + /** @var Project */ + $projectApi = $client->getApi('project'); + + $projectIdentifier = 'project-with-wiki'; + + $xmlData = $projectApi->create(['name' => 'project with wiki', 'identifier' => $projectIdentifier]); + + $projectDataJson = json_encode($xmlData); + $projectData = json_decode($projectDataJson, true); + + $this->assertIsArray($projectData, $projectDataJson); + $this->assertSame($projectIdentifier, $projectData['identifier'], $projectDataJson); + + // Upload file + /** @var Attachment */ + $attachmentApi = $client->getApi('attachment'); + + $jsonData = $attachmentApi->upload(file_get_contents(dirname(__FILE__, 3) . '/Fixtures/testfile_01.txt'), ['filename' => 'testfile.txt']); + + $attachmentData = json_decode($jsonData, true); + + $this->assertIsArray($attachmentData, $jsonData); + $this->assertArrayHasKey('upload', $attachmentData, $jsonData); + $this->assertSame( + ['id', 'token'], + array_keys($attachmentData['upload']), + $jsonData + ); + + $attachmentToken = $attachmentData['upload']['token']; + + $this->assertSame('1.7b962f8af22e26802b87abfa0b07b21dbd03b984ec8d6888dabd3f69cff162f8', $attachmentToken); + + // Check attachment + $attachmentData = $attachmentApi->show($attachmentData['upload']['id']); + + $jsonData = json_encode($attachmentData); + + $this->assertIsArray($attachmentData, $jsonData); + $this->assertArrayHasKey('attachment', $attachmentData, $jsonData); + $this->assertSame( + ['id', 'filename', 'filesize', 'content_type', 'description', 'content_url', 'author', 'created_on'], + array_keys($attachmentData['attachment']), + $jsonData + ); + } +} diff --git a/tests/End2End/Wiki/WikiTest.php b/tests/End2End/Wiki/WikiTest.php new file mode 100644 index 00000000..8ec213a9 --- /dev/null +++ b/tests/End2End/Wiki/WikiTest.php @@ -0,0 +1,100 @@ +getNativeCurlClient($redmineVersion); + + // Create project + /** @var Project */ + $projectApi = $client->getApi('project'); + + $projectIdentifier = 'project-with-wiki'; + + $xmlData = $projectApi->create(['name' => 'project with wiki', 'identifier' => $projectIdentifier]); + + $projectDataJson = json_encode($xmlData); + $projectData = json_decode($projectDataJson, true); + + $this->assertIsArray($projectData, $projectDataJson); + $this->assertSame($projectIdentifier, $projectData['identifier'], $projectDataJson); + + // Upload file + /** @var Attachment */ + $attachmentApi = $client->getApi('attachment'); + + $jsonData = $attachmentApi->upload(file_get_contents(dirname(__FILE__, 3) . '/Fixtures/testfile_01.txt'), ['filename' => 'testfile.txt']); + + $attachmentData = json_decode($jsonData, true); + + $this->assertIsArray($attachmentData, $jsonData); + $this->assertArrayHasKey('upload', $attachmentData, $jsonData); + $this->assertSame( + ['id', 'token'], + array_keys($attachmentData['upload']), + $jsonData + ); + + $attachmentToken = $attachmentData['upload']['token']; + + $this->assertSame('1.7b962f8af22e26802b87abfa0b07b21dbd03b984ec8d6888dabd3f69cff162f8', $attachmentToken); + + // Add attachment to wiki page + /** @var Wiki */ + $wikiApi = $client->getApi('wiki'); + + $xmlData = $wikiApi->create($projectIdentifier, 'Test Page', [ + 'text' => '# First Wiki page', + 'uploads' => [ + ['token' => $attachmentToken, 'filename' => 'filename.txt', 'content-type' => 'text/plain'], + ], + ]); + + $wikiDataJson = json_encode($xmlData); + $wikiData = json_decode($wikiDataJson, true); + + $this->assertIsArray($wikiData, $wikiDataJson); + $this->assertSame( + ['title', 'text', 'version', 'author', 'comments', 'created_on', 'updated_on'], + array_keys($wikiData), + $wikiDataJson + ); + $this->assertSame('Test+Page', $wikiData['title'], $wikiDataJson); + + // Check attachments + $wikiData = $wikiApi->show($projectIdentifier, 'Test Page'); + + $this->assertIsArray($wikiData, json_encode($wikiData)); + $this->assertIsArray($wikiData['wiki_page']['attachments'][0]); + $this->assertSame( + ['id', 'filename', 'filesize', 'content_type', 'description', 'content_url', 'author', 'created_on'], + array_keys($wikiData['wiki_page']['attachments'][0]) + ); + + // Update wiki page returns empty string + $returnData = $wikiApi->update($projectIdentifier, 'Test Page', [ + 'text' => '# First Wiki page with changes', + ]); + + $this->assertSame('', $returnData, json_encode($returnData)); + + // Remove wiki page + $returnData = $wikiApi->remove($projectIdentifier, 'Test Page'); + + $this->assertSame('', $returnData, json_encode($returnData)); + } +} diff --git a/tests/Integration/UrlTest.php b/tests/Integration/UrlTest.php index 6be2fd0c..34affe41 100644 --- a/tests/Integration/UrlTest.php +++ b/tests/Integration/UrlTest.php @@ -487,24 +487,24 @@ public function testWiki() { /** @var \Redmine\Api\Wiki */ $api = MockClient::create()->getApi('wiki'); - $res = $api->create('testProject', 'about', [ + $res = $api->create('testProject', 'about page', [ 'text' => 'asdf', 'comments' => 'asdf', 'version' => 'asdf', ]); $res = json_decode($res, true); - $this->assertEquals('/projects/testProject/wiki/about.xml', $res['path']); + $this->assertEquals('/projects/testProject/wiki/about+page.xml', $res['path']); $this->assertEquals('PUT', $res['method']); - $res = $api->update('testProject', 'about', [ + $res = $api->update('testProject', 'about page', [ 'text' => 'asdf', 'comments' => 'asdf', 'version' => 'asdf', ]); $res = json_decode($res, true); - $this->assertEquals('/projects/testProject/wiki/about.xml', $res['path']); + $this->assertEquals('/projects/testProject/wiki/about+page.xml', $res['path']); $this->assertEquals('PUT', $res['method']); $res = $api->all('testProject'); @@ -512,20 +512,20 @@ public function testWiki() $this->assertEquals('/projects/testProject/wiki/index.json', $res['path']); $this->assertEquals('GET', $res['method']); - $res = $api->show('testProject', 'about'); + $res = $api->show('testProject', 'about page'); - $this->assertEquals('/projects/testProject/wiki/about.json?include=attachments', $res['path']); + $this->assertEquals('/projects/testProject/wiki/about+page.json?include=attachments', $res['path']); $this->assertEquals('GET', $res['method']); - $res = $api->show('testProject', 'about', 18); + $res = $api->show('testProject', 'about page', 18); - $this->assertEquals('/projects/testProject/wiki/about/18.json?include=attachments', $res['path']); + $this->assertEquals('/projects/testProject/wiki/about+page/18.json?include=attachments', $res['path']); $this->assertEquals('GET', $res['method']); - $res = $api->remove('testProject', 'about'); + $res = $api->remove('testProject', 'about page'); $res = json_decode($res, true); - $this->assertEquals('/projects/testProject/wiki/about.xml', $res['path']); + $this->assertEquals('/projects/testProject/wiki/about+page.xml', $res['path']); $this->assertEquals('DELETE', $res['method']); } } diff --git a/tests/Integration/WikiXmlTest.php b/tests/Integration/WikiXmlTest.php index 64f23b44..be5acaea 100644 --- a/tests/Integration/WikiXmlTest.php +++ b/tests/Integration/WikiXmlTest.php @@ -11,7 +11,7 @@ public function testCreateComplex() { /** @var \Redmine\Api\Wiki */ $api = MockClient::create()->getApi('wiki'); - $res = $api->create('testProject', 'about', [ + $res = $api->create('testProject', 'about page', [ 'text' => 'asdf', 'comments' => 'asdf', 'version' => 'asdf', @@ -19,7 +19,7 @@ public function testCreateComplex() $response = json_decode($res, true); $this->assertEquals('PUT', $response['method']); - $this->assertEquals('/projects/testProject/wiki/about.xml', $response['path']); + $this->assertEquals('/projects/testProject/wiki/about+page.xml', $response['path']); $this->assertXmlStringEqualsXmlString( <<< XML diff --git a/tests/RedmineExtension/RedmineInstance.php b/tests/RedmineExtension/RedmineInstance.php index 2b86152e..58ceafd0 100644 --- a/tests/RedmineExtension/RedmineInstance.php +++ b/tests/RedmineExtension/RedmineInstance.php @@ -37,12 +37,20 @@ public static function create(TestRunnerTracer $tracer, RedmineVersion $version) private RedmineVersion $version; + private string $rootPath; + private string $workingDB; private string $migratedDB; private string $backupDB; + private string $workingFiles; + + private string $migratedFiles; + + private string $backupFiles; + private string $redmineUrl; private string $apiKey; @@ -54,15 +62,24 @@ private function __construct(TestRunnerTracer $tracer, RedmineVersion $version) $versionId = strval($version->asId()); - $this->workingDB = dirname(__FILE__, 3) . '/.docker/redmine-' . $versionId . '_data/sqlite/redmine.db'; - $this->migratedDB = dirname(__FILE__, 3) . '/.docker/redmine-' . $versionId . '_data/sqlite/redmine-migrated.db'; - $this->backupDB = dirname(__FILE__, 3) . '/.docker/redmine-' . $versionId . '_data/sqlite/redmine.db.bak'; + $this->rootPath = dirname(__FILE__, 3) . '/.docker/redmine-' . $versionId . '_data/'; + + $this->workingDB = 'sqlite/redmine.db'; + $this->migratedDB = 'sqlite/redmine-migrated.db'; + $this->backupDB = 'sqlite/redmine.db.bak'; + + $this->workingFiles = 'files/'; + $this->migratedFiles = 'files-migrated/'; + $this->backupFiles = 'files-bak/'; + $this->redmineUrl = 'http://redmine-' . $versionId . ':3000'; $this->apiKey = sha1($versionId . (string) time()); $this->createDatabaseBackup(); + $this->createFilesBackup(); $this->runDatabaseMigration(); $this->saveMigratedDatabase(); + $this->saveMigratedFiles(); } public function getVersionId(): int @@ -87,6 +104,7 @@ public function reset(TestRunnerTracer $tracer): void } $this->restoreFromMigratedDatabase(); + $this->restoreFromMigratedFiles(); } public function shutdown(TestRunnerTracer $tracer): void @@ -96,7 +114,9 @@ public function shutdown(TestRunnerTracer $tracer): void } $this->restoreDatabaseFromBackup(); + $this->restoreFilesFromBackup(); $this->removeDatabaseBackups(); + $this->removeFilesBackups(); $tracer->deregisterInstance($this); } @@ -104,7 +124,7 @@ public function shutdown(TestRunnerTracer $tracer): void private function runDatabaseMigration() { $now = new DateTimeImmutable(); - $pdo = new PDO('sqlite:' . $this->workingDB); + $pdo = new PDO('sqlite:' . $this->rootPath . $this->workingDB); // Get admin user to check sqlite connection $stmt = $pdo->prepare('SELECT * FROM users WHERE login = :login;'); @@ -139,7 +159,7 @@ private function runDatabaseMigration() */ private function createDatabaseBackup() { - copy($this->workingDB, $this->backupDB); + copy($this->rootPath . $this->workingDB, $this->rootPath . $this->backupDB); } /** @@ -147,22 +167,81 @@ private function createDatabaseBackup() */ private function saveMigratedDatabase() { - copy($this->workingDB, $this->migratedDB); + copy($this->rootPath . $this->workingDB, $this->rootPath . $this->migratedDB); } private function restoreFromMigratedDatabase(): void { - copy($this->migratedDB, $this->workingDB); + copy($this->rootPath . $this->migratedDB, $this->rootPath . $this->workingDB); } private function restoreDatabaseFromBackup(): void { - copy($this->backupDB, $this->workingDB); + copy($this->rootPath . $this->backupDB, $this->rootPath . $this->workingDB); } private function removeDatabaseBackups(): void { - unlink($this->migratedDB); - unlink($this->backupDB); + unlink($this->rootPath . $this->migratedDB); + unlink($this->rootPath . $this->backupDB); + } + + private function createFilesBackup() + { + // Add an empty file to avoid warnings about copying and removing content from an empty folder + touch($this->rootPath . $this->workingFiles . 'empty'); + exec(sprintf( + 'cp -r %s %s', + $this->rootPath . $this->workingFiles, + $this->rootPath . rtrim($this->backupFiles, '/'), + )); + } + + private function saveMigratedFiles() + { + exec(sprintf( + 'cp -r %s %s', + $this->rootPath . $this->workingFiles, + $this->rootPath . rtrim($this->migratedFiles, '/'), + )); + } + + private function restoreFromMigratedFiles(): void + { + exec(sprintf( + 'rm -r %s', + $this->rootPath . $this->workingFiles . '*', + )); + + exec(sprintf( + 'cp -r %s %s', + $this->rootPath . $this->migratedFiles . '*', + $this->rootPath . rtrim($this->workingFiles, '/'), + )); + } + + private function restoreFilesFromBackup(): void + { + exec(sprintf( + 'rm -r %s', + $this->rootPath . $this->workingFiles . '*', + )); + + exec(sprintf( + 'cp -r %s %s', + $this->rootPath . $this->backupFiles . '*', + $this->rootPath . rtrim($this->workingFiles, '/'), + )); + } + + private function removeFilesBackups(): void + { + exec(sprintf( + 'rm -r %s %s', + $this->rootPath . $this->migratedFiles, + $this->rootPath . $this->backupFiles, + )); + + unlink($this->rootPath . $this->workingFiles . 'empty'); } } diff --git a/tests/RedmineExtension/TestRunnerTracer.php b/tests/RedmineExtension/TestRunnerTracer.php index 81c073a5..8c83c757 100644 --- a/tests/RedmineExtension/TestRunnerTracer.php +++ b/tests/RedmineExtension/TestRunnerTracer.php @@ -7,7 +7,7 @@ use PHPUnit\Event\Event; use PHPUnit\Event\Test\Finished as TestFinished; use PHPUnit\Event\TestRunner\Finished as TestRunnerFinished; -use PHPUnit\Event\TestRunner\Started; +use PHPUnit\Event\TestRunner\Started as TestRunnerStarted; use PHPUnit\Event\Tracer\Tracer; use RuntimeException; @@ -56,7 +56,7 @@ public function deregisterInstance(RedmineInstance $instance): void public function trace(Event $event): void { - if ($event instanceof Started) { + if ($event instanceof TestRunnerStarted) { static::$tracer = $this; }