From 02329eec28cb51dbfdb2112e1ef6a0fc7b00c42d Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 16:25:32 +0200 Subject: [PATCH 1/7] #32861 fix(confluence): use REST endpoint for attachment download The legacy /wiki/download servlet no longer accepts API-token authentication (HTTP 401). Download attachments via the supported REST endpoint /wiki/rest/api/content/{pageId}/child/attachment/{id}/download instead; pageId is now passed through downloadAttachment(). Also replace the silent echo+return on folder/exception errors with a RuntimeException (download folder) and PSR-3 logger->error() (page download), so failures are no longer swallowed. Adds psr/log dependency and ConfluenceAttachment::getId(). Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 3 ++- src/ConfluencePageContentDownloader.php | 13 ++++++++--- src/Endpoint/Download.php | 28 ++++++++--------------- src/Endpoint/Dto/ConfluenceAttachment.php | 6 +++++ 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/composer.json b/composer.json index 9efe953..ae6518f 100755 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "php": ">=8.2", "guzzlehttp/guzzle": "~7.4", "ext-json": "*", - "ext-mbstring": "*" + "ext-mbstring": "*", + "psr/log": "^3.0" }, "require-dev": { "phpstan/phpstan": "^2.1.36", diff --git a/src/ConfluencePageContentDownloader.php b/src/ConfluencePageContentDownloader.php index 7c6fe63..6abe720 100755 --- a/src/ConfluencePageContentDownloader.php +++ b/src/ConfluencePageContentDownloader.php @@ -10,6 +10,8 @@ use Artemeon\Confluence\MacroReplacer\MacroReplacerInterface; use DOMDocument; use Exception; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; class ConfluencePageContentDownloader { @@ -19,15 +21,17 @@ class ConfluencePageContentDownloader private array $macroReplacers; private Content $contentEndpoint; private Download $downloadEndpoint; + private LoggerInterface $logger; /** * @param MacroReplacerInterface[] $macroReplacers */ - public function __construct(Content $contentEndpoint, Download $downloadEndpoint, array $macroReplacers = []) + public function __construct(Content $contentEndpoint, Download $downloadEndpoint, array $macroReplacers = [], ?LoggerInterface $logger = null) { $this->macroReplacers = $macroReplacers; $this->contentEndpoint = $contentEndpoint; $this->downloadEndpoint = $downloadEndpoint; + $this->logger = $logger ?? new NullLogger(); } public function downloadPageContent(ConfluencePage $page, bool $withAttachments = true): void @@ -54,10 +58,13 @@ public function downloadPageContent(ConfluencePage $page, bool $withAttachments $attachments = $this->contentEndpoint->findChildAttachments($pageId); foreach ($attachments as $attachment) { - $this->downloadEndpoint->downloadAttachment($attachment); + $this->downloadEndpoint->downloadAttachment($attachment, $pageId); } } catch (Exception $e) { - echo 'An error has occurred: ' . $e->getMessage(); + $this->logger->error( + sprintf('Failed to download Confluence page content for page "%s": %s', $page->getId() ?? 'unknown', $e->getMessage()), + ['exception' => $e], + ); } } diff --git a/src/Endpoint/Download.php b/src/Endpoint/Download.php index cb7183d..4d457e4 100755 --- a/src/Endpoint/Download.php +++ b/src/Endpoint/Download.php @@ -8,6 +8,7 @@ use Artemeon\Confluence\Endpoint\Dto\ConfluencePage; use DateTime; use GuzzleHttp\Client; +use RuntimeException; class Download { @@ -22,39 +23,30 @@ public function __construct(Client $client, Auth $auth, string $downloadFolder) $this->downloadFolder = $downloadFolder; } - private function checkDownloadFolder(): bool + private function ensureDownloadFolder(): void { - if (!is_dir($this->downloadFolder)) { - return mkdir($this->downloadFolder, 0755, true); + if (!is_dir($this->downloadFolder) && !mkdir($this->downloadFolder, 0755, true) && !is_dir($this->downloadFolder)) { + throw new RuntimeException(sprintf('The download folder "%s" does not exist and could not be created.', $this->downloadFolder)); } - - return true; } public function downloadPageContent(ConfluencePage $confluencePage, string $fileName): void { - if (!$this->checkDownloadFolder()) { - echo 'Error: The download folder does not exist or could not be created.'; - - return; - } + $this->ensureDownloadFolder(); $htmlFile = $this->downloadFolder . '/' . $fileName; file_put_contents($htmlFile, $confluencePage->getContent()); } - public function downloadAttachment(ConfluenceAttachment $attachment): void + public function downloadAttachment(ConfluenceAttachment $attachment, string $pageId): void { - if (!$this->checkDownloadFolder()) { - echo 'Error: The download folder does not exist or could not be created.'; - - return; - } + $this->ensureDownloadFolder(); if ($this->shouldAttachmentBeUpdated($attachment)) { - // Verwende den relativen Pfad aus der API, um das Attachment herunterzuladen + // Download via the supported REST endpoint; the legacy /wiki/download servlet + // no longer accepts API-token authentication (returns HTTP 401). $attachmentContent = $this->client->get( - '/wiki/' . $attachment->findDownloadPath(), + '/wiki/rest/api/content/' . $pageId . '/child/attachment/' . $attachment->getId() . '/download', array_merge([], $this->auth->getAuthenticationArray()) )->getBody()->getContents(); diff --git a/src/Endpoint/Dto/ConfluenceAttachment.php b/src/Endpoint/Dto/ConfluenceAttachment.php index ae5b0f1..839cb9a 100755 --- a/src/Endpoint/Dto/ConfluenceAttachment.php +++ b/src/Endpoint/Dto/ConfluenceAttachment.php @@ -14,6 +14,7 @@ class ConfluenceAttachment /** * @param array{ + * id: string, * title: string, * history?: array{ * lastUpdated?: array{ @@ -28,6 +29,11 @@ public function __construct(private array $rawData) $this->lastUpdated = isset($rawData['history']['lastUpdated']['when']) ? new DateTime($rawData['history']['lastUpdated']['when']) : null; } + public function getId(): string + { + return $this->rawData['id']; + } + public function findDownloadPath(): ?string { return $this->rawData['_links']['download'] ?? null; From 392b5b9d975add61b1eff61b140c05c7ee070f79 Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 16:33:13 +0200 Subject: [PATCH 2/7] #32861 refactor(confluence): drop dead findDownloadPath, normalize download URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findDownloadPath() was the only consumer of the broken legacy /wiki/download path and is now unused — remove it so the 401-prone path cannot be reused by accident. Normalize the attachment download URL to a relative path (no leading slash) to match findChildAttachments(); both resolve identically against the host-root base_uri. Clarify the comment: auth is unchanged (same email + API token), only the endpoint moved to the REST API. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Endpoint/Download.php | 8 +++++--- src/Endpoint/Dto/ConfluenceAttachment.php | 5 ----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Endpoint/Download.php b/src/Endpoint/Download.php index 4d457e4..e66ae9f 100755 --- a/src/Endpoint/Download.php +++ b/src/Endpoint/Download.php @@ -43,10 +43,12 @@ public function downloadAttachment(ConfluenceAttachment $attachment, string $pag $this->ensureDownloadFolder(); if ($this->shouldAttachmentBeUpdated($attachment)) { - // Download via the supported REST endpoint; the legacy /wiki/download servlet - // no longer accepts API-token authentication (returns HTTP 401). + // Download via the supported REST endpoint. Same Basic-auth credentials + // (email + API token) as every other request; only the endpoint changed: + // the legacy /wiki/download servlet rejects API-token auth (HTTP 401), + // while the REST API accepts it. $attachmentContent = $this->client->get( - '/wiki/rest/api/content/' . $pageId . '/child/attachment/' . $attachment->getId() . '/download', + 'wiki/rest/api/content/' . $pageId . '/child/attachment/' . $attachment->getId() . '/download', array_merge([], $this->auth->getAuthenticationArray()) )->getBody()->getContents(); diff --git a/src/Endpoint/Dto/ConfluenceAttachment.php b/src/Endpoint/Dto/ConfluenceAttachment.php index 839cb9a..ff2408f 100755 --- a/src/Endpoint/Dto/ConfluenceAttachment.php +++ b/src/Endpoint/Dto/ConfluenceAttachment.php @@ -34,11 +34,6 @@ public function getId(): string return $this->rawData['id']; } - public function findDownloadPath(): ?string - { - return $this->rawData['_links']['download'] ?? null; - } - public function getTitle(): string { return $this->title; From cf6d8d7414feff0e61529d3de1e33badac9d767a Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 16:35:11 +0200 Subject: [PATCH 3/7] #32861 refactor(confluence): translate German attachment-error message to English Align the attachment-retrieval exception message with the other two messages in Content.php, which are already English. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Endpoint/Content.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Endpoint/Content.php b/src/Endpoint/Content.php index a9b4bd0..f15834e 100755 --- a/src/Endpoint/Content.php +++ b/src/Endpoint/Content.php @@ -104,7 +104,7 @@ public function findChildAttachments(string $pageId): array ); if ($response->getStatusCode() !== 200) { - throw new Exception('Fehler beim Abrufen der Attachments. HTTP-Statuscode: ' . $response->getStatusCode()); + throw new Exception('Error retrieving attachments. HTTP status code: ' . $response->getStatusCode()); } $attachmentsData = json_decode($response->getBody()->getContents(), true); From d9a63c58dc2c7773c35d2b3a97628a0bd4e8dfbb Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 16:37:13 +0200 Subject: [PATCH 4/7] #32861 docs(confluence): document thrown exceptions on Content endpoints Add the missing @throws annotations (GuzzleException from the HTTP client, Exception on non-200 responses) to findPagesInSpace, findPageContent and findChildAttachments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Endpoint/Content.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Endpoint/Content.php b/src/Endpoint/Content.php index f15834e..5504f75 100755 --- a/src/Endpoint/Content.php +++ b/src/Endpoint/Content.php @@ -28,6 +28,7 @@ public function __construct(Client $client, Auth $auth) * @param int|null $offset * @return ConfluencePage[] * @throws GuzzleException + * @throws Exception */ public function findPagesInSpace(string $spaceKey, int $limit = 2000, ?int $offset = null): array { @@ -66,6 +67,9 @@ public function findPagesInSpace(string $spaceKey, int $limit = 2000, ?int $offs /** * Use the Confluence Content API to retrieve page content + * + * @throws GuzzleException + * @throws Exception */ public function findPageContent(string $pageId): ConfluencePage { @@ -91,6 +95,8 @@ public function findPageContent(string $pageId): ConfluencePage * Use descendants.attachment in the Content API to get attachments * * @return list + * @throws GuzzleException + * @throws Exception */ public function findChildAttachments(string $pageId): array { From e8b11b12d59d280e3870c69667b7bd1fec2e9e1b Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 16:44:18 +0200 Subject: [PATCH 5/7] #32861 docs(confluence): expand findPageContent and findChildAttachments doc blocks Describe what each method does, its $pageId parameter, return value and error cases. Correct the findChildAttachments summary: it uses the child/attachment endpoint, not descendants.attachment. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Endpoint/Content.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Endpoint/Content.php b/src/Endpoint/Content.php index 5504f75..4306e7d 100755 --- a/src/Endpoint/Content.php +++ b/src/Endpoint/Content.php @@ -66,10 +66,13 @@ public function findPagesInSpace(string $spaceKey, int $limit = 2000, ?int $offs } /** - * Use the Confluence Content API to retrieve page content + * Fetches a single page by its ID from the Confluence Content API, expanding its + * stored body, version, space and labels into a {@see ConfluencePage}. * - * @throws GuzzleException - * @throws Exception + * @param string $pageId the Confluence content ID of the page to load + * + * @throws GuzzleException if the HTTP request fails (network error, timeout, …) + * @throws Exception if Confluence responds with a non-200 status code */ public function findPageContent(string $pageId): ConfluencePage { @@ -92,11 +95,15 @@ public function findPageContent(string $pageId): ConfluencePage } /** - * Use descendants.attachment in the Content API to get attachments + * Lists the attachments of a page via the Content API's child/attachment endpoint, + * expanding each attachment's history so its last-updated timestamp is available. * - * @return list - * @throws GuzzleException - * @throws Exception + * @param string $pageId the Confluence content ID of the parent page + * + * @return list the page's attachments, empty if it has none + * + * @throws GuzzleException if the HTTP request fails (network error, timeout, …) + * @throws Exception if Confluence responds with a non-200 status code */ public function findChildAttachments(string $pageId): array { From 25e960c467313c52c34e39d91846a72046fb0d64 Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 16:50:07 +0200 Subject: [PATCH 6/7] #32861 refactor(confluence): document Download methods and clarify variable name Add doc blocks (description, @param, @throws) to ensureDownloadFolder, downloadPageContent, downloadAttachment and shouldAttachmentBeUpdated, and import GuzzleException so the @throws reference resolves. Rename the local $filemtime to $fileModificationTime for readability. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Endpoint/Download.php | 41 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Endpoint/Download.php b/src/Endpoint/Download.php index e66ae9f..260fed7 100755 --- a/src/Endpoint/Download.php +++ b/src/Endpoint/Download.php @@ -8,6 +8,7 @@ use Artemeon\Confluence\Endpoint\Dto\ConfluencePage; use DateTime; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use RuntimeException; class Download @@ -23,6 +24,11 @@ public function __construct(Client $client, Auth $auth, string $downloadFolder) $this->downloadFolder = $downloadFolder; } + /** + * Makes sure the configured download folder exists, creating it recursively if needed. + * + * @throws RuntimeException if the folder is missing and cannot be created + */ private function ensureDownloadFolder(): void { if (!is_dir($this->downloadFolder) && !mkdir($this->downloadFolder, 0755, true) && !is_dir($this->downloadFolder)) { @@ -30,6 +36,14 @@ private function ensureDownloadFolder(): void } } + /** + * Writes a page's (already prepared) HTML content to a file in the download folder. + * + * @param ConfluencePage $confluencePage the page whose content is written + * @param string $fileName the target file name, relative to the download folder (e.g. "content.html") + * + * @throws RuntimeException if the download folder cannot be ensured + */ public function downloadPageContent(ConfluencePage $confluencePage, string $fileName): void { $this->ensureDownloadFolder(); @@ -38,6 +52,16 @@ public function downloadPageContent(ConfluencePage $confluencePage, string $file file_put_contents($htmlFile, $confluencePage->getContent()); } + /** + * Downloads a single page attachment into the download folder, but only if it is + * new or has been updated since the locally stored copy (see {@see shouldAttachmentBeUpdated()}). + * + * @param ConfluenceAttachment $attachment the attachment to download; its title is used as the file name + * @param string $pageId the Confluence content ID of the page the attachment belongs to + * + * @throws RuntimeException if the download folder cannot be ensured + * @throws GuzzleException if the HTTP request to the REST endpoint fails + */ public function downloadAttachment(ConfluenceAttachment $attachment, string $pageId): void { $this->ensureDownloadFolder(); @@ -61,6 +85,17 @@ private function getAttachmentFilePath(ConfluenceAttachment $attachment): string return $this->downloadFolder . '/' . $attachment->getTitle(); } + /** + * Decides whether an attachment needs to be (re-)downloaded. + * + * Returns true when no local copy exists yet, when the attachment has no known + * last-updated date, or when the local file is older than the attachment's + * last-updated date; otherwise the local copy is considered up to date. + * + * @param ConfluenceAttachment $attachment the attachment to check against its local copy + * + * @return bool true if the attachment should be downloaded, false if the local copy is current + */ private function shouldAttachmentBeUpdated(ConfluenceAttachment $attachment): bool { $filepath = $this->getAttachmentFilePath($attachment); @@ -71,9 +106,9 @@ private function shouldAttachmentBeUpdated(ConfluenceAttachment $attachment): bo } if (file_exists($filepath)) { - $filemtime = filemtime($filepath); - if (is_int($filemtime)) { - return $filemtime < $lastUpdated->getTimestamp(); + $fileModificationTime = filemtime($filepath); + if (is_int($fileModificationTime)) { + return $fileModificationTime < $lastUpdated->getTimestamp(); } } From 054475456f2ff764e4946688ba498d84b99208bb Mon Sep 17 00:00:00 2001 From: Micha Roesler Date: Tue, 9 Jun 2026 18:01:31 +0200 Subject: [PATCH 7/7] #32861 test(confluence): add offline unit tests and PHPUnit CI Re-add phpunit/phpunit (^11.5, compatible with the >=8.2 floor) and phpstan/phpstan-phpunit, plus a phpunit.xml and a 'composer test' script. Add 21 unit tests covering the #32861 fix, all served by a Guzzle MockHandler so no build ever calls the live Confluence API: - DownloadTest: REST attachment URL, Basic-auth header, skip/re-download by mtime, download-folder creation and RuntimeException on failure - ContentTest: JSON-to-DTO parsing, non-200 guard, 5xx GuzzleException, pagination termination - ConfluenceAttachmentTest: id/title and lastUpdated parsing - ConfluencePageContentDownloaderTest: macro replacers, withAttachments and null-pageId guards, and the new error-logging path Add a self-contained PHPUnit GitHub Actions workflow (PHP 8.2 + 8.3, mirroring the PHPStan matrix) so the tests run on every build; the shared workflows repo has no phpunit workflow to reuse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/phpunit.yml | 30 +++ .gitignore | 1 + composer.json | 7 +- phpunit.xml | 20 ++ tests/.gitkeep | 0 tests/ConfluencePageContentDownloaderTest.php | 98 +++++++++ tests/Endpoint/ContentTest.php | 118 +++++++++++ tests/Endpoint/DownloadTest.php | 190 ++++++++++++++++++ .../Endpoint/Dto/ConfluenceAttachmentTest.php | 41 ++++ 9 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/phpunit.yml create mode 100644 phpunit.xml delete mode 100755 tests/.gitkeep create mode 100644 tests/ConfluencePageContentDownloaderTest.php create mode 100644 tests/Endpoint/ContentTest.php create mode 100644 tests/Endpoint/DownloadTest.php create mode 100644 tests/Endpoint/Dto/ConfluenceAttachmentTest.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..ab4528d --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,30 @@ +name: PHPUnit + +on: + pull_request: + push: + branches: + - main + +jobs: + phpunit: + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 8.3, 8.2 ] + stability: [ prefer-stable ] + name: PHPUnit - PHP ${{ matrix.php }} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Composer install + run: composer install --no-interaction --no-ansi --no-progress + - name: Run PHPUnit + run: php ./vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index ca25eed..e1d17f4 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ vendor .idea .phpunit.result.cache +.phpunit.cache composer.lock diff --git a/composer.json b/composer.json index ae6518f..57e0d62 100755 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "license": "MIT", "type": "library", "scripts": { - "phpstan": "php ./vendor/bin/phpstan analyse --memory-limit=4G" + "phpstan": "php ./vendor/bin/phpstan analyse --memory-limit=4G", + "test": "php ./vendor/bin/phpunit" }, "require": { "php": ">=8.2", @@ -14,8 +15,10 @@ "psr/log": "^3.0" }, "require-dev": { + "phpunit/phpunit": "^11.5", "phpstan/phpstan": "^2.1.36", - "phpstan/extension-installer": "^1.3" + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-phpunit": "^2.0.12" }, "autoload": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d780bdc --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + tests + + + + + src + + + diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100755 index e69de29..0000000 diff --git a/tests/ConfluencePageContentDownloaderTest.php b/tests/ConfluencePageContentDownloaderTest.php new file mode 100644 index 0000000..95f669b --- /dev/null +++ b/tests/ConfluencePageContentDownloaderTest.php @@ -0,0 +1,98 @@ + 'att1', 'title' => 'image.png']); + + $replacer = $this->createMock(MacroReplacerInterface::class); + $replacer->expects($this->once())->method('replace')->willReturn('

