makeProcessor( nzbParser: Mockery::mock(NzbContentParser::class) ->shouldReceive('parseNzb')->once()->with('guid-1')->andReturn(['error' => 'broken nzb', 'contents' => []])->getMock(), releaseManager: Mockery::mock(ReleaseFileManager::class) ->shouldReceive('deleteRelease')->once()->andReturnNull()->getMock(), tempWorkspace: Mockery::mock(TempWorkspaceService::class) ->shouldReceive('createReleaseTempFolder')->once()->andReturn('/tmp/ap-release/') ->shouldReceive('clearDirectory')->once()->with('/tmp/ap-release/', false)->andReturnNull()->getMock(), output: Mockery::mock(ConsoleOutputService::class) ->shouldReceive('echoReleaseStart')->once()->andReturnNull() ->shouldReceive('setProcessTitle')->once()->andReturnNull() ->shouldReceive('warning')->once()->with('broken nzb')->andReturnNull()->getMock() ); $result = $processor->process($this->makeContext(), '/tmp/main/'); $this->assertSame(ProcessingOutcome::DeletedBrokenNzb, $result->outcome); $this->assertTrue($result->outcome->isDeleted()); $this->assertFalse($result->isSuccessful()); $this->assertSame('broken nzb', $result->reason); $this->assertGreaterThan(0.0, $result->elapsedSeconds); $this->assertArrayHasKey(ProcessingStage::WorkspacePreparation->value, $result->stageDurations); $this->assertArrayHasKey(ProcessingStage::NzbParsing->value, $result->stageDurations); $this->assertArrayHasKey(ProcessingStage::WorkspaceCleanup->value, $result->stageDurations); } #[Test] public function it_finalizes_a_release_after_basic_successful_processing(): void { $config = $this->makeConfig(); $nzbParser = Mockery::mock(NzbContentParser::class); $nzbParser->shouldReceive('parseNzb')->once()->with('guid-1')->andReturn([ 'error' => null, 'contents' => [['title' => 'file.nzb', 'segments' => []]], ]); $releaseManager = Mockery::mock(ReleaseFileManager::class); $releaseManager->shouldReceive('processReleaseNameFromNzbContents')->once()->andReturnFalse(); $persistenceMetrics = new PersistenceMetricsCollector; $synchronizedReleaseIds = []; $searchSyncCoordinator = new ReleaseSearchSyncCoordinator( $persistenceMetrics, static function (int $releaseId) use (&$synchronizedReleaseIds): void { $synchronizedReleaseIds[] = $releaseId; }, ); $releaseManager->shouldReceive('finalizeRelease') ->once() ->andReturnUsing(static function () use ($searchSyncCoordinator): void { $searchSyncCoordinator->request(1); $searchSyncCoordinator->request(1); }); $tempWorkspace = Mockery::mock(TempWorkspaceService::class); $tempWorkspace->shouldReceive('createReleaseTempFolder')->once()->andReturn('/tmp/ap-release/'); $tempWorkspace->shouldReceive('clearDirectory')->once()->with('/tmp/ap-release/', false)->andReturnNull(); $output = Mockery::mock(ConsoleOutputService::class); $output->shouldReceive('echoReleaseStart')->once()->andReturnNull(); $output->shouldReceive('setProcessTitle')->once()->andReturnNull(); $processor = new ReleaseProcessor( $config, $nzbParser, new AdditionalWorkPlanner($config), Mockery::mock(ArchiveExtractionService::class), Mockery::mock(MediaExtractionService::class), $this->scopedDownloadService(), $releaseManager, Mockery::mock(ReleaseFilesArchiveFallback::class), $tempWorkspace, $output, $searchSyncCoordinator, $persistenceMetrics, ); $context = $this->makeContext(); $context->release->nfostatus = 1; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::NoUsefulArtifacts, $result->outcome); $this->assertTrue($result->isSuccessful()); $this->assertFalse($result->artifactsCreated); $this->assertSame('Processing completed without creating useful artifacts.', $result->reason); $this->assertSame(0, $result->releaseFilesAdded); $this->assertSame([1], $synchronizedReleaseIds); $this->assertSame(2, $result->persistenceMetrics?->searchSyncRequests); $this->assertSame(1, $result->persistenceMetrics?->searchSyncExecutions); $this->assertGreaterThan(0.0, $result->elapsedSeconds); $this->assertSame([ ProcessingStage::WorkspacePreparation->value, ProcessingStage::NzbParsing->value, ProcessingStage::ReleaseInitialization->value, ProcessingStage::MessageIdSelection->value, ProcessingStage::Finalization->value, ProcessingStage::WorkspaceCleanup->value, ], array_keys($result->stageDurations)); } #[Test] public function it_always_uses_the_v2_work_plan_for_message_id_selection(): void { $config = $this->makeConfig(); $nzbContents = [['title' => 'file.nzb', 'segments' => ['']]]; $nzbParser = Mockery::mock(NzbContentParser::class); $nzbParser->shouldReceive('parseNzb')->once()->with('guid-1')->andReturn([ 'error' => null, 'contents' => $nzbContents, ]); $nzbParser->shouldNotReceive('extractMessageIDs'); $releaseManager = Mockery::mock(ReleaseFileManager::class); $releaseManager->shouldReceive('processReleaseNameFromNzbContents')->once()->andReturnFalse(); $releaseManager->shouldReceive('finalizeRelease')->once()->andReturnNull(); $processor = new ReleaseProcessor( $config, $nzbParser, new AdditionalWorkPlanner($config), Mockery::mock(ArchiveExtractionService::class), Mockery::mock(MediaExtractionService::class), $this->scopedDownloadService(), $releaseManager, Mockery::mock(ReleaseFilesArchiveFallback::class), $this->successfulTempWorkspace(), Mockery::mock(ConsoleOutputService::class) ->shouldReceive('echoReleaseStart')->once()->andReturnNull() ->shouldReceive('setProcessTitle')->once()->andReturnNull() ->getMock(), ); $context = $this->makeContext(); $context->release->nfostatus = 1; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::NoUsefulArtifacts, $result->outcome); $this->assertNotNull($context->workPlan); $this->assertSame(0, $result->duplicateMessageIdCount); } #[Test] public function it_marks_release_timeout_before_full_processing_continues(): void { $config = $this->makeConfig(['releaseProcessingTimeout' => 1, 'maxPpTimeoutCount' => 2]); $nzbParser = Mockery::mock(NzbContentParser::class); $nzbParser->shouldReceive('parseNzb')->once()->with('guid-1')->andReturn([ 'error' => null, 'contents' => [], ]); $releaseManager = Mockery::mock(ReleaseFileManager::class); $releaseManager->shouldReceive('handleReleaseTimeout')->once()->andReturn(false); $releaseManager->shouldNotReceive('finalizeRelease'); $tempWorkspace = Mockery::mock(TempWorkspaceService::class); $tempWorkspace->shouldReceive('createReleaseTempFolder')->once()->andReturn('/tmp/ap-release/'); $tempWorkspace->shouldReceive('clearDirectory')->twice()->with('/tmp/ap-release/', false)->andReturnNull(); $output = Mockery::mock(ConsoleOutputService::class); $output->shouldReceive('echoReleaseStart')->once()->andReturnNull(); $output->shouldReceive('setProcessTitle')->once()->andReturnNull(); $output->shouldReceive('echoReleaseTimeout')->once()->andReturnNull(); $processor = new ReleaseProcessor( $config, $nzbParser, new AdditionalWorkPlanner($config), Mockery::mock(ArchiveExtractionService::class), Mockery::mock(MediaExtractionService::class), $this->scopedDownloadService(), $releaseManager, Mockery::mock(ReleaseFilesArchiveFallback::class), $tempWorkspace, $output ); $context = $this->makeContext(); $context->startTime = hrtime(true) - 2_000_000_000; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::TimedOut, $result->outcome); $this->assertFalse($result->isSuccessful()); $this->assertStringContainsString('exceeded', $result->reason); $this->assertArrayHasKey(ProcessingStage::TimeoutHandling->value, $result->stageDurations); } #[Test] public function it_reports_a_deleted_timeout_separately(): void { $config = $this->makeConfig(['releaseProcessingTimeout' => 1, 'maxPpTimeoutCount' => 1]); $nzbParser = Mockery::mock(NzbContentParser::class); $nzbParser->shouldReceive('parseNzb')->once()->andReturn(['error' => null, 'contents' => []]); $releaseManager = Mockery::mock(ReleaseFileManager::class); $releaseManager->shouldReceive('handleReleaseTimeout')->once()->andReturn(true); $releaseManager->shouldNotReceive('finalizeRelease'); $tempWorkspace = Mockery::mock(TempWorkspaceService::class); $tempWorkspace->shouldReceive('createReleaseTempFolder')->once()->andReturn('/tmp/ap-release/'); $tempWorkspace->shouldReceive('clearDirectory')->twice()->with('/tmp/ap-release/', false)->andReturnNull(); $output = Mockery::mock(ConsoleOutputService::class); $output->shouldReceive('echoReleaseStart')->once()->andReturnNull(); $output->shouldReceive('setProcessTitle')->once()->andReturnNull(); $output->shouldReceive('echoReleaseTimeoutDeleted')->once()->andReturnNull(); $processor = new ReleaseProcessor( $config, $nzbParser, new AdditionalWorkPlanner($config), Mockery::mock(ArchiveExtractionService::class), Mockery::mock(MediaExtractionService::class), $this->scopedDownloadService(), $releaseManager, Mockery::mock(ReleaseFilesArchiveFallback::class), $tempWorkspace, $output ); $context = $this->makeContext(); $context->startTime = hrtime(true) - 2_000_000_000; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::DeletedAfterTimeout, $result->outcome); $this->assertTrue($result->outcome->isDeleted()); $this->assertFalse($result->isSuccessful()); } #[Test] public function it_reports_a_temporary_workspace_failure(): void { $tempWorkspace = Mockery::mock(TempWorkspaceService::class); $tempWorkspace->shouldReceive('createReleaseTempFolder') ->once() ->andThrow(new \RuntimeException('workspace is not writable')); $output = Mockery::mock(ConsoleOutputService::class); $output->shouldReceive('echoReleaseStart')->once()->andReturnNull(); $output->shouldReceive('setProcessTitle')->once()->andReturnNull(); $output->shouldReceive('warning')->once()->with('Unable to prepare release temp directory: workspace is not writable')->andReturnNull(); $processor = $this->makeProcessor(tempWorkspace: $tempWorkspace, output: $output); $context = $this->makeContext(); $context->release->nfostatus = 1; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::TemporaryWorkspaceUnavailable, $result->outcome); $this->assertFalse($result->isSuccessful()); $this->assertSame('workspace is not writable', $result->reason); } #[Test] public function it_reports_password_detection_as_a_successful_outcome(): void { $config = $this->makeConfig(['processPasswords' => true]); $nzbParser = $this->compressedNzbParser(); $archiveService = Mockery::mock(ArchiveExtractionService::class); $archiveService->shouldReceive('processCompressedData')->once()->andReturn([ 'success' => false, 'files' => [], 'hasPassword' => true, 'passwordStatus' => ReleaseBrowseService::PASSWD_RAR, ]); $downloadService = Mockery::mock(UsenetDownloadService::class); $this->expectDownloadScope($downloadService); $downloadService->shouldReceive('download') ->once() ->with(DownloadKind::Compressed, [''], '', 1, 'archive.part01.rar') ->andReturn(['success' => true, 'data' => 'ARCHIVE', 'groupUnavailable' => false, 'error' => null]); $releaseManager = Mockery::mock(ReleaseFileManager::class); $releaseManager->shouldReceive('processReleaseNameFromNzbContents')->once()->andReturnFalse(); $releaseManager->shouldReceive('finalizeRelease')->once()->andReturnNull(); $processor = new ReleaseProcessor( $config, $nzbParser, new AdditionalWorkPlanner($config), $archiveService, Mockery::mock(MediaExtractionService::class), $downloadService, $releaseManager, Mockery::mock(ReleaseFilesArchiveFallback::class), $this->successfulTempWorkspace(), $this->passwordOutput() ); $context = $this->makeContext(); $context->release->nfostatus = 1; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::Passworded, $result->outcome); $this->assertTrue($result->isSuccessful()); $this->assertFalse($result->artifactsCreated); } #[Test] public function it_reports_group_unavailability_as_an_unsuccessful_outcome(): void { $config = $this->makeConfig(['processPasswords' => true]); $nzbParser = $this->compressedNzbParser(); $downloadService = Mockery::mock(UsenetDownloadService::class); $this->expectDownloadScope($downloadService); $downloadService->shouldReceive('download')->once()->andReturn([ 'success' => false, 'data' => null, 'groupUnavailable' => true, 'error' => 'Group unavailable', ]); $releaseManager = Mockery::mock(ReleaseFileManager::class); $releaseManager->shouldReceive('processReleaseNameFromNzbContents')->once()->andReturnFalse(); $releaseManager->shouldReceive('finalizeRelease')->once()->andReturnNull(); $output = Mockery::mock(ConsoleOutputService::class); $output->shouldReceive('echoReleaseStart')->once()->andReturnNull(); $output->shouldReceive('setProcessTitle')->once()->andReturnNull(); $output->shouldReceive('echoGroupUnavailable')->once()->andReturnNull(); $processor = new ReleaseProcessor( $config, $nzbParser, new AdditionalWorkPlanner($config), Mockery::mock(ArchiveExtractionService::class), Mockery::mock(MediaExtractionService::class), $downloadService, $releaseManager, Mockery::mock(ReleaseFilesArchiveFallback::class), $this->successfulTempWorkspace(), $output ); $context = $this->makeContext(); $context->release->nfostatus = 1; $result = $processor->process($context, '/tmp/main/'); $this->assertSame(ProcessingOutcome::GroupUnavailable, $result->outcome); $this->assertFalse($result->isSuccessful()); $this->assertStringContainsString('unavailable', $result->reason); } private function makeProcessor( ?NzbContentParser $nzbParser = null, ?ReleaseFileManager $releaseManager = null, ?TempWorkspaceService $tempWorkspace = null, ?ConsoleOutputService $output = null ): ReleaseProcessor { $config = $this->makeConfig(); return new ReleaseProcessor( $config, $nzbParser ?? Mockery::mock(NzbContentParser::class), new AdditionalWorkPlanner($config), Mockery::mock(ArchiveExtractionService::class), Mockery::mock(MediaExtractionService::class), $this->scopedDownloadService(), $releaseManager ?? Mockery::mock(ReleaseFileManager::class), Mockery::mock(ReleaseFilesArchiveFallback::class), $tempWorkspace ?? Mockery::mock(TempWorkspaceService::class), $output ?? Mockery::mock(ConsoleOutputService::class) ); } private function makeContext(): ReleaseProcessingContext { return new ReleaseProcessingContext(new Release([ 'id' => 1, 'guid' => 'guid-1', 'size' => 1024, 'groups_id' => 10, 'nfostatus' => -1, 'pp_timeout_count' => 0, ])); } private function compressedNzbParser(): NzbContentParser { $parser = Mockery::mock(NzbContentParser::class); $parser->shouldReceive('parseNzb')->once()->andReturn([ 'error' => null, 'contents' => [['title' => 'archive.part01.rar', 'segments' => ['']]], ]); return $parser; } private function scopedDownloadService(): UsenetDownloadService { $downloadService = Mockery::mock(UsenetDownloadService::class); $this->expectDownloadScope($downloadService); return $downloadService; } private function expectDownloadScope(UsenetDownloadService $downloadService): void { $downloadService->shouldReceive('beginReleaseScope')->once()->andReturnNull(); $downloadService->shouldReceive('finishReleaseScope')->once()->andReturn(new DownloadMetrics); } private function successfulTempWorkspace(): TempWorkspaceService { return Mockery::mock(TempWorkspaceService::class) ->shouldReceive('createReleaseTempFolder')->once()->andReturn('/tmp/ap-release/') ->shouldReceive('clearDirectory')->once()->with('/tmp/ap-release/', false)->andReturnNull() ->getMock(); } private function passwordOutput(): ConsoleOutputService { return Mockery::mock(ConsoleOutputService::class) ->shouldReceive('echoReleaseStart')->once()->andReturnNull() ->shouldReceive('setProcessTitle')->once()->andReturnNull() ->shouldReceive('echoCompressedDownload')->once()->andReturnNull() ->getMock(); } }