partialDir = sys_get_temp_dir() . '/urlshort-mig-' . bin2hex(random_bytes(4)); mkdir($this->partialDir, 0775, true); copy(dirname(__DIR__) . '/migrations/001_initial.php', $this->partialDir . '/001_initial.php'); } protected function tearDown(): void { foreach (glob($this->partialDir . '/*') ?: [] as $file) { unlink($file); } if (is_dir($this->partialDir)) { rmdir($this->partialDir); } } public function testUpgradeKeepsAllDataAndBackfillsTheDefaultDomain(): void { $db = Database::sqlite(':memory:'); // --- Stand vor dem Update ------------------------------------- $applied = (new Migrator($db, $this->partialDir))->migrate(); self::assertSame(['001_initial'], $applied); $now = gmdate('Y-m-d H:i:s'); $db->execute( 'INSERT INTO links (slug, target_url, title, is_active, click_count, last_clicked_at, created_at, updated_at) VALUES (?, ?, ?, 1, 42, ?, ?, ?)', ['messe2026', 'https://www.beispiel.de/messe', 'Messe-Flyer', $now, $now, $now], ); $linkId = $db->lastInsertId(); $db->execute( 'INSERT INTO links (slug, target_url, title, is_active, click_count, created_at, updated_at) VALUES (?, ?, NULL, 0, 7, ?, ?)', ['pausiert', 'https://www.beispiel.de/alt', $now, $now], ); $inactiveId = $db->lastInsertId(); $db->execute( 'INSERT INTO visits (link_id, visited_at, referrer_host, user_agent, device_type, browser, platform) VALUES (?, ?, ?, ?, ?, ?, ?)', [$linkId, $now, 'google.de', 'Mozilla/5.0', 'mobile', 'Safari', 'iOS'], ); $db->execute( 'INSERT INTO qr_designs (link_id, foreground_color, background_color, size, margin, error_correction, logo_scale, logo_punchout, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)', [$linkId, '#0b4f9e', '#ffffff', 1024, 6, 'Q', 22, $now], ); // --- Update --------------------------------------------------- $applied = (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate(); self::assertSame(['002_domains'], $applied, 'Nur die neue Migration darf laufen.'); // --- Bestand pruefen ------------------------------------------ $links = new LinkRepository($db); $link = $links->find($linkId); self::assertNotNull($link, 'Der Bestandslink muss erhalten bleiben.'); self::assertSame('messe2026', $link['slug']); self::assertSame('https://www.beispiel.de/messe', $link['target_url']); self::assertSame('Messe-Flyer', $link['title']); self::assertSame(42, (int) $link['click_count']); self::assertSame(1, (int) $link['is_active']); self::assertNotNull($link['last_clicked_at']); $inactive = $links->find($inactiveId); self::assertNotNull($inactive); self::assertSame(0, (int) $inactive['is_active']); self::assertSame(7, (int) $inactive['click_count']); // Verknuepfte Daten haengen weiterhin an derselben ID. self::assertSame(1, (int) $db->fetchValue('SELECT COUNT(*) FROM visits WHERE link_id = ?', [$linkId])); self::assertSame(1, (int) $db->fetchValue('SELECT COUNT(*) FROM qr_designs WHERE link_id = ?', [$linkId])); // Vor der Zuordnung tragen Bestandslinks den Platzhalter 0. self::assertSame(DomainRepository::UNASSIGNED, (int) $link['domain_id']); // --- Standarddomain anlegen und nachtragen -------------------- $domains = new DomainRepository($db); $domain = $domains->defaultDomain('https://short.example.de'); $domainId = (int) $domain['id']; self::assertSame('short.example.de', $domain['host']); foreach ([$linkId, $inactiveId] as $id) { self::assertSame($domainId, (int) $links->find($id)['domain_id']); } self::assertSame( 'https://short.example.de/messe2026', LinkRepository::shortUrlOf($links->find($linkId), 'https://short.example.de'), ); } public function testUpgradeReplacesTheGlobalSlugUniquenessWithPerDomain(): void { $db = Database::sqlite(':memory:'); (new Migrator($db, $this->partialDir))->migrate(); $now = gmdate('Y-m-d H:i:s'); $db->execute( 'INSERT INTO links (slug, target_url, is_active, click_count, created_at, updated_at) VALUES (?, ?, 1, 0, ?, ?)', ['messe', 'https://www.beispiel.de/a', $now, $now], ); (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate(); $domains = new DomainRepository($db); $links = new LinkRepository($db); $defaultId = (int) $domains->defaultDomain('https://short.example.de')['id']; $qrId = $domains->create('https://qr.example.de', null, false); // Derselbe Slug auf einer anderen Domain ist jetzt erlaubt ... $links->create($qrId, 'messe', 'https://www.beispiel.de/b'); self::assertTrue($links->slugExists('messe', $qrId)); // ... auf derselben Domain aber weiterhin nicht. $this->expectException(\PDOException::class); $links->create($defaultId, 'messe', 'https://www.beispiel.de/c'); } public function testUpgradedDatabaseStillHasAllTablesAndIndexes(): void { $db = Database::sqlite(':memory:'); (new Migrator($db, $this->partialDir))->migrate(); (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate(); $tables = array_column( $db->fetchAll("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"), 'name', ); foreach (['links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations'] as $table) { self::assertContains($table, $tables); } // Die Hilfstabelle des Umbaus darf nicht zurueckbleiben. self::assertNotContains('links_new', $tables); $indexes = array_column( $db->fetchAll("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'links'"), 'name', ); self::assertContains('idx_links_created_at', $indexes); self::assertContains('idx_links_domain', $indexes); } }