Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
271 changes: 271 additions & 0 deletions tests/integration/DiscourseAPIServiceIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Discourse\Tests\Integration;

use OCA\Discourse\Service\DiscourseAPIService;
use OCP\Server;
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 {

private DiscourseAPIService $service;
private ?string $discourseUrl;
private ?string $discourseToken;

protected function setUp(): void {
parent::setUp();

$this->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);
}
}
16 changes: 16 additions & 0 deletions tests/phpunit.integration.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<phpunit bootstrap="bootstrap.php" timeoutForSmallTests="900" timeoutForMediumTests="900" timeoutForLargeTests="900" cacheDirectory=".phpunit.cache">
<coverage>
<include>
<directory suffix=".php">../appinfo</directory>
<directory suffix=".php">../lib</directory>
</include>
</coverage>
<testsuite name="Discourse Integration Tests">
<directory suffix="Test.php">./integration</directory>
</testsuite>
</phpunit>
4 changes: 2 additions & 2 deletions tests/phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="bootstrap.php" timeoutForSmallTests="900" timeoutForMediumTests="900" timeoutForLargeTests="900" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.4/phpunit.xsd" cacheDirectory=".phpunit.cache">
<phpunit bootstrap="bootstrap.php" timeoutForSmallTests="900" timeoutForMediumTests="900" timeoutForLargeTests="900" cacheDirectory=".phpunit.cache">
<coverage>
<report>
<clover outputFile="./clover.xml"/>
</report>
</coverage>
<testsuite name="Nextcloud Discourse integration App Tests">
<directory suffix="Test.php">.</directory>
<directory suffix="Test.php">./unit</directory>
</testsuite>
<!-- filters for code coverage -->
<logging>
Expand Down