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,39 @@
{
"cookies": [
{
"name": "XSRF-TOKEN",
"value": "eyJpdiI6IllHU2pQYXRJODYwT3BSb0EvVzlpNEE9PSIsInZhbHVlIjoiNS9UWEIwM3VSVGZjVmtGNm50Q1lnM1FudlJwWnZlMW1GZGZrdUhUZ0JaT2FHMWF0cHhDeGJjanJXSnY5SWR1cjFCMGFlTGRLWlF3UGYxUzkrZ2JRQ1psVmxWTzc3eENUVG5pQ3Roa2NVL3RzTXEzZk5LblBNVEQzS3ZuWEJrWlIiLCJtYWMiOiI5YTcyZWE3Y2NjZWJiMmRhNzVmNmYwM2QxZGIwYTA4YjIxOGEwZTYzOGNlYzBiMjU2ZDVlMzQwOWFlY2FjMzQzIiwidGFnIjoiIn0%3D",
"domain": "skinbase26.test",
"path": "/",
"expires": 1774549455.73563,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "skinbasenova-session",
"value": "eyJpdiI6IjJpaTBWTk10YU56aXBrUDdMOUtia2c9PSIsInZhbHVlIjoialhXU2N3cHRLM0dQN29teDR5MlFPRWhRMXlVRG1jOGFETUNKVnA3NW9HZSt1RjNOaXRUWGdXYmhDMEJLLzJXdTc5NUNOMUwrdk1wTE5wUk0vNDVjY3JTYjk5cmY5YmplT2NqRzBGckpIcDFiTjdvRWhEaXdidnNsbGcyTDhzK2MiLCJtYWMiOiJiOWVhNDhhOWQwMmI4NTRlMDkyMjFiMzQ4OWM3ZTc3YmEzYjhiNDFhOTBjMDhjMWE1MjI3YTQ1N2Y2ODAzODFmIiwidGFnIjoiIn0%3D",
"domain": "skinbase26.test",
"path": "/",
"expires": 1774549455.735725,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://skinbase26.test",
"localStorage": [
{
"name": "phpdebugbar-height",
"value": "301"
},
{
"name": "phpdebugbar-visible",
"value": "0"
}
]
}
]
}

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

View File

@@ -0,0 +1,138 @@
<?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('stores feed analytics events with required dimensions', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/analytics/feed', [
'event_type' => 'feed_click',
'artwork_id' => $artwork->id,
'position' => 3,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 27,
]);
$response->assertOk()->assertJson(['success' => true]);
$this->assertDatabaseHas('feed_events', [
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'event_type' => 'feed_click',
'position' => 3,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 27,
]);
});
it('aggregates daily feed analytics with ctr save-rate and dwell buckets', function () {
$user = User::factory()->create();
$artworkA = Artwork::factory()->create();
$artworkB = Artwork::factory()->create();
$metricDate = now()->subDay()->toDateString();
DB::table('feed_events')->insert([
[
'event_date' => $metricDate,
'event_type' => 'feed_impression',
'user_id' => $user->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_impression',
'user_id' => $user->id,
'artwork_id' => $artworkB->id,
'position' => 2,
'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' => $user->id,
'artwork_id' => $artworkA->id,
'position' => 1,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 3,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $metricDate,
'event_type' => 'feed_click',
'user_id' => $user->id,
'artwork_id' => $artworkB->id,
'position' => 2,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 35,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
]);
DB::table('user_discovery_events')->insert([
'event_id' => '33333333-3333-3333-3333-333333333333',
'user_id' => $user->id,
'artwork_id' => $artworkA->id,
'category_id' => null,
'event_type' => 'favorite',
'event_version' => 'event-v1',
'algo_version' => 'clip-cosine-v1',
'weight' => 1,
'event_date' => $metricDate,
'occurred_at' => now()->subDay(),
'meta' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$this->artisan('analytics:aggregate-feed', ['--date' => $metricDate])->assertSuccessful();
$this->assertDatabaseHas('feed_daily_metrics', [
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 2,
'clicks' => 2,
'saves' => 1,
'dwell_0_5' => 1,
'dwell_30_120' => 1,
]);
$metric = DB::table('feed_daily_metrics')
->where('metric_date', $metricDate)
->where('algo_version', 'clip-cosine-v1')
->where('source', 'personalized')
->first();
expect((float) $metric->ctr)->toBe(1.0);
expect((float) $metric->save_rate)->toBe(0.5);
});

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
it('evaluates feed weights for all algos', function () {
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 6,
'ctr' => 0.2,
'save_rate' => 0.3,
'dwell_0_5' => 4,
'dwell_5_30' => 8,
'dwell_30_120' => 5,
'dwell_120_plus' => 3,
'created_at' => now(),
'updated_at' => now(),
],
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v2',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 22,
'saves' => 8,
'ctr' => 0.22,
'save_rate' => 0.36,
'dwell_0_5' => 3,
'dwell_5_30' => 9,
'dwell_30_120' => 6,
'dwell_120_plus' => 4,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->artisan('analytics:evaluate-feed-weights', ['--from' => $metricDate, '--to' => $metricDate])
->assertSuccessful();
});
it('compares baseline and candidate feed algos', function () {
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 6,
'ctr' => 0.2,
'save_rate' => 0.3,
'dwell_0_5' => 4,
'dwell_5_30' => 8,
'dwell_30_120' => 5,
'dwell_120_plus' => 3,
'created_at' => now(),
'updated_at' => now(),
],
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v2',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 24,
'saves' => 10,
'ctr' => 0.24,
'save_rate' => 0.416,
'dwell_0_5' => 3,
'dwell_5_30' => 8,
'dwell_30_120' => 7,
'dwell_120_plus' => 6,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->artisan('analytics:compare-feed-ab', [
'baseline' => 'clip-cosine-v1',
'candidate' => 'clip-cosine-v2',
'--from' => $metricDate,
'--to' => $metricDate,
])->assertSuccessful();
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('stores similar artwork analytics events', function () {
$author = User::factory()->create();
$source = Artwork::factory()->create(['user_id' => $author->id]);
$similar = Artwork::factory()->create(['user_id' => $author->id]);
$response = $this->postJson('/api/analytics/similar-artworks', [
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $source->id,
'similar_artwork_id' => $similar->id,
'position' => 2,
]);
$response->assertOk()->assertJson(['success' => true]);
$this->assertDatabaseHas('similar_artwork_events', [
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $source->id,
'similar_artwork_id' => $similar->id,
'position' => 2,
]);
});
it('aggregates daily analytics counts by algo version', function () {
DB::table('similar_artwork_events')->insert([
[
'event_date' => now()->subDay()->toDateString(),
'event_type' => 'impression',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => Artwork::factory()->create()->id,
'similar_artwork_id' => null,
'position' => null,
'items_count' => 8,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => now()->subDay()->toDateString(),
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => Artwork::factory()->create()->id,
'similar_artwork_id' => Artwork::factory()->create()->id,
'position' => 1,
'items_count' => null,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->artisan('analytics:aggregate-similar-artworks', ['--date' => now()->subDay()->toDateString()])
->assertSuccessful();
$this->assertDatabaseHas('similar_artwork_daily_metrics', [
'metric_date' => now()->subDay()->toDateString(),
'algo_version' => 'clip-cosine-v1',
'impressions' => 1,
'clicks' => 1,
]);
});

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\Artwork;
use App\Models\ContentType;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('view public artwork by slug', function () {
$art = Artwork::factory()->create();
$this->getJson('/api/v1/artworks/' . $art->slug)
->assertStatus(200)
->assertJsonPath('slug', $art->slug)
->assertJsonStructure(['slug', 'title', 'description', 'file', 'published_at']);
});
test('cannot view unapproved artwork', function () {
$art = Artwork::factory()->unapproved()->create();
$this->getJson('/api/v1/artworks/' . $art->slug)
->assertStatus(404);
});
test('soft-deleted artwork returns 404', function () {
$art = Artwork::factory()->create();
$art->delete();
$this->getJson('/api/v1/artworks/' . $art->slug)
->assertStatus(404);
});
test('category browsing returns artworks for the category only', function () {
$contentType = ContentType::create(['name' => 'Photography', 'slug' => 'photography', 'description' => '']);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract',
'description' => '',
'is_active' => true,
'sort_order' => 0,
]);
$inCat = Artwork::factory()->create();
$outCat = Artwork::factory()->create();
$inCat->categories()->attach($category->id);
$this->getJson('/api/v1/categories/' . $category->slug . '/artworks')
->assertStatus(200)
->assertJsonStructure(['data', 'links', 'meta'])
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.slug', $inCat->slug);
});
test('unauthorized or private access is blocked (private artwork)', function () {
$art = Artwork::factory()->private()->create();
$this->getJson('/api/v1/artworks/' . $art->slug)
->assertStatus(404);
});

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;
it('creates upload drafts as private artworks', function (): void {
$user = User::factory()->create();
actingAs($user);
$response = postJson('/api/artworks', [
'title' => 'Upload draft test',
'description' => '<p>Draft body</p>',
'is_mature' => false,
]);
$response->assertCreated()
->assertJsonPath('status', 'draft');
$artworkId = (int) $response->json('artwork_id');
$artwork = Artwork::query()->findOrFail($artworkId);
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_PRIVATE)
->and($artwork->is_public)->toBeFalse()
->and($artwork->artwork_status)->toBe('draft')
->and($artwork->published_at)->toBeNull();
});

View File

@@ -0,0 +1,156 @@
<?php
use App\Enums\ReactionType;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// ── Comment CRUD ──────────────────────────────────────────────────────────────
test('authenticated user can post a comment', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/comments", [
'content' => 'Great work! Really love the **colours**.',
])
->assertStatus(201)
->assertJsonPath('data.user.id', $user->id)
->assertJsonStructure(['data' => ['id', 'raw_content', 'rendered_content', 'user']]);
});
test('guest cannot post a comment', function () {
$artwork = Artwork::factory()->create();
$this->postJson("/api/artworks/{$artwork->id}/comments", ['content' => 'Nice!'])
->assertStatus(401);
});
test('comment with raw HTML is rejected via validation', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/comments", [
'content' => '<script>alert("xss")</script>',
])
->assertStatus(422);
});
test('user can view comments on public artwork', function () {
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
$this->getJson("/api/artworks/{$artwork->id}/comments")
->assertStatus(200)
->assertJsonStructure(['data', 'meta'])
->assertJsonCount(1, 'data');
});
// ── Reactions ─────────────────────────────────────────────────────────────────
test('authenticated user can add an artwork reaction', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::Heart->value,
])
->assertStatus(200)
->assertJsonPath('reaction', ReactionType::Heart->value)
->assertJsonPath('active', true);
});
test('reaction is toggled off when posted twice', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
// First toggle — on
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::ThumbsUp->value,
])
->assertJsonPath('active', true);
// Second toggle — off
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::ThumbsUp->value,
])
->assertJsonPath('active', false);
});
test('guest cannot add a reaction', function () {
$artwork = Artwork::factory()->create();
$this->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::Fire->value,
])->assertStatus(401);
});
test('invalid reaction slug is rejected', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => 'not_valid_slug',
])
->assertStatus(422);
});
test('reaction totals are returned for public artworks', function () {
$artwork = Artwork::factory()->create();
$user = User::factory()->create();
// Insert a reaction directly
\Illuminate\Support\Facades\DB::table('artwork_reactions')->insert([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'reaction' => ReactionType::Clap->value,
'created_at' => now(),
]);
$this->getJson("/api/artworks/{$artwork->id}/reactions")
->assertStatus(200)
->assertJsonPath('totals.' . ReactionType::Clap->value . '.count', 1)
->assertJsonPath('totals.' . ReactionType::Clap->value . '.emoji', '👏');
});
test('user can react to a comment', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
$this->actingAs($user)
->postJson("/api/comments/{$comment->id}/reactions", [
'reaction' => ReactionType::Laugh->value,
])
->assertStatus(200)
->assertJsonPath('active', true)
->assertJsonPath('entity_type', 'comment');
});
test('reaction uniqueness per user per slug', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$slug = ReactionType::Wow->value;
// Toggle on
$this->actingAs($user)->postJson("/api/artworks/{$artwork->id}/reactions", ['reaction' => $slug]);
// DB should have exactly 1 row
$this->assertDatabaseCount('artwork_reactions', 1);
// Toggle off
$this->actingAs($user)->postJson("/api/artworks/{$artwork->id}/reactions", ['reaction' => $slug]);
// DB should have 0 rows
$this->assertDatabaseCount('artwork_reactions', 0);
});

View File

@@ -0,0 +1,229 @@
<?php
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\Story;
use App\Models\User;
test('authenticated user can like artwork through generic social endpoint and owner is notified', function () {
$owner = User::factory()->create();
$actor = User::factory()->create();
$artwork = Artwork::factory()->create(['user_id' => $owner->id]);
$this->actingAs($actor)
->postJson('/api/like', [
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'state' => true,
])
->assertOk()
->assertJsonPath('is_liked', true)
->assertJsonPath('stats.likes', 1);
$this->assertDatabaseHas('artwork_likes', [
'artwork_id' => $artwork->id,
'user_id' => $actor->id,
]);
$notification = $owner->fresh()
->notifications()
->where('type', 'artwork_liked')
->latest()
->first();
expect($notification)->not->toBeNull();
expect($notification->data['type'] ?? null)->toBe('artwork_liked');
expect($notification->data['actor_id'] ?? null)->toBe($actor->id);
});
test('authenticated user can comment on artwork through generic social endpoint and send owner and mention notifications', function () {
$owner = User::factory()->create();
$actor = User::factory()->create();
$mentioned = User::factory()->create();
$artwork = Artwork::factory()->create(['user_id' => $owner->id]);
$this->actingAs($actor)
->postJson('/api/comments', [
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'content' => 'Great work @' . $mentioned->username,
])
->assertCreated()
->assertJsonPath('data.user.id', $actor->id);
$comment = ArtworkComment::query()->latest('id')->first();
expect($comment)->not->toBeNull();
expect($comment->artwork_id)->toBe($artwork->id);
$ownerNotification = $owner->fresh()
->notifications()
->where('type', 'artwork_commented')
->latest()
->first();
$mentionedNotification = $mentioned->fresh()
->notifications()
->where('type', 'artwork_mentioned')
->latest()
->first();
expect($ownerNotification)->not->toBeNull();
expect($ownerNotification->data['type'] ?? null)->toBe('artwork_commented');
expect($mentionedNotification)->not->toBeNull();
expect($mentionedNotification->data['type'] ?? null)->toBe('artwork_mentioned');
$this->assertDatabaseHas('user_mentions', [
'comment_id' => $comment->id,
'mentioned_user_id' => $mentioned->id,
]);
});
test('generic comments endpoint lists artwork comments', function () {
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
$this->getJson('/api/comments?entity_type=artwork&entity_id=' . $artwork->id)
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $comment->id);
});
test('authenticated user can bookmark artwork through generic endpoint and see it in bookmarks list', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson('/api/bookmark', [
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'state' => true,
])
->assertOk()
->assertJsonPath('is_bookmarked', true)
->assertJsonPath('stats.bookmarks', 1);
$this->assertDatabaseHas('artwork_bookmarks', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
]);
$this->actingAs($user)
->getJson('/api/bookmarks?entity_type=artwork')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.type', 'artwork')
->assertJsonPath('data.0.id', $artwork->id);
});
test('authenticated user can like and bookmark a story through generic social endpoints', function () {
$owner = User::factory()->create();
$actor = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $owner->id,
'title' => 'Published Story',
'slug' => 'published-story-' . strtolower((string) \Illuminate\Support\Str::random(6)),
'content' => '<p>Story body</p>',
'story_type' => 'creator_story',
'status' => 'published',
'published_at' => now()->subMinute(),
]);
$this->actingAs($actor)
->postJson('/api/like', [
'entity_type' => 'story',
'entity_id' => $story->id,
'state' => true,
])
->assertOk()
->assertJsonPath('is_liked', true)
->assertJsonPath('stats.likes', 1);
$this->assertDatabaseHas('story_likes', [
'story_id' => $story->id,
'user_id' => $actor->id,
]);
$likeNotification = $owner->fresh()
->notifications()
->where('type', 'story_liked')
->latest()
->first();
expect($likeNotification)->not->toBeNull();
expect($likeNotification->data['type'] ?? null)->toBe('story_liked');
$this->actingAs($actor)
->postJson('/api/bookmark', [
'entity_type' => 'story',
'entity_id' => $story->id,
'state' => true,
])
->assertOk()
->assertJsonPath('is_bookmarked', true)
->assertJsonPath('stats.bookmarks', 1);
$this->assertDatabaseHas('story_bookmarks', [
'story_id' => $story->id,
'user_id' => $actor->id,
]);
$this->actingAs($actor)
->getJson('/api/bookmarks?entity_type=story')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.type', 'story')
->assertJsonPath('data.0.id', $story->id);
});
test('authenticated user can comment on a story through generic social endpoint and send owner and mention notifications', function () {
$owner = User::factory()->create();
$actor = User::factory()->create();
$mentioned = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $owner->id,
'title' => 'Commentable Story',
'slug' => 'commentable-story-' . strtolower((string) \Illuminate\Support\Str::random(6)),
'content' => '<p>Story body</p>',
'story_type' => 'creator_story',
'status' => 'published',
'published_at' => now()->subMinute(),
]);
$this->actingAs($actor)
->postJson('/api/comments', [
'entity_type' => 'story',
'entity_id' => $story->id,
'content' => 'Great story @' . $mentioned->username,
])
->assertCreated()
->assertJsonPath('data.user.id', $actor->id);
$this->assertDatabaseHas('story_comments', [
'story_id' => $story->id,
'user_id' => $actor->id,
]);
$ownerNotification = $owner->fresh()
->notifications()
->where('type', 'story_commented')
->latest()
->first();
$mentionedNotification = $mentioned->fresh()
->notifications()
->where('type', 'story_mentioned')
->latest()
->first();
expect($ownerNotification)->not->toBeNull();
expect($ownerNotification->data['type'] ?? null)->toBe('story_commented');
expect($mentionedNotification)->not->toBeNull();
expect($mentionedNotification->data['type'] ?? null)->toBe('story_mentioned');
$this->actingAs($actor)
->getJson('/api/comments?entity_type=story&entity_id=' . $story->id)
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.user.id', $actor->id);
});

View File

