diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..fb432f3
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: 2026 STRATO GmbH
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# PHP files
+[*.php]
+indent_style = tab
+indent_size = 4
+
+# JSON files
+[*.json]
+indent_style = space
+indent_size = 2
+
+# YAML files
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
+
+# XML files
+[*.xml]
+indent_style = tab
+indent_size = 4
+
+# Markdown files
+[*.md]
+trim_trailing_whitespace = false
+
+# Shell scripts
+[*.sh]
+indent_style = tab
+indent_size = 4
+
+# Makefile
+[Makefile]
+indent_style = tab
diff --git a/README.md b/README.md
index 9bb359e..a692717 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,22 @@ NCW Tools enhances Nextcloud with advanced system utilities including event list
php occ app:enable ncw_tools
```
+## Configuration
+
+### Post-Installation Job Retry Interval
+
+The app includes a background job that sends a welcome email to the admin user after installation. You can configure how often this job retries by setting the following system configuration value in your `config/config.php`:
+
+```php
+'ncw_tools.post_setup_job.retry_interval' => 5, // Retry every 5 seconds (default: 2)
+```
+
+**Configuration Options:**
+- **Key:** `ncw_tools.post_setup_job.retry_interval`
+- **Type:** Integer (seconds)
+- **Default:** `2` seconds
+- **Description:** Interval in seconds between retry attempts for the post-installation welcome email job
+
## Development
### Prerequisites
diff --git a/composer.json b/composer.json
index 57525de..31a83f0 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,11 @@
"OCA\\NcwTools\\": "lib/"
}
},
+ "autoload-dev": {
+ "classmap": [
+ "tests/stubs/"
+ ]
+ },
"scripts": {
"post-install-cmd": [
"@composer bin all install --ansi"
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 28290cf..a8c6535 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -9,23 +9,22 @@
namespace OCA\NcwTools\AppInfo;
+use OCA\NcwTools\Listeners\InstallationCompletedEventListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Install\Events\InstallationCompletedEvent;
-/**
- * @psalm-suppress UnusedClass
- */
class Application extends App implements IBootstrap {
public const APP_ID = 'ncw_tools';
- /** @psalm-suppress PossiblyUnusedMethod */
public function __construct() {
parent::__construct(self::APP_ID);
}
public function register(IRegistrationContext $context): void {
+ $context->registerEventListener(InstallationCompletedEvent::class, InstallationCompletedEventListener::class);
}
public function boot(IBootContext $context): void {
diff --git a/lib/BackgroundJob/PostSetupJob.php b/lib/BackgroundJob/PostSetupJob.php
new file mode 100644
index 0000000..1f09855
--- /dev/null
+++ b/lib/BackgroundJob/PostSetupJob.php
@@ -0,0 +1,162 @@
+config->getSystemValueInt('ncw_tools.post_setup_job.retry_interval', 2);
+ $this->setInterval($retryInterval);
+ $this->setTimeSensitivity(IJob::TIME_SENSITIVE);
+ $this->logger->debug('PostSetupJob initialized', [
+ 'retryInterval' => $retryInterval,
+ 'timeSensitivity' => 'TIME_SENSITIVE',
+ ]);
+ }
+
+ /**
+ * Execute the post-installation job
+ *
+ * Checks if the job has already completed and sends the initial welcome email
+ * to the admin user. The job will retry until successful or marked as done.
+ *
+ * @param mixed $argument The admin user ID as a string
+ */
+ protected function run($argument): void {
+ // string post install variable
+ // used to check if job has already run
+ $jobStatus = $this->appConfig->getValueString(Application::APP_ID, self::JOB_STATUS_CONFIG_KEY, self::JOB_STATUS_UNKNOWN);
+ if ($jobStatus === self::JOB_STATUS_DONE) {
+ $this->logger->info('Post-installation job already completed, removing from queue');
+ $this->jobList->remove($this);
+ return;
+ }
+
+ if ($jobStatus === self::JOB_STATUS_UNKNOWN) {
+ $this->logger->warning('Job status unknown, waiting for initialization');
+ return;
+ }
+
+ $initAdminId = (string)$argument;
+ $this->logger->info('Starting post-installation job', ['adminUserId' => $initAdminId]);
+ $this->sendInitialWelcomeMail($initAdminId);
+ $this->logger->info('Post-installation job completed', ['adminUserId' => $initAdminId]);
+ }
+
+ /**
+ * Send initial welcome email to the admin user
+ *
+ * Validates system URL configuration, checks URL availability, verifies user exists,
+ * and sends the welcome email with a password reset token. On success, marks the job
+ * as done and removes it from the queue. Failures are logged and will trigger a retry.
+ *
+ * @param string $adminUserId The admin user ID to send the welcome email to
+ */
+ protected function sendInitialWelcomeMail(string $adminUserId): void {
+ $client = $this->clientService->newClient();
+ $overwriteUrl = (string)$this->config->getSystemValue('overwrite.cli.url');
+
+ if (empty($overwriteUrl)) {
+ $this->logger->warning('System URL not configured, cannot send welcome email', [
+ 'adminUserId' => $adminUserId,
+ 'config_key' => 'overwrite.cli.url',
+ ]);
+ return;
+ }
+
+ if (! $this->isUrlAvailable($client, $overwriteUrl)) {
+ $this->logger->info('System not ready, will retry sending welcome email', [
+ 'adminUserId' => $adminUserId,
+ 'url' => $overwriteUrl,
+ ]);
+ return;
+ }
+ if (! $this->userManager->userExists($adminUserId)) {
+ $this->logger->warning('Admin user not found, cannot send welcome email', [
+ 'adminUserId' => $adminUserId,
+ ]);
+ return;
+ }
+
+ $initAdminUser = $this->userManager->get($adminUserId);
+ if ($initAdminUser === null) {
+ $this->logger->error('Failed to retrieve admin user, will retry', [
+ 'adminUserId' => $adminUserId,
+ ]);
+ return;
+ }
+
+ try {
+ $this->welcomeMailHelper->sendWelcomeMail($initAdminUser, true);
+ } catch (\Throwable $e) {
+ $this->logger->error('Failed to send welcome email, will retry', [
+ 'adminUserId' => $adminUserId,
+ 'exception' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ $this->appConfig->setValueString(Application::APP_ID, self::JOB_STATUS_CONFIG_KEY, self::JOB_STATUS_DONE);
+ $this->jobList->remove($this);
+ }
+
+ /**
+ * Check if the system URL is accessible
+ *
+ * Performs an HTTP GET request to the status.php endpoint to verify the system
+ * is ready. Returns true if the response status code is in the 2xx range.
+ *
+ * @param IClient $client The HTTP client to use for the request
+ * @param string $baseUrl The base URL to check (e.g., https://example.com)
+ * @return bool True if the URL is accessible, false otherwise
+ */
+ private function isUrlAvailable(IClient $client, string $baseUrl): bool {
+ $url = $baseUrl . '/status.php';
+ try {
+ $this->logger->debug('Checking URL availability', ['url' => $url]);
+ $response = $client->get($url);
+ $statusCode = $response->getStatusCode();
+ return $statusCode >= 200 && $statusCode < 300;
+ } catch (\Exception $ex) {
+ $this->logger->info('URL not yet accessible', [
+ 'url' => $url,
+ 'exception' => $ex->getMessage(),
+ ]);
+ return false;
+ }
+ }
+}
diff --git a/lib/Helper/WelcomeMailHelper.php b/lib/Helper/WelcomeMailHelper.php
new file mode 100644
index 0000000..0d23031
--- /dev/null
+++ b/lib/Helper/WelcomeMailHelper.php
@@ -0,0 +1,67 @@
+defaults,
+ $this->urlGenerator,
+ $this->l10NFactory,
+ $this->mailer,
+ $this->secureRandom,
+ $this->timeFactory,
+ $this->config,
+ $this->crypto,
+ Util::getDefaultEmailAddress('no-reply')
+ );
+
+ $mailTmpl = $newUserMailHelper->generateTemplate($user, $generatePasswordResetToken);
+ if ($mailTmpl === null) {
+ // User has no email address, cannot send welcome mail
+ return;
+ }
+ $newUserMailHelper->sendMail($user, $mailTmpl);
+ }
+}
diff --git a/lib/Listeners/InstallationCompletedEventListener.php b/lib/Listeners/InstallationCompletedEventListener.php
new file mode 100644
index 0000000..0da1374
--- /dev/null
+++ b/lib/Listeners/InstallationCompletedEventListener.php
@@ -0,0 +1,58 @@
+
+ */
+class InstallationCompletedEventListener implements IEventListener {
+
+ public function __construct(
+ private IAppConfig $appConfig,
+ private LoggerInterface $logger,
+ private IJobList $jobList,
+ ) {
+ }
+
+ /**
+ * Handle the InstallationCompletedEvent
+ *
+ * When installation completes, this listener schedules a PostSetupJob
+ * to send the initial welcome email to the admin user.
+ *
+ * @param Event $event The event to handle (must be InstallationCompletedEvent)
+ */
+ public function handle(Event $event): void {
+ if (!$event instanceof InstallationCompletedEvent) {
+ return;
+ }
+
+ $this->appConfig->setValueString(Application::APP_ID, PostSetupJob::JOB_STATUS_CONFIG_KEY, PostSetupJob::JOB_STATUS_INIT);
+
+ $adminUserId = $event->getAdminUsername();
+ if ($adminUserId === null) {
+ $this->logger->warning('No admin user provided in InstallationCompletedEvent');
+ return;
+ }
+
+ $this->logger->info('Scheduling welcome email job', ['adminUserId' => $adminUserId]);
+ $this->jobList->add(PostSetupJob::class, $adminUserId);
+ $this->logger->debug('Welcome email job scheduled successfully', ['adminUserId' => $adminUserId]);
+ }
+}
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
new file mode 100644
index 0000000..08c77e3
--- /dev/null
+++ b/psalm-baseline.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/psalm.xml b/psalm.xml
index e2853b7..6f28d5e 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -1,21 +1,38 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 22f6d85..ba147cc 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -10,5 +10,14 @@
require_once __DIR__ . '/../../../tests/bootstrap.php';
require_once __DIR__ . '/../vendor/autoload.php';
+// Autoload test stubs for classes not available in the current OCP package
+spl_autoload_register(function ($class) {
+ $stubsDir = __DIR__ . '/stubs/';
+ $file = $stubsDir . str_replace('\\', '/', $class) . '.php';
+ if (file_exists($file)) {
+ require_once $file;
+ }
+});
+
\OC_App::loadApp(OCA\NcwTools\AppInfo\Application::APP_ID);
OC_Hook::clear();
diff --git a/tests/stubs/OCA/Settings/Mailer/NewUserMailHelper.php b/tests/stubs/OCA/Settings/Mailer/NewUserMailHelper.php
new file mode 100644
index 0000000..576fddd
--- /dev/null
+++ b/tests/stubs/OCA/Settings/Mailer/NewUserMailHelper.php
@@ -0,0 +1,99 @@
+getEMailAddress() === null) {
+ return null;
+ }
+
+ $userId = $user->getUID();
+
+ if ($generatePasswordResetToken) {
+ $token = $this->secureRandom->generate(
+ 21,
+ ISecureRandom::CHAR_ALPHANUMERIC
+ );
+ $tokenValue = $this->timeFactory->getTime() . ':' . $token;
+ $mailAddress = ($user->getEMailAddress() !== null) ? $user->getEMailAddress() : '';
+ $encryptedValue = $this->crypto->encrypt($tokenValue, $mailAddress . $this->config->getSystemValue('secret'));
+ $this->config->setUserValue($user->getUID(), 'core', 'lostpassword', $encryptedValue);
+ }
+
+ // Return a mock email template
+ return $this->mailer->createEMailTemplate('settings.Welcome');
+ }
+
+ /**
+ * Sends a welcome mail to $user
+ *
+ * @param IUser $user
+ * @param IEMailTemplate $emailTemplate
+ * @throws \Exception If mail could not be sent
+ */
+ public function sendMail(IUser $user, IEMailTemplate $emailTemplate): void {
+ // Return early if user has no email (stub behavior)
+ if ($user->getEMailAddress() === null) {
+ return;
+ }
+
+ // Simulate sending the mail
+ $message = $this->mailer->createMessage();
+ $message->setTo([$user->getEMailAddress() => $user->getDisplayName()]);
+ $message->setFrom([$this->fromAddress => $this->themingDefaults->getName()]);
+ $message->useTemplate($emailTemplate);
+ $this->mailer->send($message);
+ }
+}
diff --git a/tests/stubs/OCP/Install/Events/InstallationCompletedEvent.php b/tests/stubs/OCP/Install/Events/InstallationCompletedEvent.php
new file mode 100644
index 0000000..604cc03
--- /dev/null
+++ b/tests/stubs/OCP/Install/Events/InstallationCompletedEvent.php
@@ -0,0 +1,67 @@
+dataDirectory;
+ }
+
+ /**
+ * Get the admin username if an admin user was created
+ *
+ * @since 33.0.0
+ */
+ public function getAdminUsername(): ?string {
+ return $this->adminUsername;
+ }
+
+ /**
+ * Get the admin email if configured
+ *
+ * @since 33.0.0
+ */
+ public function getAdminEmail(): ?string {
+ return $this->adminEmail;
+ }
+
+ /**
+ * Check if an admin user was created during installation
+ *
+ * @since 33.0.0
+ */
+ public function hasAdminUser(): bool {
+ return $this->adminUsername !== null;
+ }
+}
diff --git a/tests/unit/AppInfo/ApplicationTest.php b/tests/unit/AppInfo/ApplicationTest.php
index ccde177..1207977 100644
--- a/tests/unit/AppInfo/ApplicationTest.php
+++ b/tests/unit/AppInfo/ApplicationTest.php
@@ -10,8 +10,10 @@
namespace OCA\NcwTools\Tests\Unit\AppInfo;
use OCA\NcwTools\AppInfo\Application;
+use OCA\NcwTools\Listeners\InstallationCompletedEventListener;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Install\Events\InstallationCompletedEvent;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
@@ -37,9 +39,15 @@ public function testConstructorInitializesWithCorrectAppId(): void {
}
public function testRegisterMethodExists(): void {
- // Test that register method can be called without errors
- // Since the method is currently empty, we just verify it doesn't throw
- $this->expectNotToPerformAssertions();
+ // Test that register method registers the InstallationCompletedEvent listener
+ $this->registrationContext
+ ->expects($this->once())
+ ->method('registerEventListener')
+ ->with(
+ InstallationCompletedEvent::class,
+ InstallationCompletedEventListener::class
+ );
+
$this->application->register($this->registrationContext);
}
}
diff --git a/tests/unit/BackgroundJob/PostSetupJobTest.php b/tests/unit/BackgroundJob/PostSetupJobTest.php
new file mode 100644
index 0000000..dbb1ef6
--- /dev/null
+++ b/tests/unit/BackgroundJob/PostSetupJobTest.php
@@ -0,0 +1,318 @@
+logger = $this->createMock(LoggerInterface::class);
+ $this->appConfig = $this->createMock(IAppConfig::class);
+ $this->config = $this->createMock(IConfig::class);
+ $this->userManager = $this->createMock(IUserManager::class);
+ $this->clientService = $this->createMock(IClientService::class);
+ $this->timeFactory = $this->createMock(ITimeFactory::class);
+ $this->jobList = $this->createMock(IJobList::class);
+ $this->welcomeMailHelper = $this->createMock(WelcomeMailHelper::class);
+
+ // Default config expectation for retry interval
+ $this->config->expects($this->any())
+ ->method('getSystemValueInt')
+ ->with('ncw_tools.post_setup_job.retry_interval', 2)
+ ->willReturn(2);
+
+ // Expect debug log in constructor
+ $this->logger->expects($this->any())
+ ->method('debug')
+ ->willReturnCallback(function ($message, $context = []) {
+ // Allow any debug messages
+ });
+
+ $this->job = new PostSetupJob(
+ $this->logger,
+ $this->appConfig,
+ $this->config,
+ $this->userManager,
+ $this->clientService,
+ $this->timeFactory,
+ $this->jobList,
+ $this->welcomeMailHelper
+ );
+ }
+
+ public function testRunWithJobAlreadyDone(): void {
+ $this->appConfig->expects($this->once())
+ ->method('getValueString')
+ ->with('ncw_tools', 'post_install', 'UNKNOWN')
+ ->willReturn('DONE');
+
+ $this->logger->expects($this->once())
+ ->method('info')
+ ->with('Post-installation job already completed, removing from queue');
+
+ // Use reflection to call protected method
+ $reflection = new \ReflectionClass($this->job);
+ $method = $reflection->getMethod('run');
+ $method->setAccessible(true);
+ $method->invoke($this->job, 'test-admin');
+ }
+
+ public function testRunWithUnknownStatus(): void {
+ $this->appConfig->expects($this->once())
+ ->method('getValueString')
+ ->with('ncw_tools', 'post_install', 'UNKNOWN')
+ ->willReturn('UNKNOWN');
+
+ $this->logger->expects($this->once())
+ ->method('warning')
+ ->with('Job status unknown, waiting for initialization');
+
+ // Use reflection to call protected method
+ $reflection = new \ReflectionClass($this->job);
+ $method = $reflection->getMethod('run');
+ $method->setAccessible(true);
+ $method->invoke($this->job, 'test-admin');
+ }
+
+ public function testRunWithPendingStatus(): void {
+ $this->appConfig->expects($this->once())
+ ->method('getValueString')
+ ->with('ncw_tools', 'post_install', 'UNKNOWN')
+ ->willReturn('PENDING');
+
+ $this->config->expects($this->once())
+ ->method('getSystemValue')
+ ->with('overwrite.cli.url')
+ ->willReturn('https://example.com');
+
+ $client = $this->createMock(IClient::class);
+ $response = $this->createMock(IResponse::class);
+ $response->expects($this->atLeastOnce())
+ ->method('getStatusCode')
+ ->willReturn(200);
+
+ $client->expects($this->once())
+ ->method('get')
+ ->with('https://example.com/status.php')
+ ->willReturn($response);
+
+ $this->clientService->expects($this->once())
+ ->method('newClient')
+ ->willReturn($client);
+
+ $this->userManager->expects($this->once())
+ ->method('userExists')
+ ->with('test-admin')
+ ->willReturn(false);
+
+ // Expect info logs for job start/completion and system ready
+ $this->logger->expects($this->atLeastOnce())
+ ->method('info');
+
+ $this->logger->expects($this->once())
+ ->method('warning')
+ ->with('Admin user not found, cannot send welcome email', $this->anything());
+
+ // setValueString should NOT be called when user doesn't exist (job will retry)
+ $this->appConfig->expects($this->never())
+ ->method('setValueString');
+
+ // Use reflection to call protected method
+ $reflection = new \ReflectionClass($this->job);
+ $method = $reflection->getMethod('run');
+ $method->setAccessible(true);
+ $method->invoke($this->job, 'test-admin');
+ }
+
+ public function testRunWithUrlNotAvailable(): void {
+ $this->appConfig->expects($this->once())
+ ->method('getValueString')
+ ->with('ncw_tools', 'post_install', 'UNKNOWN')
+ ->willReturn('PENDING');
+
+ $this->config->expects($this->once())
+ ->method('getSystemValue')
+ ->with('overwrite.cli.url')
+ ->willReturn('https://example.com');
+
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('get')
+ ->with('https://example.com/status.php')
+ ->willThrowException(new \Exception('Connection failed'));
+
+ $this->clientService->expects($this->once())
+ ->method('newClient')
+ ->willReturn($client);
+
+ // Expect info calls for:
+ // 1. "Starting post-installation job"
+ // 2. "URL not yet accessible" (from exception)
+ // 3. "System not ready, will retry sending welcome email"
+ // 4. "Post-installation job completed"
+ $this->logger->expects($this->atLeastOnce())
+ ->method('info');
+
+ // Expect 1 debug call for URL checking
+ $this->logger->expects($this->once())
+ ->method('debug');
+
+ $this->appConfig->expects($this->never())
+ ->method('setValueString');
+
+ // Use reflection to call protected method
+ $reflection = new \ReflectionClass($this->job);
+ $method = $reflection->getMethod('run');
+ $method->setAccessible(true);
+ $method->invoke($this->job, 'test-admin');
+ }
+
+ public function testRunWithSuccessfulMailSend(): void {
+ $this->appConfig->expects($this->once())
+ ->method('getValueString')
+ ->with('ncw_tools', 'post_install', 'UNKNOWN')
+ ->willReturn('PENDING');
+
+ $this->config->expects($this->once())
+ ->method('getSystemValue')
+ ->with('overwrite.cli.url')
+ ->willReturn('https://example.com');
+
+ $client = $this->createMock(IClient::class);
+ $response = $this->createMock(IResponse::class);
+ $response->expects($this->atLeastOnce())
+ ->method('getStatusCode')
+ ->willReturn(200);
+
+ $client->expects($this->once())
+ ->method('get')
+ ->with('https://example.com/status.php')
+ ->willReturn($response);
+
+ $this->clientService->expects($this->once())
+ ->method('newClient')
+ ->willReturn($client);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('test-admin');
+ $user->method('getEMailAddress')->willReturn('admin@example.com');
+ $user->method('getBackendClassName')->willReturn('Database');
+
+ $this->userManager->expects($this->once())
+ ->method('userExists')
+ ->with('test-admin')
+ ->willReturn(true);
+
+ $this->userManager->expects($this->once())
+ ->method('get')
+ ->with('test-admin')
+ ->willReturn($user);
+
+ // Expect debug logs (allow flexible count due to URL checking)
+ $this->logger->expects($this->atLeastOnce())
+ ->method('debug');
+
+ // Expect welcome mail to be sent
+ $this->welcomeMailHelper->expects($this->once())
+ ->method('sendWelcomeMail')
+ ->with($user, true);
+
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('ncw_tools', 'post_install', 'DONE');
+
+ // Use reflection to call protected method
+ $reflection = new \ReflectionClass($this->job);
+ $method = $reflection->getMethod('run');
+ $method->setAccessible(true);
+ $method->invoke($this->job, 'test-admin');
+ }
+
+ public function testConstructorUsesDefaultRetryInterval(): void {
+ $logger = $this->createMock(LoggerInterface::class);
+ $config = $this->createMock(IConfig::class);
+
+ $config->expects($this->once())
+ ->method('getSystemValueInt')
+ ->with('ncw_tools.post_setup_job.retry_interval', 2)
+ ->willReturn(2);
+
+ $logger->expects($this->once())
+ ->method('debug')
+ ->with('PostSetupJob initialized', [
+ 'retryInterval' => 2,
+ 'timeSensitivity' => 'TIME_SENSITIVE',
+ ]);
+
+ new PostSetupJob(
+ $logger,
+ $this->appConfig,
+ $config,
+ $this->userManager,
+ $this->clientService,
+ $this->timeFactory,
+ $this->jobList,
+ $this->welcomeMailHelper
+ );
+ }
+
+ public function testConstructorUsesCustomRetryInterval(): void {
+ $logger = $this->createMock(LoggerInterface::class);
+ $config = $this->createMock(IConfig::class);
+
+ $config->expects($this->once())
+ ->method('getSystemValueInt')
+ ->with('ncw_tools.post_setup_job.retry_interval', 2)
+ ->willReturn(10);
+
+ $logger->expects($this->once())
+ ->method('debug')
+ ->with('PostSetupJob initialized', [
+ 'retryInterval' => 10,
+ 'timeSensitivity' => 'TIME_SENSITIVE',
+ ]);
+
+ new PostSetupJob(
+ $logger,
+ $this->appConfig,
+ $config,
+ $this->userManager,
+ $this->clientService,
+ $this->timeFactory,
+ $this->jobList,
+ $this->welcomeMailHelper
+ );
+ }
+}
diff --git a/tests/unit/Helper/WelcomeMailHelperTest.php b/tests/unit/Helper/WelcomeMailHelperTest.php
new file mode 100644
index 0000000..ceae501
--- /dev/null
+++ b/tests/unit/Helper/WelcomeMailHelperTest.php
@@ -0,0 +1,163 @@
+defaults = $this->createMock(Defaults::class);
+ $this->crypto = $this->createMock(ICrypto::class);
+ $this->mailer = $this->createMock(IMailer::class);
+ $this->urlGenerator = $this->createMock(IURLGenerator::class);
+ $this->l10NFactory = $this->createMock(IFactory::class);
+ $this->secureRandom = $this->createMock(ISecureRandom::class);
+ $this->config = $this->createMock(IConfig::class);
+ $this->timeFactory = $this->createMock(ITimeFactory::class);
+
+ // Setup mocks required by NewUserMailHelper
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnArgument(0);
+ $this->l10NFactory->method('get')->willReturn($l10n);
+ $this->l10NFactory->method('getUserLanguage')->willReturn('en');
+
+ $emailTemplate = $this->createMock(IEMailTemplate::class);
+ $emailTemplate->method('addHeader')->willReturnSelf();
+ $emailTemplate->method('addHeading')->willReturnSelf();
+ $emailTemplate->method('addBodyText')->willReturnSelf();
+ $emailTemplate->method('addBodyButton')->willReturnSelf();
+ $emailTemplate->method('addBodyButtonGroup')->willReturnSelf();
+ $emailTemplate->method('addFooter')->willReturnSelf();
+ $emailTemplate->method('setSubject')->willReturnSelf();
+ $this->mailer->method('createEMailTemplate')->willReturn($emailTemplate);
+
+ $message = $this->createMock(IMessage::class);
+ $message->method('setTo')->willReturnSelf();
+ $message->method('setFrom')->willReturnSelf();
+ $message->method('useTemplate')->willReturnSelf();
+ $message->method('setAutoSubmitted')->willReturnSelf();
+ $this->mailer->method('createMessage')->willReturn($message);
+
+ $this->defaults->method('getName')->willReturn('Nextcloud');
+ $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://example.com');
+ $this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/reset');
+ $this->secureRandom->method('generate')->willReturn('test-token');
+ $this->crypto->method('encrypt')->willReturn('encrypted-token');
+ $this->config->method('getSystemValue')->willReturnMap([
+ ['customclient_desktop', 'https://nextcloud.com/install/#install-clients', 'https://nextcloud.com/install/#install-clients'],
+ ['secret', '', 'test-secret'],
+ ]);
+
+ $this->welcomeMailHelper = new WelcomeMailHelper(
+ $this->defaults,
+ $this->crypto,
+ $this->mailer,
+ $this->urlGenerator,
+ $this->l10NFactory,
+ $this->secureRandom,
+ $this->config,
+ $this->timeFactory
+ );
+ }
+
+ public function testSendWelcomeMailWithPasswordResetToken(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('testuser');
+ $user->method('getDisplayName')->willReturn('Test User');
+ $user->method('getEMailAddress')->willReturn('testuser@example.com');
+ $user->method('getBackendClassName')->willReturn('Database');
+
+ $this->timeFactory->expects($this->once())
+ ->method('getTime')
+ ->willReturn(1234567890);
+
+ $this->config->expects($this->once())
+ ->method('setUserValue')
+ ->with('testuser', 'core', 'lostpassword', $this->anything());
+
+ $this->mailer->expects($this->once())
+ ->method('send');
+
+ $this->welcomeMailHelper->sendWelcomeMail($user, true);
+ }
+
+ public function testSendWelcomeMailWithoutPasswordResetToken(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('testuser');
+ $user->method('getDisplayName')->willReturn('Test User');
+ $user->method('getEMailAddress')->willReturn('testuser@example.com');
+ $user->method('getBackendClassName')->willReturn('Database');
+
+ $this->timeFactory->expects($this->never())
+ ->method('getTime');
+
+ $this->config->expects($this->never())
+ ->method('setUserValue');
+
+ $this->mailer->expects($this->once())
+ ->method('send');
+
+ $this->welcomeMailHelper->sendWelcomeMail($user, false);
+ }
+
+ public function testSendWelcomeMailWithUserWithoutEmail(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('testuser');
+ $user->method('getDisplayName')->willReturn('Test User');
+ $user->method('getEMailAddress')->willReturn(null);
+ $user->method('getBackendClassName')->willReturn('Database');
+
+ $this->mailer->expects($this->never())
+ ->method('send');
+
+ $this->welcomeMailHelper->sendWelcomeMail($user, false);
+ }
+
+ public function testConstructorInitializesCorrectly(): void {
+ $helper = new WelcomeMailHelper(
+ $this->defaults,
+ $this->crypto,
+ $this->mailer,
+ $this->urlGenerator,
+ $this->l10NFactory,
+ $this->secureRandom,
+ $this->config,
+ $this->timeFactory
+ );
+
+ $this->assertInstanceOf(WelcomeMailHelper::class, $helper);
+ }
+}
diff --git a/tests/unit/Listeners/InstallationCompletedEventListenerTest.php b/tests/unit/Listeners/InstallationCompletedEventListenerTest.php
new file mode 100644
index 0000000..84bca29
--- /dev/null
+++ b/tests/unit/Listeners/InstallationCompletedEventListenerTest.php
@@ -0,0 +1,99 @@
+appConfig = $this->createMock(IAppConfig::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->jobList = $this->createMock(IJobList::class);
+
+ $this->listener = new InstallationCompletedEventListener(
+ $this->appConfig,
+ $this->logger,
+ $this->jobList
+ );
+ }
+
+ public function testHandleSetsAppConfigAndAddsJob(): void {
+ $event = new InstallationCompletedEvent(
+ '/var/www/html/data',
+ 'admin',
+ 'admin@example.com'
+ );
+
+ // Expect app config to be set for 'post_install'
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('ncw_tools', 'post_install', 'INIT');
+
+ // Expect job to be added
+ $this->jobList->expects($this->once())
+ ->method('add')
+ ->with(PostSetupJob::class, 'admin');
+
+ // Expect debug logs
+ $this->logger->expects($this->atLeastOnce())
+ ->method('debug');
+
+ $this->listener->handle($event);
+ }
+
+ public function testHandleWithoutAdminUser(): void {
+ $event = new InstallationCompletedEvent(
+ '/var/www/html/data'
+ );
+
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('ncw_tools', 'post_install', 'INIT');
+
+ // Expect warning when no admin user
+ $this->logger->expects($this->once())
+ ->method('warning')
+ ->with('No admin user provided in InstallationCompletedEvent');
+
+ // Job should NOT be added
+ $this->jobList->expects($this->never())
+ ->method('add');
+
+ $this->listener->handle($event);
+ }
+
+ public function testHandleWithWrongEventType(): void {
+ $event = $this->createMock(Event::class);
+
+ // Should not process non-InstallationCompletedEvent events
+ $this->appConfig->expects($this->never())
+ ->method('setValueString');
+
+ $this->jobList->expects($this->never())
+ ->method('add');
+
+ $this->listener->handle($event);
+ }
+}