Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,555 @@
<?php
declare(strict_types=1);
use App\Enums\ModerationContentType;
use App\Enums\ModerationDomainStatus;
use App\Enums\ModerationRuleType;
use App\Enums\ModerationStatus;
use App\Models\ContentModerationFeedback;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ContentModerationActionLog;
use App\Models\ContentModerationCluster;
use App\Models\ContentModerationDomain;
use App\Models\ContentModerationFinding;
use App\Models\ContentModerationRule;
use App\Models\UserProfile;
use App\Models\UserSocialLink;
use App\Models\User;
use App\Services\Moderation\ContentModerationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Klevze\ControlPanel\Models\Admin\AdminVerification;
uses(RefreshDatabase::class);
function createControlPanelAdmin(): User
{
$admin = User::factory()->create(['role' => 'admin']);
$admin->forceFill([
'isAdmin' => true,
'activated' => true,
])->save();
AdminVerification::createForUser($admin->fresh());
return $admin->fresh();
}
it('loads the cpad moderation list and detail screens for admins', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id + 100,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::ConfirmedSpam->value,
'severity' => 'critical',
'score' => 120,
'content_hash' => hash('sha256', 'spam-description-prior'),
'scanner_version' => '2.0',
'content_snapshot' => 'Earlier spam finding',
]);
ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id + 101,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'medium',
'score' => 45,
'content_hash' => hash('sha256', 'spam-description-pending'),
'scanner_version' => '2.0',
'content_snapshot' => 'Pending review finding',
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'high',
'score' => 95,
'content_hash' => hash('sha256', 'spam-description'),
'scanner_version' => '1.0',
'reasons_json' => ['Contains spam keywords'],
'matched_links_json' => ['https://promo.pornsite.com'],
'matched_domains_json' => ['promo.pornsite.com'],
'matched_keywords_json' => ['buy followers'],
'content_snapshot' => 'Buy followers now',
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.main'))
->assertOk()
->assertSee('Content Moderation')
->assertSee((string) $finding->id);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.show', $finding))
->assertOk()
->assertSee('Buy followers now')
->assertSee('Contains spam keywords')
->assertSee('Related Findings')
->assertSee('Confirmed Spam')
->assertSee('Pending Findings');
});
it('supports sortable moderation list columns in cpad', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'critical',
'score' => 120,
'content_hash' => hash('sha256', 'first'),
'scanner_version' => '1.0',
'content_snapshot' => 'critical finding',
]);
ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id + 1,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'medium',
'score' => 35,
'content_hash' => hash('sha256', 'second'),
'scanner_version' => '1.0',
'content_snapshot' => 'medium finding',
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.main', ['sort' => 'score', 'direction' => 'asc']))
->assertOk()
->assertSee('Score')
->assertSee('ASC');
});
it('updates finding review status from the cpad moderation actions', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'high',
'score' => 95,
'content_hash' => hash('sha256', 'spam-description'),
'scanner_version' => '1.0',
'content_snapshot' => 'Buy followers now',
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.safe', $finding), ['admin_notes' => 'false positive'])
->assertRedirect();
$finding->refresh();
expect($finding->status)->toBe(ModerationStatus::ReviewedSafe)
->and($finding->admin_notes)->toBe('false positive')
->and($finding->reviewed_by)->toBe($admin->id);
});
it('can hide flagged artwork comments from the cpad moderation screen', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkComment->value,
'content_id' => $comment->id,
'artwork_id' => $artwork->id,
'user_id' => $comment->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'critical',
'score' => 120,
'content_hash' => hash('sha256', 'spam-comment'),
'scanner_version' => '1.0',
'content_snapshot' => $comment->raw_content,
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.hide', $finding), ['admin_notes' => 'hidden by moderation'])
->assertRedirect();
expect($comment->fresh()->is_approved)->toBeFalse()
->and($finding->fresh()->status)->toBe(ModerationStatus::ConfirmedSpam)
->and($finding->fresh()->action_taken)->toBe('hide_comment');
});
it('loads the v2 moderation dashboard, domains, rules, and actions pages', function (): void {
$admin = createControlPanelAdmin();
$domain = ContentModerationDomain::query()->create([
'domain' => 'promo.pornsite.com',
'status' => ModerationDomainStatus::Blocked,
'times_seen' => 3,
'times_flagged' => 2,
'times_confirmed_spam' => 1,
]);
$rule = ContentModerationRule::query()->create([
'type' => ModerationRuleType::SuspiciousKeyword,
'value' => 'rare promo blast',
'enabled' => true,
'weight' => 20,
'created_by' => $admin->id,
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkComment->value,
'content_id' => 99,
'status' => ModerationStatus::Pending->value,
'severity' => 'high',
'score' => 95,
'content_hash' => hash('sha256', 'finding'),
'scanner_version' => '2.0',
'content_snapshot' => 'spam snapshot',
]);
ContentModerationActionLog::query()->create([
'finding_id' => $finding->id,
'target_type' => 'finding',
'target_id' => $finding->id,
'action_type' => 'rescan',
'actor_type' => 'admin',
'actor_id' => $admin->id,
'created_at' => now(),
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.dashboard'))
->assertOk()
->assertSee('Content Moderation Dashboard')
->assertSee('Top Flagged Domains');
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.domains'))
->assertOk()
->assertSee($domain->domain);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.rules'))
->assertOk()
->assertSee($rule->value);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.actions'))
->assertOk()
->assertSee('rescan');
});
it('supports proactive domain creation and domain detail inspection from cpad', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.domains.store'), [
'domain' => 'promo.example.com',
'status' => ModerationDomainStatus::Blocked->value,
'notes' => 'Manual blocklist entry',
]);
$domain = ContentModerationDomain::query()->where('domain', 'promo.example.com')->firstOrFail();
ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'high',
'score' => 95,
'content_hash' => hash('sha256', 'domain-linked-finding'),
'scanner_version' => '2.0',
'matched_domains_json' => ['promo.example.com'],
'content_snapshot' => 'Linked to promo domain',
]);
$response->assertRedirect(route('admin.site.content-moderation.domains.show', $domain));
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.domains.show', $domain))
->assertOk()
->assertSee('promo.example.com')
->assertSee('Linked Findings')
->assertSee('Manual blocklist entry');
});
it('applies admin managed domains to moderation analysis', function (): void {
$admin = createControlPanelAdmin();
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.domains.store'), [
'domain' => 'promo.example.com',
'status' => ModerationDomainStatus::Blocked->value,
])
->assertRedirect();
$result = app(ContentModerationService::class)->analyze('Visit https://promo.example.com/deal right now.', [
'user_id' => $admin->id,
]);
expect($result->matchedDomains)->toContain('promo.example.com')
->and($result->ruleHits)->toHaveKey('blocked_domain')
->and($result->status)->toBe(ModerationStatus::Pending);
});
it('applies admin managed rules to moderation analysis', function (): void {
$admin = createControlPanelAdmin();
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.rules.store'), [
'type' => ModerationRuleType::SuspiciousKeyword->value,
'value' => 'rare promo blast',
'enabled' => '1',
'weight' => 20,
])
->assertRedirect();
$result = app(ContentModerationService::class)->analyze('This rare promo blast just dropped.');
expect($result->matchedKeywords)->toContain('rare promo blast')
->and($result->ruleHits)->toHaveKey('suspicious_keyword')
->and($result->score)->toBeGreaterThan(0)
->and($result->reasons)->toContain('Contains suspicious keyword(s): rare promo blast');
});
it('reports partial bulk action failures explicitly', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
$domainFinding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'high',
'score' => 80,
'content_hash' => hash('sha256', 'bulk-domain-finding'),
'scanner_version' => '2.0',
'matched_domains_json' => ['promo.bulk-test.com'],
'content_snapshot' => 'Has a domain',
]);
$noDomainFinding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id + 1,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'medium',
'score' => 40,
'content_hash' => hash('sha256', 'bulk-no-domain-finding'),
'scanner_version' => '2.0',
'matched_domains_json' => [],
'content_snapshot' => 'No domain here',
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.bulk'), [
'action' => 'block_domains',
'finding_ids' => [$domainFinding->id, $noDomainFinding->id],
])
->assertRedirect(route('admin.site.content-moderation.main'))
->assertSessionHas('msg_success', 'Processed 1 moderation item(s).')
->assertSessionHas('msg_warning');
expect(ContentModerationDomain::query()->where('domain', 'promo.bulk-test.com')->where('status', ModerationDomainStatus::Blocked->value)->exists())->toBeTrue()
->and(ContentModerationActionLog::query()->where('target_type', 'domain')->where('target_id', ContentModerationDomain::query()->where('domain', 'promo.bulk-test.com')->value('id'))->exists())->toBeTrue();
});
it('restores auto hidden comments from the cpad moderation screen', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'is_approved' => false,
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkComment->value,
'content_id' => $comment->id,
'artwork_id' => $artwork->id,
'user_id' => $comment->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'critical',
'score' => 120,
'content_hash' => hash('sha256', 'spam-comment-restored'),
'scanner_version' => '2.0',
'content_snapshot' => $comment->raw_content,
'is_auto_hidden' => true,
'auto_action_taken' => 'auto_hide_comment',
'auto_hidden_at' => now(),
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.restore', $finding), ['admin_notes' => 'restored after review'])
->assertRedirect();
expect($comment->fresh()->is_approved)->toBeTrue()
->and($finding->fresh()->is_auto_hidden)->toBeFalse()
->and($finding->fresh()->restored_by)->toBe($admin->id);
});
it('loads the v3 moderation queue, cluster, user, policy, and feedback screens', function (): void {
$admin = createControlPanelAdmin();
$flaggedUser = User::factory()->create();
$artwork = Artwork::factory()->create(['user_id' => $flaggedUser->id]);
UserProfile::query()->create([
'user_id' => $flaggedUser->id,
'about' => 'Profile with promotional links',
]);
UserSocialLink::query()->create([
'user_id' => $flaggedUser->id,
'platform' => 'website',
'url' => 'https://promo.cluster-test.com',
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::UserProfileLink->value,
'content_id' => UserSocialLink::query()->where('user_id', $flaggedUser->id)->value('id'),
'content_target_type' => UserSocialLink::class,
'content_target_id' => UserSocialLink::query()->where('user_id', $flaggedUser->id)->value('id'),
'artwork_id' => $artwork->id,
'user_id' => $flaggedUser->id,
'status' => ModerationStatus::Pending->value,
'severity' => 'critical',
'score' => 140,
'priority_score' => 99,
'review_bucket' => 'urgent',
'policy_name' => 'strict_seo_protection',
'campaign_key' => 'campaign:test-cluster',
'cluster_reason' => 'Shared promotional domain and keywords',
'cluster_score' => 88,
'content_hash' => hash('sha256', 'cluster-finding'),
'scanner_version' => '3.0',
'matched_domains_json' => ['promo.cluster-test.com'],
'matched_keywords_json' => ['promo blast'],
'content_snapshot' => 'Visit https://promo.cluster-test.com for promo blast offers',
'ai_label' => 'spam',
'ai_suggested_action' => 'review',
'ai_confidence' => 91,
'ai_provider' => 'heuristic',
]);
ContentModerationCluster::query()->create([
'campaign_key' => 'campaign:test-cluster',
'cluster_reason' => 'Shared promotional domain and keywords',
'cluster_score' => 88,
]);
ContentModerationFeedback::query()->create([
'finding_id' => $finding->id,
'actor_id' => $admin->id,
'feedback_type' => 'reviewed_safe',
'notes' => 'Operator reviewed queue behaviour',
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.priority'))
->assertOk()
->assertSee('Priority Findings')
->assertSee((string) $finding->id);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.clusters'))
->assertOk()
->assertSee('Campaign Clusters')
->assertSee('campaign:test-cluster');
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.clusters.show', ['campaignKey' => 'campaign:test-cluster']))
->assertOk()
->assertSee('Cluster Findings')
->assertSee('Shared promotional domain and keywords');
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.users'))
->assertOk()
->assertSee('User Moderation Profiles')
->assertSee($flaggedUser->username ?? $flaggedUser->name);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.users.show', ['user' => $flaggedUser->id]))
->assertOk()
->assertSee('Profile Summary')
->assertSee($flaggedUser->username ?? $flaggedUser->name);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.policies'))
->assertOk()
->assertSee('Moderation Policies')
->assertSee('strict_seo_protection');
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.site.content-moderation.feedback'))
->assertOk()
->assertSee('Reviewer Feedback')
->assertSee('Operator reviewed queue behaviour');
});
it('marks findings as false positives from the cpad moderation screen', function (): void {
$admin = createControlPanelAdmin();
$artwork = Artwork::factory()->create();
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkTitle->value,
'content_id' => $artwork->id,
'content_target_type' => Artwork::class,
'content_target_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => ModerationStatus::Pending->value,
'severity' => 'high',
'score' => 85,
'priority_score' => 80,
'content_hash' => hash('sha256', 'artwork-title-false-positive'),
'scanner_version' => '3.0',
'content_snapshot' => 'Totally legitimate artwork title',
]);
$this->actingAs($admin)
->actingAs($admin, 'controlpanel')
->post(route('admin.site.content-moderation.false-positive', $finding), ['admin_notes' => 'Safe brand reference'])
->assertRedirect(route('admin.site.content-moderation.show', $finding));
$finding->refresh();
expect($finding->is_false_positive)->toBeTrue()
->and($finding->false_positive_count)->toBeGreaterThanOrEqual(1)
->and($finding->admin_notes)->toBe('Safe brand reference')
->and(ContentModerationFeedback::query()->where('finding_id', $finding->id)->where('feedback_type', 'false_positive')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,41 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use App\Services\Artworks\ArtworkDraftService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('allows duplicate artwork slugs when creating drafts', function () {
$user = User::factory()->create();
$drafts = app(ArtworkDraftService::class);
$first = $drafts->createDraft($user->id, 'Silent', null);
$second = $drafts->createDraft($user->id, 'Silent', null);
expect(Artwork::query()->findOrFail($first->artworkId)->slug)->toBe('silent')
->and(Artwork::query()->findOrFail($second->artworkId)->slug)->toBe('silent');
});
it('resolves public artwork pages by id even when slugs are duplicated', function () {
$first = Artwork::factory()->create([
'title' => 'Silent',
'slug' => 'silent',
'description' => 'First silent artwork.',
]);
$second = Artwork::factory()->create([
'title' => 'Silent',
'slug' => 'silent',
'description' => 'Second silent artwork.',
]);
$this->get(route('art.show', ['id' => $first->id, 'slug' => 'silent']))
->assertOk()
->assertSee('First silent artwork.', false);
$this->get(route('art.show', ['id' => $second->id, 'slug' => 'silent']))
->assertOk()
->assertSee('Second silent artwork.', false);
});

View File

@@ -0,0 +1,49 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('copies published_at into created_at when an artwork is published', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->unpublished()->create([
'user_id' => $user->id,
'created_at' => Carbon::parse('2026-03-01 10:00:00'),
'updated_at' => Carbon::parse('2026-03-01 10:00:00'),
]);
$publishedAt = Carbon::parse('2026-03-29 14:30:00');
$artwork->forceFill([
'published_at' => $publishedAt,
'artwork_status' => 'published',
'is_public' => true,
'is_approved' => true,
])->save();
$artwork->refresh();
expect($artwork->created_at?->toDateTimeString())->toBe($publishedAt->toDateTimeString());
expect(DB::table('user_statistics')->where('user_id', $user->id)->value('last_upload_at'))->toBe($publishedAt->toDateTimeString());
});
it('syncs created_at from published_at for existing artworks via command', function () {
$artwork = Artwork::factory()->create([
'created_at' => Carbon::parse('2026-03-01 10:00:00'),
'updated_at' => Carbon::parse('2026-03-01 10:00:00'),
'published_at' => Carbon::parse('2026-03-25 08:15:00'),
]);
$exitCode = Artisan::call('artworks:sync-created-at');
expect($exitCode)->toBe(0);
$createdAt = DB::table('artworks')->where('id', $artwork->id)->value('created_at');
expect(Carbon::parse($createdAt)->toDateTimeString())->toBe('2026-03-25 08:15:00');
});

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ThumbnailService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('s3');
$suffix = Str::lower(Str::random(10));
$this->localOriginalsRoot = storage_path('framework/testing/square-command-originals-' . $suffix);
File::deleteDirectory($this->localOriginalsRoot);
File::ensureDirectoryExists($this->localOriginalsRoot);
config()->set('uploads.local_originals_root', $this->localOriginalsRoot);
config()->set('uploads.object_storage.disk', 's3');
config()->set('uploads.object_storage.prefix', 'artworks');
});
afterEach(function () {
File::deleteDirectory($this->localOriginalsRoot);
});
function generateSqCommandImage(string $root, string $filename = 'source.jpg'): string
{
$path = $root . DIRECTORY_SEPARATOR . $filename;
$image = imagecreatetruecolor(1200, 800);
$background = imagecolorallocate($image, 18, 22, 29);
$subject = imagecolorallocate($image, 245, 180, 110);
imagefilledrectangle($image, 0, 0, 1200, 800, $background);
imagefilledellipse($image, 280, 340, 360, 380, $subject);
imagejpeg($image, $path, 90);
imagedestroy($image);
return $path;
}
function generateSqCommandWebp(string $root, string $filename = 'source.webp'): string
{
$path = $root . DIRECTORY_SEPARATOR . $filename;
$image = imagecreatetruecolor(1400, 900);
$background = imagecolorallocate($image, 16, 21, 28);
$subject = imagecolorallocate($image, 242, 194, 94);
imagefilledrectangle($image, 0, 0, 1400, 900, $background);
imagefilledellipse($image, 360, 420, 420, 360, $subject);
imagewebp($image, $path, 90);
imagedestroy($image);
return $path;
}
function seedArtworkWithOriginal(string $localOriginalsRoot): Artwork
{
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$source = generateSqCommandImage($localOriginalsRoot, 'seed.jpg');
$hash = hash_file('sha256', $source);
$storage = app(UploadStorageService::class);
$localTarget = $storage->localOriginalPath($hash, $hash . '.jpg');
File::ensureDirectoryExists(dirname($localTarget));
File::copy($source, $localTarget);
$artwork->update([
'hash' => $hash,
'file_ext' => 'jpg',
'thumb_ext' => 'webp',
'width' => 1200,
'height' => 800,
]);
DB::table('artwork_files')->insert([
'artwork_id' => $artwork->id,
'variant' => 'orig_image',
'path' => "artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg",
'mime' => 'image/jpeg',
'size' => (int) filesize($localTarget),
]);
return $artwork->fresh();
}
it('generates missing square thumbnails from the best available source', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs');
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue();
$sqSize = getimagesizefromstring(Storage::disk('s3')->get($sqPath));
expect($sqSize[0] ?? null)->toBe(512)
->and($sqSize[1] ?? null)->toBe(512);
});
it('supports dry run without writing sq thumbnails', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--dry-run' => true]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('planned=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertMissing($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeFalse();
});
it('forces regeneration when an sq row already exists', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
DB::table('artwork_files')->insert([
'artwork_id' => $artwork->id,
'variant' => 'sq',
'path' => "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp",
'mime' => 'image/webp',
'size' => 0,
]);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--force' => true]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
});
it('purges the canonical sq url after regeneration when Cloudflare is configured', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', 'test-zone');
config()->set('cdn.cloudflare.api_token', 'test-token');
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]);
expect($code)->toBe(0);
Http::assertSent(function ($request) use ($artwork): bool {
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
&& $request['files'] === [
'https://cdn.skinbase.org/artworks/sq/' . substr($artwork->hash, 0, 2) . '/' . substr($artwork->hash, 2, 2) . '/' . $artwork->hash . '.webp',
];
});
});
it('falls back to canonical CDN derivatives for legacy artworks without artwork_files rows', function () {
$cdnRoot = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . 'fake-cdn';
File::ensureDirectoryExists($cdnRoot);
config()->set('cdn.files_url', 'file:///' . str_replace(DIRECTORY_SEPARATOR, '/', $cdnRoot));
$user = User::factory()->create();
$hash = '6183c98975512ee6bff4657043067953a33769c7';
$artwork = Artwork::factory()->for($user)->unpublished()->create([
'hash' => $hash,
'file_ext' => 'jpg',
'thumb_ext' => 'webp',
'file_path' => 'legacy/uploads/IMG_20210727_090534.jpg',
'width' => 4624,
'height' => 2080,
]);
$xlUrl = ThumbnailService::fromHash($hash, 'webp', 'xl');
$webpSource = generateSqCommandWebp($this->localOriginalsRoot, 'legacy-xl.webp');
$xlPath = $cdnRoot . DIRECTORY_SEPARATOR . 'artworks' . DIRECTORY_SEPARATOR . 'xl' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.webp';
File::ensureDirectoryExists(dirname($xlPath));
File::copy($webpSource, $xlPath);
expect($xlUrl)->toContain('/artworks/xl/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash . '.webp');
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue();
});

View File

@@ -242,6 +242,29 @@ it('public collection routes redirect to the canonical target after canonicaliza
->assertRedirect(route('profile.collections.show', ['username' => strtolower((string) $owner->username), 'slug' => $target->slug]));
});
it('public collection detail exposes normalized seo props', function () {
$owner = User::factory()->create(['username' => 'seocollectionowner']);
$collection = Collection::factory()->for($owner)->create([
'title' => 'SEO Collection Detail',
'slug' => 'seo-collection-detail',
'description' => 'A public collection used to verify normalized SEO props.',
'visibility' => Collection::VISIBILITY_PUBLIC,
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
'moderation_status' => Collection::MODERATION_ACTIVE,
'workflow_state' => Collection::WORKFLOW_APPROVED,
]);
$this->get(route('profile.collections.show', ['username' => strtolower((string) $owner->username), 'slug' => $collection->slug]))
->assertOk()
->assertInertia(fn ($page) => $page
->component('Collection/CollectionShow')
->where('seo.title', fn ($value) => is_string($value) && str_contains($value, 'SEO Collection Detail'))
->where('seo.description', fn ($value) => is_string($value) && str_contains($value, 'verify normalized SEO props'))
->where('seo.robots', 'index,follow')
->where('seo.canonical', route('profile.collections.show', ['username' => strtolower((string) $owner->username), 'slug' => $collection->slug])));
});
it('owners can dismiss duplicate candidates from merge review', function () {
$owner = User::factory()->create(['username' => 'mergedismissowner']);
@@ -617,6 +640,31 @@ it('public search only returns safe public collections', function () {
->where('collections.0.title', 'Visible Search Result'));
});
it('public collection search exposes noindex seo props on filtered pages', function () {
$owner = User::factory()->create(['username' => 'seosearchowner']);
Collection::factory()->for($owner)->create([
'title' => 'SEO Search Target',
'slug' => 'seo-search-target',
'visibility' => Collection::VISIBILITY_PUBLIC,
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
'moderation_status' => Collection::MODERATION_ACTIVE,
'workflow_state' => Collection::WORKFLOW_APPROVED,
'placement_eligibility' => true,
]);
$url = route('collections.search', ['q' => 'SEO Search Target']);
$this->get($url)
->assertOk()
->assertInertia(fn ($page) => $page
->component('Collection/CollectionFeaturedIndex')
->where('seo.robots', 'noindex,follow')
->where('seo.canonical', $url)
->where('seo.title', 'Search Collections — Skinbase Nova')
->where('seo.description', 'Search results for "SEO Search Target" across public Skinbase Nova collections.'));
});
it('saved collections support private saved notes', function () {
$user = User::factory()->create(['username' => 'savednoteowner']);
$curator = User::factory()->create(['username' => 'savednotecurator']);

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\Vision\AiArtworkVectorSearchService;
use App\Services\Vision\ArtworkVectorIndexService;
use App\Services\Vision\ArtworkVectorMetadataService;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function bindVectorService(): void
{
$imageUrl = new ArtworkVisionImageUrl();
app()->instance(VectorService::class, new VectorService(
new AiArtworkVectorSearchService(new VectorGatewayClient(), $imageUrl),
new ArtworkVectorIndexService(new VectorGatewayClient(), $imageUrl, new ArtworkVectorMetadataService()),
));
}
it('indexes artworks by latest updated_at descending by default', function (): void {
$user = User::factory()->create();
$oldest = Artwork::factory()->for($user)->create([
'title' => 'Oldest artwork',
'slug' => 'oldest-artwork',
'hash' => str_repeat('a', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(3),
'updated_at' => now()->subDays(3),
]);
$middle = Artwork::factory()->for($user)->create([
'title' => 'Middle artwork',
'slug' => 'middle-artwork',
'hash' => str_repeat('b', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
$latest = Artwork::factory()->for($user)->create([
'title' => 'Latest artwork',
'slug' => 'latest-artwork',
'hash' => str_repeat('c', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'updated_at' => now()->subHour(),
]);
bindVectorService();
$code = Artisan::call('artworks:vectors-index', [
'--dry-run' => true,
'--public-only' => true,
'--limit' => 3,
'--batch' => 3,
]);
$output = Artisan::output();
expect($code)->toBe(0);
$latestPos = strpos($output, 'Processing artwork=' . $latest->id);
$middlePos = strpos($output, 'Processing artwork=' . $middle->id);
$oldestPos = strpos($output, 'Processing artwork=' . $oldest->id);
expect($latestPos)->not->toBeFalse()
->and($middlePos)->not->toBeFalse()
->and($oldestPos)->not->toBeFalse()
->and($latestPos)->toBeLessThan($middlePos)
->and($middlePos)->toBeLessThan($oldestPos);
});
it('supports legacy id ascending order when explicitly requested', function (): void {
$user = User::factory()->create();
$first = Artwork::factory()->for($user)->create([
'title' => 'First artwork',
'slug' => 'first-artwork-' . Str::lower(Str::random(4)),
'hash' => str_repeat('d', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'updated_at' => now(),
]);
$second = Artwork::factory()->for($user)->create([
'title' => 'Second artwork',
'slug' => 'second-artwork-' . Str::lower(Str::random(4)),
'hash' => str_repeat('e', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
bindVectorService();
$code = Artisan::call('artworks:vectors-index', [
'--dry-run' => true,
'--public-only' => true,
'--limit' => 2,
'--batch' => 2,
'--order' => 'id-asc',
]);
$output = Artisan::output();
expect($code)->toBe(0);
$firstPos = strpos($output, 'Processing artwork=' . $first->id);
$secondPos = strpos($output, 'Processing artwork=' . $second->id);
expect($firstPos)->not->toBeFalse()
->and($secondPos)->not->toBeFalse()
->and($firstPos)->toBeLessThan($secondPos);
});

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Enums\ModerationContentType;
use App\Models\ContentModerationActionLog;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ContentModerationFinding;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Log\Events\MessageLogged;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
uses(RefreshDatabase::class);
it('scans artwork comments and descriptions and stores suspicious findings', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'content' => 'Visit my site at https://promo.pornsite.com now',
'raw_content' => 'Visit my site at https://promo.pornsite.com now',
]);
$code = Artisan::call('skinbase:scan-content-moderation');
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->count())->toBe(2)
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkComment)->exists())->toBeTrue()
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->exists())->toBeTrue();
});
it('does not create duplicate findings for unchanged content', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
expect(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->count())->toBe(1)
->and(ContentModerationFinding::query()->first()?->content_id)->toBe($artwork->id);
});
it('supports dry runs without persisting findings', function (): void {
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--dry-run' => true]);
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->count())->toBe(0);
});
it('logs a command summary after scanning', function (): void {
Event::fake([MessageLogged::class]);
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
expect($code)->toBe(0);
Event::assertDispatched(MessageLogged::class, function (MessageLogged $event): bool {
return $event->level === 'info'
&& $event->message === 'Content moderation scan complete.'
&& ($event->context['targets'] ?? []) === ['artwork_description']
&& ($event->context['counts']['scanned'] ?? 0) === 1
&& ($event->context['counts']['flagged'] ?? 0) === 1;
});
});
it('shows target progress and verbose finding details while scanning', function (): void {
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', [
'--only' => 'descriptions',
'--verbose' => true,
]);
$output = Artisan::output();
expect($code)->toBe(0)
->and($output)->toContain('Starting content moderation scan...')
->and($output)->toContain('Scanning Artwork Description entries...')
->and($output)->toContain('[artwork_description #')
->and($output)->toContain('flagged')
->and($output)->toContain('Finished Artwork Description: scanned=1, flagged=1');
});
it('auto hides critical comment spam while keeping the finding', function (): void {
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'content' => 'Buy followers now at https://promo.pornsite.com and claim your crypto giveaway',
'raw_content' => 'Buy followers now at https://promo.pornsite.com and claim your crypto giveaway',
'is_approved' => true,
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--only' => 'comments']);
$finding = ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkComment)->first();
expect($code)->toBe(0)
->and($finding)->not->toBeNull()
->and($finding?->is_auto_hidden)->toBeTrue()
->and($finding?->action_taken)->toBe('auto_hide_comment')
->and($comment->fresh()->is_approved)->toBeFalse();
});
it('rescans existing findings with the latest rules', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => 'pending',
'severity' => 'high',
'score' => 90,
'content_hash' => hash('sha256', 'old-hash'),
'scanner_version' => '1.0',
'content_snapshot' => 'old snapshot',
]);
$code = Artisan::call('skinbase:rescan-content-moderation', ['--only' => 'descriptions']);
$scannerVersion = (string) config('content_moderation.scanner_version');
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->where('content_id', $artwork->id)->where('scanner_version', $scannerVersion)->exists())->toBeTrue()
->and(ContentModerationActionLog::query()->where('action_type', 'rescan')->exists())->toBeTrue();
});