@@ -0,0 +1,434 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\User;
use App\Jobs\IndexArtworkJob;
use App\Services\HomepageService;
use App\Services\ArtworkAwardService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware(VerifyCsrfToken::class);
});
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function makePublishedArtwork(array $attrs = []): Artwork
{
return Artwork::factory()->create(array_merge([
'is_public' => true,
'is_approved' => true,
], $attrs));
}
// ---------------------------------------------------------------------------
// Service-layer tests
// ---------------------------------------------------------------------------
test('user can award an artwork', function () {
$service = app(ArtworkAwardService::class);
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$award = $service->award($artwork, $user, 'gold');
expect($award->medal)->toBe('gold')
->and($award->weight)->toBe(5)
->and($award->artwork_id)->toBe($artwork->id)
->and($award->user_id)->toBe($user->id);
});
test('stats are recalculated after awarding', function () {
$service = app(ArtworkAwardService::class);
$owner = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => $owner->id]);
$userA = User::factory()->create();
$userB = User::factory()->create();
$userC = User::factory()->create();
$service->award($artwork, $userA, 'gold');
$service->award($artwork, $userB, 'silver');
$service->award($artwork, $userC, 'bronze');
$stat = ArtworkAwardStat::find($artwork->id);
expect($stat->gold_count)->toBe(1)
->and($stat->silver_count)->toBe(1)
->and($stat->bronze_count)->toBe(1)
->and($stat->score_total)->toBe(9)
->and($stat->score_7d)->toBe(9)
->and($stat->score_30d)->toBe(9);
});
test('duplicate award is rejected', function () {
$service = app(ArtworkAwardService::class);
$user = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$service->award($artwork, $user, 'gold');
expect(fn () => $service->award($artwork, $user, 'silver'))
->toThrow(Illuminate\Validation\ValidationException::class);
});
test('user can change their award', function () {
$service = app(ArtworkAwardService::class);
$user = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$service->award($artwork, $user, 'gold');
$updated = $service->changeAward($artwork, $user, 'bronze');
expect($updated->medal)->toBe('bronze')
->and($updated->weight)->toBe(1);
$stat = ArtworkAwardStat::find($artwork->id);
expect($stat->gold_count)->toBe(0)
->and($stat->bronze_count)->toBe(1)
->and($stat->score_total)->toBe(1);
});
test('user can remove their award', function () {
$service = app(ArtworkAwardService::class);
$user = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$service->award($artwork, $user, 'silver');
$service->removeAward($artwork, $user);
expect(ArtworkAward::where('artwork_id', $artwork->id)->where('user_id', $user->id)->exists())
->toBeFalse();
$stat = ArtworkAwardStat::find($artwork->id);
expect($stat)->not->toBeNull()
->and($stat->silver_count)->toBe(0)
->and($stat->score_total)->toBe(0);
});
test('score formula is gold×5 + silver×3 + bronze×1', function () {
$service = app(ArtworkAwardService::class);
$owner = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => $owner->id]);
foreach (['gold', 'gold', 'silver', 'bronze'] as $medal) {
$service->award($artwork, User::factory()->create(), $medal);
}
$stat = ArtworkAwardStat::find($artwork->id);
expect($stat->score_total)->toBe((2 * 5) + (1 * 3) + (1 * 1));
});
test('recent medal scores only count medals inside the rolling windows', function () {
$owner = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => $owner->id]);
$service = app(ArtworkAwardService::class);
DB::table('artwork_medals')->insert([
[
'artwork_id' => $artwork->id,
'user_id' => User::factory()->create()->id,
'medal_type' => 'gold',
'weight' => 5,
'created_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
],
[
'artwork_id' => $artwork->id,
'user_id' => User::factory()->create()->id,
'medal_type' => 'silver',
'weight' => 3,
'created_at' => now()->subDays(10),
'updated_at' => now()->subDays(10),
],
[
'artwork_id' => $artwork->id,
'user_id' => User::factory()->create()->id,
'medal_type' => 'bronze',
'weight' => 1,
'created_at' => now()->subDays(40),
'updated_at' => now()->subDays(40),
],
]);
$service->recalcStats($artwork->id);
$stat = ArtworkAwardStat::find($artwork->id);
expect($stat->score_total)->toBe(9)
->and($stat->score_7d)->toBe(5)
->and($stat->score_30d)->toBe(8);
});
// ---------------------------------------------------------------------------
// API endpoint tests
// ---------------------------------------------------------------------------
test('POST /api/artworks/{id}/award — guest is rejected', function () {
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$this->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold'])
->assertUnauthorized();
});
test('POST /api/artworks/{id}/award — authenticated user can award', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold'])
->assertCreated()
->assertJsonPath('awards.gold', 1)
->assertJsonPath('viewer_award', 'gold');
});
test('POST /api/artworks/{id}/medal upserts medal state and returns fresh stats', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/medal", ['medal_type' => 'gold'])
->assertCreated()
->assertJsonPath('medals.gold', 1)
->assertJsonPath('medals.score', 5)
->assertJsonPath('current_user_medal', 'gold');
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/medal", ['medal_type' => 'silver'])
->assertOk()
->assertJsonPath('medals.gold', 0)
->assertJsonPath('medals.silver', 1)
->assertJsonPath('medals.score', 3)
->assertJsonPath('current_user_medal', 'silver');
expect(ArtworkAward::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->count())->toBe(1);
});
test('DELETE /api/artworks/{id}/medal removes the current medal idempotently', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => 'silver',
'weight' => 3,
]);
$this->actingAs($user)
->deleteJson("/api/artworks/{$artwork->id}/medal")
->assertOk()
->assertJsonPath('current_user_medal', null)
->assertJsonPath('medals.score', 0);
$this->actingAs($user)
->deleteJson("/api/artworks/{$artwork->id}/medal")
->assertOk()
->assertJsonPath('current_user_medal', null)
->assertJsonPath('medals.score', 0);
});
test('POST /api/artworks/{id}/award — duplicate is rejected with 422', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => 'gold',
'weight' => 5,
]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'silver'])
->assertUnprocessable();
});
test('PUT /api/artworks/{id}/award — user can change their award', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => 'gold',
'weight' => 5,
]);
$this->actingAs($user)
->putJson("/api/artworks/{$artwork->id}/award", ['medal' => 'bronze'])
->assertOk()
->assertJsonPath('viewer_award', 'bronze');
});
test('DELETE /api/artworks/{id}/award — user can remove their award', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => 'silver',
'weight' => 3,
]);
$this->actingAs($user)
->deleteJson("/api/artworks/{$artwork->id}/award")
->assertOk()
->assertJsonPath('viewer_award', null);
expect(ArtworkAward::where('artwork_id', $artwork->id)->exists())->toBeFalse();
});
test('GET /api/artworks/{id}/awards — returns stats publicly', function () {
$owner = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => $owner->id]);
ArtworkAwardStat::create([
'artwork_id' => $artwork->id,
'gold_count' => 2,
'silver_count' => 1,
'bronze_count' => 3,
'score_total' => 16,
'score_7d' => 8,
'score_30d' => 12,
'created_at' => now(),
'updated_at' => now(),
]);
$this->getJson("/api/artworks/{$artwork->id}/awards")
->assertOk()
->assertJsonPath('awards.gold', 2)
->assertJsonPath('awards.silver', 1)
->assertJsonPath('awards.bronze', 3)
->assertJsonPath('awards.score', 16)
->assertJsonPath('awards.score_7d', 8)
->assertJsonPath('awards.score_30d', 12);
});
test('observer recalculates stats when award is created', function () {
$user = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => 'gold',
'weight' => 5,
]);
$stat = ArtworkAwardStat::find($artwork->id);
expect($stat->gold_count)->toBe(1)
->and($stat->score_total)->toBe(5);
});
// ---------------------------------------------------------------------------
// Abuse / security tests
// ---------------------------------------------------------------------------
test('new account below minimum age is rejected with 403', function () {
$user = User::factory()->create(['created_at' => now()->subHours(12)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold'])
->assertForbidden()
->assertJsonPath('message', 'Your account must be at least 24 hours old before giving medals.');
});
test('unverified account is rejected from the medal endpoint with a clear reason', function () {
$user = User::factory()->unverified()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/medal", ['medal_type' => 'gold'])
->assertForbidden()
->assertJsonPath('message', 'Verify your email address before giving medals.');
});
test('user cannot award their own artwork', function () {
$user = User::factory()->create(['created_at' => now()->subDays(30)]);
$artwork = makePublishedArtwork(['user_id' => $user->id]);
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold'])
->assertForbidden()
->assertJsonPath('message', 'You cannot medal your own artwork.');
});
// ---------------------------------------------------------------------------
// Meilisearch sync test
// ---------------------------------------------------------------------------
test('awarding a medal dispatches artwork reindexing', function () {
Queue::fake();
$service = app(ArtworkAwardService::class);
$user = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$service->award($artwork, $user, 'silver');
Queue::assertPushed(IndexArtworkJob::class);
});
test('removing a medal dispatches artwork reindexing', function () {
Queue::fake();
$service = app(ArtworkAwardService::class);
$user = User::factory()->create();
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
DB::table('artwork_medals')->insert([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal_type' => 'gold',
'weight' => 5,
'created_at' => now(),
'updated_at' => now(),
]);
$service->removeAward($artwork, $user);
Queue::assertPushed(IndexArtworkJob::class);
});
test('cache invalidation occurs after medal updates', function () {
$homepage = app(HomepageService::class);
$service = app(ArtworkAwardService::class);
$user = User::factory()->create(['created_at' => now()->subDays(30), 'email_verified_at' => now()]);
$artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]);
$guestPayloadKey = 'homepage.payload.guest.test.' . $artwork->id;
Config::set('homepage.guest_payload_key', $guestPayloadKey);
Cache::put('homepage.hero', ['stale' => true], 600);
Cache::put('homepage.community-favorites.8', ['stale' => true], 600);
Cache::put('homepage.hall-of-fame.8', ['stale' => true], 600);
Cache::store($homepage->guestPayloadCacheStoreName())->put($guestPayloadKey, ['stale' => true], 600);
$service->award($artwork, $user, 'gold');
expect(Cache::get('homepage.hero'))->toBeNull()
->and(Cache::get('homepage.community-favorites.8'))->toBeNull()
->and(Cache::get('homepage.hall-of-fame.8'))->toBeNull()
->and(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
});

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\File;
beforeEach(function () {
$root = storage_path('framework/testing/artwork-downloads');
config(['uploads.storage_root' => $root]);
if (File::exists($root)) {
File::deleteDirectory($root);
}
File::makeDirectory($root, 0755, true);
});
afterEach(function () {
$root = storage_path('framework/testing/artwork-downloads');
if (File::exists($root)) {
File::deleteDirectory($root);
}
});
function makeOriginalFile(string $hash, string $ext, string $content = 'test-image-content'): string
{
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
$dir = $root . DIRECTORY_SEPARATOR . 'original' . DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . $secondDir;
File::makeDirectory($dir, 0755, true, true);
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
File::put($path, $content);
return $path;
}
it('downloads an existing artwork file', function () {
$hash = 'a9f3e6c1b8';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'file_name' => 'Sky Sunset',
'hash' => $hash,
'file_ext' => $ext,
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk();
$response->assertDownload('Sky Sunset.png');
});
it('forces the download filename using file_name and extension', function () {
$hash = 'b7c4d1e2f3';
$ext = 'jpg';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'file_name' => 'My Original Name',
'hash' => $hash,
'file_ext' => $ext,
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk();
$response->assertDownload('My Original Name.jpg');
});
it('returns 404 for a missing artwork', function () {
$this->get('/download/artwork/999999')->assertNotFound();
});
it('returns 404 when the original file is missing', function () {
$artwork = Artwork::factory()->create([
'hash' => 'c1d2e3f4a5',
'file_ext' => 'webp',
]);
$this->get("/download/artwork/{$artwork->id}")->assertNotFound();
});
it('logs download metadata with user and request context', function () {
$hash = 'd4e5f6a7b8';
$ext = 'gif';
makeOriginalFile($hash, $ext);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
$this->actingAs($user)
->withHeaders([
'User-Agent' => 'SkinbaseTestAgent/1.0',
'Referer' => 'https://example.test/art/' . $artwork->id,
])
->get("/download/artwork/{$artwork->id}")
->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'SkinbaseTestAgent/1.0',
'referer' => 'https://example.test/art/' . $artwork->id,
]);
});
it('logs guest download with null user_id', function () {
$hash = 'e1f2a3b4c5';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
$this->get("/download/artwork/{$artwork->id}")->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => null,
]);
});

View File

@@ -0,0 +1,43 @@
<?php
use App\Models\Artwork;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('public browsing scopes include only public approved not-deleted artworks', function () {
Artwork::factory()->create(); // public + approved
Artwork::factory()->private()->create();
Artwork::factory()->unapproved()->create();
expect(Artwork::public()->count())->toBe(1);
});
test('slug-based route generation produces SEO friendly url', function () {
$art = Artwork::factory()->create(['slug' => 'my-unique-art']);
$url = route('artworks.show', [
'contentTypeSlug' => 'photography',
'categoryPath' => 'abstract',
'artwork' => $art->slug,
]);
expect($url)->toContain('/photography/abstract/my-unique-art');
});
test('soft delete hides artwork from public scope', function () {
$art = Artwork::factory()->create();
$art->delete();
expect(Artwork::public()->where('id', $art->id)->exists())->toBeFalse();
});
test('approval filtering works via approved scope', function () {
Artwork::factory()->create();
Artwork::factory()->unapproved()->create();
expect(Artwork::approved()->count())->toBe(1);
});
test('admin routes are protected from unauthenticated users', function () {
$this->get('/admin/artworks')->assertNotFound();
});

View File

@@ -0,0 +1,117 @@
<?php
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('renders JSON-LD structured data on published artwork page', function () {
$user = User::factory()->create(['name' => 'Schema Author']);
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography content',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract-' . Str::lower(Str::random(5)),
'description' => 'Abstract works',
'is_active' => true,
'sort_order' => 0,
]);
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Schema Ready Artwork',
'slug' => 'schema-ready-artwork',
'description' => 'Artwork description for schema test.',
'mime_type' => 'image/jpeg',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
$tagA = Tag::create(['name' => 'neon', 'slug' => 'neon', 'usage_count' => 0, 'is_active' => true]);
$tagB = Tag::create(['name' => 'city', 'slug' => 'city', 'usage_count' => 0, 'is_active' => true]);
$artwork->tags()->attach([
$tagA->id => ['source' => 'user', 'confidence' => 0.9],
$tagB->id => ['source' => 'user', 'confidence' => 0.8],
]);
$html = view('artworks.show', ['artwork' => $artwork])->render();
expect($html)
->toContain('application/ld+json')
->toContain('"@type":"ImageObject"')
->toContain('"name":"Schema Ready Artwork"')
->toContain('"keywords":["neon","city"]');
});
it('renders JSON-LD via routed artwork show endpoint', function () {
$user = User::factory()->create(['name' => 'Schema Route Author']);
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography content',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract-route',
'description' => 'Abstract works',
'is_active' => true,
'sort_order' => 0,
]);
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Schema Route Artwork',
'slug' => 'schema-route-artwork',
'description' => 'Artwork description for routed schema test.',
'mime_type' => 'image/png',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
$tag = Tag::create(['name' => 'route-tag', 'slug' => 'route-tag', 'usage_count' => 0, 'is_active' => true]);
$artwork->tags()->attach([
$tag->id => ['source' => 'user', 'confidence' => 0.95],
]);
$url = route('artworks.show', [
'contentTypeSlug' => $contentType->slug,
'categoryPath' => $category->slug,
'artwork' => $artwork->slug,
]);
expect($url)->toContain('/photography/abstract-route/schema-route-artwork');
$matchedRoute = app('router')->getRoutes()->match(Request::create($url, 'GET'));
expect($matchedRoute->getName())->toBe('artworks.show');
$response = $this->get($url);
$response->assertOk();
$response->assertSee('application/ld+json', false);
$response->assertSee('"@type":"ImageObject"', false);
$response->assertSee('"name":"Schema Route Artwork"', false);
$response->assertSee('"keywords":["route-tag"]', false);
});

View File

@@ -0,0 +1,41 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use App\Services\Artworks\ArtworkDraftService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('allows duplicate artwork slugs when creating drafts', function () {
$user = User::factory()->create();
$drafts = app(ArtworkDraftService::class);
$first = $drafts->createDraft($user->id, 'Silent', null);
$second = $drafts->createDraft($user->id, 'Silent', null);
expect(Artwork::query()->findOrFail($first->artworkId)->slug)->toBe('silent')
->and(Artwork::query()->findOrFail($second->artworkId)->slug)->toBe('silent');
});
it('resolves public artwork pages by id even when slugs are duplicated', function () {
$first = Artwork::factory()->create([
'title' => 'Silent',
'slug' => 'silent',
'description' => 'First silent artwork.',
]);
$second = Artwork::factory()->create([
'title' => 'Silent',
'slug' => 'silent',
'description' => 'Second silent artwork.',
]);
$this->get(route('art.show', ['id' => $first->id, 'slug' => 'silent']))
->assertOk()
->assertSee('First silent artwork.', false);
$this->get(route('art.show', ['id' => $second->id, 'slug' => 'silent']))
->assertOk()
->assertSee('Second silent artwork.', false);
});

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\ArtworkVersion;
use App\Models\ArtworkVersionEvent;
use App\Models\User;
use App\Services\ArtworkVersioningService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
// ── Helpers ────────────────────────────────────────────────────────────────────
function versioningArtwork(array $attrs = []): Artwork
{
return Artwork::withoutEvents(fn () => Artwork::factory()->create($attrs));
}
beforeEach(function () {
// SQLite GREATEST() polyfill for observer compatibility
if (DB::connection()->getDriverName() === 'sqlite') {
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
return max($args);
}, -1);
}
Cache::flush();
$this->user = User::factory()->create();
$this->artwork = versioningArtwork(['user_id' => $this->user->id, 'hash' => 'aaa', 'version_count' => 1]);
$this->service = new ArtworkVersioningService();
});
// ──────────────────────────────────────────────────────────────────────────────
// ArtworkVersioningService unit tests
// ──────────────────────────────────────────────────────────────────────────────
test('createNewVersion inserts a version row and marks it current', function () {
$version = $this->service->createNewVersion(
$this->artwork,
'path/to/new.webp',
'bbb',
1920, 1080, 204800,
$this->user->id,
'First replacement',
);
expect($version)->toBeInstanceOf(ArtworkVersion::class)
->and($version->version_number)->toBe(2)
->and($version->is_current)->toBeTrue()
->and($version->file_hash)->toBe('bbb');
$this->artwork->refresh();
expect($this->artwork->current_version_id)->toBe($version->id)
->and($this->artwork->version_count)->toBe(2);
});
test('createNewVersion sets previous version is_current = false', function () {
// Seed an existing "current" version row
$old = ArtworkVersion::create([
'artwork_id' => $this->artwork->id,
'version_number' => 1,
'file_path' => 'old.webp',
'file_hash' => 'aaahash',
'is_current' => true,
]);
$this->service->createNewVersion(
$this->artwork, 'new.webp', 'bbbhash',
1920, 1080, 500, $this->user->id,
);
expect(ArtworkVersion::findOrFail($old->id)->is_current)->toBeFalse();
});
test('createNewVersion writes an audit log entry', function () {
$this->service->createNewVersion(
$this->artwork, 'path.webp', 'ccc',
800, 600, 1024, $this->user->id,
);
$event = ArtworkVersionEvent::where('artwork_id', $this->artwork->id)->first();
expect($event)->not->toBeNull()
->and($event->action)->toBe('create_version')
->and($event->user_id)->toBe($this->user->id);
});
test('createNewVersion rejects identical hash', function () {
$this->artwork->update(['hash' => 'same_hash_here']);
expect(fn () => $this->service->createNewVersion(
$this->artwork, 'path.webp', 'same_hash_here',
800, 600, 1024, $this->user->id,
))->toThrow(\RuntimeException::class, 'identical');
});
test('artworkVersioningService enforces hourly rate limit', function () {
// Exhaust rate limit
for ($i = 0; $i < 3; $i++) {
$hash = 'hash_' . $i;
$this->artwork->update(['hash' => 'different_' . $i]); // avoid identical-hash rejection
$this->service->createNewVersion(
$this->artwork, 'path.webp', $hash,
800, 600, 1024, $this->user->id,
);
}
$this->artwork->update(['hash' => 'final_different']);
expect(fn () => $this->service->createNewVersion(
$this->artwork, 'path.webp', 'hash_over_limit',
800, 600, 1024, $this->user->id,
))->toThrow(\Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException::class);
});
test('shouldRequireReapproval returns false for first upload', function () {
$this->artwork->update(['width' => 0, 'height' => 0]);
expect($this->service->shouldRequireReapproval($this->artwork, 1920, 1080))->toBeFalse();
});
test('shouldRequireReapproval returns true when dimensions change drastically', function () {
$this->artwork->update(['width' => 1920, 'height' => 1080]);
// 300% increase in width → triggers
expect($this->service->shouldRequireReapproval($this->artwork, 7680, 4320))->toBeTrue();
});
test('shouldRequireReapproval returns false for small dimension change', function () {
$this->artwork->update(['width' => 1920, 'height' => 1080]);
// 5 % change → fine
expect($this->service->shouldRequireReapproval($this->artwork, 2016, 1134))->toBeFalse();
});
test('applyRankingProtection decays ranking and heat scores', function () {
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $this->artwork->id],
['ranking_score' => 100.0, 'heat_score' => 50.0, 'engagement_velocity' => 20.0]
);
$this->service->applyRankingProtection($this->artwork);
$stats = DB::table('artwork_stats')->where('artwork_id', $this->artwork->id)->first();
expect((float) $stats->ranking_score)->toBeLessThan(100.0)
->and((float) $stats->heat_score)->toBeLessThan(50.0);
});
test('restoreVersion clones old version as new current version', function () {
$old = ArtworkVersion::create([
'artwork_id' => $this->artwork->id,
'version_number' => 1,
'file_path' => 'original.webp',
'file_hash' => 'oldhash',
'width' => 1920,
'height' => 1080,
'file_size' => 99999,
'is_current' => true,
]);
// Simulate artwork being at version 2 with a different hash
$this->artwork->update(['hash' => 'currenthash', 'version_count' => 2]);
$restored = $this->service->restoreVersion($old, $this->artwork, $this->user->id);
expect($restored->version_number)->toBe(3)
->and($restored->file_hash)->toBe('oldhash')
->and($restored->change_note)->toContain('Restored from version 1');
$event = ArtworkVersionEvent::where('action', 'create_version')
->where('artwork_id', $this->artwork->id)
->orderByDesc('id')
->first();
expect($event)->not->toBeNull();
});
// ──────────────────────────────────────────────────────────────────────────────
// Version history API endpoint
// ──────────────────────────────────────────────────────────────────────────────
test('GET studio/artworks/{id}/versions returns version list', function () {
$this->actingAs($this->user);
ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 1,
'file_path' => 'a.webp', 'file_hash' => 'hash1', 'is_current' => false,
]);
ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 2,
'file_path' => 'b.webp', 'file_hash' => 'hash2', 'is_current' => true,
]);
$response = $this->getJson("/api/studio/artworks/{$this->artwork->id}/versions");
$response->assertOk()
->assertJsonCount(2, 'versions')
->assertJsonPath('versions.0.version_number', 2); // newest first
});
test('GET studio/artworks/{id}/versions rejects other users', function () {
$other = User::factory()->create();
$this->actingAs($other);
$this->getJson("/api/studio/artworks/{$this->artwork->id}/versions")
->assertStatus(404);
});
test('POST studio/artworks/{id}/restore/{version_id} restores version', function () {
$this->actingAs($this->user);
$old = ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 1,
'file_path' => 'restored.webp', 'file_hash' => 'restorehash',
'width' => 800, 'height' => 600, 'file_size' => 5000,
'is_current' => false,
]);
$this->artwork->update(['hash' => 'differenthash123', 'version_count' => 2]);
$response = $this->postJson("/api/studio/artworks/{$this->artwork->id}/restore/{$old->id}");
$response->assertOk()->assertJsonPath('success', true);
expect(ArtworkVersion::where('artwork_id', $this->artwork->id)
->where('file_hash', 'restorehash')
->where('is_current', true)
->exists()
)->toBeTrue();
});
test('POST restore rejects attempt to restore already-current version', function () {
$this->actingAs($this->user);
$current = ArtworkVersion::create([
'artwork_id' => $this->artwork->id, 'version_number' => 1,
'file_path' => 'x.webp', 'file_hash' => 'aaa',
'is_current' => true,
]);
$this->postJson("/api/studio/artworks/{$this->artwork->id}/restore/{$current->id}")
->assertStatus(422)
->assertJsonPath('success', false);
});

View File

