diff --git a/tests/Feature/Console/NntmuxSearchDiagCommandTest.php b/tests/Feature/Console/NntmuxSearchDiagCommandTest.php new file mode 100644 index 000000000..8c7c482e4 --- /dev/null +++ b/tests/Feature/Console/NntmuxSearchDiagCommandTest.php @@ -0,0 +1,16 @@ + 'elasticsearch']); + + $this->artisan('nntmux:search-diag', ['ids' => ['1']]) + ->assertExitCode(1); + } +} diff --git a/tests/Feature/Console/NntmuxSearchReconcileCommandTest.php b/tests/Feature/Console/NntmuxSearchReconcileCommandTest.php new file mode 100644 index 000000000..3b094fd15 --- /dev/null +++ b/tests/Feature/Console/NntmuxSearchReconcileCommandTest.php @@ -0,0 +1,16 @@ + 'elasticsearch']); + + $this->artisan('nntmux:search-reconcile', ['--dry-run' => true]) + ->assertExitCode(1); + } +} diff --git a/tests/Feature/Console/SearchConsoleCommandTestCase.php b/tests/Feature/Console/SearchConsoleCommandTestCase.php new file mode 100644 index 000000000..c0a56aba1 --- /dev/null +++ b/tests/Feature/Console/SearchConsoleCommandTestCase.php @@ -0,0 +1,94 @@ + + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-search-console-test-'.uniqid('', true).'.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (is_file($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + + $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(); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && is_file($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + 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; + } +} diff --git a/tests/Unit/ApiPerformanceHelpersTest.php b/tests/Unit/ApiPerformanceHelpersTest.php new file mode 100644 index 000000000..707d2709f --- /dev/null +++ b/tests/Unit/ApiPerformanceHelpersTest.php @@ -0,0 +1,200 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + } + + public function test_release_row_cache_reuses_cached_null_values(): void + { + Search::shouldReceive('getCurrentDriver')->andReturn('testing'); + + $calls = 0; + $cache = new ApiReleaseRowCache; + + $first = $cache->remember('v2', 'details', ['guid' => 'missing'], function () use (&$calls): mixed { + $calls++; + + return null; + }); + $second = $cache->remember('v2', 'details', ['guid' => 'missing'], function () use (&$calls): mixed { + $calls++; + + return null; + }); + + $this->assertNull($first); + $this->assertNull($second); + $this->assertSame(1, $calls); + } + + public function test_get_by_guid_for_api_returns_plain_row_without_model_hydration(): void + { + $this->createReleaseDetailsSchema(); + + DB::table('root_categories')->insert([ + 'id' => 5000, + 'title' => 'TV', + ]); + DB::table('categories')->insert([ + 'id' => 5030, + 'title' => 'SD', + 'root_categories_id' => 5000, + ]); + DB::table('usenet_groups')->insert([ + 'id' => 1, + 'name' => 'alt.binaries.test', + ]); + DB::table('releases')->insert([ + 'id' => 1, + 'searchname' => 'Ubuntu.Release', + 'guid' => 'release-guid', + 'postdate' => '2026-01-02 00:00:00', + 'categories_id' => 5030, + 'size' => 123456, + 'totalpart' => 10, + 'fromname' => 'poster', + 'passwordstatus' => 0, + 'grabs' => 2, + 'comments' => 1, + 'adddate' => '2026-01-03 00:00:00', + 'videos_id' => 0, + 'tv_episodes_id' => 0, + 'haspreview' => 0, + 'nfostatus' => 0, + 'movieinfo_id' => 0, + 'musicinfo_id' => 0, + 'consoleinfo_id' => 0, + 'groups_id' => 1, + ]); + + $row = Release::getByGuidForApi('release-guid'); + + $this->assertInstanceOf(\stdClass::class, $row); + $this->assertSame('release-guid', $row->guid); + $this->assertSame('TV > SD', $row->category_name); + $this->assertSame('alt.binaries.test', $row->group_name); + } + + public function test_release_data_fast_array_matches_existing_data_output(): void + { + $release = (object) [ + 'searchname' => 'Ubuntu.Release', + 'guid' => 'release-guid', + 'categories_id' => 5030, + 'category_name' => 'TV > SD', + 'adddate' => '2026-01-03 00:00:00', + 'size' => 123456, + 'totalpart' => 10, + 'grabs' => 0, + 'comments' => 0, + 'passwordstatus' => 0, + 'postdate' => '2026-01-02 00:00:00', + 'imdb' => '1234567', + 'tmdb' => 234, + 'trakt' => 345, + 'title' => 'Pilot', + 'series' => '1', + 'episode' => '1', + 'firstaired' => '2026-01-01', + 'tvdb' => 456, + 'tvrage' => 567, + 'tvmaze' => 678, + ]; + $user = new User; + $user->api_token = 'api-token'; + + $this->assertSame( + ReleaseData::fromRelease($release, $user)->toArray(), + ReleaseData::toArrayFromRelease($release, $user) + ); + } + + private function createReleaseDetailsSchema(): void + { + Schema::create('root_categories', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('title'); + }); + Schema::create('categories', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('title'); + $table->unsignedInteger('root_categories_id')->nullable(); + }); + Schema::create('usenet_groups', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('name'); + }); + Schema::create('videos', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->unsignedInteger('tvdb')->nullable(); + $table->unsignedInteger('trakt')->nullable(); + $table->unsignedInteger('tvrage')->nullable(); + $table->unsignedInteger('tvmaze')->nullable(); + $table->string('imdb')->nullable(); + $table->unsignedInteger('tmdb')->nullable(); + }); + Schema::create('tv_episodes', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('title')->nullable(); + $table->string('series')->nullable(); + $table->string('episode')->nullable(); + $table->date('firstaired')->nullable(); + }); + Schema::create('movieinfo', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('imdbid')->nullable(); + $table->unsignedInteger('tmdbid')->nullable(); + $table->unsignedInteger('traktid')->nullable(); + }); + Schema::create('releases', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('searchname'); + $table->string('guid')->index(); + $table->dateTime('postdate'); + $table->unsignedInteger('categories_id'); + $table->unsignedBigInteger('size'); + $table->unsignedInteger('totalpart'); + $table->string('fromname')->nullable(); + $table->integer('passwordstatus')->default(0); + $table->unsignedInteger('grabs')->default(0); + $table->unsignedInteger('comments')->default(0); + $table->dateTime('adddate'); + $table->unsignedInteger('videos_id')->default(0); + $table->unsignedInteger('tv_episodes_id')->default(0); + $table->integer('haspreview')->default(0); + $table->integer('nfostatus')->default(0); + $table->unsignedInteger('movieinfo_id')->default(0); + $table->unsignedInteger('musicinfo_id')->default(0); + $table->unsignedInteger('consoleinfo_id')->default(0); + $table->unsignedInteger('groups_id')->nullable(); + }); + } +} diff --git a/tests/Unit/MovieBrowseServiceTest.php b/tests/Unit/MovieBrowseServiceTest.php new file mode 100644 index 000000000..b16660c18 --- /dev/null +++ b/tests/Unit/MovieBrowseServiceTest.php @@ -0,0 +1,45 @@ +assertSame(1, preg_match(self::YEAR_PATTERN, '2020')); + $this->assertSame(1, preg_match(self::YEAR_PATTERN, '1999')); + $this->assertSame(1, preg_match(self::YEAR_PATTERN, '1900')); + $this->assertSame(1, preg_match(self::YEAR_PATTERN, '2099')); + } + + public function test_year_validation_rejects_invalid_years(): void + { + $this->assertSame(0, preg_match(self::YEAR_PATTERN, 'invalid')); + $this->assertSame(0, preg_match(self::YEAR_PATTERN, '1899')); + $this->assertSame(0, preg_match(self::YEAR_PATTERN, '2100')); + $this->assertSame(0, preg_match(self::YEAR_PATTERN, '20')); + $this->assertSame(0, preg_match(self::YEAR_PATTERN, '20200')); + } + + public function test_year_filter_uses_equality_not_like(): void + { + $validYear = '2020'; + $expectedFragment = ' AND m.year = '; + $this->assertStringContainsString('m.year = ', $expectedFragment.$validYear); + $this->assertStringNotContainsString('LIKE', $expectedFragment.$validYear); + } +} diff --git a/tests/Unit/NzbServiceTest.php b/tests/Unit/NzbServiceTest.php new file mode 100644 index 000000000..24306ad63 --- /dev/null +++ b/tests/Unit/NzbServiceTest.php @@ -0,0 +1,58 @@ +newInstanceWithoutConstructor(); + $method = $reflection->getMethod('buildBinarySubject'); + + $subject = $method->invoke($service, '"Example.Release.part01.rar" yEnc', 12); + + $this->assertSame('"Example.Release.part01.rar" yEnc (1/12)', $subject); + $this->assertStringNotContainsString('" yEnc(1/12)', $subject); + $this->assertStringNotContainsString('"(1/12)', $subject); + } + + public function test_build_binary_subject_trims_trailing_whitespace_before_part_suffix(): void + { + $reflection = new ReflectionClass(NzbService::class); + $service = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('buildBinarySubject'); + + $subject = $method->invoke($service, 'Example.Release.rar yEnc ', 3); + + $this->assertSame('Example.Release.rar yEnc (1/3)', $subject); + } + + public function test_normalize_segment_message_id_strips_outer_quotes_and_brackets(): void + { + $reflection = new ReflectionClass(NzbService::class); + $service = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('normalizeSegmentMessageId'); + + $messageId = $method->invoke($service, '""'); + + $this->assertSame('part01.abcd@example.test', $messageId); + } + + public function test_normalize_segment_message_id_keeps_bare_message_id_as_is(): void + { + $reflection = new ReflectionClass(NzbService::class); + $service = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('normalizeSegmentMessageId'); + + $messageId = $method->invoke($service, 'part01.abcd@example.test'); + + $this->assertSame('part01.abcd@example.test', $messageId); + } +} diff --git a/tests/Unit/Services/Search/ManticoreInsertRetryTest.php b/tests/Unit/Services/Search/ManticoreInsertRetryTest.php new file mode 100644 index 000000000..bba453539 --- /dev/null +++ b/tests/Unit/Services/Search/ManticoreInsertRetryTest.php @@ -0,0 +1,133 @@ +createMock(Request::class); + $response = $this->createMock(Response::class); + $response->method('getError')->willReturn($message); + + return new ResponseException($request, $response); + } + + /** + * @return array + */ + private function releaseRow(int $id): array + { + return [ + 'id' => $id, + 'name' => 'n', + 'searchname' => 's', + 'fromname' => 'f', + 'categories_id' => 1, + 'filename' => '', + 'imdbid' => '', + 'tmdbid' => 0, + 'traktid' => 0, + 'tvdb' => 0, + 'tvmaze' => 0, + 'tvrage' => 0, + 'videos_id' => 0, + 'movieinfo_id' => 0, + 'size' => 100, + 'postdate' => '2020-01-01 00:00:00', + 'adddate' => '2020-01-01 00:00:00', + 'totalpart' => 1, + 'grabs' => 0, + 'passwordstatus' => 0, + 'groups_id' => 1, + 'nzbstatus' => 0, + 'haspreview' => 0, + ]; + } + + public function test_replace_release_document_retries_once_on_response_exception(): void + { + $config = [ + 'host' => '127.0.0.1', + 'port' => 9308, + 'indexes' => [ + 'releases' => 'releases_rt', + 'predb' => 'predb_rt', + ], + ]; + + $ex = $this->makeResponseException('transient'); + + $table = $this->createMock(Table::class); + $table->expects($this->exactly(2)) + ->method('replaceDocument') + ->willReturnCallback(static function () use ($ex): void { + static $calls = 0; + $calls++; + if ($calls === 1) { + throw $ex; + } + }); + + $client = $this->createMock(Client::class); + $client->expects($this->exactly(2)) + ->method('table') + ->with('releases_rt') + ->willReturn($table); + + $driver = new ManticoreSearchDriver($config); + $prop = new \ReflectionProperty(ManticoreSearchDriver::class, 'manticoreSearch'); + $prop->setAccessible(true); + $prop->setValue($driver, $client); + + $refP = new ReflectionMethod(ManticoreSearchDriver::class, 'replaceReleaseDocumentWithRetry'); + $refP->setAccessible(true); + + $ok = $refP->invoke($driver, $this->releaseRow(42)); + $this->assertTrue($ok); + } + + public function test_replace_release_document_returns_false_after_two_response_exceptions(): void + { + $config = [ + 'host' => '127.0.0.1', + 'port' => 9308, + 'indexes' => ['releases' => 'releases_rt', 'predb' => 'predb_rt'], + ]; + + $ex = $this->makeResponseException('fail'); + + $table = $this->createMock(Table::class); + $table->expects($this->exactly(2)) + ->method('replaceDocument') + ->willThrowException($ex); + + $client = $this->createMock(Client::class); + $client->expects($this->exactly(2)) + ->method('table') + ->with('releases_rt') + ->willReturn($table); + + $driver = new ManticoreSearchDriver($config); + $prop = new \ReflectionProperty(ManticoreSearchDriver::class, 'manticoreSearch'); + $prop->setAccessible(true); + $prop->setValue($driver, $client); + + $refP = new ReflectionMethod(ManticoreSearchDriver::class, 'replaceReleaseDocumentWithRetry'); + $refP->setAccessible(true); + + $ok = $refP->invoke($driver, $this->releaseRow(7)); + $this->assertFalse($ok); + } +}