databasePath = sys_get_temp_dir().'/nntmux-normalize-guids.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, ]); 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_leftguid_is_resynced_in_place(): void { $guid = '01234567-89ab-cdef-0123-456789abcdef'; $this->insertRelease(1, $guid, 'f'); $status = Artisan::call('releases:normalize-guids'); $this->assertSame(0, $status); $this->assertSame('0', DB::table('releases')->where('id', 1)->value('leftguid')); $this->assertSame($guid, DB::table('releases')->where('id', 1)->value('guid')); } public function test_dry_run_reports_without_writing(): void { $this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', 'f'); $this->insertRelease(2, str_repeat('a', 41), 'x'); $status = Artisan::call('releases:normalize-guids', ['--dry-run' => true]); $output = Artisan::output(); $this->assertSame(1, $status); $this->assertStringContainsString('Dry run: no rows are modified.', $output); $this->assertSame('f', DB::table('releases')->where('id', 1)->value('leftguid')); $this->assertSame(str_repeat('a', 41), DB::table('releases')->where('id', 2)->value('guid')); } public function test_legacy_sha1_guid_fits_the_narrowed_column_and_does_not_block(): void { $sha1 = '0c5c002220e26542a4c9dae845a58a38b3e7e63a'; $this->insertRelease(1, $sha1, '0'); $status = Artisan::call('releases:normalize-guids'); $output = Artisan::output(); $this->assertSame(0, $status); $this->assertStringContainsString('Release guids are consistent.', $output); $this->assertStringContainsString('1 releases still carry a legacy non-UUID guid', $output); $this->assertSame($sha1, DB::table('releases')->where('id', 1)->value('guid')); } public function test_oversized_guid_is_reported_and_never_rewritten(): void { $tooLong = str_repeat('b', 41); $this->insertRelease(1, $tooLong, 'b', ['name' => 'Oversized guid release']); $status = Artisan::call('releases:normalize-guids'); $output = Artisan::output(); $this->assertSame(1, $status); $this->assertStringContainsString('1 releases have a guid that blocks the normalization migration.', $output); $this->assertStringContainsString('Oversized guid release', $output); $this->assertSame($tooLong, DB::table('releases')->where('id', 1)->value('guid')); } public function test_non_ascii_guid_blocks_the_migration(): void { $this->insertRelease(1, 'd41d8cd98f00b204e9800998ecf842ΓΌ', 'd', ['name' => 'Non ASCII guid']); $status = Artisan::call('releases:normalize-guids'); $output = Artisan::output(); $this->assertSame(1, $status); $this->assertStringContainsString('1 releases have a guid that blocks', $output); $this->assertStringContainsString('Non ASCII guid', $output); } public function test_case_insensitive_duplicates_report_every_id_but_the_lowest(): void { $guid = 'abcdef01-2345-6789-abcd-ef0123456789'; $this->insertRelease(1, $guid, 'a'); $this->insertRelease(2, strtoupper($guid), 'A'); $status = Artisan::call('releases:normalize-guids'); $output = Artisan::output(); $this->assertSame(1, $status); $this->assertStringContainsString('1 releases have a guid that blocks', $output); $this->assertSame($guid, DB::table('releases')->where('id', 1)->value('guid')); $this->assertSame(strtoupper($guid), DB::table('releases')->where('id', 2)->value('guid')); } public function test_consistent_guids_report_success(): void { $this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0'); $status = Artisan::call('releases:normalize-guids'); $this->assertSame(0, $status); $this->assertStringContainsString('Release guids are consistent.', Artisan::output()); } public function test_portable_scans_never_use_offset_paging(): void { for ($id = 1; $id <= 250; $id++) { $this->insertRelease($id, sprintf('%08x-89ab-cdef-0123-456789abcdef', $id), 'x'); } $queries = []; DB::listen(static function ($query) use (&$queries): void { $queries[] = $query->sql; }); Artisan::call('releases:normalize-guids', ['--chunk' => 100]); $this->assertNotEmpty($queries); foreach ($queries as $sql) { $this->assertStringNotContainsStringIgnoringCase( 'offset', $sql, 'releases:normalize-guids must page by primary key, never with LIMIT/OFFSET.', ); } } /** @param array $overrides */ private function insertRelease(int $id, string $guid, string $leftguid, array $overrides = []): void { DB::table('releases')->insert([ 'id' => $id, 'guid' => $guid, 'leftguid' => $leftguid, 'name' => 'Release '.$id, ...$overrides, ]); } private function createSchema(): void { DB::statement('DROP TABLE IF EXISTS releases'); DB::statement('CREATE TABLE releases ( id INTEGER PRIMARY KEY, guid VARCHAR(40) NOT NULL, leftguid CHAR(1) NOT NULL, name VARCHAR(255) NOT NULL DEFAULT "" )'); } 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; } }