@@ -0,0 +1,49 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('copies published_at into created_at when an artwork is published', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->unpublished()->create([
'user_id' => $user->id,
'created_at' => Carbon::parse('2026-03-01 10:00:00'),
'updated_at' => Carbon::parse('2026-03-01 10:00:00'),
]);
$publishedAt = Carbon::parse('2026-03-29 14:30:00');
$artwork->forceFill([
'published_at' => $publishedAt,
'artwork_status' => 'published',
'is_public' => true,
'is_approved' => true,
])->save();
$artwork->refresh();
expect($artwork->created_at?->toDateTimeString())->toBe($publishedAt->toDateTimeString());
expect(DB::table('user_statistics')->where('user_id', $user->id)->value('last_upload_at'))->toBe($publishedAt->toDateTimeString());
});
it('syncs created_at from published_at for existing artworks via command', function () {
$artwork = Artwork::factory()->create([
'created_at' => Carbon::parse('2026-03-01 10:00:00'),
'updated_at' => Carbon::parse('2026-03-01 10:00:00'),
'published_at' => Carbon::parse('2026-03-25 08:15:00'),
]);
$exitCode = Artisan::call('artworks:sync-created-at');
expect($exitCode)->toBe(0);
$createdAt = DB::table('artworks')->where('id', $artwork->id)->value('created_at');
expect(Carbon::parse($createdAt)->toDateTimeString())->toBe('2026-03-25 08:15:00');
});

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ThumbnailService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('s3');
$suffix = Str::lower(Str::random(10));
$this->localOriginalsRoot = storage_path('framework/testing/square-command-originals-' . $suffix);
File::deleteDirectory($this->localOriginalsRoot);
File::ensureDirectoryExists($this->localOriginalsRoot);
config()->set('uploads.local_originals_root', $this->localOriginalsRoot);
config()->set('uploads.object_storage.disk', 's3');
config()->set('uploads.object_storage.prefix', 'artworks');
});
afterEach(function () {
File::deleteDirectory($this->localOriginalsRoot);
});
function generateSqCommandImage(string $root, string $filename = 'source.jpg'): string
{
$path = $root . DIRECTORY_SEPARATOR . $filename;
$image = imagecreatetruecolor(1200, 800);
$background = imagecolorallocate($image, 18, 22, 29);
$subject = imagecolorallocate($image, 245, 180, 110);
imagefilledrectangle($image, 0, 0, 1200, 800, $background);
imagefilledellipse($image, 280, 340, 360, 380, $subject);
imagejpeg($image, $path, 90);
imagedestroy($image);
return $path;
}
function generateSqCommandWebp(string $root, string $filename = 'source.webp'): string
{
$path = $root . DIRECTORY_SEPARATOR . $filename;
$image = imagecreatetruecolor(1400, 900);
$background = imagecolorallocate($image, 16, 21, 28);
$subject = imagecolorallocate($image, 242, 194, 94);
imagefilledrectangle($image, 0, 0, 1400, 900, $background);
imagefilledellipse($image, 360, 420, 420, 360, $subject);
imagewebp($image, $path, 90);
imagedestroy($image);
return $path;
}
function seedArtworkWithOriginal(string $localOriginalsRoot): Artwork
{
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$source = generateSqCommandImage($localOriginalsRoot, 'seed.jpg');
$hash = hash_file('sha256', $source);
$storage = app(UploadStorageService::class);
$localTarget = $storage->localOriginalPath($hash, $hash . '.jpg');
File::ensureDirectoryExists(dirname($localTarget));
File::copy($source, $localTarget);
$artwork->update([
'hash' => $hash,
'file_ext' => 'jpg',
'thumb_ext' => 'webp',
'width' => 1200,
'height' => 800,
]);
DB::table('artwork_files')->insert([
'artwork_id' => $artwork->id,
'variant' => 'orig_image',
'path' => "artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg",
'mime' => 'image/jpeg',
'size' => (int) filesize($localTarget),
]);
return $artwork->fresh();
}
it('generates missing square thumbnails from the best available source', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs');
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue();
$sqSize = getimagesizefromstring(Storage::disk('s3')->get($sqPath));
expect($sqSize[0] ?? null)->toBe(512)
->and($sqSize[1] ?? null)->toBe(512);
});
it('supports dry run without writing sq thumbnails', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--dry-run' => true]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('planned=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertMissing($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeFalse();
});
it('forces regeneration when an sq row already exists', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
DB::table('artwork_files')->insert([
'artwork_id' => $artwork->id,
'variant' => 'sq',
'path' => "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp",
'mime' => 'image/webp',
'size' => 0,
]);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--force' => true]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
});
it('purges the canonical sq url after regeneration when Cloudflare is configured', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', 'test-zone');
config()->set('cdn.cloudflare.api_token', 'test-token');
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]);
expect($code)->toBe(0);
Http::assertSent(function ($request) use ($artwork): bool {
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
&& $request['files'] === [
'https://cdn.skinbase.org/artworks/sq/' . substr($artwork->hash, 0, 2) . '/' . substr($artwork->hash, 2, 2) . '/' . $artwork->hash . '.webp',
];
});
});
it('falls back to canonical CDN derivatives for legacy artworks without artwork_files rows', function () {
$cdnRoot = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . 'fake-cdn';
File::ensureDirectoryExists($cdnRoot);
config()->set('cdn.files_url', 'file:///' . str_replace(DIRECTORY_SEPARATOR, '/', $cdnRoot));
$user = User::factory()->create();
$hash = '6183c98975512ee6bff4657043067953a33769c7';
$artwork = Artwork::factory()->for($user)->unpublished()->create([
'hash' => $hash,
'file_ext' => 'jpg',
'thumb_ext' => 'webp',
'file_path' => 'legacy/uploads/IMG_20210727_090534.jpg',
'width' => 4624,
'height' => 2080,
]);
$xlUrl = ThumbnailService::fromHash($hash, 'webp', 'xl');
$webpSource = generateSqCommandWebp($this->localOriginalsRoot, 'legacy-xl.webp');
$xlPath = $cdnRoot . DIRECTORY_SEPARATOR . 'artworks' . DIRECTORY_SEPARATOR . 'xl' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.webp';
File::ensureDirectoryExists(dirname($xlPath));
File::copy($webpSource, $xlPath);
expect($xlUrl)->toContain('/artworks/xl/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash . '.webp');
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,44 @@
<?php
use App\Models\User;
test('login screen can be rendered', function () {
$response = $this->get('/login');
$response->assertStatus(200)
->assertSee('Read signup and login help')
->assertSee(route('help.auth'), false)
->assertSee(route('help.troubleshooting'), false);
});
test('users can authenticate using the login screen', function () {
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
});
test('users can not authenticate with invalid password', function () {
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
});
test('users can logout', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$this->assertGuest();
$response->assertRedirect('/');
});

View File

@@ -0,0 +1,46 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
});
test('email can be verified', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
});
test('email is not verified with invalid hash', function () {
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});

View File

@@ -0,0 +1,55 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('redirects verified step user to setup password from profile', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
]);
$this->actingAs($user)
->get('/profile')
->assertRedirect('/setup/password');
});
it('redirects password step user to setup username from upload', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->get('/upload')
->assertRedirect('/setup/username');
});
it('redirects email step user to login from forum and gallery', function () {
$user = User::factory()->create([
'onboarding_step' => 'email',
]);
$this->actingAs($user)
->get('/forum')
->assertRedirect('/login');
$this->actingAs($user)
->get('/gallery/1/test')
->assertRedirect('/login');
});
it('allows complete onboarding user to access profile and upload', function () {
$user = User::factory()->create([
'onboarding_step' => 'complete',
]);
$this->actingAs($user)
->get('/profile')
->assertRedirect('/dashboard/profile')
->assertStatus(301);
$this->actingAs($user)
->get('/upload')
->assertOk();
});

View File

@@ -0,0 +1,53 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows onboarding progress and status on setup password page', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
]);
$this->actingAs($user)
->withSession(['status' => 'Email verified. Continue with password setup.'])
->get('/setup/password')
->assertOk()
->assertSee('Email')
->assertSee('Verified')
->assertSee('Password')
->assertSee('Username')
->assertSee('Email verified. Continue with password setup.');
});
it('shows onboarding progress and status on setup username page', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->withSession(['status' => 'Password saved. Choose your public username to finish setup.'])
->get('/setup/username')
->assertOk()
->assertSee('Email')
->assertSee('Verified')
->assertSee('Password')
->assertSee('Username')
->assertSee('Password saved. Choose your public username to finish setup.');
});
it('returns clear validation message for weak setup password', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
]);
$this->actingAs($user)
->from('/setup/password')
->post('/setup/password', [
'password' => 'short',
'password_confirmation' => 'short',
])
->assertRedirect('/setup/password')
->assertSessionHasErrors('password');
});

View File

@@ -0,0 +1,32 @@
<?php
use App\Models\User;
test('confirm password screen can be rendered', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response->assertStatus(200);
});
test('password can be confirmed', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
});
test('password is not confirmed with invalid password', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
});

View File

@@ -0,0 +1,63 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification;
test('reset password link screen can be rendered', function () {
$response = $this->get('/forgot-password');
$response->assertStatus(200)
->assertSee('Read signup and login help')
->assertSee(route('help.auth'), false)
->assertSee(route('help.troubleshooting'), false);
});
test('reset password link can be requested', function () {
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
});
test('reset password screen can be rendered', function () {
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
});
test('password can be reset with valid token', function () {
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
});

View File

@@ -0,0 +1,40 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
test('password can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile');
});

View File

@@ -0,0 +1,156 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use App\Services\Security\TurnstileVerifier;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
uses(RefreshDatabase::class);
it('rejects registration when honeypot field is filled', function () {
Queue::fake();
$response = $this->from('/register')->post('/register', [
'email' => 'bot1@example.com',
'website' => 'https://spam.example',
]);
$response->assertRedirect('/register');
$response->assertSessionHasErrors('website');
$this->assertDatabaseMissing('users', ['email' => 'bot1@example.com']);
});
it('throttles excessive registration attempts by ip', function () {
Queue::fake();
config()->set('registration.ip_per_minute_limit', 2);
config()->set('registration.ip_per_day_limit', 100);
for ($i = 0; $i < 2; $i++) {
$this->post('/register', [
'email' => 'user-rate-' . $i . '@example.com',
])->assertRedirect('/register/notice');
}
$this->post('/register', [
'email' => 'user-rate-3@example.com',
])->assertStatus(429);
RateLimiter::clear('register:ip:127.0.0.1');
RateLimiter::clear('register:ip:daily:127.0.0.1');
});
it('blocks disposable email domains during registration', function () {
Queue::fake();
config()->set('registration.disposable_domains_enabled', true);
config()->set('disposable_email_domains.domains', ['tempmail.com']);
$response = $this->from('/register')->post('/register', [
'email' => 'bot@tempmail.com',
]);
$response->assertRedirect('/register');
$response->assertSessionHasErrors('email');
$this->assertDatabaseMissing('users', ['email' => 'bot@tempmail.com']);
});
it('requires turnstile after suspicious registration attempts', function () {
Queue::fake();
config()->set('registration.enable_turnstile', true);
config()->set('registration.turnstile_suspicious_attempts', 1);
config()->set('services.turnstile.site_key', 'site-key');
config()->set('services.turnstile.secret_key', 'secret-key');
$mock = \Mockery::mock(TurnstileVerifier::class);
$mock->shouldReceive('isEnabled')->andReturn(true);
$mock->shouldReceive('verify')->once()->andReturn(false);
$this->app->instance(TurnstileVerifier::class, $mock);
$response = $this->from('/register')->post('/register', [
'email' => 'captcha-user@example.com',
]);
$response->assertRedirect('/register');
$response->assertSessionHasErrors('captcha');
$this->assertDatabaseMissing('users', ['email' => 'captcha-user@example.com']);
});
it('shows turnstile when ip is in rate-limited state', function () {
config()->set('registration.enable_turnstile', true);
config()->set('registration.ip_per_minute_limit', 1);
config()->set('services.turnstile.site_key', 'site-key');
config()->set('services.turnstile.secret_key', 'secret-key');
RateLimiter::hit('register:ip:127.0.0.1', 60);
$this->get('/register')
->assertOk()
->assertSee('cf-turnstile', false);
RateLimiter::clear('register:ip:127.0.0.1');
});
it('enforces verification email cooldown per address', function () {
Queue::fake();
$this->post('/register', [
'email' => 'cooldown2@example.com',
])->assertRedirect('/register/notice');
$response = $this->post('/register', [
'email' => 'cooldown2@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class, 1);
});
it('returns generic success for existing verified emails (anti-enumeration)', function () {
Queue::fake();
User::factory()->create([
'email' => 'existing@example.com',
'email_verified_at' => now(),
'onboarding_step' => 'complete',
'is_active' => true,
]);
$response = $this->post('/register', [
'email' => 'existing@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertNothingPushed();
});
it('still allows registration when turnstile passes', function () {
Queue::fake();
config()->set('registration.enable_turnstile', true);
config()->set('registration.turnstile_suspicious_attempts', 1);
config()->set('services.turnstile.site_key', 'site-key');
config()->set('services.turnstile.secret_key', 'secret-key');
$mock = \Mockery::mock(TurnstileVerifier::class);
$mock->shouldReceive('isEnabled')->andReturn(true);
$mock->shouldReceive('verify')->once()->andReturn(false);
$mock->shouldReceive('verify')->once()->andReturn(true);
$this->app->instance(TurnstileVerifier::class, $mock);
$first = $this->from('/register')->post('/register', [
'email' => 'captcha-block@example.com',
]);
$first->assertRedirect('/register');
$first->assertSessionHasErrors('captcha');
$response = $this->post('/register', [
'email' => 'captcha-pass@example.com',
'cf-turnstile-response' => 'good-token',
]);
$response->assertRedirect('/register/notice');
$this->assertDatabaseHas('users', ['email' => 'captcha-pass@example.com']);
});

View File

@@ -0,0 +1,136 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\DB;
use App\Jobs\SendVerificationEmailJob;
uses(RefreshDatabase::class);
it('completes happy path registration onboarding flow', function () {
Queue::fake();
$register = $this->post('/register', [
'email' => 'flow-user@example.com',
]);
$register->assertRedirect('/register/notice');
$user = User::query()->where('email', 'flow-user@example.com')->firstOrFail();
expect($user->onboarding_step)->toBe('email');
$token = null;
Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$token) {
$token = $job->token;
return true;
});
$this->get('/verify/' . $token)->assertRedirect('/setup/password');
$user->refresh();
expect($user->onboarding_step)->toBe('verified');
$this->actingAs($user)
->post('/setup/password', [
'password' => 'StrongPass1!',
'password_confirmation' => 'StrongPass1!',
])->assertRedirect('/setup/username');
$this->actingAs($user)
->post('/setup/username', [
'username' => 'flow_user_final',
])->assertRedirect('/@flow_user_final');
$user->refresh();
expect($user->onboarding_step)->toBe('complete');
expect($user->username)->toBe('flow_user_final');
});
it('rejects invalid verification token', function () {
$response = $this->from('/login')->get('/verify/not-a-real-token');
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
});
it('rejects expired verification token', function () {
$user = User::factory()->create([
'email_verified_at' => null,
'onboarding_step' => 'email',
'is_active' => false,
]);
$column = \Illuminate\Support\Facades\Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
$column => hash('sha256', 'expired-checklist-token'),
'expires_at' => now()->subHour(),
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->from('/login')->get('/verify/expired-checklist-token');
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
expect($user->fresh()->email_verified_at)->toBeNull();
});
it('rejects duplicate email at registration', function () {
Queue::fake();
User::factory()->create([
'email' => 'duplicate-check@example.com',
'email_verified_at' => now(),
'onboarding_step' => 'complete',
'is_active' => true,
]);
$response = $this->post('/register', [
'email' => 'duplicate-check@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertNothingPushed();
});
it('rejects username conflict during username setup', function () {
User::factory()->create([
'username' => 'taken_username',
'onboarding_step' => 'complete',
]);
$user = User::factory()->create([
'username' => 'candidate_username',
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->from('/setup/username')
->post('/setup/username', [
'username' => 'taken_username',
])
->assertRedirect('/setup/username')
->assertSessionHasErrors('username');
expect($user->fresh()->onboarding_step)->toBe('password');
});
it('resumes onboarding by redirecting user to current required step', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
]);
$this->actingAs($user)
->get('/profile')
->assertRedirect('/setup/password');
$user->forceFill(['onboarding_step' => 'password'])->save();
$this->actingAs($user)
->get('/upload')
->assertRedirect('/setup/username');
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('shows registration notice with email after first step', function () {
Queue::fake();
$this->post('/register', [
'email' => 'notice@example.com',
])->assertRedirect('/register/notice');
$this->get('/register/notice')
->assertOk()
->assertSee('notice@example.com')
->assertSee('Change email');
});
it('prefills register form email from query string', function () {
$this->get('/register?email=prefill@example.com')
->assertOk()
->assertSee('value="prefill@example.com"', false);
});
it('blocks resend while cooldown is active', function () {
Queue::fake();
$this->post('/register', [
'email' => 'cooldown@example.com',
])->assertRedirect('/register/notice');
$response = $this->from('/register/notice')->post('/register/resend-verification', [
'email' => 'cooldown@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHasNoErrors();
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class, 1);
});
it('resends verification after cooldown expires', function () {
Queue::fake();
$this->post('/register', [
'email' => 'resend@example.com',
])->assertRedirect('/register/notice');
$user = User::query()->where('email', 'resend@example.com')->firstOrFail();
$user->forceFill([
'last_verification_sent_at' => now()->subMinutes(31),
])->save();
$this->post('/register/resend-verification', [
'email' => 'resend@example.com',
])->assertRedirect('/register/notice');
Queue::assertPushed(SendVerificationEmailJob::class, 2);
expect(User::query()->where('email', 'resend@example.com')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,67 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Services\Auth\RegistrationEmailQuotaService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('returns generic success even when quota is exceeded', function () {
Queue::fake();
DB::table('system_email_quota')->insert([
'period' => now()->format('Y-m'),
'sent_count' => 10,
'limit_count' => 10,
'updated_at' => now(),
]);
$response = $this->post('/register', [
'email' => 'quota-hit@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class);
});
it('blocks actual send in job when monthly quota is exceeded', function () {
Mail::fake();
DB::table('system_email_quota')->insert([
'period' => now()->format('Y-m'),
'sent_count' => 10,
'limit_count' => 10,
'updated_at' => now(),
]);
$eventId = DB::table('email_send_events')->insertGetId([
'type' => 'verify_email',
'email' => 'quota-block@example.com',
'ip' => '127.0.0.1',
'user_id' => null,
'status' => 'queued',
'reason' => null,
'created_at' => now(),
]);
$job = new SendVerificationEmailJob(
emailEventId: (int) $eventId,
email: 'quota-block@example.com',
token: 'raw-token',
userId: null,
ip: '127.0.0.1'
);
$job->handle(app(RegistrationEmailQuotaService::class));
Mail::assertNothingSent();
$this->assertDatabaseHas('email_send_events', [
'id' => $eventId,
'status' => 'blocked',
'reason' => 'quota',
]);
});

View File

@@ -0,0 +1,40 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use Illuminate\Support\Facades\Queue;
test('registration screen can be rendered', function () {
$response = $this->get('/register');
$response->assertStatus(200)
->assertSee('Read signup and login help')
->assertSee(route('help.auth'), false)
->assertSee(route('help.troubleshooting'), false)
->assertDontSee('name="name"', false)
->assertDontSee('name="username"', false)
->assertDontSee('name="password"', false)
->assertDontSee('name="password_confirmation"', false);
});
test('new users can register', function () {
Queue::fake();
$response = $this->post('/register', [
'email' => 'test@example.com',
]);
$this->assertGuest();
$response->assertRedirect(route('register.notice', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
'onboarding_step' => 'email',
'is_active' => 0,
]);
$this->assertDatabaseHas('user_verification_tokens', [
'user_id' => (int) \App\Models\User::query()->where('email', 'test@example.com')->value('id'),
]);
Queue::assertPushed(SendVerificationEmailJob::class);
});

View File

@@ -0,0 +1,129 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('stores verification tokens hashed instead of raw token', function () {
Queue::fake();
$this->post('/register', [
'email' => 'token-hash@example.com',
])->assertRedirect('/register/notice');
$rawToken = null;
Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$rawToken) {
$rawToken = $job->token;
return true;
});
$userId = (int) User::query()->where('email', 'token-hash@example.com')->value('id');
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
$storedToken = (string) DB::table('user_verification_tokens')
->where('user_id', $userId)
->value($column);
expect($rawToken)->not->toBeNull();
expect($storedToken)->toBe(hash('sha256', (string) $rawToken));
expect($storedToken)->not->toBe((string) $rawToken);
});
it('verifies token and redirects to password setup', function () {
$user = User::factory()->create([
'email_verified_at' => null,
'onboarding_step' => 'email',
'is_active' => false,
]);
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
$column => hash('sha256', 'verify-token-1'),
'expires_at' => now()->addHour(),
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->get('/verify/verify-token-1');
$response->assertRedirect('/setup/password');
$this->assertAuthenticatedAs($user->fresh());
$this->assertDatabaseHas('users', [
'id' => $user->id,
'onboarding_step' => 'verified',
'is_active' => 1,
]);
expect($user->fresh()->email_verified_at)->not->toBeNull();
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
$this->assertDatabaseMissing('user_verification_tokens', [$column => hash('sha256', 'verify-token-1')]);
});
it('rejects expired token', function () {
$user = User::factory()->create([
'email_verified_at' => null,
'onboarding_step' => 'email',
'is_active' => false,
]);
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
$column => hash('sha256', 'expired-token-1'),
'expires_at' => now()->subMinute(),
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->from('/login')->get('/verify/expired-token-1');
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
$this->assertGuest();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'onboarding_step' => 'email',
'is_active' => 0,
]);
expect($user->fresh()->email_verified_at)->toBeNull();
});
it('rejects unknown token', function () {
$response = $this->from('/login')->get('/verify/not-real-token');
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
$this->assertGuest();
});
it('rejects token reuse after successful verification', function () {
$user = User::factory()->create([
'email_verified_at' => null,
'onboarding_step' => 'email',
'is_active' => false,
]);
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
$column => hash('sha256', 'one-time-token'),
'expires_at' => now()->addHour(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->get('/verify/one-time-token')->assertRedirect('/setup/password');
auth()->logout();
$secondTry = $this->from('/login')->get('/verify/one-time-token');
$secondTry->assertRedirect('/login');
$secondTry->assertSessionHasErrors('email');
});

View File

@@ -0,0 +1,45 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('registration verification mailable is queued with retry policy', function () {
$mail = new RegistrationVerificationMail('token-123');
expect($mail)->toBeInstanceOf(ShouldQueue::class);
expect($mail->tries)->toBe(3);
expect($mail->timeout)->toBe(30);
expect($mail->backoff)->toBe([60, 300, 900]);
});
it('registration email contains verification link expiry and support url', function () {
config()->set('app.url', 'https://skinbase.example');
config()->set('app.name', 'Skinbase');
$mail = new RegistrationVerificationMail('abc-token');
$html = $mail->render();
expect($html)->toContain('Verify Email');
expect($html)->toContain('/verify/abc-token');
expect($html)->toContain('expires in 24 hours');
expect($html)->toContain('https://skinbase.example/support');
});
it('registration endpoint queues verification email job', function () {
Queue::fake();
$this->post('/register', [
'email' => 'mail-test@example.com',
])->assertRedirect('/register/notice');
Queue::assertPushed(SendVerificationEmailJob::class);
$this->assertDatabaseHas('users', [
'email' => 'mail-test@example.com',
'onboarding_step' => 'email',
]);
});

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
it('requires authentication to open setup password screen', function () {
$this->get('/setup/password')
->assertRedirect('/login');
});
it('renders setup password screen for authenticated user', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
'needs_password_reset' => true,
]);
$this->actingAs($user)
->get('/setup/password')
->assertOk();
});
it('accepts strong password and moves onboarding to password step', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
'needs_password_reset' => true,
'password' => Hash::make('old-password'),
]);
$response = $this->actingAs($user)
->post('/setup/password', [
'password' => 'StrongPass1!',
'password_confirmation' => 'StrongPass1!',
]);
$response->assertRedirect('/setup/username');
$user->refresh();
expect(Hash::check('StrongPass1!', $user->password))->toBeTrue();
expect($user->onboarding_step)->toBe('password');
expect((bool) $user->needs_password_reset)->toBeFalse();
});
it('rejects password without number or symbol or minimum length', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
'needs_password_reset' => true,
]);
$this->actingAs($user)
->from('/setup/password')
->post('/setup/password', [
'password' => 'weakpass',
'password_confirmation' => 'weakpass',
])
->assertRedirect('/setup/password')
->assertSessionHasErrors('password');
expect($user->fresh()->onboarding_step)->toBe('verified');
});

View File