replaced

'); + + $content = $this->createMock(Content::class); + $content->expects($this->once())->method('findChildAttachments')->with('123')->willReturn([$attachment]); + + $download = $this->createMock(Download::class); + $download->expects($this->once())->method('downloadPageContent') + ->with($this->isInstanceOf(ConfluencePage::class), 'content.html'); + $download->expects($this->once())->method('downloadAttachment')->with($attachment, '123'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('error'); + + $downloader = new ConfluencePageContentDownloader($content, $download, [$replacer], $logger); + $downloader->downloadPageContent($this->page('123')); + } + + public function testDoesNotDownloadAttachmentsWhenDisabled(): void + { + $content = $this->createMock(Content::class); + $content->expects($this->never())->method('findChildAttachments'); + + $download = $this->createMock(Download::class); + $download->expects($this->once())->method('downloadPageContent'); + $download->expects($this->never())->method('downloadAttachment'); + + $downloader = new ConfluencePageContentDownloader($content, $download); + $downloader->downloadPageContent($this->page('123'), false); + } + + public function testDoesNotDownloadAttachmentsWhenPageHasNoId(): void + { + $content = $this->createMock(Content::class); + $content->expects($this->never())->method('findChildAttachments'); + + $download = $this->createMock(Download::class); + $download->expects($this->once())->method('downloadPageContent'); + $download->expects($this->never())->method('downloadAttachment'); + + $downloader = new ConfluencePageContentDownloader($content, $download); + $downloader->downloadPageContent($this->page(null)); + } + + public function testLogsAnErrorWhenDownloadingFails(): void + { + // Stubs (no expectations): they only provide behaviour. The assertion lives on + // the logger mock below. + $download = self::createStub(Download::class); + $download->method('downloadPageContent')->willThrowException(new RuntimeException('boom')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error') + ->with($this->stringContains('boom'), $this->arrayHasKey('exception')); + + $downloader = new ConfluencePageContentDownloader(self::createStub(Content::class), $download, [], $logger); + + // Must not bubble up — the failure is caught and logged. + $downloader->downloadPageContent($this->page('123')); + } + + private function page(?string $id): ConfluencePage + { + $rawData = ['body' => ['storage' => ['value' => '

