From bbd275810bd25b9a0e421eaf75571c64a1e9134b Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Wed, 13 May 2026 15:41:21 +0200 Subject: [PATCH] fix(MailQueueHandler): check enable_email toggle before sending queued emails The admin Enable notification emails toggle was only checked when queuing new emails. The sendEmails() background job had no knowledge of it and would send all queued entries regardless. Adds an early-return guard using IAppConfig::getValueString() at the top of sendEmails(), consistent with the checks in UserSettings. The queue is left intact so pending notifications can be delivered if the admin re-enables emails. AI-Assisted-By: Claude Sonnet 4.6 Signed-off-by: Anna Larch --- .github/workflows/npm-audit-fix.yml | 18 +-- composer.lock | 8 +- l10n/it.js | 1 - l10n/it.json | 1 - lib/AppInfo/Application.php | 2 + lib/Controller/APIv2Controller.php | 85 ++++++++++--- lib/Data.php | 66 +++++----- lib/FilesHooks.php | 36 +++--- lib/MailQueueHandler.php | 29 +++-- lib/UserSettings.php | 6 +- lib/ViewInfoCache.php | 49 ++++---- package-lock.json | 149 ++++++++--------------- package.json | 10 +- tests/Controller/APIv2ControllerTest.php | 18 +-- tests/FilesHooksTest.php | 83 ------------- tests/MailQueueHandlerTest.php | 28 +++++ 16 files changed, 265 insertions(+), 324 deletions(-) diff --git a/.github/workflows/npm-audit-fix.yml b/.github/workflows/npm-audit-fix.yml index 5cf705b27..2074c4c38 100644 --- a/.github/workflows/npm-audit-fix.yml +++ b/.github/workflows/npm-audit-fix.yml @@ -67,22 +67,6 @@ jobs: npm ci npm run build --if-present - - name: Generate PR body - if: steps.checkout.outcome == 'success' - run: | - { - printf '%s\n\n' "$NPM_AUDIT_MARKDOWN" - echo '## Full `npm audit` report' - echo '' - echo '```' - npm audit 2>&1 || true - echo '```' - echo '' - echo "**Node.js:** $(node --version) | **npm:** $(npm --version) | **Branch:** ${{ matrix.branches }}" - } > pr-body.md - env: - NPM_AUDIT_MARKDOWN: ${{ steps.npm-audit.outputs.markdown }} - - name: Create Pull Request if: steps.checkout.outcome == 'success' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 @@ -94,7 +78,7 @@ jobs: signoff: true branch: automated/noid/${{ matrix.branches }}-fix-npm-audit title: '[${{ matrix.branches }}] Fix npm audit' - body-path: pr-body.md + body: ${{ steps.npm-audit.outputs.markdown }} labels: | dependencies 3. to review diff --git a/composer.lock b/composer.lock index 025d80afb..bff82a9f1 100644 --- a/composer.lock +++ b/composer.lock @@ -70,12 +70,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "f3fddf2af92f48d3f53569074f69f0ae4bad52b6" + "reference": "69ce9a120906944a58b3953b29c56b809eefc9bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/f3fddf2af92f48d3f53569074f69f0ae4bad52b6", - "reference": "f3fddf2af92f48d3f53569074f69f0ae4bad52b6", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/69ce9a120906944a58b3953b29c56b809eefc9bd", + "reference": "69ce9a120906944a58b3953b29c56b809eefc9bd", "shasum": "" }, "require": { @@ -112,7 +112,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2026-05-09T01:52:54+00:00" + "time": "2026-04-30T01:55:09+00:00" }, { "name": "psr/clock", diff --git a/l10n/it.js b/l10n/it.js index 3337a2222..bead57d54 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -51,7 +51,6 @@ OC.L10N.register( "Loading activities" : "Caricamento delle attività", "This stream will show events like additions, changes & shares" : "Questo flusso mostrerà gli eventi come aggiunte, cambiamenti e condivisioni", "No activity yet" : "Ancora nessuna attività", - "New activities" : "Nuove attività", "Loading more activities" : "Caricamento di altre attività", "No more activities." : "Nessun'altra attività.", "Could not enable RSS link" : "Impossibile attivare il collegamento RSS", diff --git a/l10n/it.json b/l10n/it.json index a4d5dd9b5..ea72a1d24 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -49,7 +49,6 @@ "Loading activities" : "Caricamento delle attività", "This stream will show events like additions, changes & shares" : "Questo flusso mostrerà gli eventi come aggiunte, cambiamenti e condivisioni", "No activity yet" : "Ancora nessuna attività", - "New activities" : "Nuove attività", "Loading more activities" : "Caricamento di altre attività", "No more activities." : "Nessun'altra attività.", "Could not enable RSS link" : "Impossibile attivare il collegamento RSS", diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 19c4f47ef..312aab673 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -31,6 +31,7 @@ use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\DB\Events\AddMissingIndicesEvent; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDateTimeFormatter; use OCP\IDBConnection; @@ -117,6 +118,7 @@ public function register(IRegistrationContext $context): void { $c->get(IFactory::class), $c->get(IManager::class), $c->get(IValidator::class), + $c->get(IAppConfig::class), $c->get(IConfig::class), $c->get(LoggerInterface::class), $c->get(Data::class), diff --git a/lib/Controller/APIv2Controller.php b/lib/Controller/APIv2Controller.php index e0444a0c3..2270bef69 100644 --- a/lib/Controller/APIv2Controller.php +++ b/lib/Controller/APIv2Controller.php @@ -28,14 +28,29 @@ use OCP\Notification\IManager as INotificationManager; class APIv2Controller extends OCSController { - protected string $filter = 'all'; - protected int $since = 0; - protected int $limit = 50; - protected string $sort = 'desc'; - protected string $objectType = ''; - protected int $objectId = 0; - protected string $user = ''; - protected bool $loadPreviews = false; + /** @var string */ + protected $filter; + + /** @var int */ + protected $since; + + /** @var int */ + protected $limit; + + /** @var string */ + protected $sort; + + /** @var string */ + protected $objectType; + + /** @var int */ + protected $objectId; + + /** @var string */ + protected $user; + + /** @var bool */ + protected $loadPreviews; public function __construct( $appName, @@ -56,19 +71,26 @@ public function __construct( } /** + * @param string $filter + * @param int $since + * @param int $limit + * @param bool $previews + * @param string $objectType + * @param int $objectId + * @param string $sort * @throws InvalidFilterException when the filter is invalid * @throws \OutOfBoundsException when no user is given */ - protected function validateParameters(string $filter, int $since, int $limit, bool $previews, string $objectType, int $objectId, string $sort): void { - $this->filter = $filter; + protected function validateParameters($filter, $since, $limit, $previews, $objectType, $objectId, $sort) { + $this->filter = \is_string($filter) ? $filter : 'all'; if ($this->filter !== $this->data->validateFilter($this->filter)) { throw new InvalidFilterException('Invalid filter'); } - $this->since = $since; - $this->limit = $limit; - $this->loadPreviews = $previews; - $this->objectType = $objectType; - $this->objectId = $objectId; + $this->since = (int)$since; + $this->limit = (int)$limit; + $this->loadPreviews = (bool)$previews; + $this->objectType = (string)$objectType; + $this->objectId = (int)$objectId; $this->sort = \in_array($sort, ['asc', 'desc'], true) ? $sort : 'desc'; if (($this->objectType !== '' && $this->objectId === 0) || ($this->objectType === '' && $this->objectId !== 0)) { @@ -88,15 +110,32 @@ protected function validateParameters(string $filter, int $since, int $limit, bo /** * @NoAdminRequired + * + * @param int $since + * @param int $limit + * @param bool $previews + * @param string $object_type + * @param int $object_id + * @param string $sort + * @return DataResponse */ - public function getDefault(int $since = 0, int $limit = 50, bool $previews = false, string $object_type = '', int $object_id = 0, string $sort = 'desc'): DataResponse { + public function getDefault($since = 0, $limit = 50, $previews = false, $object_type = '', $object_id = 0, $sort = 'desc'): DataResponse { return $this->get('all', $since, $limit, $previews, $object_type, $object_id, $sort); } /** * @NoAdminRequired + * + * @param string $filter + * @param int $since + * @param int $limit + * @param bool $previews + * @param string $object_type + * @param int $object_id + * @param string $sort + * @return DataResponse */ - public function getFilter(string $filter, int $since = 0, int $limit = 50, bool $previews = false, string $object_type = '', int $object_id = 0, string $sort = 'desc'): DataResponse { + public function getFilter($filter, $since = 0, $limit = 50, $previews = false, $object_type = '', $object_id = 0, $sort = 'desc'): DataResponse { return $this->get($filter, $since, $limit, $previews, $object_type, $object_id, $sort); } @@ -152,7 +191,17 @@ public function listFilters(): DataResponse { return new DataResponse($filters); } - protected function get(string $filter, int $since, int $limit, bool $previews, string $filterObjectType, int $filterObjectId, string $sort): DataResponse { + /** + * @param string $filter + * @param int $since + * @param int $limit + * @param bool $previews + * @param string $filterObjectType + * @param int $filterObjectId + * @param string $sort + * @return DataResponse + */ + protected function get($filter, $since, $limit, $previews, $filterObjectType, $filterObjectId, $sort): DataResponse { try { $this->validateParameters($filter, $since, $limit, $previews, $filterObjectType, $filterObjectId, $sort); } catch (InvalidFilterException $e) { diff --git a/lib/Data.php b/lib/Data.php index 06e7867af..b4d444bdf 100644 --- a/lib/Data.php +++ b/lib/Data.php @@ -226,7 +226,7 @@ public function storeMail(IEvent $event, int $latestSendTime): bool { * @return array * */ - public function get(GroupHelper $groupHelper, UserSettings $userSettings, string $user, int $since, int $limit, string $sort, string $filter, string $objectType = '', int $objectId = 0, bool $returnEvents = false): array { + public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user, $since, $limit, $sort, $filter, $objectType = '', $objectId = 0, bool $returnEvents = false) { // get current user if ($user === '') { throw new \OutOfBoundsException('Invalid user', 1); @@ -323,39 +323,36 @@ public function get(GroupHelper $groupHelper, UserSettings $userSettings, string * * @throws \OutOfBoundsException If $since is not owned by $user */ - protected function setOffsetFromSince(IQueryBuilder $query, string $user, int $since, string $sort): array { - if (!$since) { - return $this->getFirstKnownActivityHeader($user, $sort); - } - - $queryBuilder = $this->connection->getQueryBuilder(); - $queryBuilder->select(['affecteduser', 'timestamp']) - ->from('activity') - ->where($queryBuilder->expr()->eq('activity_id', $queryBuilder->createNamedParameter($since))); - $result = $queryBuilder->executeQuery(); - $activity = $result->fetch(); - $result->closeCursor(); - - if (!$activity) { - return $this->getFirstKnownActivityHeader($user, $sort); - } - - if ($activity['affecteduser'] !== $user) { - throw new \OutOfBoundsException('Invalid since', 2); - } - - $timestamp = (int)$activity['timestamp']; - if ($sort === 'DESC') { - $query->andWhere($query->expr()->lte('timestamp', $query->createNamedParameter($timestamp))); - $query->andWhere($query->expr()->lt('activity_id', $query->createNamedParameter($since))); - } else { - $query->andWhere($query->expr()->gte('timestamp', $query->createNamedParameter($timestamp))); - $query->andWhere($query->expr()->gt('activity_id', $query->createNamedParameter($since))); + protected function setOffsetFromSince(IQueryBuilder $query, $user, $since, $sort) { + if ($since) { + $queryBuilder = $this->connection->getQueryBuilder(); + $queryBuilder->select(['affecteduser', 'timestamp']) + ->from('activity') + ->where($queryBuilder->expr()->eq('activity_id', $queryBuilder->createNamedParameter((int)$since))); + $result = $queryBuilder->executeQuery(); + $activity = $result->fetch(); + $result->closeCursor(); + + if ($activity) { + if ($activity['affecteduser'] !== $user) { + throw new \OutOfBoundsException('Invalid since', 2); + } + $timestamp = (int)$activity['timestamp']; + + if ($sort === 'DESC') { + $query->andWhere($query->expr()->lte('timestamp', $query->createNamedParameter($timestamp))); + $query->andWhere($query->expr()->lt('activity_id', $query->createNamedParameter($since))); + } else { + $query->andWhere($query->expr()->gte('timestamp', $query->createNamedParameter($timestamp))); + $query->andWhere($query->expr()->gt('activity_id', $query->createNamedParameter($since))); + } + return []; + } } - return []; - } - private function getFirstKnownActivityHeader(string $user, string $sort): array { + /** + * Couldn't find the since, so find the oldest one and set the header + */ $fetchQuery = $this->connection->getQueryBuilder(); $fetchQuery->select('activity_id') ->from('activity') @@ -367,8 +364,11 @@ private function getFirstKnownActivityHeader(string $user, string $sort): array $result->closeCursor(); if ($activity !== false) { - return ['X-Activity-First-Known' => (int)$activity['activity_id']]; + return [ + 'X-Activity-First-Known' => (int)$activity['activity_id'], + ]; } + return []; } diff --git a/lib/FilesHooks.php b/lib/FilesHooks.php index e401f9fde..dbcb85ae5 100644 --- a/lib/FilesHooks.php +++ b/lib/FilesHooks.php @@ -82,10 +82,10 @@ public function fileCreate($path) { return; } - if ($this->currentUser->getUserIdentifier() === '' && $this->currentUser->isPublicShareToken()) { - $this->addNotificationsForFileAction($path, Files_Sharing::TYPE_PUBLIC_UPLOAD, '', 'created_public'); - } else { + if ($this->currentUser->getUserIdentifier() !== '' || !$this->currentUser->isPublicShareToken()) { $this->addNotificationsForFileAction($path, Files::TYPE_SHARE_CREATED, 'created_self', 'created_by'); + } else { + $this->addNotificationsForFileAction($path, Files_Sharing::TYPE_PUBLIC_UPLOAD, '', 'created_public'); } } @@ -807,15 +807,14 @@ protected function shareWithTeam(string $shareWith, Node $fileSource, string $fi * @throws \OCP\Files\NotFoundException */ public function unShare(IShare $share) { - if (!in_array($share->getNodeType(), ['file', 'folder'], true) || $this->isDeletedNode($share->getShareOwner(), $share->getNodeId())) { - return; - } - if ($share->getShareType() === IShare::TYPE_USER) { - $this->unshareFromUser($share); - } elseif ($share->getShareType() === IShare::TYPE_GROUP) { - $this->unshareFromGroup($share); - } elseif ($share->getShareType() === IShare::TYPE_LINK) { - $this->unshareLink($share); + if (in_array($share->getNodeType(), ['file', 'folder'], true) && !$this->isDeletedNode($share->getShareOwner(), $share->getNodeId())) { + if ($share->getShareType() === IShare::TYPE_USER) { + $this->unshareFromUser($share); + } elseif ($share->getShareType() === IShare::TYPE_GROUP) { + $this->unshareFromGroup($share); + } elseif ($share->getShareType() === IShare::TYPE_LINK) { + $this->unshareLink($share); + } } } @@ -826,13 +825,12 @@ public function unShare(IShare $share) { * @throws \OCP\Files\NotFoundException */ public function unShareSelf(IShare $share) { - if (!in_array($share->getNodeType(), ['file', 'folder'], true)) { - return; - } - if ($share->getShareType() === IShare::TYPE_GROUP) { - $this->unshareFromSelfGroup($share); - } elseif ($share->getShareType() === IShare::TYPE_USER) { - $this->unshareFromUser($share); + if (in_array($share->getNodeType(), ['file', 'folder'], true)) { + if ($share->getShareType() === IShare::TYPE_GROUP) { + $this->unshareFromSelfGroup($share); + } elseif ($share->getShareType() === IShare::TYPE_USER) { + $this->unshareFromUser($share); + } } } diff --git a/lib/MailQueueHandler.php b/lib/MailQueueHandler.php index 3da47f717..c1795763c 100644 --- a/lib/MailQueueHandler.php +++ b/lib/MailQueueHandler.php @@ -11,6 +11,7 @@ use OCP\Activity\IManager; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Defaults; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDateTimeFormatter; use OCP\IDBConnection; @@ -51,6 +52,7 @@ public function __construct( protected IFactory $lFactory, protected IManager $activityManager, protected IValidator $richObjectValidator, + protected IAppConfig $appConfig, protected IConfig $config, protected LoggerInterface $logger, protected Data $data, @@ -70,6 +72,10 @@ public function __construct( * @return int Number of users we sent an email to */ public function sendEmails(int $limit, int $sendTime, bool $forceSending = false, ?int $restrictEmails = null): int { + if ($this->appConfig->getValueString('activity', 'enable_email', 'yes') === 'no') { + return 0; + } + // Get all users which should receive an email $affectedUsers = $this->getAffectedUsers($limit, $sendTime, $forceSending, $restrictEmails); if (empty($affectedUsers)) { @@ -150,15 +156,24 @@ protected function getAffectedUsers(?int $limit, int $latestSend, bool $forceSen if ($restrictEmails !== null) { if ($restrictEmails === UserSettings::EMAIL_SEND_HOURLY) { - $query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(UserSettings::BATCH_TIME_HOURLY)))); + $query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(3600)))); } elseif ($restrictEmails === UserSettings::EMAIL_SEND_DAILY) { - $query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(UserSettings::BATCH_TIME_DAILY)))); + $query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(3600 * 24)))); } elseif ($restrictEmails === UserSettings::EMAIL_SEND_WEEKLY) { - $query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(UserSettings::BATCH_TIME_WEEKLY)))); + $query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(3600 * 24 * 7)))); } elseif ($restrictEmails === UserSettings::EMAIL_SEND_ASAP) { $query->where($query->expr()->eq('amq_timestamp', 'amq_latest_send')); } - return $this->fetchAffectedUsers($query); + + $result = $query->executeQuery(); + + $affectedUsers = []; + while ($row = $result->fetch()) { + $affectedUsers[] = $row['amq_affecteduser']; + } + $result->closeCursor(); + + return $affectedUsers; } if ($forceSending) { @@ -167,16 +182,14 @@ protected function getAffectedUsers(?int $limit, int $latestSend, bool $forceSen $query->where($query->expr()->lt('amq_latest_send', $query->createNamedParameter($latestSend))); } - return $this->fetchAffectedUsers($query); - } - - private function fetchAffectedUsers(IQueryBuilder $query): array { $result = $query->executeQuery(); + $affectedUsers = []; while ($row = $result->fetch()) { $affectedUsers[] = $row['amq_affecteduser']; } $result->closeCursor(); + return $affectedUsers; } diff --git a/lib/UserSettings.php b/lib/UserSettings.php index afbc469e6..7eed94bf4 100644 --- a/lib/UserSettings.php +++ b/lib/UserSettings.php @@ -26,10 +26,6 @@ class UserSettings { public const EMAIL_SEND_WEEKLY = 2; public const EMAIL_SEND_ASAP = 3; - public const BATCH_TIME_HOURLY = 3600; - public const BATCH_TIME_DAILY = 3600 * 24; - public const BATCH_TIME_WEEKLY = 3600 * 24 * 7; - /** * @param IManager $manager * @param IConfig $config @@ -113,7 +109,7 @@ public function getAdminSetting($method, $type) { protected function getDefaultSetting($method, $type) { if ($method === 'setting') { if ($type === 'batchtime') { - return self::BATCH_TIME_HOURLY; + return 3600; } if ($type === 'self') { diff --git a/lib/ViewInfoCache.php b/lib/ViewInfoCache.php index c5b145d4b..621f51fc7 100644 --- a/lib/ViewInfoCache.php +++ b/lib/ViewInfoCache.php @@ -45,45 +45,48 @@ protected function findInfoById(string $user, int $fileId, string $filePath): ar 'view' => '', ]; + $notFound = false; try { $userFolder = $this->rootFolder->getUserFolder($user); $entry = $userFolder->getFirstNodeById($fileId); if ($entry === null) { throw new NotFoundException('No entries returned'); } + $cache['path'] = $userFolder->getRelativePath($entry->getPath()); $cache['is_dir'] = $entry instanceof Folder; $cache['exists'] = true; $cache['node'] = $entry; - $this->cacheId[$user][$fileId] = $cache; - return $cache; } catch (NotFoundException) { - // The file was not found in the normal view, maybe it is in the trashbin? - } + // The file was not found in the normal view, + // maybe it is in the trashbin? + try { + $userTrashBin = $this->rootFolder->get('/' . $user . '/files_trashbin'); + if (!$userTrashBin instanceof Folder) { + throw new NotFoundException('No trash bin found for user: ' . $user); + } + $entry = $userTrashBin->getFirstNodeById($fileId); + if ($entry === null) { + throw new NotFoundException('No entries returned'); + } - try { - $userTrashBin = $this->rootFolder->get('/' . $user . '/files_trashbin'); - if (!$userTrashBin instanceof Folder) { - throw new NotFoundException('No trash bin found for user: ' . $user); + $cache = [ + 'path' => $userTrashBin->getRelativePath($entry->getPath()), + 'exists' => true, + 'is_dir' => $entry instanceof Folder, + 'view' => 'trashbin', + 'node' => $entry, + ]; + } catch (NotFoundException) { + $notFound = true; } - $entry = $userTrashBin->getFirstNodeById($fileId); - if ($entry === null) { - throw new NotFoundException('No entries returned'); - } - $cache = [ - 'path' => $userTrashBin->getRelativePath($entry->getPath()), - 'exists' => true, - 'is_dir' => $entry instanceof Folder, - 'view' => 'trashbin', - 'node' => $entry, - ]; - } catch (NotFoundException) { - // Not found anywhere — cache path as null but return original filePath - $this->cacheId[$user][$fileId] = array_merge($cache, ['path' => null]); - return $cache; } $this->cacheId[$user][$fileId] = $cache; + if ($notFound) { + $this->cacheId[$user][$fileId]['path'] = null; + } + return $cache; } } diff --git a/package-lock.json b/package-lock.json index 881d93ba8..fd9de0eab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,17 +33,17 @@ "@nextcloud/cypress": "^1.0.0-beta.15", "@nextcloud/stylelint-config": "^3.2.1", "@nextcloud/vite-config": "^2.5.2", - "@testing-library/cypress": "^10.1.3", + "@testing-library/cypress": "^10.1.0", "@types/dockerode": "^4.0.1", "@vitest/coverage-v8": "^4.1.5", - "@vue/test-utils": "^2.4.10", + "@vue/test-utils": "^2.4.8", "@vue/tsconfig": "^0.9.1", - "cypress-vite": "^1.9.1", + "cypress-vite": "^1.8.0", "cypress-wait-until": "^3.0.2", "dockerode": "^5.0.0", - "eslint-plugin-cypress": "^6.4.0", + "eslint-plugin-cypress": "^6.3.1", "happy-dom": "^20.9.0", - "stylelint": "^17.9.1", + "stylelint": "^17.9.0", "typescript": "^6.0.3", "vite": "^7.3.2", "vitest": "^4.0.18", @@ -3187,11 +3187,10 @@ } }, "node_modules/@testing-library/cypress": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.3.tgz", - "integrity": "sha512-rVCH92TmU8idROHqCdTSp/bosIIUezihSwFfR/J2GZD0EAwyzRuYNseh54eziKJWjJ64BCXPT3X3jLO7v4yenQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz", + "integrity": "sha512-tNkNtYRqPQh71xXKuMizr146zlellawUfDth7A/urYU4J66g0VGZ063YsS0gqS79Z58u1G/uo9UxN05qvKXMag==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.14.6", "@testing-library/dom": "^10.1.0" @@ -3201,7 +3200,7 @@ "npm": ">=6" }, "peerDependencies": { - "cypress": ">=12" + "cypress": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "node_modules/@testing-library/cypress/node_modules/@testing-library/dom": { @@ -4134,9 +4133,9 @@ "license": "MIT" }, "node_modules/@vue/test-utils": { - "version": "2.4.10", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.10.tgz", - "integrity": "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.8.tgz", + "integrity": "sha512-cjAKFbSXFhtZ9Cj+ug60b21lW/BN737e+Syu2LPACIW6R0zVtj65Fnfe649KjfHor3Etx3ZavDFFBrZ+p21YNw==", "dev": true, "license": "MIT", "dependencies": { @@ -5872,17 +5871,16 @@ } }, "node_modules/cypress-vite": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/cypress-vite/-/cypress-vite-1.9.1.tgz", - "integrity": "sha512-n+hmNCfCtPoVZt9BHq6FzH6I9OhkZ83cj26sc3kgtyX/TrN4oWJQyUK4fZYkckPKUf1JlroBlEHHskpFQpclxQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/cypress-vite/-/cypress-vite-1.8.0.tgz", + "integrity": "sha512-rPkIpDzCIo+upsDkFa/NlrnzVumuQ45UcwL7a2k/n8WFIwsW8QYuQaWU2JiIKExP/LNQew3H3Hbs/bp26xC0Fw==", "dev": true, - "license": "MIT", "dependencies": { "chokidar": "^3.5.3", - "debug": "^4.4.3" + "debug": "^4.3.4" }, "peerDependencies": { - "vite": "^2.9 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + "vite": "^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/cypress-wait-until": { @@ -6642,22 +6640,16 @@ } }, "node_modules/eslint-plugin-cypress": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-6.4.0.tgz", - "integrity": "sha512-zdGXFSmTH4w2A+2aU/Abna2s8Vcz5mDxDsnAXP5Vl+pERWRQxlSE2qrrmriNUEKZ62tlOA87b9AOnJX49tOhAQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-6.3.1.tgz", + "integrity": "sha512-iTJtdIZbyCUlagEI4YlVcwgPFV7X379Qi/upujaD4kvOaQkMvzmpt90vfSnaqgqprp/HPIvhnzv3fdI7mYV4QQ==", "dev": true, "license": "MIT", "dependencies": { "globals": "^17.5.0" }, "peerDependencies": { - "@typescript-eslint/parser": ">=8", "eslint": ">=9" - }, - "peerDependenciesMeta": { - "@typescript-eslint/parser": { - "optional": true - } } }, "node_modules/eslint-plugin-jsdoc": { @@ -7133,26 +7125,15 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", + "dev": true }, "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -7161,8 +7142,7 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "path-expression-matcher": "^1.1.3" } }, "node_modules/fast-xml-parser": { @@ -12368,9 +12348,9 @@ } }, "node_modules/stylelint": { - "version": "17.9.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.9.1.tgz", - "integrity": "sha512-THTmnAPJTrg/JhkTWZlSyrO+HUYMx6ELthIHeMyD2WOKqXIJUFQv2Yxn91bvUrZdbBJaW2dUuQdPST2wcQ6C3g==", + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.9.0.tgz", + "integrity": "sha512-xO0jeY6z1/urFL5L/BZLmB1yYlbRiRMQnYH6ArZIDWJ+SZXGssOY7XoYb1JIv/L220+EBnwwJXJS4Mt/F96SvA==", "dev": true, "funding": [ { @@ -14726,21 +14706,6 @@ "node": ">=12" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -16710,9 +16675,9 @@ } }, "@testing-library/cypress": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.3.tgz", - "integrity": "sha512-rVCH92TmU8idROHqCdTSp/bosIIUezihSwFfR/J2GZD0EAwyzRuYNseh54eziKJWjJ64BCXPT3X3jLO7v4yenQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz", + "integrity": "sha512-tNkNtYRqPQh71xXKuMizr146zlellawUfDth7A/urYU4J66g0VGZ063YsS0gqS79Z58u1G/uo9UxN05qvKXMag==", "dev": true, "requires": { "@babel/runtime": "^7.14.6", @@ -17407,9 +17372,9 @@ "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==" }, "@vue/test-utils": { - "version": "2.4.10", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.10.tgz", - "integrity": "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.8.tgz", + "integrity": "sha512-cjAKFbSXFhtZ9Cj+ug60b21lW/BN737e+Syu2LPACIW6R0zVtj65Fnfe649KjfHor3Etx3ZavDFFBrZ+p21YNw==", "dev": true, "requires": { "js-beautify": "^1.14.9", @@ -18667,13 +18632,13 @@ } }, "cypress-vite": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/cypress-vite/-/cypress-vite-1.9.1.tgz", - "integrity": "sha512-n+hmNCfCtPoVZt9BHq6FzH6I9OhkZ83cj26sc3kgtyX/TrN4oWJQyUK4fZYkckPKUf1JlroBlEHHskpFQpclxQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/cypress-vite/-/cypress-vite-1.8.0.tgz", + "integrity": "sha512-rPkIpDzCIo+upsDkFa/NlrnzVumuQ45UcwL7a2k/n8WFIwsW8QYuQaWU2JiIKExP/LNQew3H3Hbs/bp26xC0Fw==", "dev": true, "requires": { "chokidar": "^3.5.3", - "debug": "^4.4.3" + "debug": "^4.3.4" } }, "cypress-wait-until": { @@ -19227,9 +19192,9 @@ "requires": {} }, "eslint-plugin-cypress": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-6.4.0.tgz", - "integrity": "sha512-zdGXFSmTH4w2A+2aU/Abna2s8Vcz5mDxDsnAXP5Vl+pERWRQxlSE2qrrmriNUEKZ62tlOA87b9AOnJX49tOhAQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-6.3.1.tgz", + "integrity": "sha512-iTJtdIZbyCUlagEI4YlVcwgPFV7X379Qi/upujaD4kvOaQkMvzmpt90vfSnaqgqprp/HPIvhnzv3fdI7mYV4QQ==", "dev": true, "requires": { "globals": "^17.5.0" @@ -19518,18 +19483,17 @@ "peer": true }, "fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", "dev": true }, "fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "requires": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "path-expression-matcher": "^1.1.3" } }, "fast-xml-parser": { @@ -23082,9 +23046,9 @@ } }, "stylelint": { - "version": "17.9.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.9.1.tgz", - "integrity": "sha512-THTmnAPJTrg/JhkTWZlSyrO+HUYMx6ELthIHeMyD2WOKqXIJUFQv2Yxn91bvUrZdbBJaW2dUuQdPST2wcQ6C3g==", + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.9.0.tgz", + "integrity": "sha512-xO0jeY6z1/urFL5L/BZLmB1yYlbRiRMQnYH6ArZIDWJ+SZXGssOY7XoYb1JIv/L220+EBnwwJXJS4Mt/F96SvA==", "dev": true, "requires": { "@csstools/css-calc": "^3.2.0", @@ -24444,11 +24408,6 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==" }, - "xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 75db6cc21..75d7d9ca8 100644 --- a/package.json +++ b/package.json @@ -60,17 +60,17 @@ "@nextcloud/cypress": "^1.0.0-beta.15", "@nextcloud/stylelint-config": "^3.2.1", "@nextcloud/vite-config": "^2.5.2", - "@testing-library/cypress": "^10.1.3", + "@testing-library/cypress": "^10.1.0", "@types/dockerode": "^4.0.1", "@vitest/coverage-v8": "^4.1.5", - "@vue/test-utils": "^2.4.10", + "@vue/test-utils": "^2.4.8", "@vue/tsconfig": "^0.9.1", - "cypress-vite": "^1.9.1", + "cypress-vite": "^1.8.0", "cypress-wait-until": "^3.0.2", "dockerode": "^5.0.0", - "eslint-plugin-cypress": "^6.4.0", + "eslint-plugin-cypress": "^6.3.1", "happy-dom": "^20.9.0", - "stylelint": "^17.9.1", + "stylelint": "^17.9.0", "typescript": "^6.0.3", "vite": "^7.3.2", "vitest": "^4.0.18", diff --git a/tests/Controller/APIv2ControllerTest.php b/tests/Controller/APIv2ControllerTest.php index b133a48db..ebdb1a147 100644 --- a/tests/Controller/APIv2ControllerTest.php +++ b/tests/Controller/APIv2ControllerTest.php @@ -140,11 +140,9 @@ public function testValidateParametersFilter(string $param, string $filter): voi ->method('validateFilter') ->with($param) ->willReturn($filter); - $userMock = $this->createMock(IUser::class); - $userMock->method('getUID')->willReturn('testuser'); $this->userSession->expects($this->once()) ->method('getUser') - ->willReturn($userMock); + ->willReturn($this->createMock(IUser::class)); self::invokePrivate($this->controller, 'validateParameters', [$param, 0, 0, false, '', 0, 'desc']); $this->assertSame($filter, self::invokePrivate($this->controller, 'filter')); @@ -175,21 +173,19 @@ public static function dataValidateParametersObject(): array { return [ ['type', 42, 'type', 42], ['type', '42', 'type', 42], - ['', 42, '', 0], - ['type', 0, '', 0], + [null, '42', '', 0], + ['type', null, '', 0], ]; } #[DataProvider('dataValidateParametersObject')] - public function testValidateParametersObject(string $type, mixed $id, string $expectedType, int $expectedId): void { + public function testValidateParametersObject(?string $type, mixed $id, string $expectedType, int $expectedId): void { $this->data->expects($this->once()) ->method('validateFilter') ->willReturnArgument(0); - $userMock = $this->createMock(IUser::class); - $userMock->method('getUID')->willReturn('testuser'); $this->userSession->expects($this->once()) ->method('getUser') - ->willReturn($userMock); + ->willReturn($this->createMock(IUser::class)); self::invokePrivate($this->controller, 'validateParameters', ['all', 0, 0, false, $type, $id, 'desc']); $this->assertSame($expectedType, self::invokePrivate($this->controller, 'objectType')); @@ -233,11 +229,9 @@ public function testValidateParameters(string $param, mixed $value, string $memb $this->data->expects($this->once()) ->method('validateFilter') ->willReturnArgument(0); - $userMock = $this->createMock(IUser::class); - $userMock->method('getUID')->willReturn('testuser'); $this->userSession->expects($this->once()) ->method('getUser') - ->willReturn($userMock); + ->willReturn($this->createMock(IUser::class)); self::invokePrivate($this->controller, 'validateParameters', $params); $this->assertSame($expectedValue, self::invokePrivate($this->controller, $memberName)); diff --git a/tests/FilesHooksTest.php b/tests/FilesHooksTest.php index 0b8dd6229..a79eecd3e 100644 --- a/tests/FilesHooksTest.php +++ b/tests/FilesHooksTest.php @@ -171,8 +171,6 @@ public static function dataFileCreate(): array { ['user', false, 'created_self', 'created_by', Files::TYPE_SHARE_CREATED], ['', true, '', 'created_public', Files_Sharing::TYPE_PUBLIC_UPLOAD], ['', false, 'created_self', 'created_by', Files::TYPE_SHARE_CREATED], - // logged-in user uploading to a public share link → treated as regular upload - ['user', true, 'created_self', 'created_by', Files::TYPE_SHARE_CREATED], ]; } @@ -1141,85 +1139,4 @@ public function testGetUserPathsFromPathSuccess(): void { $this->assertSame(['remote1' => ['token' => 'abc']], $result['remotes']); $this->assertSame('/test/path', $result['ownerPath']); } - - private function getShareMock(string $nodeType, int $shareType, string $owner = 'owner', int $nodeId = 42): IShare&MockObject { - $share = $this->createMock(IShare::class); - $share->method('getNodeType')->willReturn($nodeType); - $share->method('getShareType')->willReturn($shareType); - $share->method('getShareOwner')->willReturn($owner); - $share->method('getNodeId')->willReturn($nodeId); - return $share; - } - - private function mockNodeNotDeleted(string $owner, int $nodeId): void { - $node = $this->createMock(File::class); - $userFolder = $this->createMock(Folder::class); - $userFolder->method('getFirstNodeById')->with($nodeId)->willReturn($node); - $this->rootFolder->method('getUserFolder')->with($owner)->willReturn($userFolder); - } - - private function mockNodeDeleted(string $owner): void { - $this->rootFolder->method('getUserFolder') - ->with($owner) - ->willThrowException(new NotFoundException()); - } - - public function testUnShareIgnoresNonFileOrFolder(): void { - $filesHooks = $this->getFilesHooks(['unshareFromUser', 'unshareFromGroup', 'unshareLink']); - $share = $this->getShareMock('other', IShare::TYPE_USER); - - $filesHooks->expects($this->never())->method('unshareFromUser'); - $filesHooks->expects($this->never())->method('unshareFromGroup'); - $filesHooks->expects($this->never())->method('unshareLink'); - - $filesHooks->unShare($share); - } - - public function testUnShareIgnoresDeletedNode(): void { - $filesHooks = $this->getFilesHooks(['unshareFromUser', 'unshareFromGroup', 'unshareLink']); - $share = $this->getShareMock('file', IShare::TYPE_USER); - $this->mockNodeDeleted('owner'); - - $filesHooks->expects($this->never())->method('unshareFromUser'); - $filesHooks->expects($this->never())->method('unshareFromGroup'); - $filesHooks->expects($this->never())->method('unshareLink'); - - $filesHooks->unShare($share); - } - - public function testUnShareUser(): void { - $filesHooks = $this->getFilesHooks(['unshareFromUser', 'unshareFromGroup', 'unshareLink']); - $share = $this->getShareMock('file', IShare::TYPE_USER); - $this->mockNodeNotDeleted('owner', 42); - - $filesHooks->expects($this->once())->method('unshareFromUser')->with($share); - $filesHooks->expects($this->never())->method('unshareFromGroup'); - $filesHooks->expects($this->never())->method('unshareLink'); - - $filesHooks->unShare($share); - } - - public function testUnShareGroup(): void { - $filesHooks = $this->getFilesHooks(['unshareFromUser', 'unshareFromGroup', 'unshareLink']); - $share = $this->getShareMock('folder', IShare::TYPE_GROUP); - $this->mockNodeNotDeleted('owner', 42); - - $filesHooks->expects($this->never())->method('unshareFromUser'); - $filesHooks->expects($this->once())->method('unshareFromGroup')->with($share); - $filesHooks->expects($this->never())->method('unshareLink'); - - $filesHooks->unShare($share); - } - - public function testUnShareLink(): void { - $filesHooks = $this->getFilesHooks(['unshareFromUser', 'unshareFromGroup', 'unshareLink']); - $share = $this->getShareMock('file', IShare::TYPE_LINK); - $this->mockNodeNotDeleted('owner', 42); - - $filesHooks->expects($this->never())->method('unshareFromUser'); - $filesHooks->expects($this->never())->method('unshareFromGroup'); - $filesHooks->expects($this->once())->method('unshareLink')->with($share); - - $filesHooks->unShare($share); - } } diff --git a/tests/MailQueueHandlerTest.php b/tests/MailQueueHandlerTest.php index 91a658138..9397e9192 100644 --- a/tests/MailQueueHandlerTest.php +++ b/tests/MailQueueHandlerTest.php @@ -34,6 +34,7 @@ use OCA\Activity\UserSettings; use OCP\Activity\IEvent; use OCP\Activity\IManager; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDateTimeFormatter; use OCP\IDBConnection; @@ -65,6 +66,7 @@ class MailQueueHandlerTest extends TestCase { protected IFactory&MockObject $lFactory; protected IManager&MockObject $activityManager; protected IValidator&MockObject $richObjectValidator; + protected IAppConfig&MockObject $appConfig; protected IConfig&MockObject $config; protected MockObject&LoggerInterface $logger; @@ -81,6 +83,10 @@ protected function setUp(): void { $app = self::getUniqueID('MailQueueHandlerTest', 10); $this->userManager = $this->createMock(IUserManager::class); $this->lFactory = $this->createMock(IFactory::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->appConfig->method('getValueString') + ->with('activity', 'enable_email', 'yes') + ->willReturn('yes'); $this->config = $this->createMock(IConfig::class); $this->logger = $this->createMock(LoggerInterface::class); $this->dateTimeFormatter = $this->createMock(IDateTimeFormatter::class); @@ -141,6 +147,7 @@ protected function setUp(): void { $this->lFactory, $this->activityManager, $this->richObjectValidator, + $this->appConfig, $this->config, $this->logger, $this->data, @@ -370,6 +377,27 @@ public function testSendEmailsDeletesQueueOnSendReturnFalse(): void { } } + public function testSendEmailsSkipsWhenAdminEmailDisabled(): void { + $maxTime = 200; + + $this->appConfig->method('getValueString') + ->with('activity', 'enable_email', 'yes') + ->willReturn('no'); + + $this->mailer->expects($this->never()) + ->method('send'); + + $result = $this->mailQueueHandler->sendEmails(3, $maxTime); + + $this->assertSame(0, $result); + + // Queue must be untouched so emails can be sent if admin re-enables the toggle + foreach (['user1', 'user2', 'user3'] as $user) { + [$data,] = self::invokePrivate($this->mailQueueHandler, 'getItemsForUser', [$user, $maxTime]); + $this->assertNotEmpty($data, "Queue entries for $user must survive when email is globally disabled"); + } + } + /** * @param array $users * @param int $maxTime