rootDir = dirname(__DIR__); $this->db = Database::sqlite(':memory:'); (new Migrator($this->db, $this->rootDir . '/migrations'))->migrate(); $this->links = new LinkRepository($this->db); $this->visits = new VisitRepository($this->db); $this->domains = new DomainRepository($this->db); $this->defaultDomainId = (int) $this->domains->defaultDomain(self::BASE_URL)['id']; } public function testMigrationsCreateAllTables(): void { $tables = array_column( $this->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); } } public function testMigrationsAreIdempotent(): void { $applied = (new Migrator($this->db, $this->rootDir . '/migrations'))->migrate(); self::assertSame([], $applied, 'Ein zweiter Lauf darf nichts erneut anwenden.'); } public function testDefaultDomainIsDerivedFromBaseUrl(): void { $domain = $this->domains->defaultDomain(self::BASE_URL); self::assertSame('short.example.de', $domain['host']); self::assertSame(self::BASE_URL, $domain['base_url']); self::assertSame(1, (int) $domain['is_default']); self::assertSame(1, $this->domains->count(), 'Die Standarddomain darf nur einmal angelegt werden.'); } public function testExistingLinksAreAssignedToTheDefaultDomain(): void { // Zustand nach der Migration: Bestandslink ohne Domain. $this->db->execute( 'INSERT INTO links (domain_id, slug, target_url, is_active, click_count, created_at, updated_at) VALUES (0, ?, ?, 1, 0, ?, ?)', ['bestand', 'https://www.beispiel.de/alt', gmdate('Y-m-d H:i:s'), gmdate('Y-m-d H:i:s')], ); // Frische Datenbank simulieren, in der noch keine Domain existiert. $this->db->execute('DELETE FROM domains'); $domainId = (int) $this->domains->defaultDomain(self::BASE_URL)['id']; $link = $this->db->fetchOne('SELECT * FROM links WHERE slug = ?', ['bestand']); self::assertNotNull($link); self::assertSame($domainId, (int) $link['domain_id']); } public function testKnownSlugRedirectsWithConfiguredStatus(): void { $this->links->create($this->defaultDomainId, 'messe2026', 'https://www.beispiel.de/messe'); $response = $this->controller(['REDIRECT_STATUS' => '302']) ->handle($this->request('/messe2026'), ['slug' => 'messe2026']); self::assertSame(302, $response->status()); self::assertSame('https://www.beispiel.de/messe', $response->headers()['Location']); self::assertStringContainsString('no-store', $response->headers()['Cache-Control']); } public function testRedirectStatusIsConfigurable(): void { $this->links->create($this->defaultDomainId, 'dauerhaft', 'https://www.beispiel.de/'); $response = $this->controller(['REDIRECT_STATUS' => '301']) ->handle($this->request('/dauerhaft'), ['slug' => 'dauerhaft']); self::assertSame(301, $response->status()); } public function testInvalidRedirectStatusFallsBackTo302(): void { $this->links->create($this->defaultDomainId, 'krumm', 'https://www.beispiel.de/'); $response = $this->controller(['REDIRECT_STATUS' => '418']) ->handle($this->request('/krumm'), ['slug' => 'krumm']); self::assertSame(302, $response->status()); } public function testUnknownSlugRendersFriendly404(): void { $response = $this->controller()->handle($this->request('/gibtesnicht'), ['slug' => 'gibtesnicht']); self::assertSame(404, $response->status()); self::assertArrayNotHasKey('Location', $response->headers()); self::assertStringContainsString('404', $response->body()); self::assertStringContainsString('gibtesnicht', $response->body()); } public function testInactiveLinkIsNotRedirected(): void { $id = $this->links->create($this->defaultDomainId, 'pausiert', 'https://www.beispiel.de/'); $this->links->setActive($id, false); $response = $this->controller()->handle($this->request('/pausiert'), ['slug' => 'pausiert']); self::assertSame(404, $response->status()); } // --- Mehrere Domains --------------------------------------------- public function testSameSlugOnTwoDomainsLeadsToDifferentTargets(): void { $qrId = $this->domains->create('https://qr.example.de', 'QR-Codes', false); $this->links->create($this->defaultDomainId, 'messe', 'https://www.beispiel.de/kurzlink-ziel'); $this->links->create($qrId, 'messe', 'https://www.beispiel.de/qr-ziel'); $viaShort = $this->controller()->handle( $this->request('/messe', host: 'short.example.de'), ['slug' => 'messe'], ); $viaQr = $this->controller()->handle( $this->request('/messe', host: 'qr.example.de'), ['slug' => 'messe'], ); self::assertSame('https://www.beispiel.de/kurzlink-ziel', $viaShort->headers()['Location']); self::assertSame('https://www.beispiel.de/qr-ziel', $viaQr->headers()['Location']); } public function testSlugOfAnotherDomainIsNotReachable(): void { $qrId = $this->domains->create('https://qr.example.de', null, false); $this->links->create($qrId, 'nur-qr', 'https://www.beispiel.de/'); $response = $this->controller()->handle( $this->request('/nur-qr', host: 'short.example.de'), ['slug' => 'nur-qr'], ); self::assertSame(404, $response->status()); } public function testSameSlugMayExistOncePerDomain(): void { $qrId = $this->domains->create('https://qr.example.de', null, false); $this->links->create($this->defaultDomainId, 'doppelt', 'https://www.beispiel.de/a'); self::assertTrue($this->links->slugExists('doppelt', $this->defaultDomainId)); self::assertFalse($this->links->slugExists('doppelt', $qrId)); $this->links->create($qrId, 'doppelt', 'https://www.beispiel.de/b'); self::assertTrue($this->links->slugExists('doppelt', $qrId)); } public function testDuplicateSlugOnSameDomainIsRejectedByTheDatabase(): void { $this->links->create($this->defaultDomainId, 'einmalig', 'https://www.beispiel.de/a'); $this->expectException(\PDOException::class); $this->links->create($this->defaultDomainId, 'einmalig', 'https://www.beispiel.de/b'); } public function testUnknownHostFallsBackToTheDefaultDomain(): void { $this->links->create($this->defaultDomainId, 'fallback', 'https://www.beispiel.de/'); // Aufruf per IP-Adresse oder hinter einem Proxy. $response = $this->controller()->handle( $this->request('/fallback', host: '203.0.113.10'), ['slug' => 'fallback'], ); self::assertSame(302, $response->status()); } public function testHostMatchingIgnoresCaseAndStandardPort(): void { $this->links->create($this->defaultDomainId, 'hostcheck', 'https://www.beispiel.de/'); foreach (['short.example.de', 'SHORT.example.de', 'short.example.de:443'] as $host) { $response = $this->controller()->handle( $this->request('/hostcheck', host: $host), ['slug' => 'hostcheck'], ); self::assertSame(302, $response->status(), $host); } } public function testShortUrlUsesTheDomainOfTheLink(): void { $qrId = $this->domains->create('https://qr.example.de', null, false); $id = $this->links->create($qrId, 'druck', 'https://www.beispiel.de/'); $link = $this->links->find($id); self::assertNotNull($link); self::assertSame( 'https://qr.example.de/druck', LinkRepository::shortUrlOf($link, self::BASE_URL), ); } public function testDomainWithSubdirectoryBuildsCorrectShortUrl(): void { $id = $this->domains->create('https://example.de/kurz', null, false); $linkId = $this->links->create($id, 'abc', 'https://www.beispiel.de/'); $link = $this->links->find($linkId); self::assertNotNull($link); self::assertSame('example.de', $link['domain_host']); self::assertSame('https://example.de/kurz/abc', LinkRepository::shortUrlOf($link, self::BASE_URL)); } // --- Statistik und Datenschutz ----------------------------------- public function testClickIsCountedAndVisitLoggedAfterResponse(): void { $id = $this->links->create($this->defaultDomainId, 'zaehlen', 'https://www.beispiel.de/'); $request = $this->request( '/zaehlen', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1', 'https://www.google.de/search?q=test', ); $response = $this->controller()->handle($request, ['slug' => 'zaehlen']); // Vor dem Nachlauf darf noch nichts protokolliert sein: // der Redirect geht zuerst raus. self::assertSame(0, (int) $this->links->find($id)['click_count']); self::assertSame(0, $this->visits->totalForLink($id)); $response->runAfterSend(); $link = $this->links->find($id); self::assertSame(1, (int) $link['click_count']); self::assertNotNull($link['last_clicked_at']); self::assertSame(1, $this->visits->totalForLink($id)); $visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]); self::assertSame('mobile', $visit['device_type']); self::assertSame('Safari', $visit['browser']); self::assertSame('iOS', $visit['platform']); self::assertSame('google.de', $visit['referrer_host']); } public function testNoIpIsStoredByDefault(): void { $id = $this->links->create($this->defaultDomainId, 'datenschutz', 'https://www.beispiel.de/'); $this->controller() ->handle($this->request('/datenschutz'), ['slug' => 'datenschutz']) ->runAfterSend(); $visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]); self::assertNull($visit['visitor_hash']); self::assertArrayNotHasKey('ip', $visit); self::assertArrayNotHasKey('ip_address', $visit); } public function testVisitorHashIsAnonymousWhenEnabled(): void { $id = $this->links->create($this->defaultDomainId, 'gehasht', 'https://www.beispiel.de/'); $this->controller(['TRACK_IP' => 'hash', 'IP_HASH_SALT' => 'sehr-geheim']) ->handle($this->request('/gehasht'), ['slug' => 'gehasht']) ->runAfterSend(); $visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]); self::assertIsString($visit['visitor_hash']); self::assertSame(32, strlen($visit['visitor_hash'])); self::assertStringNotContainsString('203.0.113', $visit['visitor_hash']); } public function testSlugLookupIsCaseInsensitiveAsFallback(): void { $this->links->create($this->defaultDomainId, 'grossklein', 'https://www.beispiel.de/'); $response = $this->controller()->handle($this->request('/GROSSKLEIN'), ['slug' => 'GROSSKLEIN']); self::assertSame(302, $response->status()); } // --- Mehrteilige Slugs aus fremden Systemen ----------------------- public function testPathSlugIsStoredAndRedirected(): void { $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/uebernommen'); $response = $this->controller()->handle( $this->request('/g/1AEJ0X90ZmF'), ['slug' => 'g/1AEJ0X90ZmF'], ); self::assertSame(302, $response->status()); self::assertSame('https://www.beispiel.de/uebernommen', $response->headers()['Location']); } public function testLetterCaseIsPreservedExactly(): void { $id = $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/'); self::assertSame('g/1AEJ0X90ZmF', $this->links->find($id)['slug']); } public function testPathSlugAlsoWorksWithWrongLetterCase(): void { // Abgetippte Codes stimmen nicht immer in der Schreibweise. $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/'); $response = $this->controller()->handle( $this->request('/g/1aej0x90zmf'), ['slug' => 'g/1aej0x90zmf'], ); self::assertSame(302, $response->status()); } public function testSlugsDifferingOnlyInCaseAreConsideredTaken(): void { $this->links->create($this->defaultDomainId, 'Messe', 'https://www.beispiel.de/a'); // Sonst waere an einer gedruckten Adresse nicht mehr erkennbar, // welcher Link gemeint ist. self::assertTrue($this->links->slugExists('messe', $this->defaultDomainId)); self::assertTrue($this->links->slugExists('MESSE', $this->defaultDomainId)); self::assertTrue($this->links->slugExists('Messe', $this->defaultDomainId)); self::assertFalse($this->links->slugExists('messe2', $this->defaultDomainId)); } public function testPathSlugsAreUniquePerDomain(): void { $qrId = $this->domains->create('https://qr.example.de', null, false); $this->links->create($this->defaultDomainId, 'g/ABC', 'https://www.beispiel.de/a'); self::assertTrue($this->links->slugExists('g/ABC', $this->defaultDomainId)); self::assertFalse($this->links->slugExists('g/ABC', $qrId)); $this->links->create($qrId, 'g/ABC', 'https://www.beispiel.de/b'); $viaQr = $this->controller()->handle( $this->request('/g/ABC', host: 'qr.example.de'), ['slug' => 'g/ABC'], ); self::assertSame('https://www.beispiel.de/b', $viaQr->headers()['Location']); } public function testUnknownPathSlugGivesFriendly404(): void { $response = $this->controller()->handle( $this->request('/g/gibtesnicht'), ['slug' => 'g/gibtesnicht'], ); self::assertSame(404, $response->status()); self::assertStringContainsString('g/gibtesnicht', $response->body()); } public function testClicksAreCountedForPathSlugs(): void { $id = $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/'); $this->controller() ->handle($this->request('/g/1AEJ0X90ZmF'), ['slug' => 'g/1AEJ0X90ZmF']) ->runAfterSend(); self::assertSame(1, (int) $this->links->find($id)['click_count']); self::assertSame(1, $this->visits->totalForLink($id)); } public function testUmlautTargetIsRedirectedAsPunycode(): void { $this->links->create($this->defaultDomainId, 'umlaut', 'https://müller.de/preise'); $response = $this->controller()->handle($this->request('/umlaut'), ['slug' => 'umlaut']); self::assertSame('https://xn--mller-kva.de/preise', $response->headers()['Location']); } public function testDeletingALinkRemovesItsVisits(): void { $id = $this->links->create($this->defaultDomainId, 'weg', 'https://www.beispiel.de/'); $this->controller()->handle($this->request('/weg'), ['slug' => 'weg'])->runAfterSend(); self::assertSame(1, $this->visits->totalForLink($id)); $this->links->delete($id); self::assertNull($this->links->find($id)); self::assertSame(0, $this->visits->totalForLink($id)); } /** * @param array $overrides */ private function controller(array $overrides = []): RedirectController { $envFile = tempnam(sys_get_temp_dir(), 'env'); self::assertIsString($envFile); $values = [ 'APP_BASE_URL' => self::BASE_URL, 'DB_DRIVER' => 'sqlite', 'DB_SQLITE_PATH' => ':memory:', ] + $overrides; $lines = []; foreach ($values as $key => $value) { $lines[] = $key . '=' . $value; } file_put_contents($envFile, implode("\n", $lines)); $config = Config::load($this->rootDir, $envFile); unlink($envFile); return new RedirectController( $config, new View($this->rootDir . '/templates'), $this->links, $this->visits, $this->domains, ); } private function request( string $path, string $userAgent = '', string $referrer = '', string $host = 'short.example.de', ): Request { return new Request( method: 'GET', path: $path, query: [], post: [], server: [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => $path, 'SCRIPT_NAME' => '/index.php', 'HTTP_HOST' => $host, 'HTTP_USER_AGENT' => $userAgent, 'HTTP_REFERER' => $referrer, 'REMOTE_ADDR' => '203.0.113.42', ], files: [], ); } }