View File

@@ -12,6 +12,17 @@ describe('Footer pages', function () {
->assertSee('Frequently Asked Questions');
});
it('FAQ page includes structured data for visible questions', function () {
$html = $this->get('/faq')
->assertOk()
->getContent();
expect($html)
->toContain('application/ld+json')
->toContain('FAQPage')
->toContain('Is Skinbase free to use?');
});
it('shows the Rules & Guidelines page', function () {
$this->get('/rules-and-guidelines')
->assertOk()

View File

@@ -41,6 +41,19 @@ it('home page includes a canonical link tag', function () {
->assertSee('rel="canonical"', false);
});
it('home page emits unified SEO tags and structured data', function () {
$html = $this->get('/')
->assertStatus(200)
->getContent();
expect($html)
->toContain('name="description"')
->toContain('property="og:title"')
->toContain('name="twitter:card"')
->toContain('application/ld+json')
->toContain('WebSite');
});
it('home page with ?page=2 renders without errors', function () {
// getLatestArtworks() returns a plain Collection (no pagination),
// so seoNext/seoPrev for home are always null — but the page must still render cleanly.

View File

@@ -0,0 +1,12 @@
<?php
it('leaderboard exposes normalized seo props to inertia', function () {
$this->get('/leaderboard')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Leaderboard/LeaderboardPage')
->where('seo.title', 'Top Creators & Artworks Leaderboard — Skinbase')
->where('seo.description', fn ($value) => is_string($value) && str_contains($value, 'leading creators'))
->where('seo.robots', 'index,follow')
->where('seo.canonical', url('/leaderboard')));
});

View File

@@ -13,6 +13,7 @@ use App\Models\NovaCardCategory;
use App\Models\NovaCardBackground;
use App\Models\NovaCardTemplate;
use App\Models\User;
use App\Services\NovaCards\NovaCardBackgroundService;
use App\Services\NovaCards\NovaCardRenderService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
@@ -413,6 +414,40 @@ it('rejects malformed background uploads without throwing', function (): void {
@unlink($tempPath);
});
it('stores uploaded backgrounds when real path falls back to pathname', function (): void {
Storage::fake('local');
Storage::fake('public');
config()->set('nova_cards.storage.private_disk', 'local');
config()->set('nova_cards.storage.public_disk', 'public');
$user = User::factory()->create();
$tempFile = UploadedFile::fake()->image('background.png', 1400, 1000);
$pathname = $tempFile->getPathname();
$upload = new class($pathname) extends UploadedFile {
public function __construct(string $path)
{
parent::__construct($path, 'background.png', 'image/png', \UPLOAD_ERR_OK, true);
}
public function getRealPath(): string|false
{
return false;
}
};
$background = app(NovaCardBackgroundService::class)->storeUploadedBackground($user, $upload);
expect($background->width)->toBe(1400)
->and($background->height)->toBe(1000)
->and($background->sha256)->not->toBeNull();
Storage::disk('local')->assertExists($background->original_path);
Storage::disk('public')->assertExists($background->processed_path);
});
it('renders a draft preview through the render endpoint', function (): void {
$user = User::factory()->create();
$card = draftCard($user);

View File

@@ -362,6 +362,12 @@ it('renders the public card detail page and increments views', function (): void
->toContain('Copy link')
->toContain('data-card-report');
expect(substr_count($response->getContent(), 'property="og:title"'))->toBe(1);
expect(substr_count($response->getContent(), '<link rel="canonical"'))->toBe(1);
expect($response->getContent())
->toContain('property="og:image"')
->toContain('application/ld+json');
expect($card->fresh()->views_count)->toBe(8);
Event::assertDispatched(NovaCardViewed::class);
});

View File

@@ -118,11 +118,11 @@ it('renders the studio cards index with user stats and edit endpoints', function
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardsIndex')
->where('stats.all', 1)
->where('stats.drafts', 1)
->where('cards.data.0.title', 'My Studio Draft')
->where('endpoints.create', route('studio.cards.create'))
->where('endpoints.draftStore', route('api.cards.drafts.store')));
->where('title', 'Cards')
->where('summary.count', 1)
->where('summary.draft_count', 1)
->where('listing.items.0.title', 'My Studio Draft')
->where('publicBrowseUrl', '/cards'));
});
it('renders the create editor with preview mode disabled', function (): void {

View File

@@ -81,8 +81,8 @@ it('GET /discover redirects to /discover/trending with 301', function () {
$this->get('/discover')->assertRedirect('/discover/trending')->assertStatus(301);
});
it('GET /sections returns the live sections page', function () {
$this->get('/sections')->assertOk()->assertSee('Browse Sections', false);
it('GET /sections redirects to /categories with 301', function () {
$this->get('/sections')->assertRedirect('/categories')->assertStatus(301);
});
it('GET /browse-categories redirects to /categories with 301', function () {

View File

@@ -0,0 +1,699 @@
<?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,
)));
}

View File

@@ -156,11 +156,23 @@ it('published story page renders successfully', function () {
'published_at' => now()->subMinute(),
]);
$this->get(route('stories.show', ['slug' => $story->slug]))
$response = $this->get(route('stories.show', ['slug' => $story->slug]));
$response
->assertOk()
->assertSee('Renderable Story', false)
->assertSee('language-bash', false)
->assertDontSee('{"type":"doc"', false);
$html = $response->getContent();
expect($html)
->toContain('application/ld+json')
->toContain('"Article"')
->toContain('property="og:type" content="article"');
expect(substr_count($html, 'property="og:title"'))->toBe(1);
expect(substr_count($html, '<link rel="canonical"'))->toBe(1);
});
it('creator can publish through story editor api create endpoint', function () {

View File

@@ -4,6 +4,7 @@ use App\Models\User;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
/**
* Helper: create an artwork without triggering observers (avoids GREATEST() SQLite issue).
@@ -32,9 +33,28 @@ test('studio routes require authentication', function () {
$routes = [
'/studio',
'/studio/content',
'/studio/artworks',
'/studio/artworks/drafts',
'/studio/artworks/archived',
'/studio/cards',
'/studio/collections',
'/studio/stories',
'/studio/drafts',
'/studio/scheduled',
'/studio/calendar',
'/studio/archived',
'/studio/assets',
'/studio/activity',
'/studio/inbox',
'/studio/challenges',
'/studio/analytics',
'/studio/growth',
'/studio/search',
'/studio/comments',
'/studio/followers',
'/studio/profile',
'/studio/featured',
'/studio/preferences',
'/studio/settings',
];
foreach ($routes as $route) {
@@ -44,22 +64,272 @@ test('studio routes require authentication', function () {
test('studio dashboard loads for authenticated user', function () {
$this->get('/studio')
->assertStatus(200);
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioDashboard')
->has('overview.active_challenges')
->has('overview.creator_health')
->has('overview.featured_status'));
});
test('studio dashboard honors saved landing page preference', function () {
DB::table('dashboard_preferences')->insert([
'user_id' => $this->user->id,
'studio_preferences' => json_encode([
'default_landing_page' => 'scheduled',
], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
$this->get('/studio')
->assertRedirect('/studio/scheduled');
});
test('studio artworks page loads', function () {
$this->get('/studio/artworks')
->assertStatus(200);
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioArtworks')->where('title', 'Artworks'));
});
test('studio drafts page loads', function () {
$this->get('/studio/artworks/drafts')
->assertStatus(200);
->assertRedirect('/studio/drafts');
$this->get('/studio/drafts')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioDrafts')->where('title', 'Drafts'));
});
test('studio archived page loads', function () {
$this->get('/studio/artworks/archived')
->assertStatus(200);
->assertRedirect('/studio/archived');
$this->get('/studio/archived')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioArchived')->where('title', 'Archived'));
});
test('studio scheduled page loads', function () {
$this->get('/studio/scheduled')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioScheduled')
->where('title', 'Scheduled')
->has('listing.items')
->has('listing.summary')
->has('listing.agenda')
->has('listing.range_options')
->where('endpoints.publishNowPattern', route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']))
->where('endpoints.unschedulePattern', route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__'])));
});
test('studio calendar page loads', function () {
$this->get('/studio/calendar')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCalendar')
->where('title', 'Calendar')
->has('calendar.month.days')
->has('calendar.week.days')
->has('calendar.agenda')
->has('calendar.unscheduled_items')
->where('endpoints.publishNowPattern', route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__'])));
});
test('studio activity page loads', function () {
$this->get('/studio/activity')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioActivity')
->where('title', 'Activity')
->has('listing.items')
->has('listing.summary')
->has('listing.module_options')
->where('endpoints.markAllRead', route('api.studio.activity.readAll')));
});
test('studio inbox page loads', function () {
$this->get('/studio/inbox')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioInbox')
->where('title', 'Inbox')
->has('inbox.items')
->has('inbox.panels.attention_now')
->has('inbox.read_state_options')
->has('inbox.priority_options')
->where('endpoints.markAllRead', route('api.studio.activity.readAll')));
});
test('studio challenges page exposes challenge workflow payload', function () {
$this->get('/studio/challenges')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioChallenges')
->where('title', 'Challenges')
->has('summary')
->has('activeChallenges')
->has('recentEntries')
->has('cardLeaders')
->has('reminders'));
});
test('studio search page loads', function () {
$this->get('/studio/search')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioSearch')
->where('title', 'Search')
->has('search.filters')
->has('search.empty_state.continue_working')
->has('quickCreate'));
});
test('studio followers page loads', function () {
$this->get('/studio/followers')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioFollowers')->where('title', 'Followers'));
});
test('studio assets page exposes asset library payload', function () {
studioArtwork([
'user_id' => $this->user->id,
]);
$this->get('/studio/assets')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioAssets')
->where('title', 'Assets')
->has('assets.items')
->has('assets.meta')
->has('assets.filters')
->has('assets.type_options')
->has('assets.source_options')
->has('assets.sort_options')
->has('assets.summary')
->has('assets.items.0.usage_references'));
});
test('studio comments page exposes unified comments contract', function () {
$this->get('/studio/comments')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioComments')
->where('title', 'Comments')
->has('listing.items')
->has('listing.meta')
->has('listing.filters')
->has('listing.module_options')
->where('endpoints.replyPattern', route('api.studio.comments.reply', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']))
->where('endpoints.moderatePattern', route('api.studio.comments.moderate', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']))
->where('endpoints.reportPattern', route('api.studio.comments.report', ['module' => '__MODULE__', 'commentId' => '__COMMENT__'])));
});
test('studio profile page exposes editable profile contract', function () {
$this->get('/studio/profile')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioProfile')
->where('title', 'Profile')
->where('profile.name', $this->user->name)
->where('profile.username', $this->user->username)
->has('profile.social_links')
->has('moduleSummaries')
->has('featuredModules')
->has('featuredContent')
->where('endpoints.profile', route('api.studio.preferences.profile'))
->where('endpoints.avatarUpload', route('avatar.upload'))
->where('endpoints.coverUpload', route('api.profile.cover.upload'))
->where('endpoints.coverPosition', route('api.profile.cover.position'))
->where('endpoints.coverDelete', route('api.profile.cover.destroy')));
});
test('studio featured page exposes selection contract', function () {
$this->get('/studio/featured')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioFeatured')
->where('title', 'Featured')
->has('items')
->has('selected')
->has('featuredModules')
->where('endpoints.save', route('api.studio.preferences.featured')));
});
test('studio preferences page exposes preference contract', function () {
$this->get('/studio/preferences')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioPreferences')
->where('title', 'Preferences')
->has('preferences')
->where('preferences.default_landing_page', 'overview')
->has('preferences.widget_visibility')
->has('preferences.widget_order')
->has('links')
->where('endpoints.save', route('api.studio.preferences.settings')));
});
test('studio settings page exposes settings handoff payload', function () {
$this->get('/studio/settings')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioSettings')
->where('title', 'Settings')
->has('links')
->has('sections'));
});
test('studio analytics page exposes trend and comparison payloads', function () {
$this->get('/studio/analytics')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioAnalytics')
->has('totals')
->has('topContent')
->has('moduleBreakdown')
->has('viewsTrend')
->has('engagementTrend')
->has('publishingTimeline')
->has('comparison')
->has('insightBlocks')
->where('rangeDays', 30)
->has('recentComments'));
});
test('studio analytics page respects requested date range', function () {
$this->get('/studio/analytics?range_days=14')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioAnalytics')
->where('rangeDays', 14));
});
test('studio growth page exposes growth workspace payloads', function () {
$this->get('/studio/growth')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioGrowth')
->where('title', 'Growth')
->has('summary')
->has('moduleFocus')
->has('checkpoints')
->has('opportunities')
->has('milestones')
->has('momentum.views_trend')
->has('momentum.engagement_trend')
->has('momentum.publishing_timeline')
->where('rangeDays', 30));
});
test('studio growth page respects requested date range', function () {
$this->get('/studio/growth?range_days=14')
->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioGrowth')
->where('rangeDays', 14));
});
// ── API Tests ─────────────────────────────────────────────────────────────────
@@ -69,6 +339,26 @@ test('studio api requires authentication', function () {
$this->getJson('/api/studio/artworks')
->assertStatus(401);
$this->postJson('/api/studio/events', [
'event_type' => 'studio_opened',
])->assertStatus(401);
});
test('studio events api accepts known creator studio hooks', function () {
$this->postJson('/api/studio/events', [
'event_type' => 'studio_filter_used',
'module' => 'artworks',
'surface' => '/studio/artworks',
'item_module' => 'artworks',
'item_id' => 42,
'meta' => [
'filter' => 'bucket',
'value' => 'drafts',
],
])
->assertStatus(202)
->assertJsonPath('ok', true);
});
test('studio api returns artworks for authenticated user', function () {

View File

@@ -68,10 +68,16 @@ it('indexes artworks into the vector gateway with artwork metadata', function ()
return $request->hasHeader('X-API-Key', 'test-key')
&& is_array($payload)
&& ($payload['id'] ?? null) === (string) $artwork->id
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/md/aa/bb/aabbcc112233.webp'
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/artworks/md/aa/bb/aabbcc112233.webp'
&& ($payload['metadata']['content_type'] ?? null) === 'Photography'
&& ($payload['metadata']['category'] ?? null) === 'Abstract'
&& ($payload['metadata']['tags'] ?? null) === ['skyline'];
&& ($payload['metadata']['tags'] ?? null) === ['skyline']
&& array_key_exists('is_public', $payload['metadata'])
&& array_key_exists('is_deleted', $payload['metadata'])
&& array_key_exists('is_nsfw', $payload['metadata'])
&& array_key_exists('category_id', $payload['metadata'])
&& array_key_exists('content_type_id', $payload['metadata'])
&& array_key_exists('status', $payload['metadata']);
});
});
@@ -200,6 +206,6 @@ it('can re-upsert only artworks that already have local embeddings', function ()
return is_array($payload)
&& ($payload['id'] ?? null) === (string) $embeddedArtwork->id
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/md/11/22/112233445566.webp';
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/artworks/md/11/22/112233445566.webp';
});
});

View File

@@ -89,11 +89,17 @@ it('persists a normalized embedding and upserts the artwork to the vector gatewa
$data = $request->data();
return ($data['id'] ?? null) === (string) $artwork->id
&& ($data['url'] ?? null) === 'https://files.local/md/aa/bb/aabbccddeeff1122.webp'
&& ($data['url'] ?? null) === 'https://files.local/artworks/md/aa/bb/aabbccddeeff1122.webp'
&& ($data['metadata']['content_type'] ?? null) === 'Wallpapers'
&& ($data['metadata']['category'] ?? null) === 'Abstract'
&& ($data['metadata']['tags'] ?? null) === ['neon']
&& ($data['metadata']['user_id'] ?? null) === (string) $artwork->user_id;
&& ($data['metadata']['user_id'] ?? null) === (string) $artwork->user_id
&& ($data['metadata']['is_public'] ?? null) === true
&& ($data['metadata']['is_deleted'] ?? null) === false
&& ($data['metadata']['is_nsfw'] ?? null) === false
&& isset($data['metadata']['category_id'])
&& isset($data['metadata']['content_type_id'])
&& array_key_exists('status', $data['metadata']);
});
});

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
use App\Data\Images\CropBoxData;
use App\Services\Images\Detectors\HeuristicSubjectDetector;
use App\Services\Images\Detectors\NullSubjectDetector;
use App\Services\Images\SquareThumbnailService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
$this->imageTestRoot = storage_path('framework/testing/square-thumbnails-' . Str::lower(Str::random(10)));
File::deleteDirectory($this->imageTestRoot);
File::ensureDirectoryExists($this->imageTestRoot);
});
afterEach(function () {
File::deleteDirectory($this->imageTestRoot);
});
function squareThumbCreateImage(string $root, int $width, int $height, ?callable $drawer = null): string
{
$path = $root . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.png';
$image = imagecreatetruecolor($width, $height);
$background = imagecolorallocate($image, 22, 28, 36);
imagefilledrectangle($image, 0, 0, $width, $height, $background);
if ($drawer !== null) {
$drawer($image, $width, $height);
}
imagepng($image, $path);
imagedestroy($image);
return $path;
}
it('calculates a padded square crop and clamps it to image bounds', function () {
$service = new SquareThumbnailService(new NullSubjectDetector());
$crop = $service->calculateCropBox(1200, 800, new CropBoxData(10, 40, 260, 180), [
'padding_ratio' => 0.2,
]);
expect($crop->width)->toBe($crop->height)
->and($crop->x)->toBe(0)
->and($crop->y)->toBeGreaterThanOrEqual(0)
->and($crop->x + $crop->width)->toBeLessThanOrEqual(1200)
->and($crop->y + $crop->height)->toBeLessThanOrEqual(800);
});
it('falls back to a centered square crop when no focus box exists', function () {
$service = new SquareThumbnailService(new NullSubjectDetector());
$crop = $service->calculateCropBox(900, 500, null);
expect($crop->width)->toBe(500)
->and($crop->height)->toBe(500)
->and($crop->x)->toBe(200)
->and($crop->y)->toBe(0);
});
it('generates a valid square thumbnail when smart detection is unavailable', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 640, 320, function ($image): void {
$accent = imagecolorallocate($image, 240, 200, 80);
imagefilledellipse($image, 320, 160, 180, 180, $accent);
});
$destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'output.webp';
$service = new SquareThumbnailService(new NullSubjectDetector());
$result = $service->generateFromPath($source, $destination, ['target_size' => 256]);
$size = getimagesize($destination);
expect(File::exists($destination))->toBeTrue()
->and($result->cropMode)->toBe('center')
->and($size[0] ?? null)->toBe(256)
->and($size[1] ?? null)->toBe(256);
});
it('does not upscale tiny images when allow_upscale is disabled', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 60, 40, function ($image): void {
$accent = imagecolorallocate($image, 220, 120, 120);
imagefilledrectangle($image, 4, 4, 36, 32, $accent);
});
$destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'tiny.webp';
$service = new SquareThumbnailService(new NullSubjectDetector());
$result = $service->generateFromPath($source, $destination, [
'target_size' => 256,
'allow_upscale' => false,
]);
$size = getimagesize($destination);
expect($result->outputWidth)->toBe(40)
->and($result->outputHeight)->toBe(40)
->and($size[0] ?? null)->toBe(40)
->and($size[1] ?? null)->toBe(40);
});
it('can derive a saliency crop near the border', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 900, 600, function ($image): void {
$subject = imagecolorallocate($image, 250, 240, 240);
imagefilledellipse($image, 120, 240, 180, 220, $subject);
});
$detector = new HeuristicSubjectDetector();
$size = getimagesize($source);
$result = $detector->detect($source, (int) $size[0], (int) $size[1]);
expect($result)->not->toBeNull()
->and($result?->strategy)->toBe('saliency')
->and($result?->cropBox->x)->toBeLessThan(220);
});
it('prefers a distinct subject over textured foliage', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 1200, 700, function ($image): void {
$sky = imagecolorallocate($image, 165, 205, 255);
$leaf = imagecolorallocate($image, 42, 118, 34);
$leafDark = imagecolorallocate($image, 28, 88, 24);
$branch = imagecolorallocate($image, 96, 72, 52);
$fur = imagecolorallocate($image, 242, 236, 228);
$ginger = imagecolorallocate($image, 214, 152, 102);
$mouth = imagecolorallocate($image, 36, 20, 18);
imagefilledrectangle($image, 0, 0, 1200, 700, $sky);
for ($i = 0; $i < 28; $i++) {
imageline($image, rand(0, 520), rand(0, 340), rand(80, 620), rand(80, 420), $branch);
imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(60, 120), rand(20, 60), $leaf);
imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(40, 90), rand(16, 44), $leafDark);
}
imagefilledellipse($image, 890, 300, 420, 500, $fur);
imagefilledpolygon($image, [760, 130, 840, 10, 865, 165], 3, $ginger);
imagefilledpolygon($image, [930, 165, 1000, 10, 1070, 130], 3, $ginger);
imagefilledellipse($image, 885, 355, 150, 220, $mouth);
imagefilledellipse($image, 890, 520, 180, 130, $fur);
});
$detector = new HeuristicSubjectDetector();
$size = getimagesize($source);
$result = $detector->detect($source, (int) $size[0], (int) $size[1]);
expect($result)->not->toBeNull()
->and($result?->strategy)->toBe('saliency')
->and($result?->cropBox->x)->toBeGreaterThan(180);
});

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
use App\Enums\ModerationDomainStatus;
use App\Enums\ModerationRuleType;
use App\Enums\ModerationContentType;
use App\Enums\ModerationSeverity;
use App\Enums\ModerationStatus;
use App\Models\ContentModerationDomain;
use App\Models\ContentModerationFinding;
use App\Models\ContentModerationRule;
use App\Models\User;
use App\Services\Moderation\ContentModerationService;
use App\Services\Moderation\ContentModerationSourceService;
use App\Services\Moderation\Rules\LinkPresenceRule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('extracts explicit and www urls from content', function (): void {
$rule = app(LinkPresenceRule::class);
$urls = $rule->extractUrls('Visit https://spam.example.com and www.short.test/offer now.');
expect($urls)->toContain('https://spam.example.com')
->and($urls)->toContain('www.short.test/offer');
});
it('detects suspicious keywords and domains with a queued status', function (): void {
$result = app(ContentModerationService::class)->analyze(
'Buy followers today at https://promo.pornsite.com right now',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->score)->toBeGreaterThanOrEqual(110)
->and(in_array($result->severity, [ModerationSeverity::High, ModerationSeverity::Critical], true))->toBeTrue()
->and($result->matchedDomains)->toContain('promo.pornsite.com')
->and($result->matchedKeywords)->toContain('buy followers');
});
it('detects unicode obfuscation patterns', function (): void {
$result = app(ContentModerationService::class)->analyze(
'раypal giveaway click here',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->score)->toBeGreaterThan(0)
->and(collect($result->reasons)->implode(' '))->toContain('Unicode');
});
it('produces stable hashes for semantically identical whitespace variants', function (): void {
$service = app(ContentModerationService::class);
$first = $service->analyze("Visit my site\n\nnow", ['content_type' => 'artwork_comment', 'content_id' => 1]);
$second = $service->analyze(' visit my site now ', ['content_type' => 'artwork_comment', 'content_id' => 2]);
expect($first->contentHash)->toBe($second->contentHash);
});
it('detects keyword stuffing patterns', function (): void {
$result = app(ContentModerationService::class)->analyze(
'seo seo seo seo seo seo seo seo seo seo seo seo seo service service service service service cheap cheap cheap traffic traffic traffic',
['content_type' => 'artwork_description', 'content_id' => 1]
);
expect($result->score)->toBeGreaterThan(0)
->and($result->matchedKeywords)->toContain('seo');
});
it('maps score thresholds to the expected severities', function (): void {
expect(ModerationSeverity::fromScore(0))->toBe(ModerationSeverity::Low)
->and(ModerationSeverity::fromScore(30))->toBe(ModerationSeverity::Medium)
->and(ModerationSeverity::fromScore(60))->toBe(ModerationSeverity::High)
->and(ModerationSeverity::fromScore(90))->toBe(ModerationSeverity::Critical);
});
it('applies db managed moderation rules alongside config rules', function (): void {
ContentModerationRule::query()->create([
'type' => ModerationRuleType::SuspiciousKeyword,
'value' => 'rare promo blast',
'enabled' => true,
'weight' => 22,
]);
$result = app(ContentModerationService::class)->analyze(
'This rare promo blast just dropped.',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->matchedKeywords)->toContain('rare promo blast')
->and($result->score)->toBeGreaterThan(0);
});
it('uses domain reputation to escalate blocked domains and auto hide recommendations', function (): void {
ContentModerationDomain::query()->create([
'domain' => 'campaign.spam.test',
'status' => ModerationDomainStatus::Blocked,
]);
$result = app(ContentModerationService::class)->analyze(
'Buy followers now at https://campaign.spam.test and claim your giveaway',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->matchedDomains)->toContain('campaign.spam.test')
->and($result->autoHideRecommended)->toBeTrue()
->and($result->score)->toBeGreaterThanOrEqual(95);
});
it('applies user risk modifiers conservatively', function (): void {
$user = User::factory()->create();
ContentModerationFinding::query()->create([
'content_type' => 'artwork_comment',
'content_id' => 11,
'user_id' => $user->id,
'status' => ModerationStatus::ConfirmedSpam->value,
'severity' => 'high',
'score' => 85,
'content_hash' => hash('sha256', 'a'),
'scanner_version' => '2.0',
'content_snapshot' => 'spam one',
]);
ContentModerationFinding::query()->create([
'content_type' => 'artwork_comment',
'content_id' => 12,
'user_id' => $user->id,
'status' => ModerationStatus::ConfirmedSpam->value,
'severity' => 'critical',
'score' => 120,
'content_hash' => hash('sha256', 'b'),
'scanner_version' => '2.0',
'content_snapshot' => 'spam two',
]);
$base = app(ContentModerationService::class)->analyze(
'Visit https://safe.example.test for more info',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
$risky = app(ContentModerationService::class)->analyze(
'Visit https://safe.example.test for more info',
['content_type' => 'artwork_comment', 'content_id' => 2, 'user_id' => $user->id]
);
expect($risky->score)->toBeGreaterThan($base->score)
->and($risky->userRiskScore)->toBeGreaterThan(0)
->and($risky->ruleHits)->toHaveKey('user_risk_modifier');
});
it('applies strict seo policy and v3 assistive fields for link-heavy profile content', function (): void {
$result = app(ContentModerationService::class)->analyze(
'Visit https://promo.cluster-test.com now for a limited time offer and boost your traffic',
[
'content_type' => ModerationContentType::UserProfileLink->value,
'content_id' => 77,
'content_target_type' => 'user_social_link',
'content_target_id' => 77,
'is_publicly_exposed' => true,
]
);
expect($result->policyName)->toBe('strict_seo_protection')
->and($result->status)->toBe(ModerationStatus::Pending)
->and($result->priorityScore)->toBeGreaterThan($result->score)
->and(in_array($result->reviewBucket, ['urgent', 'high'], true))->toBeTrue()
->and($result->aiProvider)->toBe('heuristic_assist')
->and($result->aiLabel)->not->toBeNull()
->and($result->contentTargetType)->toBe('user_social_link')
->and($result->contentTargetId)->toBe(77)
->and($result->scoreBreakdown)->not->toBeEmpty();
});
it('builds v3 moderation source context for profile links and card text', function (): void {
$service = app(ContentModerationSourceService::class);
$profileLinkContext = $service->buildContext(ModerationContentType::UserProfileLink, (object) [
'id' => 14,
'user_id' => 5,
'url' => 'https://promo.example.test',
]);
$cardTextContext = $service->buildContext(ModerationContentType::CardText, (object) [
'id' => 9,
'user_id' => 3,
'quote_text' => 'Promo headline',
'description' => 'Additional landing page copy',
'quote_author' => 'Campaign Bot',
'quote_source' => 'promo.example.test',
'visibility' => 'public',
]);
expect($profileLinkContext['content_type'])->toBe(ModerationContentType::UserProfileLink->value)
->and($profileLinkContext['content_target_type'])->toBe('user_social_link')
->and($profileLinkContext['content_target_id'])->toBe(14)
->and($profileLinkContext['user_id'])->toBe(5)
->and($profileLinkContext['is_publicly_exposed'])->toBeTrue()
->and($cardTextContext['content_type'])->toBe(ModerationContentType::CardText->value)
->and($cardTextContext['content_target_type'])->toBe('nova_card')
->and($cardTextContext['content_target_id'])->toBe(9)
->and($cardTextContext['user_id'])->toBe(3)
->and($cardTextContext['is_publicly_exposed'])->toBeTrue()
->and($cardTextContext['content_snapshot'])->toContain('Promo headline')
->and($cardTextContext['content_snapshot'])->toContain('promo.example.test');
});

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Services\Cdn\ArtworkCdnPurgeService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('purges canonical artwork urls through the Cloudflare api', function (): void {
Http::fake([
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', 'test-zone');
config()->set('cdn.cloudflare.api_token', 'test-token');
$result = app(ArtworkCdnPurgeService::class)->purgeArtworkObjectPaths([
'artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
], ['reason' => 'test']);
expect($result)->toBeTrue();
Http::assertSent(function ($request): bool {
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
&& $request->hasHeader('Authorization', 'Bearer test-token')
&& $request['files'] === [
'https://cdn.skinbase.org/artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
];
});
});
it('falls back to the legacy purge webhook when Cloudflare credentials are absent', function (): void {
Http::fake([
'https://purge.internal.example/purge' => Http::response(['ok' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', null);
config()->set('cdn.cloudflare.api_token', null);
config()->set('cdn.purge_url', 'https://purge.internal.example/purge');
$result = app(ArtworkCdnPurgeService::class)->purgeArtworkObjectPaths([
'artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
], ['reason' => 'test']);
expect($result)->toBeTrue();
Http::assertSent(function ($request): bool {
return $request->url() === 'https://purge.internal.example/purge'
&& $request['paths'] === [
'/artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
];
});
});

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Services\Upload\PreviewService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('generates a smart square thumb during preview creation', function () {
Storage::fake('local');
$uploadId = (string) Str::uuid();
$file = UploadedFile::fake()->image('preview-source.jpg', 1200, 800);
$path = $file->storeAs("tmp/drafts/{$uploadId}/main", 'preview-source.jpg', 'local');
$result = app(PreviewService::class)->generateFromImage($uploadId, $path);
expect(Storage::disk('local')->exists($result['preview_path']))->toBeTrue()
->and(Storage::disk('local')->exists($result['thumb_path']))->toBeTrue();
$thumbSize = getimagesizefromstring(Storage::disk('local')->get($result['thumb_path']));
expect($thumbSize[0] ?? null)->toBe(320)
->and($thumbSize[1] ?? null)->toBe(320);
});

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadSessionStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Storage::fake('s3');
$suffix = Str::lower(Str::random(10));
$this->tempUploadRoot = storage_path('framework/testing/uploads-pipeline-' . $suffix);
$this->localOriginalsRoot = storage_path('framework/testing/originals-artworks-' . $suffix);
File::deleteDirectory($this->tempUploadRoot);
File::deleteDirectory($this->localOriginalsRoot);
config()->set('uploads.storage_root', $this->tempUploadRoot);
config()->set('uploads.local_originals_root', $this->localOriginalsRoot);
config()->set('uploads.object_storage.disk', 's3');
config()->set('uploads.object_storage.prefix', 'artworks');
config()->set('uploads.derivatives', [
'xs' => ['max' => 320],
'sm' => ['max' => 680],
'md' => ['max' => 1024],
'lg' => ['max' => 1920],
'xl' => ['max' => 2560],
'sq' => ['size' => 512],
]);
config()->set('uploads.square_thumbnails', [
'width' => 512,
'height' => 512,
'quality' => 82,
'smart_crop' => true,
'padding_ratio' => 0.18,
'allow_upscale' => false,
'fallback_strategy' => 'center',
'log' => false,
'preview_size' => 320,
'subject_detector' => [
'preferred_labels' => ['person', 'portrait', 'animal', 'face'],
],
'saliency' => [
'sample_max_dimension' => 96,
'min_total_energy' => 2400,
'window_ratios' => [0.55, 0.7, 0.82, 1.0],
],
]);
});
afterEach(function () {
File::deleteDirectory($this->tempUploadRoot);
File::deleteDirectory($this->localOriginalsRoot);
});
function pipelineTestCreateTempImage(string $root, string $name = 'preview.jpg'): string
{
$tmpDir = $root . DIRECTORY_SEPARATOR . 'tmp';
if (! File::exists($tmpDir)) {
File::makeDirectory($tmpDir, 0755, true);
}
$upload = UploadedFile::fake()->image($name, 1800, 1200);
$path = $tmpDir . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.jpg';
if (! File::copy($upload->getPathname(), $path)) {
throw new RuntimeException('Failed to copy fake image into upload temp path.');
}
return $path;
}
function pipelineTestCreateTempArchive(string $root, string $name = 'pack.zip'): string
{
$tmpDir = $root . DIRECTORY_SEPARATOR . 'tmp';
if (! File::exists($tmpDir)) {
File::makeDirectory($tmpDir, 0755, true);
}
$path = $tmpDir . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.zip';
File::put($path, 'fake-archive-binary-' . Str::random(24));
return $path;
}
it('stores image originals locally and in object storage with all derivatives', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'sunset.jpg');
$hash = hash_file('sha256', $tempImage);
$sessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($sessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
$result = app(UploadPipelineService::class)->processAndPublish($sessionId, $hash, $artwork->id, 'sunset.jpg');
$localOriginal = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.jpg';
expect(File::exists($localOriginal))->toBeTrue();
Storage::disk('s3')->assertExists("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg");
foreach (['xs', 'sm', 'md', 'lg', 'xl', 'sq'] as $variant) {
Storage::disk('s3')->assertExists("artworks/{$variant}/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
}
$sqBytes = Storage::disk('s3')->get("artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
$sqSize = getimagesizefromstring($sqBytes);
expect($sqSize[0] ?? null)->toBe(512)
->and($sqSize[1] ?? null)->toBe(512);
$mdBytes = Storage::disk('s3')->get("artworks/md/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
$mdSize = getimagesizefromstring($mdBytes);
expect($mdSize[0] ?? 0)->toBeGreaterThan(($mdSize[1] ?? 0));
$storedVariants = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
expect($storedVariants['orig'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg")
->and($storedVariants['orig_image'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg")
->and($storedVariants['sq'])->toBe("artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
$artwork->refresh();
expect($artwork->hash)->toBe($hash)
->and($artwork->thumb_ext)->toBe('webp')
->and($artwork->file_ext)->toBe('jpg')
->and($artwork->file_path)->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg")
->and($result['orig'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg");
});
it('stores preview image and archive originals separately when an archive session is provided', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'cover.jpg');
$imageHash = hash_file('sha256', $tempImage);
$imageSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($imageSessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
$tempArchive = pipelineTestCreateTempArchive($this->tempUploadRoot, 'skin-pack.zip');
$archiveHash = hash_file('sha256', $tempArchive);
$archiveSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($archiveSessionId, $user->id, $tempArchive, UploadSessionStatus::TMP, '127.0.0.1');
$result = app(UploadPipelineService::class)->processAndPublish(
$imageSessionId,
$imageHash,
$artwork->id,
'cover.jpg',
$archiveSessionId,
$archiveHash,
'skin-pack.zip'
);
Storage::disk('s3')->assertExists("artworks/original/" . substr($imageHash, 0, 2) . "/" . substr($imageHash, 2, 2) . "/{$imageHash}.jpg");
Storage::disk('s3')->assertExists("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip");
$variants = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
expect($variants['orig'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip")
->and($variants['orig_image'])->toBe("artworks/original/" . substr($imageHash, 0, 2) . "/" . substr($imageHash, 2, 2) . "/{$imageHash}.jpg")
->and($variants['orig_archive'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip");
$artwork->refresh();
expect($artwork->hash)->toBe($imageHash)
->and($artwork->file_ext)->toBe('zip')
->and($artwork->file_path)->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip")
->and($result['orig_archive'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip");
});
it('stores additional archive screenshots as dedicated artwork file variants', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'cover.jpg');
$imageHash = hash_file('sha256', $tempImage);
$imageSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($imageSessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
$tempArchive = pipelineTestCreateTempArchive($this->tempUploadRoot, 'skin-pack.zip');
$archiveHash = hash_file('sha256', $tempArchive);
$archiveSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($archiveSessionId, $user->id, $tempArchive, UploadSessionStatus::TMP, '127.0.0.1');
$tempScreenshot = pipelineTestCreateTempImage($this->tempUploadRoot, 'screen-2.jpg');
$screenshotHash = hash_file('sha256', $tempScreenshot);
$screenshotSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($screenshotSessionId, $user->id, $tempScreenshot, UploadSessionStatus::TMP, '127.0.0.1');
$result = app(UploadPipelineService::class)->processAndPublish(
$imageSessionId,
$imageHash,
$artwork->id,
'cover.jpg',
$archiveSessionId,
$archiveHash,
'skin-pack.zip',
[
[
'session_id' => $screenshotSessionId,
'hash' => $screenshotHash,
'file_name' => 'screen-2.jpg',
],
]
);
Storage::disk('s3')->assertExists("artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg");
$variants = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
expect($variants['shot01'])->toBe("artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg")
->and($result['screenshots'])->toBe([
[
'variant' => 'shot01',
'path' => "artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg",
],
]);
});
it('cleans up local and object storage when publish persistence fails', function () {
$user = User::factory()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'broken.jpg');
$hash = hash_file('sha256', $tempImage);
$sessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($sessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
try {
app(UploadPipelineService::class)->processAndPublish($sessionId, $hash, 999999, 'broken.jpg');
$this->fail('Expected upload pipeline publish to fail for a missing artwork.');
} catch (\Throwable) {
// Expected: DB persistence fails and the pipeline must clean up written files.
}
$localOriginal = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.jpg';
expect(File::exists($localOriginal))->toBeFalse();
foreach (['original', 'xs', 'sm', 'md', 'lg', 'xl', 'sq'] as $variant) {
$filename = $variant === 'original' ? "{$hash}.jpg" : "{$hash}.webp";
Storage::disk('s3')->assertMissing("artworks/{$variant}/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$filename}");
}
});