databasePath = sys_get_temp_dir().'/nntmux-releases-migration.sqlite'; 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 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, 'nntmux.releases_optimize.chunk_size' => 100, 'nntmux.releases_optimize.skip_preflight' => false, ]); DB::purge(); DB::reconnect(); $this->createSchema(); } protected function tearDown(): void { DB::disconnect(); if (file_exists($this->databasePath)) { unlink($this->databasePath); } parent::tearDown(); } public function test_comment_recount_spans_multiple_chunks_and_skips_correct_rows(): void { $releases = []; $comments = []; $commentId = 1; for ($id = 1; $id <= 250; $id++) { // Every third release already stores the right counter and must not be rewritten. $alreadyCorrect = $id % 3 === 0; $releases[] = ['id' => $id, 'guid' => 'g'.$id, 'leftguid' => 'g', 'comments' => $alreadyCorrect ? 1 : 99]; $comments[] = ['id' => $commentId++, 'releases_id' => $id, 'isvisible' => 1]; $comments[] = ['id' => $commentId++, 'releases_id' => $id, 'isvisible' => 0]; } DB::table('releases')->insert($releases); DB::table('release_comments')->insert($comments); $this->invoke('recountVisibleComments'); $this->assertSame(0, DB::table('releases')->where('comments', '<>', 1)->count()); $this->assertSame(250, DB::table('releases')->where('comments', 1)->count()); } public function test_comment_recount_zeroes_releases_without_visible_comments(): void { DB::table('releases')->insert([ ['id' => 1, 'guid' => 'a', 'leftguid' => 'a', 'comments' => 7], ['id' => 2, 'guid' => 'b', 'leftguid' => 'b', 'comments' => 0], ]); DB::table('release_comments')->insert([['id' => 1, 'releases_id' => 1, 'isvisible' => 0]]); $this->invoke('recountVisibleComments'); $this->assertSame(0, (int) DB::table('releases')->where('id', 1)->value('comments')); $this->assertSame(0, (int) DB::table('releases')->where('id', 2)->value('comments')); } public function test_rollback_backfill_restores_sparse_rows_in_batches(): void { $releases = []; $passwords = []; $failures = []; for ($id = 1; $id <= 250; $id++) { $releases[] = ['id' => $id, 'guid' => 'g'.$id, 'leftguid' => 'g', 'comments' => 0]; $passwords[] = ['releases_id' => $id, 'password' => 'secret-'.$id]; $failures[] = ['releases_id' => $id, 'attempts' => $id % 5, 'last_error' => 'boom-'.$id]; } DB::table('releases')->insert($releases); DB::table('release_nzb_passwords')->insert($passwords); DB::table('release_nzb_creation_failures')->insert($failures); $this->invoke('restoreSparseDataToReleases'); $restored = DB::table('releases')->orderBy('id')->get(); $this->assertCount(250, $restored); foreach ($restored as $release) { $this->assertSame('secret-'.$release->id, $release->nzb_password); $this->assertSame((int) $release->id % 5, (int) $release->nzb_creation_attempts); $this->assertSame('boom-'.$release->id, $release->nzb_creation_last_error); } } public function test_preflight_blocks_bad_identifiers_and_the_opt_out_bypasses_it(): void { DB::table('releases')->insert([ ['id' => 1, 'guid' => 'not-a-uuid', 'leftguid' => 'z', 'comments' => 0], ]); try { $this->invoke('assertReleaseIdentifiersAreSafe'); $this->fail('The migration accepted an invalid guid.'); } catch (RuntimeException $e) { $this->assertStringContainsString('invalid release GUIDs', $e->getMessage()); $this->assertStringContainsString('releases:normalize-guids', $e->getMessage()); } config(['nntmux.releases_optimize.skip_preflight' => true]); $this->invoke('assertReleaseIdentifiersAreSafe'); $this->assertSame(1, DB::table('releases')->count()); } private function invoke(string $method): void { /** @var Migration $migration */ $migration = require database_path('migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php'); (new ReflectionMethod($migration, $method))->invoke($migration); } private function createSchema(): void { foreach (['release_comments', 'release_nzb_passwords', 'release_nzb_creation_failures', 'releases'] as $table) { DB::statement('DROP TABLE IF EXISTS '.$table); } DB::statement('CREATE TABLE releases ( id INTEGER PRIMARY KEY, guid VARCHAR(40) NOT NULL, leftguid CHAR(1) NOT NULL, comments INTEGER NOT NULL DEFAULT 0, nzb_password VARCHAR(255) NULL, nzb_creation_attempts INTEGER NOT NULL DEFAULT 0, nzb_creation_last_error TEXT NULL )'); DB::statement('CREATE TABLE release_comments ( id INTEGER PRIMARY KEY, releases_id INTEGER NOT NULL, isvisible INTEGER NOT NULL DEFAULT 1 )'); DB::statement('CREATE TABLE release_nzb_passwords ( releases_id INTEGER PRIMARY KEY, password VARCHAR(255) NOT NULL )'); DB::statement('CREATE TABLE release_nzb_creation_failures ( releases_id INTEGER PRIMARY KEY, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT NULL )'); } private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { putenv($key); unset($_ENV[$key], $_SERVER[$key]); return; } putenv("{$key}={$value}"); $_ENV[$key] = $_SERVER[$key] = $value; } }