From b455301f40dd53b98ce63822a7cdb8cfd524b84a Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Thu, 3 Sep 2026 14:53:04 +0200 Subject: [PATCH 1/3] perf(cleanup): Optimize cleanup job Signed-off-by: Kostiantyn Miakshyn [skip ci] --- appinfo/info.xml | 2 +- lib/Db/Document.php | 9 ++ lib/Db/DocumentMapper.php | 4 +- .../Version100000Date20261011000000.php | 36 ++++++ lib/Service/DocumentService.php | 16 ++- tests/unit/CleanupTest.php | 118 ++++++++++++++++++ tests/unit/Db/DocumentMapperTest.php | 79 ++++++++++++ 7 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 lib/Migration/Version100000Date20261011000000.php create mode 100644 tests/unit/CleanupTest.php create mode 100644 tests/unit/Db/DocumentMapperTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 7a5230954ae..9145b34f741 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,6 +1,6 @@ addType('checksum', 'string'); } + public function getLastAttachmentCleanup(): int { + return $this->lastAttachmentCleanup; + } + + public function setLastAttachmentCleanup(int $time): void { + $this->setter('lastAttachmentCleanup', [$time]); + } + public function jsonSerialize(): array { return [ 'id' => $this->getId(), diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php index efde04e80dd..5a068ab17f2 100644 --- a/lib/Db/DocumentMapper.php +++ b/lib/Db/DocumentMapper.php @@ -54,12 +54,14 @@ public function findAll(): Generator { } } - public function findAllWithNoActiveSessions(): Generator { + public function findAllPendingAttachmentCleanup(): Generator { $qb = $this->db->getQueryBuilder(); $result = $qb->select('d.*') ->from($this->getTableName(), 'd') ->leftJoin('d', 'text_sessions', 's', $qb->expr()->eq('s.document_id', 'd.id')) ->where($qb->expr()->isNull('s.id')) + ->andWhere($qb->expr()->gt('d.last_saved_version_time', 'd.last_attachment_cleanup')) + ->andWhere($qb->expr()->eq('d.context_type', $qb->createNamedParameter('file'))) ->executeQuery(); try { while ($row = $result->fetch()) { diff --git a/lib/Migration/Version100000Date20261011000000.php b/lib/Migration/Version100000Date20261011000000.php new file mode 100644 index 00000000000..792bb1a1f87 --- /dev/null +++ b/lib/Migration/Version100000Date20261011000000.php @@ -0,0 +1,36 @@ +getTable('text_documents'); + if (!$table->hasColumn('last_attachment_cleanup')) { + $table->addColumn('last_attachment_cleanup', Types::BIGINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + return $schema; + } + + return null; + } +} diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index eae2178aebc..883bdbc0c8c 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -487,8 +487,20 @@ public function getAll(): \Generator { return $this->documentMapper->findAll(); } - public function getAllWithNoActiveSession(): \Generator { - return $this->documentMapper->findAllWithNoActiveSessions(); + public function getAllPendingAttachmentCleanup(): \Generator { + return $this->documentMapper->findAllPendingAttachmentCleanup(); + } + + public function markAttachmentCleanup(Document $document): void { + $document->setLastAttachmentCleanup($document->getLastSavedVersionTime()); + $this->documentMapper->update($document); + } + + public function hasUnsavedChanges(Document $document): bool { + if ($document->id === null) { + throw new RuntimeException('Document needs an id to check unsaved changes'); + } + return ($this->stepMapper->getLatestVersion($document->id) ?? 0) !== $document->getLastSavedVersion(); } /** diff --git a/tests/unit/CleanupTest.php b/tests/unit/CleanupTest.php new file mode 100644 index 00000000000..0c52542ac5d --- /dev/null +++ b/tests/unit/CleanupTest.php @@ -0,0 +1,118 @@ +document = Document::fromRow([ + 'id' => '123456789012345678', + 'context_type' => 'file', + 'context_id' => 123, + 'last_saved_version_time' => 1000, + ]); + $this->documentService = $this->createMock(DocumentService::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->attachmentService = new AttachmentService( + $this->rootFolder, + $this->createMock(IManager::class), + $this->createMock(IPreview::class), + $this->createMock(IMimeTypeDetector::class), + $this->createMock(IURLGenerator::class), + $this->createMock(IFilenameValidator::class), + $this->createMock(IFilesMetadataManager::class), + $this->createMock(ISession::class), + $this->createMock(FileService::class), + ); + $this->sessionService = $this->createMock(SessionService::class); + $this->setupManager = $this->createMock(ISetupManager::class); + $this->documentService->expects(self::never())->method('resetDocument'); + $this->documentService->expects(self::once()) + ->method('getAllPendingAttachmentCleanup') + ->willReturn((function (): \Generator { + yield $this->document; + })()); + $this->cleanup = new class($this->createMock(ITimeFactory::class), $this->sessionService, $this->documentService, $this->attachmentService, $this->createMock(LoggerInterface::class), $this->setupManager, ) extends Cleanup { + public function run($argument): void { + parent::run($argument); + } + }; + } + + public function testMarksCleanupOnlyAfterAttachmentsAreCleaned(): void { + $this->documentService->expects(self::once()) + ->method('hasUnsavedChanges')->with($this->document)->willReturn(false); + $this->setupManager->expects(self::once())->method('tearDown'); + $cleaned = false; + $this->rootFolder->expects(self::once()) + ->method('getFirstNodeById')->with(123) + ->willReturnCallback(function () use (&$cleaned): ?Node { + $cleaned = true; + return null; + }); + $this->documentService->expects(self::once()) + ->method('markAttachmentCleanup')->with($this->document) + ->willReturnCallback(function () use (&$cleaned): void { + self::assertTrue($cleaned); + }); + $this->sessionService->expects(self::once())->method('removeInactiveSessionsWithoutSteps'); + $this->sessionService->expects(self::once())->method('removeOldSessions'); + $this->sessionService->expects(self::once())->method('removeOrphanedSteps'); + $this->documentService->expects(self::once())->method('cleanupOldDocumentsFolders'); + + $this->cleanup->run([]); + } + + public function testMarksUnsavedChangesWithoutCleanup(): void { + $this->documentService->expects(self::once()) + ->method('hasUnsavedChanges')->with($this->document)->willReturn(true); + $this->setupManager->expects(self::never())->method('tearDown'); + $this->rootFolder->expects(self::never())->method('getFirstNodeById'); + $this->documentService->expects(self::once())->method('markAttachmentCleanup')->with($this->document); + + $this->cleanup->run([]); + } + + public function testFailedCleanupRemainsPending(): void { + $this->documentService->method('hasUnsavedChanges')->willReturn(false); + $this->rootFolder->expects(self::once()) + ->method('getFirstNodeById')->with(123) + ->willThrowException(new \RuntimeException('Cleanup failed')); + $this->documentService->expects(self::never())->method('markAttachmentCleanup'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cleanup failed'); + + $this->cleanup->run([]); + } +} diff --git a/tests/unit/Db/DocumentMapperTest.php b/tests/unit/Db/DocumentMapperTest.php new file mode 100644 index 00000000000..6f87ddfe2cb --- /dev/null +++ b/tests/unit/Db/DocumentMapperTest.php @@ -0,0 +1,79 @@ +documentMapper = Server::get(DocumentMapper::class); + $this->sessionMapper = Server::get(SessionMapper::class); + $this->document = $this->documentMapper->insert(Document::fromParams([ + 'contextType' => 'file', + 'contextId' => 900001, + 'lastSavedVersionTime' => 1000, + 'lastSavedVersionEtag' => 'saved-etag', + 'baseVersionEtag' => 'base-etag', + ])); + } + + public function tearDown(): void { + $this->sessionMapper->deleteByDocumentId($this->document->getId()); + $this->documentMapper->delete($this->document); + parent::tearDown(); + } + + private function pendingDocumentIds(): array { + return array_map( + static fn (Document $document): string => $document->getId(), + iterator_to_array($this->documentMapper->findAllPendingAttachmentCleanup()), + ); + } + + public function testUnprocessedDocumentsArePending(): void { + self::assertSame(0, $this->documentMapper->find($this->document->getId())->getLastAttachmentCleanup()); + self::assertContains($this->document->getId(), $this->pendingDocumentIds()); + } + + public function testDocumentsWithSessionsAreExcluded(): void { + $session = $this->sessionMapper->insert(Session::fromParams([ + 'documentId' => $this->document->getId(), + 'userId' => 'admin', + 'token' => uniqid(), + 'lastContact' => time(), + ])); + self::assertNotContains($this->document->getId(), $this->pendingDocumentIds()); + $this->sessionMapper->delete($session); + self::assertContains($this->document->getId(), $this->pendingDocumentIds()); + } + + public function testCleanedDocumentsAreExcludedUntilSavedAgain(): void { + $this->document->setLastAttachmentCleanup(1000); + $this->documentMapper->update($this->document); + self::assertNotContains($this->document->getId(), $this->pendingDocumentIds()); + + $this->document->setLastSavedVersionTime(1001); + $this->documentMapper->update($this->document); + self::assertContains($this->document->getId(), $this->pendingDocumentIds()); + } + + public function testOlderSavedTimestampsAreExcluded(): void { + $this->document->setLastAttachmentCleanup(1001); + $this->documentMapper->update($this->document); + + self::assertNotContains($this->document->getId(), $this->pendingDocumentIds()); + } +} From 20e529fc6eafc0aa035c76bf882012d2e5529144 Mon Sep 17 00:00:00 2001 From: silver Date: Tue, 3 Nov 2026 14:13:34 +0100 Subject: [PATCH 2/3] fix(backport): adapt backport to stable34 Signed-off-by: silver Assisted-by: ClaudeCode:claude-opus-5.5 --- composer/composer/autoload_classmap.php | 1 + composer/composer/autoload_static.php | 1 + lib/Cron/Cleanup.php | 7 ++- lib/Db/Document.php | 1 + lib/Db/DocumentMapper.php | 1 - lib/Service/DocumentService.php | 7 --- tests/unit/CleanupTest.php | 6 +-- tests/unit/Db/DocumentMapperTest.php | 5 +- tests/unit/Service/DocumentServiceTest.php | 58 ++++++++++++++++++++++ 9 files changed, 70 insertions(+), 17 deletions(-) diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index 40b2567a2ae..cc96fdcf367 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -68,6 +68,7 @@ 'OCA\\Text\\Migration\\Version070000Date20250925110024' => $baseDir . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Migration\\Version080000Date20260331132113' => $baseDir . '/../lib/Migration/Version080000Date20260331132113.php', 'OCA\\Text\\Migration\\Version080000Date20261020120000' => $baseDir . '/../lib/Migration/Version080000Date20261020120000.php', + 'OCA\\Text\\Migration\\Version100000Date20261011000000' => $baseDir . '/../lib/Migration/Version100000Date20261011000000.php', 'OCA\\Text\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\AiTagService' => $baseDir . '/../lib/Service/AiTagService.php', 'OCA\\Text\\Service\\ApiService' => $baseDir . '/../lib/Service/ApiService.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index 9afce9d9961..ca595772429 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -83,6 +83,7 @@ class ComposerStaticInitText 'OCA\\Text\\Migration\\Version070000Date20250925110024' => __DIR__ . '/..' . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Migration\\Version080000Date20260331132113' => __DIR__ . '/..' . '/../lib/Migration/Version080000Date20260331132113.php', 'OCA\\Text\\Migration\\Version080000Date20261020120000' => __DIR__ . '/..' . '/../lib/Migration/Version080000Date20261020120000.php', + 'OCA\\Text\\Migration\\Version100000Date20261011000000' => __DIR__ . '/..' . '/../lib/Migration/Version100000Date20261011000000.php', 'OCA\\Text\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\AiTagService' => __DIR__ . '/..' . '/../lib/Service/AiTagService.php', 'OCA\\Text\\Service\\ApiService' => __DIR__ . '/..' . '/../lib/Service/ApiService.php', diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index 46f19377bc3..02ea67e5f29 100644 --- a/lib/Cron/Cleanup.php +++ b/lib/Cron/Cleanup.php @@ -37,9 +37,14 @@ public function __construct( */ protected function run($argument): void { $this->logger->debug('Run cleanup job for text documents'); - foreach ($this->documentService->getAllWithNoActiveSession() as $document) { + foreach ($this->documentService->getAllPendingAttachmentCleanup() as $document) { + if ($this->documentService->hasUnsavedChanges($document)) { + $this->documentService->markAttachmentCleanup($document); + continue; + } $this->setupManager->tearDown(); $this->attachmentService->cleanupAttachments($document->getId()); + $this->documentService->markAttachmentCleanup($document); } $this->logger->debug('Run cleanup job for text sessions'); diff --git a/lib/Db/Document.php b/lib/Db/Document.php index cd0e5f44163..778578fe6f3 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -42,6 +42,7 @@ public function __construct() { $this->addType('currentVersion', 'integer'); $this->addType('lastSavedVersion', 'integer'); $this->addType('lastSavedVersionTime', 'integer'); + $this->addType('lastAttachmentCleanup', 'integer'); $this->addType('initialVersion', 'integer'); $this->addType('checksum', 'string'); } diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php index 5a068ab17f2..caf52afe5b5 100644 --- a/lib/Db/DocumentMapper.php +++ b/lib/Db/DocumentMapper.php @@ -61,7 +61,6 @@ public function findAllPendingAttachmentCleanup(): Generator { ->leftJoin('d', 'text_sessions', 's', $qb->expr()->eq('s.document_id', 'd.id')) ->where($qb->expr()->isNull('s.id')) ->andWhere($qb->expr()->gt('d.last_saved_version_time', 'd.last_attachment_cleanup')) - ->andWhere($qb->expr()->eq('d.context_type', $qb->createNamedParameter('file'))) ->executeQuery(); try { while ($row = $result->fetch()) { diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index 883bdbc0c8c..0532c291be3 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -496,13 +496,6 @@ public function markAttachmentCleanup(Document $document): void { $this->documentMapper->update($document); } - public function hasUnsavedChanges(Document $document): bool { - if ($document->id === null) { - throw new RuntimeException('Document needs an id to check unsaved changes'); - } - return ($this->stepMapper->getLatestVersion($document->id) ?? 0) !== $document->getLastSavedVersion(); - } - /** * @throws NotPermittedException * @throws NotFoundException diff --git a/tests/unit/CleanupTest.php b/tests/unit/CleanupTest.php index 0c52542ac5d..4ba8a6d3805 100644 --- a/tests/unit/CleanupTest.php +++ b/tests/unit/CleanupTest.php @@ -11,7 +11,6 @@ use OCA\Text\Db\Document; use OCA\Text\Service\AttachmentService; use OCA\Text\Service\DocumentService; -use OCA\Text\Service\FileService; use OCA\Text\Service\SessionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\IFilenameValidator; @@ -37,9 +36,7 @@ class CleanupTest extends \PHPUnit\Framework\TestCase { public function setUp(): void { $this->document = Document::fromRow([ - 'id' => '123456789012345678', - 'context_type' => 'file', - 'context_id' => 123, + 'id' => 123, 'last_saved_version_time' => 1000, ]); $this->documentService = $this->createMock(DocumentService::class); @@ -53,7 +50,6 @@ public function setUp(): void { $this->createMock(IFilenameValidator::class), $this->createMock(IFilesMetadataManager::class), $this->createMock(ISession::class), - $this->createMock(FileService::class), ); $this->sessionService = $this->createMock(SessionService::class); $this->setupManager = $this->createMock(ISetupManager::class); diff --git a/tests/unit/Db/DocumentMapperTest.php b/tests/unit/Db/DocumentMapperTest.php index 6f87ddfe2cb..a18775f2ed7 100644 --- a/tests/unit/Db/DocumentMapperTest.php +++ b/tests/unit/Db/DocumentMapperTest.php @@ -22,8 +22,7 @@ public function setUp(): void { $this->documentMapper = Server::get(DocumentMapper::class); $this->sessionMapper = Server::get(SessionMapper::class); $this->document = $this->documentMapper->insert(Document::fromParams([ - 'contextType' => 'file', - 'contextId' => 900001, + 'id' => 900001, 'lastSavedVersionTime' => 1000, 'lastSavedVersionEtag' => 'saved-etag', 'baseVersionEtag' => 'base-etag', @@ -38,7 +37,7 @@ public function tearDown(): void { private function pendingDocumentIds(): array { return array_map( - static fn (Document $document): string => $document->getId(), + static fn (Document $document): int => $document->getId(), iterator_to_array($this->documentMapper->findAllPendingAttachmentCleanup()), ); } diff --git a/tests/unit/Service/DocumentServiceTest.php b/tests/unit/Service/DocumentServiceTest.php index dea1ddab808..0a317b0d3f8 100644 --- a/tests/unit/Service/DocumentServiceTest.php +++ b/tests/unit/Service/DocumentServiceTest.php @@ -2,6 +2,7 @@ namespace OCA\Text\Tests; +use OCA\Text\Db\Document; use OCA\Text\Db\DocumentMapper; use OCA\Text\Db\Session; use OCA\Text\Db\SessionMapper; @@ -73,6 +74,63 @@ public function setUp(): void { ); } + private function createDocument(): Document { + return Document::fromRow([ + 'id' => 1, + 'last_saved_version' => 3, + 'last_saved_version_time' => 1000, + ]); + } + + public function testGetAllPendingAttachmentCleanup(): void { + $document = $this->createDocument(); + $documents = (static function () use ($document): \Generator { + yield $document; + })(); + $this->documentMapper->expects(self::once()) + ->method('findAllPendingAttachmentCleanup')->willReturn($documents); + + self::assertSame($documents, $this->documentService->getAllPendingAttachmentCleanup()); + } + + public function testMarksOnlyTheProcessedSavedTimestamp(): void { + $document = $this->createDocument(); + $document->resetUpdatedFields(); + $this->documentMapper->expects(self::once()) + ->method('update')->with($document)->willReturn($document); + + $this->documentService->markAttachmentCleanup($document); + + self::assertSame(1000, $document->getLastAttachmentCleanup()); + self::assertSame(1000, $document->getLastSavedVersionTime()); + self::assertSame(['lastAttachmentCleanup' => true], $document->getUpdatedFields()); + } + + public function testDetectsUnsavedChanges(): void { + $document = $this->createDocument(); + $this->setpMapper->expects(self::once()) + ->method('getLatestVersion')->with(1)->willReturn(4); + + self::assertTrue($this->documentService->hasUnsavedChanges($document)); + } + + public function testSavedStepsHaveNoUnsavedChanges(): void { + $document = $this->createDocument(); + $this->setpMapper->expects(self::once()) + ->method('getLatestVersion')->with(1)->willReturn(3); + + self::assertFalse($this->documentService->hasUnsavedChanges($document)); + } + + public function testDocumentWithoutStepsHasNoUnsavedChanges(): void { + $document = $this->createDocument(); + $document->setLastSavedVersion(0); + $this->setpMapper->expects(self::once()) + ->method('getLatestVersion')->with(1)->willReturn(null); + + self::assertFalse($this->documentService->hasUnsavedChanges($document)); + } + public function testGetFileById() { $userFolder = $this->createMock(Folder::class); $this->rootFolder->method('getUserFolder')->willReturn($userFolder); From 28dea967fb1f9f51a78c8c143744907840999559 Mon Sep 17 00:00:00 2001 From: silver Date: Tue, 3 Nov 2026 12:39:28 +0100 Subject: [PATCH 3/3] fix(cron): do not abort cleanup job when attachments cannot be deleted Signed-off-by: silver Assisted-by: ClaudeCode:claude-opus-5.5 (cherry picked from commit 322ddeb09a152e3bfae674f0ef2a414e6cd439b7) --- lib/Cron/Cleanup.php | 8 +++++++- tests/unit/CleanupTest.php | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index 02ea67e5f29..5ed120a7773 100644 --- a/lib/Cron/Cleanup.php +++ b/lib/Cron/Cleanup.php @@ -17,6 +17,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use OCP\Files\ISetupManager; +use OCP\Files\NotPermittedException; use Psr\Log\LoggerInterface; class Cleanup extends TimedJob { @@ -43,7 +44,12 @@ protected function run($argument): void { continue; } $this->setupManager->tearDown(); - $this->attachmentService->cleanupAttachments($document->getId()); + try { + $this->attachmentService->cleanupAttachments($document->getId()); + } catch (NotPermittedException $e) { + // Retrying will not help until the document is saved again, so mark it as cleaned up anyway + $this->logger->warning('Could not clean up attachments of file ' . $document->getId() . ', deleting is not permitted', ['exception' => $e]); + } $this->documentService->markAttachmentCleanup($document); } diff --git a/tests/unit/CleanupTest.php b/tests/unit/CleanupTest.php index 4ba8a6d3805..75005bd35bd 100644 --- a/tests/unit/CleanupTest.php +++ b/tests/unit/CleanupTest.php @@ -18,6 +18,7 @@ use OCP\Files\IRootFolder; use OCP\Files\ISetupManager; use OCP\Files\Node; +use OCP\Files\NotPermittedException; use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IPreview; use OCP\ISession; @@ -33,6 +34,7 @@ class CleanupTest extends \PHPUnit\Framework\TestCase { private SessionService $sessionService; private IRootFolder $rootFolder; private ISetupManager $setupManager; + private LoggerInterface $logger; public function setUp(): void { $this->document = Document::fromRow([ @@ -53,13 +55,14 @@ public function setUp(): void { ); $this->sessionService = $this->createMock(SessionService::class); $this->setupManager = $this->createMock(ISetupManager::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->documentService->expects(self::never())->method('resetDocument'); $this->documentService->expects(self::once()) ->method('getAllPendingAttachmentCleanup') ->willReturn((function (): \Generator { yield $this->document; })()); - $this->cleanup = new class($this->createMock(ITimeFactory::class), $this->sessionService, $this->documentService, $this->attachmentService, $this->createMock(LoggerInterface::class), $this->setupManager, ) extends Cleanup { + $this->cleanup = new class($this->createMock(ITimeFactory::class), $this->sessionService, $this->documentService, $this->attachmentService, $this->logger, $this->setupManager, ) extends Cleanup { public function run($argument): void { parent::run($argument); } @@ -111,4 +114,19 @@ public function testFailedCleanupRemainsPending(): void { $this->cleanup->run([]); } + + public function testNotPermittedCleanupIsMarkedAndJobContinues(): void { + $this->documentService->method('hasUnsavedChanges')->willReturn(false); + $this->rootFolder->expects(self::once()) + ->method('getFirstNodeById')->with(123) + ->willThrowException(new NotPermittedException()); + $this->logger->expects(self::once())->method('warning'); + $this->documentService->expects(self::once())->method('markAttachmentCleanup')->with($this->document); + $this->sessionService->expects(self::once())->method('removeInactiveSessionsWithoutSteps'); + $this->sessionService->expects(self::once())->method('removeOldSessions'); + $this->sessionService->expects(self::once())->method('removeOrphanedSteps'); + $this->documentService->expects(self::once())->method('cleanupOldDocumentsFolders'); + + $this->cleanup->run([]); + } }