@@ -0,0 +1,77 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('requires authentication to open setup username screen', function () {
$this->get('/setup/username')
->assertRedirect('/login');
});
it('renders setup username screen for authenticated user', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->get('/setup/username')
->assertOk();
});
it('accepts unique username and completes onboarding', function () {
$user = User::factory()->create([
'username' => 'initial_user',
'onboarding_step' => 'password',
]);
$response = $this->actingAs($user)
->post('/setup/username', [
'username' => 'Final_User_7',
]);
$response->assertRedirect('/@final_user_7');
$user->refresh();
expect($user->username)->toBe('final_user_7');
expect($user->onboarding_step)->toBe('complete');
expect($user->username_changed_at)->not->toBeNull();
});
it('rejects reserved username in setup flow', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->from('/setup/username')
->post('/setup/username', [
'username' => 'admin',
])
->assertRedirect('/setup/username')
->assertSessionHasErrors('username');
expect($user->fresh()->onboarding_step)->toBe('password');
});
it('queues similarity-flagged username for manual approval in setup flow', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->from('/setup/username')
->post('/setup/username', [
'username' => 'support1',
])
->assertRedirect('/setup/username')
->assertSessionHasErrors('username');
$this->assertDatabaseHas('username_approval_requests', [
'user_id' => $user->id,
'requested_username' => 'support1',
'context' => 'onboarding_username',
'status' => 'pending',
]);
});

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns available true for valid free username', function () {
$response = $this->getJson('/api/username/availability?username=free_name');
$response->assertOk()->assertJson([
'available' => true,
'normalized' => 'free_name',
]);
});
it('returns validation error for invalid username format', function () {
$response = $this->getJson('/api/username/availability?username=bad.name');
$response->assertStatus(422)->assertJsonPath('available', false);
});
it('returns validation error for reserved username', function () {
$response = $this->getJson('/api/username/availability?username=admin');
$response->assertStatus(422)->assertJsonPath('available', false);
});
it('returns available false for already taken username', function () {
User::factory()->create(['username' => 'taken_name']);
$response = $this->getJson('/api/username/availability?username=taken_name');
$response->assertOk()->assertJson([
'available' => false,
'normalized' => 'taken_name',
]);
});

View File

@@ -0,0 +1,83 @@
<?php
use App\Models\User;
test('registration normalizes username to lowercase', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
'username' => null,
]);
$response = $this->actingAs($user)->post('/setup/username', [
'username' => ' GregOr_One ',
]);
$response->assertRedirect('/@gregor_one');
$this->assertDatabaseHas('users', [
'id' => $user->id,
'username' => 'gregor_one',
]);
});
test('registration rejects reserved usernames', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$response = $this->actingAs($user)->from('/setup/username')->post('/setup/username', [
'username' => 'admin',
]);
$response->assertSessionHasErrors('username');
$this->assertAuthenticatedAs($user);
});
test('legacy profile username route redirects to canonical at-username route', function () {
$user = User::factory()->create(['username' => 'author_two']);
$response = $this->get('/profile/Author_Two');
$response->assertRedirect('/@author_two');
});
test('non-canonical at-username route redirects to lowercase canonical route', function () {
$user = User::factory()->create(['username' => 'author_two']);
$response = $this->get('/@Author_Two');
$response->assertRedirect('/@author_two');
});
test('profile username update writes history and redirect map', function () {
$user = User::factory()->create([
'username' => 'oldname',
'username_changed_at' => now()->subDays(120),
]);
$response = $this
->actingAs($user)
->patch('/profile', [
'username' => 'New_Name',
'name' => $user->name,
'email' => $user->email,
]);
$response->assertRedirect('/dashboard/profile');
$this->assertDatabaseHas('users', [
'id' => $user->id,
'username' => 'new_name',
]);
$this->assertDatabaseHas('username_history', [
'user_id' => $user->id,
'old_username' => 'oldname',
]);
$this->assertDatabaseHas('username_redirects', [
'old_username' => 'oldname',
'new_username' => 'new_name',
'user_id' => $user->id,
]);
});

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Jobs\AutoTagArtworkJob;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use Illuminate\Support\Facades\Http;
it('calls CLIP analyze and attaches AI tags', function () {
config()->set('vision.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/analyze' => Http::response([
['tag' => 'Cyber Punk', 'confidence' => 0.42],
['tag' => 'City', 'confidence' => 0.31],
], 200),
]);
$artwork = Artwork::factory()->create();
$hash = 'abcdef123456';
(new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
expect(Tag::query()->whereIn('slug', ['cyber-punk', 'city'])->count())->toBe(2);
expect($artwork->tags()->pluck('slug')->all())->toContain('cyber-punk', 'city');
});
it('optionally calls YOLO for photography', function () {
config()->set('vision.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.base_url', 'https://yolo.local');
config()->set('vision.yolo.endpoint', '/analyze');
config()->set('vision.yolo.enabled', true);
config()->set('vision.yolo.photography_only', true);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/analyze' => Http::response([['tag' => 'tree', 'confidence' => 0.2]], 200),
'https://yolo.local/analyze' => Http::response(['objects' => [['label' => 'person', 'confidence' => 0.9]]], 200),
]);
$photoType = ContentType::query()->create(['name' => 'Photography', 'slug' => 'photography', 'description' => '']);
$cat = Category::query()->create(['content_type_id' => $photoType->id, 'parent_id' => null, 'name' => 'Test', 'slug' => 'test', 'description' => '', 'image' => null, 'is_active' => true, 'sort_order' => 0]);
$artwork = Artwork::factory()->create();
$artwork->categories()->attach($cat->id);
$hash = 'abcdef123456';
(new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
expect($artwork->tags()->pluck('slug')->all())->toContain('tree', 'person');
});
it('does not throw on CLIP 4xx and never blocks publish', function () {
config()->set('vision.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/analyze' => Http::response(['message' => 'bad'], 422),
]);
$artwork = Artwork::factory()->create();
$hash = 'abcdef123456';
(new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
expect($artwork->tags()->count())->toBe(0);
});
it('persists clip tags blip caption and yolo objects from the unified gateway response', function () {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'Neon City', 'confidence' => 0.71],
],
'blip' => ['A neon city street at night'],
'yolo' => [
['label' => 'car', 'confidence' => 0.88],
['label' => 'person', 'confidence' => 0.67],
],
], 200),
]);
$artwork = Artwork::factory()->create();
(new AutoTagArtworkJob($artwork->id, 'abcdef123456'))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
$artwork->refresh();
expect($artwork->clip_tags_json)->toBeArray()
->and($artwork->clip_tags_json[0]['tag'] ?? null)->toBe('Neon City')
->and($artwork->blip_caption)->toBe('A neon city street at night')
->and($artwork->yolo_objects_json)->toBeArray()
->and($artwork->yolo_objects_json[0]['tag'] ?? null)->toBe('car')
->and($artwork->vision_metadata_updated_at)->not->toBeNull();
});

View File

@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\User;
use Tests\TestCase;
class AvatarUploadTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->set('avatars.disk', 'public');
Storage::fake('public');
}
public function test_upload_requires_authentication()
{
$response = $this->postJson(route('avatar.upload'));
$response->assertStatus(401);
}
public function test_upload_rejects_invalid_avatar_type(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('avatar.upload'), [
'avatar' => UploadedFile::fake()->create('avatar.txt', 50, 'text/plain'),
]);
$response->assertStatus(422);
}
public function test_upload_processes_avatar_and_stores_webp_variants(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('avatar.upload'), [
'avatar' => UploadedFile::fake()->image('avatar.png', 300, 180),
]);
$response->assertOk()->assertJson(['success' => true]);
$payload = $response->json();
$this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) ($payload['hash'] ?? ''));
$this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/32.webp"));
$this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/64.webp"));
$this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/128.webp"));
$this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/256.webp"));
$this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/512.webp"));
$record = DB::table('user_profiles')->where('user_id', $user->id)->first();
$this->assertNotNull($record);
$this->assertSame('image/webp', $record->avatar_mime);
$this->assertSame($payload['hash'], $record->avatar_hash);
$this->assertNotNull($record->avatar_updated_at);
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace Tests\Feature;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BrowseApiTest extends TestCase
{
use RefreshDatabase;
public function test_web_browse_renders_canonical_and_rel_prev_next_for_paginated_pages(): void
{
$user = User::factory()->create(['name' => 'Seo Author']);
$contentType = ContentType::create([
'name' => 'Skins',
'slug' => 'skins',
'description' => 'Skins content type',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'name' => 'Classic',
'slug' => 'classic',
'description' => 'Classic skins',
'is_active' => true,
'sort_order' => 1,
]);
for ($i = 1; $i <= 25; $i++) {
$artwork = Artwork::factory()
->for($user)
->create([
'title' => 'Seo Item ' . $i,
'slug' => 'seo-item-' . $i,
'published_at' => now()->subMinutes($i),
]);
$artwork->categories()->attach($category->id);
}
$response = $this->get('/explore?limit=12&grid=v2');
$response->assertOk();
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('name="robots" content="index,follow"', $html);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/explore\?limit=12"\s*\/>/i', $html);
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $html, $canonicalMatches);
$this->assertArrayHasKey(1, $canonicalMatches);
$canonicalUrl = html_entity_decode((string) $canonicalMatches[1], ENT_QUOTES);
$this->assertStringNotContainsString('grid=v2', $canonicalUrl);
$secondPage = $this->get('/explore?limit=12&page=2&grid=v2');
$secondPage->assertOk();
$secondHtml = $secondPage->getContent();
$this->assertNotFalse($secondHtml);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/explore\?[^"]*page=2/i', $secondHtml);
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $secondHtml, $secondCanonicalMatches);
$this->assertArrayHasKey(1, $secondCanonicalMatches);
$secondCanonicalUrl = html_entity_decode((string) $secondCanonicalMatches[1], ENT_QUOTES);
$this->assertStringNotContainsString('grid=v2', $secondCanonicalUrl);
$this->assertStringContainsString('page=2', $secondCanonicalUrl);
$pageOne = $this->get('/explore?limit=12&page=1&grid=v2');
$pageOne->assertOk();
$pageOneHtml = $pageOne->getContent();
$this->assertNotFalse($pageOneHtml);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/explore\?limit=12"\s*\/>/i', $pageOneHtml);
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $pageOneHtml, $pageOneCanonicalMatches);
$this->assertArrayHasKey(1, $pageOneCanonicalMatches);
$pageOneCanonicalUrl = html_entity_decode((string) $pageOneCanonicalMatches[1], ENT_QUOTES);
$this->assertStringNotContainsString('page=1', $pageOneCanonicalUrl);
}
public function test_api_browse_supports_limit_and_cursor_pagination(): void
{
$user = User::factory()->create(['name' => 'Cursor Author']);
$contentType = ContentType::create([
'name' => 'Skins',
'slug' => 'skins',
'description' => 'Skins content type',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'name' => 'Winamp',
'slug' => 'winamp',
'description' => 'Winamp skins',
'is_active' => true,
'sort_order' => 1,
]);
for ($i = 1; $i <= 6; $i++) {
$artwork = Artwork::factory()
->for($user)
->create([
'title' => 'Cursor Item ' . $i,
'slug' => 'cursor-item-' . $i,
'published_at' => now()->subMinutes($i),
]);
$artwork->categories()->attach($category->id);
}
$first = $this->getJson('/api/v1/browse?limit=2');
$first->assertOk();
$first->assertJsonCount(2, 'data');
$nextCursor = (string) data_get($first->json(), 'links.next', '');
$this->assertNotEmpty($nextCursor);
$this->assertStringContainsString('cursor=', $nextCursor);
$second = $this->getJson($nextCursor);
$second->assertOk();
$second->assertJsonCount(2, 'data');
$firstFirstSlug = data_get($first->json(), 'data.0.slug');
$secondFirstSlug = data_get($second->json(), 'data.0.slug');
$this->assertNotSame($firstFirstSlug, $secondFirstSlug);
}
public function test_api_browse_returns_public_artworks(): void
{
$user = User::factory()->create(['name' => 'Author One']);
$contentType = ContentType::create([
'name' => 'Wallpapers',
'slug' => 'wallpapers',
'description' => 'Wallpapers content type',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'name' => 'Abstract',
'slug' => 'abstract',
'description' => 'Abstract wallpapers',
'is_active' => true,
'sort_order' => 1,
]);
$artwork = Artwork::factory()
->for($user)
->create([
'slug' => 'neon-city',
'published_at' => now()->subDay(),
]);
$artwork->categories()->attach($category->id);
$response = $this->getJson('/api/v1/browse');
$response->assertOk()
->assertJsonPath('data.0.slug', 'neon-city')
->assertJsonPath('data.0.category.slug', 'abstract')
->assertJsonPath('data.0.author.name', 'Author One');
}
public function test_web_browse_shows_artworks(): void
{
$user = User::factory()->create(['name' => 'Author Two']);
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photos',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'name' => 'Nature',
'slug' => 'nature',
'description' => 'Nature photos',
'is_active' => true,
'sort_order' => 1,
]);
$artwork = Artwork::factory()
->for($user)
->create([
'title' => 'Forest Light',
'slug' => 'forest-light',
'published_at' => now()->subDay(),
]);
$artwork->categories()->attach($category->id);
$response = $this->get('/explore');
$response->assertOk();
$response->assertSee('Forest Light');
$response->assertSee('Author Two');
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('data-react-masonry-gallery', $html);
$this->assertStringContainsString('data-gallery-type="browse"', $html);
$this->assertStringContainsString('Forest Light', $html);
}
}

View File

