'sqlite', 'database.connections.sqlite.database' => ':memory:']); DB::purge(); DB::reconnect(); DB::connection()->getPdo()->sqliteCreateFunction('UNIX_TIMESTAMP', static fn (?string $value): int => strtotime((string) $value)); DB::connection()->getPdo()->sqliteCreateFunction( 'REGEXP', static function (?string $subject, ?string $pattern): int { if ($subject === null || $pattern === null || $pattern === '') { return 0; } set_error_handler(static fn (): true => true); $ok = @preg_match($pattern, $subject); restore_error_handler(); return $ok ? 1 : 0; }, 2 ); $this->createTables(); $this->seedSettings(); } public function test_retention_cleanup_deletes_parts_binaries_and_collections_without_fk_cascades(): void { DB::table('collections')->insert([ 'id' => 100, 'subject' => 'Retention.Release', 'fromname' => 'poster@example.com', 'date' => now()->subHours(10)->format('Y-m-d H:i:s'), 'dateadded' => now()->subHours(10)->format('Y-m-d H:i:s'), 'added' => now()->subHours(10)->format('Y-m-d H:i:s'), 'xref' => 'alt.test:123', 'groups_id' => 1, 'totalfiles' => 1, 'filesize' => 500, 'filecheck' => CollectionFileCheckStatus::Sized->value, 'collectionhash' => 'retention-hash', 'collection_regexes_id' => 0, 'releases_id' => null, 'noise' => '', ]); DB::table('binaries')->insert([ 'id' => 1000, 'name' => 'Retention.Release.par2', 'collections_id' => 100, 'totalparts' => 1, ]); DB::table('parts')->insert([ 'binaries_id' => 1000, 'number' => 1, 'messageid' => '', 'partnumber' => 1, 'size' => 10, ]); app(CollectionCleanupService::class)->deleteFinishedAndOrphans(false); $this->assertSame(0, DB::table('parts')->count()); $this->assertSame(0, DB::table('binaries')->count()); $this->assertSame(0, DB::table('collections')->count()); } public function test_nzb_creation_cleans_up_collection_binary_and_parts_explicitly(): void { DB::table('releases')->insert([ 'id' => 1, 'name' => 'Nzb.Release', 'searchname' => 'Nzb.Release', 'totalpart' => 1, 'groups_id' => 1, 'adddate' => now()->format('Y-m-d H:i:s'), 'guid' => str_repeat('a', 36), 'leftguid' => 'a', 'postdate' => now()->format('Y-m-d H:i:s'), 'fromname' => 'poster@example.com', 'size' => 500, 'passwordstatus' => 0, 'haspreview' => -1, 'categories_id' => 1, 'nfostatus' => -1, 'nzbstatus' => NzbService::NZB_NONE, 'isrenamed' => 1, 'iscategorized' => 1, 'predb_id' => 0, 'source' => null, ]); DB::table('collections')->insert([ 'id' => 200, 'subject' => 'Nzb.Release', 'fromname' => 'poster@example.com', 'date' => now()->subHours(1)->format('Y-m-d H:i:s'), 'dateadded' => now()->subHours(1)->format('Y-m-d H:i:s'), 'added' => now()->subHours(1)->format('Y-m-d H:i:s'), 'xref' => 'alt.test:200', 'groups_id' => 1, 'totalfiles' => 1, 'filesize' => 500, 'filecheck' => CollectionFileCheckStatus::Inserted->value, 'collectionhash' => 'nzb-hash', 'collection_regexes_id' => 0, 'releases_id' => 1, 'noise' => '', ]); DB::table('binaries')->insert([ 'id' => 2000, 'name' => 'Nzb.Release yEnc', 'collections_id' => 200, 'totalparts' => 1, ]); DB::table('parts')->insert([ 'binaries_id' => 2000, 'number' => 1, 'messageid' => '', 'partnumber' => 1, 'size' => 10, ]); $release = Release::query()->findOrFail(1); $release->setRelation('category', (object) ['title' => 'Misc', 'parent' => (object) ['title' => 'Other']]); $written = app(NzbService::class)->writeNzbForReleaseId($release); $this->assertTrue($written); $this->assertSame(0, DB::table('parts')->count()); $this->assertSame(0, DB::table('binaries')->count()); $this->assertSame(0, DB::table('collections')->count()); $this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('nzbstatus')); } public function test_duplicate_release_path_cleans_up_collection_binary_and_parts(): void { DB::table('releases')->insert([ 'id' => 2, 'name' => 'Duplicate.Release', 'searchname' => 'Duplicate.Release', 'totalpart' => 1, 'groups_id' => 1, 'adddate' => now()->format('Y-m-d H:i:s'), 'guid' => str_repeat('b', 36), 'leftguid' => 'b', 'postdate' => now()->format('Y-m-d H:i:s'), 'fromname' => 'poster@example.com', 'size' => 1000, 'passwordstatus' => 0, 'haspreview' => -1, 'categories_id' => 1, 'nfostatus' => -1, 'nzbstatus' => NzbService::NZB_NONE, 'isrenamed' => 1, 'iscategorized' => 1, 'predb_id' => 0, 'source' => null, ]); DB::table('collections')->insert([ 'id' => 300, 'subject' => 'Duplicate.Release', 'fromname' => 'poster@example.com', 'date' => now()->subHours(1)->format('Y-m-d H:i:s'), 'dateadded' => now()->subHours(1)->format('Y-m-d H:i:s'), 'added' => now()->subHours(1)->format('Y-m-d H:i:s'), 'xref' => 'alt.test:300', 'groups_id' => 1, 'totalfiles' => 1, 'filesize' => 1000, 'filecheck' => CollectionFileCheckStatus::Sized->value, 'collectionhash' => 'duplicate-hash', 'collection_regexes_id' => 0, 'releases_id' => null, 'noise' => '', ]); DB::table('binaries')->insert([ 'id' => 3000, 'name' => 'Duplicate.Release yEnc', 'collections_id' => 300, 'totalparts' => 1, ]); DB::table('parts')->insert([ 'binaries_id' => 3000, 'number' => 1, 'messageid' => '', 'partnumber' => 1, 'size' => 10, ]); $service = new ReleaseCreationService( app(ReleaseCleaningService::class), app(CollectionCleanupService::class), app(ReleaseDuplicateFinder::class) ); $result = $service->createReleases(null, 10, false); $this->assertSame(['added' => 0, 'dupes' => 1], $result); $this->assertSame(0, DB::table('parts')->count()); $this->assertSame(0, DB::table('binaries')->count()); $this->assertSame(0, DB::table('collections')->count()); } public function test_release_duplicate_finder_matches_searchname_within_size_band(): void { DB::table('releases')->insert([ 'id' => 20, 'name' => 'raw-obfuscated-a', 'searchname' => 'Unified.Scene.S01E01.1080p', 'totalpart' => 1, 'groups_id' => 1, 'adddate' => now()->format('Y-m-d H:i:s'), 'guid' => str_repeat('c', 36), 'leftguid' => 'c', 'postdate' => now()->format('Y-m-d H:i:s'), 'fromname' => 'poster-a@example.com', 'size' => 1_000_000, 'passwordstatus' => 0, 'haspreview' => -1, 'categories_id' => 1, 'nfostatus' => -1, 'nzbstatus' => NzbService::NZB_NONE, 'isrenamed' => 1, 'iscategorized' => 1, 'predb_id' => 0, 'source' => null, ]); $finder = app(ReleaseDuplicateFinder::class); [$dup, $reason] = $finder->findDuplicate( 'raw-obfuscated-b', 'Unified.Scene.S01E01.1080p', 0, 1_020_000 ); $this->assertNotNull($dup); $this->assertSame('searchname_match', $reason); } public function test_release_duplicate_finder_matches_predb_id_when_searchname_differs(): void { DB::table('releases')->insert([ 'id' => 21, 'name' => 'old', 'searchname' => 'Old Style Name', 'totalpart' => 1, 'groups_id' => 1, 'adddate' => now()->format('Y-m-d H:i:s'), 'guid' => str_repeat('d', 36), 'leftguid' => 'd', 'postdate' => now()->format('Y-m-d H:i:s'), 'fromname' => 'p@example.com', 'size' => 2_000_000, 'passwordstatus' => 0, 'haspreview' => -1, 'categories_id' => 1, 'nfostatus' => -1, 'nzbstatus' => NzbService::NZB_NONE, 'isrenamed' => 1, 'iscategorized' => 1, 'predb_id' => 9001, 'source' => null, ]); $finder = app(ReleaseDuplicateFinder::class); [$dup, $reason] = $finder->findDuplicate( 'new', 'New Style Name', 9001, 2_050_000 ); $this->assertNotNull($dup); $this->assertSame('predb_id_match', $reason); } public function test_release_duplicate_finder_does_not_match_outside_size_tolerance(): void { config(['nntmux.release_dedupe_size_tolerance' => 0.05]); DB::table('releases')->insert([ 'id' => 22, 'name' => 'x', 'searchname' => 'Same.Search', 'totalpart' => 1, 'groups_id' => 1, 'adddate' => now()->format('Y-m-d H:i:s'), 'guid' => str_repeat('e', 36), 'leftguid' => 'e', 'postdate' => now()->format('Y-m-d H:i:s'), 'fromname' => 'p@example.com', 'size' => 1_000_000, 'passwordstatus' => 0, 'haspreview' => -1, 'categories_id' => 1, 'nfostatus' => -1, 'nzbstatus' => NzbService::NZB_NONE, 'isrenamed' => 1, 'iscategorized' => 1, 'predb_id' => 0, 'source' => null, ]); $finder = app(ReleaseDuplicateFinder::class); [$dup] = $finder->findDuplicate('x', 'Same.Search', 0, 1_200_000); $this->assertNull($dup); } public function test_release_duplicate_finder_falls_back_to_name_when_searchname_empty(): void { DB::table('releases')->insert([ 'id' => 23, 'name' => 'fallback.unique', 'searchname' => '', 'totalpart' => 1, 'groups_id' => 1, 'adddate' => now()->format('Y-m-d H:i:s'), 'guid' => str_repeat('f', 36), 'leftguid' => 'f', 'postdate' => now()->format('Y-m-d H:i:s'), 'fromname' => 'p@example.com', 'size' => 500, 'passwordstatus' => 0, 'haspreview' => -1, 'categories_id' => 1, 'nfostatus' => -1, 'nzbstatus' => NzbService::NZB_NONE, 'isrenamed' => 1, 'iscategorized' => 1, 'predb_id' => 0, 'source' => null, ]); $finder = app(ReleaseDuplicateFinder::class); [$dup, $reason] = $finder->findDuplicate('fallback.unique', '', 0, 500); $this->assertNotNull($dup); $this->assertSame('name_match_fallback', $reason); } private function seedSettings(): void { $settings = [ 'partretentionhours' => '1', 'nzbsplitlevel' => '1', 'check_passworded_rars' => '0', 'categorizeforeign' => '1', 'catwebdl' => '1', ]; foreach ($settings as $name => $value) { DB::table('settings')->insert(['name' => $name, 'value' => $value]); } } private function createTables(): void { DB::statement('CREATE TABLE settings (name VARCHAR(255) PRIMARY KEY, value TEXT)'); DB::statement('CREATE TABLE usenet_groups (id INTEGER PRIMARY KEY, name VARCHAR(255))'); DB::statement('CREATE TABLE categories (id INTEGER PRIMARY KEY, title VARCHAR(255), parent_categories_id INTEGER NULL)'); DB::statement('CREATE TABLE releases ( id INTEGER PRIMARY KEY, name VARCHAR(255), searchname VARCHAR(255), totalpart INTEGER, groups_id INTEGER, adddate DATETIME NULL, guid VARCHAR(64), leftguid VARCHAR(1), postdate DATETIME NULL, fromname VARCHAR(255), size INTEGER, passwordstatus INTEGER, haspreview INTEGER, categories_id INTEGER, nfostatus INTEGER, nzbstatus INTEGER, isrenamed INTEGER, iscategorized INTEGER, predb_id INTEGER, source VARCHAR(255) NULL )'); DB::statement('CREATE TABLE collections ( id INTEGER PRIMARY KEY, subject VARCHAR(255), fromname VARCHAR(255), date DATETIME NULL, dateadded DATETIME NULL, added DATETIME NULL, xref TEXT, groups_id INTEGER, totalfiles INTEGER, filesize INTEGER, filecheck INTEGER, collectionhash VARCHAR(255), collection_regexes_id INTEGER, releases_id INTEGER NULL, noise VARCHAR(64) )'); DB::statement('CREATE TABLE binaries ( id INTEGER PRIMARY KEY, name VARCHAR(255), collections_id INTEGER, totalparts INTEGER )'); DB::statement('CREATE TABLE parts ( binaries_id INTEGER, number INTEGER, messageid VARCHAR(255), partnumber INTEGER, size INTEGER )'); DB::statement('CREATE TABLE release_naming_regexes ( id INTEGER PRIMARY KEY, group_regex VARCHAR(255), regex VARCHAR(255), status INTEGER DEFAULT 1, ordinal INTEGER DEFAULT 0 )'); DB::statement('CREATE TABLE collection_regexes ( id INTEGER PRIMARY KEY, group_regex VARCHAR(255), regex VARCHAR(255), status INTEGER DEFAULT 1, ordinal INTEGER DEFAULT 0 )'); DB::statement('CREATE TABLE predb ( id INTEGER PRIMARY KEY, title VARCHAR(255), filename VARCHAR(255) )'); DB::table('usenet_groups')->insert(['id' => 1, 'name' => 'alt.test']); DB::table('categories')->insert(['id' => 1, 'title' => 'Misc', 'parent_categories_id' => null]); } }