original

']]]; + if ($id !== null) { + $rawData['id'] = $id; + } + + return new ConfluencePage($rawData); + } +} diff --git a/tests/Endpoint/ContentTest.php b/tests/Endpoint/ContentTest.php new file mode 100644 index 0000000..7d2a930 --- /dev/null +++ b/tests/Endpoint/ContentTest.php @@ -0,0 +1,118 @@ + [ + ['id' => 'a1', 'title' => 'first.png'], + ['id' => 'a2', 'title' => 'second.png', 'history' => ['lastUpdated' => ['when' => '2021-01-01T00:00:00.000Z']]], + ], + ], JSON_THROW_ON_ERROR); + $content = $this->content(new Response(200, [], $json)); + + $attachments = $content->findChildAttachments('123'); + + self::assertCount(2, $attachments); + self::assertContainsOnlyInstancesOf(ConfluenceAttachment::class, $attachments); + self::assertSame('a1', $attachments[0]->getId()); + self::assertSame('second.png', $attachments[1]->getTitle()); + } + + public function testFindChildAttachmentsReturnsEmptyListWhenThereAreNoAttachments(): void + { + $content = $this->content(new Response(200, [], json_encode(['results' => []], JSON_THROW_ON_ERROR))); + + self::assertSame([], $content->findChildAttachments('123')); + } + + public function testFindChildAttachmentsThrowsOnUnexpectedSuccessStatus(): void + { + // 204 is a 2xx (so Guzzle's http_errors does not trigger) but not the 200 the + // endpoint requires, exercising the explicit non-200 guard. + $content = $this->content(new Response(204)); + + $this->expectException(Exception::class); + $content->findChildAttachments('123'); + } + + public function testFindChildAttachmentsSurfacesServerErrorsAsGuzzleException(): void + { + $content = $this->content(new Response(500, [], 'boom')); + + $this->expectException(GuzzleException::class); + $content->findChildAttachments('123'); + } + + public function testFindPagesInSpaceParsesPagesAndTerminatesOnAShortPage(): void + { + $json = json_encode([ + 'results' => [ + ['id' => 'p1', 'title' => 'Page one'], + ['id' => 'p2', 'title' => 'Page two'], + ], + 'limit' => 200, + 'size' => 2, + ], JSON_THROW_ON_ERROR); + $content = $this->content(new Response(200, [], $json)); + + $pages = $content->findPagesInSpace('SPACE'); + + self::assertCount(2, $pages); + self::assertContainsOnlyInstancesOf(ConfluencePage::class, $pages); + self::assertArrayHasKey('p1', $pages); + self::assertSame('Page two', $pages['p2']->getTitle()); + } + + public function testFindPageContentParsesASinglePage(): void + { + $json = json_encode([ + 'id' => 'p9', + 'title' => 'Single page', + 'body' => ['storage' => ['value' => '

