Save workspace changes
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createArtworkBrowserAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('remembers the selected content type and root scope on the categories browser', function (): void {
|
||||
$admin = createArtworkBrowserAdmin();
|
||||
|
||||
$skins = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skin uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$wallpapers = ContentType::query()->create([
|
||||
'name' => 'Wallpapers',
|
||||
'slug' => 'wallpapers',
|
||||
'description' => 'Wallpaper uploads',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$selectedRoot = Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Skins Root Selected',
|
||||
'slug' => 'skins-root-selected',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $selectedRoot->id,
|
||||
'name' => 'Skin Child Alpha',
|
||||
'slug' => 'skin-child-alpha',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $selectedRoot->id,
|
||||
'name' => 'Skin Child Beta',
|
||||
'slug' => 'skin-child-beta',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $wallpapers->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Wallpaper Root Hidden',
|
||||
'slug' => 'wallpaper-root-hidden',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.categories.main', [
|
||||
'content_type_id' => $skins->id,
|
||||
'root_category_id' => $selectedRoot->id,
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSessionHas('cp.artworks.categories.filters.content_type_id', (string) $skins->id)
|
||||
->assertSessionHas('cp.artworks.categories.filters.root_category_id', (string) $selectedRoot->id)
|
||||
->assertSee('Subcategories of Skins Root Selected')
|
||||
->assertSee('Skin Child Alpha')
|
||||
->assertSee('Skin Child Beta')
|
||||
->assertDontSee('Wallpaper Root Hidden');
|
||||
});
|
||||
|
||||
it('reorders only the currently selected sibling level', function (): void {
|
||||
$admin = createArtworkBrowserAdmin();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skin uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$firstRoot = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'First Root',
|
||||
'slug' => 'first-root',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$secondRoot = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Second Root',
|
||||
'slug' => 'second-root',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$childOne = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'name' => 'Child One',
|
||||
'slug' => 'child-one',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$childTwo = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'name' => 'Child Two',
|
||||
'slug' => 'child-two',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$otherBranchChild = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $secondRoot->id,
|
||||
'name' => 'Other Branch Child',
|
||||
'slug' => 'other-branch-child',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.categories.reorder-tree'), [
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'data' => [
|
||||
['id' => $childTwo->id],
|
||||
['id' => $childOne->id],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'status' => 200,
|
||||
'message' => 'Category order updated.',
|
||||
]);
|
||||
|
||||
expect($childOne->fresh()->sort_order)->toBe(2)
|
||||
->and($childTwo->fresh()->sort_order)->toBe(1)
|
||||
->and($otherBranchChild->fresh()->sort_order)->toBe(1)
|
||||
->and($otherBranchChild->fresh()->parent_id)->toBe($secondRoot->id);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createArtworkTaxonomyAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('stores plain ampersands when categories are updated in control panel', function (): void {
|
||||
$admin = createArtworkTaxonomyAdmin();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Anime',
|
||||
'slug' => 'anime',
|
||||
'description' => 'Initial description',
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.cp.artworks.categories.update', $category->id), [
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => '',
|
||||
'name' => 'Anime & Manga',
|
||||
'slug' => 'anime',
|
||||
'description' => 'Anime & Manga artwork',
|
||||
'image' => '',
|
||||
'sort_order' => 1,
|
||||
'is_active' => '1',
|
||||
])
|
||||
->assertRedirect(route('admin.cp.artworks.categories.main'));
|
||||
|
||||
$category->refresh();
|
||||
|
||||
expect(DB::table('categories')->where('id', $category->id)->value('name'))->toBe('Anime & Manga')
|
||||
->and(DB::table('categories')->where('id', $category->id)->value('description'))->toBe('Anime & Manga artwork')
|
||||
->and($category->name)->toBe('Anime & Manga')
|
||||
->and($category->description)->toBe('Anime & Manga artwork');
|
||||
});
|
||||
|
||||
it('renders legacy encoded category names decoded in control panel edit form', function (): void {
|
||||
$admin = createArtworkTaxonomyAdmin();
|
||||
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$categoryId = DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Anime &amp; Manga',
|
||||
'slug' => 'anime-manga',
|
||||
'description' => 'Anime &amp; Manga artwork',
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$html = $this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.categories.edit', $categoryId))
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($html)
|
||||
->toContain('value="Anime & Manga"')
|
||||
->not->toContain('value="Anime &amp; Manga"')
|
||||
->toContain('Anime & Manga artwork')
|
||||
->not->toContain('Anime &amp; Manga artwork');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin discovery feedback report includes negative feedback and undo metrics', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
$previousDate = now()->subDay()->toDateString();
|
||||
$date = now()->toDateString();
|
||||
|
||||
$recordEvent = function (string $eventDate, string $eventType, array $meta = []) use ($user, $artwork) {
|
||||
$algoVersion = (string) ($meta['algo_version'] ?? 'clip-cosine-v2-adaptive');
|
||||
unset($meta['algo_version']);
|
||||
|
||||
DB::table('user_discovery_events')->insert([
|
||||
'event_id' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'category_id' => null,
|
||||
'event_type' => $eventType,
|
||||
'event_version' => 'event-v1',
|
||||
'algo_version' => $algoVersion,
|
||||
'weight' => 1.0,
|
||||
'event_date' => $eventDate,
|
||||
'occurred_at' => now(),
|
||||
'meta' => json_encode(array_merge([
|
||||
'gallery_type' => 'for-you',
|
||||
'surface' => 'for-you',
|
||||
], $meta), JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
};
|
||||
|
||||
$recordEvent($previousDate, 'view');
|
||||
$recordEvent($previousDate, 'click');
|
||||
$recordEvent($previousDate, 'favorite');
|
||||
$recordEvent($previousDate, 'hide_artwork', ['reason' => 'not_relevant']);
|
||||
$recordEvent($previousDate, 'dislike_tag', ['tag_slug' => 'abstract']);
|
||||
$recordEvent($previousDate, 'view', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($previousDate, 'click', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($previousDate, 'favorite', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
|
||||
$recordEvent($date, 'view');
|
||||
$recordEvent($date, 'click');
|
||||
$recordEvent($date, 'favorite');
|
||||
$recordEvent($date, 'download');
|
||||
$recordEvent($date, 'hide_artwork', ['reason' => 'not_relevant']);
|
||||
$recordEvent($date, 'unhide_artwork', ['reason' => 'undo']);
|
||||
$recordEvent($date, 'view', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($date, 'click', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($date, 'hide_artwork', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1', 'reason' => 'not_relevant']);
|
||||
$recordEvent($date, 'dislike_tag', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1', 'tag_slug' => 'portrait']);
|
||||
|
||||
$this->artisan('analytics:aggregate-discovery-feedback', ['--date' => $previousDate])
|
||||
->assertExitCode(0);
|
||||
$this->artisan('analytics:aggregate-discovery-feedback', ['--date' => $date])
|
||||
->assertExitCode(0);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->getJson('/api/admin/reports/discovery-feedback?from=' . $previousDate . '&to=' . $date . '&limit=10');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('overview.views', 4);
|
||||
$response->assertJsonPath('overview.clicks', 4);
|
||||
$response->assertJsonPath('overview.feedback_actions', 4);
|
||||
$response->assertJsonPath('overview.hidden_artworks', 3);
|
||||
$response->assertJsonPath('overview.disliked_tags', 2);
|
||||
$response->assertJsonPath('overview.negative_feedback_actions', 5);
|
||||
$response->assertJsonPath('overview.undo_hidden_artworks', 1);
|
||||
$response->assertJsonPath('overview.undo_disliked_tags', 0);
|
||||
$response->assertJsonPath('overview.undo_actions', 1);
|
||||
$response->assertJsonPath('daily_feedback.0.negative_feedback_actions', 2);
|
||||
$response->assertJsonPath('daily_feedback.1.negative_feedback_actions', 3);
|
||||
$response->assertJsonPath('daily_feedback.1.undo_actions', 1);
|
||||
$response->assertJsonPath('trend_summary.latest_day.date', $date);
|
||||
$response->assertJsonPath('trend_summary.previous_day.date', $previousDate);
|
||||
$response->assertJsonPath('trend_summary.rolling_7d_average.feedback_actions', 2);
|
||||
$response->assertJsonPath('trend_summary.rolling_7d_average.negative_feedback_actions', 2.5);
|
||||
$response->assertJsonPath('trend_summary.rolling_7d_average.undo_actions', 0.5);
|
||||
$response->assertJsonPath('trend_summary.deltas.feedback_actions.label', 'Flat');
|
||||
$response->assertJsonPath('trend_summary.deltas.negative_feedback_actions.label', 'Worse +1 vs prev day');
|
||||
$response->assertJsonPath('trend_summary.overall_status.level', 'watch');
|
||||
$response->assertJsonPath('by_surface.0.surface', 'homepage');
|
||||
$response->assertJsonPath('by_surface.0.negative_feedback_actions', 2);
|
||||
$response->assertJsonPath('by_surface.0.trend.overall_status.level', 'risk');
|
||||
$response->assertJsonPath('by_surface.0.trend.deltas.feedback_actions.label', 'Worse -1 vs prev day');
|
||||
$response->assertJsonPath('by_surface.0.trend.deltas.negative_feedback_actions.label', 'Worse +2 vs prev day');
|
||||
$response->assertJsonPath('by_surface.1.surface', 'for-you');
|
||||
$response->assertJsonPath('by_surface.1.trend.overall_status.level', 'healthy');
|
||||
$response->assertJsonPath('by_algo_surface.0.algo_version', 'clip-cosine-v1');
|
||||
$response->assertJsonPath('by_algo_surface.0.surface', 'homepage');
|
||||
$response->assertJsonPath('by_algo_surface.0.negative_feedback_actions', 2);
|
||||
$response->assertJsonPath('by_algo_surface.0.trend.overall_status.level', 'risk');
|
||||
$response->assertJsonPath('by_algo_surface.0.trend.deltas.feedback_actions.label', 'Worse -1 vs prev day');
|
||||
$response->assertJsonPath('by_algo_surface.0.trend.deltas.negative_feedback_actions.label', 'Worse +2 vs prev day');
|
||||
$response->assertJsonPath('by_algo_surface.1.algo_version', 'clip-cosine-v2-adaptive');
|
||||
$response->assertJsonPath('top_artworks.0.artwork_id', $artwork->id);
|
||||
$response->assertJsonPath('top_artworks.0.negative_feedback_actions', 3);
|
||||
$response->assertJsonPath('top_artworks.0.undo_actions', 1);
|
||||
$response->assertJsonPath('latest_aggregated_date', $date);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin can inspect feed engine decisions for a user bucket', function () {
|
||||
config()->set('discovery.v2.enabled', true);
|
||||
config()->set('discovery.v2.rollout_percentage', 35);
|
||||
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$subject = User::factory()->create();
|
||||
$expectedBucket = abs((int) crc32((string) $subject->id)) % 100;
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('decision.user_id', $subject->id);
|
||||
$response->assertJsonPath('decision.bucket', $expectedBucket);
|
||||
$response->assertJsonPath('decision.rollout_percentage', 35);
|
||||
$response->assertJsonPath('decision.uses_v2', $expectedBucket < 35);
|
||||
$response->assertJsonPath('decision.selected_engine', $expectedBucket < 35 ? 'v2' : 'v1');
|
||||
});
|
||||
|
||||
it('admin can inspect explicit v2 algo overrides even when rollout is disabled', function () {
|
||||
config()->set('discovery.v2.enabled', false);
|
||||
config()->set('discovery.v2.rollout_percentage', 0);
|
||||
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id . '&algo_version=clip-cosine-v2-adaptive');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('decision.uses_v2', true);
|
||||
$response->assertJsonPath('decision.selected_engine', 'v2');
|
||||
$response->assertJsonPath('decision.reason', 'explicit_algo_override');
|
||||
});
|
||||
|
||||
it('non-admin is denied feed engine decision endpoint', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin report returns feed performance breakdown and top clicked artworks', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$artworkA = Artwork::factory()->create(['title' => 'Feed Artwork A']);
|
||||
$artworkB = Artwork::factory()->create(['title' => 'Feed Artwork B']);
|
||||
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 10,
|
||||
'clicks' => 4,
|
||||
'saves' => 2,
|
||||
'ctr' => 0.4,
|
||||
'save_rate' => 0.5,
|
||||
'dwell_0_5' => 1,
|
||||
'dwell_5_30' => 1,
|
||||
'dwell_30_120' => 1,
|
||||
'dwell_120_plus' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('feed_events')->insert([
|
||||
[
|
||||
'event_date' => $metricDate,
|
||||
'event_type' => 'feed_impression',
|
||||
'user_id' => $admin->id,
|
||||
'artwork_id' => $artworkA->id,
|
||||
'position' => 1,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'dwell_seconds' => null,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $metricDate,
|
||||
'event_type' => 'feed_click',
|
||||
'user_id' => $admin->id,
|
||||
'artwork_id' => $artworkA->id,
|
||||
'position' => 1,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'dwell_seconds' => 12,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $metricDate,
|
||||
'event_type' => 'feed_click',
|
||||
'user_id' => $admin->id,
|
||||
'artwork_id' => $artworkB->id,
|
||||
'position' => 2,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'dwell_seconds' => 7,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/reports/feed-performance?from=' . $metricDate . '&to=' . $metricDate);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.from', $metricDate);
|
||||
$response->assertJsonPath('meta.to', $metricDate);
|
||||
|
||||
$rows = collect($response->json('by_algo_source'));
|
||||
expect($rows->count())->toBe(1);
|
||||
expect($rows->first()['algo_version'])->toBe('clip-cosine-v1');
|
||||
expect($rows->first()['source'])->toBe('personalized');
|
||||
expect((float) $rows->first()['ctr'])->toBe(0.4);
|
||||
|
||||
$top = collect($response->json('top_clicked_artworks'));
|
||||
expect($top->isNotEmpty())->toBeTrue();
|
||||
expect((int) $top->first()['artwork_id'])->toBe($artworkA->id);
|
||||
});
|
||||
|
||||
it('non-admin is denied feed performance report endpoint', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/admin/reports/feed-performance');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin report returns date-filtered breakdown by algo and top similarities with ctr', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$sourceA = Artwork::factory()->create(['title' => 'Source A']);
|
||||
$similarA = Artwork::factory()->create(['title' => 'Similar A']);
|
||||
$sourceB = Artwork::factory()->create(['title' => 'Source B']);
|
||||
$similarB = Artwork::factory()->create(['title' => 'Similar B']);
|
||||
|
||||
$inRangeDate = now()->subDay()->toDateString();
|
||||
$outRangeDate = now()->subDays(5)->toDateString();
|
||||
|
||||
DB::table('similar_artwork_events')->insert([
|
||||
[
|
||||
'event_date' => $inRangeDate,
|
||||
'event_type' => 'impression',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source_artwork_id' => $sourceA->id,
|
||||
'similar_artwork_id' => $similarA->id,
|
||||
'position' => 1,
|
||||
'items_count' => 4,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $inRangeDate,
|
||||
'event_type' => 'click',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source_artwork_id' => $sourceA->id,
|
||||
'similar_artwork_id' => $similarA->id,
|
||||
'position' => 1,
|
||||
'items_count' => null,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $inRangeDate,
|
||||
'event_type' => 'impression',
|
||||
'algo_version' => 'clip-cosine-v2',
|
||||
'source_artwork_id' => $sourceB->id,
|
||||
'similar_artwork_id' => $similarB->id,
|
||||
'position' => 2,
|
||||
'items_count' => 4,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $outRangeDate,
|
||||
'event_type' => 'click',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source_artwork_id' => $sourceA->id,
|
||||
'similar_artwork_id' => $similarA->id,
|
||||
'position' => 1,
|
||||
'items_count' => null,
|
||||
'occurred_at' => now()->subDays(5),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/reports/similar-artworks?from=' . $inRangeDate . '&to=' . $inRangeDate);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.from', $inRangeDate);
|
||||
$response->assertJsonPath('meta.to', $inRangeDate);
|
||||
|
||||
$byAlgo = collect($response->json('by_algo_version'));
|
||||
expect($byAlgo->count())->toBe(2);
|
||||
|
||||
$v1 = $byAlgo->firstWhere('algo_version', 'clip-cosine-v1');
|
||||
expect($v1['impressions'])->toBe(1);
|
||||
expect($v1['clicks'])->toBe(1);
|
||||
expect((float) $v1['ctr'])->toBe(1.0);
|
||||
|
||||
$top = collect($response->json('top_similarities'));
|
||||
expect($top->isNotEmpty())->toBeTrue();
|
||||
expect($top->first()['source_artwork_id'])->toBe($sourceA->id);
|
||||
expect($top->first()['similar_artwork_id'])->toBe($similarA->id);
|
||||
expect((float) $top->first()['ctr'])->toBe(1.0);
|
||||
});
|
||||
|
||||
it('non-admin is denied similar artwork report endpoint', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/admin/reports/similar-artworks');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createModerationCategory(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Moderation',
|
||||
'slug' => 'moderation-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createModerationDraft(int $userId, int $categoryId, array $overrides = []): string
|
||||
{
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert(array_merge([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $userId,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'ready',
|
||||
'moderation_status' => 'pending',
|
||||
'title' => 'Pending Moderation Upload',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $overrides));
|
||||
|
||||
return $uploadId;
|
||||
}
|
||||
|
||||
function addReadyMainFile(string $uploadId, string $hash = 'aabbccddeeff00112233'): void
|
||||
{
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/main.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => 3,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('admin sees pending uploads', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createModerationCategory();
|
||||
|
||||
createModerationDraft($owner->id, $categoryId, ['title' => 'First Pending']);
|
||||
createModerationDraft($owner->id, $categoryId, ['title' => 'Second Pending']);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
it('non-admin is denied moderation API access', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/admin/uploads/pending');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('approve works', function () {
|
||||
$admin = User::factory()->create(['role' => 'moderator']);
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createModerationCategory();
|
||||
$uploadId = createModerationDraft($owner->id, $categoryId);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/approve", [
|
||||
'note' => 'Looks good.',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$row = DB::table('uploads')->where('id', $uploadId)->first([
|
||||
'moderation_status',
|
||||
'moderation_note',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
]);
|
||||
|
||||
expect($row->moderation_status)->toBe('approved');
|
||||
expect($row->moderation_note)->toBe('Looks good.');
|
||||
expect((int) $row->moderated_by)->toBe((int) $admin->id);
|
||||
expect($row->moderated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('reject works', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createModerationCategory();
|
||||
$uploadId = createModerationDraft($owner->id, $categoryId);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/reject", [
|
||||
'note' => 'Policy violation.',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$row = DB::table('uploads')->where('id', $uploadId)->first([
|
||||
'status',
|
||||
'processing_state',
|
||||
'moderation_status',
|
||||
'moderation_note',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
]);
|
||||
|
||||
expect($row->status)->toBe('rejected');
|
||||
expect($row->processing_state)->toBe('rejected');
|
||||
expect($row->moderation_status)->toBe('rejected');
|
||||
expect($row->moderation_note)->toBe('Policy violation.');
|
||||
expect((int) $row->moderated_by)->toBe((int) $admin->id);
|
||||
expect($row->moderated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('user cannot publish without approval', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create(['role' => 'user']);
|
||||
$categoryId = createModerationCategory();
|
||||
$uploadId = createModerationDraft($owner->id, $categoryId, [
|
||||
'moderation_status' => 'pending',
|
||||
'title' => 'Blocked Publish',
|
||||
]);
|
||||
|
||||
addReadyMainFile($uploadId);
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment([
|
||||
'message' => 'Upload requires moderation approval before publish.',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin can open username moderation page', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/admin/usernames/moderation')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('non-admin cannot open username moderation page', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/usernames/moderation')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('queues similarity-flagged onboarding username for manual approval', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarding_step' => 'password',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->from('/setup/username')->post('/setup/username', [
|
||||
'username' => 'admin1',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('username');
|
||||
|
||||
$this->assertDatabaseHas('username_approval_requests', [
|
||||
'user_id' => $user->id,
|
||||
'requested_username' => 'admin1',
|
||||
'context' => 'onboarding_username',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('admin can approve queued onboarding username and allow retry', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$user = User::factory()->create([
|
||||
'onboarding_step' => 'password',
|
||||
'username' => 'before_approval',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->post('/setup/username', [
|
||||
'username' => 'support1',
|
||||
])->assertSessionHasErrors('username');
|
||||
|
||||
$requestId = (int) DB::table('username_approval_requests')
|
||||
->where('user_id', $user->id)
|
||||
->where('requested_username', 'support1')
|
||||
->where('context', 'onboarding_username')
|
||||
->where('status', 'pending')
|
||||
->value('id');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson("/api/admin/usernames/{$requestId}/approve", ['note' => 'Allowed'])
|
||||
->assertOk()
|
||||
->assertJsonFragment(['status' => 'approved']);
|
||||
|
||||
$response = $this->actingAs($user)->post('/setup/username', [
|
||||
'username' => 'support1',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/@support1');
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'username' => 'support1',
|
||||
'onboarding_step' => 'complete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('approving profile-update request applies the username rename', function () {
|
||||
$admin = User::factory()->create(['role' => 'moderator']);
|
||||
$user = User::factory()->create([
|
||||
'username' => 'old_name',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'username' => 'admin1',
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
])
|
||||
->assertSessionHasErrors('username');
|
||||
|
||||
$requestId = (int) DB::table('username_approval_requests')
|
||||
->where('user_id', $user->id)
|
||||
->where('requested_username', 'admin1')
|
||||
->where('context', 'profile_update')
|
||||
->where('status', 'pending')
|
||||
->value('id');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson("/api/admin/usernames/{$requestId}/approve")
|
||||
->assertOk()
|
||||
->assertJsonFragment(['status' => 'approved']);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'username' => 'admin1',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('username_history', [
|
||||
'user_id' => $user->id,
|
||||
'old_username' => 'old_name',
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user