- 134-tool registry with programmatic SEO (unique titles/H1/descriptions, JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter) - DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD), Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG) - Security: SSRF guard, MIME validation, rate limits, API-key auth, bcrypt admin login, security headers - Admin panel: dashboard, tools/categories/guides CRUD, SEO audit, analytics, job inspector with retry, system health, feature flags - Docker deployment (nginx + web/api FPM pools + scalable workers), PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
127 lines
4.8 KiB
PHP
127 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use CodeIgniter\Test\CIUnitTestCase;
|
|
use CodeIgniter\Test\FeatureTestTrait;
|
|
|
|
/**
|
|
* SEO contract tests: every indexable page must render complete,
|
|
* crawlable HTML on the FIRST response — no JS required.
|
|
*
|
|
* These run against the test database (migrated + seeded by setUp).
|
|
*/
|
|
final class SeoPagesTest extends CIUnitTestCase
|
|
{
|
|
use FeatureTestTrait;
|
|
|
|
public static function setUpBeforeClass(): void
|
|
{
|
|
parent::setUpBeforeClass();
|
|
|
|
// migrate + seed the test database once (idempotent)
|
|
try {
|
|
command('migrate --all');
|
|
command('db:seed InitialSeeder');
|
|
} catch (\Throwable $e) {
|
|
fwrite(STDERR, 'Test DB bootstrap: ' . $e->getMessage() . "\n");
|
|
}
|
|
}
|
|
|
|
private function get(string $page): \CodeIgniter\Test\TestResponse
|
|
{
|
|
return $this->call('get', $page);
|
|
}
|
|
|
|
public function testToolPageRendersFullSeoContract(): void
|
|
{
|
|
$response = $this->get('/youtube-to-mp3');
|
|
$response->assertStatus(200);
|
|
|
|
$html = (string) $response->getBody();
|
|
|
|
// canonical, title, description, OG
|
|
$this->assertMatchesRegularExpression('#<link rel="canonical" href="https?://[^"]+/youtube-to-mp3">#', $html);
|
|
$this->assertMatchesRegularExpression('/<title>[^<]+<\/title>/', $html);
|
|
$this->assertStringContainsString('name="description"', $html);
|
|
$this->assertStringContainsString('property="og:title"', $html);
|
|
$this->assertStringContainsString('name="twitter:card" content="summary_large_image"', $html);
|
|
|
|
// structured data: FAQPage + BreadcrumbList + WebApplication
|
|
$jsonLd = '';
|
|
if (preg_match('/<script type="application\/ld\+json">(.*?)<\/script>/s', $html, $m)) {
|
|
$jsonLd = $m[1];
|
|
}
|
|
$this->assertNotSame('', $jsonLd, 'JSON-LD block missing');
|
|
foreach (['BreadcrumbList', 'WebApplication', 'FAQPage'] as $type) {
|
|
$this->assertStringContainsString('"' . $type . '"', $jsonLd, "{$type} missing from JSON-LD");
|
|
}
|
|
|
|
// content in first response: H1, how-to steps, FAQ questions visible
|
|
$this->assertStringContainsString('<h1>', $html);
|
|
$this->assertStringContainsString('Frequently asked questions', $html);
|
|
$this->assertStringContainsString('Related tools', $html);
|
|
$this->assertStringContainsString('breadcrumbs', $html);
|
|
|
|
// robots meta allows indexing
|
|
$this->assertStringContainsString('content="index, follow', $html);
|
|
}
|
|
|
|
public function testCategoryAndHomepageAreIndexable(): void
|
|
{
|
|
foreach (['/', '/youtube-tools', '/tools'] as $page) {
|
|
$html = (string) $this->get($page)->getBody();
|
|
$this->assertStringContainsString('rel="canonical"', $html, "canonical missing on {$page}");
|
|
$this->assertStringContainsString('index, follow', $html, "robots meta wrong on {$page}");
|
|
}
|
|
}
|
|
|
|
public function testSearchIsNoindexed(): void
|
|
{
|
|
$html = (string) $this->get('/search?q=mp3')->getBody();
|
|
|
|
$this->assertStringContainsString('noindex', $html);
|
|
}
|
|
|
|
public function testSitemapsContainOnlyCanonicalUrls(): void
|
|
{
|
|
$index = (string) $this->get('/sitemap.xml')->getBody();
|
|
$this->assertStringContainsString('sitemapindex', $index);
|
|
$this->assertStringContainsString('sitemap-tools-1.xml', $index);
|
|
$this->assertStringContainsString('sitemap-guides.xml', $index);
|
|
|
|
$tools = (string) $this->get('/sitemap-tools-1.xml')->getBody();
|
|
$this->assertStringContainsString('/youtube-to-mp3</loc>', $tools);
|
|
$this->assertDoesNotMatchRegularExpression('#<loc>[^<]*/(api/|admin|download/|search\?)#', $tools, 'Non-indexable URL leaked into sitemap');
|
|
|
|
$categories = (string) $this->get('/sitemap-categories.xml')->getBody();
|
|
$this->assertStringContainsString('/youtube-tools</loc>', $categories);
|
|
}
|
|
|
|
public function testRobotsTxtBlocksProcessingEndpoints(): void
|
|
{
|
|
$body = (string) $this->get('/robots.txt')->getBody();
|
|
|
|
$this->assertStringContainsString('Disallow: /api/', $body);
|
|
$this->assertStringContainsString('Disallow: /download/', $body);
|
|
$this->assertStringContainsString('Disallow: /admin', $body);
|
|
$this->assertStringContainsString('Sitemap:', $body);
|
|
}
|
|
|
|
public function testUppercaseUrl301RedirectsToCanonical(): void
|
|
{
|
|
$this->get('/YouTube-To-MP3')->assertStatus(301);
|
|
}
|
|
|
|
public function testUnknownSlugReturnsUseful404(): void
|
|
{
|
|
$result = $this->get('/this-tool-does-not-exist');
|
|
$result->assertStatus(404);
|
|
$html = (string) $result->getBody();
|
|
$this->assertStringContainsString('Tool not found', $html);
|
|
$this->assertStringContainsString('Popular tools', $html, '404 page must link popular tools');
|
|
}
|
|
}
|