Files
2026-04-18 17:02:56 +02:00

799 lines
30 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Jobs\DetectArtworkMaturityJob;
use App\Services\Maturity\ArtworkMaturityService;
use App\Models\Collection;
use App\Services\CollectionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia;
use Klevze\ControlPanel\Models\Admin\AdminVerification;
use Klevze\ControlPanel\Models\Auth\User as ControlPanelUser;
uses(RefreshDatabase::class);
function createMaturityQueueAdmin(): User
{
$admin = User::factory()->create(['role' => 'admin']);
$admin->forceFill([
'isAdmin' => true,
'activated' => true,
])->save();
AdminVerification::createForUser($admin->fresh());
return $admin->fresh();
}
it('persists content preferences from the settings endpoint', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/settings/content/update', [
'mature_content_visibility' => 'hide',
'mature_content_warning_enabled' => false,
])
->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('message', 'Content preferences saved successfully.');
$profile = DB::table('user_profiles')
->where('user_id', $user->id)
->first(['mature_content_visibility', 'mature_content_warning_enabled']);
expect($profile)->not->toBeNull()
->and($profile->mature_content_visibility)->toBe('hide')
->and((int) $profile->mature_content_warning_enabled)->toBe(0);
});
it('derives mature artwork presentation from viewer preferences', function () {
$artwork = Artwork::factory()->create([
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
]);
$hideViewer = User::factory()->create();
$blurViewer = User::factory()->create();
$showViewer = User::factory()->create();
DB::table('user_profiles')->insert([
[
'user_id' => $hideViewer->id,
'mature_content_visibility' => 'hide',
'mature_content_warning_enabled' => true,
],
[
'user_id' => $blurViewer->id,
'mature_content_visibility' => 'blur',
'mature_content_warning_enabled' => false,
],
[
'user_id' => $showViewer->id,
'mature_content_visibility' => 'show',
'mature_content_warning_enabled' => true,
],
]);
$maturity = app(ArtworkMaturityService::class);
$hidden = $maturity->presentation($artwork, $hideViewer);
$blurred = $maturity->presentation($artwork, $blurViewer);
$shown = $maturity->presentation($artwork, $showViewer);
expect($hidden['should_hide'])->toBeTrue()
->and($hidden['should_blur'])->toBeFalse()
->and($hidden['requires_interstitial'])->toBeTrue()
->and($blurred['should_hide'])->toBeFalse()
->and($blurred['should_blur'])->toBeTrue()
->and($blurred['requires_interstitial'])->toBeFalse()
->and($shown['should_hide'])->toBeFalse()
->and($shown['should_blur'])->toBeFalse()
->and($shown['requires_interstitial'])->toBeTrue();
});
it('applies uploader mature declarations when publishing an existing artwork', function () {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
]);
$this->actingAs($user)
->postJson("/api/uploads/{$artwork->id}/publish", [
'title' => 'Updated title',
'is_mature' => true,
'visibility' => 'private',
])
->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('status', 'published');
$artwork->refresh();
expect($artwork->is_mature)->toBeTrue()
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_MATURE)
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_DECLARED)
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_USER);
});
it('flags undeclared mature content from AI assessment', function () {
Queue::fake();
$artwork = Artwork::factory()->create([
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_mismatch_count' => 0,
]);
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
'clip_tags' => [
['tag' => 'nudity'],
],
'yolo_objects' => [],
'blip_caption' => 'topless portrait',
]);
$artwork->refresh();
expect($assessment['flagged'])->toBeTrue()
->and($assessment['score'])->toBeGreaterThanOrEqual(0.68)
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_SUSPECTED)
->and($artwork->maturity_flagged_at)->not->toBeNull()
->and($artwork->maturity_mismatch_count)->toBe(1)
->and($artwork->maturity_ai_labels)->toContain('nudity');
});
it('stores normalized maturity endpoint results and flags review-worthy content', function () {
Queue::fake();
$artwork = Artwork::factory()->create([
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_mismatch_count' => 0,
]);
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
'status' => 'succeeded',
'maturity_label' => 'mature',
'confidence' => 0.9321,
'action_hint' => 'flag_high',
'threshold_used' => 0.7,
'analysis_time_ms' => 321,
'model' => 'vision-maturity-v2',
'advisory' => 'High confidence mature content.',
'labels' => ['nudity'],
]);
$artwork->refresh();
expect($assessment['flagged'])->toBeTrue()
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_SUSPECTED)
->and($artwork->maturity_ai_status)->toBe(ArtworkMaturityService::AI_STATUS_SUCCEEDED)
->and($artwork->maturity_ai_label)->toBe(ArtworkMaturityService::LEVEL_MATURE)
->and($artwork->maturity_ai_confidence)->toBe(0.9321)
->and($artwork->maturity_ai_action_hint)->toBe(ArtworkMaturityService::AI_ACTION_FLAG_HIGH)
->and($artwork->maturity_ai_threshold_used)->toBe(0.7)
->and($artwork->maturity_ai_analysis_time_ms)->toBe(321)
->and($artwork->maturity_ai_model)->toBe('vision-maturity-v2')
->and($artwork->maturity_ai_advisory)->toBe('High confidence mature content.')
->and($artwork->maturity_mismatch_count)->toBe(1);
});
it('normalizes safe action hints from the vision maturity contract', function () {
Queue::fake();
$artwork = Artwork::factory()->create([
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
]);
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
'status' => 'succeeded',
'maturity_label' => 'safe',
'confidence' => 0.1123,
'action_hint' => 'safe',
'threshold_used' => 0.7,
'model' => 'vision-maturity-v2',
'labels' => ['safe'],
]);
$artwork->refresh();
expect($assessment['flagged'])->toBeFalse()
->and($assessment['action_hint'])->toBe(ArtworkMaturityService::AI_ACTION_SAFE)
->and($artwork->maturity_ai_action_hint)->toBe(ArtworkMaturityService::AI_ACTION_SAFE)
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR);
});
it('records failed maturity AI analysis without implying safe content', function () {
Queue::fake();
$artwork = Artwork::factory()->create([
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
]);
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
'status' => 'failed',
'advisory' => 'Gateway timeout.',
]);
$artwork->refresh();
expect($assessment['flagged'])->toBeFalse()
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR)
->and($artwork->is_mature)->toBeFalse()
->and($artwork->maturity_ai_status)->toBe(ArtworkMaturityService::AI_STATUS_FAILED)
->and($artwork->maturity_ai_advisory)->toBe('Gateway timeout.')
->and($artwork->maturity_ai_label)->toBeNull();
});
it('lets moderators review suspected artworks', function () {
Queue::fake();
$moderator = User::factory()->create(['role' => 'moderator']);
$artwork = Artwork::factory()->create([
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
]);
$this->actingAs($moderator)
->postJson("/cp/maturity/{$artwork->id}/review", [
'action' => 'mark_mature',
'note' => 'Confirmed mature by moderation review.',
])
->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('artwork.id', $artwork->id);
$artwork->refresh();
expect($artwork->is_mature)->toBeTrue()
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_MATURE)
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
->and($artwork->maturity_reviewed_by)->toBe($moderator->id)
->and($artwork->maturity_reviewer_note)->toBe('Confirmed mature by moderation review.');
});
it('renders the moderation queue page and filters queue items by status', function () {
Queue::fake();
$moderator = User::factory()->create(['role' => 'moderator']);
$suspected = Artwork::factory()->create([
'title' => 'Suspected Queue Artwork',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'maturity_ai_label' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_ai_score' => 0.8812,
'maturity_ai_confidence' => 0.8812,
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'maturity_ai_labels' => ['nudity'],
'published_at' => now(),
]);
$reviewed = Artwork::factory()->create([
'title' => 'Reviewed Queue Artwork',
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_REVIEWED,
'maturity_source' => ArtworkMaturityService::SOURCE_MODERATOR,
'maturity_reviewed_by' => $moderator->id,
'maturity_reviewed_at' => now(),
'maturity_reviewer_note' => 'Already reviewed.',
'published_at' => now(),
]);
$this->actingAs($moderator)
->get('/cp/maturity')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Moderation/ArtworkMaturityQueue')
->where('title', 'Artwork Maturity Queue')
->where('stats.suspected', 1)
->where('stats.reviewed', 1)
->where('initialItems.0.id', $suspected->id)
->where('initialItems.0.title', 'Suspected Queue Artwork')
->where('initialItems.0.maturity.ai_action_hint', ArtworkMaturityService::AI_ACTION_REVIEW)
);
$this->actingAs($moderator)
->getJson('/cp/maturity/queue?status=reviewed')
->assertOk()
->assertJsonPath('meta.status', 'reviewed')
->assertJsonPath('meta.stats.suspected', 1)
->assertJsonPath('meta.stats.reviewed', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $reviewed->id)
->assertJsonPath('data.0.review.reviewer_note', 'Already reviewed.');
});
it('renders the artwork admin maturity queue for cpad admins', function () {
Queue::fake();
$admin = createMaturityQueueAdmin();
$suspected = Artwork::factory()->create([
'title' => 'Admin Surface Suspected Artwork',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'published_at' => now(),
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.cp.artworks.maturity.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Moderation/ArtworkMaturityQueue')
->where('initialItems.0.id', $suspected->id)
->where('endpoints.list', route('admin.cp.artworks.maturity.queue'))
->where('endpoints.reviewPattern', route('admin.cp.artworks.maturity.review', ['artwork' => '__ARTWORK__']))
);
});
it('allows cpad admins to open the legacy maturity queue url', function () {
Queue::fake();
$admin = createMaturityQueueAdmin();
$suspected = Artwork::factory()->create([
'title' => 'Legacy Url Suspected Artwork',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'published_at' => now(),
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get('/cp/maturity')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Moderation/ArtworkMaturityQueue')
->where('initialItems.0.id', $suspected->id)
->where('endpoints.list', route('cp.maturity.list'))
->where('endpoints.reviewPattern', route('cp.maturity.review', ['artwork' => '__ARTWORK__']))
);
});
it('defaults to the audit queue when suspected items are empty but audit candidates exist', function () {
Queue::fake();
$admin = createMaturityQueueAdmin();
$artwork = Artwork::factory()->create([
'title' => 'Audit First Queue Candidate',
'hash' => 'audit-first-queue-candidate',
'thumb_ext' => 'webp',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_declared_at' => null,
'maturity_reviewed_at' => null,
'published_at' => now(),
]);
DB::table('artwork_maturity_audit_findings')->insert([
'artwork_id' => $artwork->id,
'status' => 'open',
'thumbnail_variant' => 'md',
'ai_label' => 'mature',
'ai_confidence' => 0.8442,
'ai_score' => 0.8442,
'ai_labels' => json_encode(['nudity'], JSON_UNESCAPED_SLASHES),
'ai_model' => 'vision-maturity-v2',
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'ai_advisory' => 'Needs manual review.',
'detected_at' => now(),
'last_scanned_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get('/cp/maturity')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Moderation/ArtworkMaturityQueue')
->where('initialFilters.status', 'audit')
->where('initialItems.0.id', $artwork->id)
->where('stats.audit', 1)
->where('stats.suspected', 0)
);
});
it('filters the moderation queue by AI action hint', function () {
Queue::fake();
$moderator = User::factory()->create(['role' => 'moderator']);
$flagHigh = Artwork::factory()->create([
'title' => 'Flag High Artwork',
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
'published_at' => now(),
]);
Artwork::factory()->create([
'title' => 'Review Artwork',
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'published_at' => now(),
]);
$this->actingAs($moderator)
->getJson('/cp/maturity/queue?status=suspected&ai_action=flag_high')
->assertOk()
->assertJsonPath('meta.filters.ai_action', ArtworkMaturityService::AI_ACTION_FLAG_HIGH)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $flagHigh->id);
});
it('records audit findings for legacy artworks without mutating artwork maturity fields', function () {
Queue::fake();
$artwork = Artwork::factory()->create([
'title' => 'Legacy Audit Candidate',
'hash' => 'abc123def456',
'thumb_ext' => 'webp',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_declared_at' => null,
'maturity_reviewed_at' => null,
]);
config()->set('vision.enabled', true);
config()->set('vision.maturity.base_url', 'https://vision.test');
config()->set('vision.maturity.endpoint', '/analyze/maturity');
Http::fake([
'https://vision.test/*' => Http::response([
'status' => 'succeeded',
'maturity_label' => 'mature',
'confidence' => 0.9123,
'score' => 0.9123,
'action_hint' => 'review',
'labels' => ['nudity'],
'model' => 'vision-maturity-v2',
'advisory' => 'Needs moderator confirmation.',
], 200),
]);
$this->artisan('artworks:audit-thumbnail-maturity', ['--limit' => 1])
->assertSuccessful();
$artwork->refresh();
expect($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR)
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_LEGACY)
->and($artwork->is_mature)->toBeFalse();
$this->assertDatabaseHas('artwork_maturity_audit_findings', [
'artwork_id' => $artwork->id,
'status' => 'open',
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
]);
});
it('shows audit candidates in cpad and resolves them after moderator review', function () {
Queue::fake();
$moderator = User::factory()->create(['role' => 'moderator']);
$artwork = Artwork::factory()->create([
'title' => 'Legacy Queue Candidate',
'hash' => 'queuecandidate123',
'thumb_ext' => 'webp',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_declared_at' => null,
'maturity_reviewed_at' => null,
'published_at' => now(),
]);
DB::table('artwork_maturity_audit_findings')->insert([
'artwork_id' => $artwork->id,
'status' => 'open',
'thumbnail_variant' => 'md',
'ai_label' => 'mature',
'ai_confidence' => 0.8442,
'ai_score' => 0.8442,
'ai_labels' => json_encode(['nudity'], JSON_UNESCAPED_SLASHES),
'ai_model' => 'vision-maturity-v2',
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'ai_advisory' => 'Needs manual review.',
'detected_at' => now(),
'last_scanned_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($moderator)
->getJson('/cp/maturity/queue?status=audit')
->assertOk()
->assertJsonPath('meta.status', 'audit')
->assertJsonPath('meta.stats.audit', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $artwork->id)
->assertJsonPath('data.0.audit.ai_action_hint', ArtworkMaturityService::AI_ACTION_REVIEW)
->assertJsonPath('data.0.audit.legacy_unset', true);
$this->actingAs($moderator)
->postJson("/cp/maturity/{$artwork->id}/review", [
'action' => 'mark_mature',
'note' => 'Confirmed from audit queue.',
])
->assertOk();
$artwork->refresh();
expect($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
->and($artwork->is_mature)->toBeTrue();
$this->assertDatabaseHas('artwork_maturity_audit_findings', [
'artwork_id' => $artwork->id,
'status' => 'reviewed',
'resolved_by' => $moderator->id,
'resolution_action' => 'mark_mature',
]);
});
it('allows cpad admins to review artwork maturity from the artwork admin surface', function () {
Queue::fake();
$admin = createMaturityQueueAdmin();
$artwork = Artwork::factory()->create([
'title' => 'Artwork Admin Review Candidate',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('admin.cp.artworks.maturity.review', ['artwork' => $artwork->id]), [
'action' => 'mark_mature',
'note' => 'Reviewed from artwork admin surface.',
])
->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('artwork.id', $artwork->id);
$artwork->refresh();
expect($artwork->is_mature)->toBeTrue()
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_MATURE)
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
->and($artwork->maturity_reviewer_note)->toBe('Reviewed from artwork admin surface.');
});
it('accepts controlpanel auth users when recording maturity reviews', function () {
Queue::fake();
$admin = ControlPanelUser::query()->create([
'name' => 'Legacy CP Admin',
'email' => 'legacy-cp-admin@example.test',
'password' => Hash::make('password'),
'isAdmin' => true,
'activated' => true,
]);
$artwork = Artwork::factory()->create([
'title' => 'Legacy CP Review Candidate',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
]);
DB::table('artwork_maturity_audit_findings')->insert([
'artwork_id' => $artwork->id,
'status' => 'open',
'thumbnail_variant' => 'md',
'ai_label' => 'mature',
'ai_confidence' => 0.8123,
'ai_score' => 0.8123,
'ai_labels' => json_encode(['nudity'], JSON_UNESCAPED_SLASHES),
'ai_model' => 'vision-maturity-v2',
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
'ai_advisory' => 'Needs manual review.',
'detected_at' => now(),
'last_scanned_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
app(ArtworkMaturityService::class)->review($artwork, 'mark_mature', $admin, 'Reviewed from legacy cp route.');
app(\App\Services\Maturity\ArtworkMaturityAuditService::class)->resolveFindingForReview(
$artwork,
$admin,
'mark_mature',
'Reviewed from legacy cp route.',
);
$artwork->refresh();
expect($artwork->is_mature)->toBeTrue()
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
->and($artwork->maturity_reviewed_by)->toBe($admin->id)
->and($artwork->maturity_reviewer_note)->toBe('Reviewed from legacy cp route.');
$this->assertDatabaseHas('artwork_maturity_audit_findings', [
'artwork_id' => $artwork->id,
'status' => 'reviewed',
'resolved_by' => $admin->id,
'resolution_action' => 'mark_mature',
]);
});
it('records a failed maturity detection job without marking the artwork safe by implication', function () {
Queue::fake();
$artwork = Artwork::factory()->create([
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING,
'maturity_ai_label' => null,
]);
$job = new DetectArtworkMaturityJob($artwork->id, 'fake-hash');
$job->failed(new RuntimeException('Vision gateway timeout.'));
$artwork->refresh();
expect($artwork->is_mature)->toBeFalse()
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_SAFE)
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR)
->and($artwork->maturity_ai_status)->toBe(ArtworkMaturityService::AI_STATUS_FAILED)
->and($artwork->maturity_ai_advisory)->toBe('Vision gateway timeout.')
->and($artwork->maturity_ai_label)->toBeNull();
});
it('hides mature items from the daily uploads page for hide-mode viewers', function () {
$viewer = User::factory()->create();
DB::table('user_profiles')->insert([
'user_id' => $viewer->id,
'mature_content_visibility' => 'hide',
'mature_content_warning_enabled' => true,
]);
$safeArtwork = Artwork::factory()->create([
'title' => 'Daily Safe Artwork',
'is_public' => true,
'is_approved' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now(),
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
]);
$matureArtwork = Artwork::factory()->create([
'title' => 'Daily Hidden Mature Artwork',
'is_public' => true,
'is_approved' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now(),
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
]);
$this->actingAs($viewer)
->get(route('uploads.daily'))
->assertOk()
->assertSee('Daily Safe Artwork')
->assertDontSee('Daily Hidden Mature Artwork');
expect($safeArtwork->exists)->toBeTrue()
->and($matureArtwork->exists)->toBeTrue();
});
it('filters collection artworks and cover fallbacks for hide-mode viewers', function () {
$viewer = User::factory()->create();
$owner = User::factory()->create();
DB::table('user_profiles')->insert([
'user_id' => $viewer->id,
'mature_content_visibility' => 'hide',
'mature_content_warning_enabled' => true,
]);
$collection = Collection::factory()->create([
'user_id' => $owner->id,
'visibility' => Collection::VISIBILITY_PUBLIC,
]);
$matureArtwork = Artwork::factory()->create([
'user_id' => $owner->id,
'title' => 'Hidden Cover Artwork',
'hash' => 'mature-cover-hash',
'thumb_ext' => 'jpg',
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
'published_at' => now(),
]);
$safeArtwork = Artwork::factory()->create([
'user_id' => $owner->id,
'title' => 'Visible Safe Artwork',
'hash' => 'safe-cover-hash',
'thumb_ext' => 'jpg',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'published_at' => now(),
]);
$collection->forceFill([
'cover_artwork_id' => $matureArtwork->id,
])->save();
DB::table('collection_artwork')->insert([
[
'collection_id' => $collection->id,
'artwork_id' => $matureArtwork->id,
'order_num' => 1,
'created_at' => now(),
'updated_at' => now(),
],
[
'collection_id' => $collection->id,
'artwork_id' => $safeArtwork->id,
'order_num' => 2,
'created_at' => now(),
'updated_at' => now(),
],
]);
$service = app(CollectionService::class);
$cardPayload = $service->mapCollectionCardPayloads(
collect([$collection->fresh()->loadMissing(['user.profile', 'coverArtwork'])]),
false,
$viewer,
)[0];
$artworks = $service->getCollectionDetailArtworks($collection->fresh(), false, 24, $viewer);
expect($cardPayload['cover_artwork_id'])->toBe($safeArtwork->id)
->and($artworks->getCollection()->pluck('id')->all())->toBe([$safeArtwork->id]);
});