From 5b0769a67c70be55dceaa71800f72df5d92f95e1 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 2 Mar 2026 17:05:35 +0100 Subject: [PATCH 1/4] feat(tests): add simple integration test calling the DiscourseAPIService::getNotifications method on a real instance Signed-off-by: Julien Veyssier --- .github/workflows/integration.yml | 79 +++++++++++++++++++ composer.json | 1 + .../DiscourseAPIServiceIntegrationTest.php | 72 +++++++++++++++++ tests/phpunit.integration.xml | 16 ++++ tests/phpunit.xml | 4 +- 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 tests/integration/DiscourseAPIServiceIntegrationTest.php create mode 100644 tests/phpunit.integration.xml 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..431cff3 --- /dev/null +++ b/tests/integration/DiscourseAPIServiceIntegrationTest.php @@ -0,0 +1,72 @@ +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'); + } + } + + public function testGetNotifications(): void { + $this->requireCredentials(); + + $result = $this->service->getNotifications($this->discourseUrl, $this->discourseToken); + + $this->assertIsArray($result); + $this->assertArrayNotHasKey('error', $result); + + if (count($result) > 0) { + $notification = $result[0]; + $this->assertArrayHasKey('id', $notification); + $this->assertArrayHasKey('notification_type', $notification); + $this->assertArrayHasKey('read', $notification); + $this->assertArrayHasKey('created_at', $notification); + $this->assertArrayHasKey('slug', $notification); + } + } + + 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 From 242f2114468f99417cb381cd9f824561cb0cba6f Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 2 Mar 2026 17:09:07 +0100 Subject: [PATCH 2/4] adjust the new test Signed-off-by: Julien Veyssier --- tests/integration/DiscourseAPIServiceIntegrationTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/DiscourseAPIServiceIntegrationTest.php b/tests/integration/DiscourseAPIServiceIntegrationTest.php index 431cff3..0f2bf17 100644 --- a/tests/integration/DiscourseAPIServiceIntegrationTest.php +++ b/tests/integration/DiscourseAPIServiceIntegrationTest.php @@ -42,7 +42,10 @@ public function testGetNotifications(): void { $this->assertIsArray($result); $this->assertArrayNotHasKey('error', $result); + $this->assertGreaterThan(0, count($result), 'test requires at least one notification'); + if (count($result) > 0) { + // echo json_encode($result, JSON_PRETTY_PRINT); $notification = $result[0]; $this->assertArrayHasKey('id', $notification); $this->assertArrayHasKey('notification_type', $notification); From 68e6059b41e3dccf76bbbdd28c923bda37a9e234 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 2 Mar 2026 17:26:56 +0100 Subject: [PATCH 3/4] feat(tests): test that the real notifications can be parsed by the dashboard widget Signed-off-by: Julien Veyssier --- .../DiscourseAPIServiceIntegrationTest.php | 136 ++++++++++++++++-- 1 file changed, 126 insertions(+), 10 deletions(-) diff --git a/tests/integration/DiscourseAPIServiceIntegrationTest.php b/tests/integration/DiscourseAPIServiceIntegrationTest.php index 0f2bf17..aca792f 100644 --- a/tests/integration/DiscourseAPIServiceIntegrationTest.php +++ b/tests/integration/DiscourseAPIServiceIntegrationTest.php @@ -12,6 +12,39 @@ use PHPUnit\Framework\Attributes\Group; use Test\TestCase; +/** + * Notification type constants matching src/views/Dashboard.vue TYPES + */ +const TYPES_MENTION = 1; +const TYPES_REPLY = 2; +const TYPES_QUOTED = 3; +const TYPES_EDIT = 4; +const TYPES_LIKE = 5; +const TYPES_PRIVATE_MESSAGE = 6; +const TYPES_REPLY_2 = 9; +const TYPES_LINKED = 11; +const TYPES_BADGE_EARNED = 12; +const TYPES_SOLVED = 14; +const TYPES_GROUP_MENTION = 15; +const TYPES_MODERATOR_OR_ADMIN_INBOX = 16; + +/** + * All notification types handled by the Dashboard widget + */ +const DASHBOARD_TYPES = [ + TYPES_MENTION, + TYPES_REPLY, + TYPES_QUOTED, + TYPES_EDIT, + TYPES_LIKE, + TYPES_PRIVATE_MESSAGE, + TYPES_REPLY_2, + TYPES_LINKED, + TYPES_SOLVED, + TYPES_GROUP_MENTION, + TYPES_MODERATOR_OR_ADMIN_INBOX, +]; + #[Group('DB')] class DiscourseAPIServiceIntegrationTest extends TestCase { @@ -34,6 +67,11 @@ private function requireCredentials(): void { } } + /** + * 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(); @@ -42,16 +80,94 @@ public function testGetNotifications(): void { $this->assertIsArray($result); $this->assertArrayNotHasKey('error', $result); - $this->assertGreaterThan(0, count($result), 'test requires at least one notification'); - - if (count($result) > 0) { - // echo json_encode($result, JSON_PRETTY_PRINT); - $notification = $result[0]; - $this->assertArrayHasKey('id', $notification); - $this->assertArrayHasKey('notification_type', $notification); - $this->assertArrayHasKey('read', $notification); - $this->assertArrayHasKey('created_at', $notification); - $this->assertArrayHasKey('slug', $notification); + $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; + } } } From 9b532c4319e2109cb4ba55b51a2b3ccad526f270 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 2 Mar 2026 17:35:43 +0100 Subject: [PATCH 4/4] feat(tests): test searchTopics and searchPosts Signed-off-by: Julien Veyssier --- .../DiscourseAPIServiceIntegrationTest.php | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/integration/DiscourseAPIServiceIntegrationTest.php b/tests/integration/DiscourseAPIServiceIntegrationTest.php index aca792f..b8f3217 100644 --- a/tests/integration/DiscourseAPIServiceIntegrationTest.php +++ b/tests/integration/DiscourseAPIServiceIntegrationTest.php @@ -80,6 +80,8 @@ public function testGetNotifications(): void { $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) { @@ -171,6 +173,84 @@ public function testGetNotifications(): void { } } + /** + * 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');