diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..685fc85 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Integration tests + +on: + pull_request: + paths: + - .github/workflows/integration.yml + - appinfo/** + - composer.* + - lib/** + - templates/** + - tests/** + push: + branches: + - main + - stable* + - test + paths: + - .github/workflows/integration.yml + - appinfo/** + - composer.* + - lib/** + - templates/** + - tests/** + +env: + APP_NAME: integration_discourse + +jobs: + integration: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-versions: ['8.2'] + databases: ['sqlite'] + server-versions: ['master'] + + name: php${{ matrix.php-versions }}-${{ matrix.databases }}-${{ matrix.server-versions }} + + steps: + - name: Checkout server + uses: actions/checkout@v4 + with: + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + submodules: true + + - name: Checkout app + uses: actions/checkout@v4 + with: + path: apps/${{ env.APP_NAME }} + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: phpunit:10.5.x + extensions: mbstring, iconv, fileinfo, intl, sqlite, pdo_sqlite, gd, zip + + - name: Set up PHPUnit + working-directory: apps/${{ env.APP_NAME }} + run: composer i + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=sqlite --admin-user admin --admin-pass admin + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: PHPUnit integration + working-directory: apps/${{ env.APP_NAME }} + env: + DISCOURSE_URL: ${{ secrets.DISCOURSE_URL }} + DISCOURSE_TOKEN: ${{ secrets.DISCOURSE_TOKEN }} + run: phpunit -c tests/phpunit.integration.xml diff --git a/composer.json b/composer.json index 110a41f..885edb9 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", "test:unit": "phpunit --config tests/phpunit.xml", + "test:integration": "phpunit --config tests/phpunit.integration.xml", "psalm": "psalm --no-cache", "psalm:update-baseline": "psalm --threads=1 --update-baseline", "psalm:update-baseline:force": "psalm --threads=1 --update-baseline --set-baseline=tests/psalm-baseline.xml", diff --git a/tests/integration/DiscourseAPIServiceIntegrationTest.php b/tests/integration/DiscourseAPIServiceIntegrationTest.php new file mode 100644 index 0000000..b8f3217 --- /dev/null +++ b/tests/integration/DiscourseAPIServiceIntegrationTest.php @@ -0,0 +1,271 @@ +discourseUrl = getenv('DISCOURSE_URL') ?: null; + $this->discourseToken = getenv('DISCOURSE_TOKEN') ?: null; + + $this->service = Server::get(DiscourseAPIService::class); + } + + private function requireCredentials(): void { + if ($this->discourseUrl === null || $this->discourseToken === null) { + $this->markTestSkipped('DISCOURSE_URL and/or DISCOURSE_TOKEN not set'); + } + } + + /** + * Test that getNotifications returns a valid array and that every notification + * has the fields required by the Dashboard.vue computed properties and methods + * so that rendering won't crash. + */ + public function testGetNotifications(): void { + $this->requireCredentials(); + + $result = $this->service->getNotifications($this->discourseUrl, $this->discourseToken); + + $this->assertIsArray($result); + $this->assertArrayNotHasKey('error', $result); + + // echo json_encode($result, JSON_PRETTY_PRINT); + + $this->assertGreaterThan(0, count($result), 'test data requires at least one notification'); + + foreach ($result as $i => $n) { + $prefix = "notification[$i] (id=" . ($n['id'] ?? '?') . ', type=' . ($n['notification_type'] ?? '?') . ')'; + + // Fields accessed by every notification in Dashboard.vue: + // getUniqueKey -> n.id + // filter -> n.read, n.notification_type + // getTargetTitle -> n.fancy_title (fallback) + // getFormattedDate -> n.created_at + // getNotificationTarget -> n.slug + // items computed -> all above methods + $this->assertArrayHasKey('id', $n, "$prefix: missing 'id'"); + $this->assertArrayHasKey('notification_type', $n, "$prefix: missing 'notification_type'"); + $this->assertIsInt($n['notification_type'], "$prefix: 'notification_type' should be int"); + $this->assertArrayHasKey('read', $n, "$prefix: missing 'read'"); + $this->assertArrayHasKey('created_at', $n, "$prefix: missing 'created_at'"); + $this->assertIsString($n['created_at'], "$prefix: 'created_at' should be string"); + $this->assertArrayHasKey('slug', $n, "$prefix: missing 'slug'"); + + $type = $n['notification_type']; + + // Only validate type-specific fields for types the Dashboard actually handles. + // Other types are filtered out and never rendered. + if (!in_array($type, DASHBOARD_TYPES)) { + continue; + } + + // getTargetTitle accesses n.fancy_title as a fallback for all non-MODERATOR_OR_ADMIN_INBOX types + if ($type !== TYPES_MODERATOR_OR_ADMIN_INBOX) { + $this->assertArrayHasKey('fancy_title', $n, "$prefix: missing 'fancy_title'"); + } + + // n.data is accessed by virtually every method for handled types + $this->assertArrayHasKey('data', $n, "$prefix: missing 'data'"); + $this->assertIsArray($n['data'], "$prefix: 'data' should be an array"); + $data = $n['data']; + + switch ($type) { + case TYPES_MENTION: + case TYPES_PRIVATE_MESSAGE: + // getNotificationTarget -> n.slug, n.topic_id + $this->assertArrayHasKey('topic_id', $n, "$prefix: missing 'topic_id'"); + // getNotificationImage -> n.data.original_username + $this->assertArrayHasKey('original_username', $data, "$prefix: missing 'data.original_username'"); + // getDisplayAndOriginalUsername (via getSubline) -> n.data.display_username, n.data.original_username + $this->assertArrayHasKey('display_username', $data, "$prefix: missing 'data.display_username'"); + break; + + case TYPES_REPLY: + case TYPES_REPLY_2: + case TYPES_LIKE: + // getNotificationTarget -> n.slug, n.topic_id, n.post_number + $this->assertArrayHasKey('topic_id', $n, "$prefix: missing 'topic_id'"); + $this->assertArrayHasKey('post_number', $n, "$prefix: missing 'post_number'"); + // getNotificationImage -> n.data.original_username + $this->assertArrayHasKey('original_username', $data, "$prefix: missing 'data.original_username'"); + // getDisplayAndOriginalUsername (via getSubline) -> n.data.display_username + $this->assertArrayHasKey('display_username', $data, "$prefix: missing 'data.display_username'"); + break; + + case TYPES_SOLVED: + // getNotificationTarget -> n.slug, n.topic_id, n.post_number + $this->assertArrayHasKey('topic_id', $n, "$prefix: missing 'topic_id'"); + $this->assertArrayHasKey('post_number', $n, "$prefix: missing 'post_number'"); + // getNotificationImage -> n.data.display_username + $this->assertArrayHasKey('display_username', $data, "$prefix: missing 'data.display_username'"); + // getSubline -> n.data.display_username (already checked above) + break; + + case TYPES_BADGE_EARNED: + // getNotificationTarget -> n.data.badge_id, n.data.badge_slug, n.data.username + $this->assertArrayHasKey('badge_id', $data, "$prefix: missing 'data.badge_id'"); + $this->assertArrayHasKey('badge_slug', $data, "$prefix: missing 'data.badge_slug'"); + $this->assertArrayHasKey('username', $data, "$prefix: missing 'data.username'"); + // getSubline -> n.data.badge_name + $this->assertArrayHasKey('badge_name', $data, "$prefix: missing 'data.badge_name'"); + break; + + case TYPES_MODERATOR_OR_ADMIN_INBOX: + // getTargetTitle accesses n.data.group_name, n.data.inbox_count (guarded by && checks, so won't crash) + // nbAdminInboxItem / nbModeratorInboxItem -> n.data.group_name (guarded by n.data && check) + // These are safe even if missing since the code guards with `n.data && n.data.group_name` + // but we still verify they are present for correctness + $this->assertArrayHasKey('group_name', $data, "$prefix: missing 'data.group_name'"); + $this->assertArrayHasKey('inbox_count', $data, "$prefix: missing 'data.inbox_count'"); + break; + } + } + } + + /** + * Test that searchTopics returns results whose structure matches + * what DiscourseSearchTopicsProvider expects: + * getMainText -> $entry['title'] + * getSubline -> $entry['id'], $entry['posts_count'] + * getLinkToDiscourse -> $entry['slug'], $entry['id'] + */ + public function testSearchTopics(): void { + $this->requireCredentials(); + + $result = $this->service->searchTopics($this->discourseUrl, $this->discourseToken, 'error', 0, 5); + + $this->assertIsArray($result); + $this->assertArrayNotHasKey('error', $result); + $this->assertNotEmpty($result, 'Searching "error" should return at least one topic'); + + // echo json_encode($result, JSON_PRETTY_PRINT); + // echo '-----------------------------------------------'; + + foreach ($result as $i => $entry) { + $prefix = "topic[$i] (id=" . ($entry['id'] ?? '?') . ')'; + + // DiscourseSearchTopicsProvider::getMainText + $this->assertArrayHasKey('title', $entry, "$prefix: missing 'title'"); + $this->assertIsString($entry['title'], "$prefix: 'title' should be a string"); + + // DiscourseSearchTopicsProvider::getSubline + $this->assertArrayHasKey('id', $entry, "$prefix: missing 'id'"); + $this->assertArrayHasKey('posts_count', $entry, "$prefix: missing 'posts_count'"); + $this->assertIsInt($entry['posts_count'], "$prefix: 'posts_count' should be an int"); + + // DiscourseSearchTopicsProvider::getLinkToDiscourse + $this->assertArrayHasKey('slug', $entry, "$prefix: missing 'slug'"); + $this->assertIsString($entry['slug'], "$prefix: 'slug' should be a string"); + } + } + + /** + * Test that searchPosts returns results whose structure matches + * what DiscourseSearchPostsProvider expects: + * getMainText -> $entry['blurb'] ?? $entry['username'] + * getSubline -> $entry['topic_id'] + * getLinkToDiscourse -> $entry['topic_id'] + * getThumbnailUrl -> $entry['username'] + */ + public function testSearchPosts(): void { + $this->requireCredentials(); + + $result = $this->service->searchPosts($this->discourseUrl, $this->discourseToken, 'error', 0, 5); + + $this->assertIsArray($result); + $this->assertArrayNotHasKey('error', $result); + $this->assertNotEmpty($result, 'Searching "error" should return at least one post'); + + // echo json_encode($result, JSON_PRETTY_PRINT); + // echo '-----------------------------------------------'; + + foreach ($result as $i => $entry) { + $prefix = "post[$i] (id=" . ($entry['id'] ?? '?') . ')'; + + // DiscourseSearchPostsProvider::getMainText -> $entry['blurb'] ?? $entry['username'] + // At least one of blurb or username must be present + $this->assertTrue( + isset($entry['blurb']) || isset($entry['username']), + "$prefix: at least one of 'blurb' or 'username' must be present" + ); + + // DiscourseSearchPostsProvider::getSubline and getLinkToDiscourse + $this->assertArrayHasKey('topic_id', $entry, "$prefix: missing 'topic_id'"); + + // DiscourseSearchPostsProvider::getThumbnailUrl + // username is used for the avatar URL; the provider falls back to a static + // icon when it is absent, but it is always present in practice + $this->assertArrayHasKey('username', $entry, "$prefix: missing 'username'"); + $this->assertIsString($entry['username'], "$prefix: 'username' should be a string"); + } + } + + public function testGetNotificationsWithInvalidToken(): void { + if ($this->discourseUrl === null) { + $this->markTestSkipped('DISCOURSE_URL not set'); + } + + $result = $this->service->getNotifications($this->discourseUrl, 'invalid_token_12345'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('error', $result); + } + + public function testGetNotificationsWithInvalidUrl(): void { + $result = $this->service->getNotifications('https://invalid.discourse.example.com', 'some_token'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('error', $result); + } +} diff --git a/tests/phpunit.integration.xml b/tests/phpunit.integration.xml new file mode 100644 index 0000000..e0a3d88 --- /dev/null +++ b/tests/phpunit.integration.xml @@ -0,0 +1,16 @@ + + + + + + ../appinfo + ../lib + + + + ./integration + + diff --git a/tests/phpunit.xml b/tests/phpunit.xml index ed54203..92a593d 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -3,14 +3,14 @@ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - SPDX-License-Identifier: AGPL-3.0-or-later --> - + - . + ./unit