903 lines
34 KiB
PHP
903 lines
34 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('treats guests as hide-mode viewers when filtering catalog results', function () {
|
|
$safeArtwork = Artwork::factory()->create([
|
|
'title' => 'Guest Visible Safe Artwork',
|
|
'is_mature' => false,
|
|
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
|
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
|
]);
|
|
|
|
$matureArtwork = Artwork::factory()->create([
|
|
'title' => 'Guest Hidden Mature Artwork',
|
|
'is_mature' => true,
|
|
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
|
|
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
|
|
]);
|
|
|
|
$maturity = app(ArtworkMaturityService::class);
|
|
|
|
$preferences = $maturity->viewerPreferences(null);
|
|
$visibleIds = $maturity->applyViewerFilter(
|
|
Artwork::query()->whereKey([$safeArtwork->id, $matureArtwork->id]),
|
|
null,
|
|
)->pluck('id')->all();
|
|
$searchFilter = $maturity->appendSearchFilter('is_public = true', null);
|
|
|
|
expect($preferences['is_guest'])->toBeTrue()
|
|
->and($preferences['visibility'])->toBe(ArtworkMaturityService::VIEW_HIDE)
|
|
->and($visibleIds)->toContain($safeArtwork->id)
|
|
->and($visibleIds)->not->toContain($matureArtwork->id)
|
|
->and($searchFilter)->toContain('is_mature_effective = false');
|
|
});
|
|
|
|
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('hides mature items from the daily uploads page for guests', function () {
|
|
$safeArtwork = Artwork::factory()->create([
|
|
'title' => 'Guest 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' => 'Guest Daily 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->get(route('uploads.daily'))
|
|
->assertOk()
|
|
->assertSee('Guest Daily Safe Artwork')
|
|
->assertDontSee('Guest Daily Mature Artwork');
|
|
|
|
expect($safeArtwork->exists)->toBeTrue()
|
|
->and($matureArtwork->exists)->toBeTrue();
|
|
});
|
|
|
|
it('shows mature items on the daily uploads page for blur-mode viewers', function () {
|
|
$viewer = User::factory()->create();
|
|
|
|
DB::table('user_profiles')->insert([
|
|
'user_id' => $viewer->id,
|
|
'mature_content_visibility' => 'blur',
|
|
'mature_content_warning_enabled' => true,
|
|
]);
|
|
|
|
$safeArtwork = Artwork::factory()->create([
|
|
'title' => 'Blur 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' => 'Blur Daily 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('Blur Daily Safe Artwork')
|
|
->assertSee('Blur Daily 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]);
|
|
}); |