@@ -0,0 +1,12 @@
<?php
it('renders the categories page with collection structured data', function () {
$html = $this->get('/categories')
->assertOk()
->getContent();
expect($html)
->toContain('application/ld+json')
->toContain('CollectionPage')
->toContain('Browse all categories on Skinbase');
});

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
uses(RefreshDatabase::class);
it('passes when every artwork has a matching user', function (): void {
Artwork::factory()->count(3)->create();
$code = Artisan::call('artworks:check-user-refs', [
'--chunk' => 2,
'--show-missing' => 5,
]);
$output = Artisan::output();
file_put_contents(storage_path('logs/check-artwork-user-refs-test-output.log'), $output);
expect($code)->toBe(0)
->and($output)->toContain('Checked 3 artworks: 3 valid, 0 missing user references, 0 null user_id values.')
->and($output)->toContain('No missing user references found in artworks.user_id.');
});
it('fails and reports orphaned artwork user references', function (): void {
$validArtwork = Artwork::factory()->create();
$orphanedArtwork = Artwork::factory()->create();
DB::table('artworks')
->where('id', $orphanedArtwork->id)
->update(['user_id' => 999999]);
$code = Artisan::call('artworks:check-user-refs', [
'--chunk' => 1,
'--show-missing' => 5,
]);
$output = Artisan::output();
expect($validArtwork->fresh())->not->toBeNull()
->and($code)->toBe(1)
->and($output)->toContain('Checked 2 artworks: 1 valid, 1 missing user references, 0 null user_id values.')
->and($output)->toContain('Found artworks with missing user references.')
->and($output)->toContain((string) $orphanedArtwork->id)
->and($output)->toContain('999999');
});
it('can copy missing referenced users from the legacy users table by the same id', function (): void {
$legacyUserId = 777;
$artwork = Artwork::factory()->create();
DB::table('artworks')
->where('id', $artwork->id)
->update(['user_id' => $legacyUserId]);
config()->set('database.connections.legacy', config('database.connections.' . config('database.default')));
DB::purge('legacy');
Schema::connection('legacy')->dropIfExists('legacy_users');
Schema::connection('legacy')->create('legacy_users', function (Blueprint $table): void {
$table->unsignedBigInteger('user_id')->primary();
$table->string('uname')->nullable();
$table->string('real_name')->nullable();
$table->string('email')->nullable();
$table->unsignedTinyInteger('active')->default(1);
$table->timestamp('joinDate')->nullable();
$table->timestamp('LastVisit')->nullable();
$table->string('country')->nullable();
$table->string('country_code', 2)->nullable();
$table->string('web')->nullable();
$table->text('about_me')->nullable();
$table->text('description')->nullable();
$table->string('gender', 16)->nullable();
});
DB::connection('legacy')->table('legacy_users')->insert([
'user_id' => $legacyUserId,
'uname' => 'legacy_artist',
'real_name' => 'Legacy Artist',
'email' => 'legacy.artist@example.test',
'active' => 1,
'joinDate' => '2020-01-02 03:04:05',
'LastVisit' => '2025-01-05 06:07:08',
'country' => 'Finland',
'country_code' => 'FI',
'web' => 'legacy.example.test',
'about_me' => 'Imported from legacy.',
'gender' => 'F',
]);
$code = Artisan::call('artworks:check-user-refs', [
'--chunk' => 1,
'--show-missing' => 5,
'--copy-missing-from-legacy' => true,
'--legacy-users-table' => 'legacy_users',
]);
$output = Artisan::output();
file_put_contents(storage_path('logs/check-artwork-user-refs-copy-test-output.log'), $output);
expect($code)->toBe(0)
->and(DB::table('users')->where('id', $legacyUserId)->exists())->toBeTrue()
->and(DB::table('user_profiles')->where('user_id', $legacyUserId)->value('country_code'))->toBe('FI')
->and($output)->toContain('[copied] imported legacy user #777 username=@legacy_artist name="Legacy Artist" email=<legacy.artist@example.test>')
->and($output)->toContain('Checked 1 artworks: 1 valid, 0 missing user references, 0 null user_id values.')
->and($output)->toContain('Legacy copy summary: requested 1 users, copied 1, would copy 0, conflicts 0, not found in legacy 0, errors 0.')
->and($output)->toContain('Copied or would-copy user ids: 777');
Schema::connection('legacy')->dropIfExists('legacy_users');
});
it('shows dry run copy details for legacy imports', function (): void {
$legacyUserId = 778;
$artwork = Artwork::factory()->create();
DB::table('artworks')
->where('id', $artwork->id)
->update(['user_id' => $legacyUserId]);
config()->set('database.connections.legacy', config('database.connections.' . config('database.default')));
DB::purge('legacy');
Schema::connection('legacy')->dropIfExists('legacy_users');
Schema::connection('legacy')->create('legacy_users', function (Blueprint $table): void {
$table->unsignedBigInteger('user_id')->primary();
$table->string('uname')->nullable();
$table->string('real_name')->nullable();
$table->string('email')->nullable();
$table->unsignedTinyInteger('active')->default(1);
$table->timestamp('joinDate')->nullable();
$table->timestamp('LastVisit')->nullable();
$table->string('country')->nullable();
$table->string('country_code', 2)->nullable();
$table->string('web')->nullable();
$table->text('about_me')->nullable();
$table->text('description')->nullable();
$table->string('gender', 16)->nullable();
});
DB::connection('legacy')->table('legacy_users')->insert([
'user_id' => $legacyUserId,
'uname' => 'legacy_preview',
'real_name' => 'Legacy Preview',
'email' => 'legacy.preview@example.test',
'active' => 1,
]);
$code = Artisan::call('artworks:check-user-refs', [
'--chunk' => 1,
'--show-missing' => 5,
'--copy-missing-from-legacy' => true,
'--dry-run-copy' => true,
'--legacy-users-table' => 'legacy_users',
]);
$output = Artisan::output();
expect($code)->toBe(1)
->and(DB::table('users')->where('id', $legacyUserId)->exists())->toBeFalse()
->and($output)->toContain('[dry-run] would import legacy user #778 username=@legacy_preview name="Legacy Preview" email=<legacy.preview@example.test>')
->and($output)->toContain('Legacy copy summary: requested 1 users, copied 0, would copy 1, conflicts 0, not found in legacy 0, errors 0.');
Schema::connection('legacy')->dropIfExists('legacy_users');
});

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\Vision\AiArtworkVectorSearchService;
use App\Services\Vision\ArtworkVectorIndexService;
use App\Services\Vision\ArtworkVectorMetadataService;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function bindVectorService(): void
{
$imageUrl = new ArtworkVisionImageUrl();
app()->instance(VectorService::class, new VectorService(
new AiArtworkVectorSearchService(new VectorGatewayClient(), $imageUrl),
new ArtworkVectorIndexService(new VectorGatewayClient(), $imageUrl, new ArtworkVectorMetadataService()),
));
}
it('indexes artworks by latest updated_at descending by default', function (): void {
$user = User::factory()->create();
$oldest = Artwork::factory()->for($user)->create([
'title' => 'Oldest artwork',
'slug' => 'oldest-artwork',
'hash' => str_repeat('a', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(3),
'updated_at' => now()->subDays(3),
]);
$middle = Artwork::factory()->for($user)->create([
'title' => 'Middle artwork',
'slug' => 'middle-artwork',
'hash' => str_repeat('b', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
$latest = Artwork::factory()->for($user)->create([
'title' => 'Latest artwork',
'slug' => 'latest-artwork',
'hash' => str_repeat('c', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'updated_at' => now()->subHour(),
]);
bindVectorService();
$code = Artisan::call('artworks:vectors-index', [
'--dry-run' => true,
'--public-only' => true,
'--limit' => 3,
'--batch' => 3,
]);
$output = Artisan::output();
expect($code)->toBe(0);
$latestPos = strpos($output, 'Processing artwork=' . $latest->id);
$middlePos = strpos($output, 'Processing artwork=' . $middle->id);
$oldestPos = strpos($output, 'Processing artwork=' . $oldest->id);
expect($latestPos)->not->toBeFalse()
->and($middlePos)->not->toBeFalse()
->and($oldestPos)->not->toBeFalse()
->and($latestPos)->toBeLessThan($middlePos)
->and($middlePos)->toBeLessThan($oldestPos);
});
it('supports legacy id ascending order when explicitly requested', function (): void {
$user = User::factory()->create();
$first = Artwork::factory()->for($user)->create([
'title' => 'First artwork',
'slug' => 'first-artwork-' . Str::lower(Str::random(4)),
'hash' => str_repeat('d', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'updated_at' => now(),
]);
$second = Artwork::factory()->for($user)->create([
'title' => 'Second artwork',
'slug' => 'second-artwork-' . Str::lower(Str::random(4)),
'hash' => str_repeat('e', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
bindVectorService();
$code = Artisan::call('artworks:vectors-index', [
'--dry-run' => true,
'--public-only' => true,
'--limit' => 2,
'--batch' => 2,
'--order' => 'id-asc',
]);
$output = Artisan::output();
expect($code)->toBe(0);
$firstPos = strpos($output, 'Processing artwork=' . $first->id);
$secondPos = strpos($output, 'Processing artwork=' . $second->id);
expect($firstPos)->not->toBeFalse()
->and($secondPos)->not->toBeFalse()
->and($firstPos)->toBeLessThan($secondPos);
});

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
uses(RefreshDatabase::class);
it('publishes scheduled news articles whose publish time has passed', function (): void {
$author = User::factory()->create();
$category = NewsCategory::query()->create([
'name' => 'Announcements',
'slug' => 'announcements',
'description' => 'Announcement category',
'position' => 0,
'is_active' => true,
]);
$dueArticle = NewsArticle::query()->create([
'title' => 'Due scheduled article',
'slug' => 'due-scheduled-article',
'excerpt' => 'Due now.',
'content' => 'Body',
'author_id' => $author->id,
'category_id' => $category->id,
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
'status' => 'scheduled',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_SCHEDULED,
'published_at' => now()->subMinute(),
]);
$futureArticle = NewsArticle::query()->create([
'title' => 'Future scheduled article',
'slug' => 'future-scheduled-article',
'excerpt' => 'Not due yet.',
'content' => 'Body',
'author_id' => $author->id,
'category_id' => $category->id,
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
'status' => 'scheduled',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_SCHEDULED,
'published_at' => now()->addHour(),
]);
$this->artisan('news:publish-scheduled')
->expectsOutput(sprintf('Published News article #%d: "%s"', $dueArticle->id, $dueArticle->title))
->expectsOutput('Done. Published: 1, Errors: 0.')
->assertSuccessful();
expect($dueArticle->fresh())
->editorial_status->toBe(NewsArticle::EDITORIAL_STATUS_PUBLISHED)
->status->toBe('published')
->and($futureArticle->fresh())
->editorial_status->toBe(NewsArticle::EDITORIAL_STATUS_SCHEDULED)
->status->toBe('scheduled');
});
it('supports dry run for scheduled news publishing', function (): void {
$author = User::factory()->create();
$category = NewsCategory::query()->create([
'name' => 'Platform',
'slug' => 'platform',
'description' => 'Platform category',
'position' => 0,
'is_active' => true,
]);
$article = NewsArticle::query()->create([
'title' => 'Dry run article',
'slug' => 'dry-run-article',
'excerpt' => 'Due but not published in dry run.',
'content' => 'Body',
'author_id' => $author->id,
'category_id' => $category->id,
'type' => NewsArticle::TYPE_PLATFORM_UPDATE,
'status' => 'scheduled',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_SCHEDULED,
'published_at' => now()->subMinute(),
]);
$this->artisan('news:publish-scheduled', ['--dry-run' => true])
->expectsOutput(sprintf('[dry-run] Would publish News article #%d: "%s"', $article->id, $article->title))
->assertSuccessful();
expect($article->fresh())
->editorial_status->toBe(NewsArticle::EDITORIAL_STATUS_SCHEDULED)
->status->toBe('scheduled');
});

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Enums\ModerationContentType;
use App\Models\ContentModerationActionLog;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ContentModerationFinding;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Log\Events\MessageLogged;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
uses(RefreshDatabase::class);
it('scans artwork comments and descriptions and stores suspicious findings', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'content' => 'Visit my site at https://promo.pornsite.com now',
'raw_content' => 'Visit my site at https://promo.pornsite.com now',
]);
$code = Artisan::call('skinbase:scan-content-moderation');
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->count())->toBe(2)
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkComment)->exists())->toBeTrue()
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->exists())->toBeTrue();
});
it('does not create duplicate findings for unchanged content', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
expect(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->count())->toBe(1)
->and(ContentModerationFinding::query()->first()?->content_id)->toBe($artwork->id);
});
it('supports dry runs without persisting findings', function (): void {
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--dry-run' => true]);
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->count())->toBe(0);
});
it('logs a command summary after scanning', function (): void {
Event::fake([MessageLogged::class]);
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
expect($code)->toBe(0);
Event::assertDispatched(MessageLogged::class, function (MessageLogged $event): bool {
return $event->level === 'info'
&& $event->message === 'Content moderation scan complete.'
&& ($event->context['targets'] ?? []) === ['artwork_description']
&& ($event->context['counts']['scanned'] ?? 0) === 1
&& ($event->context['counts']['flagged'] ?? 0) === 1;
});
});
it('shows target progress and verbose finding details while scanning', function (): void {
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', [
'--only' => 'descriptions',
'--verbose' => true,
]);
$output = Artisan::output();
expect($code)->toBe(0)
->and($output)->toContain('Starting content moderation scan...')
->and($output)->toContain('Scanning Artwork Description entries...')
->and($output)->toContain('[artwork_description #')
->and($output)->toContain('flagged')
->and($output)->toContain('Finished Artwork Description: scanned=1, flagged=1');
});
it('auto hides critical comment spam while keeping the finding', function (): void {
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'content' => 'Buy followers now at https://promo.pornsite.com and claim your crypto giveaway',
'raw_content' => 'Buy followers now at https://promo.pornsite.com and claim your crypto giveaway',
'is_approved' => true,
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--only' => 'comments']);
$finding = ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkComment)->first();
expect($code)->toBe(0)
->and($finding)->not->toBeNull()
->and($finding?->is_auto_hidden)->toBeTrue()
->and($finding?->action_taken)->toBe('auto_hide_comment')
->and($comment->fresh()->is_approved)->toBeFalse();
});
it('rescans existing findings with the latest rules', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => 'pending',
'severity' => 'high',
'score' => 90,
'content_hash' => hash('sha256', 'old-hash'),
'scanner_version' => '1.0',
'content_snapshot' => 'old snapshot',
]);
$code = Artisan::call('skinbase:rescan-content-moderation', ['--only' => 'descriptions']);
$scannerVersion = (string) config('content_moderation.scanner_version');
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->where('content_id', $artwork->id)->where('scanner_version', $scannerVersion)->exists())->toBeTrue()
->and(ContentModerationActionLog::query()->where('action_type', 'rescan')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,56 @@
<?php
use App\Http\Controllers\ContentRouterController;
use App\Models\Artwork;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\View\View;
uses(RefreshDatabase::class);
it('handles model-bound artwork parameter without 404 fallback', function () {
$artwork = Artwork::factory()->create([
'slug' => 'bound-model-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subMinute(),
]);
$request = Request::create('/photography/abstract/bound-model-artwork', 'GET');
$response = app(ContentRouterController::class)->handle(
$request,
'photography',
'abstract',
$artwork
);
expect($response)->toBeInstanceOf(View::class);
expect($response->name())->toBe('artworks.show');
expect($response->getData()['artwork']->id)->toBe($artwork->id);
});
it('binds routed artwork model and renders published artwork page', function () {
$artwork = Artwork::factory()->create([
'title' => 'Routed Binding Artwork',
'slug' => 'routed-binding-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subMinute(),
]);
$url = route('artworks.show', [
'contentTypeSlug' => 'photography',
'categoryPath' => 'abstract',
'artwork' => $artwork->slug,
]);
$matchedRoute = app('router')->getRoutes()->match(Request::create($url, 'GET'));
app('router')->substituteBindings($matchedRoute);
expect($matchedRoute->parameter('artwork'))->toBeInstanceOf(Artwork::class);
expect($matchedRoute->parameter('artwork')->id)->toBe($artwork->id);
$this->get($url)
->assertOk()
->assertSee('Routed Binding Artwork');
});

View File

@@ -0,0 +1,126 @@
<?php
use App\Services\ArtworkService;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function dynamicRoutingEmptyPaginator(string $path = '/'): LengthAwarePaginator
{
return (new LengthAwarePaginator(collect(), 0, 20, 1))->setPath($path);
}
beforeEach(function () {
$artworksMock = \Mockery::mock(ArtworkService::class);
$artworksMock->shouldReceive('getFeaturedArtworks')->andReturn(dynamicRoutingEmptyPaginator('/'))->byDefault();
$artworksMock->shouldReceive('getLatestArtworks')->andReturn(collect())->byDefault();
$this->app->instance(ArtworkService::class, $artworksMock);
$this->contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Digital Art',
'slug' => 'digital-art',
'description' => 'Digital art uploads',
'order' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('content_type_slug_histories')->insert([
'content_type_id' => $this->contentTypeId,
'old_slug' => 'concept-art',
'created_at' => now(),
'updated_at' => now(),
]);
});
it('redirects old explore content type slugs to the current canonical slug', function () {
$this->get('/explore/concept-art?sort=latest')
->assertRedirect('/digital-art?sort=latest');
});
it('redirects old rss explore slugs while preserving mode and query string', function () {
$this->get('/rss/explore/concept-art/trending?limit=10')
->assertRedirect('/rss/explore/digital-art/trending?limit=10');
});
it('redirects old artwork paths to the current canonical content type slug', function () {
$this->get('/concept-art/fantasy/castle-sunrise')
->assertRedirect('/digital-art/fantasy/castle-sunrise');
});
it('returns 404 for unknown dynamic content type slugs', function () {
$this->get('/made-up-content-type')
->assertNotFound();
});
it('lists resolver-backed content types on the rss feeds page', function () {
$this->get('/rss-feeds')
->assertOk()
->assertSee('/rss/explore/digital-art', false)
->assertSee('Digital Art');
});
it('keeps static routes ahead of dynamic content type routes even with bad data', function () {
DB::table('content_types')->insert([
'name' => 'RSS Collision',
'slug' => 'rss',
'description' => 'Invalid but useful for route-order regression coverage',
'order' => 99,
'created_at' => now(),
'updated_at' => now(),
]);
$this->get('/rss')
->assertOk()
->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
});
it('blocks reserved content type slugs in the admin save flow', function () {
$admin = User::factory()->create(['role' => 'admin']);
$this->withoutMiddleware();
$this->actingAs($admin)
->post(route('admin.cp.artworks.content-types.store'), [
'name' => 'Reserved Help',
'slug' => 'help',
'description' => 'Should be rejected',
'order' => 1,
])
->assertStatus(422)
->assertSeeText('This content type slug is reserved by the public routing layer.');
});
it('allows updating an existing content type that already uses the same reserved slug', function () {
$admin = User::factory()->create(['role' => 'admin']);
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Members',
'slug' => 'members',
'description' => 'Legacy reserved slug still in use',
'order' => 5,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withoutMiddleware();
$this->actingAs($admin)
->post(route('admin.cp.artworks.content-types.update', ['id' => $contentTypeId]), [
'name' => 'Members Updated',
'slug' => 'members',
'description' => 'Updated without renaming slug',
'order' => 5,
])
->assertRedirect(route('admin.cp.artworks.content-types.main'));
$this->assertDatabaseHas('content_types', [
'id' => $contentTypeId,
'slug' => 'members',
'name' => 'Members Updated',
'description' => 'Updated without renaming slug',
]);
});

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Schema;
it('creates the countries table and user country relation column', function (): void {
expect(Schema::hasTable('countries'))->toBeTrue();
expect(Schema::hasColumns('countries', [
'id',
'iso2',
'iso3',
'numeric_code',
'name_common',
'name_official',
'region',
'subregion',
'flag_svg_url',
'flag_png_url',
'flag_emoji',
'active',
'sort_order',
'is_featured',
'created_at',
'updated_at',
]))->toBeTrue();
expect(Schema::hasColumn('users', 'country_id'))->toBeTrue();
});

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use App\Models\Country;
use App\Models\User;
use App\Services\Countries\CountryCatalogService;
use App\Services\Countries\CountryRemoteProviderInterface;
use App\Services\Countries\CountrySyncService;
use Illuminate\Support\Facades\DB;
it('syncs countries updates cache and backfills users from legacy country codes', function (): void {
config()->set('skinbase-countries.deactivate_missing', true);
Country::query()->where('iso2', 'SI')->update([
'iso' => 'SI',
'iso3' => 'SVN',
'name' => 'Old Slovenia',
'name_common' => 'Old Slovenia',
'active' => true,
]);
$user = User::factory()->create();
DB::table('user_profiles')->insert([
'user_id' => $user->id,
'country_code' => 'SI',
'created_at' => now(),
'updated_at' => now(),
]);
$catalog = app(CountryCatalogService::class);
expect(collect($catalog->profileSelectOptions())->firstWhere('iso2', 'SI')['name'])->toBe('Old Slovenia');
app()->instance(CountryRemoteProviderInterface::class, new class implements CountryRemoteProviderInterface {
public function fetchAll(): array
{
return [
[
'iso2' => 'SI',
'iso3' => 'SVN',
'numeric_code' => '705',
'name_common' => 'Slovenia',
'name_official' => 'Republic of Slovenia',
'region' => 'Europe',
'subregion' => 'Central Europe',
'flag_svg_url' => 'https://flags.test/si.svg',
'flag_png_url' => 'https://flags.test/si.png',
'flag_emoji' => '🇸🇮',
],
[
'iso2' => '',
'name_common' => 'Invalid',
],
[
'iso2' => 'SI',
'name_common' => 'Duplicate Slovenia',
],
[
'iso2' => 'ZZ',
'iso3' => 'ZZZ',
'numeric_code' => '999',
'name_common' => 'Zedland',
'name_official' => 'Republic of Zedland',
'region' => 'Europe',
'subregion' => 'Nowhere',
'flag_svg_url' => 'https://flags.test/zz.svg',
'flag_png_url' => 'https://flags.test/zz.png',
'flag_emoji' => '🏳️',
],
];
}
public function normalizePayload(array $payload): array
{
return $payload;
}
});
$summary = app(CountrySyncService::class)->sync(allowFallback: false, deactivateMissing: true);
expect($summary['updated'])->toBe(1)
->and($summary['inserted'])->toBe(1)
->and($summary['invalid'])->toBe(1)
->and($summary['skipped'])->toBe(1)
->and($summary['backfilled_users'])->toBe(1);
expect(collect($catalog->profileSelectOptions())->firstWhere('iso2', 'SI')['name'])->toBe('Slovenia');
expect($user->fresh()->country_id)->toBe(Country::query()->where('iso2', 'SI')->value('id'));
});

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Http\Middleware\ForumBotProtectionMiddleware;
use App\Models\Country;
use App\Models\User;
it('stores a selected country on the personal settings endpoint', function (): void {
$this->withoutMiddleware(ForumBotProtectionMiddleware::class);
$user = User::factory()->create();
$country = Country::query()->where('iso2', 'SI')->firstOrFail();
$country->update([
'iso' => 'SI',
'iso3' => 'SVN',
'name' => 'Slovenia',
'name_common' => 'Slovenia',
]);
$response = $this->actingAs($user)->postJson('/settings/personal/update', [
'birthday' => '1990-01-02',
'gender' => 'm',
'country_id' => $country->id,
]);
$response->assertOk();
$user->refresh();
expect($user->country_id)->toBe($country->id);
expect(optional($user->profile)->country_code)->toBe('SI');
});
it('rejects invalid country identifiers on the personal settings endpoint', function (): void {
$this->withoutMiddleware(ForumBotProtectionMiddleware::class);
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/settings/personal/update', [
'country_id' => 999999,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['country_id']);
});
it('loads countries on the dashboard profile settings page', function (): void {
$this->withoutMiddleware(ForumBotProtectionMiddleware::class);
$user = User::factory()->create();
Country::query()->where('iso2', 'SI')->update([
'iso' => 'SI',
'iso3' => 'SVN',
'name' => 'Slovenia',
'name_common' => 'Slovenia',
'flag_emoji' => '🇸🇮',
]);
$response = $this->actingAs($user)->get('/dashboard/profile');
$response->assertOk()->assertSee('Slovenia');
});
it('supports country persistence through the legacy profile update endpoint', function (): void {
$user = User::factory()->create();
$country = Country::query()->where('iso2', 'DE')->firstOrFail();
$country->update([
'iso' => 'DE',
'iso3' => 'DEU',
'name' => 'Germany',
'name_common' => 'Germany',
]);
$response = $this->actingAs($user)
->from('/profile/edit')
->patch('/profile', [
'name' => 'Updated User',
'email' => $user->email,
'country_id' => $country->id,
]);
$response->assertSessionHasNoErrors();
expect($user->fresh()->country_id)->toBe($country->id);
expect(optional($user->fresh()->profile)->country_code)->toBe('DE');
});

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Models\Country;
use Illuminate\Support\Facades\Http;
it('sync command imports countries from the configured remote source', function (): void {
config()->set('skinbase-countries.endpoint', 'https://countries.test/all');
config()->set('skinbase-countries.fallback_seed_enabled', false);
Http::fake([
'https://countries.test/all' => Http::response([
[
'cca2' => 'SI',
'cca3' => 'SVN',
'ccn3' => '705',
'name' => ['common' => 'Slovenia', 'official' => 'Republic of Slovenia'],
'region' => 'Europe',
'subregion' => 'Central Europe',
'flags' => ['svg' => 'https://flags.test/si.svg', 'png' => 'https://flags.test/si.png'],
'flag' => '🇸🇮',
],
], 200),
]);
$this->artisan('skinbase:sync-countries')->assertSuccessful();
expect(Country::query()->where('iso2', 'SI')->value('name_common'))->toBe('Slovenia');
});
it('sync command fails when the remote source errors and fallback is disabled', function (): void {
config()->set('skinbase-countries.endpoint', 'https://countries.test/all');
config()->set('skinbase-countries.fallback_seed_enabled', false);
Http::fake([
'https://countries.test/all' => Http::response(['message' => 'server error'], 500),
]);
$this->artisan('skinbase:sync-countries')
->assertExitCode(1);
});
it('sync command fails gracefully when the payload contains no valid country records', function (): void {
config()->set('skinbase-countries.endpoint', 'https://countries.test/all');
config()->set('skinbase-countries.fallback_seed_enabled', false);
Http::fake([
'https://countries.test/all' => Http::response([
['bad' => 'payload'],
], 200),
]);
$this->artisan('skinbase:sync-countries')
->assertExitCode(1);
});

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\DashboardPreference;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Support\Facades\DB;
it('embeds dashboard overview counts for the authenticated user', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
$follower = User::factory()->create();
$followed = User::factory()->create();
$commenter = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDay(),
]);
DB::table('user_followers')->insert([
[
'user_id' => $user->id,
'follower_id' => $follower->id,
'created_at' => now(),
],
[
'user_id' => $followed->id,
'follower_id' => $user->id,
'created_at' => now(),
],
]);
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'created_at' => now(),
'updated_at' => now(),
]);
Notification::query()->create([
'user_id' => $user->id,
'type' => 'comment',
'data' => [
'message' => 'Unread dashboard notification',
'url' => '/dashboard/notifications',
],
'read_at' => null,
]);
ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $commenter->id,
'content' => 'Unread dashboard comment',
'raw_content' => 'Unread dashboard comment',
'rendered_content' => '<p>Unread dashboard comment</p>',
'is_approved' => true,
]);
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk()->assertSee('data-overview=', false);
$overview = $response->viewData('dashboard_overview');
$preferences = $response->viewData('dashboard_preferences');
expect($overview)->toMatchArray([
'artworks' => 1,
'stories' => 0,
'followers' => 1,
'following' => 1,
'favorites' => 1,
'notifications' => $user->unreadNotifications()->count(),
'received_comments' => 1,
]);
expect($preferences)->toMatchArray([
'pinned_spaces' => [],
]);
});
it('embeds saved pinned dashboard spaces for the authenticated user', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
DashboardPreference::query()->create([
'user_id' => $user->id,
'pinned_spaces' => [
'/dashboard/notifications',
'/studio',
],
]);
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
expect($response->viewData('dashboard_preferences'))->toMatchArray([
'pinned_spaces' => [
'/dashboard/notifications',
'/studio',
],
]);
});

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Models\DashboardPreference;
use App\Models\User;
it('persists sanitized pinned dashboard spaces for the authenticated user', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
$response = $this->actingAs($user)->putJson('/api/dashboard/preferences/shortcuts', [
'pinned_spaces' => [
'/dashboard/notifications',
'/dashboard/notifications',
'/dashboard/comments/received',
'/not-allowed',
'/studio',
],
]);
$response
->assertOk()
->assertJson([
'data' => [
'pinned_spaces' => [
'/dashboard/notifications',
'/dashboard/comments/received',
'/studio',
],
],
]);
expect(DashboardPreference::query()->find($user->id)?->pinned_spaces)->toBe([
'/dashboard/notifications',
'/dashboard/comments/received',
'/studio',
]);
});
it('allows clearing all pinned dashboard spaces for the authenticated user', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
DashboardPreference::query()->create([
'user_id' => $user->id,
'pinned_spaces' => [
'/dashboard/notifications',
],
]);
$response = $this->actingAs($user)->putJson('/api/dashboard/preferences/shortcuts', [
'pinned_spaces' => [],
]);
$response
->assertOk()
->assertJson([
'data' => [
'pinned_spaces' => [],
],
]);
expect(DashboardPreference::query()->find($user->id)?->pinned_spaces)->toBe([]);
});

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\Notification;
use App\Models\User;
it('renders the dashboard notifications page for an authenticated user', function () {
$user = User::factory()->create();
Notification::query()->create([
'user_id' => $user->id,
'type' => 'comment',
'data' => [
'type' => 'comment',
'message' => 'Someone commented on your artwork',
'url' => '/dashboard/comments/received',
],
'read_at' => null,
]);
$response = $this->actingAs($user)->get('/dashboard/notifications');
$response
->assertOk()
->assertSee('Notifications', false)
->assertSee('Someone commented on your artwork', false)
->assertSee('Unread', false);
});

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Services\ReceivedCommentsInboxService;
use Illuminate\Support\Carbon;
it('tracks unread received comments and clears them when the inbox is opened', function () {
Carbon::setTestNow('2026-03-19 12:00:00');
$owner = User::factory()->create();
$commenter = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $owner->id,
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDay(),
]);
$firstComment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $commenter->id,
'content' => 'First unread comment',
'raw_content' => 'First unread comment',
'rendered_content' => '<p>First unread comment</p>',
'is_approved' => true,
'created_at' => now()->subMinute(),
'updated_at' => now()->subMinute(),
]);
$service = app(ReceivedCommentsInboxService::class);
expect($service->unreadCountForUser($owner))->toBe(1);
$this->actingAs($owner)
->get('/dashboard/comments/received')
->assertOk()
->assertSee('Marked 1 new comment as read', false);
$this->assertDatabaseHas('user_received_comment_reads', [
'user_id' => $owner->id,
'artwork_comment_id' => $firstComment->id,
]);
expect($service->unreadCountForUser($owner))->toBe(0);
Carbon::setTestNow('2026-03-19 12:05:00');
ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $commenter->id,
'content' => 'Second unread comment',
'raw_content' => 'Second unread comment',
'rendered_content' => '<p>Second unread comment</p>',
'is_approved' => true,
'created_at' => now(),
'updated_at' => now(),
]);
expect($service->unreadCountForUser($owner))->toBe(1);
Carbon::setTestNow();
});

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class DashboardFavoritesTest extends TestCase
{
use RefreshDatabase;
public function test_guest_is_redirected_from_favorites(): void
{
$this->get('/dashboard/favorites')->assertRedirect('/login');
}
public function test_authenticated_user_sees_favourites_and_can_remove(): void
{
$user = User::factory()->create();
$art = Artwork::factory()->create(['user_id' => $user->id, 'title' => 'Fav Artwork']);
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $art->id,
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->actingAs($user)
->get(route('dashboard.favorites'))
->assertOk()
->assertSee('Fav Artwork');
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('data-react-masonry-gallery', $html);
$this->assertStringContainsString('data-artworks=', $html);
$this->assertStringContainsString('data-gallery-type="dashboard-favorites"', $html);
$this->actingAs($user)
->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id]))
->assertRedirect(route('dashboard.favorites'));
$this->assertDatabaseMissing('artwork_favourites', ['user_id' => $user->id, 'artwork_id' => $art->id]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Tests\Feature;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardGalleryTest extends TestCase
{
use RefreshDatabase;
public function test_guest_is_redirected_from_dashboard_gallery(): void
{
$this->get('/dashboard/gallery')->assertRedirect('/login');
}
public function test_authenticated_user_sees_gallery(): void
{
$user = User::factory()->create();
$art = Artwork::factory()->create(['user_id' => $user->id, 'title' => 'Test Artwork']);
$this->actingAs($user)
->get(route('dashboard.gallery'))
->assertOk()
->assertSee('My Gallery')
->assertSee('Test Artwork');
}
}

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\Artwork;
use App\Models\ArtworkMetricSnapshotHourly;
use Illuminate\Support\Facades\Cache;
beforeEach(function (): void {
Cache::flush();
});
it('GET /rss/discover/rising returns 200', function (): void {
$this->get('/rss/discover/rising')
->assertOk();
});
it('uses the low-signal fallback ordering in the RSS rising feed', function (): void {
$olderActiveArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'RSS Older Active Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(3),
'created_at' => now()->subDays(3),
]));
$newerQuietArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'RSS Newer Quiet Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'created_at' => now()->subHour(),
]));
$previousHour = now()->startOfHour()->subHour();
$currentHour = now()->startOfHour();
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $olderActiveArtwork->id,
'bucket_hour' => $previousHour,
'views_count' => 10,
'downloads_count' => 0,
'favourites_count' => 0,
'comments_count' => 0,
'shares_count' => 0,
]);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $olderActiveArtwork->id,
'bucket_hour' => $currentHour,
'views_count' => 45,
'downloads_count' => 2,
'favourites_count' => 1,
'comments_count' => 0,
'shares_count' => 0,
]);
$response = $this->get('/rss/discover/rising');
$response->assertOk();
$response->assertSeeInOrder([
'RSS Older Active Artwork',
'RSS Newer Quiet Artwork',
], false);
expect($newerQuietArtwork->id)->not->toBe($olderActiveArtwork->id);
});

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Pagination\LengthAwarePaginator;
beforeEach(function () {
$this->artworksMock = Mockery::mock(ArtworkService::class);
$this->artworksMock->shouldReceive('getFeaturedArtworks')
->andReturn(new LengthAwarePaginator(collect(), 0, 20, 1))
->byDefault();
$this->artworksMock->shouldReceive('getLatestArtworks')
->andReturn(collect())
->byDefault();
$this->app->instance(ArtworkService::class, $this->artworksMock);
});
it('GET /discover/rising returns 200', function () {
$this->get('/discover/rising')
->assertStatus(200);
});
it('/discover/rising page contains Rising Now heading', function () {
$this->get('/discover/rising')
->assertStatus(200)
->assertSee('Rising Now', false);
});
it('/discover/rising page includes the rising section pill as active', function () {
$this->get('/discover/rising')
->assertStatus(200)
->assertSee('bg-sky-600', false);
});
it('GET /discover/trending still returns 200', function () {
$this->get('/discover/trending')
->assertStatus(200);
});
it('home page still renders with rising section data', function () {
$this->get('/')
->assertStatus(200);
});
it('uses the low-signal fallback ordering when rising search results have no momentum', function () {
$olderArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Older Fallback Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(4),
'created_at' => now()->subDays(4),
]));
$newerArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Newer Fallback Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'created_at' => now()->subHour(),
]));
$this->get('/discover/rising')
->assertStatus(200)
->assertSeeInOrder(['Newer Fallback Artwork', 'Older Fallback Artwork'], false);
});

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
// ── ActivityEvent::record() factory helper ────────────────────────────────────
it('creates a db row via record()', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
ActivityEvent::record(
actorId: $user->id,
type: ActivityEvent::TYPE_FAVORITE,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
meta: ['source' => 'test'],
);
$this->assertDatabaseHas('activity_events', [
'actor_id' => $user->id,
'type' => 'favorite',
'target_type' => 'artwork',
'target_id' => $artwork->id,
]);
});
it('stores all five event types without error', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$events = [
[ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_COMMENT, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_FAVORITE, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_AWARD, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_FOLLOW, ActivityEvent::TARGET_USER, $user->id],
];
foreach ($events as [$type, $targetType, $targetId]) {
ActivityEvent::record($user->id, $type, $targetType, $targetId);
}
expect(ActivityEvent::where('actor_id', $user->id)->count())->toBe(5);
});
it('created_at is populated on the returned instance', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record(
$user->id,
ActivityEvent::TYPE_COMMENT,
ActivityEvent::TARGET_ARTWORK,
$artwork->id,
);
expect($event->created_at)->not->toBeNull();
});
it('actor relation resolves after record()', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
expect($event->actor->id)->toBe($user->id);
});
it('meta is null when empty array is passed', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
expect($event->meta)->toBeNull();
});
it('meta is stored when non-empty array is passed', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record(
$user->id,
ActivityEvent::TYPE_AWARD,
ActivityEvent::TARGET_ARTWORK,
$artwork->id,
['medal' => 'gold'],
);
expect($event->meta)->toBe(['medal' => 'gold']);
});
// ── Community activity feed route ─────────────────────────────────────────────
it('global activity feed returns 200 for guests', function () {
$this->get('/community/activity')->assertStatus(200);
});
it('following tab returns 200 for users with no follows', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/community/activity?filter=following')
->assertStatus(200);
});
it('following tab shows only events from followed users', function () {
$user = User::factory()->create();
$creator = User::factory()->create();
$other = User::factory()->create();
$artwork = Artwork::factory()->create();
// user_followers has no updated_at column
DB::table('user_followers')->insert([
'user_id' => $creator->id,
'follower_id' => $user->id,
'created_at' => now(),
]);
// Event from followed creator
ActivityEvent::record($creator->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
// Event from non-followed user (should not appear)
ActivityEvent::record($other->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
$response = $this->actingAs($user)->get('/community/activity?filter=following');
$response->assertStatus(200);
$props = $response->viewData('props');
$events = collect($props['initialActivities'] ?? []);
expect($events)->toHaveCount(1);
expect(data_get($events->first(), 'user.id'))->toBe($creator->id);
});

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Jobs\IngestUserDiscoveryEventJob;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('queues discovery event ingestion asynchronously', function () {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
'event_type' => 'view',
'artwork_id' => $artwork->id,
'meta' => ['source' => 'artwork_show'],
]);
$response
->assertStatus(202)
->assertJsonPath('queued', true)
->assertJsonPath('algo_version', (string) config('discovery.algo_version'));
Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job) use ($user, $artwork): bool {
return $job->userId === $user->id
&& $job->artworkId === $artwork->id
&& $job->eventType === 'view';
});
});
it('validates discovery event payload', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
'event_type' => 'impression',
'artwork_id' => $artwork->id,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['event_type']);
});
it('accepts session-oriented discovery events', function () {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
'event_type' => 'dwell',
'artwork_id' => $artwork->id,
'meta' => ['duration_ms' => 4500],
]);
$response->assertStatus(202);
Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job): bool {
return $job->eventType === 'dwell';
});
});

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Tag;
use App\Models\User;
use App\Models\UserNegativeSignal;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores hidden artwork signals', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/feedback/hide-artwork', [
'artwork_id' => $artwork->id,
'source' => 'feed-test',
]);
$response->assertStatus(202)->assertJsonPath('stored', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', $artwork->id)
->exists())->toBeTrue();
});
it('revokes hidden artwork signals', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
UserNegativeSignal::query()->create([
'user_id' => $user->id,
'signal_type' => 'hide_artwork',
'artwork_id' => $artwork->id,
'source' => 'feed-test',
]);
$response = $this->actingAs($user)->deleteJson('/api/discovery/feedback/hide-artwork', [
'artwork_id' => $artwork->id,
]);
$response->assertOk()->assertJsonPath('revoked', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', $artwork->id)
->exists())->toBeFalse();
});
it('stores disliked tag signals by slug', function () {
$user = User::factory()->create();
$tag = Tag::query()->create(['name' => 'Sci-Fi', 'slug' => 'sci-fi']);
$response = $this->actingAs($user)->postJson('/api/discovery/feedback/dislike-tag', [
'tag_slug' => 'sci-fi',
'source' => 'feed-test',
]);
$response->assertStatus(202)->assertJsonPath('stored', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tag->id)
->exists())->toBeTrue();
});
it('revokes disliked tag signals by slug', function () {
$user = User::factory()->create();
$tag = Tag::query()->create(['name' => 'Sci-Fi', 'slug' => 'sci-fi']);
UserNegativeSignal::query()->create([
'user_id' => $user->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tag->id,
'source' => 'feed-test',
]);
$response = $this->actingAs($user)->deleteJson('/api/discovery/feedback/dislike-tag', [
'tag_slug' => 'sci-fi',
]);
$response->assertOk()->assertJsonPath('revoked', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tag->id)
->exists())->toBeFalse();
});

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\User;
use App\Models\UserRecommendationCache;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('returns feed from cache with cursor pagination', function () {
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
$artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
UserRecommendationCache::query()->create([
'user_id' => $user->id,
'algo_version' => (string) config('discovery.algo_version'),
'cache_version' => (string) config('discovery.cache_version'),
'recommendations_json' => [
'items' => [
['artwork_id' => $artworkA->id, 'score' => 0.9, 'source' => 'profile'],
['artwork_id' => $artworkB->id, 'score' => 0.8, 'source' => 'profile'],
['artwork_id' => $artworkC->id, 'score' => 0.7, 'source' => 'profile'],
],
],
'generated_at' => now(),
'expires_at' => now()->addMinutes(30),
]);
$first = $this->actingAs($user)->getJson('/api/v1/feed?limit=2');
$first->assertOk();
$first->assertJsonPath('meta.cache_status', 'hit');
expect(count((array) $first->json('data')))->toBe(2);
$nextCursor = $first->json('meta.next_cursor');
expect($nextCursor)->not->toBeNull();
$second = $this->actingAs($user)->getJson('/api/v1/feed?limit=2&cursor=' . urlencode((string) $nextCursor));
$second->assertOk();
expect(count((array) $second->json('data')))->toBe(1);
expect($second->json('meta.next_cursor'))->toBeNull();
});
it('dispatches async regeneration on cache miss and returns cold start items', function () {
Queue::fake();
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
DB::table('artwork_stats')->insert([
['artwork_id' => $artworkA->id, 'views' => 100, 'downloads' => 30, 'favorites' => 10, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkB->id, 'views' => 80, 'downloads' => 10, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0],
]);
$response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10');
$response->assertOk();
expect(count((array) $response->json('data')))->toBeGreaterThan(0);
expect((string) $response->json('meta.cache_status'))->toContain('miss');
Queue::assertPushed(RegenerateUserRecommendationCacheJob::class, function (RegenerateUserRecommendationCacheJob $job) use ($user): bool {
return $job->userId === $user->id;
});
});
it('applies diversity guard to avoid near-duplicates in cold start fallback', function () {
Queue::fake();
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
$artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
DB::table('artwork_stats')->insert([
['artwork_id' => $artworkA->id, 'views' => 200, 'downloads' => 20, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkB->id, 'views' => 190, 'downloads' => 18, 'favorites' => 4, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkC->id, 'views' => 180, 'downloads' => 12, 'favorites' => 3, 'rating_avg' => 0, 'rating_count' => 0],
]);
DB::table('artwork_similarities')->insert([
'artwork_id' => $artworkA->id,
'similar_artwork_id' => $artworkB->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => (string) config('discovery.algo_version'),
'rank' => 1,
'score' => 0.991,
'generated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10');
$response->assertOk();
$ids = collect((array) $response->json('data'))->pluck('id')->all();
expect(in_array($artworkA->id, $ids, true) && in_array($artworkB->id, $ids, true))->toBeFalse();
expect(in_array($artworkC->id, $ids, true))->toBeTrue();
});

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\Tag;
use App\Models\User;
use App\Models\UserRecommendationCache;
use App\Services\Recommendations\SessionRecoService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
uses(RefreshDatabase::class);
it('can serve the feed through the v2 selector path', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 100);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
$user = User::factory()->create();
$creator = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $creator->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'trending_score_1h' => 5,
'trending_score_24h' => 10,
'trending_score_7d' => 20,
]);
$tag = Tag::query()->create(['name' => 'Abstract', 'slug' => 'abstract']);
DB::table('artwork_tag')->insert([
'artwork_id' => $artwork->id,
'tag_id' => $tag->id,
'source' => 'user',
'created_at' => now(),
]);
DB::table('artwork_stats')->insert([
'artwork_id' => $artwork->id,
'views' => 150,
'downloads' => 20,
'favorites' => 15,
'comments_count' => 5,
'shares_count' => 2,
'views_24h' => 150,
'views_7d' => 150,
'downloads_24h' => 20,
'downloads_7d' => 20,
'shares_24h' => 2,
'comments_24h' => 5,
'favourites_24h' => 15,
'views_1h' => 40,
'downloads_1h' => 5,
'favourites_1h' => 4,
'comments_1h' => 2,
'shares_1h' => 1,
'ranking_score' => 50,
'engagement_velocity' => 12,
'heat_score' => 30,
'rating_avg' => 0,
'rating_count' => 0,
]);
UserRecommendationCache::query()->create([
'user_id' => $user->id,
'algo_version' => 'clip-cosine-v2-adaptive',
'cache_version' => 'cache-v2',
'recommendations_json' => [
'items' => [
['artwork_id' => $artwork->id, 'score' => 1.2, 'source' => 'trending', 'layer_sources' => ['trending']],
],
],
'generated_at' => now(),
'expires_at' => now()->addMinutes(10),
]);
actingAs($user);
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
$response->assertOk();
$response->assertJsonPath('meta.engine', 'v2');
$response->assertJsonPath('meta.algo_version', 'clip-cosine-v2-adaptive');
$response->assertJsonPath('meta.local_embedding_count', 0);
$response->assertJsonPath('meta.vector_indexed_count', 0);
$response->assertJsonPath('data.0.primary_tag.slug', 'abstract');
$response->assertJsonPath('data.0.has_local_embedding', false);
$response->assertJsonPath('data.0.vector_indexed_at', null);
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', false);
$response->assertJsonPath('data.0.ranking_signals.vector_indexed_at', null);
expect((array) $response->json('data'))->toHaveCount(1);
});
it('boosts vector-similar candidates in the v3 hybrid feed', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 100);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
config()->set('discovery.v3.enabled', true);
config()->set('discovery.v3.vector_similarity_weight', 2.0);
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('cdn.files_url', 'https://files.skinbase.org');
$user = User::factory()->create();
$creator = User::factory()->create();
$seedArtwork = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => 'aabbcc112233',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
]);
$vectorMatch = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => 'ddeeff445566',
'thumb_ext' => 'webp',
'title' => 'Vector winner',
'slug' => 'vector-winner',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 5,
'trending_score_24h' => 5,
'trending_score_7d' => 5,
'last_vector_indexed_at' => now()->subMinutes(5),
]);
$trendingLeader = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => '778899001122',
'thumb_ext' => 'webp',
'title' => 'Trending leader',
'slug' => 'trending-leader',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 30,
'trending_score_24h' => 30,
'trending_score_7d' => 30,
]);
$sectionArtwork = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => '334455667788',
'thumb_ext' => 'webp',
'title' => 'Section artwork',
'slug' => 'section-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 6,
'trending_score_24h' => 6,
'trending_score_7d' => 6,
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $vectorMatch->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => 'ddeeff445566',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
foreach ([$seedArtwork, $vectorMatch, $trendingLeader, $sectionArtwork] as $artwork) {
DB::table('artwork_stats')->insert([
'artwork_id' => $artwork->id,
'views' => 100,
'downloads' => 10,
'favorites' => 8,
'comments_count' => 3,
'shares_count' => 1,
'views_24h' => 100,
'views_7d' => 100,
'downloads_24h' => 10,
'downloads_7d' => 10,
'shares_24h' => 1,
'comments_24h' => 3,
'favourites_24h' => 8,
'views_1h' => 20,
'downloads_1h' => 2,
'favourites_1h' => 2,
'comments_1h' => 1,
'shares_1h' => 1,
'ranking_score' => 25,
'engagement_velocity' => 8,
'heat_score' => 15,
'rating_avg' => 0,
'rating_count' => 0,
]);
}
Http::fake(function ($request) use ($seedArtwork, $vectorMatch, $sectionArtwork) {
$payload = json_decode($request->body(), true);
$url = (string) ($payload['url'] ?? '');
if (str_contains($url, 'aabbcc112233')) {
return Http::response([
'results' => [
['id' => $seedArtwork->id, 'score' => 1.0],
['id' => $vectorMatch->id, 'score' => 0.98],
],
], 200);
}
if (str_contains($url, 'ddeeff445566')) {
return Http::response([
'results' => [
['id' => $vectorMatch->id, 'score' => 1.0],
['id' => $sectionArtwork->id, 'score' => 0.91],
],
], 200);
}
return Http::response(['results' => []], 200);
});
app(SessionRecoService::class)->applyEvent(
userId: $user->id,
eventType: 'view',
artworkId: $seedArtwork->id,
categoryId: null,
occurredAt: now()->toIso8601String(),
meta: []
);
actingAs($user);
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
$response->assertOk();
$response->assertJsonPath('meta.engine', 'v2');
$response->assertJsonPath('meta.vector_influenced_count', 1);
$response->assertJsonPath('meta.local_embedding_count', 1);
$response->assertJsonPath('meta.vector_indexed_count', 1);
$response->assertJsonPath('data.0.id', $vectorMatch->id);
$response->assertJsonPath('data.0.source', 'vector');
$response->assertJsonPath('data.0.reason', 'Visually similar to art you engaged with');
$response->assertJsonPath('data.0.vector_influenced', true);
$response->assertJsonPath('data.0.has_local_embedding', true);
expect($response->json('data.0.vector_indexed_at'))->not->toBeNull();
$response->assertJsonPath('data.0.ranking_signals.vector_similarity_score', 0.98);
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', true);
expect($response->json('data.0.ranking_signals.vector_indexed_at'))->not->toBeNull();
$response->assertJsonPath('sections.0.key', 'similar_style');
$response->assertJsonPath('sections.1.key', 'you_may_also_like');
$response->assertJsonPath('sections.2.key', 'visually_related');
$response->assertJsonPath('sections.0.items.0.id', $sectionArtwork->id);
$response->assertJsonPath('sections.2.items.0.id', $sectionArtwork->id);
});

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
// Use null Scout driver so no Meilisearch calls are made
config(['scout.driver' => 'null']);
});
it('redirects unauthenticated users to login', function () {
$this->get(route('discover.following'))
->assertRedirect(route('login'));
});
it('shows empty state with fallback data when user follows nobody', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('discover.following'));
$response->assertStatus(200);
$response->assertViewHas('empty', true);
$response->assertViewHas('fallback_trending');
$response->assertViewHas('fallback_creators');
$response->assertViewHas('section', 'following');
});
it('paginates artworks from followed creators', function () {
$user = User::factory()->create();
$creator = User::factory()->create();
// user_followers has no updated_at column
DB::table('user_followers')->insert([
'user_id' => $creator->id,
'follower_id' => $user->id,
'created_at' => now(),
]);
Artwork::factory()->count(3)->create([
'user_id' => $creator->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
$response = $this->actingAs($user)->get(route('discover.following'));
$response->assertStatus(200);
$response->assertViewHas('section', 'following');
$response->assertViewMissing('empty');
});
it('does not include artworks from non-followed creators in the feed', function () {
$user = User::factory()->create();
$creator = User::factory()->create();
$stranger = User::factory()->create();
DB::table('user_followers')->insert([
'user_id' => $creator->id,
'follower_id' => $user->id,
'created_at' => now(),
]);
// Only the stranger has an artwork — creator has none
Artwork::factory()->create([
'user_id' => $stranger->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
$response = $this->actingAs($user)->get(route('discover.following'));
$response->assertStatus(200);
/** @var \Illuminate\Pagination\LengthAwarePaginator $artworks */
$artworks = $response->original->gatherData()['artworks'];
expect($artworks->total())->toBe(0);
});
it('other discover routes return 200 without Meilisearch', function () {
// Trending and fresh routes fall through to DB fallback with null driver
$this->get(route('discover.trending'))->assertStatus(200);
$this->get(route('discover.fresh'))->assertStatus(200);
});

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\ArtworkService;
use App\Services\HomepageService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
// Use null Scout driver — Meilisearch calls return empty results gracefully
config(['scout.driver' => 'null']);
// ArtworkService is not final so it can be mocked
$artworksMock = Mockery::mock(ArtworkService::class);
$artworksMock->shouldReceive('getFeaturedArtworks')
->andReturn(new LengthAwarePaginator(collect(), 0, 1))
->byDefault();
app()->instance(ArtworkService::class, $artworksMock);
});
// ── Route integration ─────────────────────────────────────────────────────────
it('home page renders 200 for guests', function () {
$this->get('/')->assertStatus(200);
});
it('home page renders 200 for authenticated users', function () {
$this->actingAs(User::factory()->create())
->get('/')
->assertStatus(200);
});
// ── HomepageService section shape ─────────────────────────────────────────────
it('guest homepage has expected sections but no from_following', function () {
$sections = app(HomepageService::class)->all();
expect($sections)->toHaveKeys(['hero', 'trending', 'fresh', 'tags', 'creators', 'news']);
expect($sections)->not->toHaveKey('from_following');
expect($sections)->not->toHaveKey('by_tags');
expect($sections)->not->toHaveKey('by_categories');
});
it('authenticated homepage contains all personalised sections', function () {
$user = User::factory()->create();
$sections = app(HomepageService::class)->allForUser($user);
expect($sections)->toHaveKeys([
'hero',
'from_following',
'trending',
'by_tags',
'by_categories',
'tags',
'creators',
'news',
'preferences',
]);
});
it('preferences section exposes top_tags and top_categories arrays', function () {
$user = User::factory()->create();
$sections = app(HomepageService::class)->allForUser($user);
expect($sections['preferences'])->toHaveKeys(['top_tags', 'top_categories']);
expect($sections['preferences']['top_tags'])->toBeArray();
expect($sections['preferences']['top_categories'])->toBeArray();
});
it('guest and auth homepages have different key sets', function () {
$user = User::factory()->create();
$guest = array_keys(app(HomepageService::class)->all());
$auth = array_keys(app(HomepageService::class)->allForUser($user));
expect($guest)->not->toEqual($auth);
expect(in_array('from_following', $auth))->toBeTrue();
expect(in_array('from_following', $guest))->toBeFalse();
});
it('homepage artwork payload uses group name and avatar for group-published artworks', function () {
$owner = User::factory()->create();
$group = Group::factory()->create([
'owner_user_id' => $owner->id,
'name' => 'Skinbase Collective',
'slug' => 'skinbase-collective',
]);
Artwork::factory()->create([
'user_id' => $owner->id,
'group_id' => $group->id,
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
'title' => 'Group Published Artwork',
'hash' => 'homepagegroupartwork',
'thumb_ext' => 'webp',
'published_at' => now()->subMinute(),
]);
Cache::flush();
$items = app(HomepageService::class)->getFreshUploads(10);
expect($items)->not->toBeEmpty()
->and($items[0]['author'])->toBe('Skinbase Collective')
->and($items[0]['author_username'])->toBe('')
->and($items[0]['published_as_type'])->toBe(Artwork::PUBLISHED_AS_GROUP)
->and($items[0]['publisher']['type'])->toBe('group')
->and($items[0]['publisher']['name'])->toBe('Skinbase Collective')
->and($items[0]['publisher']['profile_url'])->toContain('/groups/skinbase-collective');
});

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
// Disable Meilisearch and Redis during tests
config(['scout.driver' => 'null']);
});
// ── ArtworkViewController (POST /api/art/{id}/view) ──────────────────────────
it('returns 404 for a non-existent artwork on view', function () {
$this->postJson('/api/art/99999/view')->assertStatus(404);
});
it('returns 404 for a private artwork on view', function () {
$artwork = Artwork::factory()->create(['is_public' => false]);
$this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404);
});
it('returns 404 for an unapproved artwork on view', function () {
$artwork = Artwork::factory()->create(['is_approved' => false]);
$this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404);
});
it('records a view and returns ok=true on first call', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// Ensure a stats row exists with 0 views
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artwork->id,
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('logViewEvent')
->once()
->with($artwork->id, null); // null = guest (unauthenticated request)
$mock->shouldReceive('incrementViews')
->once()
->with($artwork->id, 1, true);
$response = $this->postJson("/api/art/{$artwork->id}/view");
$response->assertStatus(200)
->assertJsonPath('ok', true)
->assertJsonPath('counted', true);
});
it('skips DB increment and returns counted=false if artwork was already viewed this session', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// Mark as already viewed in the session
session()->put("art_viewed.{$artwork->id}", true);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementViews')->never();
$response = $this->postJson("/api/art/{$artwork->id}/view");
$response->assertStatus(200)
->assertJsonPath('ok', true)
->assertJsonPath('counted', false);
});
// ── ArtworkDownloadController (POST /api/art/{id}/download) ──────────────────
it('returns 404 for a non-existent artwork on download', function () {
$this->postJson('/api/art/99999/download')->assertStatus(404);
});
it('returns 404 for a private artwork on download', function () {
$artwork = Artwork::factory()->create(['is_public' => false]);
$this->postJson("/api/art/{$artwork->id}/download")->assertStatus(404);
});
it('records a download and returns ok=true with a url', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementDownloads')
->once()
->with($artwork->id, 1, true);
$response = $this->postJson("/api/art/{$artwork->id}/download");
$response->assertStatus(200)
->assertJsonPath('ok', true)
->assertJsonStructure(['ok', 'url']);
});
it('inserts a row in artwork_downloads on valid download', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// Stub the stats service so we don't need Redis
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementDownloads')->once();
$this->actingAs($user)->postJson("/api/art/{$artwork->id}/download");
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
]);
});
it('records download as guest (no user_id) when unauthenticated', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementDownloads')->once();
$this->postJson("/api/art/{$artwork->id}/download");
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => null,
]);
});
// ── Route names ───────────────────────────────────────────────────────────────
it('view endpoint route is named api.art.view', function () {
$artwork = Artwork::factory()->create([
'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(),
]);
expect(route('api.art.view', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/view");
});
it('download endpoint route is named api.art.download', function () {
$artwork = Artwork::factory()->create([
'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(),
]);
expect(route('api.art.download', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/download");
});

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
beforeEach(function () {
// Use null Scout driver so no Meilisearch calls are made
config(['scout.driver' => 'null']);
});
// ── 404 cases ─────────────────────────────────────────────────────────────────
it('returns 404 for a non-existent artwork id', function () {
$this->getJson('/api/art/99999/similar')
->assertStatus(404)
->assertJsonPath('error', 'Artwork not found');
});
it('returns 404 for a private artwork', function () {
$artwork = Artwork::factory()->create(['is_public' => false]);
$this->getJson("/api/art/{$artwork->id}/similar")
->assertStatus(404);
});
it('returns 404 for an unapproved artwork', function () {
$artwork = Artwork::factory()->create(['is_approved' => false]);
$this->getJson("/api/art/{$artwork->id}/similar")
->assertStatus(404);
});
it('returns 404 for an unpublished artwork', function () {
$artwork = Artwork::factory()->unpublished()->create();
$this->getJson("/api/art/{$artwork->id}/similar")
->assertStatus(404);
});
// ── Success cases ─────────────────────────────────────────────────────────────
it('returns a data array for a valid public artwork', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$response = $this->getJson("/api/art/{$artwork->id}/similar");
$response->assertStatus(200);
$response->assertJsonStructure(['data']);
expect($response->json('data'))->toBeArray();
});
it('the source artwork id is never present in results', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$ids = collect($this->getJson("/api/art/{$artwork->id}/similar")->json('data'))
->pluck('id')
->all();
expect($ids)->not->toContain($artwork->id);
});
it('result count does not exceed 12', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$count = count($this->getJson("/api/art/{$artwork->id}/similar")->json('data'));
// null Scout driver returns 0 results; max is 12
expect($count <= 12)->toBeTrue();
});
it('results do not include artworks by the same creator', function () {
$creatorA = User::factory()->create();
$creatorB = User::factory()->create();
$source = Artwork::factory()->create([
'user_id' => $creatorA->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// A matching artwork from a different creator
Artwork::factory()->create([
'user_id' => $creatorB->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$response = $this->getJson("/api/art/{$source->id}/similar");
$response->assertStatus(200);
$items = $response->json('data');
// With null Scout driver the search returns 0 items; if items are present
// none should belong to the source artwork's creator.
foreach ($items as $item) {
expect($item)->toHaveKeys(['id', 'title', 'slug', 'thumb', 'url', 'author_id']);
expect($item['author_id'])->not->toBe($creatorA->id);
}
expect(true)->toBeTrue(); // always at least one assertion
});

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\TrendingService;
// RefreshDatabase is applied automatically to all Feature tests via Pest.php
it('returns zero when no artworks exist', function () {
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
expect(app(TrendingService::class)->recalculate('7d'))->toBe(0);
});
it('updates trending_score_24h for artworks published within 7 days', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHours(6),
]);
$updated = app(TrendingService::class)->recalculate('24h');
expect($updated)->toBe(1);
$artwork->refresh();
expect($artwork->trending_score_24h)->toBeFloat();
expect($artwork->last_trending_calculated_at)->not->toBeNull();
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
it('updates trending_score_7d for artworks published within 30 days', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
]);
$updated = app(TrendingService::class)->recalculate('7d');
expect($updated)->toBe(1);
$artwork->refresh();
expect($artwork->trending_score_7d)->toBeFloat();
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
it('skips artworks published outside the look-back window', function () {
Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(45), // outside 30-day window
]);
expect(app(TrendingService::class)->recalculate('7d'))->toBe(0);
});
it('skips private artworks', function () {
Artwork::factory()->create([
'is_public' => false,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
});
it('skips unapproved artworks', function () {
Artwork::factory()->create([
'is_public' => true,
'is_approved' => false,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
});
it('score is always non-negative (GREATEST clamp)', function () {
// Artwork with no stats — time decay may be large, but score is clamped to ≥ 0
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(6),
]);
app(TrendingService::class)->recalculate('24h');
$artwork->refresh();
expect($artwork->trending_score_24h)->toBeGreaterThanOrEqualTo(0.0);
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
it('processes multiple artworks in a single run', function () {
Artwork::factory()->count(5)->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('7d'))->toBe(5);
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
config(['scout.driver' => 'null']);
});
// ── Helper: ensure a stats row exists ────────────────────────────────────────
function seedStats(int $artworkId, array $overrides = []): void
{
DB::table('artwork_stats')->insertOrIgnore(array_merge([
'artwork_id' => $artworkId,
'views' => 0,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => 0,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
], $overrides));
}
// ── ArtworkStatsService ───────────────────────────────────────────────────────
it('incrementViews updates views, views_24h, and views_7d', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id);
app(ArtworkStatsService::class)->incrementViews($artwork->id, 3, defer: false);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views)->toBe(3);
expect((int) $row->views_24h)->toBe(3);
expect((int) $row->views_7d)->toBe(3);
});
it('incrementDownloads updates downloads, downloads_24h, and downloads_7d', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id);
app(ArtworkStatsService::class)->incrementDownloads($artwork->id, 2, defer: false);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->downloads)->toBe(2);
expect((int) $row->downloads_24h)->toBe(2);
expect((int) $row->downloads_7d)->toBe(2);
});
it('multiple view increments accumulate across all three columns', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id);
$svc = app(ArtworkStatsService::class);
$svc->incrementViews($artwork->id, 1, defer: false);
$svc->incrementViews($artwork->id, 1, defer: false);
$svc->incrementViews($artwork->id, 1, defer: false);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views)->toBe(3);
expect((int) $row->views_24h)->toBe(3);
expect((int) $row->views_7d)->toBe(3);
});
// ── ResetWindowedStatsCommand ─────────────────────────────────────────────────
it('reset-windowed-stats --period=24h zeros views_24h', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views_24h)->toBe(0);
// 7d column is NOT touched by a 24h reset
expect((int) $row->views_7d)->toBe(200);
});
it('reset-windowed-stats --period=7d zeros views_7d', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views_7d)->toBe(0);
// 24h column is NOT touched by a 7d reset
expect((int) $row->views_24h)->toBe(50);
});
it('reset-windowed-stats recomputes downloads_24h from artwork_downloads log', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id, ['downloads_24h' => 99]); // stale value
// Insert 3 downloads within the last 24 hours
$ip = inet_pton('127.0.0.1');
DB::table('artwork_downloads')->insert([
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(1)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(6)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(12)],
]);
// Insert 2 old downloads outside the 24h window
DB::table('artwork_downloads')->insert([
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(2)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)],
]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
// Should equal exactly the 3 recent downloads, not the stale 99
expect((int) $row->downloads_24h)->toBe(3);
});
it('reset-windowed-stats recomputes downloads_7d including all downloads in 7-day window', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10)]);
seedStats($artwork->id, ['downloads_7d' => 0]);
$ip = inet_pton('127.0.0.1');
DB::table('artwork_downloads')->insert([
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(1)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(8)], // outside 7d
]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->downloads_7d)->toBe(2);
});
it('reset-windowed-stats returns failure for invalid period', function () {
$this->artisan('skinbase:reset-windowed-stats', ['--period' => 'bad'])
->assertExitCode(1);
});

