Save workspace changes
This commit is contained in:
39
.deploy/artwork-evolution-release/tests/.auth/admin.json
Normal file
39
.deploy/artwork-evolution-release/tests/.auth/admin.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createArtworkBrowserAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('remembers the selected content type and root scope on the categories browser', function (): void {
|
||||
$admin = createArtworkBrowserAdmin();
|
||||
|
||||
$skins = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skin uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$wallpapers = ContentType::query()->create([
|
||||
'name' => 'Wallpapers',
|
||||
'slug' => 'wallpapers',
|
||||
'description' => 'Wallpaper uploads',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$selectedRoot = Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Skins Root Selected',
|
||||
'slug' => 'skins-root-selected',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $selectedRoot->id,
|
||||
'name' => 'Skin Child Alpha',
|
||||
'slug' => 'skin-child-alpha',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $selectedRoot->id,
|
||||
'name' => 'Skin Child Beta',
|
||||
'slug' => 'skin-child-beta',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $wallpapers->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Wallpaper Root Hidden',
|
||||
'slug' => 'wallpaper-root-hidden',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.categories.main', [
|
||||
'content_type_id' => $skins->id,
|
||||
'root_category_id' => $selectedRoot->id,
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSessionHas('cp.artworks.categories.filters.content_type_id', (string) $skins->id)
|
||||
->assertSessionHas('cp.artworks.categories.filters.root_category_id', (string) $selectedRoot->id)
|
||||
->assertSee('Subcategories of Skins Root Selected')
|
||||
->assertSee('Skin Child Alpha')
|
||||
->assertSee('Skin Child Beta')
|
||||
->assertDontSee('Wallpaper Root Hidden');
|
||||
});
|
||||
|
||||
it('reorders only the currently selected sibling level', function (): void {
|
||||
$admin = createArtworkBrowserAdmin();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skin uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$firstRoot = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'First Root',
|
||||
'slug' => 'first-root',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$secondRoot = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Second Root',
|
||||
'slug' => 'second-root',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$childOne = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'name' => 'Child One',
|
||||
'slug' => 'child-one',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$childTwo = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'name' => 'Child Two',
|
||||
'slug' => 'child-two',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$otherBranchChild = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $secondRoot->id,
|
||||
'name' => 'Other Branch Child',
|
||||
'slug' => 'other-branch-child',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.categories.reorder-tree'), [
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'data' => [
|
||||
['id' => $childTwo->id],
|
||||
['id' => $childOne->id],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'status' => 200,
|
||||
'message' => 'Category order updated.',
|
||||
]);
|
||||
|
||||
expect($childOne->fresh()->sort_order)->toBe(2)
|
||||
->and($childTwo->fresh()->sort_order)->toBe(1)
|
||||
->and($otherBranchChild->fresh()->sort_order)->toBe(1)
|
||||
->and($otherBranchChild->fresh()->parent_id)->toBe($secondRoot->id);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createArtworkTaxonomyAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('stores plain ampersands when categories are updated in control panel', function (): void {
|
||||
$admin = createArtworkTaxonomyAdmin();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Anime',
|
||||
'slug' => 'anime',
|
||||
'description' => 'Initial description',
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.cp.artworks.categories.update', $category->id), [
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => '',
|
||||
'name' => 'Anime & Manga',
|
||||
'slug' => 'anime',
|
||||
'description' => 'Anime & Manga artwork',
|
||||
'image' => '',
|
||||
'sort_order' => 1,
|
||||
'is_active' => '1',
|
||||
])
|
||||
->assertRedirect(route('admin.cp.artworks.categories.main'));
|
||||
|
||||
$category->refresh();
|
||||
|
||||
expect(DB::table('categories')->where('id', $category->id)->value('name'))->toBe('Anime & Manga')
|
||||
->and(DB::table('categories')->where('id', $category->id)->value('description'))->toBe('Anime & Manga artwork')
|
||||
->and($category->name)->toBe('Anime & Manga')
|
||||
->and($category->description)->toBe('Anime & Manga artwork');
|
||||
});
|
||||
|
||||
it('renders legacy encoded category names decoded in control panel edit form', function (): void {
|
||||
$admin = createArtworkTaxonomyAdmin();
|
||||
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$categoryId = DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Anime &amp; Manga',
|
||||
'slug' => 'anime-manga',
|
||||
'description' => 'Anime &amp; Manga artwork',
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$html = $this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.categories.edit', $categoryId))
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($html)
|
||||
->toContain('value="Anime & Manga"')
|
||||
->not->toContain('value="Anime &amp; Manga"')
|
||||
->toContain('Anime & Manga artwork')
|
||||
->not->toContain('Anime &amp; Manga artwork');
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Enums\ModerationDomainStatus;
|
||||
use App\Enums\ModerationRuleType;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationFeedback;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ContentModerationActionLog;
|
||||
use App\Models\ContentModerationCluster;
|
||||
use App\Models\ContentModerationDomain;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Models\ContentModerationRule;
|
||||
use App\Models\UserProfile;
|
||||
use App\Models\UserSocialLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Moderation\ContentModerationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createControlPanelAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('loads the cpad moderation list and detail screens for admins', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id + 100,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::ConfirmedSpam->value,
|
||||
'severity' => 'critical',
|
||||
'score' => 120,
|
||||
'content_hash' => hash('sha256', 'spam-description-prior'),
|
||||
'scanner_version' => '2.0',
|
||||
'content_snapshot' => 'Earlier spam finding',
|
||||
]);
|
||||
|
||||
ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id + 101,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'medium',
|
||||
'score' => 45,
|
||||
'content_hash' => hash('sha256', 'spam-description-pending'),
|
||||
'scanner_version' => '2.0',
|
||||
'content_snapshot' => 'Pending review finding',
|
||||
]);
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'high',
|
||||
'score' => 95,
|
||||
'content_hash' => hash('sha256', 'spam-description'),
|
||||
'scanner_version' => '1.0',
|
||||
'reasons_json' => ['Contains spam keywords'],
|
||||
'matched_links_json' => ['https://promo.pornsite.com'],
|
||||
'matched_domains_json' => ['promo.pornsite.com'],
|
||||
'matched_keywords_json' => ['buy followers'],
|
||||
'content_snapshot' => 'Buy followers now',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.main'))
|
||||
->assertOk()
|
||||
->assertSee('Content Moderation')
|
||||
->assertSee((string) $finding->id);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.show', $finding))
|
||||
->assertOk()
|
||||
->assertSee('Buy followers now')
|
||||
->assertSee('Contains spam keywords')
|
||||
->assertSee('Related Findings')
|
||||
->assertSee('Confirmed Spam')
|
||||
->assertSee('Pending Findings');
|
||||
});
|
||||
|
||||
it('supports sortable moderation list columns in cpad', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'critical',
|
||||
'score' => 120,
|
||||
'content_hash' => hash('sha256', 'first'),
|
||||
'scanner_version' => '1.0',
|
||||
'content_snapshot' => 'critical finding',
|
||||
]);
|
||||
|
||||
ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id + 1,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'medium',
|
||||
'score' => 35,
|
||||
'content_hash' => hash('sha256', 'second'),
|
||||
'scanner_version' => '1.0',
|
||||
'content_snapshot' => 'medium finding',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.main', ['sort' => 'score', 'direction' => 'asc']))
|
||||
->assertOk()
|
||||
->assertSee('Score')
|
||||
->assertSee('ASC');
|
||||
});
|
||||
|
||||
it('updates finding review status from the cpad moderation actions', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'high',
|
||||
'score' => 95,
|
||||
'content_hash' => hash('sha256', 'spam-description'),
|
||||
'scanner_version' => '1.0',
|
||||
'content_snapshot' => 'Buy followers now',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.safe', $finding), ['admin_notes' => 'false positive'])
|
||||
->assertRedirect();
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(ModerationStatus::ReviewedSafe)
|
||||
->and($finding->admin_notes)->toBe('false positive')
|
||||
->and($finding->reviewed_by)->toBe($admin->id);
|
||||
});
|
||||
|
||||
it('can hide flagged artwork comments from the cpad moderation screen', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
$comment = ArtworkComment::factory()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
]);
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkComment->value,
|
||||
'content_id' => $comment->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $comment->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'critical',
|
||||
'score' => 120,
|
||||
'content_hash' => hash('sha256', 'spam-comment'),
|
||||
'scanner_version' => '1.0',
|
||||
'content_snapshot' => $comment->raw_content,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.hide', $finding), ['admin_notes' => 'hidden by moderation'])
|
||||
->assertRedirect();
|
||||
|
||||
expect($comment->fresh()->is_approved)->toBeFalse()
|
||||
->and($finding->fresh()->status)->toBe(ModerationStatus::ConfirmedSpam)
|
||||
->and($finding->fresh()->action_taken)->toBe('hide_comment');
|
||||
});
|
||||
|
||||
it('loads the v2 moderation dashboard, domains, rules, and actions pages', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
|
||||
$domain = ContentModerationDomain::query()->create([
|
||||
'domain' => 'promo.pornsite.com',
|
||||
'status' => ModerationDomainStatus::Blocked,
|
||||
'times_seen' => 3,
|
||||
'times_flagged' => 2,
|
||||
'times_confirmed_spam' => 1,
|
||||
]);
|
||||
|
||||
$rule = ContentModerationRule::query()->create([
|
||||
'type' => ModerationRuleType::SuspiciousKeyword,
|
||||
'value' => 'rare promo blast',
|
||||
'enabled' => true,
|
||||
'weight' => 20,
|
||||
'created_by' => $admin->id,
|
||||
]);
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkComment->value,
|
||||
'content_id' => 99,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'high',
|
||||
'score' => 95,
|
||||
'content_hash' => hash('sha256', 'finding'),
|
||||
'scanner_version' => '2.0',
|
||||
'content_snapshot' => 'spam snapshot',
|
||||
]);
|
||||
|
||||
ContentModerationActionLog::query()->create([
|
||||
'finding_id' => $finding->id,
|
||||
'target_type' => 'finding',
|
||||
'target_id' => $finding->id,
|
||||
'action_type' => 'rescan',
|
||||
'actor_type' => 'admin',
|
||||
'actor_id' => $admin->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('Content Moderation Dashboard')
|
||||
->assertSee('Top Flagged Domains');
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.domains'))
|
||||
->assertOk()
|
||||
->assertSee($domain->domain);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.rules'))
|
||||
->assertOk()
|
||||
->assertSee($rule->value);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.actions'))
|
||||
->assertOk()
|
||||
->assertSee('rescan');
|
||||
});
|
||||
|
||||
it('supports proactive domain creation and domain detail inspection from cpad', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.domains.store'), [
|
||||
'domain' => 'promo.example.com',
|
||||
'status' => ModerationDomainStatus::Blocked->value,
|
||||
'notes' => 'Manual blocklist entry',
|
||||
]);
|
||||
|
||||
$domain = ContentModerationDomain::query()->where('domain', 'promo.example.com')->firstOrFail();
|
||||
|
||||
ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'high',
|
||||
'score' => 95,
|
||||
'content_hash' => hash('sha256', 'domain-linked-finding'),
|
||||
'scanner_version' => '2.0',
|
||||
'matched_domains_json' => ['promo.example.com'],
|
||||
'content_snapshot' => 'Linked to promo domain',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.site.content-moderation.domains.show', $domain));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.domains.show', $domain))
|
||||
->assertOk()
|
||||
->assertSee('promo.example.com')
|
||||
->assertSee('Linked Findings')
|
||||
->assertSee('Manual blocklist entry');
|
||||
});
|
||||
|
||||
it('applies admin managed domains to moderation analysis', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.domains.store'), [
|
||||
'domain' => 'promo.example.com',
|
||||
'status' => ModerationDomainStatus::Blocked->value,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$result = app(ContentModerationService::class)->analyze('Visit https://promo.example.com/deal right now.', [
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
|
||||
expect($result->matchedDomains)->toContain('promo.example.com')
|
||||
->and($result->ruleHits)->toHaveKey('blocked_domain')
|
||||
->and($result->status)->toBe(ModerationStatus::Pending);
|
||||
});
|
||||
|
||||
it('applies admin managed rules to moderation analysis', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.rules.store'), [
|
||||
'type' => ModerationRuleType::SuspiciousKeyword->value,
|
||||
'value' => 'rare promo blast',
|
||||
'enabled' => '1',
|
||||
'weight' => 20,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$result = app(ContentModerationService::class)->analyze('This rare promo blast just dropped.');
|
||||
|
||||
expect($result->matchedKeywords)->toContain('rare promo blast')
|
||||
->and($result->ruleHits)->toHaveKey('suspicious_keyword')
|
||||
->and($result->score)->toBeGreaterThan(0)
|
||||
->and($result->reasons)->toContain('Contains suspicious keyword(s): rare promo blast');
|
||||
});
|
||||
|
||||
it('reports partial bulk action failures explicitly', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$domainFinding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'high',
|
||||
'score' => 80,
|
||||
'content_hash' => hash('sha256', 'bulk-domain-finding'),
|
||||
'scanner_version' => '2.0',
|
||||
'matched_domains_json' => ['promo.bulk-test.com'],
|
||||
'content_snapshot' => 'Has a domain',
|
||||
]);
|
||||
|
||||
$noDomainFinding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkDescription->value,
|
||||
'content_id' => $artwork->id + 1,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'medium',
|
||||
'score' => 40,
|
||||
'content_hash' => hash('sha256', 'bulk-no-domain-finding'),
|
||||
'scanner_version' => '2.0',
|
||||
'matched_domains_json' => [],
|
||||
'content_snapshot' => 'No domain here',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.bulk'), [
|
||||
'action' => 'block_domains',
|
||||
'finding_ids' => [$domainFinding->id, $noDomainFinding->id],
|
||||
])
|
||||
->assertRedirect(route('admin.site.content-moderation.main'))
|
||||
->assertSessionHas('msg_success', 'Processed 1 moderation item(s).')
|
||||
->assertSessionHas('msg_warning');
|
||||
|
||||
expect(ContentModerationDomain::query()->where('domain', 'promo.bulk-test.com')->where('status', ModerationDomainStatus::Blocked->value)->exists())->toBeTrue()
|
||||
->and(ContentModerationActionLog::query()->where('target_type', 'domain')->where('target_id', ContentModerationDomain::query()->where('domain', 'promo.bulk-test.com')->value('id'))->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('restores auto hidden comments from the cpad moderation screen', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
$comment = ArtworkComment::factory()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'is_approved' => false,
|
||||
]);
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkComment->value,
|
||||
'content_id' => $comment->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $comment->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'critical',
|
||||
'score' => 120,
|
||||
'content_hash' => hash('sha256', 'spam-comment-restored'),
|
||||
'scanner_version' => '2.0',
|
||||
'content_snapshot' => $comment->raw_content,
|
||||
'is_auto_hidden' => true,
|
||||
'auto_action_taken' => 'auto_hide_comment',
|
||||
'auto_hidden_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.restore', $finding), ['admin_notes' => 'restored after review'])
|
||||
->assertRedirect();
|
||||
|
||||
expect($comment->fresh()->is_approved)->toBeTrue()
|
||||
->and($finding->fresh()->is_auto_hidden)->toBeFalse()
|
||||
->and($finding->fresh()->restored_by)->toBe($admin->id);
|
||||
});
|
||||
|
||||
it('loads the v3 moderation queue, cluster, user, policy, and feedback screens', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$flaggedUser = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create(['user_id' => $flaggedUser->id]);
|
||||
|
||||
UserProfile::query()->create([
|
||||
'user_id' => $flaggedUser->id,
|
||||
'about' => 'Profile with promotional links',
|
||||
]);
|
||||
|
||||
UserSocialLink::query()->create([
|
||||
'user_id' => $flaggedUser->id,
|
||||
'platform' => 'website',
|
||||
'url' => 'https://promo.cluster-test.com',
|
||||
]);
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::UserProfileLink->value,
|
||||
'content_id' => UserSocialLink::query()->where('user_id', $flaggedUser->id)->value('id'),
|
||||
'content_target_type' => UserSocialLink::class,
|
||||
'content_target_id' => UserSocialLink::query()->where('user_id', $flaggedUser->id)->value('id'),
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $flaggedUser->id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'critical',
|
||||
'score' => 140,
|
||||
'priority_score' => 99,
|
||||
'review_bucket' => 'urgent',
|
||||
'policy_name' => 'strict_seo_protection',
|
||||
'campaign_key' => 'campaign:test-cluster',
|
||||
'cluster_reason' => 'Shared promotional domain and keywords',
|
||||
'cluster_score' => 88,
|
||||
'content_hash' => hash('sha256', 'cluster-finding'),
|
||||
'scanner_version' => '3.0',
|
||||
'matched_domains_json' => ['promo.cluster-test.com'],
|
||||
'matched_keywords_json' => ['promo blast'],
|
||||
'content_snapshot' => 'Visit https://promo.cluster-test.com for promo blast offers',
|
||||
'ai_label' => 'spam',
|
||||
'ai_suggested_action' => 'review',
|
||||
'ai_confidence' => 91,
|
||||
'ai_provider' => 'heuristic',
|
||||
]);
|
||||
|
||||
ContentModerationCluster::query()->create([
|
||||
'campaign_key' => 'campaign:test-cluster',
|
||||
'cluster_reason' => 'Shared promotional domain and keywords',
|
||||
'cluster_score' => 88,
|
||||
]);
|
||||
|
||||
ContentModerationFeedback::query()->create([
|
||||
'finding_id' => $finding->id,
|
||||
'actor_id' => $admin->id,
|
||||
'feedback_type' => 'reviewed_safe',
|
||||
'notes' => 'Operator reviewed queue behaviour',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.priority'))
|
||||
->assertOk()
|
||||
->assertSee('Priority Findings')
|
||||
->assertSee((string) $finding->id);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.clusters'))
|
||||
->assertOk()
|
||||
->assertSee('Campaign Clusters')
|
||||
->assertSee('campaign:test-cluster');
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.clusters.show', ['campaignKey' => 'campaign:test-cluster']))
|
||||
->assertOk()
|
||||
->assertSee('Cluster Findings')
|
||||
->assertSee('Shared promotional domain and keywords');
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.users'))
|
||||
->assertOk()
|
||||
->assertSee('User Moderation Profiles')
|
||||
->assertSee($flaggedUser->username ?? $flaggedUser->name);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.users.show', ['user' => $flaggedUser->id]))
|
||||
->assertOk()
|
||||
->assertSee('Profile Summary')
|
||||
->assertSee($flaggedUser->username ?? $flaggedUser->name);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.policies'))
|
||||
->assertOk()
|
||||
->assertSee('Moderation Policies')
|
||||
->assertSee('strict_seo_protection');
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.site.content-moderation.feedback'))
|
||||
->assertOk()
|
||||
->assertSee('Reviewer Feedback')
|
||||
->assertSee('Operator reviewed queue behaviour');
|
||||
});
|
||||
|
||||
it('marks findings as false positives from the cpad moderation screen', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$finding = ContentModerationFinding::query()->create([
|
||||
'content_type' => ModerationContentType::ArtworkTitle->value,
|
||||
'content_id' => $artwork->id,
|
||||
'content_target_type' => Artwork::class,
|
||||
'content_target_id' => $artwork->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'status' => ModerationStatus::Pending->value,
|
||||
'severity' => 'high',
|
||||
'score' => 85,
|
||||
'priority_score' => 80,
|
||||
'content_hash' => hash('sha256', 'artwork-title-false-positive'),
|
||||
'scanner_version' => '3.0',
|
||||
'content_snapshot' => 'Totally legitimate artwork title',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.site.content-moderation.false-positive', $finding), ['admin_notes' => 'Safe brand reference'])
|
||||
->assertRedirect(route('admin.site.content-moderation.show', $finding));
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->is_false_positive)->toBeTrue()
|
||||
->and($finding->false_positive_count)->toBeGreaterThanOrEqual(1)
|
||||
->and($finding->admin_notes)->toBe('Safe brand reference')
|
||||
->and(ContentModerationFeedback::query()->where('finding_id', $finding->id)->where('feedback_type', 'false_positive')->exists())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin discovery feedback report includes negative feedback and undo metrics', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
$previousDate = now()->subDay()->toDateString();
|
||||
$date = now()->toDateString();
|
||||
|
||||
$recordEvent = function (string $eventDate, string $eventType, array $meta = []) use ($user, $artwork) {
|
||||
$algoVersion = (string) ($meta['algo_version'] ?? 'clip-cosine-v2-adaptive');
|
||||
unset($meta['algo_version']);
|
||||
|
||||
DB::table('user_discovery_events')->insert([
|
||||
'event_id' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'category_id' => null,
|
||||
'event_type' => $eventType,
|
||||
'event_version' => 'event-v1',
|
||||
'algo_version' => $algoVersion,
|
||||
'weight' => 1.0,
|
||||
'event_date' => $eventDate,
|
||||
'occurred_at' => now(),
|
||||
'meta' => json_encode(array_merge([
|
||||
'gallery_type' => 'for-you',
|
||||
'surface' => 'for-you',
|
||||
], $meta), JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
};
|
||||
|
||||
$recordEvent($previousDate, 'view');
|
||||
$recordEvent($previousDate, 'click');
|
||||
$recordEvent($previousDate, 'favorite');
|
||||
$recordEvent($previousDate, 'hide_artwork', ['reason' => 'not_relevant']);
|
||||
$recordEvent($previousDate, 'dislike_tag', ['tag_slug' => 'abstract']);
|
||||
$recordEvent($previousDate, 'view', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($previousDate, 'click', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($previousDate, 'favorite', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
|
||||
$recordEvent($date, 'view');
|
||||
$recordEvent($date, 'click');
|
||||
$recordEvent($date, 'favorite');
|
||||
$recordEvent($date, 'download');
|
||||
$recordEvent($date, 'hide_artwork', ['reason' => 'not_relevant']);
|
||||
$recordEvent($date, 'unhide_artwork', ['reason' => 'undo']);
|
||||
$recordEvent($date, 'view', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($date, 'click', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
|
||||
$recordEvent($date, 'hide_artwork', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1', 'reason' => 'not_relevant']);
|
||||
$recordEvent($date, 'dislike_tag', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1', 'tag_slug' => 'portrait']);
|
||||
|
||||
$this->artisan('analytics:aggregate-discovery-feedback', ['--date' => $previousDate])
|
||||
->assertExitCode(0);
|
||||
$this->artisan('analytics:aggregate-discovery-feedback', ['--date' => $date])
|
||||
->assertExitCode(0);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->getJson('/api/admin/reports/discovery-feedback?from=' . $previousDate . '&to=' . $date . '&limit=10');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('overview.views', 4);
|
||||
$response->assertJsonPath('overview.clicks', 4);
|
||||
$response->assertJsonPath('overview.feedback_actions', 4);
|
||||
$response->assertJsonPath('overview.hidden_artworks', 3);
|
||||
$response->assertJsonPath('overview.disliked_tags', 2);
|
||||
$response->assertJsonPath('overview.negative_feedback_actions', 5);
|
||||
$response->assertJsonPath('overview.undo_hidden_artworks', 1);
|
||||
$response->assertJsonPath('overview.undo_disliked_tags', 0);
|
||||
$response->assertJsonPath('overview.undo_actions', 1);
|
||||
$response->assertJsonPath('daily_feedback.0.negative_feedback_actions', 2);
|
||||
$response->assertJsonPath('daily_feedback.1.negative_feedback_actions', 3);
|
||||
$response->assertJsonPath('daily_feedback.1.undo_actions', 1);
|
||||
$response->assertJsonPath('trend_summary.latest_day.date', $date);
|
||||
$response->assertJsonPath('trend_summary.previous_day.date', $previousDate);
|
||||
$response->assertJsonPath('trend_summary.rolling_7d_average.feedback_actions', 2);
|
||||
$response->assertJsonPath('trend_summary.rolling_7d_average.negative_feedback_actions', 2.5);
|
||||
$response->assertJsonPath('trend_summary.rolling_7d_average.undo_actions', 0.5);
|
||||
$response->assertJsonPath('trend_summary.deltas.feedback_actions.label', 'Flat');
|
||||
$response->assertJsonPath('trend_summary.deltas.negative_feedback_actions.label', 'Worse +1 vs prev day');
|
||||
$response->assertJsonPath('trend_summary.overall_status.level', 'watch');
|
||||
$response->assertJsonPath('by_surface.0.surface', 'homepage');
|
||||
$response->assertJsonPath('by_surface.0.negative_feedback_actions', 2);
|
||||
$response->assertJsonPath('by_surface.0.trend.overall_status.level', 'risk');
|
||||
$response->assertJsonPath('by_surface.0.trend.deltas.feedback_actions.label', 'Worse -1 vs prev day');
|
||||
$response->assertJsonPath('by_surface.0.trend.deltas.negative_feedback_actions.label', 'Worse +2 vs prev day');
|
||||
$response->assertJsonPath('by_surface.1.surface', 'for-you');
|
||||
$response->assertJsonPath('by_surface.1.trend.overall_status.level', 'healthy');
|
||||
$response->assertJsonPath('by_algo_surface.0.algo_version', 'clip-cosine-v1');
|
||||
$response->assertJsonPath('by_algo_surface.0.surface', 'homepage');
|
||||
$response->assertJsonPath('by_algo_surface.0.negative_feedback_actions', 2);
|
||||
$response->assertJsonPath('by_algo_surface.0.trend.overall_status.level', 'risk');
|
||||
$response->assertJsonPath('by_algo_surface.0.trend.deltas.feedback_actions.label', 'Worse -1 vs prev day');
|
||||
$response->assertJsonPath('by_algo_surface.0.trend.deltas.negative_feedback_actions.label', 'Worse +2 vs prev day');
|
||||
$response->assertJsonPath('by_algo_surface.1.algo_version', 'clip-cosine-v2-adaptive');
|
||||
$response->assertJsonPath('top_artworks.0.artwork_id', $artwork->id);
|
||||
$response->assertJsonPath('top_artworks.0.negative_feedback_actions', 3);
|
||||
$response->assertJsonPath('top_artworks.0.undo_actions', 1);
|
||||
$response->assertJsonPath('latest_aggregated_date', $date);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin can inspect feed engine decisions for a user bucket', function () {
|
||||
config()->set('discovery.v2.enabled', true);
|
||||
config()->set('discovery.v2.rollout_percentage', 35);
|
||||
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$subject = User::factory()->create();
|
||||
$expectedBucket = abs((int) crc32((string) $subject->id)) % 100;
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('decision.user_id', $subject->id);
|
||||
$response->assertJsonPath('decision.bucket', $expectedBucket);
|
||||
$response->assertJsonPath('decision.rollout_percentage', 35);
|
||||
$response->assertJsonPath('decision.uses_v2', $expectedBucket < 35);
|
||||
$response->assertJsonPath('decision.selected_engine', $expectedBucket < 35 ? 'v2' : 'v1');
|
||||
});
|
||||
|
||||
it('admin can inspect explicit v2 algo overrides even when rollout is disabled', function () {
|
||||
config()->set('discovery.v2.enabled', false);
|
||||
config()->set('discovery.v2.rollout_percentage', 0);
|
||||
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id . '&algo_version=clip-cosine-v2-adaptive');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('decision.uses_v2', true);
|
||||
$response->assertJsonPath('decision.selected_engine', 'v2');
|
||||
$response->assertJsonPath('decision.reason', 'explicit_algo_override');
|
||||
});
|
||||
|
||||
it('non-admin is denied feed engine decision endpoint', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin report returns feed performance breakdown and top clicked artworks', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$artworkA = Artwork::factory()->create(['title' => 'Feed Artwork A']);
|
||||
$artworkB = Artwork::factory()->create(['title' => 'Feed Artwork B']);
|
||||
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 10,
|
||||
'clicks' => 4,
|
||||
'saves' => 2,
|
||||
'ctr' => 0.4,
|
||||
'save_rate' => 0.5,
|
||||
'dwell_0_5' => 1,
|
||||
'dwell_5_30' => 1,
|
||||
'dwell_30_120' => 1,
|
||||
'dwell_120_plus' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('feed_events')->insert([
|
||||
[
|
||||
'event_date' => $metricDate,
|
||||
'event_type' => 'feed_impression',
|
||||
'user_id' => $admin->id,
|
||||
'artwork_id' => $artworkA->id,
|
||||
'position' => 1,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'dwell_seconds' => null,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $metricDate,
|
||||
'event_type' => 'feed_click',
|
||||
'user_id' => $admin->id,
|
||||
'artwork_id' => $artworkA->id,
|
||||
'position' => 1,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'dwell_seconds' => 12,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $metricDate,
|
||||
'event_type' => 'feed_click',
|
||||
'user_id' => $admin->id,
|
||||
'artwork_id' => $artworkB->id,
|
||||
'position' => 2,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'dwell_seconds' => 7,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/reports/feed-performance?from=' . $metricDate . '&to=' . $metricDate);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.from', $metricDate);
|
||||
$response->assertJsonPath('meta.to', $metricDate);
|
||||
|
||||
$rows = collect($response->json('by_algo_source'));
|
||||
expect($rows->count())->toBe(1);
|
||||
expect($rows->first()['algo_version'])->toBe('clip-cosine-v1');
|
||||
expect($rows->first()['source'])->toBe('personalized');
|
||||
expect((float) $rows->first()['ctr'])->toBe(0.4);
|
||||
|
||||
$top = collect($response->json('top_clicked_artworks'));
|
||||
expect($top->isNotEmpty())->toBeTrue();
|
||||
expect((int) $top->first()['artwork_id'])->toBe($artworkA->id);
|
||||
});
|
||||
|
||||
it('non-admin is denied feed performance report endpoint', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/admin/reports/feed-performance');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin report returns date-filtered breakdown by algo and top similarities with ctr', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$sourceA = Artwork::factory()->create(['title' => 'Source A']);
|
||||
$similarA = Artwork::factory()->create(['title' => 'Similar A']);
|
||||
$sourceB = Artwork::factory()->create(['title' => 'Source B']);
|
||||
$similarB = Artwork::factory()->create(['title' => 'Similar B']);
|
||||
|
||||
$inRangeDate = now()->subDay()->toDateString();
|
||||
$outRangeDate = now()->subDays(5)->toDateString();
|
||||
|
||||
DB::table('similar_artwork_events')->insert([
|
||||
[
|
||||
'event_date' => $inRangeDate,
|
||||
'event_type' => 'impression',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source_artwork_id' => $sourceA->id,
|
||||
'similar_artwork_id' => $similarA->id,
|
||||
'position' => 1,
|
||||
'items_count' => 4,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $inRangeDate,
|
||||
'event_type' => 'click',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source_artwork_id' => $sourceA->id,
|
||||
'similar_artwork_id' => $similarA->id,
|
||||
'position' => 1,
|
||||
'items_count' => null,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $inRangeDate,
|
||||
'event_type' => 'impression',
|
||||
'algo_version' => 'clip-cosine-v2',
|
||||
'source_artwork_id' => $sourceB->id,
|
||||
'similar_artwork_id' => $similarB->id,
|
||||
'position' => 2,
|
||||
'items_count' => 4,
|
||||
'occurred_at' => now()->subDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'event_date' => $outRangeDate,
|
||||
'event_type' => 'click',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source_artwork_id' => $sourceA->id,
|
||||
'similar_artwork_id' => $similarA->id,
|
||||
'position' => 1,
|
||||
'items_count' => null,
|
||||
'occurred_at' => now()->subDays(5),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/reports/similar-artworks?from=' . $inRangeDate . '&to=' . $inRangeDate);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.from', $inRangeDate);
|
||||
$response->assertJsonPath('meta.to', $inRangeDate);
|
||||
|
||||
$byAlgo = collect($response->json('by_algo_version'));
|
||||
expect($byAlgo->count())->toBe(2);
|
||||
|
||||
$v1 = $byAlgo->firstWhere('algo_version', 'clip-cosine-v1');
|
||||
expect($v1['impressions'])->toBe(1);
|
||||
expect($v1['clicks'])->toBe(1);
|
||||
expect((float) $v1['ctr'])->toBe(1.0);
|
||||
|
||||
$top = collect($response->json('top_similarities'));
|
||||
expect($top->isNotEmpty())->toBeTrue();
|
||||
expect($top->first()['source_artwork_id'])->toBe($sourceA->id);
|
||||
expect($top->first()['similar_artwork_id'])->toBe($similarA->id);
|
||||
expect((float) $top->first()['ctr'])->toBe(1.0);
|
||||
});
|
||||
|
||||
it('non-admin is denied similar artwork report endpoint', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/admin/reports/similar-artworks');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createModerationCategory(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Moderation',
|
||||
'slug' => 'moderation-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createModerationDraft(int $userId, int $categoryId, array $overrides = []): string
|
||||
{
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert(array_merge([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $userId,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'ready',
|
||||
'moderation_status' => 'pending',
|
||||
'title' => 'Pending Moderation Upload',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $overrides));
|
||||
|
||||
return $uploadId;
|
||||
}
|
||||
|
||||
function addReadyMainFile(string $uploadId, string $hash = 'aabbccddeeff00112233'): void
|
||||
{
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/main.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => 3,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('admin sees pending uploads', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createModerationCategory();
|
||||
|
||||
createModerationDraft($owner->id, $categoryId, ['title' => 'First Pending']);
|
||||
createModerationDraft($owner->id, $categoryId, ['title' => 'Second Pending']);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
it('non-admin is denied moderation API access', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/admin/uploads/pending');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('approve works', function () {
|
||||
$admin = User::factory()->create(['role' => 'moderator']);
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createModerationCategory();
|
||||
$uploadId = createModerationDraft($owner->id, $categoryId);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/approve", [
|
||||
'note' => 'Looks good.',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$row = DB::table('uploads')->where('id', $uploadId)->first([
|
||||
'moderation_status',
|
||||
'moderation_note',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
]);
|
||||
|
||||
expect($row->moderation_status)->toBe('approved');
|
||||
expect($row->moderation_note)->toBe('Looks good.');
|
||||
expect((int) $row->moderated_by)->toBe((int) $admin->id);
|
||||
expect($row->moderated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('reject works', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createModerationCategory();
|
||||
$uploadId = createModerationDraft($owner->id, $categoryId);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/reject", [
|
||||
'note' => 'Policy violation.',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$row = DB::table('uploads')->where('id', $uploadId)->first([
|
||||
'status',
|
||||
'processing_state',
|
||||
'moderation_status',
|
||||
'moderation_note',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
]);
|
||||
|
||||
expect($row->status)->toBe('rejected');
|
||||
expect($row->processing_state)->toBe('rejected');
|
||||
expect($row->moderation_status)->toBe('rejected');
|
||||
expect($row->moderation_note)->toBe('Policy violation.');
|
||||
expect((int) $row->moderated_by)->toBe((int) $admin->id);
|
||||
expect($row->moderated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('user cannot publish without approval', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create(['role' => 'user']);
|
||||
$categoryId = createModerationCategory();
|
||||
$uploadId = createModerationDraft($owner->id, $categoryId, [
|
||||
'moderation_status' => 'pending',
|
||||
'title' => 'Blocked Publish',
|
||||
]);
|
||||
|
||||
addReadyMainFile($uploadId);
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment([
|
||||
'message' => 'Upload requires moderation approval before publish.',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('admin can open username moderation page', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/admin/usernames/moderation')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('non-admin cannot open username moderation page', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/usernames/moderation')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('queues similarity-flagged onboarding username for manual approval', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarding_step' => 'password',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->from('/setup/username')->post('/setup/username', [
|
||||
'username' => 'admin1',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('username');
|
||||
|
||||
$this->assertDatabaseHas('username_approval_requests', [
|
||||
'user_id' => $user->id,
|
||||
'requested_username' => 'admin1',
|
||||
'context' => 'onboarding_username',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('admin can approve queued onboarding username and allow retry', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$user = User::factory()->create([
|
||||
'onboarding_step' => 'password',
|
||||
'username' => 'before_approval',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->post('/setup/username', [
|
||||
'username' => 'support1',
|
||||
])->assertSessionHasErrors('username');
|
||||
|
||||
$requestId = (int) DB::table('username_approval_requests')
|
||||
->where('user_id', $user->id)
|
||||
->where('requested_username', 'support1')
|
||||
->where('context', 'onboarding_username')
|
||||
->where('status', 'pending')
|
||||
->value('id');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson("/api/admin/usernames/{$requestId}/approve", ['note' => 'Allowed'])
|
||||
->assertOk()
|
||||
->assertJsonFragment(['status' => 'approved']);
|
||||
|
||||
$response = $this->actingAs($user)->post('/setup/username', [
|
||||
'username' => 'support1',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/@support1');
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'username' => 'support1',
|
||||
'onboarding_step' => 'complete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('approving profile-update request applies the username rename', function () {
|
||||
$admin = User::factory()->create(['role' => 'moderator']);
|
||||
$user = User::factory()->create([
|
||||
'username' => 'old_name',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'username' => 'admin1',
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
])
|
||||
->assertSessionHasErrors('username');
|
||||
|
||||
$requestId = (int) DB::table('username_approval_requests')
|
||||
->where('user_id', $user->id)
|
||||
->where('requested_username', 'admin1')
|
||||
->where('context', 'profile_update')
|
||||
->where('status', 'pending')
|
||||
->value('id');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson("/api/admin/usernames/{$requestId}/approve")
|
||||
->assertOk()
|
||||
->assertJsonFragment(['status' => 'approved']);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'username' => 'admin1',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('username_history', [
|
||||
'user_id' => $user->id,
|
||||
'old_username' => 'old_name',
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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('/');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
it('returns a successful response', function () {
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user