diff --git a/lib/BackgroundJob/SyncSubmissionsWithLinkedFileJob.php b/lib/BackgroundJob/SyncSubmissionsWithLinkedFileJob.php new file mode 100644 index 000000000..32326843c --- /dev/null +++ b/lib/BackgroundJob/SyncSubmissionsWithLinkedFileJob.php @@ -0,0 +1,98 @@ + + * + * @author Andrii Ilkiv + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Forms\BackgroundJob; + +use OCA\Forms\Db\FormMapper; +use OCA\Forms\Service\FormsService; +use OCA\Forms\Service\SubmissionService; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\Files\NotFoundException; + +use Psr\Log\LoggerInterface; +use Throwable; + +class SyncSubmissionsWithLinkedFileJob extends QueuedJob { + public const MAX_ATTEMPTS = 10; + + public function __construct( + private FormMapper $formMapper, + private FormsService $formsService, + private SubmissionService $submissionService, + ITimeFactory $time, + private LoggerInterface $logger, + private IJobList $jobList, + ) { + parent::__construct($time); + } + + /** + * @param array $argument + */ + public function run($argument): void { + $formId = $argument['form_id']; + $attempt = $argument['attempt'] ?: 1; + + try { + $form = $this->formMapper->findById($formId); + + $filePath = $this->formsService->getFilePath($form); + $fileFormat = $form->getFileFormat(); + $ownerId = $form->getOwnerId(); + + $this->submissionService->writeFileToCloud($form, $filePath, $fileFormat, $ownerId); + } catch (NotFoundException $e) { + $this->logger->notice('Form {formId} linked to a file that doesn\'t exist anymore', [ + 'formId' => $formId + ]); + } catch (Throwable $e) { + $this->logger->warning( + 'Failed to synchronize form {formId} with the file (attempt {attempt} of {maxAttempts}), reason: {message}', + [ + 'formId' => $formId, + 'message' => $e->getMessage(), + 'attempt' => $attempt, + 'maxAttempts' => self::MAX_ATTEMPTS, + ] + ); + + if ($attempt < self::MAX_ATTEMPTS) { + $this->jobList->scheduleAfter( + SyncSubmissionsWithLinkedFileJob::class, + $this->nextAttempt($attempt), + ['form_id' => $formId, 'attempt' => $attempt + 1] + ); + } + } + } + + /** + * Calculates exponential delay (cubic growth) in seconds. + */ + private function nextAttempt(int $numberOfAttempt): int { + return $this->time->getTime() + pow($numberOfAttempt, 3) * 60; + } +} diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 00f08d73d..8dcdf8d37 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -30,6 +30,7 @@ namespace OCA\Forms\Controller; +use OCA\Forms\BackgroundJob\SyncSubmissionsWithLinkedFileJob; use OCA\Forms\Constants; use OCA\Forms\Db\Answer; use OCA\Forms\Db\AnswerMapper; @@ -62,9 +63,9 @@ use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; +use OCP\BackgroundJob\IJobList; use OCP\Files\IMimeTypeDetector; use OCP\Files\IRootFolder; -use OCP\Files\NotFoundException; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; @@ -95,6 +96,7 @@ public function __construct( private IRootFolder $rootFolder, private UploadedFileMapper $uploadedFileMapper, private IMimeTypeDetector $mimeTypeDetector, + private IJobList $jobList, ) { parent::__construct($appName, $request); $this->currentUser = $userSession->getUser(); @@ -1194,17 +1196,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' $this->formsService->notifyNewSubmission($form, $submission); if ($form->getFileId() !== null) { - try { - $filePath = $this->formsService->getFilePath($form); - $fileFormat = $form->getFileFormat(); - $ownerId = $form->getOwnerId(); - - $this->submissionService->writeFileToCloud($form, $filePath, $fileFormat, $ownerId); - } catch (NotFoundException $e) { - $this->logger->notice('Form {formId} linked to a file that doesn\'t exist anymore', [ - 'formId' => $formId - ]); - } + $this->jobList->add(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => $form->getId()]); } return new DataResponse(); diff --git a/tests/Unit/BackgroundJob/SyncSubmissionsWithLinkedFileJobTest.php b/tests/Unit/BackgroundJob/SyncSubmissionsWithLinkedFileJobTest.php new file mode 100644 index 000000000..8c2faf9a6 --- /dev/null +++ b/tests/Unit/BackgroundJob/SyncSubmissionsWithLinkedFileJobTest.php @@ -0,0 +1,194 @@ + + * + * @author Andrii Ilkiv + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Forms\Tests\Unit\BackgroundJob; + +use Exception; + +use OCA\Forms\BackgroundJob\SyncSubmissionsWithLinkedFileJob; +use OCA\Forms\Db\Form; +use OCA\Forms\Db\FormMapper; +use OCA\Forms\Service\FormsService; +use OCA\Forms\Service\SubmissionService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; + +use OCP\Files\NotFoundException; +use Psr\Log\LoggerInterface; + +use Test\TestCase; + +class SyncSubmissionsWithLinkedFileJobTest extends TestCase { + private SyncSubmissionsWithLinkedFileJob $job; + + /** @var FormMapper|MockObject */ + private $formMapper; + + /** @var FormsService|MockObject */ + private $formsService; + + /** @var SubmissionService|MockObject */ + private $submissionService; + + /** @var ITimeFactory|MockObject */ + private $timeFactory; + + /** @var LoggerInterface|MockObject */ + private $logger; + + /** @var IJobList|MockObject */ + private $jobList; + + protected function setUp(): void { + parent::setUp(); + + $this->formMapper = $this->createMock(FormMapper::class); + $this->formsService = $this->createMock(FormsService::class); + $this->submissionService = $this->createMock(SubmissionService::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->jobList = $this->createMock(IJobList::class); + + $this->job = new SyncSubmissionsWithLinkedFileJob( + $this->formMapper, + $this->formsService, + $this->submissionService, + $this->timeFactory, + $this->logger, + $this->jobList + ); + } + + public function testRunSuccessfulSync(): void { + $formId = 1; + $argument = ['form_id' => $formId, 'attempt' => 1]; + $form = $this->getForm($formId); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with($formId) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('getFilePath') + ->with($form) + ->willReturn('some/file/path'); + + $this->submissionService->expects($this->once()) + ->method('writeFileToCloud') + ->with($form, 'some/file/path', $this->anything(), $this->anything()); + + $this->job->run($argument); + } + + public function testRunNotFoundException(): void { + $formId = 1; + $argument = ['form_id' => $formId, 'attempt' => 1]; + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with($formId) + ->willThrowException(new NotFoundException('Test exception')); + + $this->logger->expects($this->once()) + ->method('notice') + ->with('Form {formId} linked to a file that doesn\'t exist anymore', ['formId' => $formId]); + + $this->job->run($argument); + } + + public function testRunThrowableException(): void { + $formId = 1; + $argument = ['form_id' => $formId, 'attempt' => 1]; + $form = $this->getForm($formId); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with($formId) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('getFilePath') + ->willReturn('some/file/path'); + + $this->submissionService->expects($this->once()) + ->method('writeFileToCloud') + ->willThrowException(new \Exception('Test exception')); + + $this->logger->expects($this->once()) + ->method('warning') + ->with( + 'Failed to synchronize form {formId} with the file (attempt {attempt} of {maxAttempts}), reason: {message}', + [ + 'formId' => $formId, + 'message' => 'Test exception', + 'attempt' => 1, + 'maxAttempts' => SyncSubmissionsWithLinkedFileJob::MAX_ATTEMPTS + ] + ); + + $this->jobList->expects($this->once()) + ->method('scheduleAfter') + ->with( + SyncSubmissionsWithLinkedFileJob::class, + $this->anything(), + ['form_id' => $formId, 'attempt' => 2] + ); + + $this->job->run($argument); + } + + public function testMaxAttemptsReached(): void { + $formId = 1; + $argument = ['form_id' => $formId, 'attempt' => SyncSubmissionsWithLinkedFileJob::MAX_ATTEMPTS]; + $form = $this->getForm($formId); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with($formId) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('getFilePath') + ->willReturn('some/file/path'); + + $this->submissionService->expects($this->once()) + ->method('writeFileToCloud') + ->willThrowException(new Exception('Test exception')); + + $this->jobList->expects($this->never())->method('add'); + + $this->job->run($argument); + } + + private function getForm(int $formId): Form { + $form = new Form(); + $form->setId($formId); + $form->setFileFormat('csv'); + $form->setOwnerId('owner_name'); + + return $form; + } +} diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index fa27ba42d..8ca2b5689 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -47,6 +47,7 @@ function is_uploaded_file(string|bool|null $filename) { namespace OCA\Forms\Tests\Unit\Controller; +use OCA\Forms\BackgroundJob\SyncSubmissionsWithLinkedFileJob; use OCA\Forms\Constants; use OCA\Forms\Controller\ApiController; use OCA\Forms\Db\AnswerMapper; @@ -68,6 +69,7 @@ function is_uploaded_file(string|bool|null $filename) { use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\BackgroundJob\IJobList; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IMimeTypeDetector; @@ -120,6 +122,8 @@ class ApiControllerTest extends TestCase { private $uploadedFileMapper; /** @var IMimeTypeDetector|MockObject */ private $mimeTypeDetector; + /** @var IJobList|MockObject */ + private $jobList; public function setUp(): void { $this->answerMapper = $this->createMock(AnswerMapper::class); @@ -144,6 +148,7 @@ public function setUp(): void { $this->storage = $this->createMock(IRootFolder::class); $this->uploadedFileMapper = $this->createMock(UploadedFileMapper::class); $this->mimeTypeDetector = $this->createMock(IMimeTypeDetector::class); + $this->jobList = $this->createMock(IJobList::class); $this->apiController = new ApiController( 'forms', @@ -164,6 +169,7 @@ public function setUp(): void { $this->storage, $this->uploadedFileMapper, $this->mimeTypeDetector, + $this->jobList, ); } @@ -428,6 +434,7 @@ public function testCreateNewForm($expectedForm) { $this->storage, $this->uploadedFileMapper, $this->mimeTypeDetector, + $this->jobList, ])->getMock(); $this->configService->expects($this->once()) @@ -589,6 +596,7 @@ public function testCloneForm($old, $new) { $this->storage, $this->uploadedFileMapper, $this->mimeTypeDetector, + $this->jobList, ]) ->getMock(); @@ -787,13 +795,9 @@ public function testNewSubmission_answers() { $this->formsService->expects($this->once()) ->method('notifyNewSubmission'); - $this->formsService->expects($this->once()) - ->method('getFilePath') - ->willReturn('foo/bar'); - - $this->submissionService->expects($this->once()) - ->method('writeFileToCloud') - ->with($form, 'foo/bar', 'xlsx', 'admin'); + $this->jobList->expects($this->once()) + ->method('add') + ->with(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => 1]); $userFolder = $this->createMock(Folder::class); $userFolder->expects($this->once())