'https://moon-well.online', 'seo.indexable' => true, 'seo.google_verification' => null, 'seo.yandex_verification' => null, 'moonwell.realm.server_name' => 'MoonWell', 'moonwell.realm.client_version' => '1.12.1', 'moonwell.realm.rates.experience' => 'x1', ]); $this->mock(VMangosAccountService::class) ->shouldReceive('onlinePlayerCount')->andReturn(null); } public function test_landing_has_server_rendered_pvp_metadata_and_readable_content(): void { $response = $this->get('/?utm_source=google'); $response->assertOk()->assertSee('', false); $html = $this->xpath($response->getContent()); $this->assertSame(1, $html->query('//head/title')->length); $this->assertSame('MoonWell — PvP сервер WoW Vanilla 1.12.1 x1', $html->evaluate('string(//head/title)')); $this->assertSame(1, $html->query('//head/meta[@name="description"]')->length); $this->assertSame('https://moon-well.online/', $html->evaluate('string(//head/link[@rel="canonical"]/@href)')); $this->assertSame('index, follow, max-image-preview:large', $html->evaluate('string(//head/meta[@name="robots"]/@content)')); $this->assertSame(1, $html->query('//div[@id="app"]//h1')->length); $this->assertStringContainsString('PvP сервер World of Warcraft Vanilla 1.12.1', $html->evaluate('string(//div[@id="app"])')); $this->assertSame('https://moon-well.online/landing.png', $html->evaluate('string(//head/meta[@property="og:image"]/@content)')); $schema = json_decode($html->evaluate('string(//head/script[@type="application/ld+json"])'), true, flags: JSON_THROW_ON_ERROR); $this->assertSame('WebSite', $schema['@graph'][0]['@type']); $response->assertInertia(fn (Assert $page) => $page ->where('seo.canonical', 'https://moon-well.online/') ->where('realm.mode', 'PvP · x1 rate · RU') ->has('seo.head') ->etc() ); } public function test_canonical_and_sitemap_use_the_configured_domain(): void { $response = $this->get('http://alternate.example/?utm_campaign=test'); $response->assertOk(); $this->assertSame('https://moon-well.online/', $this->xpath($response->getContent())->evaluate('string(//head/link[@rel="canonical"]/@href)')); $this->get('http://alternate.example/robots.txt') ->assertOk() ->assertSee('Sitemap: https://moon-well.online/sitemap.xml', false) ->assertDontSee('alternate.example', false); } public function test_seo_uses_the_configured_version_and_rates(): void { config([ 'moonwell.realm.server_name' => 'Moonwell', 'moonwell.realm.client_version' => '1.12.2', 'moonwell.realm.rates.experience' => 'x3', ]); $response = $this->get('/'); $response->assertOk(); $this->assertSame('MoonWell — PvP сервер WoW Vanilla 1.12.2 x3', $this->xpath($response->getContent())->evaluate('string(//head/title)')); $response->assertSee('PvP сервер World of Warcraft Vanilla 1.12.2', false); } public function test_news_has_full_html_content_and_safe_article_structured_data(): void { $article = $this->article([ 'title' => "Обновление\n", 'body' => "Первый абзац.\n\nВторой абзац: рейды & подземелья.", ]); $response = $this->get('/news/'.$article->id.'?utm_source=yandex'); $response->assertOk()->assertDontSee('', false); $this->assertStringContainsString(e($article->title), $response->viewData('page')['props']['seo']['head'][0]); $html = $this->xpath($response->getContent()); $this->assertSame($article->title, $html->evaluate('string(//div[@id="app"]//h1)')); $this->assertStringContainsString('Второй абзац: рейды & подземелья.', $html->evaluate('string(//div[@id="app"])')); $this->assertSame('https://moon-well.online/news/'.$article->id, $html->evaluate('string(//head/link[@rel="canonical"]/@href)')); $schema = json_decode($html->evaluate('string(//head/script[@type="application/ld+json"])'), true, flags: JSON_THROW_ON_ERROR); $this->assertSame('NewsArticle', $schema['@graph'][2]['@type']); $this->assertSame($article->title, $schema['@graph'][2]['headline']); $this->assertSame($article->updated_at->toIso8601String(), $schema['@graph'][2]['dateModified']); } public function test_news_pagination_has_its_own_canonical_and_crawlable_links(): void { foreach (range(1, 10) as $number) { $this->article(['title' => "Новость {$number}"]); } $response = $this->get('/news?page=2&utm_source=test'); $response->assertOk(); $html = $this->xpath($response->getContent()); $this->assertSame('https://moon-well.online/news?page=2', $html->evaluate('string(//head/link[@rel="canonical"]/@href)')); $this->assertStringContainsString('страница 2', $html->evaluate('string(//head/title)')); $this->assertGreaterThan(0, $html->query('//div[@id="app"]//nav//a[@href]')->length); $first = $this->get('/news?page=1&utm_source=test')->assertOk(); $this->assertSame('https://moon-well.online/news', $this->xpath($first->getContent())->evaluate('string(//head/link[@rel="canonical"]/@href)')); $this->get('/news?page=3')->assertNotFound(); } public function test_sitemap_is_valid_xml_and_contains_only_published_public_pages(): void { $published = $this->article(); $draft = $this->article(['is_published' => false]); $response = $this->get('/sitemap.xml'); $response->assertOk()->assertHeader('Content-Type', 'application/xml; charset=UTF-8'); $xml = simplexml_load_string($response->getContent()); $this->assertNotFalse($xml); $xml->registerXPathNamespace('s', 'http://www.sitemaps.org/schemas/sitemap/0.9'); $locations = array_map('strval', $xml->xpath('//s:loc')); $this->assertSame([ 'https://moon-well.online/', 'https://moon-well.online/news', 'https://moon-well.online/news/'.$published->id, ], $locations); $this->assertSame($published->updated_at->toAtomString(), (string) $xml->xpath('//s:lastmod')[0]); $this->get('/news/'.$draft->id)->assertNotFound(); $published->update(['is_published' => false]); $this->get('/sitemap.xml')->assertOk()->assertDontSee('/news/'.$published->id, false); } public function test_robots_allows_assets_and_advertises_the_sitemap(): void { $this->get('/robots.txt') ->assertOk() ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') ->assertSee("User-agent: *\nAllow: /\n", false) ->assertSee('Sitemap: https://moon-well.online/sitemap.xml', false) ->assertDontSee('Disallow: /build', false) ->assertDontSee('Disallow: /storage', false) ->assertDontSee('Disallow: /login', false); } public function test_staging_disables_indexing(): void { config(['seo.indexable' => false]); $this->get('/robots.txt')->assertOk()->assertContent("User-agent: *\nDisallow: /\n"); $response = $this->get('/'); $response->assertOk(); $this->assertSame('noindex, follow', $this->xpath($response->getContent())->evaluate('string(//head/meta[@name="robots"]/@content)')); } public function test_inertia_navigation_gets_fresh_metadata_including_private_page_noindex(): void { $this->withHeaders([ 'X-Inertia' => 'true', 'X-Inertia-Version' => app(HandleInertiaRequests::class)->version(Request::create('/')), ])->get('/news') ->assertOk() ->assertJsonPath('props.seo.canonical', 'https://moon-well.online/news') ->assertJsonPath('props.seo.robots', 'index, follow, max-image-preview:large'); $this->get('/login') ->assertOk() ->assertJsonPath('props.seo.canonical', null) ->assertJsonPath('props.seo.robots', 'noindex, follow') ->assertJsonPath('props.seo.structured_data', null); } public function test_verification_tags_are_optional_and_escaped(): void { $response = $this->get('/login')->assertOk(); $response->assertDontSee('name="google-site-verification"', false)->assertDontSee('name="yandex-verification"', false); config(['seo.google_verification' => 'google-token"<>', 'seo.yandex_verification' => 'yandex-token']); $response = $this->get('/login')->assertOk(); $html = $this->xpath($response->getContent()); $this->assertSame('google-token"<>', $html->evaluate('string(//head/meta[@name="google-site-verification"]/@content)')); $this->assertSame('yandex-token', $html->evaluate('string(//head/meta[@name="yandex-verification"]/@content)')); $this->assertSame('noindex, follow', $html->evaluate('string(//head/meta[@name="robots"]/@content)')); } private function article(array $attributes = []): News { return News::query()->create([ 'title' => 'Обновление сервера', 'body' => 'Новости классического Азерота.', 'sort_order' => 0, 'is_published' => true, ...$attributes, ]); } private function xpath(string $html): DOMXPath { $document = new DOMDocument; @$document->loadHTML(''.$html); return new DOMXPath($document); } }