From 644c02aed50d6079f17165c221adb882a7345f7a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:24:05 -0300 Subject: [PATCH 01/14] test: add client server protocol compatibility coverage --- tests/integration/composer.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/integration/composer.json diff --git a/tests/integration/composer.json b/tests/integration/composer.json new file mode 100644 index 0000000..d9b402c --- /dev/null +++ b/tests/integration/composer.json @@ -0,0 +1,31 @@ +{ + "repositories": [ + { + "type": "path", + "url": "../..", + "options": { + "symlink": true + } + } + ], + "require": { + "behat/behat": "3.32.0", + "guzzlehttp/guzzle": "7.15.3", + "jarnaiz/behat-junit-formatter": "1.3.2", + "librecodecoop/usage-statistics-client": "@dev", + "libresign/nextcloud-behat": "1.6.0", + "php-http/guzzle7-adapter": "1.1.0", + "php-http/message": "1.16.2" + }, + "scripts": { + "behat": "@php -d error_reporting=24575 vendor/bin/behat -f junit -f pretty --colors" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + }, + "platform": { + "php": "8.3" + } + } +} From 84d6809c97903647a8f5db73dffe3035f7d31f3c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:24:08 -0300 Subject: [PATCH 02/14] test: add client server protocol compatibility coverage --- tests/integration/config/behat.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/integration/config/behat.yml diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml new file mode 100644 index 0000000..55517c9 --- /dev/null +++ b/tests/integration/config/behat.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +default: + autoload: + '': '%paths.base%/../features/bootstrap' + suites: + default: + contexts: + - FeatureContext + paths: + - '%paths.base%/../features' + + extensions: + PhpBuiltin\\Server: + workers: 2 + jarnaiz\\JUnitFormatter\\JUnitFormatterExtension: + filename: report.xml + outputDir: '%paths.base%/../output/' From 1a52390cb5dc6ca97e1f7e054e81172a68e6c324 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:24:10 -0300 Subject: [PATCH 03/14] test: add client server protocol compatibility coverage --- .../features/bootstrap/FeatureContext.php | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/integration/features/bootstrap/FeatureContext.php diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..83cef93 --- /dev/null +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -0,0 +1,104 @@ +setCurrentUser(''); + } + + #[When('the usage statistics client submits the compatibility report')] + public function submitCompatibilityReport(): void { + $report = new Report( + application: 'client_server_e2e', + installationId: (string)InstallationId::derive('client_server_e2e', 'integration-installation'), + schemaVersion: 1, + period: new ReportingPeriod( + new DateTimeImmutable('2026-09-01T00:00:00Z'), + new DateTimeImmutable('2026-10-01T00:00:00Z'), + ), + metrics: [ + Metric::string('environment', 'version', '1.2.3'), + Metric::integer('usage', 'requests_completed', 17), + ], + ); + + $transport = new class($this->baseUrl) implements TransportInterface { + public function __construct(private readonly string $baseUrl) { + } + + public function request(string $method, string $url, array $headers, string $body, float $timeoutSeconds): Response { + $path = parse_url($url, PHP_URL_PATH); + if (!is_string($path) || $path === '') { + throw new RuntimeException('Compatibility endpoint path is invalid.'); + } + + $http = new HttpClient(['http_errors' => false]); + $response = $http->request( + $method, + rtrim($this->baseUrl, '/') . $path, + [ + 'headers' => $headers, + 'body' => $body, + 'timeout' => $timeoutSeconds, + ], + ); + + $responseHeaders = []; + foreach ($response->getHeaders() as $name => $values) { + $responseHeaders[$name] = implode(', ', $values); + } + + return new Response( + $response->getStatusCode(), + (string)$response->getBody(), + $responseHeaders, + ); + } + }; + + $client = new Client( + $transport, + new Endpoint('https://usage-statistics.invalid/index.php/apps/usage_statistics_server/api/v1/reports'), + ); + + $result = $client->submit($report, ConsentState::Enabled); + if ($result !== SubmissionResult::Accepted) { + throw new RuntimeException('Compatibility report was not accepted.'); + } + } +} From 36ca3c76950f93c66e840f1e24e63875bd58b815 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:24:13 -0300 Subject: [PATCH 04/14] test: add client server protocol compatibility coverage --- .../client_server_compatibility.feature | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/integration/features/client_server_compatibility.feature diff --git a/tests/integration/features/client_server_compatibility.feature b/tests/integration/features/client_server_compatibility.feature new file mode 100644 index 0000000..d0b768f --- /dev/null +++ b/tests/integration/features/client_server_compatibility.feature @@ -0,0 +1,27 @@ +Feature: Usage Statistics Protocol v1 client/server compatibility + + Scenario: the real client submits a report that the server stores and aggregates + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | client_server_e2e | + | schemaVersion | 1 | + | metrics | [{"category":"environment","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true},{"category":"usage","key":"requests_completed","type":"integer","kind":"period","aggregation":"numerical","description":"Completed requests","required":true}] | + Then the response should have a status code 201 + + When the usage statistics client submits the compatibility report + + Given as user "admin" + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/client_server_e2e/metrics/environment/version/distribution" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.values[0].value | 1.2.3 | + | (jq).ocs.data.values[0].count | 1 | + + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/client_server_e2e/metrics/usage/requests_completed/numerical" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.statistics.count | 1 | + | (jq).ocs.data.statistics.average | 17 | + | (jq).ocs.data.statistics.total | 17 | From dc54e46361cd6c952b51df212f3a1d0e0be60171 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:28:02 -0300 Subject: [PATCH 05/14] ci: test client against usage statistics server --- .github/workflows/server-compatibility.yml | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/server-compatibility.yml diff --git a/.github/workflows/server-compatibility.yml b/.github/workflows/server-compatibility.yml new file mode 100644 index 0000000..bf02ac8 --- /dev/null +++ b/.github/workflows/server-compatibility.yml @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Server compatibility + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: server-compatibility-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + compatibility: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout client + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout Nextcloud + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: nextcloud/server + ref: stable36 + path: build/nextcloud + submodules: true + + - name: Checkout Usage Statistics Server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: LibreCodeCoop/usage_statistics_server + ref: fix/protocol-v1-direct-ingestion + path: build/nextcloud/apps/usage_statistics_server + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: '8.3' + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib + coverage: none + ini-file: development + ini-values: disable_functions= + + - name: Install server dependencies + working-directory: build/nextcloud/apps/usage_statistics_server + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install --no-dev --no-scripts --prefer-dist --no-progress + + - name: Install compatibility test dependencies + working-directory: tests/integration + run: composer install --prefer-dist --no-progress + + - name: Set up Nextcloud + working-directory: build/nextcloud + run: | + mkdir data + ./occ maintenance:install \ + --verbose \ + --database=sqlite \ + --database-name=nextcloud \ + --admin-user admin \ + --admin-pass admin + ./occ app:enable --force usage_statistics_server + ./occ config:system:set auth.bruteforce.protection.enabled --value false --type boolean + ./occ config:system:set ratelimit.protection.enabled --value false --type boolean + ./occ config:system:set debug --value true --type boolean + + - name: Run client/server compatibility test + working-directory: tests/integration + env: + BEHAT_ROOT_DIR: ../../build/nextcloud + BEHAT_RUN_AS: runner + BEHAT_VERBOSE: ${{ runner.debug }} + run: composer run behat + + - name: Upload Behat results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: server-compatibility-results + path: tests/integration/output/ + if-no-files-found: ignore + retention-days: 30 + + - name: Print Nextcloud logs + if: always() + run: cat build/nextcloud/data/nextcloud.log 2>/dev/null || true From e7d8d34040c4680f0e44b2389dd37c2e360c465f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:31:29 -0300 Subject: [PATCH 06/14] chore: cover integration metadata in REUSE --- REUSE.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/REUSE.toml b/REUSE.toml index 8f4b9e0..7be64cb 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -15,7 +15,8 @@ path = [ "phpunit.xml.dist", "phpmd.xml", "psalm.xml", - "vendor-bin/**/composer.json" + "vendor-bin/**/composer.json", + "tests/integration/composer.json" ] precedence = "aggregate" SPDX-FileCopyrightText = "2026 LibreCode coop and contributors" From 9b63bc384d30863aee1dca00bc9c76fdf1352d2e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:31:32 -0300 Subject: [PATCH 07/14] chore: add SPDX metadata to compatibility feature --- tests/integration/features/client_server_compatibility.feature | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/features/client_server_compatibility.feature b/tests/integration/features/client_server_compatibility.feature index d0b768f..198e71b 100644 --- a/tests/integration/features/client_server_compatibility.feature +++ b/tests/integration/features/client_server_compatibility.feature @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + Feature: Usage Statistics Protocol v1 client/server compatibility Scenario: the real client submits a report that the server stores and aggregates From 825f5ccd64021b657c1ffeb8d81ab5f5054aacaa Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:32:04 -0300 Subject: [PATCH 08/14] style: align compatibility context with project rules --- .../features/bootstrap/FeatureContext.php | 140 +++++++++--------- 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 83cef93..773ff52 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -11,7 +11,6 @@ use Behat\Step\Given; use Behat\Step\When; use Behat\Testwork\Hook\Scope\BeforeSuiteScope; -use DateTimeImmutable; use GuzzleHttp\Client as HttpClient; use LibreCode\UsageStatistics\Client; use LibreCode\UsageStatistics\ConsentState; @@ -24,81 +23,88 @@ use LibreCode\UsageStatistics\Transport\Response; use LibreCode\UsageStatistics\Transport\TransportInterface; use Libresign\NextcloudBehat\NextcloudApiContext; -use RuntimeException; final class FeatureContext extends NextcloudApiContext { - #[BeforeSuite] - public static function beforeSuite(BeforeSuiteScope $scope): void { - parent::beforeSuite($scope); - self::runCommand('config:system:set debug --value true --type boolean'); - self::runCommand('config:system:set auth.bruteforce.protection.enabled --value false --type boolean'); - self::runCommand('config:system:set ratelimit.protection.enabled --value false --type boolean'); - self::runCommand('app:enable --force usage_statistics_server'); - } + #[BeforeSuite] + public static function beforeSuite(BeforeSuiteScope $scope): void { + parent::beforeSuite($scope); + self::runCommand('config:system:set debug --value true --type boolean'); + self::runCommand('config:system:set auth.bruteforce.protection.enabled --value false --type boolean'); + self::runCommand('config:system:set ratelimit.protection.enabled --value false --type boolean'); + self::runCommand('app:enable --force usage_statistics_server'); + } - #[Given('as anonymous user')] - public function asAnonymousUser(): void { - $this->setCurrentUser(''); - } + #[Given('as anonymous user')] + public function asAnonymousUser(): void { + $this->setCurrentUser(''); + } - #[When('the usage statistics client submits the compatibility report')] - public function submitCompatibilityReport(): void { - $report = new Report( - application: 'client_server_e2e', - installationId: (string)InstallationId::derive('client_server_e2e', 'integration-installation'), - schemaVersion: 1, - period: new ReportingPeriod( - new DateTimeImmutable('2026-09-01T00:00:00Z'), - new DateTimeImmutable('2026-10-01T00:00:00Z'), - ), - metrics: [ - Metric::string('environment', 'version', '1.2.3'), - Metric::integer('usage', 'requests_completed', 17), - ], - ); + #[When('the usage statistics client submits the compatibility report')] + public function submitCompatibilityReport(): void { + $report = new Report( + application: 'client_server_e2e', + installationId: (string)InstallationId::derive('client_server_e2e', 'integration-installation'), + schemaVersion: 1, + period: new ReportingPeriod( + new \DateTimeImmutable('2026-09-01T00:00:00Z'), + new \DateTimeImmutable('2026-10-01T00:00:00Z'), + ), + metrics: [ + Metric::string('environment', 'version', '1.2.3'), + Metric::integer('usage', 'requests_completed', 17), + ], + ); - $transport = new class($this->baseUrl) implements TransportInterface { - public function __construct(private readonly string $baseUrl) { - } + $transport = new class($this->baseUrl) implements TransportInterface { + public function __construct( + private readonly string $baseUrl, + ) { + } - public function request(string $method, string $url, array $headers, string $body, float $timeoutSeconds): Response { - $path = parse_url($url, PHP_URL_PATH); - if (!is_string($path) || $path === '') { - throw new RuntimeException('Compatibility endpoint path is invalid.'); - } + public function request( + string $method, + string $url, + array $headers, + string $body, + float $timeoutSeconds, + ): Response { + $path = parse_url($url, PHP_URL_PATH); + if (!is_string($path) || $path === '') { + throw new \RuntimeException('Compatibility endpoint path is invalid.'); + } - $http = new HttpClient(['http_errors' => false]); - $response = $http->request( - $method, - rtrim($this->baseUrl, '/') . $path, - [ - 'headers' => $headers, - 'body' => $body, - 'timeout' => $timeoutSeconds, - ], - ); + $http = new HttpClient(['http_errors' => false]); + $response = $http->request( + $method, + rtrim($this->baseUrl, '/') . $path, + [ + 'headers' => $headers, + 'body' => $body, + 'timeout' => $timeoutSeconds, + ], + ); - $responseHeaders = []; - foreach ($response->getHeaders() as $name => $values) { - $responseHeaders[$name] = implode(', ', $values); - } + $responseHeaders = []; + foreach ($response->getHeaders() as $name => $values) { + $responseHeaders[$name] = implode(', ', $values); + } - return new Response( - $response->getStatusCode(), - (string)$response->getBody(), - $responseHeaders, - ); - } - }; + return new Response( + $response->getStatusCode(), + (string)$response->getBody(), + $responseHeaders, + ); + } + }; - $client = new Client( - $transport, - new Endpoint('https://usage-statistics.invalid/index.php/apps/usage_statistics_server/api/v1/reports'), - ); + $client = new Client( + $transport, + new Endpoint('https://usage-statistics.invalid/index.php/apps/usage_statistics_server/api/v1/reports'), + ); - $result = $client->submit($report, ConsentState::Enabled); - if ($result !== SubmissionResult::Accepted) { - throw new RuntimeException('Compatibility report was not accepted.'); - } - } + $result = $client->submit($report, ConsentState::Enabled); + if ($result !== SubmissionResult::Accepted) { + throw new \RuntimeException('Compatibility report was not accepted.'); + } + } } From a85e77c08cd7c7b8f028652c06dcb724ece815cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:35:18 -0300 Subject: [PATCH 09/14] ci: preserve compatibility diagnostics --- .github/workflows/server-compatibility.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/server-compatibility.yml b/.github/workflows/server-compatibility.yml index bf02ac8..c14f40c 100644 --- a/.github/workflows/server-compatibility.yml +++ b/.github/workflows/server-compatibility.yml @@ -84,14 +84,19 @@ jobs: BEHAT_ROOT_DIR: ../../build/nextcloud BEHAT_RUN_AS: runner BEHAT_VERBOSE: ${{ runner.debug }} - run: composer run behat + run: | + mkdir -p output + set -o pipefail + composer run behat 2>&1 | tee output/compatibility.log - name: Upload Behat results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: server-compatibility-results - path: tests/integration/output/ + path: | + tests/integration/output/ + build/nextcloud/data/nextcloud.log if-no-files-found: ignore retention-days: 30 From 00f8d56f66e9519db2a5e651cbe18e3e61daa16f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:51:05 -0300 Subject: [PATCH 10/14] ci: run compatibility test on stable Nextcloud --- .github/workflows/server-compatibility.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/server-compatibility.yml b/.github/workflows/server-compatibility.yml index c14f40c..08e6667 100644 --- a/.github/workflows/server-compatibility.yml +++ b/.github/workflows/server-compatibility.yml @@ -32,7 +32,7 @@ jobs: with: persist-credentials: false repository: nextcloud/server - ref: stable36 + ref: stable35 path: build/nextcloud submodules: true From f8be3d9f7eb94dea845a4d4e5aeb4230ac970636 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:53:40 -0300 Subject: [PATCH 11/14] fix: use valid Behat extension class names From 7e220bcee3e1197d99c91db7f66cf9bb4c1a4651 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:54:06 -0300 Subject: [PATCH 12/14] fix: normalize Behat extension namespaces --- tests/integration/config/behat.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml index 55517c9..c1c32e3 100644 --- a/tests/integration/config/behat.yml +++ b/tests/integration/config/behat.yml @@ -12,8 +12,8 @@ default: - '%paths.base%/../features' extensions: - PhpBuiltin\\Server: + PhpBuiltin\Server: workers: 2 - jarnaiz\\JUnitFormatter\\JUnitFormatterExtension: + jarnaiz\JUnitFormatter\JUnitFormatterExtension: filename: report.xml outputDir: '%paths.base%/../output/' From 25d325e5767e17279d28474d25731c685e04d1c1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 15:06:14 -0300 Subject: [PATCH 13/14] fix: rely on workflow Nextcloud setup --- .../integration/features/bootstrap/FeatureContext.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 773ff52..e0450f0 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -7,10 +7,8 @@ declare(strict_types=1); -use Behat\Hook\BeforeSuite; use Behat\Step\Given; use Behat\Step\When; -use Behat\Testwork\Hook\Scope\BeforeSuiteScope; use GuzzleHttp\Client as HttpClient; use LibreCode\UsageStatistics\Client; use LibreCode\UsageStatistics\ConsentState; @@ -25,15 +23,6 @@ use Libresign\NextcloudBehat\NextcloudApiContext; final class FeatureContext extends NextcloudApiContext { - #[BeforeSuite] - public static function beforeSuite(BeforeSuiteScope $scope): void { - parent::beforeSuite($scope); - self::runCommand('config:system:set debug --value true --type boolean'); - self::runCommand('config:system:set auth.bruteforce.protection.enabled --value false --type boolean'); - self::runCommand('config:system:set ratelimit.protection.enabled --value false --type boolean'); - self::runCommand('app:enable --force usage_statistics_server'); - } - #[Given('as anonymous user')] public function asAnonymousUser(): void { $this->setCurrentUser(''); From ef72d8527cb0671a6632cdd78b70e5c6f474892e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 15:44:52 -0300 Subject: [PATCH 14/14] test: verify compatibility against server main --- .github/workflows/server-compatibility.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/server-compatibility.yml b/.github/workflows/server-compatibility.yml index 08e6667..487c5ec 100644 --- a/.github/workflows/server-compatibility.yml +++ b/.github/workflows/server-compatibility.yml @@ -41,7 +41,7 @@ jobs: with: persist-credentials: false repository: LibreCodeCoop/usage_statistics_server - ref: fix/protocol-v1-direct-ingestion + ref: main path: build/nextcloud/apps/usage_statistics_server - name: Set up PHP