Toolvana: production-ready media tools platform (CodeIgniter 4)
- 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
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user