Files
newznab-tmux-NNTmux/tests/Unit/AdditionalProcessing/ReleaseProcessorTest.php
T
2026-08-11 10:43:22 +02:00

464 lines
20 KiB
PHP

<?php
namespace Tests\Unit\AdditionalProcessing;
use App\Models\Release;
use App\Services\AdditionalProcessing\AdditionalWorkPlanner;
use App\Services\AdditionalProcessing\ArchiveExtractionService;
use App\Services\AdditionalProcessing\ConsoleOutputService;
use App\Services\AdditionalProcessing\DTO\DownloadMetrics;
use App\Services\AdditionalProcessing\Enums\DownloadKind;
use App\Services\AdditionalProcessing\Enums\ProcessingOutcome;
use App\Services\AdditionalProcessing\Enums\ProcessingStage;
use App\Services\AdditionalProcessing\MediaExtractionService;
use App\Services\AdditionalProcessing\NzbContentParser;
use App\Services\AdditionalProcessing\ReleaseFileManager;
use App\Services\AdditionalProcessing\ReleaseFilesArchiveFallback;
use App\Services\AdditionalProcessing\ReleaseProcessor;
use App\Services\AdditionalProcessing\ReleaseSearchSyncCoordinator;
use App\Services\AdditionalProcessing\State\PersistenceMetricsCollector;
use App\Services\AdditionalProcessing\State\ReleaseProcessingContext;
use App\Services\AdditionalProcessing\UsenetDownloadService;
use App\Services\Releases\ReleaseBrowseService;
use App\Services\TempWorkspaceService;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
class ReleaseProcessorTest extends TestCase
{
use CreatesProcessingConfiguration;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_deletes_release_when_nzb_parsing_fails(): void
{
$processor = $this->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' => ['<message>']]];
$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, ['<archive>'], '', 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' => ['<archive>']]],
]);
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();
}
}