'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('') ->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(''); $storiesXml = $this->get('/sitemaps/stories.xml')->assertOk()->getContent(); expect($storiesXml) ->toContain('/stories/creator-spotlight') ->toContain(''); $newsXml = $this->get('/sitemaps/news.xml')->assertOk()->getContent(); expect($newsXml) ->toContain('/news/platform-update') ->toContain(''); $googleNewsXml = $this->get('/sitemaps/news-google.xml')->assertOk()->getContent(); expect($googleNewsXml) ->toContain('') ->toContain('Platform Update'); $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 */ 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 $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 */ 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, ))); }