feat: ship creator journey v2 and profile updates
This commit is contained in:
186
tests/Feature/Admin/ArtworkCategoriesBrowserTest.php
Normal file
186
tests/Feature/Admin/ArtworkCategoriesBrowserTest.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createArtworkBrowserAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('remembers the selected content type and root scope on the categories browser', function (): void {
|
||||
$admin = createArtworkBrowserAdmin();
|
||||
|
||||
$skins = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skin uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$wallpapers = ContentType::query()->create([
|
||||
'name' => 'Wallpapers',
|
||||
'slug' => 'wallpapers',
|
||||
'description' => 'Wallpaper uploads',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$selectedRoot = Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Skins Root Selected',
|
||||
'slug' => 'skins-root-selected',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $selectedRoot->id,
|
||||
'name' => 'Skin Child Alpha',
|
||||
'slug' => 'skin-child-alpha',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $selectedRoot->id,
|
||||
'name' => 'Skin Child Beta',
|
||||
'slug' => 'skin-child-beta',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $wallpapers->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Wallpaper Root Hidden',
|
||||
'slug' => 'wallpaper-root-hidden',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.categories.main', [
|
||||
'content_type_id' => $skins->id,
|
||||
'root_category_id' => $selectedRoot->id,
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSessionHas('cp.artworks.categories.filters.content_type_id', (string) $skins->id)
|
||||
->assertSessionHas('cp.artworks.categories.filters.root_category_id', (string) $selectedRoot->id)
|
||||
->assertSee('Subcategories of Skins Root Selected')
|
||||
->assertSee('Skin Child Alpha')
|
||||
->assertSee('Skin Child Beta')
|
||||
->assertDontSee('Wallpaper Root Hidden');
|
||||
});
|
||||
|
||||
it('reorders only the currently selected sibling level', function (): void {
|
||||
$admin = createArtworkBrowserAdmin();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skin uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$firstRoot = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'First Root',
|
||||
'slug' => 'first-root',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$secondRoot = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Second Root',
|
||||
'slug' => 'second-root',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$childOne = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'name' => 'Child One',
|
||||
'slug' => 'child-one',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$childTwo = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'name' => 'Child Two',
|
||||
'slug' => 'child-two',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$otherBranchChild = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $secondRoot->id,
|
||||
'name' => 'Other Branch Child',
|
||||
'slug' => 'other-branch-child',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.categories.reorder-tree'), [
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $firstRoot->id,
|
||||
'data' => [
|
||||
['id' => $childTwo->id],
|
||||
['id' => $childOne->id],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'status' => 200,
|
||||
'message' => 'Category order updated.',
|
||||
]);
|
||||
|
||||
expect($childOne->fresh()->sort_order)->toBe(2)
|
||||
->and($childTwo->fresh()->sort_order)->toBe(1)
|
||||
->and($otherBranchChild->fresh()->sort_order)->toBe(1)
|
||||
->and($otherBranchChild->fresh()->parent_id)->toBe($secondRoot->id);
|
||||
});
|
||||
104
tests/Feature/Admin/ArtworkCategoriesEncodingTest.php
Normal file
104
tests/Feature/Admin/ArtworkCategoriesEncodingTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createArtworkTaxonomyAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('stores plain ampersands when categories are updated in control panel', function (): void {
|
||||
$admin = createArtworkTaxonomyAdmin();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Anime',
|
||||
'slug' => 'anime',
|
||||
'description' => 'Initial description',
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->post(route('admin.cp.artworks.categories.update', $category->id), [
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => '',
|
||||
'name' => 'Anime & Manga',
|
||||
'slug' => 'anime',
|
||||
'description' => 'Anime & Manga artwork',
|
||||
'image' => '',
|
||||
'sort_order' => 1,
|
||||
'is_active' => '1',
|
||||
])
|
||||
->assertRedirect(route('admin.cp.artworks.categories.main'));
|
||||
|
||||
$category->refresh();
|
||||
|
||||
expect(DB::table('categories')->where('id', $category->id)->value('name'))->toBe('Anime & Manga')
|
||||
->and(DB::table('categories')->where('id', $category->id)->value('description'))->toBe('Anime & Manga artwork')
|
||||
->and($category->name)->toBe('Anime & Manga')
|
||||
->and($category->description)->toBe('Anime & Manga artwork');
|
||||
});
|
||||
|
||||
it('renders legacy encoded category names decoded in control panel edit form', function (): void {
|
||||
$admin = createArtworkTaxonomyAdmin();
|
||||
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$categoryId = DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Anime &amp; 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');
|
||||
});
|
||||
80
tests/Feature/Api/ArtworkAnalyticsRealtimeSyncTest.php
Normal file
80
tests/Feature/Api/ArtworkAnalyticsRealtimeSyncTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('posting an artwork comment updates artwork stats comments count immediately', function () {
|
||||
$commenter = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->actingAs($commenter)
|
||||
->postJson("/api/artworks/{$artwork->id}/comments", [
|
||||
'content' => 'Realtime analytics check.',
|
||||
])
|
||||
->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('artwork_stats', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'comments_count' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
test('deleting an artwork comment updates artwork stats comments count immediately', function () {
|
||||
$commenter = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$comment = ArtworkComment::factory()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenter->id,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('artwork_stats', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'comments_count' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($commenter)
|
||||
->deleteJson("/api/artworks/{$artwork->id}/comments/{$comment->id}")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('artwork_stats', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'comments_count' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
test('sharing an artwork updates artwork stats shares count immediately', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/share", [
|
||||
'platform' => 'copy',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
|
||||
$this->assertDatabaseHas('artwork_shares', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'platform' => 'copy',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('artwork_stats', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'shares_count' => 1,
|
||||
]);
|
||||
});
|
||||
@@ -23,6 +23,25 @@ test('authenticated user can post a comment', function () {
|
||||
->assertJsonStructure(['data' => ['id', 'raw_content', 'rendered_content', 'user']]);
|
||||
});
|
||||
|
||||
test('authenticated user can post a comment with emoji', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/comments", [
|
||||
'content' => 'Love this 🚀🔥',
|
||||
])
|
||||
->assertStatus(201)
|
||||
->assertJsonPath('data.raw_content', 'Love this 🚀🔥');
|
||||
|
||||
$comment = ArtworkComment::query()->latest('id')->first();
|
||||
|
||||
expect($comment)->not()->toBeNull()
|
||||
->and($comment->raw_content)->toContain('🚀')
|
||||
->and($comment->rendered_content)->toContain('🚀')
|
||||
->and($comment->rendered_content)->toContain('🔥');
|
||||
});
|
||||
|
||||
test('guest cannot post a comment', function () {
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
|
||||
@@ -6,11 +6,22 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,7 +46,7 @@ test('user can award an artwork', function () {
|
||||
$award = $service->award($artwork, $user, 'gold');
|
||||
|
||||
expect($award->medal)->toBe('gold')
|
||||
->and($award->weight)->toBe(3)
|
||||
->and($award->weight)->toBe(5)
|
||||
->and($award->artwork_id)->toBe($artwork->id)
|
||||
->and($award->user_id)->toBe($user->id);
|
||||
});
|
||||
@@ -58,7 +69,9 @@ test('stats are recalculated after awarding', function () {
|
||||
expect($stat->gold_count)->toBe(1)
|
||||
->and($stat->silver_count)->toBe(1)
|
||||
->and($stat->bronze_count)->toBe(1)
|
||||
->and($stat->score_total)->toBe(6); // 3+2+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 () {
|
||||
@@ -106,7 +119,7 @@ test('user can remove their award', function () {
|
||||
->and($stat->score_total)->toBe(0);
|
||||
});
|
||||
|
||||
test('score formula is gold×3 + silver×2 + bronze×1', function () {
|
||||
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]);
|
||||
@@ -116,7 +129,48 @@ test('score formula is gold×3 + silver×2 + bronze×1', function () {
|
||||
}
|
||||
|
||||
$stat = ArtworkAwardStat::find($artwork->id);
|
||||
expect($stat->score_total)->toBe((2 * 3) + (1 * 2) + (1 * 1)); // 9
|
||||
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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -141,6 +195,55 @@ test('POST /api/artworks/{id}/award — authenticated user can award', function
|
||||
->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]);
|
||||
@@ -149,7 +252,7 @@ test('POST /api/artworks/{id}/award — duplicate is rejected with 422', functio
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => 'gold',
|
||||
'weight' => 3,
|
||||
'weight' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
@@ -165,7 +268,7 @@ test('PUT /api/artworks/{id}/award — user can change their award', function ()
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => 'gold',
|
||||
'weight' => 3,
|
||||
'weight' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
@@ -182,7 +285,7 @@ test('DELETE /api/artworks/{id}/award — user can remove their award', function
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => 'silver',
|
||||
'weight' => 2,
|
||||
'weight' => 3,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
@@ -202,7 +305,10 @@ test('GET /api/artworks/{id}/awards — returns stats publicly', function () {
|
||||
'gold_count' => 2,
|
||||
'silver_count' => 1,
|
||||
'bronze_count' => 3,
|
||||
'score_total' => 11,
|
||||
'score_total' => 16,
|
||||
'score_7d' => 8,
|
||||
'score_30d' => 12,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -211,7 +317,9 @@ test('GET /api/artworks/{id}/awards — returns stats publicly', function () {
|
||||
->assertJsonPath('awards.gold', 2)
|
||||
->assertJsonPath('awards.silver', 1)
|
||||
->assertJsonPath('awards.bronze', 3)
|
||||
->assertJsonPath('awards.score', 11);
|
||||
->assertJsonPath('awards.score', 16)
|
||||
->assertJsonPath('awards.score_7d', 8)
|
||||
->assertJsonPath('awards.score_30d', 12);
|
||||
});
|
||||
|
||||
test('observer recalculates stats when award is created', function () {
|
||||
@@ -222,25 +330,36 @@ test('observer recalculates stats when award is created', function () {
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => 'gold',
|
||||
'weight' => 3,
|
||||
'weight' => 5,
|
||||
]);
|
||||
|
||||
$stat = ArtworkAwardStat::find($artwork->id);
|
||||
expect($stat->gold_count)->toBe(1)
|
||||
->and($stat->score_total)->toBe(3);
|
||||
->and($stat->score_total)->toBe(5);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Abuse / security tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('new account (< 7 days) is rejected with 403', function () {
|
||||
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();
|
||||
->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 () {
|
||||
@@ -249,44 +368,67 @@ test('user cannot award their own artwork', function () {
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold'])
|
||||
->assertForbidden();
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'You cannot medal your own artwork.');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meilisearch sync test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('syncToSearch is called when an award is given', function () {
|
||||
$service = $this->partialMock(
|
||||
\App\Services\ArtworkAwardService::class,
|
||||
function (\Mockery\MockInterface $mock) {
|
||||
$mock->shouldReceive('syncToSearch')->atLeast()->once();
|
||||
}
|
||||
);
|
||||
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('syncToSearch is called when an award is removed', function () {
|
||||
$service = $this->partialMock(
|
||||
\App\Services\ArtworkAwardService::class,
|
||||
function (\Mockery\MockInterface $mock) {
|
||||
$mock->shouldReceive('syncToSearch')->atLeast()->once();
|
||||
}
|
||||
);
|
||||
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]);
|
||||
|
||||
ArtworkAward::create([
|
||||
DB::table('artwork_medals')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => 'gold',
|
||||
'weight' => 3,
|
||||
'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();
|
||||
});
|
||||
|
||||
@@ -5,10 +5,14 @@ declare(strict_types=1);
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads');
|
||||
config(['uploads.storage_root' => $root]);
|
||||
config([
|
||||
'uploads.storage_root' => $root,
|
||||
'uploads.local_originals_root' => $root,
|
||||
]);
|
||||
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
@@ -26,10 +30,10 @@ afterEach(function () {
|
||||
|
||||
function makeOriginalFile(string $hash, string $ext, string $content = 'test-image-content'): string
|
||||
{
|
||||
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
$firstDir = substr($hash, 0, 2);
|
||||
$secondDir = substr($hash, 2, 2);
|
||||
$dir = $root . DIRECTORY_SEPARATOR . 'original' . DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . $secondDir;
|
||||
$dir = $root . DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . $secondDir;
|
||||
|
||||
File::makeDirectory($dir, 0755, true, true);
|
||||
|
||||
@@ -131,3 +135,34 @@ it('logs guest download with null user_id', function () {
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('increments artwork_stats downloads on the real download route', function () {
|
||||
$hash = 'f1e2d3c4b5';
|
||||
$ext = 'png';
|
||||
makeOriginalFile($hash, $ext);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => $ext,
|
||||
]);
|
||||
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'downloads_24h' => 0,
|
||||
'downloads_7d' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
$this->get("/download/artwork/{$artwork->id}")->assertOk();
|
||||
$this->get("/download/artwork/{$artwork->id}")->assertOk();
|
||||
|
||||
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads'))->toBe(2);
|
||||
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads_24h'))->toBe(2);
|
||||
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads_7d'))->toBe(2);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BrowseApiTest extends TestCase
|
||||
@@ -199,4 +201,54 @@ class BrowseApiTest extends TestCase
|
||||
$this->assertStringContainsString('data-gallery-type="browse"', $html);
|
||||
$this->assertStringContainsString('Forest Light', $html);
|
||||
}
|
||||
|
||||
public function test_web_browse_filters_out_artworks_that_are_no_longer_publicly_browsable(): void
|
||||
{
|
||||
$user = User::factory()->create(['name' => 'Visibility Author']);
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Minimal',
|
||||
'slug' => 'minimal',
|
||||
'description' => 'Minimal skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$visible = Artwork::factory()->for($user)->create([
|
||||
'title' => 'Visible Explore Item',
|
||||
'slug' => 'visible-explore-item',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
$visible->categories()->attach($category->id);
|
||||
|
||||
$hidden = Artwork::factory()->for($user)->create([
|
||||
'title' => 'Hidden Explore Item',
|
||||
'slug' => 'hidden-explore-item',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => false,
|
||||
'is_approved' => false,
|
||||
]);
|
||||
$hidden->categories()->attach($category->id);
|
||||
|
||||
Cache::forever('explore.cache.version', 1);
|
||||
Cache::put(
|
||||
'explore.all.v1.trending.1',
|
||||
new LengthAwarePaginator(collect([$hidden, $visible]), 2, 24, 1),
|
||||
300,
|
||||
);
|
||||
|
||||
$response = $this->get('/explore');
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('Visible Explore Item')
|
||||
->assertDontSee('Hidden Explore Item');
|
||||
}
|
||||
}
|
||||
|
||||
12
tests/Feature/CategoriesPageTest.php
Normal file
12
tests/Feature/CategoriesPageTest.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
it('renders the categories page with collection structured data', function () {
|
||||
$html = $this->get('/categories')
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($html)
|
||||
->toContain('application/ld+json')
|
||||
->toContain('CollectionPage')
|
||||
->toContain('Browse all categories on Skinbase');
|
||||
});
|
||||
126
tests/Feature/ContentTypeDynamicRoutingTest.php
Normal file
126
tests/Feature/ContentTypeDynamicRoutingTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ArtworkService;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function dynamicRoutingEmptyPaginator(string $path = '/'): LengthAwarePaginator
|
||||
{
|
||||
return (new LengthAwarePaginator(collect(), 0, 20, 1))->setPath($path);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$artworksMock = \Mockery::mock(ArtworkService::class);
|
||||
$artworksMock->shouldReceive('getFeaturedArtworks')->andReturn(dynamicRoutingEmptyPaginator('/'))->byDefault();
|
||||
$artworksMock->shouldReceive('getLatestArtworks')->andReturn(collect())->byDefault();
|
||||
$this->app->instance(ArtworkService::class, $artworksMock);
|
||||
|
||||
$this->contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('content_type_slug_histories')->insert([
|
||||
'content_type_id' => $this->contentTypeId,
|
||||
'old_slug' => 'concept-art',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('redirects old explore content type slugs to the current canonical slug', function () {
|
||||
$this->get('/explore/concept-art?sort=latest')
|
||||
->assertRedirect('/digital-art?sort=latest');
|
||||
});
|
||||
|
||||
it('redirects old rss explore slugs while preserving mode and query string', function () {
|
||||
$this->get('/rss/explore/concept-art/trending?limit=10')
|
||||
->assertRedirect('/rss/explore/digital-art/trending?limit=10');
|
||||
});
|
||||
|
||||
it('redirects old artwork paths to the current canonical content type slug', function () {
|
||||
$this->get('/concept-art/fantasy/castle-sunrise')
|
||||
->assertRedirect('/digital-art/fantasy/castle-sunrise');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown dynamic content type slugs', function () {
|
||||
$this->get('/made-up-content-type')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('lists resolver-backed content types on the rss feeds page', function () {
|
||||
$this->get('/rss-feeds')
|
||||
->assertOk()
|
||||
->assertSee('/rss/explore/digital-art', false)
|
||||
->assertSee('Digital Art');
|
||||
});
|
||||
|
||||
it('keeps static routes ahead of dynamic content type routes even with bad data', function () {
|
||||
DB::table('content_types')->insert([
|
||||
'name' => 'RSS Collision',
|
||||
'slug' => 'rss',
|
||||
'description' => 'Invalid but useful for route-order regression coverage',
|
||||
'order' => 99,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->get('/rss')
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
});
|
||||
|
||||
it('blocks reserved content type slugs in the admin save flow', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->withoutMiddleware();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.cp.artworks.content-types.store'), [
|
||||
'name' => 'Reserved Help',
|
||||
'slug' => 'help',
|
||||
'description' => 'Should be rejected',
|
||||
'order' => 1,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertSeeText('This content type slug is reserved by the public routing layer.');
|
||||
});
|
||||
|
||||
it('allows updating an existing content type that already uses the same reserved slug', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Members',
|
||||
'slug' => 'members',
|
||||
'description' => 'Legacy reserved slug still in use',
|
||||
'order' => 5,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.cp.artworks.content-types.update', ['id' => $contentTypeId]), [
|
||||
'name' => 'Members Updated',
|
||||
'slug' => 'members',
|
||||
'description' => 'Updated without renaming slug',
|
||||
'order' => 5,
|
||||
])
|
||||
->assertRedirect(route('admin.cp.artworks.content-types.main'));
|
||||
|
||||
$this->assertDatabaseHas('content_types', [
|
||||
'id' => $contentTypeId,
|
||||
'slug' => 'members',
|
||||
'name' => 'Members Updated',
|
||||
'description' => 'Updated without renaming slug',
|
||||
]);
|
||||
});
|
||||
@@ -3,9 +3,12 @@
|
||||
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
|
||||
@@ -16,6 +19,9 @@ beforeEach(function () {
|
||||
$artworksMock->shouldReceive('getFeaturedArtworks')
|
||||
->andReturn(new LengthAwarePaginator(collect(), 0, 1))
|
||||
->byDefault();
|
||||
$artworksMock->shouldReceive('getFeaturedArtworkWinner')
|
||||
->andReturn(null)
|
||||
->byDefault();
|
||||
app()->instance(ArtworkService::class, $artworksMock);
|
||||
});
|
||||
|
||||
@@ -78,3 +84,34 @@ it('guest and auth homepages have different key sets', function () {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -39,45 +39,52 @@ it('records a view and returns ok=true on first call', function () {
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 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);
|
||||
|
||||
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('views'))->toBe(1);
|
||||
expect(DB::table('artwork_view_events')->where('artwork_id', $artwork->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('skips DB increment and returns counted=false if artwork was already viewed this session', function () {
|
||||
it('counts repeated visits for the same artwork', 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);
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementViews')->never();
|
||||
$this->postJson("/api/art/{$artwork->id}/view")
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('counted', true);
|
||||
|
||||
$response = $this->postJson("/api/art/{$artwork->id}/view");
|
||||
$this->postJson("/api/art/{$artwork->id}/view")
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('counted', true);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('counted', false);
|
||||
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('views'))->toBe(2);
|
||||
expect(DB::table('artwork_view_events')->where('artwork_id', $artwork->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
// ── ArtworkDownloadController (POST /api/art/{id}/download) ──────────────────
|
||||
@@ -101,7 +108,7 @@ it('records a download and returns ok=true with a url', function () {
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementDownloads')
|
||||
->once()
|
||||
->with($artwork->id, 1, true);
|
||||
->with($artwork->id, 1, false);
|
||||
|
||||
$response = $this->postJson("/api/art/{$artwork->id}/download");
|
||||
|
||||
@@ -120,7 +127,7 @@ it('inserts a row in artwork_downloads on valid download', function () {
|
||||
|
||||
// Stub the stats service so we don't need Redis
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementDownloads')->once();
|
||||
$mock->shouldReceive('incrementDownloads')->once()->with($artwork->id, 1, false);
|
||||
|
||||
$this->actingAs($user)->postJson("/api/art/{$artwork->id}/download");
|
||||
|
||||
@@ -138,7 +145,7 @@ it('records download as guest (no user_id) when unauthenticated', function () {
|
||||
]);
|
||||
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementDownloads')->once();
|
||||
$mock->shouldReceive('incrementDownloads')->once()->with($artwork->id, 1, false);
|
||||
|
||||
$this->postJson("/api/art/{$artwork->id}/download");
|
||||
|
||||
|
||||
316
tests/Feature/FeaturedArtworkAdminTest.php
Normal file
316
tests/Feature/FeaturedArtworkAdminTest.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkFeature;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
|
||||
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createControlPanelAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
function adminArtwork(array $attributes = []): Artwork
|
||||
{
|
||||
return Artwork::factory()->create(array_merge([
|
||||
'title' => 'Featured Artwork ' . fake()->unique()->words(2, true),
|
||||
'slug' => 'featured-artwork-' . fake()->unique()->numberBetween(1000, 9999),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
'has_missing_thumbnails' => false,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function featureRow(Artwork $artwork, array $attributes = []): ArtworkFeature
|
||||
{
|
||||
return ArtworkFeature::query()->create(array_merge([
|
||||
'artwork_id' => $artwork->id,
|
||||
'priority' => 100,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'is_active' => true,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function medalScore(Artwork $artwork, int $score30d): void
|
||||
{
|
||||
DB::table('artwork_medal_stats')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'gold_count' => 0,
|
||||
'silver_count' => 0,
|
||||
'bronze_count' => 0,
|
||||
'score_total' => $score30d,
|
||||
'score_7d' => $score30d,
|
||||
'score_30d' => $score30d,
|
||||
'last_medaled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('blocks non staff users from the featured artworks admin area', function (): void {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$user->forceFill([
|
||||
'isAdmin' => false,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
$this->actingAs($user)->actingAs($user, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.featured.main'))
|
||||
->assertRedirect(route('cp.login'));
|
||||
});
|
||||
|
||||
it('registers the featured artworks entry in the cpad menu', function (): void {
|
||||
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
|
||||
|
||||
$editorialRoot = $sidebarMenu
|
||||
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
|
||||
|
||||
expect($editorialRoot)->toBeInstanceOf(MenuRootItem::class);
|
||||
|
||||
$featuredItem = collect($editorialRoot->getItems())
|
||||
->first(fn ($item): bool => ($item->name ?? null) === 'Featured Artworks');
|
||||
|
||||
expect($featuredItem)->not->toBeNull()
|
||||
->and($featuredItem->mainRoute)->toBe('admin.cp.artworks.featured.main')
|
||||
->and($featuredItem->icon)->toBe('fas fa-star');
|
||||
});
|
||||
|
||||
it('renders the featured artworks admin index with the current winner summary', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$owner = User::factory()->create(['username' => 'winnermaker']);
|
||||
$higherMedal = adminArtwork(['user_id' => $owner->id, 'title' => 'Higher Medal Winner']);
|
||||
$runnerUp = adminArtwork(['user_id' => $owner->id, 'title' => 'Runner Up']);
|
||||
|
||||
featureRow($higherMedal, ['priority' => 100, 'featured_at' => now()->subHour()]);
|
||||
featureRow($runnerUp, ['priority' => 100, 'featured_at' => now()->subHour()]);
|
||||
medalScore($higherMedal, 12);
|
||||
medalScore($runnerUp, 3);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.featured.main'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Collection/FeaturedArtworksAdmin')
|
||||
->where('winner.artwork.id', $higherMedal->id)
|
||||
->where('winner.medals.score_30d', 12)
|
||||
->where('winner.selection_reason', 'Tied on priority, won on higher 30-day medal score.')
|
||||
->where('entries.0.is_winner', true)
|
||||
->where('entries.0.artwork.id', $higherMedal->id)
|
||||
->where('endpoints.store', route('admin.cp.artworks.featured.store')));
|
||||
});
|
||||
|
||||
it('allows admins to create featured rows', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = adminArtwork();
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.featured.store'), [
|
||||
'artwork_id' => $artwork->id,
|
||||
'priority' => 220,
|
||||
'featured_at' => now()->toISOString(),
|
||||
'expires_at' => now()->addDay()->toISOString(),
|
||||
'is_active' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('winner.artwork.id', $artwork->id)
|
||||
->assertJsonPath('stats.total', 1);
|
||||
|
||||
$feature = ArtworkFeature::query()->firstOrFail();
|
||||
expect((int) $feature->artwork_id)->toBe($artwork->id)
|
||||
->and((int) $feature->priority)->toBe(220)
|
||||
->and((int) $feature->created_by)->toBe($admin->id)
|
||||
->and((bool) $feature->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows admins to update featured rows', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$feature = featureRow(adminArtwork(), [
|
||||
'priority' => 50,
|
||||
'featured_at' => now()->subDays(2),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
|
||||
'priority' => 180,
|
||||
'featured_at' => now()->subHour()->toISOString(),
|
||||
'expires_at' => now()->addHours(6)->toISOString(),
|
||||
'is_active' => false,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('entries.0.priority', 180)
|
||||
->assertJsonPath('entries.0.is_active', false);
|
||||
|
||||
$fresh = $feature->fresh();
|
||||
expect((int) $fresh->priority)->toBe(180)
|
||||
->and((bool) $fresh->is_active)->toBeFalse()
|
||||
->and($fresh->expires_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('allows admins to activate and deactivate featured rows', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$feature = featureRow(adminArtwork(), ['is_active' => true]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('entries.0.is_active', false);
|
||||
|
||||
expect($feature->fresh()->is_active)->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admins to force and unforce the homepage hero from the featured pool', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$owner = User::factory()->create(['username' => 'forcehero']);
|
||||
$naturalWinner = adminArtwork(['user_id' => $owner->id, 'title' => 'Natural Winner']);
|
||||
$forcedArtwork = adminArtwork(['user_id' => $owner->id, 'title' => 'Forced Winner']);
|
||||
|
||||
$naturalFeature = featureRow($naturalWinner, ['priority' => 300, 'featured_at' => now()->subHour()]);
|
||||
$forcedFeature = featureRow($forcedArtwork, ['priority' => 100, 'featured_at' => now()->subDays(2)]);
|
||||
|
||||
medalScore($naturalWinner, 50);
|
||||
medalScore($forcedArtwork, 1);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('winner.artwork.id', $forcedArtwork->id)
|
||||
->assertJsonPath('winner.is_force_hero', true)
|
||||
->assertJsonPath('winner.selection_reason', 'Forced hero override is enabled for this featured artwork.');
|
||||
|
||||
expect($forcedFeature->fresh()->force_hero)->toBeTrue()
|
||||
->and($naturalFeature->fresh()->force_hero)->toBeFalse()
|
||||
->and(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(1);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('winner.artwork.id', $naturalWinner->id)
|
||||
->assertJsonPath('winner.is_force_hero', false)
|
||||
->assertJsonPath('winner.selection_reason', 'Highest priority among active, eligible featured artworks.');
|
||||
|
||||
expect($forcedFeature->fresh()->force_hero)->toBeFalse()
|
||||
->and(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('returns a forced hero as the admin winner even when standard artwork eligibility fails', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$owner = User::factory()->create(['username' => 'forceheromissingpreview']);
|
||||
$naturalWinner = adminArtwork(['user_id' => $owner->id, 'title' => 'Natural Winner']);
|
||||
$forcedArtwork = adminArtwork(['user_id' => $owner->id, 'title' => 'Forced Missing Preview', 'has_missing_thumbnails' => true]);
|
||||
|
||||
featureRow($naturalWinner, ['priority' => 300, 'featured_at' => now()->subHour()]);
|
||||
$forcedFeature = featureRow($forcedArtwork, ['priority' => 100, 'featured_at' => now()->subDays(2)]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('winner.artwork.id', $forcedArtwork->id)
|
||||
->assertJsonPath('winner.is_force_hero', true)
|
||||
->assertJsonPath('winner.selection_reason', 'Forced hero override is enabled for this featured artwork.');
|
||||
|
||||
expect(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('allows admins to delete featured rows', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$feature = featureRow(adminArtwork());
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('stats.total', 0);
|
||||
|
||||
expect(ArtworkFeature::query()->count())->toBe(0)
|
||||
->and(ArtworkFeature::withTrashed()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('marks expired and ineligible rows on the index page', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$privateArtwork = adminArtwork([
|
||||
'title' => 'Private Artwork',
|
||||
'is_public' => false,
|
||||
'visibility' => Artwork::VISIBILITY_PRIVATE,
|
||||
]);
|
||||
$expiredArtwork = adminArtwork(['title' => 'Expired Artwork']);
|
||||
|
||||
featureRow($privateArtwork, ['priority' => 300]);
|
||||
featureRow($expiredArtwork, ['priority' => 200, 'expires_at' => now()->subMinute()]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.featured.main'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Collection/FeaturedArtworksAdmin')
|
||||
->where('entries.0.artwork.id', $privateArtwork->id)
|
||||
->where('entries.0.eligibility.is_eligible', false)
|
||||
->where('entries.0.eligibility.reasons.0', 'Private')
|
||||
->where('entries.1.artwork.id', $expiredArtwork->id)
|
||||
->where('entries.1.is_expired', true)
|
||||
->where('stats.expired', 1)
|
||||
->where('stats.ineligible', 2));
|
||||
});
|
||||
|
||||
it('clears homepage hero cache after create update toggle and delete actions', function (): void {
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = adminArtwork();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.featured.store'), [
|
||||
'artwork_id' => $artwork->id,
|
||||
'priority' => 100,
|
||||
'featured_at' => now()->toISOString(),
|
||||
'expires_at' => null,
|
||||
'is_active' => true,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
|
||||
$feature = ArtworkFeature::query()->firstOrFail();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
|
||||
'priority' => 110,
|
||||
'featured_at' => now()->addMinute()->toISOString(),
|
||||
'expires_at' => now()->addDay()->toISOString(),
|
||||
'is_active' => true,
|
||||
])
|
||||
->assertOk();
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
|
||||
->assertOk();
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
|
||||
->assertOk();
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
|
||||
/** Return an empty LengthAwarePaginator with the given path. */
|
||||
function emptyPaginator(string $path = '/'): LengthAwarePaginator
|
||||
@@ -54,6 +54,16 @@ it('home page emits unified SEO tags and structured data', function () {
|
||||
->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.
|
||||
|
||||
415
tests/Feature/HomepageFeaturedMedalsTest.php
Normal file
415
tests/Feature/HomepageFeaturedMedalsTest.php
Normal file
@@ -0,0 +1,415 @@
|
||||
<?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\Cache;
|
||||
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('community favorites backfills with archive artworks when medal picks are sparse', function () {
|
||||
Cache::flush();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
|
||||
$archiveA = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Archive A', 'published_at' => now()->subDays(10)]);
|
||||
$archiveB = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Archive B', 'published_at' => now()->subDays(20)]);
|
||||
|
||||
DB::table('artwork_medal_stats')->insert([
|
||||
'artwork_id' => $leader->id,
|
||||
'gold_count' => 1,
|
||||
'silver_count' => 0,
|
||||
'bronze_count' => 0,
|
||||
'score_total' => 5,
|
||||
'score_7d' => 5,
|
||||
'score_30d' => 5,
|
||||
'last_medaled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$results = app(HomepageService::class)->getCommunityFavorites(3);
|
||||
$resultIds = collect($results)->pluck('id')->all();
|
||||
|
||||
expect($results)->toHaveCount(3)
|
||||
->and($resultIds[0])->toBe($leader->id)
|
||||
->and($resultIds)->toContain($archiveA->id)
|
||||
->and($resultIds)->toContain($archiveB->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);
|
||||
});
|
||||
|
||||
test('trending backfills with archive artworks when the recent ranking pool is sparse', function () {
|
||||
Cache::flush();
|
||||
config(['scout.driver' => 'null']);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$recentLeader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Recent Leader', 'published_at' => now()->subDay()]);
|
||||
$archiveA = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Archive A', 'published_at' => now()->subDays(45)]);
|
||||
$archiveB = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Archive B', 'published_at' => now()->subDays(60)]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $recentLeader->id,
|
||||
'views' => 100,
|
||||
'downloads' => 10,
|
||||
'favorites' => 5,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
'ranking_score' => 500,
|
||||
'engagement_velocity' => 100,
|
||||
'heat_score' => 100,
|
||||
]);
|
||||
|
||||
$results = app(HomepageService::class)->getTrending(3);
|
||||
$resultIds = collect($results)->pluck('id')->all();
|
||||
|
||||
expect($results)->toHaveCount(3)
|
||||
->and($resultIds[0])->toBe($recentLeader->id)
|
||||
->and($resultIds)->toContain($archiveA->id)
|
||||
->and($resultIds)->toContain($archiveB->id);
|
||||
});
|
||||
799
tests/Feature/MaturitySystemTest.php
Normal file
799
tests/Feature/MaturitySystemTest.php
Normal file
@@ -0,0 +1,799 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Models\Collection;
|
||||
use App\Services\CollectionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
use Klevze\ControlPanel\Models\Auth\User as ControlPanelUser;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createMaturityQueueAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
}
|
||||
|
||||
it('persists content preferences from the settings endpoint', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/settings/content/update', [
|
||||
'mature_content_visibility' => 'hide',
|
||||
'mature_content_warning_enabled' => false,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('message', 'Content preferences saved successfully.');
|
||||
|
||||
$profile = DB::table('user_profiles')
|
||||
->where('user_id', $user->id)
|
||||
->first(['mature_content_visibility', 'mature_content_warning_enabled']);
|
||||
|
||||
expect($profile)->not->toBeNull()
|
||||
->and($profile->mature_content_visibility)->toBe('hide')
|
||||
->and((int) $profile->mature_content_warning_enabled)->toBe(0);
|
||||
});
|
||||
|
||||
it('derives mature artwork presentation from viewer preferences', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => true,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
|
||||
]);
|
||||
|
||||
$hideViewer = User::factory()->create();
|
||||
$blurViewer = User::factory()->create();
|
||||
$showViewer = User::factory()->create();
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
[
|
||||
'user_id' => $hideViewer->id,
|
||||
'mature_content_visibility' => 'hide',
|
||||
'mature_content_warning_enabled' => true,
|
||||
],
|
||||
[
|
||||
'user_id' => $blurViewer->id,
|
||||
'mature_content_visibility' => 'blur',
|
||||
'mature_content_warning_enabled' => false,
|
||||
],
|
||||
[
|
||||
'user_id' => $showViewer->id,
|
||||
'mature_content_visibility' => 'show',
|
||||
'mature_content_warning_enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$maturity = app(ArtworkMaturityService::class);
|
||||
|
||||
$hidden = $maturity->presentation($artwork, $hideViewer);
|
||||
$blurred = $maturity->presentation($artwork, $blurViewer);
|
||||
$shown = $maturity->presentation($artwork, $showViewer);
|
||||
|
||||
expect($hidden['should_hide'])->toBeTrue()
|
||||
->and($hidden['should_blur'])->toBeFalse()
|
||||
->and($hidden['requires_interstitial'])->toBeTrue()
|
||||
->and($blurred['should_hide'])->toBeFalse()
|
||||
->and($blurred['should_blur'])->toBeTrue()
|
||||
->and($blurred['requires_interstitial'])->toBeFalse()
|
||||
->and($shown['should_hide'])->toBeFalse()
|
||||
->and($shown['should_blur'])->toBeFalse()
|
||||
->and($shown['requires_interstitial'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('applies uploader mature declarations when publishing an existing artwork', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/api/uploads/{$artwork->id}/publish", [
|
||||
'title' => 'Updated title',
|
||||
'is_mature' => true,
|
||||
'visibility' => 'private',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('status', 'published');
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->is_mature)->toBeTrue()
|
||||
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_MATURE)
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_DECLARED)
|
||||
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_USER);
|
||||
});
|
||||
|
||||
it('flags undeclared mature content from AI assessment', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_mismatch_count' => 0,
|
||||
]);
|
||||
|
||||
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
|
||||
'clip_tags' => [
|
||||
['tag' => 'nudity'],
|
||||
],
|
||||
'yolo_objects' => [],
|
||||
'blip_caption' => 'topless portrait',
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($assessment['flagged'])->toBeTrue()
|
||||
->and($assessment['score'])->toBeGreaterThanOrEqual(0.68)
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_SUSPECTED)
|
||||
->and($artwork->maturity_flagged_at)->not->toBeNull()
|
||||
->and($artwork->maturity_mismatch_count)->toBe(1)
|
||||
->and($artwork->maturity_ai_labels)->toContain('nudity');
|
||||
});
|
||||
|
||||
it('stores normalized maturity endpoint results and flags review-worthy content', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_mismatch_count' => 0,
|
||||
]);
|
||||
|
||||
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
|
||||
'status' => 'succeeded',
|
||||
'maturity_label' => 'mature',
|
||||
'confidence' => 0.9321,
|
||||
'action_hint' => 'flag_high',
|
||||
'threshold_used' => 0.7,
|
||||
'analysis_time_ms' => 321,
|
||||
'model' => 'vision-maturity-v2',
|
||||
'advisory' => 'High confidence mature content.',
|
||||
'labels' => ['nudity'],
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($assessment['flagged'])->toBeTrue()
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_SUSPECTED)
|
||||
->and($artwork->maturity_ai_status)->toBe(ArtworkMaturityService::AI_STATUS_SUCCEEDED)
|
||||
->and($artwork->maturity_ai_label)->toBe(ArtworkMaturityService::LEVEL_MATURE)
|
||||
->and($artwork->maturity_ai_confidence)->toBe(0.9321)
|
||||
->and($artwork->maturity_ai_action_hint)->toBe(ArtworkMaturityService::AI_ACTION_FLAG_HIGH)
|
||||
->and($artwork->maturity_ai_threshold_used)->toBe(0.7)
|
||||
->and($artwork->maturity_ai_analysis_time_ms)->toBe(321)
|
||||
->and($artwork->maturity_ai_model)->toBe('vision-maturity-v2')
|
||||
->and($artwork->maturity_ai_advisory)->toBe('High confidence mature content.')
|
||||
->and($artwork->maturity_mismatch_count)->toBe(1);
|
||||
});
|
||||
|
||||
it('normalizes safe action hints from the vision maturity contract', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
]);
|
||||
|
||||
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
|
||||
'status' => 'succeeded',
|
||||
'maturity_label' => 'safe',
|
||||
'confidence' => 0.1123,
|
||||
'action_hint' => 'safe',
|
||||
'threshold_used' => 0.7,
|
||||
'model' => 'vision-maturity-v2',
|
||||
'labels' => ['safe'],
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($assessment['flagged'])->toBeFalse()
|
||||
->and($assessment['action_hint'])->toBe(ArtworkMaturityService::AI_ACTION_SAFE)
|
||||
->and($artwork->maturity_ai_action_hint)->toBe(ArtworkMaturityService::AI_ACTION_SAFE)
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR);
|
||||
});
|
||||
|
||||
it('records failed maturity AI analysis without implying safe content', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||
]);
|
||||
|
||||
$assessment = app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
|
||||
'status' => 'failed',
|
||||
'advisory' => 'Gateway timeout.',
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($assessment['flagged'])->toBeFalse()
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR)
|
||||
->and($artwork->is_mature)->toBeFalse()
|
||||
->and($artwork->maturity_ai_status)->toBe(ArtworkMaturityService::AI_STATUS_FAILED)
|
||||
->and($artwork->maturity_ai_advisory)->toBe('Gateway timeout.')
|
||||
->and($artwork->maturity_ai_label)->toBeNull();
|
||||
});
|
||||
|
||||
it('lets moderators review suspected artworks', function () {
|
||||
Queue::fake();
|
||||
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->postJson("/cp/maturity/{$artwork->id}/review", [
|
||||
'action' => 'mark_mature',
|
||||
'note' => 'Confirmed mature by moderation review.',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('artwork.id', $artwork->id);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->is_mature)->toBeTrue()
|
||||
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_MATURE)
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
|
||||
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
|
||||
->and($artwork->maturity_reviewed_by)->toBe($moderator->id)
|
||||
->and($artwork->maturity_reviewer_note)->toBe('Confirmed mature by moderation review.');
|
||||
});
|
||||
|
||||
it('renders the moderation queue page and filters queue items by status', function () {
|
||||
Queue::fake();
|
||||
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
|
||||
$suspected = Artwork::factory()->create([
|
||||
'title' => 'Suspected Queue Artwork',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'maturity_ai_label' => ArtworkMaturityService::LEVEL_MATURE,
|
||||
'maturity_ai_score' => 0.8812,
|
||||
'maturity_ai_confidence' => 0.8812,
|
||||
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'maturity_ai_labels' => ['nudity'],
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$reviewed = Artwork::factory()->create([
|
||||
'title' => 'Reviewed Queue Artwork',
|
||||
'is_mature' => true,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_REVIEWED,
|
||||
'maturity_source' => ArtworkMaturityService::SOURCE_MODERATOR,
|
||||
'maturity_reviewed_by' => $moderator->id,
|
||||
'maturity_reviewed_at' => now(),
|
||||
'maturity_reviewer_note' => 'Already reviewed.',
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get('/cp/maturity')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Moderation/ArtworkMaturityQueue')
|
||||
->where('title', 'Artwork Maturity Queue')
|
||||
->where('stats.suspected', 1)
|
||||
->where('stats.reviewed', 1)
|
||||
->where('initialItems.0.id', $suspected->id)
|
||||
->where('initialItems.0.title', 'Suspected Queue Artwork')
|
||||
->where('initialItems.0.maturity.ai_action_hint', ArtworkMaturityService::AI_ACTION_REVIEW)
|
||||
);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->getJson('/cp/maturity/queue?status=reviewed')
|
||||
->assertOk()
|
||||
->assertJsonPath('meta.status', 'reviewed')
|
||||
->assertJsonPath('meta.stats.suspected', 1)
|
||||
->assertJsonPath('meta.stats.reviewed', 1)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $reviewed->id)
|
||||
->assertJsonPath('data.0.review.reviewer_note', 'Already reviewed.');
|
||||
});
|
||||
|
||||
it('renders the artwork admin maturity queue for cpad admins', function () {
|
||||
Queue::fake();
|
||||
|
||||
$admin = createMaturityQueueAdmin();
|
||||
$suspected = Artwork::factory()->create([
|
||||
'title' => 'Admin Surface Suspected Artwork',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.maturity.main'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Moderation/ArtworkMaturityQueue')
|
||||
->where('initialItems.0.id', $suspected->id)
|
||||
->where('endpoints.list', route('admin.cp.artworks.maturity.queue'))
|
||||
->where('endpoints.reviewPattern', route('admin.cp.artworks.maturity.review', ['artwork' => '__ARTWORK__']))
|
||||
);
|
||||
});
|
||||
|
||||
it('allows cpad admins to open the legacy maturity queue url', function () {
|
||||
Queue::fake();
|
||||
|
||||
$admin = createMaturityQueueAdmin();
|
||||
$suspected = Artwork::factory()->create([
|
||||
'title' => 'Legacy Url Suspected Artwork',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get('/cp/maturity')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Moderation/ArtworkMaturityQueue')
|
||||
->where('initialItems.0.id', $suspected->id)
|
||||
->where('endpoints.list', route('cp.maturity.list'))
|
||||
->where('endpoints.reviewPattern', route('cp.maturity.review', ['artwork' => '__ARTWORK__']))
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to the audit queue when suspected items are empty but audit candidates exist', function () {
|
||||
Queue::fake();
|
||||
|
||||
$admin = createMaturityQueueAdmin();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Audit First Queue Candidate',
|
||||
'hash' => 'audit-first-queue-candidate',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_declared_at' => null,
|
||||
'maturity_reviewed_at' => null,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('artwork_maturity_audit_findings')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => 'open',
|
||||
'thumbnail_variant' => 'md',
|
||||
'ai_label' => 'mature',
|
||||
'ai_confidence' => 0.8442,
|
||||
'ai_score' => 0.8442,
|
||||
'ai_labels' => json_encode(['nudity'], JSON_UNESCAPED_SLASHES),
|
||||
'ai_model' => 'vision-maturity-v2',
|
||||
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'ai_advisory' => 'Needs manual review.',
|
||||
'detected_at' => now(),
|
||||
'last_scanned_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get('/cp/maturity')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Moderation/ArtworkMaturityQueue')
|
||||
->where('initialFilters.status', 'audit')
|
||||
->where('initialItems.0.id', $artwork->id)
|
||||
->where('stats.audit', 1)
|
||||
->where('stats.suspected', 0)
|
||||
);
|
||||
});
|
||||
|
||||
it('filters the moderation queue by AI action hint', function () {
|
||||
Queue::fake();
|
||||
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
|
||||
$flagHigh = Artwork::factory()->create([
|
||||
'title' => 'Flag High Artwork',
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
Artwork::factory()->create([
|
||||
'title' => 'Review Artwork',
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'maturity_ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->getJson('/cp/maturity/queue?status=suspected&ai_action=flag_high')
|
||||
->assertOk()
|
||||
->assertJsonPath('meta.filters.ai_action', ArtworkMaturityService::AI_ACTION_FLAG_HIGH)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $flagHigh->id);
|
||||
});
|
||||
|
||||
it('records audit findings for legacy artworks without mutating artwork maturity fields', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Legacy Audit Candidate',
|
||||
'hash' => 'abc123def456',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_declared_at' => null,
|
||||
'maturity_reviewed_at' => null,
|
||||
]);
|
||||
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.maturity.base_url', 'https://vision.test');
|
||||
config()->set('vision.maturity.endpoint', '/analyze/maturity');
|
||||
|
||||
Http::fake([
|
||||
'https://vision.test/*' => Http::response([
|
||||
'status' => 'succeeded',
|
||||
'maturity_label' => 'mature',
|
||||
'confidence' => 0.9123,
|
||||
'score' => 0.9123,
|
||||
'action_hint' => 'review',
|
||||
'labels' => ['nudity'],
|
||||
'model' => 'vision-maturity-v2',
|
||||
'advisory' => 'Needs moderator confirmation.',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->artisan('artworks:audit-thumbnail-maturity', ['--limit' => 1])
|
||||
->assertSuccessful();
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR)
|
||||
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_LEGACY)
|
||||
->and($artwork->is_mature)->toBeFalse();
|
||||
|
||||
$this->assertDatabaseHas('artwork_maturity_audit_findings', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => 'open',
|
||||
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows audit candidates in cpad and resolves them after moderator review', function () {
|
||||
Queue::fake();
|
||||
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Legacy Queue Candidate',
|
||||
'hash' => 'queuecandidate123',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_source' => ArtworkMaturityService::SOURCE_LEGACY,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_declared_at' => null,
|
||||
'maturity_reviewed_at' => null,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('artwork_maturity_audit_findings')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => 'open',
|
||||
'thumbnail_variant' => 'md',
|
||||
'ai_label' => 'mature',
|
||||
'ai_confidence' => 0.8442,
|
||||
'ai_score' => 0.8442,
|
||||
'ai_labels' => json_encode(['nudity'], JSON_UNESCAPED_SLASHES),
|
||||
'ai_model' => 'vision-maturity-v2',
|
||||
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'ai_advisory' => 'Needs manual review.',
|
||||
'detected_at' => now(),
|
||||
'last_scanned_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->getJson('/cp/maturity/queue?status=audit')
|
||||
->assertOk()
|
||||
->assertJsonPath('meta.status', 'audit')
|
||||
->assertJsonPath('meta.stats.audit', 1)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $artwork->id)
|
||||
->assertJsonPath('data.0.audit.ai_action_hint', ArtworkMaturityService::AI_ACTION_REVIEW)
|
||||
->assertJsonPath('data.0.audit.legacy_unset', true);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->postJson("/cp/maturity/{$artwork->id}/review", [
|
||||
'action' => 'mark_mature',
|
||||
'note' => 'Confirmed from audit queue.',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
|
||||
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
|
||||
->and($artwork->is_mature)->toBeTrue();
|
||||
|
||||
$this->assertDatabaseHas('artwork_maturity_audit_findings', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => 'reviewed',
|
||||
'resolved_by' => $moderator->id,
|
||||
'resolution_action' => 'mark_mature',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows cpad admins to review artwork maturity from the artwork admin surface', function () {
|
||||
Queue::fake();
|
||||
|
||||
$admin = createMaturityQueueAdmin();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Artwork Admin Review Candidate',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.maturity.review', ['artwork' => $artwork->id]), [
|
||||
'action' => 'mark_mature',
|
||||
'note' => 'Reviewed from artwork admin surface.',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('artwork.id', $artwork->id);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->is_mature)->toBeTrue()
|
||||
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_MATURE)
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
|
||||
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
|
||||
->and($artwork->maturity_reviewer_note)->toBe('Reviewed from artwork admin surface.');
|
||||
});
|
||||
|
||||
it('accepts controlpanel auth users when recording maturity reviews', function () {
|
||||
Queue::fake();
|
||||
|
||||
$admin = ControlPanelUser::query()->create([
|
||||
'name' => 'Legacy CP Admin',
|
||||
'email' => 'legacy-cp-admin@example.test',
|
||||
'password' => Hash::make('password'),
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
]);
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Legacy CP Review Candidate',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
|
||||
'maturity_flag_reason' => 'AI suspected mature content from: nudity',
|
||||
]);
|
||||
|
||||
DB::table('artwork_maturity_audit_findings')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => 'open',
|
||||
'thumbnail_variant' => 'md',
|
||||
'ai_label' => 'mature',
|
||||
'ai_confidence' => 0.8123,
|
||||
'ai_score' => 0.8123,
|
||||
'ai_labels' => json_encode(['nudity'], JSON_UNESCAPED_SLASHES),
|
||||
'ai_model' => 'vision-maturity-v2',
|
||||
'ai_action_hint' => ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||
'ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||
'ai_advisory' => 'Needs manual review.',
|
||||
'detected_at' => now(),
|
||||
'last_scanned_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
app(ArtworkMaturityService::class)->review($artwork, 'mark_mature', $admin, 'Reviewed from legacy cp route.');
|
||||
app(\App\Services\Maturity\ArtworkMaturityAuditService::class)->resolveFindingForReview(
|
||||
$artwork,
|
||||
$admin,
|
||||
'mark_mature',
|
||||
'Reviewed from legacy cp route.',
|
||||
);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->is_mature)->toBeTrue()
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_REVIEWED)
|
||||
->and($artwork->maturity_source)->toBe(ArtworkMaturityService::SOURCE_MODERATOR)
|
||||
->and($artwork->maturity_reviewed_by)->toBe($admin->id)
|
||||
->and($artwork->maturity_reviewer_note)->toBe('Reviewed from legacy cp route.');
|
||||
|
||||
$this->assertDatabaseHas('artwork_maturity_audit_findings', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => 'reviewed',
|
||||
'resolved_by' => $admin->id,
|
||||
'resolution_action' => 'mark_mature',
|
||||
]);
|
||||
});
|
||||
|
||||
it('records a failed maturity detection job without marking the artwork safe by implication', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING,
|
||||
'maturity_ai_label' => null,
|
||||
]);
|
||||
|
||||
$job = new DetectArtworkMaturityJob($artwork->id, 'fake-hash');
|
||||
$job->failed(new RuntimeException('Vision gateway timeout.'));
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->is_mature)->toBeFalse()
|
||||
->and($artwork->maturity_level)->toBe(ArtworkMaturityService::LEVEL_SAFE)
|
||||
->and($artwork->maturity_status)->toBe(ArtworkMaturityService::STATUS_CLEAR)
|
||||
->and($artwork->maturity_ai_status)->toBe(ArtworkMaturityService::AI_STATUS_FAILED)
|
||||
->and($artwork->maturity_ai_advisory)->toBe('Vision gateway timeout.')
|
||||
->and($artwork->maturity_ai_label)->toBeNull();
|
||||
});
|
||||
|
||||
it('hides mature items from the daily uploads page for hide-mode viewers', function () {
|
||||
$viewer = User::factory()->create();
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
'user_id' => $viewer->id,
|
||||
'mature_content_visibility' => 'hide',
|
||||
'mature_content_warning_enabled' => true,
|
||||
]);
|
||||
|
||||
$safeArtwork = Artwork::factory()->create([
|
||||
'title' => 'Daily Safe Artwork',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'published_at' => now(),
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
]);
|
||||
|
||||
$matureArtwork = Artwork::factory()->create([
|
||||
'title' => 'Daily Hidden Mature Artwork',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'published_at' => now(),
|
||||
'is_mature' => true,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
|
||||
]);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('uploads.daily'))
|
||||
->assertOk()
|
||||
->assertSee('Daily Safe Artwork')
|
||||
->assertDontSee('Daily Hidden Mature Artwork');
|
||||
|
||||
expect($safeArtwork->exists)->toBeTrue()
|
||||
->and($matureArtwork->exists)->toBeTrue();
|
||||
});
|
||||
|
||||
it('filters collection artworks and cover fallbacks for hide-mode viewers', function () {
|
||||
$viewer = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
'user_id' => $viewer->id,
|
||||
'mature_content_visibility' => 'hide',
|
||||
'mature_content_warning_enabled' => true,
|
||||
]);
|
||||
|
||||
$collection = Collection::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$matureArtwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Hidden Cover Artwork',
|
||||
'hash' => 'mature-cover-hash',
|
||||
'thumb_ext' => 'jpg',
|
||||
'is_mature' => true,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$safeArtwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Visible Safe Artwork',
|
||||
'hash' => 'safe-cover-hash',
|
||||
'thumb_ext' => 'jpg',
|
||||
'is_mature' => false,
|
||||
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
|
||||
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$collection->forceFill([
|
||||
'cover_artwork_id' => $matureArtwork->id,
|
||||
])->save();
|
||||
|
||||
DB::table('collection_artwork')->insert([
|
||||
[
|
||||
'collection_id' => $collection->id,
|
||||
'artwork_id' => $matureArtwork->id,
|
||||
'order_num' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'collection_id' => $collection->id,
|
||||
'artwork_id' => $safeArtwork->id,
|
||||
'order_num' => 2,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(CollectionService::class);
|
||||
$cardPayload = $service->mapCollectionCardPayloads(
|
||||
collect([$collection->fresh()->loadMissing(['user.profile', 'coverArtwork'])]),
|
||||
false,
|
||||
$viewer,
|
||||
)[0];
|
||||
$artworks = $service->getCollectionDetailArtworks($collection->fresh(), false, 24, $viewer);
|
||||
|
||||
expect($cardPayload['cover_artwork_id'])->toBe($safeArtwork->id)
|
||||
->and($artworks->getCollection()->pluck('id')->all())->toBe([$safeArtwork->id]);
|
||||
});
|
||||
279
tests/Feature/Profile/CreatorJourneyTest.php
Normal file
279
tests/Feature/Profile/CreatorJourneyTest.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\GroupReleaseContributor;
|
||||
use App\Models\User;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedCreatorJourneyFixture(): User
|
||||
{
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'journeymaker',
|
||||
'is_active' => true,
|
||||
'created_at' => Carbon::parse('2020-01-02 09:00:00'),
|
||||
]);
|
||||
|
||||
$hiddenArtwork = Artwork::factory()->for($creator)->private()->create([
|
||||
'title' => 'Hidden Draft',
|
||||
'slug' => 'hidden-draft',
|
||||
'published_at' => Carbon::parse('2020-02-01 12:00:00'),
|
||||
]);
|
||||
|
||||
$firstArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Sky One',
|
||||
'slug' => 'sky-one',
|
||||
'published_at' => Carbon::parse('2021-03-10 10:00:00'),
|
||||
]);
|
||||
|
||||
$breakthroughArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Neon Archive',
|
||||
'slug' => 'neon-archive',
|
||||
'published_at' => Carbon::parse('2024-05-10 09:00:00'),
|
||||
]);
|
||||
|
||||
$lateYearArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Terminal Bloom',
|
||||
'slug' => 'terminal-bloom',
|
||||
'published_at' => Carbon::parse('2024-11-20 19:00:00'),
|
||||
]);
|
||||
|
||||
$latestArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Afterglow Atlas',
|
||||
'slug' => 'afterglow-atlas',
|
||||
'published_at' => Carbon::parse('2025-02-14 18:30:00'),
|
||||
]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
[
|
||||
'artwork_id' => $hiddenArtwork->id,
|
||||
'views' => 5000,
|
||||
'downloads' => 900,
|
||||
'favorites' => 400,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 35,
|
||||
'shares_count' => 12,
|
||||
'downloads_1h' => 80,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $firstArtwork->id,
|
||||
'views' => 120,
|
||||
'downloads' => 18,
|
||||
'favorites' => 9,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 2,
|
||||
'shares_count' => 0,
|
||||
'downloads_1h' => 2,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'views' => 1800,
|
||||
'downloads' => 220,
|
||||
'favorites' => 110,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 26,
|
||||
'shares_count' => 9,
|
||||
'downloads_1h' => 24,
|
||||
'heat_score_updated_at' => Carbon::parse('2024-05-10 11:00:00'),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'views' => 640,
|
||||
'downloads' => 90,
|
||||
'favorites' => 34,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 7,
|
||||
'shares_count' => 2,
|
||||
'downloads_1h' => 5,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $latestArtwork->id,
|
||||
'views' => 450,
|
||||
'downloads' => 48,
|
||||
'favorites' => 18,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 4,
|
||||
'shares_count' => 1,
|
||||
'downloads_1h' => 3,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $firstArtwork->id,
|
||||
'featured_at' => Carbon::parse('2022-06-01 13:00:00'),
|
||||
'priority' => 100,
|
||||
'label' => 'Feature',
|
||||
'is_active' => true,
|
||||
'created_by' => $creator->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('artwork_metric_snapshots_hourly')->insert([
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-05-10 10:00:00'),
|
||||
'views_count' => 1200,
|
||||
'downloads_count' => 40,
|
||||
'favourites_count' => 60,
|
||||
'comments_count' => 12,
|
||||
'shares_count' => 3,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-05-10 11:00:00'),
|
||||
'views_count' => 1320,
|
||||
'downloads_count' => 64,
|
||||
'favourites_count' => 68,
|
||||
'comments_count' => 13,
|
||||
'shares_count' => 4,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-11-20 19:00:00'),
|
||||
'views_count' => 300,
|
||||
'downloads_count' => 10,
|
||||
'favourites_count' => 12,
|
||||
'comments_count' => 1,
|
||||
'shares_count' => 0,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-11-20 20:00:00'),
|
||||
'views_count' => 360,
|
||||
'downloads_count' => 14,
|
||||
'favourites_count' => 15,
|
||||
'comments_count' => 2,
|
||||
'shares_count' => 0,
|
||||
'created_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$groupOwner = User::factory()->create(['is_active' => true]);
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create([
|
||||
'name' => 'Nova Collective',
|
||||
'slug' => 'nova-collective',
|
||||
'visibility' => Group::VISIBILITY_PUBLIC,
|
||||
'status' => Group::LIFECYCLE_ACTIVE,
|
||||
]);
|
||||
|
||||
$release = GroupRelease::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'First Spectrum Pack',
|
||||
'slug' => 'first-spectrum-pack',
|
||||
'summary' => 'A first collaborative release.',
|
||||
'status' => GroupRelease::STATUS_RELEASED,
|
||||
'current_stage' => GroupRelease::STAGE_RELEASED,
|
||||
'visibility' => GroupRelease::VISIBILITY_PUBLIC,
|
||||
'released_at' => Carbon::parse('2023-08-15 16:00:00'),
|
||||
'published_at' => Carbon::parse('2023-08-15 16:00:00'),
|
||||
'created_by_user_id' => $groupOwner->id,
|
||||
]);
|
||||
|
||||
GroupReleaseContributor::query()->create([
|
||||
'group_release_id' => $release->id,
|
||||
'user_id' => $creator->id,
|
||||
'role_label' => 'Illustrator',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
return $creator;
|
||||
}
|
||||
|
||||
it('rebuilds persisted creator milestones from public source data', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
|
||||
$this->artisan('skinbase:rebuild-creator-journey', ['user_id' => $creator->id])
|
||||
->assertExitCode(0);
|
||||
|
||||
$storedTypes = DB::table('creator_milestones')
|
||||
->where('user_id', $creator->id)
|
||||
->orderBy('type')
|
||||
->pluck('type')
|
||||
->all();
|
||||
|
||||
expect($storedTypes)->toContain(
|
||||
'first_upload',
|
||||
'first_featured_artwork',
|
||||
'first_group_release',
|
||||
'biggest_download_spike',
|
||||
'best_performing_work',
|
||||
'most_productive_year',
|
||||
'yearly_recap',
|
||||
);
|
||||
|
||||
$payload = app(CreatorJourneyService::class)->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload['summary']['available'])->toBeTrue()
|
||||
->and($payload['summary']['member_since_year'])->toBe(2020)
|
||||
->and($payload['highlights'][0]['type'])->toBe('best_performing_work')
|
||||
->and(collect($payload['timeline'])->pluck('headline')->all())->toContain('Sky One', 'First Spectrum Pack')
|
||||
->and(collect($payload['timeline'])->pluck('headline')->all())->not->toContain('Hidden Draft')
|
||||
->and($payload['yearly_recaps'][0]['metrics']['year'])->toBe(2025);
|
||||
});
|
||||
|
||||
it('returns the public creator journey api payload without leaking private content', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$response = $this->getJson(route('api.profile.journey', ['username' => $creator->username]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.summary.available', true);
|
||||
|
||||
// v2: comeback milestones appear in timeline (fixture has a 3+ year gap → legendary comeback)
|
||||
$highlightHeadlines = collect($response->json('data.highlights'))->pluck('headline')->filter()->all();
|
||||
$headlines = collect($response->json('data.timeline'))->pluck('headline')->filter()->all();
|
||||
$timelineTypes = collect($response->json('data.timeline'))->pluck('type')->filter()->values()->all();
|
||||
|
||||
expect($highlightHeadlines)->toContain('Neon Archive')
|
||||
->and($headlines)->toContain('Sky One')
|
||||
->and($headlines)->not->toContain('Hidden Draft')
|
||||
->and($timelineTypes)->toContain('biggest_download_spike');
|
||||
});
|
||||
|
||||
it('hydrates the public profile page with creator journey props', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Profile/ProfileShow')
|
||||
->where('journey.summary.available', true)
|
||||
->where('journey.summary.member_since_year', 2020)
|
||||
->where('journey.highlights', fn ($highlights) => collect($highlights)->pluck('headline')->contains('Neon Archive'))
|
||||
->where('journey.timeline', fn ($timeline) => collect($timeline)->pluck('type')->contains('biggest_download_spike'))
|
||||
->where('journey.timeline', fn ($timeline) => collect($timeline)->pluck('headline')->contains('First Spectrum Pack'))
|
||||
->where('journeyApiUrl', route('api.profile.journey', ['username' => strtolower((string) $creator->username)]))
|
||||
);
|
||||
});
|
||||
530
tests/Feature/Profile/CreatorJourneyV2Test.php
Normal file
530
tests/Feature/Profile/CreatorJourneyV2Test.php
Normal file
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\CreatorMilestoneType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\CreatorEra;
|
||||
use App\Models\User;
|
||||
use App\Services\Profile\CreatorComebackService;
|
||||
use App\Services\Profile\CreatorEraService;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use App\Services\Profile\CreatorStreakService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a fake artwork row (stdClass) matching the shape returned by
|
||||
* CreatorJourneyService::publicArtworkRows().
|
||||
*/
|
||||
function makeArtworkRow(int $id, string $publishedAt, array $overrides = []): object
|
||||
{
|
||||
return (object) array_merge([
|
||||
'id' => $id,
|
||||
'title' => "Artwork #{$id}",
|
||||
'slug' => "artwork-{$id}",
|
||||
'published_at' => $publishedAt,
|
||||
'thumbnail_url' => null,
|
||||
'art_url' => null,
|
||||
'url' => "/art/{$id}/artwork-{$id}",
|
||||
'downloads' => 0,
|
||||
'views' => 0,
|
||||
'favorites' => 0,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the $makeMilestoneRow closure expected by all v2 services.
|
||||
* Returns a plain array so tests can inspect it easily.
|
||||
*/
|
||||
function makeMilestoneRowFn(): callable
|
||||
{
|
||||
return function (
|
||||
int $userId,
|
||||
CreatorMilestoneType $type,
|
||||
\Carbon\CarbonInterface $occurredAt,
|
||||
array $payload,
|
||||
?int $relatedArtworkId,
|
||||
\Carbon\CarbonInterface $computedAt,
|
||||
): array {
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'type' => $type->value,
|
||||
'occurred_at' => $occurredAt->toDateTimeString(),
|
||||
'occurred_year' => (int) $occurredAt->format('Y'),
|
||||
'related_artwork_id' => $relatedArtworkId,
|
||||
'is_public' => true,
|
||||
'priority' => $type->priority(),
|
||||
'payload_json' => json_encode($payload),
|
||||
'computed_at' => $computedAt->toDateTimeString(),
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorComebackService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorComebackService', function () {
|
||||
it('returns empty when fewer than 2 artworks', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$rows = $svc->calculateComebacks(
|
||||
collect([makeArtworkRow(1, '2022-01-01')]),
|
||||
1,
|
||||
now(),
|
||||
makeMilestoneRowFn(),
|
||||
);
|
||||
expect($rows)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('detects a minor comeback (180–364 day gap)', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2022-01-01'),
|
||||
makeArtworkRow(2, '2022-08-01'), // ~212 days later
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackMinor->value);
|
||||
});
|
||||
|
||||
it('detects a major comeback (365–1094 day gap)', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2020-01-01'),
|
||||
makeArtworkRow(2, '2021-06-01'), // ~517 days
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackMajor->value);
|
||||
});
|
||||
|
||||
it('detects a legendary comeback (1095+ day gap)', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2018-01-01'),
|
||||
makeArtworkRow(2, '2021-08-15'), // ~1326 days
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackLegendary->value);
|
||||
});
|
||||
|
||||
it('does not fire a comeback for a gap shorter than 180 days', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-01'),
|
||||
makeArtworkRow(2, '2023-05-01'), // ~120 days
|
||||
makeArtworkRow(3, '2023-07-01'), // ~61 days
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('fires one milestone per comeback gap even if multiple thresholds match', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
// 3+ year gap → only legendary, not also major or minor
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2017-03-01'),
|
||||
makeArtworkRow(2, '2021-03-01'), // exactly 1461 days (4 years)
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackLegendary->value);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorStreakService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorStreakService', function () {
|
||||
it('returns zero streaks for empty collection', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$stats = $svc->computeStreakStats(collect());
|
||||
|
||||
expect($stats['current_monthly_streak'])->toBe(0)
|
||||
->and($stats['best_monthly_streak'])->toBe(0)
|
||||
->and($stats['current_year_streak'])->toBe(0)
|
||||
->and($stats['best_year_streak'])->toBe(0);
|
||||
});
|
||||
|
||||
it('computes a 3-month consecutive upload streak', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-15'),
|
||||
makeArtworkRow(2, '2023-02-10'),
|
||||
makeArtworkRow(3, '2023-03-20'),
|
||||
]);
|
||||
|
||||
$stats = $svc->computeStreakStats($artworks);
|
||||
|
||||
expect($stats['best_monthly_streak'])->toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('does not inflate streak across non-consecutive months', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
// Jan, Mar (skipped Feb) → streak of 1 each
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-15'),
|
||||
makeArtworkRow(2, '2023-03-10'),
|
||||
]);
|
||||
|
||||
$stats = $svc->computeStreakStats($artworks);
|
||||
|
||||
expect($stats['best_monthly_streak'])->toBe(1);
|
||||
});
|
||||
|
||||
it('returns upload_streak_3 milestone when best streak is 3+ months', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-15'),
|
||||
makeArtworkRow(2, '2023-02-10'),
|
||||
makeArtworkRow(3, '2023-03-20'),
|
||||
]);
|
||||
|
||||
$milestones = $svc->calculateStreakMilestones($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
$types = array_column($milestones, 'type');
|
||||
expect($types)->toContain(CreatorMilestoneType::UploadStreak3->value);
|
||||
});
|
||||
|
||||
it('only inserts the best streak milestone, not lesser tiers', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
// Build 12 consecutive months
|
||||
$artworks = collect(range(1, 12))->map(
|
||||
fn (int $i): object => makeArtworkRow($i, Carbon::create(2022, $i, 10)->toDateTimeString())
|
||||
);
|
||||
|
||||
$milestones = $svc->calculateStreakMilestones($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
$types = array_column($milestones, 'type');
|
||||
expect($types)->toContain(CreatorMilestoneType::UploadStreak12->value)
|
||||
->and($types)->not->toContain(CreatorMilestoneType::UploadStreak3->value)
|
||||
->and($types)->not->toContain(CreatorMilestoneType::UploadStreak6->value);
|
||||
});
|
||||
|
||||
it('computes a 3-year active streak', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2020-06-01'),
|
||||
makeArtworkRow(2, '2021-03-15'),
|
||||
makeArtworkRow(3, '2022-11-20'),
|
||||
]);
|
||||
|
||||
$stats = $svc->computeStreakStats($artworks);
|
||||
|
||||
expect($stats['best_year_streak'])->toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('returns active_year_streak_3 milestone when best year streak is 3+', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2020-06-01'),
|
||||
makeArtworkRow(2, '2021-03-15'),
|
||||
makeArtworkRow(3, '2022-11-20'),
|
||||
]);
|
||||
|
||||
$milestones = $svc->calculateStreakMilestones($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
$types = array_column($milestones, 'type');
|
||||
expect($types)->toContain(CreatorMilestoneType::ActiveYearStreak3->value);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorEraService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorEraService', function () {
|
||||
it('creates an early_years era for a creator with uploads', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::parse('2020-01-01')]);
|
||||
$artwork = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2020-06-01')]);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$artworks = collect([makeArtworkRow($artwork->id, '2020-06-01')]);
|
||||
$svc->rebuildForUser($creator, $artworks);
|
||||
|
||||
$eras = CreatorEra::where('user_id', $creator->id)->get();
|
||||
expect($eras->pluck('era_type')->all())->toContain('early_years');
|
||||
});
|
||||
|
||||
it('creates a breakthrough era when a featured artwork exists', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::parse('2019-01-01')]);
|
||||
$a1 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2019-03-01')]);
|
||||
$a2 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2021-07-01')]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $a1->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
['artwork_id' => $a2->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $a1->id,
|
||||
'featured_at' => Carbon::parse('2021-01-15'),
|
||||
'priority' => 100,
|
||||
'label' => 'Feature',
|
||||
'is_active' => true,
|
||||
'created_by' => $creator->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow($a1->id, '2019-03-01'),
|
||||
makeArtworkRow($a2->id, '2021-07-01'),
|
||||
]);
|
||||
$svc->rebuildForUser($creator, $artworks);
|
||||
|
||||
$eraTypes = CreatorEra::where('user_id', $creator->id)->pluck('era_type')->all();
|
||||
expect($eraTypes)->toContain('breakthrough');
|
||||
});
|
||||
|
||||
it('creates a comeback era after a 180+ day gap', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::parse('2018-01-01')]);
|
||||
$a1 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2018-06-01')]);
|
||||
$a2 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2019-06-01')]); // 365 days
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $a1->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
['artwork_id' => $a2->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow($a1->id, '2018-06-01'),
|
||||
makeArtworkRow($a2->id, '2019-06-01'),
|
||||
]);
|
||||
$svc->rebuildForUser($creator, $artworks);
|
||||
|
||||
$eraTypes = CreatorEra::where('user_id', $creator->id)->pluck('era_type')->all();
|
||||
expect($eraTypes)->toContain('comeback');
|
||||
});
|
||||
|
||||
it('marks exactly one era as is_current', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::now()->subYears(2)]);
|
||||
$artwork = Artwork::factory()->for($creator)->create(['published_at' => Carbon::now()->subMonths(6)]);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$svc->rebuildForUser($creator, collect([makeArtworkRow($artwork->id, Carbon::now()->subMonths(6)->toDateTimeString())]));
|
||||
|
||||
$currentCount = CreatorEra::where('user_id', $creator->id)->where('is_current', true)->count();
|
||||
expect($currentCount)->toBe(1);
|
||||
});
|
||||
|
||||
it('publicErasForUser returns formatted eras in ascending order', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::now()->subYears(2)]);
|
||||
$artwork = Artwork::factory()->for($creator)->create(['published_at' => Carbon::now()->subMonths(6)]);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$svc->rebuildForUser($creator, collect([makeArtworkRow($artwork->id, Carbon::now()->subMonths(6)->toDateTimeString())]));
|
||||
|
||||
$eras = $svc->publicErasForUser($creator->id);
|
||||
|
||||
expect($eras)->not->toBeEmpty();
|
||||
expect($eras[0])->toHaveKeys(['type', 'title', 'starts_at', 'is_current']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorJourneyService — v2 payload shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorJourneyService v2 payload', function () {
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed a creator with a simple 3-artwork history (no long gaps, no features).
|
||||
*/
|
||||
function seedSimpleCreator(): User
|
||||
{
|
||||
$creator = User::factory()->create([
|
||||
'is_active' => true,
|
||||
'created_at' => Carbon::parse('2021-01-01'),
|
||||
]);
|
||||
|
||||
$artworks = [
|
||||
['published_at' => Carbon::parse('2021-02-01'), 'title' => 'Alpha'],
|
||||
['published_at' => Carbon::parse('2021-03-01'), 'title' => 'Beta'],
|
||||
['published_at' => Carbon::parse('2021-04-01'), 'title' => 'Gamma'],
|
||||
];
|
||||
|
||||
foreach ($artworks as $data) {
|
||||
$art = Artwork::factory()->for($creator)->create($data);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $art->id, 'views' => 10, 'downloads' => 5, 'favorites' => 2,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $creator;
|
||||
}
|
||||
|
||||
it('includes eras key in the public payload after rebuild', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('eras');
|
||||
expect($payload['eras'])->toBeArray();
|
||||
});
|
||||
|
||||
it('includes streaks key with expected sub-keys in the public payload', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('streaks');
|
||||
expect($payload['streaks'])->toHaveKeys([
|
||||
'current_monthly_upload_streak',
|
||||
'best_monthly_upload_streak',
|
||||
'current_active_year_streak',
|
||||
'best_active_year_streak',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes evolution key in the public payload', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('evolution');
|
||||
expect($payload['evolution'])->toBeArray();
|
||||
});
|
||||
|
||||
it('includes shareable_recaps key in the public payload', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('shareable_recaps');
|
||||
expect($payload['shareable_recaps'])->toBeArray();
|
||||
});
|
||||
|
||||
it('rebuilds eras in the creator_eras table when rebuildForUser is called', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$eraCount = CreatorEra::where('user_id', $creator->id)->count();
|
||||
expect($eraCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes comeback milestones in public milestones when a large gap is present', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create([
|
||||
'is_active' => true,
|
||||
'created_at' => Carbon::parse('2018-01-01'),
|
||||
]);
|
||||
|
||||
$dates = [
|
||||
['published_at' => Carbon::parse('2018-06-01'), 'title' => 'Early Work'],
|
||||
['published_at' => Carbon::parse('2019-12-01'), 'title' => 'Return Work'], // ~548 days
|
||||
];
|
||||
|
||||
foreach ($dates as $data) {
|
||||
$art = Artwork::factory()->for($creator)->create($data);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $art->id, 'views' => 10, 'downloads' => 5, 'favorites' => 2,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$types = DB::table('creator_milestones')
|
||||
->where('user_id', $creator->id)
|
||||
->pluck('type')
|
||||
->all();
|
||||
|
||||
expect($types)->toContain(CreatorMilestoneType::ComebackMajor->value);
|
||||
});
|
||||
|
||||
it('v2 payload is returned via the public API endpoint', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$response = $this->getJson(route('api.profile.journey', ['username' => $creator->username]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data');
|
||||
|
||||
expect($data)->toHaveKey('eras')
|
||||
->and($data)->toHaveKey('streaks')
|
||||
->and($data)->toHaveKey('evolution');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Services\HomepageService;
|
||||
@@ -70,3 +71,48 @@ it('allForUser for_you returns items when cache exists', function () {
|
||||
expect($result['for_you'][0])->toHaveKeys(['id', 'title', 'slug', 'url']);
|
||||
}
|
||||
});
|
||||
|
||||
it('allForUser for_you uses group publisher payload for group-published artworks', function () {
|
||||
$user = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$group = Group::factory()->create([
|
||||
'owner_user_id' => $owner->id,
|
||||
'name' => 'Nova Group',
|
||||
'slug' => 'nova-group',
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'group_id' => $group->id,
|
||||
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
|
||||
'title' => 'Group Recommended Artwork',
|
||||
'hash' => 'grouprecommendedartwork',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
UserRecommendationCache::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'),
|
||||
'cache_version' => (string) config('discovery.cache_version', 'cache-v1'),
|
||||
'recommendations_json' => [
|
||||
'items' => [
|
||||
['artwork_id' => $artwork->id, 'score' => 0.91, 'source' => 'profile'],
|
||||
],
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addMinutes(60),
|
||||
]);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$result = app(HomepageService::class)->allForUser($user);
|
||||
|
||||
expect($result['for_you'])->not->toBeEmpty()
|
||||
->and($result['for_you'][0]['author'])->toBe('Nova Group')
|
||||
->and($result['for_you'][0]['published_as_type'])->toBe(Artwork::PUBLISHED_AS_GROUP)
|
||||
->and($result['for_you'][0]['publisher']['type'])->toBe('group')
|
||||
->and($result['for_you'][0]['publisher']['name'])->toBe('Nova Group');
|
||||
});
|
||||
|
||||
@@ -10,6 +10,15 @@ declare(strict_types=1);
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['wallpapers', 'skins', 'photography', 'other'] as $slug) {
|
||||
ContentType::query()->firstOrCreate(
|
||||
['slug' => $slug],
|
||||
['name' => ucfirst($slug)]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── /explore routes ──────────────────────────────────────────────────────────
|
||||
|
||||
it('GET /explore returns 200', function () {
|
||||
|
||||
198
tests/Feature/Studio/ArtworkEvolutionApiTest.php
Normal file
198
tests/Feature/Studio/ArtworkEvolutionApiTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkRelation;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\putJson;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeEvolutionPublishedArtwork(User $user, string $title, string $publishedAt, array $overrides = []): Artwork
|
||||
{
|
||||
return Artwork::factory()->create(array_merge([
|
||||
'user_id' => $user->id,
|
||||
'title' => $title,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => Carbon::parse($publishedAt),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
it('syncs a primary artwork evolution relation from the studio save endpoint', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$original = makeEvolutionPublishedArtwork($user, 'Original version', '2021-04-11 09:00:00');
|
||||
$updated = makeEvolutionPublishedArtwork($user, 'Updated version', '2025-04-11 09:00:00');
|
||||
|
||||
actingAs($user);
|
||||
|
||||
putJson('/api/studio/artworks/' . $updated->id, [
|
||||
'evolution_target_artwork_id' => $original->id,
|
||||
'evolution_relation_type' => ArtworkRelation::TYPE_REMASTER_OF,
|
||||
'evolution_note' => 'Much cleaner materials and a stronger silhouette.',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('artwork.evolution_relation.relation_type', ArtworkRelation::TYPE_REMASTER_OF)
|
||||
->assertJsonPath('artwork.evolution_relation.target_artwork.id', $original->id)
|
||||
->assertJsonPath('artwork.evolution_relation.note', 'Much cleaner materials and a stronger silhouette.');
|
||||
|
||||
$this->assertDatabaseHas('artwork_relations', [
|
||||
'source_artwork_id' => $updated->id,
|
||||
'target_artwork_id' => $original->id,
|
||||
'relation_type' => ArtworkRelation::TYPE_REMASTER_OF,
|
||||
'note' => 'Much cleaner materials and a stronger silhouette.',
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects linking an artwork the actor does not manage', function (): void {
|
||||
$owner = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$updated = makeEvolutionPublishedArtwork($owner, 'My newer piece', '2025-04-11 09:00:00');
|
||||
$foreignOriginal = makeEvolutionPublishedArtwork($otherUser, 'Someone else original', '2020-04-11 09:00:00');
|
||||
|
||||
actingAs($owner);
|
||||
|
||||
putJson('/api/studio/artworks/' . $updated->id, [
|
||||
'evolution_target_artwork_id' => $foreignOriginal->id,
|
||||
'evolution_relation_type' => ArtworkRelation::TYPE_REMAKE_OF,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.evolution_target_artwork_id.0', 'You can only link artworks that you are allowed to manage.');
|
||||
|
||||
$this->assertDatabaseCount('artwork_relations', 0);
|
||||
});
|
||||
|
||||
it('limits evolution options to older manageable public artworks', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00');
|
||||
$eligible = makeEvolutionPublishedArtwork($user, 'Older eligible version', '2021-04-11 09:00:00');
|
||||
$tooNew = makeEvolutionPublishedArtwork($user, 'Too new version', '2026-04-11 09:00:00');
|
||||
makeEvolutionPublishedArtwork($user, 'Private original', '2020-04-11 09:00:00', [
|
||||
'visibility' => Artwork::VISIBILITY_PRIVATE,
|
||||
'is_public' => false,
|
||||
]);
|
||||
makeEvolutionPublishedArtwork($otherUser, 'Other user original', '2020-04-11 09:00:00');
|
||||
|
||||
actingAs($user);
|
||||
|
||||
getJson('/api/studio/artworks/' . $source->id . '/evolution-options')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $eligible->id)
|
||||
->assertJsonMissing(['id' => $tooNew->id]);
|
||||
});
|
||||
|
||||
it('sorts evolution options by vector similarity when available', function (): void {
|
||||
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();
|
||||
|
||||
$source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00', [
|
||||
'hash' => 'sourcehash1234',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
$recentButLessSimilar = makeEvolutionPublishedArtwork($user, 'Recent candidate', '2024-04-11 09:00:00');
|
||||
$olderButMoreSimilar = makeEvolutionPublishedArtwork($user, 'Most similar original', '2021-04-11 09:00:00');
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search' => Http::response([
|
||||
'results' => [
|
||||
['id' => $source->id, 'score' => 1.0],
|
||||
['id' => $olderButMoreSimilar->id, 'score' => 0.98123],
|
||||
['id' => $recentButLessSimilar->id, 'score' => 0.61234],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
getJson('/api/studio/artworks/' . $source->id . '/evolution-options')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $olderButMoreSimilar->id)
|
||||
->assertJsonPath('data.0.sort_source', 'vector_similarity')
|
||||
->assertJsonPath('data.0.similarity_score', 0.98123)
|
||||
->assertJsonPath('data.1.id', $recentButLessSimilar->id);
|
||||
});
|
||||
|
||||
it('keeps deeper vector matches ahead of recency fallback in evolution options', function (): void {
|
||||
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();
|
||||
|
||||
$source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00', [
|
||||
'hash' => 'sourcehash5678',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
$recentFallback = makeEvolutionPublishedArtwork($user, 'Recent fallback candidate', '2024-04-11 09:00:00');
|
||||
$deepSimilarMatch = makeEvolutionPublishedArtwork($user, 'Deep similar original', '2020-04-11 09:00:00');
|
||||
|
||||
$results = collect(range(1, 28))->map(fn (int $offset): array => [
|
||||
'id' => 900000 + $offset,
|
||||
'score' => 0.95 - ($offset * 0.01),
|
||||
])->all();
|
||||
|
||||
array_unshift($results, ['id' => $source->id, 'score' => 1.0]);
|
||||
$results[] = ['id' => $deepSimilarMatch->id, 'score' => 0.73123];
|
||||
$results[] = ['id' => $recentFallback->id, 'score' => 0.51234];
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search' => Http::response([
|
||||
'results' => $results,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
getJson('/api/studio/artworks/' . $source->id . '/evolution-options')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $deepSimilarMatch->id)
|
||||
->assertJsonPath('data.0.sort_source', 'vector_similarity')
|
||||
->assertJsonPath('data.1.id', $recentFallback->id);
|
||||
});
|
||||
|
||||
it('includes bidirectional artwork evolution payloads on the public page api', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$original = makeEvolutionPublishedArtwork($user, 'Original scene', '2019-04-11 09:00:00');
|
||||
$updated = makeEvolutionPublishedArtwork($user, 'Updated scene', '2025-04-11 09:00:00');
|
||||
|
||||
ArtworkRelation::query()->create([
|
||||
'source_artwork_id' => $updated->id,
|
||||
'target_artwork_id' => $original->id,
|
||||
'relation_type' => ArtworkRelation::TYPE_REMAKE_OF,
|
||||
'note' => 'Rebuilt from scratch with a new lighting pass.',
|
||||
'sort_order' => 0,
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
getJson('/api/artworks/' . $updated->id . '/page')
|
||||
->assertOk()
|
||||
->assertJsonPath('evolution.primary.before.id', $original->id)
|
||||
->assertJsonPath('evolution.primary.after.id', $updated->id)
|
||||
->assertJsonPath('evolution.primary.heading', 'Then & Now');
|
||||
|
||||
getJson('/api/artworks/' . $original->id . '/page')
|
||||
->assertOk()
|
||||
->assertJsonPath('evolution.updates.0.before.id', $original->id)
|
||||
->assertJsonPath('evolution.updates.0.after.id', $updated->id)
|
||||
->assertJsonPath('evolution.updates.0.heading', 'Updated Version');
|
||||
});
|
||||
160
tests/Feature/ToolbarExploreMenuTest.php
Normal file
160
tests/Feature/ToolbarExploreMenuTest.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function toolbarEmptyPaginator(string $path = '/'): LengthAwarePaginator
|
||||
{
|
||||
return (new LengthAwarePaginator(collect(), 0, 20, 1))->setPath($path);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->artworksMock = \Mockery::mock(ArtworkService::class);
|
||||
$this->artworksMock->shouldReceive('getFeaturedArtworks')->andReturn(toolbarEmptyPaginator('/'))->byDefault();
|
||||
$this->artworksMock->shouldReceive('getFeaturedArtworkWinner')->andReturn(null)->byDefault();
|
||||
$this->artworksMock->shouldReceive('getLatestArtworks')->andReturn(collect())->byDefault();
|
||||
$this->app->instance(ArtworkService::class, $this->artworksMock);
|
||||
});
|
||||
|
||||
function exploreMenuItems(string $html): array
|
||||
{
|
||||
$document = new DOMDocument();
|
||||
@$document->loadHTML($html);
|
||||
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//div[@id="dd-browse"]/a');
|
||||
|
||||
$items = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$items[] = [
|
||||
'href' => (string) $node->attributes->getNamedItem('href')?->nodeValue,
|
||||
'label' => trim(preg_replace('/\s+/', ' ', $node->textContent ?? '') ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
it('orders explore menu content types from the database and appends categories and tags', function () {
|
||||
DB::table('content_types')->insert([
|
||||
['name' => 'Wallpapers', 'slug' => 'wallpapers', 'description' => null, 'order' => 2],
|
||||
['name' => 'Digital Art', 'slug' => 'digital-art', 'description' => null, 'order' => 1],
|
||||
['name' => 'Photography', 'slug' => 'photography', 'description' => null, 'order' => 3],
|
||||
]);
|
||||
|
||||
$items = exploreMenuItems($this->get('/')->assertOk()->getContent());
|
||||
|
||||
expect(array_map(fn (array $item) => $item['label'], $items))->toBe([
|
||||
'All Artworks',
|
||||
'Digital Art',
|
||||
'Wallpapers',
|
||||
'Photography',
|
||||
'Categories',
|
||||
'Tags',
|
||||
]);
|
||||
|
||||
expect(array_map(fn (array $item) => $item['href'], $items))->toBe([
|
||||
'/explore',
|
||||
'/digital-art',
|
||||
'/wallpapers',
|
||||
'/photography',
|
||||
route('categories.index'),
|
||||
'/tags',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reflects updated content type ordering on the next request', function () {
|
||||
DB::table('content_types')->insert([
|
||||
['name' => 'Wallpapers', 'slug' => 'wallpapers', 'description' => null, 'order' => 1],
|
||||
['name' => 'Digital Art', 'slug' => 'digital-art', 'description' => null, 'order' => 2],
|
||||
]);
|
||||
|
||||
$initialItems = exploreMenuItems($this->get('/')->assertOk()->getContent());
|
||||
|
||||
expect(array_map(fn (array $item) => $item['label'], $initialItems))->toBe([
|
||||
'All Artworks',
|
||||
'Wallpapers',
|
||||
'Digital Art',
|
||||
'Categories',
|
||||
'Tags',
|
||||
]);
|
||||
|
||||
ContentType::query()->where('slug', 'digital-art')->firstOrFail()->update(['order' => 1]);
|
||||
ContentType::query()->where('slug', 'wallpapers')->firstOrFail()->update(['order' => 2]);
|
||||
|
||||
$updatedItems = exploreMenuItems($this->get('/')->assertOk()->getContent());
|
||||
|
||||
expect(array_map(fn (array $item) => $item['label'], $updatedItems))->toBe([
|
||||
'All Artworks',
|
||||
'Digital Art',
|
||||
'Wallpapers',
|
||||
'Categories',
|
||||
'Tags',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits content types marked hidden from the explore menu', function () {
|
||||
DB::table('content_types')->insert([
|
||||
['name' => 'Wallpapers', 'slug' => 'wallpapers', 'description' => null, 'order' => 1, 'hide_from_menu' => false],
|
||||
['name' => 'Private Type', 'slug' => 'private-type', 'description' => null, 'order' => 2, 'hide_from_menu' => true],
|
||||
['name' => 'Photography', 'slug' => 'photography', 'description' => null, 'order' => 3, 'hide_from_menu' => false],
|
||||
]);
|
||||
|
||||
$items = exploreMenuItems($this->get('/')->assertOk()->getContent());
|
||||
|
||||
expect(array_map(fn (array $item) => $item['label'], $items))->toBe([
|
||||
'All Artworks',
|
||||
'Wallpapers',
|
||||
'Photography',
|
||||
'Categories',
|
||||
'Tags',
|
||||
]);
|
||||
|
||||
expect(array_map(fn (array $item) => $item['href'], $items))->not->toContain('/private-type');
|
||||
});
|
||||
|
||||
it('shows a verification reminder in the toolbar for unverified users', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Verify email')
|
||||
->assertSee('Verify your email')
|
||||
->assertSee($user->email)
|
||||
->assertSee('/verify-email', false)
|
||||
->assertSee('/email/verification-notification', false)
|
||||
->assertSee('Resend verification email');
|
||||
});
|
||||
|
||||
it('shows resend success feedback in the toolbar reminder after sending a verification email', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession(['status' => 'verification-link-sent'])
|
||||
->get('/');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('A fresh verification link was sent to your email.');
|
||||
});
|
||||
|
||||
it('hides the verification reminder for verified users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertDontSee('Verify your email')
|
||||
->assertDontSee('Resend verification email');
|
||||
});
|
||||
36
tests/Feature/Uploads/UploadContentTypeAssetsTest.php
Normal file
36
tests/Feature/Uploads/UploadContentTypeAssetsTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('exposes mascot and cover art urls for upload content types', function () {
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
'mascot_path' => 'content-types/digital-art/mascot.webp',
|
||||
'cover_art_path' => 'content-types/digital-art/cover.webp',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
expect($contentTypeId)->toBeInt();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/upload')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Upload/Index')
|
||||
->has('content_types', 1)
|
||||
->where('content_types.0.slug', 'digital-art')
|
||||
->where('content_types.0.mascot_url', rtrim((string) config('cdn.files_url'), '/') . '/content-types/digital-art/mascot.webp')
|
||||
->where('content_types.0.cover_art_url', rtrim((string) config('cdn.files_url'), '/') . '/content-types/digital-art/cover.webp')
|
||||
);
|
||||
});
|
||||
60
tests/Unit/ArtworkServiceContentTypeResolutionTest.php
Normal file
60
tests/Unit/ArtworkServiceContentTypeResolutionTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ContentTypeSlugHistory;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('resolves historical content type slugs in artwork service content type browsing', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Illustration',
|
||||
'slug' => 'digital-illustration',
|
||||
'description' => 'Digital illustration uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'digital-art',
|
||||
]);
|
||||
|
||||
$paginator = app(ArtworkService::class)->getArtworksByContentType('digital-art', 24);
|
||||
|
||||
expect($paginator->count())->toBe(0)
|
||||
->and($paginator->items())->toBeArray();
|
||||
});
|
||||
|
||||
it('resolves historical content type slugs in artwork service category path browsing', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Illustration',
|
||||
'slug' => 'digital-illustration',
|
||||
'description' => 'Digital illustration uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'digital-art',
|
||||
]);
|
||||
|
||||
$rootCategory = $contentType->categories()->create([
|
||||
'parent_id' => null,
|
||||
'name' => 'Concepts',
|
||||
'slug' => 'concepts',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$paginator = app(ArtworkService::class)->getArtworksByCategoryPath([
|
||||
'digital-art',
|
||||
$rootCategory->slug,
|
||||
], 24);
|
||||
|
||||
expect($paginator->count())->toBe(0)
|
||||
->and($paginator->items())->toBeArray();
|
||||
});
|
||||
37
tests/Unit/CategoriesSitemapBuilderTest.php
Normal file
37
tests/Unit/CategoriesSitemapBuilderTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Sitemaps\Builders\CategoriesSitemapBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('builds category sitemap entries from dynamic content types', function () {
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Pixel Art',
|
||||
'slug' => 'pixel-art',
|
||||
'description' => 'Pixel art uploads',
|
||||
'order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('categories')->insert([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Characters',
|
||||
'slug' => 'characters',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$items = app(CategoriesSitemapBuilder::class)->items();
|
||||
$locations = array_map(static fn ($item) => $item->loc, $items);
|
||||
|
||||
expect($locations)->toContain(url('/pixel-art'))
|
||||
->and($locations)->toContain(url('/pixel-art/characters'));
|
||||
});
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\SmartCollectionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
@@ -74,6 +75,98 @@ it('keeps artworks_count in sync while attaching and removing artworks', functio
|
||||
expect($collection->artworks()->pluck('artworks.id')->all())->toBe([$artworkB->id]);
|
||||
});
|
||||
|
||||
it('sorts manual collection artworks by stats views for popular mode', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'sort_mode' => Collection::SORT_POPULAR,
|
||||
]);
|
||||
$lowViews = Artwork::factory()->for($user)->create();
|
||||
$highViews = Artwork::factory()->for($user)->create();
|
||||
$noStats = Artwork::factory()->for($user)->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
$service->attachArtworks($collection, $user, [$lowViews->id, $noStats->id, $highViews->id]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
[
|
||||
'artwork_id' => $lowViews->id,
|
||||
'views' => 25,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $highViews->id,
|
||||
'views' => 250,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$artworks = $service->getCollectionDetailArtworks($collection->fresh(), true, 24);
|
||||
|
||||
expect($artworks->getCollection()->pluck('id')->all())->toBe([
|
||||
$highViews->id,
|
||||
$lowViews->id,
|
||||
$noStats->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts smart collection artworks by stats views for popular mode', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'mode' => Collection::MODE_SMART,
|
||||
'smart_rules_json' => [
|
||||
'match' => 'all',
|
||||
'sort' => Collection::SORT_POPULAR,
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'created_at',
|
||||
'operator' => 'between',
|
||||
'value' => [
|
||||
'from' => now()->subDay()->toDateString(),
|
||||
'to' => now()->addDay()->toDateString(),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$lowViews = Artwork::factory()->for($user)->create();
|
||||
$highViews = Artwork::factory()->for($user)->create();
|
||||
$noStats = Artwork::factory()->for($user)->create();
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
[
|
||||
'artwork_id' => $lowViews->id,
|
||||
'views' => 12,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $highViews->id,
|
||||
'views' => 120,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$artworks = $service->resolveArtworks($collection, true, 24);
|
||||
|
||||
expect($artworks->getCollection()->pluck('id')->all())->toBe([
|
||||
$highViews->id,
|
||||
$lowViews->id,
|
||||
$noStats->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for medium rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ test('render returns empty string for whitespace-only input', function () {
|
||||
expect(ContentSanitizer::render(' '))->toBe('');
|
||||
});
|
||||
|
||||
test('render preserves emoji characters in output', function () {
|
||||
$html = ContentSanitizer::render('Love this piece 🚀🔥');
|
||||
|
||||
expect($html)->toContain('🚀')
|
||||
->and($html)->toContain('🔥');
|
||||
});
|
||||
|
||||
// ── XSS Prevention ────────────────────────────────────────────────────────────
|
||||
|
||||
test('render strips script tags', function () {
|
||||
|
||||
36
tests/Unit/ContentTypeAssetServiceTest.php
Normal file
36
tests/Unit/ContentTypeAssetServiceTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ContentTypeAssetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('stores and deletes content type assets on the configured object storage disk', function () {
|
||||
Storage::fake('s3');
|
||||
Config::set('uploads.object_storage.disk', 's3');
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$path = app(ContentTypeAssetService::class)->storeUploadedAsset(
|
||||
$contentType,
|
||||
UploadedFile::fake()->image('mascot.webp', 240, 320),
|
||||
'mascot'
|
||||
);
|
||||
|
||||
expect($path)->toStartWith('content-types/' . $contentType->id . '/mascot-');
|
||||
expect(Storage::disk('s3')->exists($path))->toBeTrue();
|
||||
|
||||
app(ContentTypeAssetService::class)->deleteIfManaged($path);
|
||||
|
||||
expect(Storage::disk('s3')->exists($path))->toBeFalse();
|
||||
});
|
||||
65
tests/Unit/ContentTypeSlugResolverTest.php
Normal file
65
tests/Unit/ContentTypeSlugResolverTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ContentTypeSlugHistory;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('resolves current, historical, and virtual content type slugs', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'concept-art',
|
||||
]);
|
||||
|
||||
$resolver = app(ContentTypeSlugResolver::class);
|
||||
|
||||
$current = $resolver->resolve('digital-art');
|
||||
$historical = $resolver->resolve('concept-art');
|
||||
$virtual = $resolver->resolve('artworks', allowVirtual: true);
|
||||
|
||||
expect($current->found())->toBeTrue()
|
||||
->and($current->requiresRedirect())->toBeFalse()
|
||||
->and($current->contentType?->slug)->toBe('digital-art')
|
||||
->and($historical->found())->toBeTrue()
|
||||
->and($historical->requiresRedirect())->toBeTrue()
|
||||
->and($historical->redirectSlug)->toBe('digital-art')
|
||||
->and($historical->contentType?->id)->toBe($contentType->id)
|
||||
->and($virtual->found())->toBeTrue()
|
||||
->and($virtual->isVirtual)->toBeTrue()
|
||||
->and($virtual->virtualType)->toBe('artworks');
|
||||
});
|
||||
|
||||
it('reports reserved and historical slug conflicts', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
'description' => 'Photography uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'photos',
|
||||
]);
|
||||
|
||||
$resolver = app(ContentTypeSlugResolver::class);
|
||||
|
||||
expect($resolver->isReservedSlug('help'))->toBeTrue()
|
||||
->and($resolver->isReservedSlug('photography'))->toBeFalse()
|
||||
->and($resolver->historicalSlugExists('photos'))->toBeTrue()
|
||||
->and($resolver->historicalSlugExists('photos', $contentType->id))->toBeFalse();
|
||||
});
|
||||
33
tests/Unit/Seo/SeoDataBuilderTest.php
Normal file
33
tests/Unit/Seo/SeoDataBuilderTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('normalizes a single associative structured data schema', function () {
|
||||
$seo = SeoDataBuilder::fromArray([
|
||||
'title' => 'Categories',
|
||||
'structured_data' => [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => 'Categories',
|
||||
],
|
||||
])->build()->toArray();
|
||||
|
||||
expect($seo['json_ld'] ?? [])
|
||||
->toHaveCount(1)
|
||||
->and($seo['json_ld'][0]['@type'] ?? null)->toBe('CollectionPage');
|
||||
});
|
||||
|
||||
it('normalizes JSON string structured data schemas', function () {
|
||||
$seo = SeoDataBuilder::fromArray([
|
||||
'title' => 'Categories',
|
||||
'structured_data' => '{"@context":"https://schema.org","@type":"CollectionPage","name":"Categories"}',
|
||||
])->build()->toArray();
|
||||
|
||||
expect($seo['json_ld'] ?? [])
|
||||
->toHaveCount(1)
|
||||
->and($seo['json_ld'][0]['@context'] ?? null)->toBe('https://schema.org')
|
||||
->and($seo['json_ld'][0]['@type'] ?? null)->toBe('CollectionPage');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> 404 routes — clean error page, not a 500 >> 404 — unknown path
|
||||
- Location: tests\e2e\routes.spec.ts:286:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> 404 routes — clean error page, not a 500 >> 404 — unknown artwork
|
||||
- Location: tests\e2e\routes.spec.ts:286:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Dashboard awards
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Dashboard
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Dashboard gallery
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Statistics
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Dashboard favorites
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Studio artworks
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Upload page
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Buddies
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Dashboard profile
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Manage
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> Received comments
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Auth-guarded routes — redirect to /login cleanly >> My buddies
|
||||
- Location: tests\e2e\routes.spec.ts:257:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Browse-categories page — renders category links
|
||||
- Location: tests\e2e\routes.spec.ts:336:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Sections page — renders content-type section headings
|
||||
- Location: tests\e2e\routes.spec.ts:327:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Daily uploads — AJAX endpoint returns HTML fragment
|
||||
- Location: tests\e2e\routes.spec.ts:372:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Daily uploads — date strip renders
|
||||
- Location: tests\e2e\routes.spec.ts:360:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Authors top — renders leaderboard
|
||||
- Location: tests\e2e\routes.spec.ts:352:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Forum — renders forum index
|
||||
- Location: tests\e2e\routes.spec.ts:343:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Home page — has gallery section
|
||||
- Location: tests\e2e\routes.spec.ts:320:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Login page loads and has form
|
||||
- Location: tests\e2e\routes.spec.ts:393:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Photography root loads
|
||||
- Location: tests\e2e\routes.spec.ts:409:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Register page loads and has form
|
||||
- Location: tests\e2e\routes.spec.ts:402:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Landmark spot-checks >> Wallpapers root loads
|
||||
- Location: tests\e2e\routes.spec.ts:417:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Discover: Most Downloaded
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Monthly commentators (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Monthly commentators (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Featured artworks (alias)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Blank template
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Browse Categories
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Browse
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Categories
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Chat
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Creator Stories
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Creators: Rising
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Creators: Top
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Daily uploads (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Daily uploads (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Discover: Fresh
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Discover: On This Day
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Discover: Top Rated
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Discover: Trending
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Featured artworks
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Forgot password
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Forum index
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Home (alias)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Home page
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Interviews
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Latest comments (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Latest comments (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Latest (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Latest uploads (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Login page
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Member photos (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Photography root
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Register page
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Sections
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Skins root
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Tags index
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Today downloads (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Today downloads (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Today in history
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Top authors (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Top authors (new)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Top favourites (legacy)
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Public routes — 200, no errors >> Wallpapers root
|
||||
- Location: tests\e2e\routes.spec.ts:223:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Response time — no page should take over 8 s >> /sections responds within 8000ms
|
||||
- Location: tests\e2e\routes.spec.ts:435:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Response time — no page should take over 8 s >> /uploads/latest responds within 8000ms
|
||||
- Location: tests\e2e\routes.spec.ts:435:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Response time — no page should take over 8 s >> /browse-categories responds within 8000ms
|
||||
- Location: tests\e2e\routes.spec.ts:435:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\routes.spec.ts >> Response time — no page should take over 8 s >> /comments/latest responds within 8000ms
|
||||
- Location: tests\e2e\routes.spec.ts:435:5
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at C:\Users\Gregor\AppData\Local\ms-playwright\chromium_headless_shell-1217\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user