View File

@@ -0,0 +1,7 @@
<?php
it('returns a successful response', function () {
$response = $this->get('/');
$response->assertStatus(200);
});

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkFeature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
use Klevze\ControlPanel\Models\Admin\AdminVerification;
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
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();
}
function adminArtwork(array $attributes = []): Artwork
{
return Artwork::factory()->create(array_merge([
'title' => 'Featured Artwork ' . fake()->unique()->words(2, true),
'slug' => 'featured-artwork-' . fake()->unique()->numberBetween(1000, 9999),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'has_missing_thumbnails' => false,
], $attributes));
}
function featureRow(Artwork $artwork, array $attributes = []): ArtworkFeature
{
return ArtworkFeature::query()->create(array_merge([
'artwork_id' => $artwork->id,
'priority' => 100,
'featured_at' => now()->subHour(),
'expires_at' => null,
'is_active' => true,
], $attributes));
}
function medalScore(Artwork $artwork, int $score30d): void
{
DB::table('artwork_medal_stats')->insert([
'artwork_id' => $artwork->id,
'gold_count' => 0,
'silver_count' => 0,
'bronze_count' => 0,
'score_total' => $score30d,
'score_7d' => $score30d,
'score_30d' => $score30d,
'last_medaled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
it('blocks non staff users from the featured artworks admin area', function (): void {
$user = User::factory()->create(['role' => 'user']);
$user->forceFill([
'isAdmin' => false,
'activated' => true,
])->save();
$this->actingAs($user)->actingAs($user, 'controlpanel')
->get(route('admin.cp.artworks.featured.main'))
->assertRedirect(route('cp.login'));
});
it('registers the featured artworks entry in the cpad menu', function (): void {
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
$editorialRoot = $sidebarMenu
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
expect($editorialRoot)->toBeInstanceOf(MenuRootItem::class);
$featuredItem = collect($editorialRoot->getItems())
->first(fn ($item): bool => ($item->name ?? null) === 'Featured Artworks');
expect($featuredItem)->not->toBeNull()
->and($featuredItem->mainRoute)->toBe('admin.cp.artworks.featured.main')
->and($featuredItem->icon)->toBe('fas fa-star');
});
it('renders the featured artworks admin index with the current winner summary', function (): void {
$admin = createControlPanelAdmin();
$owner = User::factory()->create(['username' => 'winnermaker']);
$higherMedal = adminArtwork(['user_id' => $owner->id, 'title' => 'Higher Medal Winner']);
$runnerUp = adminArtwork(['user_id' => $owner->id, 'title' => 'Runner Up']);
featureRow($higherMedal, ['priority' => 100, 'featured_at' => now()->subHour()]);
featureRow($runnerUp, ['priority' => 100, 'featured_at' => now()->subHour()]);
medalScore($higherMedal, 12);
medalScore($runnerUp, 3);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.cp.artworks.featured.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/FeaturedArtworksAdmin')
->where('winner.artwork.id', $higherMedal->id)
->where('winner.medals.score_30d', 12)
->where('winner.selection_reason', 'Tied on priority, won on higher 30-day medal score.')
->where('entries.0.is_winner', true)
->where('entries.0.artwork.id', $higherMedal->id)
->where('endpoints.store', route('admin.cp.artworks.featured.store')));
});
it('allows admins to create featured rows', function (): void {
$admin = createControlPanelAdmin();
$artwork = adminArtwork();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('admin.cp.artworks.featured.store'), [
'artwork_id' => $artwork->id,
'priority' => 220,
'featured_at' => now()->toISOString(),
'expires_at' => now()->addDay()->toISOString(),
'is_active' => true,
])
->assertOk()
->assertJsonPath('winner.artwork.id', $artwork->id)
->assertJsonPath('stats.total', 1);
$feature = ArtworkFeature::query()->firstOrFail();
expect((int) $feature->artwork_id)->toBe($artwork->id)
->and((int) $feature->priority)->toBe(220)
->and((int) $feature->created_by)->toBe($admin->id)
->and((bool) $feature->is_active)->toBeTrue();
});
it('allows admins to update featured rows', function (): void {
$admin = createControlPanelAdmin();
$feature = featureRow(adminArtwork(), [
'priority' => 50,
'featured_at' => now()->subDays(2),
'is_active' => true,
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
'priority' => 180,
'featured_at' => now()->subHour()->toISOString(),
'expires_at' => now()->addHours(6)->toISOString(),
'is_active' => false,
])
->assertOk()
->assertJsonPath('entries.0.priority', 180)
->assertJsonPath('entries.0.is_active', false);
$fresh = $feature->fresh();
expect((int) $fresh->priority)->toBe(180)
->and((bool) $fresh->is_active)->toBeFalse()
->and($fresh->expires_at)->not->toBeNull();
});
it('allows admins to activate and deactivate featured rows', function (): void {
$admin = createControlPanelAdmin();
$feature = featureRow(adminArtwork(), ['is_active' => true]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
->assertOk()
->assertJsonPath('entries.0.is_active', false);
expect($feature->fresh()->is_active)->toBeFalse();
});
it('allows admins to force and unforce the homepage hero from the featured pool', function (): void {
$admin = createControlPanelAdmin();
$owner = User::factory()->create(['username' => 'forcehero']);
$naturalWinner = adminArtwork(['user_id' => $owner->id, 'title' => 'Natural Winner']);
$forcedArtwork = adminArtwork(['user_id' => $owner->id, 'title' => 'Forced Winner']);
$naturalFeature = featureRow($naturalWinner, ['priority' => 300, 'featured_at' => now()->subHour()]);
$forcedFeature = featureRow($forcedArtwork, ['priority' => 100, 'featured_at' => now()->subDays(2)]);
medalScore($naturalWinner, 50);
medalScore($forcedArtwork, 1);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id]))
->assertOk()
->assertJsonPath('winner.artwork.id', $forcedArtwork->id)
->assertJsonPath('winner.is_force_hero', true)
->assertJsonPath('winner.selection_reason', 'Forced hero override is enabled for this featured artwork.');
expect($forcedFeature->fresh()->force_hero)->toBeTrue()
->and($naturalFeature->fresh()->force_hero)->toBeFalse()
->and(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(1);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id]))
->assertOk()
->assertJsonPath('winner.artwork.id', $naturalWinner->id)
->assertJsonPath('winner.is_force_hero', false)
->assertJsonPath('winner.selection_reason', 'Highest priority among active, eligible featured artworks.');
expect($forcedFeature->fresh()->force_hero)->toBeFalse()
->and(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(0);
});
it('returns a forced hero as the admin winner even when standard artwork eligibility fails', function (): void {
$admin = createControlPanelAdmin();
$owner = User::factory()->create(['username' => 'forceheromissingpreview']);
$naturalWinner = adminArtwork(['user_id' => $owner->id, 'title' => 'Natural Winner']);
$forcedArtwork = adminArtwork(['user_id' => $owner->id, 'title' => 'Forced Missing Preview', 'has_missing_thumbnails' => true]);
featureRow($naturalWinner, ['priority' => 300, 'featured_at' => now()->subHour()]);
$forcedFeature = featureRow($forcedArtwork, ['priority' => 100, 'featured_at' => now()->subDays(2)]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id]))
->assertOk()
->assertJsonPath('winner.artwork.id', $forcedArtwork->id)
->assertJsonPath('winner.is_force_hero', true)
->assertJsonPath('winner.selection_reason', 'Forced hero override is enabled for this featured artwork.');
expect(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(1);
});
it('allows admins to delete featured rows', function (): void {
$admin = createControlPanelAdmin();
$feature = featureRow(adminArtwork());
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
->assertOk()
->assertJsonPath('stats.total', 0);
expect(ArtworkFeature::query()->count())->toBe(0)
->and(ArtworkFeature::withTrashed()->count())->toBe(1);
});
it('marks expired and ineligible rows on the index page', function (): void {
$admin = createControlPanelAdmin();
$privateArtwork = adminArtwork([
'title' => 'Private Artwork',
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
]);
$expiredArtwork = adminArtwork(['title' => 'Expired Artwork']);
featureRow($privateArtwork, ['priority' => 300]);
featureRow($expiredArtwork, ['priority' => 200, 'expires_at' => now()->subMinute()]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.cp.artworks.featured.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/FeaturedArtworksAdmin')
->where('entries.0.artwork.id', $privateArtwork->id)
->where('entries.0.eligibility.is_eligible', false)
->where('entries.0.eligibility.reasons.0', 'Private')
->where('entries.1.artwork.id', $expiredArtwork->id)
->where('entries.1.is_expired', true)
->where('stats.expired', 1)
->where('stats.ineligible', 2));
});
it('clears homepage hero cache after create update toggle and delete actions', function (): void {
$admin = createControlPanelAdmin();
$artwork = adminArtwork();
Cache::put('homepage.hero', ['stale' => true], 600);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('admin.cp.artworks.featured.store'), [
'artwork_id' => $artwork->id,
'priority' => 100,
'featured_at' => now()->toISOString(),
'expires_at' => null,
'is_active' => true,
])
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
$feature = ArtworkFeature::query()->firstOrFail();
Cache::put('homepage.hero', ['stale' => true], 600);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
'priority' => 110,
'featured_at' => now()->addMinute()->toISOString(),
'expires_at' => now()->addDay()->toISOString(),
'is_active' => true,
])
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
Cache::put('homepage.hero', ['stale' => true], 600);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
Cache::put('homepage.hero', ['stale' => true], 600);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
});