body

']], + ], JSON_THROW_ON_ERROR); + $content = $this->content(new Response(200, [], $json)); + + $page = $content->findPageContent('p9'); + + self::assertSame('p9', $page->getId()); + self::assertSame('

body

', $page->getContent()); + } + + public function testFindPageContentThrowsOnUnexpectedSuccessStatus(): void + { + $content = $this->content(new Response(204)); + + $this->expectException(Exception::class); + $content->findPageContent('p9'); + } + + private function content(Response $response): Content + { + $stack = HandlerStack::create(new MockHandler([$response])); + $client = new Client(['handler' => $stack, 'base_uri' => 'https://example.atlassian.net/']); + + return new Content($client, new Auth('user@example.com', 'secret-token')); + } +} diff --git a/tests/Endpoint/DownloadTest.php b/tests/Endpoint/DownloadTest.php new file mode 100644 index 0000000..787fa63 --- /dev/null +++ b/tests/Endpoint/DownloadTest.php @@ -0,0 +1,190 @@ +tempDir = sys_get_temp_dir() . '/confluence-download-test-' . uniqid('', true); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + $this->removeRecursively($this->tempDir); + } + + public function testDownloadAttachmentHitsTheRestEndpointAndWritesTheFile(): void + { + $mock = new MockHandler([new Response(200, [], 'PNG-BYTES')]); + $download = new Download($this->client($mock), $this->auth(), $this->tempDir); + + $download->downloadAttachment($this->attachment('att42', 'image.png'), '12345'); + + self::assertSame('PNG-BYTES', file_get_contents($this->tempDir . '/image.png')); + $request = $mock->getLastRequest(); + self::assertNotNull($request); + self::assertSame( + '/wiki/rest/api/content/12345/child/attachment/att42/download', + $request->getUri()->getPath(), + ); + } + + public function testDownloadAttachmentSendsBasicAuthCredentials(): void + { + $mock = new MockHandler([new Response(200, [], 'data')]); + $download = new Download($this->client($mock), $this->auth(), $this->tempDir); + + $download->downloadAttachment($this->attachment('att1', 'file.bin'), '1'); + + $request = $mock->getLastRequest(); + self::assertNotNull($request); + $authorization = $request->getHeaderLine('Authorization'); + self::assertStringStartsWith('Basic ', $authorization); + self::assertSame( + 'user@example.com:secret-token', + base64_decode(substr($authorization, strlen('Basic ')), true), + ); + } + + public function testDownloadAttachmentSkipsDownloadWhenLocalCopyIsUpToDate(): void + { + file_put_contents($this->tempDir . '/image.png', 'existing'); + + // No responses queued: any HTTP call would make the MockHandler throw. + $mock = new MockHandler([]); + $download = new Download($this->client($mock), $this->auth(), $this->tempDir); + + // Attachment last changed in the past, local file's mtime is "now" -> up to date. + $download->downloadAttachment($this->attachment('att1', 'image.png', '2000-01-01T00:00:00.000Z'), '1'); + + self::assertNull($mock->getLastRequest()); + self::assertSame('existing', file_get_contents($this->tempDir . '/image.png')); + } + + public function testDownloadAttachmentReDownloadsWhenRemoteIsNewerThanLocalCopy(): void + { + file_put_contents($this->tempDir . '/image.png', 'stale'); + + $mock = new MockHandler([new Response(200, [], 'fresh')]); + $download = new Download($this->client($mock), $this->auth(), $this->tempDir); + + // Attachment last changed far in the future, local file's mtime is "now" -> stale. + $download->downloadAttachment($this->attachment('att1', 'image.png', '2999-01-01T00:00:00.000Z'), '1'); + + self::assertNotNull($mock->getLastRequest()); + self::assertSame('fresh', file_get_contents($this->tempDir . '/image.png')); + } + + public function testDownloadPageContentWritesContentToTheGivenFile(): void + { + $mock = new MockHandler([]); + $download = new Download($this->client($mock), $this->auth(), $this->tempDir); + + $download->downloadPageContent($this->page('

