path = tempnam(sys_get_temp_dir(), 'kant-lang-'); $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path)); TestSchema::build($this->db); $this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']); $this->settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')); $this->pages = new PageRepository($this->db, new DocumentValidator(), $this->settings); $this->languages = new Languages($this->db, $this->settings); $partials = new PartialRepository($this->db, new DocumentValidator()); foreach (DemoContent::areas() as $kind => $area) { $partials->create($kind, 'default', $area['name'], $area['document']); } } protected function tearDown(): void { $this->db->close(); gc_collect_cycles(); @unlink($this->path); } private function document(string $title = 'Leistungen', array $translations = []): array { $document = [ 'schemaVersion' => 27, 'title' => $title, 'description' => 'Was wir für Sie tun', 'blocks' => [[ 'id' => 's1', 'type' => 'section', 'children' => [ ['id' => 'h1', 'type' => 'heading', 'props' => ['text' => $title, 'level' => 1]], ['id' => 't1', 'type' => 'text', 'props' => ['text' => '

Schreiben Sie uns über Kontakt.

']], ['id' => 'b1', 'type' => 'button', 'props' => ['text' => 'Zum Kontakt', 'href' => '/kontakt']], ['id' => 'i1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true, 'width' => 10, 'height' => 10]], ['id' => 'l1', 'type' => 'language', 'props' => ['display' => 'code']], ], ]], ]; if ([] !== $translations) { $document['translations'] = $translations; } return $document; } /** Legt eine Seite an, speichert einen Entwurf und veröffentlicht ihn. */ private function publishedPage(string $slug, array $document, ?string $parent = null): string { $id = $this->pages->createPage($slug, $parent, 'page', $this->document()); $revision = $this->pages->saveDraft($id, $document, 1); $this->pages->publish($id, $revision, 2); return $id; } private function republish(string $id, array $document): void { $version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]); $revision = $this->pages->saveDraft($id, $document, $version); $this->pages->publish($id, $revision, $version + 1); } public function testApplyLaysTranslatedTextsOverTheSamePageAndKeepsEverythingElse(): void { $document = $this->document('Leistungen', ['en' => [ 'title' => 'Services', 'blocks' => ['h1' => ['text' => 'Services'], 'b1' => ['text' => ' '], 'i1' => ['alt' => 'A picture']], ]]); $english = Translations::apply($document, 'en'); self::assertArrayNotHasKey('translations', $english); self::assertSame('Services', $english['title']); self::assertSame('Was wir für Sie tun', $english['description'], 'Ohne Übersetzung bleibt der Text der Hauptsprache.'); $children = $english['blocks'][0]['children']; self::assertSame('Services', $children[0]['props']['text']); self::assertSame(1, $children[0]['props']['level'], 'Aufbau und Einstellungen bleiben unverändert.'); self::assertSame('Zum Kontakt', $children[2]['props']['text'], 'Ein leerer Text gilt als nicht übersetzt.'); self::assertSame('/kontakt', $children[2]['props']['href']); self::assertSame('', $children[3]['props']['alt'], 'Ein dekoratives Bild bekommt auch übersetzt keinen Text.'); self::assertSame(['total' => 5, 'done' => 2, 'stale' => 0], Translations::progress($document, 'en')); } public function testTranslationsPassTheSameChecksAsTheMainLanguage(): void { $id = $this->pages->createPage('leistungen', null, 'page', $this->document()); // Formatierter Text geht durch denselben Filter, Einträge verschwundener Blöcke fallen weg. $this->pages->saveDraft($id, $this->document('Leistungen', ['en' => [ 'blocks' => [ 'h1' => ['text' => 'Services', 'level' => 'kein Text-Feld'], 'weg' => ['text' => 'Block gibt es nicht'], ], 'source' => ['h1.text' => Translations::fingerprint('Leistungen'), 'weg.text' => '00000000'], ]]), 1); $stored = $this->pages->findForEditor($id)['document']['translations']['en']; self::assertSame(['h1' => ['text' => 'Services']], $stored['blocks']); self::assertSame(['h1.text' => Translations::fingerprint('Leistungen')], $stored['source']); $refused = [ 'unbekannte Sprache' => ['xx' => ['title' => 'X']], 'zu langer Titel' => ['en' => ['title' => str_repeat('a', 161)]], 'falsche Adresse' => ['en' => ['title' => 'Services', 'slug' => 'Unsere Leistungen']], 'unbekanntes Feld' => ['en' => ['title' => 'Services', 'menu' => 'x']], 'zu langer Button' => ['en' => ['blocks' => ['b1' => ['text' => str_repeat('a', 301)]]]], ]; foreach ($refused as $case => $translations) { try { $this->pages->saveDraft($id, $this->document('Leistungen', $translations), 2); self::fail('Abgelehnt werden muss: '.$case); } catch (InvalidDocument) { self::assertSame(2, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]), $case); } } $older = $this->document('Leistungen', ['en' => ['title' => 'Services']]); $older['schemaVersion'] = 26; $older['blocks'][0]['children'] = array_slice($older['blocks'][0]['children'], 0, 4); $this->expectException(InvalidDocument::class); (new DocumentValidator())->validate($older); } public function testPublishingWritesTheTranslatedAddressesAndMovesFollow(): void { $this->languages->save(['en']); $parent = $this->pages->createPage('leistungen', null, 'page', $this->document()); $revision = $this->pages->saveDraft($parent, $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'services']]), 1); self::assertSame([], $this->pages->translatedPaths($parent), 'Ein Entwurf ist noch nicht unter /en erreichbar.'); $this->pages->publish($parent, $revision, 2); self::assertSame(['en' => 'services'], $this->pages->translatedPaths($parent)); $child = $this->publishedPage('webdesign', $this->document('Webdesign', ['en' => ['title' => 'Web design', 'slug' => 'web-design']]), $parent); self::assertSame(['en' => 'services/web-design'], $this->pages->translatedPaths($child)); $english = $this->pages->findPublishedTranslation('services/web-design', 'en'); self::assertSame('Web design', $english['document']['title']); self::assertSame('en', $english['locale']); self::assertSame('leistungen/webdesign', $english['path'], 'Navigation und Brotkrumen hängen an der Adresse der Hauptsprache.'); self::assertArrayNotHasKey('translations', $this->pages->findPublished('leistungen/webdesign')['document']); // Neue übersetzte Adresse der Elternseite: die Unterseite wandert mit, die alte Adresse leitet weiter. $this->republish($parent, $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'offers']])); self::assertSame(['en' => 'offers/web-design'], $this->pages->translatedPaths($child)); self::assertSame(['to' => 'en/offers/web-design', 'status' => 301], (new RedirectRepository($this->db))->find('en/services/web-design')); // Verschieben in der Hauptsprache trägt die Übersetzungen mit. $other = $this->publishedPage('ueber-uns', $this->document('Über uns')); $version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$child]); $this->pages->movePage($child, 'webdesign', $other, $version, new RedirectRepository($this->db)); self::assertSame(['en' => 'ueber-uns/web-design'], $this->pages->translatedPaths($child), 'Eine nicht übersetzte Elternseite steht mit ihrer Adresse der Hauptsprache davor.'); // Ohne Übersetzung veröffentlicht: die Sprache ist für diese Seite wieder weg. $this->republish($child, $this->document('Webdesign')); self::assertSame([], $this->pages->translatedPaths($child)); self::assertNull($this->pages->findPublishedTranslation('ueber-uns/web-design', 'en')); } public function testATranslatedAddressThatIsTakenIsRefusedWhenSaving(): void { $this->languages->save(['en']); $this->publishedPage('leistungen', $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'services']])); $other = $this->pages->createPage('angebote', null, 'page', $this->document('Angebote')); $this->expectException(InvalidPage::class); $this->expectExceptionMessage('/en/services'); $this->pages->saveDraft($other, $this->document('Angebote', ['en' => ['title' => 'Offers', 'slug' => 'services']]), 1); } public function testALanguageCodeCannotCollideWithARootPage(): void { $this->pages->createPage('fr', null, 'page', $this->document('Frankreich')); try { $this->languages->save(['fr']); self::fail('Die Seite /fr würde unerreichbar.'); } catch (InvalidPage $error) { self::assertStringContainsString('/fr', $error->getMessage()); } $this->languages->save(['en', 'de', 'en']); self::assertSame(['en'], $this->languages->extra(), 'Die Hauptsprache und Doppelte fallen weg.'); $this->expectException(InvalidPage::class); $this->pages->createPage('en', null, 'page', $this->document('Englisch')); } public function testTheWebsiteServesTheTranslationWithHreflangAndLinksInItsLanguage(): void { $this->languages->save(['en']); $home = $this->publishedPage('home', $this->document('Start', ['en' => ['title' => 'Home', 'blocks' => ['h1' => ['text' => 'Welcome']]]])); $this->publishedPage('kontakt', $this->document('Kontakt', ['en' => ['title' => 'Contact', 'slug' => 'contact']])); $this->publishedPage('leistungen', $this->document('Leistungen', ['en' => [ 'title' => 'Services', 'slug' => 'services', 'seoTitle' => 'Our services | KantSites', 'blocks' => ['h1' => ['text' => 'Services'], 't1' => ['text' => '

Write to us via contact.

'], 'b1' => ['text' => 'Get in touch']], ]])); $this->publishedPage('impressum', $this->document('Impressum')); $english = $this->request('/en/services'); $html = (string) $english->getContent(); self::assertSame(200, $english->getStatusCode()); self::assertStringContainsString('Our services | KantSites', $html); self::assertStringContainsString('', $html); self::assertStringContainsString('', $html); self::assertStringContainsString('', $html); self::assertStringContainsString('hreflang="x-default" href="https://cms.test/leistungen"', $html); self::assertStringContainsString('content="en_US"', $html); self::assertStringContainsString('Get in touch', $html); self::assertStringContainsString('href="/en/contact"', $html, 'Links auf übersetzte Seiten führen zur Übersetzung.'); self::assertStringNotContainsString('href="/kontakt"', $html); self::assertMatchesRegularExpression('#DE#', $html, 'Die Sprachwahl zeigt bewusst auf die andere Sprache.'); self::assertMatchesRegularExpression('#EN#', $html); $german = (string) $this->request('/leistungen')->getContent(); self::assertStringContainsString('request('/impressum')->getContent(), 'Eine Seite ohne Übersetzung hat keine Sprachalternativen.'); self::assertStringContainsString('Welcome', (string) $this->request('/en')->getContent(), '/en ist die Startseite der Sprache.'); self::assertSame(301, $this->request('/en/home')->getStatusCode()); self::assertSame(404, $this->request('/en/impressum')->getStatusCode()); self::assertSame(404, $this->request('/fr/services')->getStatusCode()); $sitemap = (string) $this->request('/sitemap.xml')->getContent(); self::assertStringContainsString('https://cms.test/en/services', $sitemap); self::assertStringContainsString('https://cms.test/en', $sitemap); unset($home); // Sprache abgewählt: ihre Seiten sind weg, die Übersetzungen bleiben in den Seiten liegen. $this->languages->save([]); self::assertSame(404, $this->request('/en/services')->getStatusCode()); self::assertStringNotContainsString('hreflang', (string) $this->request('/leistungen')->getContent()); self::assertStringNotContainsString('/en/', (string) $this->request('/sitemap.xml')->getContent()); } public function testHeaderMenuAndFixedTextsFollowTheLanguageOfThePage(): void { $this->languages->save(['en']); $partials = new PartialRepository($this->db, new DocumentValidator()); $header = $partials->findByKind('header'); $partials->save($header['id'], [ 'schemaVersion' => 27, 'title' => 'Kopf', 'description' => 'Kopfbereich', 'blocks' => [['id' => 'kopf', 'type' => 'section', 'children' => [ ['id' => 'kopf-text', 'type' => 'text', 'props' => ['text' => '

Willkommen

']], ['id' => 'kopf-menu', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal']], ]]], 'translations' => ['en' => ['blocks' => ['kopf-text' => ['text' => '

Welcome

'], 'weg' => ['text' => 'x']]]], ], $header['version']); self::assertSame(['kopf-text' => ['text' => '

Welcome

']], $partials->find($header['id'])['document']['translations']['en']['blocks'], 'Ein Bereich wird beim Speichern bereinigt wie eine Seite.'); $this->publishedPage('home', $this->document('Start')); $services = $this->publishedPage('leistungen', $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'services']])); $menus = new \App\Content\MenuRepository($this->db, $this->settings); $menus->save('header', 'Hauptnavigation', [ ['label' => 'Leistungen', 'type' => 'page', 'pageId' => $services, 'anchor' => '', 'labels' => ['en' => 'Our services'], 'children' => []], ], 0); try { $menus->save('header', 'Hauptnavigation', [['label' => 'X', 'type' => 'url', 'url' => '/x', 'labels' => ['xx' => 'Y'], 'children' => []]], 1); self::fail('Eine unbekannte Sprache im Menü wird abgelehnt.'); } catch (\App\Domain\InvalidMenu) { } $english = (string) $this->request('/en/services')->getContent(); self::assertStringContainsString('

Welcome

', $english); self::assertStringNotContainsString('Willkommen', $english); self::assertMatchesRegularExpression('#
]*>.*?Our services#s', $english, 'Menübeschriftung und Ziel in der Sprache der Seite.'); self::assertStringContainsString('>Skip to content', $english); self::assertStringContainsString('aria-label="Main navigation"', $english); $german = (string) $this->request('/leistungen')->getContent(); self::assertStringContainsString('

Willkommen

', $german); self::assertMatchesRegularExpression('#]*>.*?Leistungen#s', $german); self::assertStringNotContainsString('Our services', $german); self::assertStringContainsString('>Zum Inhalt springen', $german); } public function testFormsSpeakTheLanguageOfThePageButSendInTheMainLanguage(): void { $this->languages->save(['en']); $forms = new FormRepository($this->db); $translations = ['en' => ['buttonLabel' => 'Send request', 'success' => 'Thanks!', 'fields' => [ 'art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote', 'Gibt es nicht' => 'x']], 'email' => ['label' => 'Email', 'help' => ''], 'weg' => ['label' => 'Feld gibt es nicht'], ]]]; $fields = [ ['name' => 'art', 'type' => 'radio', 'label' => 'Art der Anfrage', 'required' => true, 'options' => ['Beratung', 'Angebot']], ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => 'Für die Antwort'], ]; try { $forms->save('anfrage', 'Anfrage', $fields, ['recipient' => 'info@example.org', 'translations' => ['xx' => []]], 0); self::fail('Eine unbekannte Sprache wird abgelehnt.'); } catch (\App\Domain\InvalidForm) { } $forms->save('anfrage', 'Anfrage', $fields, ['recipient' => 'info@example.org', 'translations' => $translations], 0); $stored = $forms->findBySlug('anfrage')['settings']['translations']['en']; self::assertSame(['art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote']], 'email' => ['label' => 'Email']], $stored['fields'], 'Nur Felder und Optionen, die es gibt; leere fallen weg.'); $checked = $forms->check($forms->findBySlug('anfrage'), ['art' => 'Advice', 'email' => 'kaputt'], 'en'); self::assertSame(['art' => 'Please choose one of the options offered.', 'email' => 'Please enter a valid email address.'], $checked['errors'], 'Abgeschickt wird der Wert der Hauptsprache, nicht die Anzeige.'); $document = $this->document('Kontakt', ['en' => ['title' => 'Contact', 'slug' => 'contact']]); $document['blocks'][0]['children'][] = ['id' => 'f1', 'type' => 'form', 'props' => ['form' => 'anfrage']]; $this->publishedPage('kontakt', $document); $english = (string) $this->request('/en/contact')->getContent(); self::assertMatchesRegularExpression('#Type of enquiry#', $english); self::assertStringContainsString('type="radio" value="Beratung"', $english); self::assertMatchesRegularExpression('##', $english); self::assertStringContainsString('', $english); self::assertStringContainsString('>Send request', $english); self::assertStringContainsString('(required)', $english); self::assertStringContainsString('Für die Antwort', $english, 'Ein nicht übersetzter Hilfetext bleibt in der Hauptsprache.'); $german = (string) $this->request('/kontakt')->getContent(); self::assertMatchesRegularExpression('##', $german); self::assertStringNotContainsString('name="language"', $german); $issued = time() - 10; $body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('anfrage', $issued, 'test-only'), 'art' => 'Beratung', 'email' => '']; $refused = $this->request('/formular/anfrage', 'POST', $body); self::assertSame(422, $refused->getStatusCode()); self::assertStringContainsString('This field is required.', (string) $refused->getContent()); self::assertStringContainsString('getContent()); $sent = $this->request('/formular/anfrage', 'POST', [...$body, 'email' => 'kunde@example.org']); self::assertSame('/en/contact?gesendet=anfrage#f1', $sent->headers->get('Location')); self::assertSame('Beratung', json_decode((string) $this->db->fetchOne('SELECT data FROM form_submission'), true)['art'], 'Gespeichert wird in der Hauptsprache.'); self::assertStringContainsString('Thanks!', (string) $this->request('/en/contact?gesendet=anfrage')->getContent()); } public function testPostsArchivesConsentAndNotFoundFollowTheLanguage(): void { $this->languages->save(['en']); $this->publishedPage('home', $this->document('Start', ['en' => ['title' => 'Home']])); $posts = new \App\Content\PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), $this->settings); $translated = $this->pages->createPage('neues-dach', null, 'post', $this->document('Neues Dach')); $this->pages->publish($translated, $this->pages->saveDraft($translated, $this->document('Neues Dach', ['en' => ['title' => 'New roof', 'description' => 'A new roof for a farmhouse', 'slug' => 'new-roof']]), 1), 2); $posts->saveMetadata($translated, [], ['Projekte'], [], 3); $german = $this->pages->createPage('garten', null, 'post', $this->document('Garten')); $this->pages->publish($german, $this->pages->saveDraft($german, $this->document('Garten'), 1), 2); $this->settings->saveLanguageTexts(['en' => ['category:projekte' => 'Projects', 'tag:kaputt!' => 'x']], ['en' => ['title' => 'Your choice', 'text' => '']]); self::assertSame(['en' => ['category:projekte' => 'Projects']], $this->settings->load()['termNames']); $list = $posts->published(language: 'en')['posts']; self::assertContains('New roof', array_column($list, 'title')); self::assertContains('Garten', array_column($list, 'title'), 'Ein nicht übersetzter Beitrag steht in der Hauptsprache in der Liste.'); self::assertContains('A new roof for a farmhouse', array_column($list, 'excerpt')); $archive = $this->request('/en/beitraege'); $html = (string) $archive->getContent(); self::assertSame(200, $archive->getStatusCode()); self::assertStringContainsString('Posts', $html); self::assertStringContainsString('href="/en/new-roof"', $html); self::assertStringContainsString('href="/garten"', $html); self::assertStringContainsString('>Projects', $html); self::assertStringContainsString('href="/en/kategorie/projekte"', $html); $category = (string) $this->request('/en/kategorie/projekte')->getContent(); self::assertStringContainsString('>Projects', $category); self::assertStringContainsString('>Beiträge', (string) $this->request('/beitraege')->getContent()); $post = (string) $this->request('/en/new-roof')->getContent(); self::assertStringContainsString('New roof', $post); self::assertStringContainsString('>Projects', $post); $missing = $this->request('/en/gibt-es-nicht'); self::assertSame(404, $missing->getStatusCode()); self::assertStringContainsString('getContent()); self::assertStringContainsString('Page not found', (string) $missing->getContent()); self::assertStringContainsString('Seite nicht gefunden', (string) $this->request('/gibt-es-nicht')->getContent()); $consent = new \App\Content\Consent($this->settings); $request = Request::create('/en'); $request->attributes->set('kant_locale', 'en'); self::assertSame('Your choice', $consent->state($request)['title']); self::assertSame('', $consent->state(Request::create('/'))['title']); } private function request(string $url, string $method = 'GET', array $body = []): Response { $previous = []; foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) { $previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null]; $_SERVER[$key] = $_ENV[$key] = $value; } $kernel = new Kernel('test', false); try { $request = Request::create($url, $method, $body); $response = $kernel->handle($request); $kernel->terminate($request, $response); return $response; } finally { $kernel->shutdown(); foreach ($previous as $key => [$server, $env]) { if (null === $server) { unset($_SERVER[$key]); } else { $_SERVER[$key] = $server; } if (null === $env) { unset($_ENV[$key]); } else { $_ENV[$key] = $env; } } } } }