diff --git a/.gitignore b/.gitignore
index d3d331b..9165828 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
/tests/.phpunit.cache
/coverage/
+/.vscode/
diff --git a/REUSE.toml b/REUSE.toml
index 9f6d292..34d2636 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -41,7 +41,10 @@ SPDX-License-Identifier = "AGPL-3.0-or-later"
path = [
"README.md",
"CHANGELOG.md",
- "CODE_OF_CONDUCT.md"
+ "CODE_OF_CONDUCT.md",
+ "docs/README.md",
+ "docs/events/post-setup.md",
+ "docs/events/user-stats.md"
]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 STRATO GmbH"
diff --git a/composer.json b/composer.json
index 07d4ed9..6c48070 100644
--- a/composer.json
+++ b/composer.json
@@ -9,6 +9,34 @@
"homepage": "https://example.com"
}
],
+ "repositories": [
+ {
+ "type": "package",
+ "package": {
+ "name": "ionos-productivity/ionos-ncpss-addons-api-client",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client.git",
+ "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client/archive/refs/tags/2.0.0-20260429080405.zip",
+ "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18"
+ },
+ "require": {
+ "guzzlehttp/guzzle": "^7.3",
+ "guzzlehttp/psr7": "^1.7 || ^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "IONOS\\NextcloudPSS\\AddonsAPI\\Client\\": "lib/"
+ }
+ }
+ }
+ }
+ ],
"autoload": {
"psr-4": {
"OCA\\NcwTools\\": "lib/"
@@ -37,6 +65,7 @@
},
"require": {
"bamarni/composer-bin-plugin": "^1.8",
+ "ionos-productivity/ionos-ncpss-addons-api-client": "2.0.0",
"php": "^8.1"
},
"require-dev": {
diff --git a/composer.lock b/composer.lock
index 7ec3dc8..72f3dbd 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c429e219370ff3b6c87f4d2c835f43b7",
+ "content-hash": "7ac7adafbdcb64d2d37d9c9e42108378",
"packages": [
{
"name": "bamarni/composer-bin-plugin",
@@ -62,6 +62,631 @@
"source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1"
},
"time": "2026-02-04T10:18:12+00:00"
+ },
+ {
+ "name": "guzzlehttp/guzzle",
+ "version": "7.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/guzzle.git",
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/promises": "^2.3",
+ "guzzlehttp/psr7": "^2.8",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "ext-curl": "*",
+ "guzzle/client-integration-tests": "3.0.2",
+ "php-http/message-factory": "^1.1",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+ "psr/log": "Required for using the Log middleware"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Jeremy Lindblom",
+ "email": "jeremeamia@gmail.com",
+ "homepage": "https://github.com/jeremeamia"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle is a PHP HTTP client library",
+ "keywords": [
+ "client",
+ "curl",
+ "framework",
+ "http",
+ "http client",
+ "psr-18",
+ "psr-7",
+ "rest",
+ "web service"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-23T22:36:01+00:00"
+ },
+ {
+ "name": "guzzlehttp/promises",
+ "version": "2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/promises.git",
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Promise\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle promises library",
+ "keywords": [
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/2.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-22T14:34:08+00:00"
+ },
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "http-interop/http-factory-tests": "0.9.0",
+ "jshttp/mime-db": "1.54.0.1",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.9.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-10T16:41:02+00:00"
+ },
+ {
+ "name": "ionos-productivity/ionos-ncpss-addons-api-client",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client.git",
+ "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client/archive/refs/tags/2.0.0-20260429080405.zip",
+ "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18"
+ },
+ "require": {
+ "guzzlehttp/guzzle": "^7.3",
+ "guzzlehttp/psr7": "^1.7 || ^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "IONOS\\NextcloudPSS\\AddonsAPI\\Client\\": "lib/"
+ }
+ }
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-13T15:52:40+00:00"
}
],
"packages-dev": [
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..6535d63
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,6 @@
+# Event Flows
+
+NCW Tools reacts to Nextcloud lifecycle events by registering listeners that enqueue background jobs. Each document below walks through one end-to-end flow — the triggering event, the listener's synchronous work, the job's asynchronous behaviour, and the configuration it relies on.
+
+- [Post-setup welcome mail](events/post-setup.md) — sends the initial welcome email to the admin user once the system is reachable after installation.
+- [User stats reporting](events/user-stats.md) — reports the current total user count to the PSS Stats API after every user create or delete.
diff --git a/docs/events/post-setup.md b/docs/events/post-setup.md
new file mode 100644
index 0000000..ecc7db1
--- /dev/null
+++ b/docs/events/post-setup.md
@@ -0,0 +1,100 @@
+# Post-setup welcome mail
+
+After Nextcloud installation completes, this app sends an initial welcome email to the admin user. A listener seeds an app-config status flag and schedules a time-sensitive background job; the job retries on a configurable interval until the system URL is reachable and the admin user exists, then sends the welcome mail via Nextcloud's `NewUserMailHelper` (with a fresh password-reset token) and marks itself done.
+
+## Trigger event
+
+`OCP\Install\Events\InstallationCompletedEvent`
+
+## Configuration
+
+| Key | Where | Type | Default | Purpose |
+| --- | --- | --- | --- | --- |
+| `overwrite.cli.url` | `config/config.php` | string | _required_ | Base URL the job probes via `…/status.php` to confirm the instance is reachable. |
+| `ncw_tools.post_setup_job.retry_interval` | `config/config.php` | int (seconds) | `2` | Interval between job retries while waiting for the system to become ready. |
+| `post_install` | App config (`ncw_tools`) | string | _set by listener_ | Observable status: `INIT` (work pending), `DONE` (welcome mail sent), `UNKNOWN` (listener has not run). |
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+
+ participant INS as Nextcloud Installer
+ participant ED as EventDispatcher
+ participant ICEL as InstallationCompletedEventListener
+ participant CFG as IAppConfig
+ participant LOG as LoggerInterface
+ participant JL as IJobList
+ participant CRON as Nextcloud Cron
+ participant PSJ as PostSetupJob
+ participant HTTP as IClientService
+ participant UM as IUserManager
+ participant MAIL as WelcomeMailHelper
+
+ Note over INS,JL: Synchronous phase — runs during installation
+
+ INS->>ED: dispatch(InstallationCompletedEvent)
+ ED->>ICEL: handle(event)
+ ICEL->>CFG: setValueString(post_install, "INIT")
+
+ alt admin username missing
+ ICEL->>LOG: warning("No admin user provided")
+ else admin username present
+ ICEL->>LOG: info("Scheduling welcome email job")
+ ICEL->>JL: add(PostSetupJob::class, adminUserId)
+ end
+
+ Note over CRON,MAIL: Asynchronous phase — TIME_SENSITIVE,
retries every ncw_tools.post_setup_job.retry_interval seconds (default 2)
+
+ CRON->>PSJ: run(adminUserId)
+ PSJ->>CFG: getValueString(post_install)
+
+ alt status == "DONE"
+ PSJ->>JL: remove(this)
+ PSJ-->>CRON: return (already completed)
+ else status == "UNKNOWN"
+ PSJ->>LOG: warning("Job status unknown, waiting")
+ PSJ-->>CRON: return (retry)
+ else status == "INIT"
+ PSJ->>HTTP: GET {overwrite.cli.url}/status.php
+
+ alt URL empty
+ PSJ->>LOG: warning("System URL not configured")
+ PSJ-->>CRON: return (retry)
+ else HTTP not 2xx
+ PSJ->>LOG: info("System not ready, will retry")
+ PSJ-->>CRON: return (retry)
+ else HTTP 2xx
+ PSJ->>UM: userExists(adminUserId)
+
+ alt user not found
+ PSJ->>LOG: warning("Admin user not found")
+ PSJ-->>CRON: return (retry)
+ else user exists
+ PSJ->>UM: get(adminUserId)
+ PSJ->>MAIL: sendWelcomeMail(user, generateResetToken=true)
+
+ alt exception thrown
+ PSJ->>LOG: error("Failed to send welcome email, will retry")
+ PSJ-->>CRON: return (retry)
+ else success
+ PSJ->>CFG: setValueString(post_install, "DONE")
+ PSJ->>JL: remove(this)
+ PSJ->>LOG: info("Post-installation job completed")
+ end
+ end
+ end
+ end
+```
+
+## Failure modes
+
+The job is `TIME_SENSITIVE` and re-runs on every cron tick until it succeeds. Any of the following keep it in the queue:
+
+- `overwrite.cli.url` is unset.
+- `…/status.php` returns a non-2xx response, or the request throws.
+- The admin user does not exist (or cannot be retrieved).
+- `WelcomeMailHelper::sendWelcomeMail` throws.
+
+On success, the job sets `post_install = "DONE"` and removes itself from the job list. A subsequent install event would re-seed the status to `INIT` and re-schedule the job.
diff --git a/docs/events/user-stats.md b/docs/events/user-stats.md
new file mode 100644
index 0000000..afcdee9
--- /dev/null
+++ b/docs/events/user-stats.md
@@ -0,0 +1,122 @@
+# User stats reporting
+
+On every user create or delete, this app reports the current total user count to the PSS Stats API. A listener logs the change and enqueues a single deduplicated background job; on the next cron tick the job reads the total user count and delegates to a `StatsReporter` (the PSS adapter validates configuration, builds the `StatsUpdateRequest`, and POSTs it to the PSS Stats API). The job is queued — it does not retry on failure; the next user event re-enqueues it.
+
+## Trigger events
+
+- `OCP\User\Events\UserCreatedEvent`
+- `OCP\User\Events\UserDeletedEvent`
+
+## Configuration
+
+All keys live in `config/config.php`. The adapter aborts (with a logged error listing the missing keys) if any required value is missing or empty.
+
+### Required
+
+| Key | Type | Purpose |
+| --- | --- | --- |
+| `ncw_tools.pss.brand` | string | PSS brand identifier; first path segment of the stats endpoint. |
+| `ncw_tools.pss.ext_ref` | string | External tenant reference; second path segment of the stats endpoint. |
+| `ncw_tools.pss.base_url` | string | Base URL of the PSS API. |
+| `ncw_tools.pss.username` | string | HTTP Basic auth username. |
+| `ncw_tools.pss.password` | string | HTTP Basic auth password. |
+
+### Optional
+
+| Key | Type | Default | Purpose |
+| --- | --- | --- | --- |
+| `ncw_tools.pss.connect_timeout` | int (seconds) | `5` | Guzzle connect timeout. |
+| `ncw_tools.pss.timeout` | int (seconds) | `10` | Guzzle overall timeout. |
+| `ncw_tools.pss.allow_insecure` | bool | `false` | When `true`, disables TLS verification (`verify => false`). **Dev/debug only** — never enable in production. |
+
+### Security notes for `ncw_tools.pss.password`
+
+The password is stored in `config/config.php` in plaintext, same trust model as the DB password already in that file. Two operational considerations:
+
+- **Shell history / `ps` exposure.** `occ config:system:set` puts the value on the command line — visible in `ps` and shell history. Prefer a leading-space command (with `HISTCONTROL=ignorespace` set) or read the value from an env var so it does not appear in process listings.
+- **File permissions.** `config/config.php` should be `0640 root:www-data`. Audit on deploy.
+
+Code-side, the `PssConfig` DTO redacts `password` via `__debugInfo()`, and the PSS error handler logs `exceptionClass + message` only (not the full trace), so accidental serialization or deep-vendor stack-traces will not leak the value.
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+
+ participant OCC as occ user:add / user:delete
+ participant ED as EventDispatcher
+ participant UEL as UserEventListener
+ participant LOG as LoggerInterface
+ participant JL as IJobList
+ participant CRON as Nextcloud Cron
+ participant USJ as UserStatsJob
+ participant UM as IUserManager
+ participant SR as StatsReporter
(PssStatsReporter)
+ participant CR as PssConfigReader
+ participant AF as PssApiFactory
+ participant PSS as PSS Stats API
+
+ Note over OCC,JL: Synchronous phase — runs during the OCC command
+
+ OCC->>ED: dispatch(UserCreatedEvent | UserDeletedEvent)
+ ED->>UEL: handle(event)
+
+ alt UserCreatedEvent
+ UEL->>LOG: info("User added", {uid})
+ else UserDeletedEvent
+ UEL->>LOG: info("User deleted", {uid})
+ end
+
+ UEL->>JL: has(UserStatsJob::class, null)
+
+ alt already queued
+ JL-->>UEL: true (skip)
+ else not queued
+ JL-->>UEL: false
+ UEL->>JL: add(UserStatsJob::class)
+ end
+
+ Note over CRON,PSS: Asynchronous phase — next cron cycle
+
+ CRON->>USJ: run()
+ USJ->>UM: countUsersTotal()
+ UM-->>USJ: int | false
+
+ alt count === false
+ USJ->>LOG: warning("could not retrieve user count")
+ USJ-->>CRON: return
+ else count is int
+ USJ->>SR: reportUserCount(count, now)
+ SR->>CR: read()
+
+ alt any required key missing
+ CR->>LOG: error("missing required PSS configuration", {keys})
+ CR-->>SR: null
+ SR-->>USJ: return
+ else all values present
+ CR-->>SR: PssConfig
+ SR->>SR: build StatsUpdateRequest with UserStats(existingUsers)
+ SR->>AF: newStatsApi(baseUrl, username, password)
+ SR->>PSS: updateStats(brand, extRef, request)
+
+ alt Throwable
+ PSS-->>SR: exception
+ SR->>LOG: error("failed to push stats to PSS", {exceptionClass, message})
+ else success
+ PSS-->>SR: 2xx
+ SR->>LOG: info("pushed user stats", {existingUsers, timestamp})
+ end
+ end
+ end
+```
+
+## Failure modes
+
+The job is a `QueuedJob` — there is no automatic retry. Each of the following ends the run without reporting:
+
+- `IUserManager::countUsersTotal()` returns `false` (logged at warning by the job).
+- Any of the five required `ncw_tools.pss.*` values is missing or empty (logged at error by `PssConfigReader`, naming the missing keys).
+- The PSS API call throws (logged at error by `PssStatsReporter` with `exceptionClass` + `message`).
+
+The next `UserCreatedEvent` or `UserDeletedEvent` re-enqueues the job, so the count converges once the underlying problem is resolved.
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index b0fc7ac..5a34c44 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -12,6 +12,8 @@
use OCA\NcwTools\Capabilities;
use OCA\NcwTools\Listeners\InstallationCompletedEventListener;
use OCA\NcwTools\Listeners\UserEventListener;
+use OCA\NcwTools\Stats\PssStatsReporter;
+use OCA\NcwTools\Stats\StatsReporter;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -20,6 +22,8 @@
use OCP\User\Events\UserCreatedEvent;
use OCP\User\Events\UserDeletedEvent;
+require_once __DIR__ . '/../../vendor/autoload.php';
+
class Application extends App implements IBootstrap {
public const APP_ID = 'ncw_tools';
@@ -32,6 +36,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserCreatedEvent::class, UserEventListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserEventListener::class);
$context->registerCapability(Capabilities::class);
+ $context->registerServiceAlias(StatsReporter::class, PssStatsReporter::class);
}
public function boot(IBootContext $context): void {
diff --git a/lib/BackgroundJob/UserStatsJob.php b/lib/BackgroundJob/UserStatsJob.php
index a88d633..c68a617 100644
--- a/lib/BackgroundJob/UserStatsJob.php
+++ b/lib/BackgroundJob/UserStatsJob.php
@@ -9,6 +9,7 @@
namespace OCA\NcwTools\BackgroundJob;
+use OCA\NcwTools\Stats\StatsReporter;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use OCP\IUserManager;
@@ -18,24 +19,22 @@ class UserStatsJob extends QueuedJob {
public function __construct(
private LoggerInterface $logger,
- private ITimeFactory $timeFactory,
+ ITimeFactory $timeFactory,
private IUserManager $userManager,
+ private StatsReporter $reporter,
) {
parent::__construct($timeFactory);
}
protected function run(mixed $argument): void {
- $userTotalCount = $this->userManager->countUsersTotal();
- if ($userTotalCount === false) {
+ $count = $this->userManager->countUsersTotal();
+ if ($count === false) {
$this->logger->warning('UserStatsJob: could not retrieve user count');
return;
}
-
- $payload = [
- 'timestamp' => $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.v\Z'),
- 'users' => ['existingUsers' => $userTotalCount],
- ];
-
- $this->logger->info('User stats payload', ['payload' => $payload]);
+ $this->reporter->reportUserCount(
+ $count,
+ $this->time->getDateTime('now', new \DateTimeZone('UTC')),
+ );
}
}
diff --git a/lib/Stats/PssApiFactory.php b/lib/Stats/PssApiFactory.php
new file mode 100644
index 0000000..dbeab02
--- /dev/null
+++ b/lib/Stats/PssApiFactory.php
@@ -0,0 +1,47 @@
+connectTimeout = $config->getSystemValueInt(self::KEY_CONNECT_TIMEOUT, self::DEFAULT_CONNECT_TIMEOUT_S);
+ $this->timeout = $config->getSystemValueInt(self::KEY_TIMEOUT, self::DEFAULT_TIMEOUT_S);
+ $this->allowInsecure = $config->getSystemValueBool(self::KEY_ALLOW_INSECURE, false);
+ }
+
+ public function newStatsApi(string $baseUrl, string $username, string $password): StatsAPIApi {
+ $client = new Client([
+ 'connect_timeout' => $this->connectTimeout,
+ 'timeout' => $this->timeout,
+ 'verify' => !$this->allowInsecure,
+ ]);
+ $config = new Configuration();
+ $config->setHost($baseUrl);
+ $config->setUsername($username);
+ $config->setPassword($password);
+ return new StatsAPIApi($client, $config);
+ }
+}
diff --git a/lib/Stats/PssConfig.php b/lib/Stats/PssConfig.php
new file mode 100644
index 0000000..3b2181a
--- /dev/null
+++ b/lib/Stats/PssConfig.php
@@ -0,0 +1,35 @@
+password;
+ }
+
+ public function __debugInfo(): array {
+ return [
+ 'brand' => $this->brand,
+ 'extRef' => $this->extRef,
+ 'baseUrl' => $this->baseUrl,
+ 'username' => $this->username,
+ 'password' => '***',
+ ];
+ }
+}
diff --git a/lib/Stats/PssConfigReader.php b/lib/Stats/PssConfigReader.php
new file mode 100644
index 0000000..f1aa6fb
--- /dev/null
+++ b/lib/Stats/PssConfigReader.php
@@ -0,0 +1,55 @@
+ 'ncw_tools.pss.brand',
+ 'extRef' => 'ncw_tools.pss.ext_ref',
+ 'baseUrl' => 'ncw_tools.pss.base_url',
+ 'username' => 'ncw_tools.pss.username',
+ 'password' => 'ncw_tools.pss.password',
+ ];
+
+ public function __construct(
+ private IConfig $config,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function read(): ?PssConfig {
+ $values = [];
+ $missing = [];
+ foreach (self::KEYS as $field => $key) {
+ $value = $this->config->getSystemValueString($key);
+ if ($value === '') {
+ $missing[] = $key;
+ }
+ $values[$field] = $value;
+ }
+ if ($missing !== []) {
+ $this->logger->error(
+ 'PssConfigReader: missing required PSS configuration',
+ ['keys' => $missing],
+ );
+ return null;
+ }
+ return new PssConfig(
+ $values['brand'],
+ $values['extRef'],
+ $values['baseUrl'],
+ $values['username'],
+ $values['password'],
+ );
+ }
+}
diff --git a/lib/Stats/PssStatsReporter.php b/lib/Stats/PssStatsReporter.php
new file mode 100644
index 0000000..ff428c4
--- /dev/null
+++ b/lib/Stats/PssStatsReporter.php
@@ -0,0 +1,62 @@
+configReader->read();
+ if ($config === null) {
+ return;
+ }
+
+ $userStats = new UserStats();
+ $userStats->setExistingUsers($count);
+
+ $request = new StatsUpdateRequest();
+ $request->setTimestamp($at instanceof \DateTime ? $at : \DateTime::createFromInterface($at));
+ $request->setUsers($userStats);
+
+ $api = $this->apiFactory->newStatsApi(
+ $config->baseUrl,
+ $config->username,
+ $config->getPassword(),
+ );
+
+ // Narrow the catch to just updateStats() and log message-only (not the
+ // full exception). Deep vendor frames can carry credential strings in
+ // their stack-trace args; PHP 8.1 has no #[\SensitiveParameter] to
+ // scrub them. We trade trace richness for credential safety.
+ try {
+ $api->updateStats($config->brand, $config->extRef, $request);
+ } catch (\Throwable $e) {
+ $this->logger->error('PssStatsReporter: failed to push stats to PSS', [
+ 'exceptionClass' => $e::class,
+ 'message' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ $this->logger->info('PssStatsReporter: pushed user stats', [
+ 'existingUsers' => $count,
+ 'timestamp' => $at->format('Y-m-d\TH:i:s.v\Z'),
+ ]);
+ }
+}
diff --git a/lib/Stats/StatsReporter.php b/lib/Stats/StatsReporter.php
new file mode 100644
index 0000000..3f59f8b
--- /dev/null
+++ b/lib/Stats/StatsReporter.php
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/tests/unit/BackgroundJob/UserStatsJobTest.php b/tests/unit/BackgroundJob/UserStatsJobTest.php
index 839fc3b..f888767 100644
--- a/tests/unit/BackgroundJob/UserStatsJobTest.php
+++ b/tests/unit/BackgroundJob/UserStatsJobTest.php
@@ -11,6 +11,7 @@
use DateTime;
use OCA\NcwTools\BackgroundJob\UserStatsJob;
+use OCA\NcwTools\Stats\StatsReporter;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
@@ -21,6 +22,7 @@ class UserStatsJobTest extends TestCase {
private LoggerInterface&MockObject $logger;
private ITimeFactory&MockObject $timeFactory;
private IUserManager&MockObject $userManager;
+ private StatsReporter&MockObject $reporter;
private UserStatsJob $job;
protected function setUp(): void {
@@ -28,42 +30,40 @@ protected function setUp(): void {
$this->logger = $this->createMock(LoggerInterface::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
- $this->timeFactory->method('getDateTime')->willReturn(new DateTime('2026-01-01T00:00:00.000 UTC'));
$this->userManager = $this->createMock(IUserManager::class);
+ $this->reporter = $this->createMock(StatsReporter::class);
$this->job = new UserStatsJob(
$this->logger,
$this->timeFactory,
$this->userManager,
+ $this->reporter,
);
}
- public function testRunLogsPayloadOnSuccess(): void {
+ public function testRunDelegatesToReporterOnSuccess(): void {
+ $now = new DateTime('2026-01-01T00:00:00.000', new \DateTimeZone('UTC'));
$this->userManager->method('countUsersTotal')->willReturn(42);
+ $this->timeFactory->method('getDateTime')->willReturn($now);
- $this->logger->expects($this->once())
- ->method('info')
- ->with(
- 'User stats payload',
- $this->callback(function (array $context): bool {
- return preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $context['payload']['timestamp']) === 1
- && $context['payload']['users']['existingUsers'] === 42;
- })
- );
+ $this->reporter->expects($this->once())
+ ->method('reportUserCount')
+ ->with(42, $now);
$this->logger->expects($this->never())->method('warning');
+ $this->logger->expects($this->never())->method('error');
$this->invokePrivate($this->job, 'run', [null]);
}
- public function testRunLogsWarningWhenCountFalse(): void {
+ public function testRunLogsWarningAndSkipsReporterWhenCountFalse(): void {
$this->userManager->method('countUsersTotal')->willReturn(false);
$this->logger->expects($this->once())
->method('warning')
->with('UserStatsJob: could not retrieve user count');
- $this->logger->expects($this->never())->method('info');
+ $this->reporter->expects($this->never())->method('reportUserCount');
$this->invokePrivate($this->job, 'run', [null]);
}
diff --git a/tests/unit/Stats/PssConfigReaderTest.php b/tests/unit/Stats/PssConfigReaderTest.php
new file mode 100644
index 0000000..19dee66
--- /dev/null
+++ b/tests/unit/Stats/PssConfigReaderTest.php
@@ -0,0 +1,107 @@
+config = $this->createMock(IConfig::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->reader = new PssConfigReader($this->config, $this->logger);
+ }
+
+ public function testReadReturnsConfigWhenAllKeysPresent(): void {
+ $this->stubValues([
+ 'ncw_tools.pss.brand' => 'IONOS',
+ 'ncw_tools.pss.ext_ref' => 'tenant-1',
+ 'ncw_tools.pss.base_url' => 'https://pss.example.com',
+ 'ncw_tools.pss.username' => 'alice',
+ 'ncw_tools.pss.password' => 'secret',
+ ]);
+
+ $this->logger->expects($this->never())->method('error');
+
+ $result = $this->reader->read();
+ $this->assertNotNull($result);
+ $this->assertSame('IONOS', $result->brand);
+ $this->assertSame('tenant-1', $result->extRef);
+ $this->assertSame('https://pss.example.com', $result->baseUrl);
+ $this->assertSame('alice', $result->username);
+ $this->assertSame('secret', $result->getPassword());
+ }
+
+ public static function provideMissingKey(): array {
+ $full = [
+ 'ncw_tools.pss.brand' => 'IONOS',
+ 'ncw_tools.pss.ext_ref' => 'tenant-1',
+ 'ncw_tools.pss.base_url' => 'https://pss.example.com',
+ 'ncw_tools.pss.username' => 'alice',
+ 'ncw_tools.pss.password' => 'secret',
+ ];
+ $cases = [];
+ foreach (array_keys($full) as $missing) {
+ $values = $full;
+ $values[$missing] = '';
+ $cases[$missing] = [$values, [$missing]];
+ }
+ $cases['all missing'] = [
+ array_fill_keys(array_keys($full), ''),
+ array_keys($full),
+ ];
+ return $cases;
+ }
+
+ #[DataProvider('provideMissingKey')]
+ public function testReadReturnsNullAndLogsMissingKeys(array $values, array $expectedMissing): void {
+ $this->stubValues($values);
+
+ $this->logger->expects($this->once())
+ ->method('error')
+ ->with(
+ 'PssConfigReader: missing required PSS configuration',
+ $this->callback(fn (array $ctx): bool => $ctx['keys'] === $expectedMissing),
+ );
+
+ $this->assertNull($this->reader->read());
+ }
+
+ public function testPasswordRedactedFromDebugInfo(): void {
+ $this->stubValues([
+ 'ncw_tools.pss.brand' => 'IONOS',
+ 'ncw_tools.pss.ext_ref' => 'tenant-1',
+ 'ncw_tools.pss.base_url' => 'https://pss.example.com',
+ 'ncw_tools.pss.username' => 'alice',
+ 'ncw_tools.pss.password' => 'super-secret-value',
+ ]);
+ $config = $this->reader->read();
+ $this->assertNotNull($config);
+
+ $dump = print_r($config, true);
+ $this->assertStringNotContainsString('super-secret-value', $dump);
+ $this->assertStringContainsString('***', $dump);
+ }
+
+ private function stubValues(array $values): void {
+ $this->config
+ ->method('getSystemValueString')
+ ->willReturnCallback(fn (string $key, string $default = '') => $values[$key] ?? $default);
+ }
+}
diff --git a/tests/unit/Stats/PssStatsReporterTest.php b/tests/unit/Stats/PssStatsReporterTest.php
new file mode 100644
index 0000000..4722311
--- /dev/null
+++ b/tests/unit/Stats/PssStatsReporterTest.php
@@ -0,0 +1,113 @@
+configReader = $this->createMock(PssConfigReader::class);
+ $this->apiFactory = $this->createMock(PssApiFactory::class);
+ $this->statsApi = $this->createMock(StatsAPIApi::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->apiFactory->method('newStatsApi')->willReturn($this->statsApi);
+
+ $this->reporter = new PssStatsReporter(
+ $this->configReader,
+ $this->apiFactory,
+ $this->logger,
+ );
+ }
+
+ public function testReportSkipsWhenConfigNull(): void {
+ $this->configReader->method('read')->willReturn(null);
+
+ $this->statsApi->expects($this->never())->method('updateStats');
+ $this->logger->expects($this->never())->method('error');
+ $this->logger->expects($this->never())->method('info');
+
+ $this->reporter->reportUserCount(42, new DateTime('2026-01-01T00:00:00.000', new \DateTimeZone('UTC')));
+ }
+
+ public function testReportPostsAndLogsOnSuccess(): void {
+ $at = new DateTime('2026-01-01T12:34:56.789', new \DateTimeZone('UTC'));
+ $this->configReader->method('read')->willReturn(new PssConfig(
+ 'IONOS',
+ 'tenant-1',
+ 'https://pss.example.com',
+ 'alice',
+ 'secret',
+ ));
+
+ $this->apiFactory->expects($this->once())
+ ->method('newStatsApi')
+ ->with('https://pss.example.com', 'alice', 'secret')
+ ->willReturn($this->statsApi);
+
+ $this->statsApi->expects($this->once())
+ ->method('updateStats')
+ ->with(
+ 'IONOS',
+ 'tenant-1',
+ $this->callback(function (StatsUpdateRequest $req) use ($at): bool {
+ return $req->getUsers()?->getExistingUsers() === 42
+ && $req->getTimestamp() == $at;
+ }),
+ );
+
+ $this->logger->expects($this->once())
+ ->method('info')
+ ->with('PssStatsReporter: pushed user stats', $this->callback(function (array $ctx): bool {
+ return $ctx['existingUsers'] === 42
+ && $ctx['timestamp'] === '2026-01-01T12:34:56.789Z';
+ }));
+ $this->logger->expects($this->never())->method('error');
+
+ $this->reporter->reportUserCount(42, $at);
+ }
+
+ public function testReportLogsErrorOnThrowableWithoutLeakingException(): void {
+ $this->configReader->method('read')->willReturn(new PssConfig(
+ 'IONOS',
+ 'tenant-1',
+ 'https://pss.example.com',
+ 'alice',
+ 'secret',
+ ));
+ $this->statsApi->method('updateStats')->willThrowException(new \RuntimeException('connection refused'));
+
+ $this->logger->expects($this->once())
+ ->method('error')
+ ->with('PssStatsReporter: failed to push stats to PSS', $this->callback(function (array $ctx): bool {
+ return $ctx['exceptionClass'] === \RuntimeException::class
+ && $ctx['message'] === 'connection refused'
+ && !array_key_exists('exception', $ctx);
+ }));
+ $this->logger->expects($this->never())->method('info');
+
+ $this->reporter->reportUserCount(42, new DateTime('2026-01-01T00:00:00.000', new \DateTimeZone('UTC')));
+ }
+}