View File

@@ -0,0 +1,127 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('Footer pages', function () {
it('shows the FAQ page', function () {
$this->get('/faq')
->assertOk()
->assertSee('Frequently Asked Questions');
});
it('FAQ page includes structured data for visible questions', function () {
$html = $this->get('/faq')
->assertOk()
->getContent();
expect($html)
->toContain('application/ld+json')
->toContain('FAQPage')
->toContain('Is Skinbase free to use?');
});
it('shows the Rules & Guidelines page', function () {
$this->get('/rules-and-guidelines')
->assertOk()
->assertSee('Rules & Guidelines');
});
it('shows the Privacy Policy page', function () {
$this->get('/privacy-policy')
->assertOk()
->assertSee('Privacy Policy');
});
it('shows the Terms of Service page', function () {
$this->get('/terms-of-service')
->assertOk()
->assertSee('Terms of Service');
});
it('shows the bug report page to guests with login prompt', function () {
$this->get('/bug-report')
->assertOk()
->assertSee('Bug Report');
});
it('shows the bug report form to authenticated users', function () {
$user = \App\Models\User::factory()->create();
$this->actingAs($user)
->get('/bug-report')
->assertOk()
->assertSee('Subject');
});
it('submits a bug report as authenticated user', function () {
$user = \App\Models\User::factory()->create();
$this->actingAs($user)
->post('/bug-report', [
'subject' => 'Test subject',
'description' => 'This is a test bug report description.',
])
->assertRedirect('/bug-report');
$this->assertDatabaseHas('bug_reports', [
'user_id' => $user->id,
'subject' => 'Test subject',
]);
});
it('rejects bug report submission from guests', function () {
$this->post('/bug-report', [
'subject' => 'Test',
'description' => 'Test description',
])->assertRedirect('/login');
});
it('shows the staff page', function () {
$this->get('/staff')
->assertOk()
->assertSee('Meet the Staff');
});
it('shows the RSS feeds info page', function () {
$this->get('/rss-feeds')
->assertOk()
->assertSee('RSS')
->assertSee('Latest Uploads');
});
it('returns XML for latest uploads feed', function () {
$this->get('/rss/latest-uploads.xml')
->assertOk()
->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
});
it('returns XML for latest skins feed', function () {
$this->get('/rss/latest-skins.xml')
->assertOk()
->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
});
it('returns XML for latest wallpapers feed', function () {
$this->get('/rss/latest-wallpapers.xml')
->assertOk()
->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
});
it('returns XML for latest photos feed', function () {
$this->get('/rss/latest-photos.xml')
->assertOk()
->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
});
it('RSS feed contains valid XML', function () {
$response = $this->get('/rss/latest-uploads.xml');
$response->assertOk();
$xml = simplexml_load_string($response->getContent());
expect($xml)->not->toBeFalse();
expect((string) $xml->channel->title)->toContain('Skinbase');
});
});

View File

