Implement creator studio and upload updates
This commit is contained in:
555
tests/Feature/Admin/ContentModerationAdminTest.php
Normal file
555
tests/Feature/Admin/ContentModerationAdminTest.php
Normal 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();
|
||||
});
|
||||
41
tests/Feature/ArtworkSlugRoutingTest.php
Normal file
41
tests/Feature/ArtworkSlugRoutingTest.php
Normal 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);
|
||||
});
|
||||
49
tests/Feature/Artworks/ArtworkCreatedAtSyncTest.php
Normal file
49
tests/Feature/Artworks/ArtworkCreatedAtSyncTest.php
Normal 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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
129
tests/Feature/Console/IndexArtworkVectorsCommandTest.php
Normal file
129
tests/Feature/Console/IndexArtworkVectorsCommandTest.php
Normal 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);
|
||||
});
|
||||
145
tests/Feature/Console/ScanContentModerationCommandTest.php
Normal file
145
tests/Feature/Console/ScanContentModerationCommandTest.php
Normal 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();
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
12
tests/Feature/LeaderboardPageTest.php
Normal file
12
tests/Feature/LeaderboardPageTest.php
Normal 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')));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
699
tests/Feature/SitemapTest.php
Normal file
699
tests/Feature/SitemapTest.php
Normal 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,
|
||||
)));
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
152
tests/Unit/Images/SquareThumbnailServiceTest.php
Normal file
152
tests/Unit/Images/SquareThumbnailServiceTest.php
Normal 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);
|
||||
});
|
||||
208
tests/Unit/Moderation/ContentModerationServiceTest.php
Normal file
208
tests/Unit/Moderation/ContentModerationServiceTest.php
Normal 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');
|
||||
});
|
||||
57
tests/Unit/Services/ArtworkCdnPurgeServiceTest.php
Normal file
57
tests/Unit/Services/ArtworkCdnPurgeServiceTest.php
Normal 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',
|
||||
];
|
||||
});
|
||||
});
|
||||
30
tests/Unit/Upload/PreviewServiceTest.php
Normal file
30
tests/Unit/Upload/PreviewServiceTest.php
Normal 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);
|
||||
});
|
||||
263
tests/Unit/Uploads/UploadPipelineServiceTest.php
Normal file
263
tests/Unit/Uploads/UploadPipelineServiceTest.php
Normal 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}");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user