*/ private array $originalEnvironment = []; public function createApplication() { $this->databasePath = sys_get_temp_dir().'/nntmux-release-file-manager-test.sqlite'; $this->originalEnvironment = [ 'APP_ENV' => getenv('APP_ENV'), 'DB_CONNECTION' => getenv('DB_CONNECTION'), 'DB_DATABASE' => getenv('DB_DATABASE'), ]; if (file_exists($this->databasePath)) { unlink($this->databasePath); } $pdo = new PDO('sqlite:'.$this->databasePath); $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); $pdo->exec("INSERT INTO settings (name, value) VALUES ('categorizeforeign', '0'), ('catwebdl', '0')"); $this->setEnvironmentValue('APP_ENV', 'testing'); $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); $app = require __DIR__.'/../../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } protected function setUp(): void { parent::setUp(); config([ 'database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath, ]); DB::purge(); DB::reconnect(); $this->createSchema(); } protected function tearDown(): void { Mockery::close(); if ($this->databasePath !== '' && file_exists($this->databasePath)) { unlink($this->databasePath); } parent::tearDown(); foreach ($this->originalEnvironment as $key => $value) { $this->setEnvironmentValue($key, $value === false ? null : $value); } } public function test_release_file_rows_are_deduped_and_flushed_once_at_finalize(): void { DB::table('releases')->insert($this->releaseRow()); Search::shouldReceive('updateRelease')->once()->with(1); $nameFixing = new CountingNameFixingService; $manager = $this->makeManager($nameFixing); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $this->assertTrue($manager->addFileInfo([ 'name' => 'Example.Movie.2026.mkv', 'size' => 1024, 'date' => 1_788_600_000, 'pass' => 0, 'crc32' => 'ABC123', ], $context, '\\.(?:par2|sfv|nzb)')); $this->assertFalse($manager->addFileInfo([ 'name' => 'Example.Movie.2026.mkv', 'size' => 1024, 'date' => 1_788_600_000, 'pass' => 0, 'crc32' => 'ABC123', ], $context, '\\.(?:par2|sfv|nzb)')); $manager->finalizeRelease($context, false); $this->assertSame(1, $nameFixing->matchPreDbFilesCalls); $this->assertSame(1, DB::table('release_files')->count()); $this->assertSame(1, DB::table('releases')->where('id', 1)->value('rarinnerfilecount')); $this->assertNull(DB::table('releases')->where('id', 1)->value('additional_pp_claimed_at')); $this->assertNull(DB::table('releases')->where('id', 1)->value('additional_pp_claim_token')); } public function test_finalization_flushes_rows_and_synchronizes_search_immediately(): void { DB::table('releases')->insert($this->releaseRow()); Search::shouldReceive('updateRelease')->once()->with(1); $manager = $this->makeManagerWithImageService(new ReleaseImageService); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $this->assertTrue($manager->addFileInfo([ 'name' => 'Example.Movie.2026.mkv', 'size' => 2048, 'date' => 1_788_600_000, ], $context, '\\.(?:par2|sfv|nzb)')); $manager->finalizeRelease($context, false); $this->assertSame(1, DB::table('release_files')->count()); $this->assertSame(1, DB::table('releases')->where('id', 1)->value('rarinnerfilecount')); $this->assertSame([], $context->pendingReleaseFiles); } public function test_database_statements_are_measured_inside_an_active_release_scope(): void { DB::table('releases')->insert($this->releaseRow()); $collector = app(PersistenceMetricsCollector::class); $collector->beginReleaseScope(1); DB::table('releases')->where('id', 1)->value('guid'); $metrics = $collector->finishReleaseScope(); $this->assertSame(1, $metrics->databaseStatements); $this->assertGreaterThanOrEqual(0.0, $metrics->databaseMilliseconds); } public function test_queued_par_hashes_flush_with_release_files(): void { DB::table('releases')->insert($this->releaseRow()); Search::shouldReceive('updateRelease')->once()->with(1); $manager = $this->makeManager(); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $queue = new ReflectionMethod(ReleaseFileManager::class, 'queueReleaseFile'); $queue->invoke( $manager, $context, 1, 'Example.Movie.2026.par2', 512, 1_788_600_000, 0, '1234567890abcdef1234567890abcdef' ); $manager->finalizeRelease($context, false); $this->assertSame(1, DB::table('release_files')->count()); $this->assertSame(1, DB::table('par_hashes')->count()); } public function test_failed_finalization_rolls_back_buffered_rows_and_keeps_them_for_retry(): void { DB::table('releases')->insert($this->releaseRow()); Search::shouldReceive('updateRelease')->never(); $manager = $this->makeManager(); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $this->assertTrue($manager->addFileInfo([ 'name' => 'Example.Movie.2026.mkv', 'size' => 1024, 'date' => 1_788_600_000, ], $context, '\\.(?:par2|sfv|nzb)')); DB::unprepared("CREATE TRIGGER fail_release_finalize BEFORE UPDATE ON releases BEGIN SELECT RAISE(ABORT, 'forced finalization failure'); END"); try { $manager->finalizeRelease($context, false); $this->fail('Finalization should have failed.'); } catch (QueryException $exception) { $this->assertStringContainsString('forced finalization failure', $exception->getMessage()); } finally { DB::unprepared('DROP TRIGGER IF EXISTS fail_release_finalize'); } $this->assertSame(0, DB::table('release_files')->count()); $this->assertArrayHasKey('Example.Movie.2026.mkv', $context->pendingReleaseFiles); } public function test_float_release_file_size_is_normalized_before_queueing(): void { DB::table('releases')->insert($this->releaseRow()); Search::shouldReceive('updateRelease')->once()->with(1); $manager = $this->makeManager(); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $this->assertTrue($manager->addFileInfo([ 'name' => 'Example.Movie.2026.mkv', 'size' => 1024.0, 'date' => 1_788_600_000, ], $context, '\\.(?:par2|sfv|nzb)')); $manager->finalizeRelease($context, false); $this->assertSame(1024, DB::table('release_files')->value('size')); } public function test_invalid_release_file_sizes_are_rejected(): void { DB::table('releases')->insert($this->releaseRow()); Log::shouldReceive('warning') ->times(4) ->with('Skipping release file with invalid size metadata.', Mockery::type('array')); $manager = $this->makeManager(); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $invalidSizes = [-1, INF, PHP_INT_MAX + 1.0, 'not-numeric']; foreach ($invalidSizes as $index => $invalidSize) { $this->assertFalse($manager->addFileInfo([ 'name' => "Invalid.Size.{$index}.mkv", 'size' => $invalidSize, ], $context, '\\.(?:par2|sfv|nzb)')); } $this->assertSame(0, $context->totalFileInfo); $this->assertSame(0, $context->addedFileInfo); } public function test_finalize_recognizes_webp_preview_and_sample_without_moving_them(): void { DB::table('releases')->insert($this->releaseRow()); Search::shouldReceive('updateRelease')->once()->with(1); $imageService = new ReleaseImageService; $preview = $imageService->imgSavePath.'guid-1_thumb.webp'; $sample = $imageService->jpgSavePath.'guid-1_thumb.webp'; File::ensureDirectoryExists(dirname($preview)); File::ensureDirectoryExists(dirname($sample)); File::put($preview, 'preview'); File::put($sample, 'sample'); try { $manager = $this->makeManagerWithImageService($imageService); $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); $manager->finalizeRelease($context, false); $this->assertSame(1, DB::table('releases')->where('id', 1)->value('haspreview')); $this->assertSame(1, DB::table('releases')->where('id', 1)->value('jpgstatus')); } finally { File::delete([$preview, $sample]); } } private function makeManager(?NameFixingService $nameFixing = null): ReleaseFileManager { return $this->makeManagerWithImageService(new ReleaseImageService, $nameFixing); } /** * @param array $configOverrides */ private function makeManagerWithImageService( ReleaseImageService $imageService, ?NameFixingService $nameFixing = null, array $configOverrides = [], ): ReleaseFileManager { return new ReleaseFileManager( $this->makeConfig($configOverrides), $imageService, new NfoService, new TestNzbService, $nameFixing ?? new CountingNameFixingService ); } /** * @return array */ private function releaseRow(): array { return [ 'id' => 1, 'guid' => 'guid-1', 'name' => 'Example', 'searchname' => 'Example', 'size' => 1024, 'groups_id' => 1, 'nfostatus' => -1, 'categories_id' => 10, 'passwordstatus' => -1, 'haspreview' => -1, 'nzbstatus' => 1, 'rarinnerfilecount' => 0, 'pp_timeout_count' => 0, 'additional_pp_claimed_at' => now(), 'additional_pp_claim_token' => 'token', ]; } private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { putenv($key); unset($_ENV[$key], $_SERVER[$key]); return; } putenv($key.'='.$value); $_ENV[$key] = $value; $_SERVER[$key] = $value; } private function createSchema(): void { if (! Schema::hasTable('settings')) { Schema::create('settings', function (Blueprint $table): void { $table->string('name')->primary(); $table->text('value')->nullable(); }); } DB::table('settings')->upsert([ ['name' => 'categorizeforeign', 'value' => '0'], ['name' => 'catwebdl', 'value' => '0'], ], ['name'], ['value']); Schema::dropIfExists('par_hashes'); Schema::dropIfExists('release_files'); Schema::dropIfExists('releases'); Schema::create('releases', function (Blueprint $table): void { $table->unsignedInteger('id')->primary(); $table->string('guid'); $table->string('name')->default(''); $table->string('searchname')->default(''); $table->unsignedBigInteger('size')->default(0); $table->unsignedInteger('groups_id')->default(0); $table->integer('nfostatus')->default(0); $table->integer('categories_id')->default(10); $table->integer('passwordstatus')->default(-1); $table->integer('haspreview')->default(-1); $table->integer('jpgstatus')->default(0); $table->integer('videostatus')->default(0); $table->integer('nzbstatus')->default(1); $table->integer('rarinnerfilecount')->default(0); $table->integer('pp_timeout_count')->default(0); $table->timestamp('additional_pp_claimed_at')->nullable(); $table->string('additional_pp_claim_token', 64)->nullable(); }); Schema::create('release_files', function (Blueprint $table): void { $table->unsignedInteger('releases_id'); $table->string('name'); $table->unsignedBigInteger('size')->default(0); $table->string('crc32')->default(''); $table->timestamp('created_at')->nullable(); $table->timestamp('updated_at')->nullable(); $table->boolean('passworded')->default(false); $table->primary(['releases_id', 'name']); }); Schema::create('par_hashes', function (Blueprint $table): void { $table->unsignedInteger('releases_id'); $table->string('hash', 32); $table->primary(['releases_id', 'hash']); }); } } class CountingNameFixingService extends NameFixingService { public int $matchPreDbFilesCalls = 0; public function matchPreDbFiles(object $release, bool $echo, bool $nameStatus, bool $show): int { $this->matchPreDbFilesCalls++; return 0; } } class TestNzbService extends NzbService { public function __construct() { parent::__construct(new CollectionCleanupService); } }