Hello

'), 'content.html'); + + self::assertSame('

Hello

', file_get_contents($this->tempDir . '/content.html')); + self::assertNull($mock->getLastRequest()); + } + + public function testEnsureDownloadFolderCreatesAMissingNestedFolder(): void + { + $nested = $this->tempDir . '/a/b/c'; + $download = new Download($this->client(new MockHandler([])), $this->auth(), $nested); + + $download->downloadPageContent($this->page('x'), 'content.html'); + + self::assertDirectoryExists($nested); + self::assertFileExists($nested . '/content.html'); + } + + public function testThrowsWhenDownloadFolderCannotBeCreated(): void + { + // A file blocks the folder path, so mkdir() cannot create it. + $blocker = $this->tempDir . '/iam-a-file'; + file_put_contents($blocker, 'x'); + + $download = new Download($this->client(new MockHandler([])), $this->auth(), $blocker . '/sub'); + + // Swallow the expected mkdir() warning so PHPUnit's strict handler does not + // fail the test before the RuntimeException we actually want to assert. + set_error_handler(static fn (): bool => true); + + try { + $this->expectException(RuntimeException::class); + $download->downloadPageContent($this->page('x'), 'content.html'); + } finally { + restore_error_handler(); + } + } + + private function client(MockHandler $mock): Client + { + return new Client([ + 'handler' => HandlerStack::create($mock), + 'base_uri' => 'https://example.atlassian.net/', + ]); + } + + private function auth(): Auth + { + return new Auth('user@example.com', 'secret-token'); + } + + private function attachment(string $id, string $title, ?string $lastUpdated = null): ConfluenceAttachment + { + $rawData = ['id' => $id, 'title' => $title]; + if ($lastUpdated !== null) { + $rawData['history'] = ['lastUpdated' => ['when' => $lastUpdated]]; + } + + return new ConfluenceAttachment($rawData); + } + + private function page(string $content): ConfluencePage + { + return new ConfluencePage(['id' => '1', 'body' => ['storage' => ['value' => $content]]]); + } + + private function removeRecursively(string $path): void + { + if (!file_exists($path)) { + return; + } + + if (is_dir($path)) { + foreach (scandir($path) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + $this->removeRecursively($path . '/' . $entry); + } + } + rmdir($path); + + return; + } + + unlink($path); + } +} diff --git a/tests/Endpoint/Dto/ConfluenceAttachmentTest.php b/tests/Endpoint/Dto/ConfluenceAttachmentTest.php new file mode 100644 index 0000000..85bdf0e --- /dev/null +++ b/tests/Endpoint/Dto/ConfluenceAttachmentTest.php @@ -0,0 +1,41 @@ + 'att123', 'title' => 'diagram.png']); + + self::assertSame('att123', $attachment->getId()); + self::assertSame('diagram.png', $attachment->getTitle()); + } + + public function testLastUpdatedIsNullWhenHistoryIsMissing(): void + { + $attachment = new ConfluenceAttachment(['id' => 'att123', 'title' => 'diagram.png']); + + self::assertNull($attachment->getLastUpdated()); + } + + public function testLastUpdatedIsParsedFromHistoryWhenPresent(): void + { + $attachment = new ConfluenceAttachment([ + 'id' => 'att123', + 'title' => 'diagram.png', + 'history' => ['lastUpdated' => ['when' => '2021-06-15T10:30:00.000Z']], + ]); + + $lastUpdated = $attachment->getLastUpdated(); + + self::assertInstanceOf(DateTime::class, $lastUpdated); + self::assertSame('2021-06-15', $lastUpdated->format('Y-m-d')); + } +}