Files
SkinbaseNova/tests/Feature/SitemapTest.php

699 lines
24 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Collection;
use App\Models\ContentType;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardTemplate;
use App\Models\Page;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
beforeEach(function (): void {
config([
'app.url' => 'http://skinbase26.test',
'sitemaps.pre_generated.enabled' => false,
'sitemaps.pre_generated.prefer' => false,
'sitemaps.delivery.prefer_published_release' => true,
'sitemaps.delivery.fallback_to_live_build' => true,
]);
Cache::flush();
ensureNewsSchema();
ensureForumSchema();
});
it('renders the sitemap index and every child sitemap endpoint', function (): void {
seedSitemapFixtures();
$indexResponse = $this->get('/sitemap.xml')
->assertOk()
->assertHeader('Content-Type', 'application/xml; charset=UTF-8');
$indexXml = simplexml_load_string($indexResponse->getContent());
expect($indexXml)->not->toBeFalse();
expect($indexResponse->getContent())
->toContain(url('/sitemaps/artworks.xml'))
->toContain(url('/sitemaps/static-pages.xml'))
->toContain(url('/sitemaps/forum-threads.xml'))
->toContain(url('/sitemaps/news-google.xml'));
foreach ([
'artworks',
'users',
'tags',
'categories',
'collections',
'cards',
'stories',
'news',
'news-google',
'forum-index',
'forum-categories',
'forum-threads',
'static-pages',
] as $name) {
$response = $this->get('/sitemaps/' . $name . '.xml')
->assertOk()
->assertHeader('Content-Type', 'application/xml; charset=UTF-8');
expect(simplexml_load_string($response->getContent()))->not->toBeFalse();
}
});
it('includes only public canonical urls and exposes the sitemap in robots txt', function (): void {
$fixtures = seedSitemapFixtures();
$artworksXml = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
expect($artworksXml)
->toContain($fixtures['artwork_url'])
->toContain('<image:image>')
->not->toContain('/art/' . $fixtures['private_artwork_id'] . '/');
$usersXml = $this->get('/sitemaps/users.xml')->assertOk()->getContent();
expect($usersXml)
->toContain($fixtures['profile_url'])
->not->toContain('/@inactivecreator');
$tagsXml = $this->get('/sitemaps/tags.xml')->assertOk()->getContent();
expect($tagsXml)
->toContain('/tag/featured-tag')
->not->toContain('/tag/inactive-tag');
$categoriesXml = $this->get('/sitemaps/categories.xml')->assertOk()->getContent();
expect($categoriesXml)
->toContain('/categories')
->toContain('/photography')
->toContain('/photography/portraits');
$collectionsXml = $this->get('/sitemaps/collections.xml')->assertOk()->getContent();
expect($collectionsXml)->toContain($fixtures['collection_url']);
$cardsXml = $this->get('/sitemaps/cards.xml')->assertOk()->getContent();
expect($cardsXml)
->toContain($fixtures['card_url'])
->toContain('<image:image>');
$storiesXml = $this->get('/sitemaps/stories.xml')->assertOk()->getContent();
expect($storiesXml)
->toContain('/stories/creator-spotlight')
->toContain('<image:image>');
$newsXml = $this->get('/sitemaps/news.xml')->assertOk()->getContent();
expect($newsXml)
->toContain('/news/platform-update')
->toContain('<image:image>');
$googleNewsXml = $this->get('/sitemaps/news-google.xml')->assertOk()->getContent();
expect($googleNewsXml)
->toContain('<news:news>')
->toContain('<news:title>Platform Update</news:title>');
$forumIndexXml = $this->get('/sitemaps/forum-index.xml')->assertOk()->getContent();
expect($forumIndexXml)->toContain('/forum');
$forumCategoriesXml = $this->get('/sitemaps/forum-categories.xml')->assertOk()->getContent();
expect($forumCategoriesXml)
->toContain('/forum/category/general')
->toContain('/forum/general-board');
$forumThreadsXml = $this->get('/sitemaps/forum-threads.xml')->assertOk()->getContent();
expect($forumThreadsXml)
->toContain('/forum/topic/welcome-thread')
->not->toContain('#reply-content');
$staticPagesXml = $this->get('/sitemaps/static-pages.xml')->assertOk()->getContent();
expect($staticPagesXml)
->toContain('http://skinbase26.test/')
->toContain('/about')
->toContain('/pages/community-handbook')
->not->toContain('/pages/about');
$robots = $this->get('/robots.txt')
->assertOk()
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
->getContent();
expect($robots)->toContain('Sitemap: http://skinbase26.test/sitemap.xml');
});
it('returns 404 for unknown sitemap names', function (): void {
$this->get('/sitemaps/not-a-real-sitemap.xml')->assertNotFound();
});
it('lists shard urls in the sitemap index and serves shard compatibility indexes', function (): void {
config([
'sitemaps.shards.enabled' => true,
'sitemaps.shards.artworks.size' => 1,
'sitemaps.shards.users.size' => 1,
]);
$fixtures = seedSitemapFixtures();
$secondCreator = User::factory()->create([
'username' => 'sitemapuser2',
'is_active' => true,
]);
$secondArtwork = Artwork::factory()->create([
'user_id' => $secondCreator->id,
'title' => 'Second Sitemap Artwork',
'slug' => 'second-sitemap-artwork',
'hash' => str_repeat('d', 32),
'file_path' => 'artworks/original/dd/dd/' . str_repeat('d', 32) . '.jpg',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHours(12),
]);
$indexXml = $this->get('/sitemap.xml')->assertOk()->getContent();
expect($indexXml)
->toContain('/sitemaps/artworks-index.xml')
->toContain('/sitemaps/users-index.xml');
$artworksCompatibilityIndex = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
expect($artworksCompatibilityIndex)
->toContain('/sitemaps/artworks-0001.xml')
->toContain('/sitemaps/artworks-0002.xml');
$artworksShardOne = $this->get('/sitemaps/artworks-1.xml')->assertOk()->getContent();
$artworksShardTwo = $this->get('/sitemaps/artworks-2.xml')->assertOk()->getContent();
expect($artworksShardOne)
->toContain($fixtures['artwork_url'])
->not->toContain('/art/' . $secondArtwork->id . '/');
expect($artworksShardTwo)->toContain('/art/' . $secondArtwork->id . '/');
$allArtworkLocs = array_merge(extractUrlLocs($artworksShardOne), extractUrlLocs($artworksShardTwo));
expect(array_unique($allArtworkLocs))->toHaveCount(count($allArtworkLocs));
$this->get('/sitemaps/artworks-99.xml')->assertNotFound();
});
it('build command creates a validated sitemap release artifact set', function (): void {
Storage::fake('local');
config([
'sitemaps.shards.enabled' => true,
'sitemaps.shards.artworks.size' => 1,
'sitemaps.shards.users.size' => 1,
'sitemaps.releases.disk' => 'local',
'sitemaps.releases.path' => 'sitemaps',
]);
seedSitemapFixtures();
$secondCreator = User::factory()->create([
'username' => 'warmbuilder',
'is_active' => true,
]);
Artwork::factory()->create([
'user_id' => $secondCreator->id,
'title' => 'Warm Builder Artwork',
'slug' => 'warm-builder-artwork',
'hash' => str_repeat('e', 32),
'file_path' => 'artworks/original/ee/ee/' . str_repeat('e', 32) . '.jpg',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHours(6),
]);
$code = Artisan::call('skinbase:sitemaps:build', [
'--force' => true,
'--shards' => true,
]);
expect($code)->toBe(0)
->and(Artisan::output())
->toContain('Family [artworks] complete')
->toContain('Family [users] complete')
->toContain('Sitemap release [')
->toContain('status=validated');
$releases = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->listReleases();
expect($releases)->toHaveCount(1)
->and($releases[0]['status'])->toBe('validated');
$releaseId = $releases[0]['release_id'];
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemap.xml');
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/artworks-index.xml');
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/artworks-0001.xml');
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/manifest.json');
});
it('publish and validate commands serve the active sitemap release', function (): void {
Storage::fake('local');
config([
'sitemaps.shards.enabled' => true,
'sitemaps.shards.artworks.size' => 1,
'sitemaps.shards.users.size' => 1,
'sitemaps.releases.disk' => 'local',
'sitemaps.releases.path' => 'sitemaps',
]);
seedSitemapFixtures();
$secondCreator = User::factory()->create([
'username' => 'validatoruser',
'is_active' => true,
]);
Artwork::factory()->create([
'user_id' => $secondCreator->id,
'title' => 'Validator Artwork',
'slug' => 'validator-artwork',
'hash' => str_repeat('f', 32),
'file_path' => 'artworks/original/ff/ff/' . str_repeat('f', 32) . '.jpg',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHours(5),
]);
$publishCode = Artisan::call('skinbase:sitemaps:publish');
expect($publishCode)->toBe(0)
->and(Artisan::output())->toContain('Published sitemap release');
$activeRelease = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->activeReleaseId();
expect($activeRelease)->not->toBeNull();
$indexXml = $this->get('/sitemap.xml')->assertOk()->getContent();
expect($indexXml)
->toContain('/sitemaps/artworks-index.xml')
->toContain('/sitemaps/users-index.xml');
$artworksXml = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
expect($artworksXml)->toContain('/sitemaps/artworks-0001.xml');
$code = Artisan::call('skinbase:sitemaps:validate', ['--active' => true]);
expect($code)->toBe(0)
->and(Artisan::output())
->toContain('Family [artworks]')
->toContain('Family [users]')
->toContain('Sitemap validation passed.');
});
it('can rollback sitemap delivery to the previous published release', function (): void {
Storage::fake('local');
config([
'sitemaps.shards.enabled' => true,
'sitemaps.shards.artworks.size' => 1,
'sitemaps.shards.users.size' => 1,
'sitemaps.releases.disk' => 'local',
'sitemaps.releases.path' => 'sitemaps',
]);
$fixtures = seedSitemapFixtures();
expect(Artisan::call('skinbase:sitemaps:publish'))->toBe(0);
$firstRelease = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->activeReleaseId();
$secondCreator = User::factory()->create([
'username' => 'rollbackuser',
'is_active' => true,
]);
$newArtwork = Artwork::factory()->create([
'user_id' => $secondCreator->id,
'title' => 'Rollback Artwork',
'slug' => 'rollback-artwork',
'hash' => str_repeat('9', 32),
'file_path' => 'artworks/original/99/99/' . str_repeat('9', 32) . '.jpg',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHours(2),
]);
expect(Artisan::call('skinbase:sitemaps:publish'))->toBe(0);
$secondRelease = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->activeReleaseId();
expect($secondRelease)->not->toBe($firstRelease);
$currentIndex = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
expect($currentIndex)->toContain('/sitemaps/artworks-0002.xml');
expect(Artisan::call('skinbase:sitemaps:rollback', ['release' => $firstRelease]))->toBe(0);
$rolledBackIndex = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
expect($rolledBackIndex)
->toContain($fixtures['artwork_url'])
->not->toContain('/art/' . $newArtwork->id . '/');
});
/**
* @return array<string, int|string>
*/
function seedSitemapFixtures(): array
{
$creator = User::factory()->create([
'username' => 'sitemapuser',
'is_active' => true,
]);
$inactiveCreator = User::factory()->create([
'username' => 'inactivecreator',
'is_active' => false,
]);
$contentType = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography on Skinbase',
'order' => 1,
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Portraits',
'slug' => 'portraits',
'description' => 'Portrait photography',
'is_active' => true,
'sort_order' => 1,
]);
$publicTag = Tag::factory()->create([
'name' => 'Featured Tag',
'slug' => 'featured-tag',
'usage_count' => 10,
'is_active' => true,
]);
$inactiveTag = Tag::factory()->inactive()->create([
'name' => 'Inactive Tag',
'slug' => 'inactive-tag',
'usage_count' => 10,
]);
$artwork = Artwork::factory()->create([
'user_id' => $creator->id,
'title' => 'Sitemap Artwork',
'slug' => 'sitemap-artwork',
'hash' => str_repeat('a', 32),
'file_path' => 'artworks/original/aa/aa/' . str_repeat('a', 32) . '.jpg',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$artwork->categories()->attach($category->id);
$artwork->tags()->attach($publicTag->id, ['source' => 'user']);
$privateArtwork = Artwork::factory()->create([
'user_id' => $inactiveCreator->id,
'title' => 'Private Artwork',
'slug' => 'private-artwork',
'is_public' => false,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$privateArtwork->tags()->attach($inactiveTag->id, ['source' => 'user']);
$collection = Collection::factory()->create([
'user_id' => $creator->id,
'title' => 'Showcase Set',
'slug' => 'showcase-set',
'canonical_collection_id' => null,
]);
$cardCategory = NovaCardCategory::query()->create([
'slug' => 'mindset',
'name' => 'Mindset',
'description' => 'Mindset cards',
'active' => true,
'order_num' => 0,
]);
$cardTemplate = NovaCardTemplate::query()->create([
'slug' => 'sitemap-template',
'name' => 'Sitemap Template',
'description' => 'Sitemap test template',
'config_json' => ['font_preset' => 'modern-sans'],
'supported_formats' => ['square'],
'active' => true,
'official' => true,
'order_num' => 0,
]);
$card = NovaCard::query()->create([
'user_id' => $creator->id,
'category_id' => $cardCategory->id,
'template_id' => $cardTemplate->id,
'title' => 'Clarity Card',
'slug' => 'clarity-card',
'quote_text' => 'Clarity is a public card.',
'description' => 'A public card used for the sitemap test.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => ['layout' => ['layout' => 'quote_heavy']],
'render_version' => 1,
'background_type' => 'gradient',
'preview_path' => 'cards/previews/clarity-card.webp',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'published_at' => now()->subDay(),
]);
insertFiltered('stories', [
'slug' => 'creator-spotlight',
'title' => 'Creator Spotlight',
'excerpt' => 'A public story for the sitemap test.',
'content' => 'Story body',
'cover_image' => '/images/stories/creator-spotlight.webp',
'creator_id' => $creator->id,
'author_id' => null,
'featured' => true,
'status' => 'published',
'published_at' => now()->subDay(),
]);
Page::factory()->create([
'slug' => 'about',
'title' => 'About Skinbase',
'is_published' => true,
'published_at' => now()->subMonth(),
]);
Page::factory()->create([
'slug' => 'community-handbook',
'title' => 'Community Handbook',
'is_published' => true,
'published_at' => now()->subWeek(),
]);
$newsCategoryId = insertFiltered('news_categories', [
'name' => 'Updates',
'slug' => 'updates',
'is_active' => true,
]);
insertFiltered('news_articles', [
'title' => 'Platform Update',
'slug' => 'platform-update',
'excerpt' => 'A news article for the sitemap test.',
'content' => 'News body',
'cover_image' => 'news/platform-update.webp',
'author_id' => $creator->id,
'category_id' => $newsCategoryId,
'status' => 'published',
'published_at' => now()->subDay(),
'is_featured' => true,
'views' => 10,
]);
$forumCategoryId = insertFiltered('forum_categories', [
'name' => 'General',
'title' => 'General',
'slug' => 'general',
'description' => 'General forum category',
'is_active' => true,
'parent_id' => null,
'position' => 1,
]);
$forumBoardId = insertFiltered('forum_boards', [
'category_id' => $forumCategoryId,
'legacy_category_id' => null,
'title' => 'General Board',
'slug' => 'general-board',
'description' => 'General board',
'position' => 1,
'is_active' => true,
'is_read_only' => false,
]);
insertFiltered('forum_topics', [
'board_id' => $forumBoardId,
'user_id' => $creator->id,
'title' => 'Welcome Thread',
'slug' => 'welcome-thread',
'views' => 1,
'replies_count' => 0,
'is_pinned' => false,
'is_locked' => false,
'is_deleted' => false,
'last_post_at' => now()->subHours(2),
]);
return [
'artwork_url' => route('art.show', [
'id' => $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'private_artwork_id' => $privateArtwork->id,
'profile_url' => route('profile.show', ['username' => 'sitemapuser']),
'collection_url' => route('profile.collections.show', ['username' => 'sitemapuser', 'slug' => 'showcase-set']),
'card_url' => route('cards.show', ['slug' => 'clarity-card', 'id' => $card->id]),
];
}
function ensureNewsSchema(): void
{
if (! Schema::hasTable('news_categories')) {
Schema::create('news_categories', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
if (! Schema::hasTable('news_articles')) {
Schema::create('news_articles', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('content')->nullable();
$table->string('cover_image')->nullable();
$table->foreignId('author_id')->nullable();
$table->foreignId('category_id')->nullable();
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->boolean('is_featured')->default(false);
$table->unsignedInteger('views')->default(0);
$table->softDeletes();
$table->timestamps();
});
}
}
function ensureForumSchema(): void
{
if (! Schema::hasTable('forum_categories')) {
Schema::create('forum_categories', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('title')->nullable();
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->foreignId('parent_id')->nullable();
$table->integer('position')->default(0);
$table->timestamps();
});
} else {
ensureColumn('forum_categories', 'title', fn (Blueprint $table) => $table->string('title')->nullable());
ensureColumn('forum_categories', 'description', fn (Blueprint $table) => $table->text('description')->nullable());
ensureColumn('forum_categories', 'is_active', fn (Blueprint $table) => $table->boolean('is_active')->default(true));
}
if (! Schema::hasTable('forum_boards')) {
Schema::create('forum_boards', function (Blueprint $table): void {
$table->id();
$table->foreignId('category_id');
$table->foreignId('legacy_category_id')->nullable();
$table->string('title');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->integer('position')->default(0);
$table->boolean('is_active')->default(true);
$table->boolean('is_read_only')->default(false);
$table->timestamps();
});
}
if (! Schema::hasTable('forum_topics')) {
Schema::create('forum_topics', function (Blueprint $table): void {
$table->id();
$table->foreignId('board_id');
$table->foreignId('user_id')->nullable();
$table->foreignId('artwork_id')->nullable();
$table->unsignedBigInteger('legacy_thread_id')->nullable();
$table->string('title');
$table->string('slug')->unique();
$table->unsignedInteger('views')->default(0);
$table->unsignedInteger('replies_count')->default(0);
$table->boolean('is_pinned')->default(false);
$table->boolean('is_locked')->default(false);
$table->boolean('is_deleted')->default(false);
$table->unsignedBigInteger('last_post_id')->nullable();
$table->timestamp('last_post_at')->nullable();
$table->timestamps();
});
}
}
function ensureColumn(string $table, string $column, Closure $callback): void
{
if (! Schema::hasColumn($table, $column)) {
Schema::table($table, $callback);
}
}
/**
* @param array<string, mixed> $attributes
*/
function insertFiltered(string $table, array $attributes): int
{
$columns = array_flip(Schema::getColumnListing($table));
$payload = [];
foreach ($attributes as $column => $value) {
if (isset($columns[$column])) {
$payload[$column] = $value;
}
}
if (isset($columns['created_at']) && ! array_key_exists('created_at', $payload)) {
$payload['created_at'] = now();
}
if (isset($columns['updated_at']) && ! array_key_exists('updated_at', $payload)) {
$payload['updated_at'] = now();
}
return (int) DB::table($table)->insertGetId($payload);
}
/**
* @return list<string>
*/
function extractUrlLocs(string $xml): array
{
$document = simplexml_load_string($xml);
if ($document === false) {
return [];
}
$nodes = $document->xpath('//*[local-name()="url"]/*[local-name()="loc"]') ?: [];
return array_values(array_filter(array_map(
static fn ($node): string => trim((string) $node),
$nodes,
)));
}