@@ -0,0 +1,168 @@
<?php
use App\Models\Group;
use App\Models\GroupMember;
use App\Models\User;
use App\Policies\GroupPolicy;
use App\Services\GroupMembershipService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function attachGroupMember(Group $group, User $user, string $role): GroupMember
{
return GroupMember::query()->create([
'group_id' => $group->id,
'user_id' => $user->id,
'invited_by_user_id' => $group->owner_user_id,
'role' => $role,
'status' => Group::STATUS_ACTIVE,
'invited_at' => now(),
'accepted_at' => now(),
]);
}
it('allows contributors into studio but not management or publishing policy actions', function () {
$owner = User::factory()->create();
$contributor = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $contributor, Group::ROLE_MEMBER);
$policy = app(GroupPolicy::class);
expect($policy->viewStudio($contributor, $group))->toBeTrue()
->and($policy->update($contributor, $group))->toBeFalse()
->and($policy->manageMembers($contributor, $group))->toBeFalse()
->and($policy->publishArtworks($contributor, $group))->toBeFalse()
->and($policy->manageCollections($contributor, $group))->toBeFalse();
});
it('allows editors to publish artworks and manage collections without member administration', function () {
$owner = User::factory()->create();
$editor = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $editor, Group::ROLE_EDITOR);
$policy = app(GroupPolicy::class);
expect($policy->viewStudio($editor, $group))->toBeTrue()
->and($policy->publishArtworks($editor, $group))->toBeTrue()
->and($policy->manageCollections($editor, $group))->toBeTrue()
->and($policy->manageMembers($editor, $group))->toBeFalse()
->and($policy->archive($editor, $group))->toBeFalse();
});
it('allows admins to manage group settings and members but not archive ownership actions', function () {
$owner = User::factory()->create();
$admin = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $admin, Group::ROLE_ADMIN);
$policy = app(GroupPolicy::class);
expect($policy->viewStudio($admin, $group))->toBeTrue()
->and($policy->update($admin, $group))->toBeTrue()
->and($policy->manageMembers($admin, $group))->toBeTrue()
->and($policy->publishArtworks($admin, $group))->toBeTrue()
->and($policy->archive($admin, $group))->toBeFalse();
});
it('blocks studio access for suspended groups even for active non-owner members', function () {
$owner = User::factory()->create();
$editor = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create([
'status' => Group::LIFECYCLE_SUSPENDED,
]);
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $editor, Group::ROLE_EDITOR);
$policy = app(GroupPolicy::class);
expect($policy->viewStudio($editor, $group))->toBeFalse()
->and($policy->publishArtworks($editor, $group))->toBeFalse()
->and($policy->manageCollections($editor, $group))->toBeFalse();
});
it('keeps archive authority with the owner', function () {
$owner = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
$policy = app(GroupPolicy::class);
expect($policy->update($owner, $group))->toBeTrue()
->and($policy->manageMembers($owner, $group))->toBeTrue()
->and($policy->publishArtworks($owner, $group))->toBeTrue()
->and($policy->archive($owner, $group))->toBeTrue();
});
it('does not allow admins or editors to transfer ownership through policy update access alone', function () {
$owner = User::factory()->create();
$admin = User::factory()->create();
$editor = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $admin, Group::ROLE_ADMIN);
attachGroupMember($group, $editor, Group::ROLE_EDITOR);
$policy = app(GroupPolicy::class);
expect($policy->update($admin, $group))->toBeTrue()
->and($policy->archive($admin, $group))->toBeFalse()
->and($policy->manageMembers($editor, $group))->toBeFalse();
});
it('exposes explicit v3 event and private access policy hooks', function () {
$owner = User::factory()->create();
$editor = User::factory()->create();
$member = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $editor, Group::ROLE_EDITOR);
attachGroupMember($group, $member, Group::ROLE_MEMBER);
$policy = app(GroupPolicy::class);
expect($policy->publishEventUpdates($editor, $group))->toBeTrue()
->and($policy->viewInternalEvents($editor, $group))->toBeTrue()
->and($policy->viewPrivateProject($editor, $group))->toBeTrue()
->and($policy->participateInChallenge($member, $group))->toBeTrue()
->and($policy->publishEventUpdates($member, $group))->toBeFalse();
});
it('exposes explicit v4 release, milestone, badge, and trust policy hooks', function () {
$owner = User::factory()->create();
$admin = User::factory()->create();
$editor = User::factory()->create();
$member = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create();
app(GroupMembershipService::class)->ensureOwnerMembership($group);
attachGroupMember($group, $admin, Group::ROLE_ADMIN);
attachGroupMember($group, $editor, Group::ROLE_EDITOR);
attachGroupMember($group, $member, Group::ROLE_MEMBER);
$policy = app(GroupPolicy::class);
expect($policy->manageReleases($editor, $group))->toBeTrue()
->and($policy->publishReleases($editor, $group))->toBeTrue()
->and($policy->moveReleaseStage($editor, $group))->toBeTrue()
->and($policy->manageMilestones($editor, $group))->toBeTrue()
->and($policy->assignReleaseLead($editor, $group))->toBeTrue()
->and($policy->viewReputationDashboard($editor, $group))->toBeFalse()
->and($policy->manageBadges($admin, $group))->toBeTrue()
->and($policy->viewInternalTrustMetrics($admin, $group))->toBeTrue()
->and($policy->featureRelease($admin, $group))->toBeTrue()
->and($policy->manageReleases($member, $group))->toBeFalse()
->and($policy->viewReputationDashboard($member, $group))->toBeFalse();
});

View File

@@ -0,0 +1,19 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the groups faq page with related help links', function () {
$this->get(route('help.groups.faq'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupFaqPage')
->where('title', 'Groups FAQ')
->where('seo.canonical', route('help.groups.faq'))
->where('links.full_documentation', route('help.groups'))
->where('links.quickstart', route('help.groups.quickstart'))
->where('links.group_studio', route('studio.groups.index'))
->where('links.create_group', route('studio.groups.create'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the groups help page with real internal links', function () {
$this->get(route('help.groups'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupHelpPage')
->where('title', 'Groups Help & Guide')
->where('seo.canonical', route('help.groups'))
->where('links.groups_directory', route('groups.index'))
->where('links.create_group', route('studio.groups.create'))
->where('links.group_studio', route('studio.groups.index'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,17 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the groups quickstart page with onboarding links', function () {
$this->get(route('help.groups.quickstart'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupQuickstartPage')
->where('title', 'Groups Quickstart')
->where('seo.canonical', route('help.groups.quickstart'))
->where('links.create_group', route('studio.groups.create'))
->where('links.group_studio', route('studio.groups.index'))
->where('links.groups_directory', route('groups.index'))
->where('links.full_documentation', route('help.groups'))
);
});

View File

@@ -0,0 +1,26 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the account help page with real internal links', function () {
$this->get(route('help.account'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/AccountHelpPage')
->where('title', 'Account Settings Help')
->where('seo.canonical', route('help.account'))
->where('links.help_home', route('help'))
->where('links.help_auth', route('help.auth'))
->where('links.help_profile', route('help.profile'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.help_troubleshooting', route('help.troubleshooting'))
->where('links.profile_settings', route('dashboard.profile'))
->where('links.open_studio', route('studio.index'))
->where('links.login', route('login'))
->where('links.register', route('register'))
->where('links.password_request', route('password.request'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,27 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the auth help page with real internal links', function () {
$this->get(route('help.auth'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/AuthHelpPage')
->where('title', 'Signup & Login Help')
->where('seo.canonical', route('help.auth'))
->where('links.help_home', route('help'))
->where('links.help_profile', route('help.profile'))
->where('links.studio_help', route('help.studio'))
->where('links.groups_help', route('help.groups'))
->where('links.help_account', route('help.account'))
->where('links.help_troubleshooting', route('help.troubleshooting'))
->where('links.login', route('login'))
->where('links.register', route('register'))
->where('links.password_request', route('password.request'))
->where('links.verification_notice', route('verification.notice'))
->where('links.open_studio', route('studio.index'))
->where('links.profile_settings', route('dashboard.profile'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,24 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the cards help page with real internal links', function () {
$this->get(route('help.cards'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/CardsHelpPage')
->where('title', 'Cards Help')
->where('seo.canonical', route('help.cards'))
->where('links.help_home', route('help'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.groups_help', route('help.groups'))
->where('links.open_studio', route('studio.index'))
->where('links.studio_cards', route('studio.cards.index'))
->where('links.create_card', route('studio.cards.create'))
->where('links.cards_index', route('cards.index'))
->where('links.help_profile', route('help.profile'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,32 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the help center homepage with key platform help links', function () {
$this->get(route('help'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/HelpCenterPage')
->where('title', 'Help Center')
->where('seo.canonical', route('help'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.groups_documentation', route('help.groups'))
->where('links.groups_quickstart', route('help.groups.quickstart'))
->where('links.groups_faq', route('help.groups.faq'))
->where('links.open_studio', route('studio.index'))
->where('links.studio_home', route('studio.index'))
->where('links.upload', route('upload'))
->where('links.help_cards', route('help.cards'))
->where('links.help_profile', route('help.profile'))
->where('links.help_auth', route('help.auth'))
->where('links.help_account', route('help.account'))
->where('links.help_troubleshooting', route('help.troubleshooting'))
->where('links.cards_create', route('studio.cards.create'))
->where('links.profile_settings', route('dashboard.profile'))
->where('links.login', route('login'))
->where('links.register', route('register'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,25 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the profile help page with real internal links', function () {
$this->get(route('help.profile'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/ProfileHelpPage')
->where('title', 'Profile Help')
->where('seo.canonical', route('help.profile'))
->where('links.help_home', route('help'))
->where('links.groups_help', route('help.groups'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.cards_help', route('help.cards'))
->where('links.profile_settings', route('dashboard.profile'))
->where('links.open_studio', route('studio.index'))
->where('links.help_auth', route('help.auth'))
->where('links.login', route('login'))
->where('links.register', route('register'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,23 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the studio help page with real internal links', function () {
$this->get(route('help.studio'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/StudioHelpPage')
->where('title', 'Studio Help')
->where('seo.canonical', route('help.studio'))
->where('links.open_studio', route('studio.index'))
->where('links.studio_drafts', route('studio.drafts'))
->where('links.upload_help', route('help.upload'))
->where('links.group_studio', route('studio.groups.index'))
->where('links.groups_help', route('help.groups'))
->where('links.help_cards', route('help.cards'))
->where('links.help_profile', route('help.profile'))
->where('links.help_auth', route('help.auth'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,28 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the troubleshooting help page with real internal links', function () {
$this->get(route('help.troubleshooting'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/TroubleshootingHelpPage')
->where('title', 'Troubleshooting Help')
->where('seo.canonical', route('help.troubleshooting'))
->where('links.help_home', route('help'))
->where('links.help_auth', route('help.auth'))
->where('links.help_account', route('help.account'))
->where('links.help_profile', route('help.profile'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.groups_help', route('help.groups'))
->where('links.groups_faq', route('help.groups.faq'))
->where('links.profile_settings', route('dashboard.profile'))
->where('links.open_studio', route('studio.index'))
->where('links.login', route('login'))
->where('links.register', route('register'))
->where('links.password_request', route('password.request'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,22 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the upload help page with real internal links', function () {
$this->get(route('help.upload'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/UploadHelpPage')
->where('title', 'Upload Help')
->where('seo.canonical', route('help.upload'))
->where('links.upload', route('upload'))
->where('links.studio_help', route('help.studio'))
->where('links.studio_drafts', route('studio.drafts'))
->where('links.groups_help', route('help.groups'))
->where('links.group_studio', route('studio.groups.index'))
->where('links.help_cards', route('help.cards'))
->where('links.help_profile', route('help.profile'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -0,0 +1,81 @@
<?php
use App\Services\ArtworkService;
use Illuminate\Pagination\LengthAwarePaginator;
use Mockery;
/** Return an empty LengthAwarePaginator with the given path. */
function emptyPaginator(string $path = '/'): LengthAwarePaginator
{
return (new LengthAwarePaginator(collect(), 0, 20, 1))->setPath($path);
}
beforeEach(function () {
// Swap ArtworkService so tests need no database
$this->artworksMock = Mockery::mock(ArtworkService::class);
$this->artworksMock->shouldReceive('getFeaturedArtworks')->andReturn(emptyPaginator('/'))->byDefault();
$this->artworksMock->shouldReceive('getLatestArtworks')->andReturn(collect())->byDefault();
$this->app->instance(ArtworkService::class, $this->artworksMock);
});
it('renders the home page successfully', function () {
$this->get('/')
->assertStatus(200);
});
it('renders the home page with grid=v2 without errors', function () {
$this->get('/?grid=v2')
->assertStatus(200);
});
it('home page contains the gallery section', function () {
$this->get('/')
->assertStatus(200)
->assertSee('id="homepage-root"', false)
->assertSee('id="homepage-props"', false);
});
it('home page includes a canonical link tag', function () {
$this->get('/')
->assertStatus(200)
->assertSee('rel="canonical"', false);
});
it('home page emits unified SEO tags and structured data', function () {
$html = $this->get('/')
->assertStatus(200)
->getContent();
expect($html)
->toContain('name="description"')
->toContain('property="og:title"')
->toContain('name="twitter:card"')
->toContain('application/ld+json')
->toContain('WebSite');
});
it('home page exposes digital art in the explore navigation', function () {
$html = $this->get('/')
->assertStatus(200)
->getContent();
expect($html)
->toContain('href="/digital-art"')
->toContain('Digital Art');
});
it('home page with ?page=2 renders without errors', function () {
// getLatestArtworks() returns a plain Collection (no pagination),
// so seoNext/seoPrev for home are always null — but the page must still render cleanly.
$this->get('/?page=2')
->assertStatus(200)
->assertSee('rel="canonical"', false);
});
it('home page does not throw undefined variable errors', function () {
// If any Blade variable is undefined an exception is thrown → non-200 status
// This test explicitly guards against regressions on $gridV2 / $seoCanonical
expect(
fn () => $this->get('/')->assertStatus(200)
)->not->toThrow(\ErrorException::class);
});

View File

@@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\HomepageService;
use App\Services\ArtworkService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function makeFeaturedArtwork(array $attributes = []): Artwork
{
return Artwork::factory()->create(array_merge([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
], $attributes));
}
test('featured hero ordering uses recent medal score as tie-break inside the same priority', function () {
$owner = User::factory()->create();
$artworkA = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'A']);
$artworkB = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'B']);
foreach ([$artworkA, $artworkB] as $artwork) {
DB::table('artwork_features')->insert([
'artwork_id' => $artwork->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 100,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
}
DB::table('artwork_medal_stats')->insert([
'artwork_id' => $artworkA->id,
'gold_count' => 0,
'silver_count' => 1,
'bronze_count' => 0,
'score_total' => 3,
'score_7d' => 3,
'score_30d' => 3,
'last_medaled_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('artwork_medal_stats')->insert([
'artwork_id' => $artworkB->id,
'gold_count' => 1,
'silver_count' => 0,
'bronze_count' => 0,
'score_total' => 5,
'score_7d' => 5,
'score_30d' => 5,
'last_medaled_at' => now()->subHour(),
'created_at' => now(),
'updated_at' => now(),
]);
$featured = app(ArtworkService::class)->getFeaturedArtworks(null, 1);
expect($featured->items()[0]->id)->toBe($artworkB->id);
});
test('featured query excludes inactive and expired feature rows', function () {
$owner = User::factory()->create();
$activeArtwork = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Active']);
$inactiveArtwork = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Inactive']);
$expiredArtwork = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Expired']);
DB::table('artwork_features')->insert([
[
'artwork_id' => $activeArtwork->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 50,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
[
'artwork_id' => $inactiveArtwork->id,
'featured_at' => now(),
'expires_at' => null,
'priority' => 999,
'label' => null,
'note' => null,
'is_active' => false,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
[
'artwork_id' => $expiredArtwork->id,
'featured_at' => now(),
'expires_at' => now()->subMinute(),
'priority' => 999,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
]);
$featuredIds = app(ArtworkService::class)
->getFeaturedArtworks(null, 10)
->getCollection()
->pluck('id')
->all();
expect($featuredIds)->toContain($activeArtwork->id)
->and($featuredIds)->not->toContain($inactiveArtwork->id)
->and($featuredIds)->not->toContain($expiredArtwork->id);
});
test('featured hero sorts by priority before featured_at', function () {
$owner = User::factory()->create();
$higherPriority = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Higher Priority']);
$newerFeaturedAt = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Newer Featured']);
DB::table('artwork_features')->insert([
[
'artwork_id' => $higherPriority->id,
'featured_at' => now()->subDays(2),
'expires_at' => null,
'priority' => 200,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
[
'artwork_id' => $newerFeaturedAt->id,
'featured_at' => now(),
'expires_at' => null,
'priority' => 100,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
]);
$featured = app(ArtworkService::class)->getFeaturedArtworks(null, 1);
expect($featured->items()[0]->id)->toBe($higherPriority->id);
});
test('force hero overrides normal homepage eligibility filters without leaking into the public featured listing', function () {
$owner = User::factory()->create();
$forcedHero = makeFeaturedArtwork([
'user_id' => $owner->id,
'title' => 'Forced Hero',
'has_missing_thumbnails' => true,
]);
$naturalWinner = makeFeaturedArtwork([
'user_id' => $owner->id,
'title' => 'Natural Winner',
]);
DB::table('artwork_features')->insert([
[
'artwork_id' => $forcedHero->id,
'featured_at' => now()->subDays(2),
'expires_at' => null,
'priority' => 50,
'label' => null,
'note' => null,
'is_active' => true,
'force_hero' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
[
'artwork_id' => $naturalWinner->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 500,
'label' => null,
'note' => null,
'is_active' => true,
'force_hero' => false,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
]);
$winner = app(ArtworkService::class)->getFeaturedArtworkWinner();
$featuredIds = app(ArtworkService::class)
->getFeaturedArtworks(null, 10)
->getCollection()
->pluck('id')
->all();
expect($winner?->id)->toBe($forcedHero->id)
->and($featuredIds)->toContain($naturalWinner->id)
->and($featuredIds)->not->toContain($forcedHero->id);
});
test('homepage hero payload uses the forced hero artwork when one is set', function () {
$owner = User::factory()->create();
$forcedHero = makeFeaturedArtwork([
'user_id' => $owner->id,
'title' => 'Forced Homepage Hero',
'has_missing_thumbnails' => true,
]);
$naturalWinner = makeFeaturedArtwork([
'user_id' => $owner->id,
'title' => 'Natural Homepage Winner',
]);
DB::table('artwork_features')->insert([
[
'artwork_id' => $forcedHero->id,
'featured_at' => now()->subDays(2),
'expires_at' => null,
'priority' => 10,
'label' => null,
'note' => null,
'is_active' => true,
'force_hero' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
[
'artwork_id' => $naturalWinner->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 500,
'label' => null,
'note' => null,
'is_active' => true,
'force_hero' => false,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
],
]);
$hero = app(HomepageService::class)->getHeroArtwork();
expect($hero)->not->toBeNull()
->and($hero['id'])->toBe($forcedHero->id)
->and($hero['title'])->toBe('Forced Homepage Hero');
});
test('community favorites returns artworks ordered by recent medal score', function () {
$owner = User::factory()->create();
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
$runnerUp = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Runner Up', 'published_at' => now()->subDays(2)]);
DB::table('artwork_medal_stats')->insert([
[
'artwork_id' => $runnerUp->id,
'gold_count' => 0,
'silver_count' => 2,
'bronze_count' => 0,
'score_total' => 6,
'score_7d' => 6,
'score_30d' => 6,
'last_medaled_at' => now()->subHour(),
'created_at' => now(),
'updated_at' => now(),
],
[
'artwork_id' => $leader->id,
'gold_count' => 2,
'silver_count' => 0,
'bronze_count' => 0,
'score_total' => 10,
'score_7d' => 10,
'score_30d' => 10,
'last_medaled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
],
]);
$results = app(HomepageService::class)->getCommunityFavorites(8);
expect($results)->toHaveCount(2)
->and($results[0]['id'])->toBe($leader->id)
->and($results[1]['id'])->toBe($runnerUp->id);
});
test('hall of fame returns artworks ordered by all time medal score', function () {
$owner = User::factory()->create();
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Legend']);
$runnerUp = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Veteran']);
DB::table('artwork_medal_stats')->insert([
[
'artwork_id' => $runnerUp->id,
'gold_count' => 1,
'silver_count' => 1,
'bronze_count' => 1,
'score_total' => 9,
'score_7d' => 0,
'score_30d' => 0,
'last_medaled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
],
[
'artwork_id' => $leader->id,
'gold_count' => 3,
'silver_count' => 0,
'bronze_count' => 0,
'score_total' => 15,
'score_7d' => 0,
'score_30d' => 0,
'last_medaled_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
]);
$results = app(HomepageService::class)->getHallOfFame(8);
expect($results)->toHaveCount(2)
->and($results[0]['id'])->toBe($leader->id)
->and($results[1]['id'])->toBe($runnerUp->id);
});

View File

@@ -0,0 +1,12 @@
<?php
it('leaderboard exposes normalized seo props to inertia', function () {
$this->get('/leaderboard')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Leaderboard/LeaderboardPage')
->where('seo.title', 'Top Creators & Artworks Leaderboard — Skinbase')
->where('seo.description', fn ($value) => is_string($value) && str_contains($value, 'leading creators'))
->where('seo.robots', 'index,follow')
->where('seo.canonical', url('/leaderboard')));
});

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
it('redirects legacy received comments urls to the canonical dashboard page', function () {
$owner = User::factory()->create();
$this->actingAs($owner)
->get('/recieved-comments')
->assertRedirect('/dashboard/comments/received')
->assertStatus(301);
$this->actingAs($owner)
->get('/received-comments')
->assertRedirect('/dashboard/comments/received')
->assertStatus(301);
});
it('renders the canonical received comments dashboard page for an authenticated user', function () {
$owner = User::factory()->create();
$commenter = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $owner->id,
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDay(),
]);
ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $commenter->id,
'content' => 'Legacy comment regression test',
'raw_content' => 'Legacy comment regression test',
'rendered_content' => '<p>Legacy comment regression test</p>',
'is_approved' => true,
]);
$response = $this->actingAs($owner)->get('/dashboard/comments/received');
$response
->assertOk()
->assertSee('Received Comments', false)
->assertSee('Total comments', false)
->assertSee('Legacy comment regression test', false);
});

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
beforeEach(function (): void {
config()->set('app.url', 'http://skinbase26.test');
});
it('redirects a username subdomain root to the canonical profile URL', function () {
User::factory()->create([
'username' => 'gregor',
]);
$response = app(Kernel::class)->handle(
Request::create('/', 'GET', ['tab' => 'favourites'], [], [], ['HTTP_HOST' => 'gregor.skinbase26.test'])
);
expect($response->getStatusCode())->toBe(301);
expect($response->headers->get('location'))->toBe('http://skinbase26.test/@gregor?tab=favourites');
});
it('redirects the legacy username subdomain gallery path to the canonical profile gallery URL', function () {
User::factory()->create([
'username' => 'gregor',
]);
$response = app(Kernel::class)->handle(
Request::create('/gallery', 'GET', [], [], [], ['HTTP_HOST' => 'gregor.skinbase26.test'])
);
expect($response->getStatusCode())->toBe(301);
expect($response->headers->get('location'))->toBe('http://skinbase26.test/@gregor/gallery');
});
it('redirects an old username subdomain to the canonical profile URL for the renamed user', function () {
$user = User::factory()->create([
'username' => 'gregor',
]);
DB::table('username_redirects')->insert([
'old_username' => 'oldgregor',
'new_username' => 'gregor',
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$response = app(Kernel::class)->handle(
Request::create('/', 'GET', [], [], [], ['HTTP_HOST' => 'oldgregor.skinbase26.test'])
);
expect($response->getStatusCode())->toBe(301);
expect($response->headers->get('location'))->toBe('http://skinbase26.test/@gregor');
});
it('does not treat reserved subdomains as profile hosts', function () {
$this->call('GET', '/sections', [], [], [], ['HTTP_HOST' => 'www.skinbase26.test'])
->assertRedirect('/categories');
});

View File

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

Some files were not shown because too many files have changed in this diff Show More