partialDir = $base . '-1'; $this->domainsDir = $base . '-2'; foreach ([$this->partialDir, $this->domainsDir] as $dir) { mkdir($dir, 0775, true); copy($source . '/001_initial.php', $dir . '/001_initial.php'); } copy($source . '/002_domains.php', $this->domainsDir . '/002_domains.php'); } protected function tearDown(): void { foreach ([$this->partialDir, $this->domainsDir] as $dir) { foreach (glob($dir . '/*') ?: [] as $file) { unlink($file); } if (is_dir($dir)) { rmdir($dir); } } } 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', '003_users_groups'], $applied, 'Nur die noch offenen Migrationen duerfen 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'); } /** * Das bisherige Einzelkonto steckte in der settings-Tabelle. Nach dem * Update muss es als vollwertiger Administrator in "users" stehen - * sonst waere die Installation nach dem Update ausgesperrt. */ public function testUpgradeAdoptsTheSingleAdminAccountFromSettings(): void { $db = Database::sqlite(':memory:'); (new Migrator($db, $this->partialDir))->migrate(); $hash = password_hash('ein-langes-passwort', PASSWORD_DEFAULT); $settings = new SettingRepository($db); $settings->set('admin_user', 'florian'); $settings->set('admin_password_hash', $hash); (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate(); $users = new UserRepository($db); $user = $users->findByUsername('florian'); self::assertNotNull($user, 'Das Bestandskonto muss uebernommen werden.'); self::assertSame(Role::ADMIN, $user['role']); self::assertSame(1, (int) $user['is_active']); self::assertTrue( password_verify('ein-langes-passwort', (string) $user['password_hash']), 'Der bestehende Hash muss unveraendert uebernommen werden - sonst aendert sich das Passwort.', ); // Die alten Schluessel duerfen nicht als tote Zugangsdaten zurueckbleiben. self::assertNull($settings->get('admin_user')); self::assertNull($settings->get('admin_password_hash')); // Und er ist Editor der Standardgruppe, kann also selbst Links anlegen. $groups = new GroupRepository($db); self::assertSame( Role::EDITOR, $groups->roleInGroup((int) $user['id'], $groups->defaultGroupId()), ); } /** * Eine frische Installation hat noch kein Konto - dann darf die * Migration auch keines erfinden. */ public function testUpgradeCreatesNoAccountWhenThereWasNone(): void { $db = Database::sqlite(':memory:'); (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate(); self::assertSame(0, (new UserRepository($db))->count()); self::assertFalse((new UserRepository($db))->hasAnyActive()); } /** * Bestandsdomains muessen in einer Gruppe landen, sonst waeren ihre * Kurzlinks fuer alle ausser Administratoren unsichtbar. */ public function testUpgradePutsExistingDomainsIntoTheDefaultGroup(): void { $db = Database::sqlite(':memory:'); // Stand vor den Gruppen: erste und zweite Migration, dann eine // Domain wie sie eine laufende Installation haette. (new Migrator($db, $this->domainsDir))->migrate(); $db->execute( 'INSERT INTO domains (host, base_url, label, is_default, created_at) VALUES (?, ?, NULL, 1, ?)', ['short.example.de', 'https://short.example.de', gmdate('Y-m-d H:i:s')], ); (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate(); $groups = new GroupRepository($db); $defaultId = $groups->defaultGroupId(); $groupId = (int) $db->fetchValue('SELECT group_id FROM domains WHERE host = ?', ['short.example.de']); self::assertSame($defaultId, $groupId); self::assertSame(GroupRepository::DEFAULT_NAME, $groups->find($defaultId)['name']); } 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', ); $expected = [ 'links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations', 'users', 'user_groups', 'group_members', ]; foreach ($expected 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); } }