Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -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);
});

View File

@@ -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;amp; Manga',
'slug' => 'anime-manga',
'description' => 'Anime &amp;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 &amp; Manga"')
->not->toContain('value="Anime &amp;amp; Manga"')
->toContain('Anime &amp; Manga artwork')
->not->toContain('Anime &amp;amp; Manga artwork');
});

View File

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

View File

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

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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.',
]);
});

View File

@@ -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',
]);
});