chore: commit current workspace changes
This commit is contained in:
316
tests/Feature/Artworks/ArtworkPageCommentsTest.php
Normal file
316
tests/Feature/Artworks/ArtworkPageCommentsTest.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
File::ensureDirectoryExists(public_path('build'));
|
||||
File::put(public_path('build/manifest.json'), json_encode([
|
||||
'resources/css/app.css' => ['file' => 'assets/app.css', 'src' => 'resources/css/app.css', 'isEntry' => true],
|
||||
'resources/css/nova-grid.css' => ['file' => 'assets/nova-grid.css', 'src' => 'resources/css/nova-grid.css', 'isEntry' => true],
|
||||
'resources/scss/nova.scss' => ['file' => 'assets/nova.css', 'src' => 'resources/scss/nova.scss', 'isEntry' => true],
|
||||
'resources/js/nova.js' => ['file' => 'assets/nova.js', 'src' => 'resources/js/nova.js', 'isEntry' => true],
|
||||
'resources/js/entry-search.jsx' => ['file' => 'assets/entry-search.js', 'src' => 'resources/js/entry-search.jsx', 'isEntry' => true],
|
||||
'resources/js/app.js' => ['file' => 'assets/app.js', 'src' => 'resources/js/app.js', 'isEntry' => true],
|
||||
'resources/js/artwork.jsx' => ['file' => 'assets/artwork.js', 'src' => 'resources/js/artwork.jsx', 'isEntry' => true],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
});
|
||||
|
||||
it('renders nested artwork comments without lazy-loading reply branches during page render', function () {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'commentauthor',
|
||||
]);
|
||||
|
||||
$commenterA = User::factory()->create([
|
||||
'username' => 'commentera',
|
||||
]);
|
||||
|
||||
$commenterB = User::factory()->create([
|
||||
'username' => 'commenterb',
|
||||
]);
|
||||
|
||||
$commenterC = User::factory()->create([
|
||||
'username' => 'commenterc',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Internet',
|
||||
'slug' => 'internet',
|
||||
'description' => 'Internet skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'ICQ Skin',
|
||||
'slug' => 'icq',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
$rootComment = ArtworkComment::query()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenterA->id,
|
||||
'content' => 'Root comment',
|
||||
'raw_content' => 'Root comment',
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$reply = ArtworkComment::query()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenterB->id,
|
||||
'parent_id' => $rootComment->id,
|
||||
'content' => 'First reply',
|
||||
'raw_content' => 'First reply',
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
ArtworkComment::query()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenterC->id,
|
||||
'parent_id' => $reply->id,
|
||||
'content' => 'Nested reply',
|
||||
'raw_content' => 'Nested reply',
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$this->get('/skins/internet/icq')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('ArtworkPage')
|
||||
->has('comments', 1)
|
||||
->where('comments.0.content', 'Root comment')
|
||||
->has('comments.0.replies', 1)
|
||||
->where('comments.0.replies.0.content', 'First reply')
|
||||
->has('comments.0.replies.0.replies', 1)
|
||||
->where('comments.0.replies.0.replies.0.content', 'Nested reply'));
|
||||
});
|
||||
|
||||
it('keeps artwork page query count bounded with deep nested comments', function () {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'querycountauthor',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Internet',
|
||||
'slug' => 'internet',
|
||||
'description' => 'Internet skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'ICQ Query Budget',
|
||||
'slug' => 'icq-query-budget',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
$parentId = null;
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$commenter = User::factory()->create([
|
||||
'username' => 'querycommenter' . $i,
|
||||
]);
|
||||
|
||||
$comment = ArtworkComment::query()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $commenter->id,
|
||||
'parent_id' => $parentId,
|
||||
'content' => 'Nested comment ' . $i,
|
||||
'raw_content' => 'Nested comment ' . $i,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$parentId = $comment->id;
|
||||
}
|
||||
|
||||
$queryCount = 0;
|
||||
DB::listen(function () use (&$queryCount) {
|
||||
$queryCount++;
|
||||
});
|
||||
|
||||
$this->get('/skins/internet/icq-query-budget')->assertOk();
|
||||
|
||||
expect($queryCount)->toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('keeps artwork page category queries bounded with nested attached categories', function () {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'categoryqueryauthor',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$rootCategory = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Misc',
|
||||
'slug' => 'misc',
|
||||
'description' => 'Misc skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Parasite Runboxurl Launcher',
|
||||
'slug' => 'parasite-runboxurl-launcher',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$attachedCategoryIds = [$rootCategory->id];
|
||||
|
||||
for ($i = 1; $i <= 8; $i++) {
|
||||
$section = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $rootCategory->id,
|
||||
'name' => 'Section ' . $i,
|
||||
'slug' => 'section-' . $i,
|
||||
'description' => 'Section ' . $i,
|
||||
'is_active' => true,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
|
||||
$leaf = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $section->id,
|
||||
'name' => 'Leaf ' . $i,
|
||||
'slug' => 'leaf-' . $i,
|
||||
'description' => 'Leaf ' . $i,
|
||||
'is_active' => true,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
|
||||
$attachedCategoryIds[] = $leaf->id;
|
||||
}
|
||||
|
||||
$artwork->categories()->attach($attachedCategoryIds);
|
||||
|
||||
$categoryQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$categoryQueryCount) {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?(categories|content_types)\b/i', $query->sql) === 1) {
|
||||
$categoryQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/skins/misc/parasite-runboxurl-launcher')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('ArtworkPage')
|
||||
->has('artwork.categories', 9)
|
||||
->where('artwork.categories.0.slug', 'misc'));
|
||||
|
||||
expect($categoryQueryCount)->toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('keeps artwork page world participation queries bounded with many recurring reward badges', function () {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'jetaudioauthor',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Audio',
|
||||
'slug' => 'audio',
|
||||
'description' => 'Audio skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Jet Audio',
|
||||
'slug' => 'jet-audio',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
for ($i = 1; $i <= 8; $i++) {
|
||||
$world = World::query()->create([
|
||||
'title' => 'Audio World ' . $i,
|
||||
'slug' => 'audio-world-' . $i,
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'type' => World::TYPE_SEASONAL,
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'audio-world-series-' . $i,
|
||||
'edition_year' => 2020 + $i,
|
||||
'is_active_campaign' => false,
|
||||
'is_featured' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'accepts_submissions' => false,
|
||||
'participation_mode' => World::PARTICIPATION_MODE_CLOSED,
|
||||
'published_at' => now()->subDays(30 + $i),
|
||||
'starts_at' => now()->subDays(20 + $i),
|
||||
'ends_at' => now()->subDays(10 + $i),
|
||||
'created_by_user_id' => $author->id,
|
||||
]);
|
||||
|
||||
WorldRewardGrant::query()->create([
|
||||
'user_id' => $author->id,
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'reward_type' => 'featured',
|
||||
'grant_source' => 'manual',
|
||||
'granted_at' => now()->subDays($i),
|
||||
]);
|
||||
}
|
||||
|
||||
$worldQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$worldQueryCount) {
|
||||
if (preg_match('/\bfrom\s+["`\[]?worlds\b/i', $query->sql) === 1) {
|
||||
$worldQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/skins/audio/jet-audio')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('ArtworkPage')
|
||||
->has('artwork.world_participation', 8));
|
||||
|
||||
expect($worldQueryCount)->toBeLessThanOrEqual(6);
|
||||
});
|
||||
59
tests/Feature/Artworks/SimilarArtworksPageTest.php
Normal file
59
tests/Feature/Artworks/SimilarArtworksPageTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders the similar artworks page without duplicate seo head tags', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'similarmetaauthor',
|
||||
'name' => 'Similar Meta Author',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Internet',
|
||||
'slug' => 'internet',
|
||||
'description' => 'Internet skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Similar Head Artwork',
|
||||
'slug' => 'similar-head-artwork',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
$response = $this->get(route('art.similar', ['id' => $artwork->id]));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$html = $response->getContent();
|
||||
|
||||
expect($html)
|
||||
->toContain('Similar Artworks')
|
||||
->toContain('application/ld+json');
|
||||
|
||||
expect(substr_count($html, '<meta name="description"'))->toBe(1);
|
||||
expect(substr_count($html, '<link rel="canonical"'))->toBe(1);
|
||||
expect(substr_count($html, 'property="og:title"'))->toBe(1);
|
||||
expect(substr_count($html, 'name="twitter:title"'))->toBe(1);
|
||||
expect(substr_count($html, 'name="robots"'))->toBe(1);
|
||||
});
|
||||
135
tests/Feature/Auth/AuthAuditLogTest.php
Normal file
135
tests/Feature/Auth/AuthAuditLogTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuthAuditLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('logs successful login attempts', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$log = AuthAuditLog::query()->latest('id')->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->event_type)->toBe('login')
|
||||
->and($log->status)->toBe('success')
|
||||
->and($log->identifier)->toBe(strtolower($user->email))
|
||||
->and($log->user_id)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('logs login validation failures', function (): void {
|
||||
config()->set('app.debug', false);
|
||||
|
||||
$this->from('/login')->post('/login', [
|
||||
'email' => '',
|
||||
'password' => '',
|
||||
])->assertRedirect('/login')
|
||||
->assertSessionHasErrors(['email', 'password']);
|
||||
|
||||
$log = AuthAuditLog::query()->latest('id')->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->event_type)->toBe('login')
|
||||
->and($log->status)->toBe('failed')
|
||||
->and($log->reason)->toBe('validation_failed')
|
||||
->and($log->metadata)->toMatchArray(['fields' => ['email', 'password']]);
|
||||
});
|
||||
|
||||
it('logs successful registration attempts', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$this->post('/register', [
|
||||
'email' => 'audit-register@example.com',
|
||||
])->assertRedirect(route('setup.password.create', absolute: false));
|
||||
|
||||
$log = AuthAuditLog::query()->where('event_type', 'register')->latest('id')->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->status)->toBe('success')
|
||||
->and($log->identifier)->toBe('audit-register@example.com')
|
||||
->and($log->reason)->toBe('user_created');
|
||||
});
|
||||
|
||||
it('logs forgot password attempts', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email])
|
||||
->assertSessionHas('status');
|
||||
|
||||
$log = AuthAuditLog::query()->where('event_type', 'forgot_password')->latest('id')->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->status)->toBe('success')
|
||||
->and($log->identifier)->toBe(strtolower($user->email))
|
||||
->and($log->user_id)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('logs successful password resets', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
$token = null;
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use (&$token): bool {
|
||||
$token = $notification->token;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->post('/reset-password', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
])->assertRedirect(route('login'));
|
||||
|
||||
$log = AuthAuditLog::query()->where('event_type', 'reset_password')->latest('id')->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->status)->toBe('success')
|
||||
->and($log->identifier)->toBe(strtolower($user->email))
|
||||
->and($log->user_id)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('limits the auth audit moderation page to admins', function (): void {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$manager = User::factory()->create(['role' => 'manager']);
|
||||
|
||||
AuthAuditLog::query()->create([
|
||||
'event_type' => 'login',
|
||||
'identifier' => 'audit@example.com',
|
||||
'status' => 'failed',
|
||||
'reason' => 'invalid_credentials',
|
||||
'ip' => '127.0.0.1',
|
||||
'metadata' => ['via' => 'email'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/moderation/auth-audit')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/AuthAudit')
|
||||
->where('logs.data.0.event_type', 'login')
|
||||
->where('logs.data.0.reason', 'invalid_credentials')
|
||||
);
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get('/moderation/auth-audit')
|
||||
->assertForbidden();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -202,6 +203,63 @@ class BrowseApiTest extends TestCase
|
||||
$this->assertStringContainsString('Forest Light', $html);
|
||||
}
|
||||
|
||||
public function test_web_explore_does_not_lazy_load_presentation_relations_per_artwork(): void
|
||||
{
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$user = User::factory()->create(['name' => 'Explore 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,
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$artwork = Artwork::factory()
|
||||
->for($user)
|
||||
->create([
|
||||
'title' => 'Explore Item ' . $i,
|
||||
'slug' => 'explore-item-' . $i,
|
||||
'published_at' => now()->subMinutes($i),
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
}
|
||||
|
||||
$response = $this->get('/explore?sort=latest');
|
||||
|
||||
$response->assertOk()->assertSee('Explore Item 1');
|
||||
|
||||
$queries = collect(DB::getQueryLog())->pluck('query')->all();
|
||||
|
||||
$this->assertFalse(
|
||||
collect($queries)->contains(fn (string $query): bool => str_contains($query, 'from "users" where "users"."id" = ? limit 1')),
|
||||
'Explore should not lazy-load the artwork user relation per tile.'
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
collect($queries)->contains(fn (string $query): bool => str_contains($query, 'from "profiles" where "profiles"."user_id" = ? limit 1')),
|
||||
'Explore should not lazy-load the artwork profile relation per tile.'
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
collect($queries)->contains(fn (string $query): bool => str_contains($query, 'from "artwork_category" where "artwork_category"."artwork_id" = ?')),
|
||||
'Explore should not lazy-load artwork categories one tile at a time.'
|
||||
);
|
||||
|
||||
DB::disableQueryLog();
|
||||
}
|
||||
|
||||
public function test_web_browse_filters_out_artworks_that_are_no_longer_publicly_browsable(): void
|
||||
{
|
||||
$user = User::factory()->create(['name' => 'Visibility Author']);
|
||||
|
||||
120
tests/Feature/BrowseCategoryPagePerformanceTest.php
Normal file
120
tests/Feature/BrowseCategoryPagePerformanceTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('keeps root category browse page queries bounded when rendering many child category pills', function (): void {
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Winstep Full Pak',
|
||||
'slug' => 'winstep-full-pak',
|
||||
'description' => 'Winstep suite skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $category->id,
|
||||
'name' => 'Child Category ' . $i,
|
||||
'slug' => 'child-category-' . $i,
|
||||
'description' => 'Nested category ' . $i,
|
||||
'is_active' => true,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
$categoryQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$categoryQueryCount): void {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?(categories|content_types)\b/i', $query->sql) === 1) {
|
||||
$categoryQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/skins/winstep-full-pak')
|
||||
->assertOk()
|
||||
->assertSee('Winstep Full Pak')
|
||||
->assertSee('Child Category 1')
|
||||
->assertSee('Child Category 20');
|
||||
|
||||
expect($categoryQueryCount)->toBeLessThanOrEqual(14);
|
||||
});
|
||||
|
||||
it('keeps category browse artwork card relation queries bounded', function (): void {
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Misc',
|
||||
'slug' => 'misc',
|
||||
'description' => 'Misc skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artworks = collect();
|
||||
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$user = User::factory()->create([
|
||||
'username' => 'miscbrowseuser' . $i,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($user)->create([
|
||||
'title' => 'Misc Browse Artwork ' . $i,
|
||||
'slug' => 'misc-browse-artwork-' . $i,
|
||||
'published_at' => now()->subMinutes($i),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
$artworks->push($artwork);
|
||||
}
|
||||
|
||||
Cache::put(
|
||||
'gallery.cat.catalog-visible.v4.' . md5('skins|misc') . '.trending.1',
|
||||
new LengthAwarePaginator(
|
||||
new EloquentCollection($artworks->all()),
|
||||
$artworks->count(),
|
||||
24,
|
||||
1,
|
||||
[
|
||||
'path' => url('/skins/misc'),
|
||||
'query' => [],
|
||||
]
|
||||
),
|
||||
300,
|
||||
);
|
||||
|
||||
$relationQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$relationQueryCount): void {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?(users|user_profiles|categories|content_types|groups)\b/i', $query->sql) === 1) {
|
||||
$relationQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/skins/misc')
|
||||
->assertOk()
|
||||
->assertSee('Misc')
|
||||
->assertSee('Misc Browse Artwork 1')
|
||||
->assertSee('Misc Browse Artwork 12');
|
||||
|
||||
expect($relationQueryCount)->toBeLessThanOrEqual(12);
|
||||
});
|
||||
20
tests/Feature/Console/RefreshLeaderboardsCommandTest.php
Normal file
20
tests/Feature/Console/RefreshLeaderboardsCommandTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\LeaderboardService;
|
||||
|
||||
it('refreshes all leaderboards from the command entrypoint', function (): void {
|
||||
$leaderboards = $this->mock(LeaderboardService::class);
|
||||
$leaderboards->shouldReceive('refreshAll')
|
||||
->once()
|
||||
->andReturn([
|
||||
'creator' => ['daily' => 3],
|
||||
'artwork' => ['daily' => 5],
|
||||
]);
|
||||
|
||||
$this->artisan('leaderboards:refresh')
|
||||
->expectsOutput('Refreshing leaderboards …')
|
||||
->expectsOutput('Done. Updated: 8 leaderboard row(s).')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
use App\Jobs\IngestUserDiscoveryEventJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -65,3 +68,56 @@ it('accepts session-oriented discovery events', function () {
|
||||
return $job->eventType === 'dwell';
|
||||
});
|
||||
});
|
||||
|
||||
it('stores discovery event meta as json when ingesting queued events', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Audio',
|
||||
'slug' => 'audio',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
DB::table('artwork_category')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$meta = [
|
||||
'source' => 'feed',
|
||||
'context' => [
|
||||
'slot' => 'for-you',
|
||||
'score' => 0.88,
|
||||
],
|
||||
];
|
||||
|
||||
$job = new IngestUserDiscoveryEventJob(
|
||||
eventId: (string) \Illuminate\Support\Str::uuid(),
|
||||
userId: $user->id,
|
||||
artworkId: $artwork->id,
|
||||
eventType: 'view',
|
||||
algoVersion: 'clip-cosine-v2-adaptive',
|
||||
occurredAt: now()->toIso8601String(),
|
||||
meta: $meta,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
app(\App\Services\Recommendations\UserInterestProfileService::class),
|
||||
app(\App\Services\Recommendations\SessionRecoService::class),
|
||||
);
|
||||
|
||||
$storedMeta = DB::table('user_discovery_events')->value('meta');
|
||||
|
||||
expect($storedMeta)->not->toBeNull();
|
||||
expect(json_decode((string) $storedMeta, true))->toBe($meta);
|
||||
});
|
||||
|
||||
89
tests/Feature/ForumBoardPagePerformanceTest.php
Normal file
89
tests/Feature/ForumBoardPagePerformanceTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumPost;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->withoutMiddleware(HandleInertiaRequests::class);
|
||||
});
|
||||
|
||||
it('keeps board page opening-post queries bounded across many topics', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'illustrator',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Illustration Author',
|
||||
'email' => 'illustration@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Art Query Budget',
|
||||
'title' => 'Art',
|
||||
'slug' => 'art-query-budget',
|
||||
'description' => 'Art discussion',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Illustration',
|
||||
'slug' => 'illustration-query-budget',
|
||||
'description' => 'Illustration board',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
for ($index = 1; $index <= 15; $index++) {
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => 'Topic ' . $index,
|
||||
'slug' => 'topic-' . $index,
|
||||
'replies_count' => 2,
|
||||
'last_post_at' => now()->subMinutes($index),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => 'Opening post for topic ' . $index,
|
||||
'created_at' => now()->subMinutes($index + 30),
|
||||
'updated_at' => now()->subMinutes($index + 30),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => 'Reply for topic ' . $index,
|
||||
'created_at' => now()->subMinutes($index),
|
||||
'updated_at' => now()->subMinutes($index),
|
||||
]);
|
||||
}
|
||||
|
||||
$forumPostQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$forumPostQueryCount): void {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?forum_posts\b/i', $query->sql) === 1) {
|
||||
$forumPostQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get(route('forum.board.show', ['boardSlug' => $board->slug]))
|
||||
->assertOk()
|
||||
->assertSee('Illustration')
|
||||
->assertSee('Topic 1')
|
||||
->assertSee('Opening post for topic 1');
|
||||
|
||||
expect($forumPostQueryCount)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
@@ -62,3 +62,12 @@ it('does not treat reserved subdomains as profile hosts', function () {
|
||||
$this->call('GET', '/sections', [], [], [], ['HTTP_HOST' => 'www.skinbase26.test'])
|
||||
->assertRedirect('/categories');
|
||||
});
|
||||
|
||||
it('redirects arbitrary single-subdomain routes to the canonical host', function () {
|
||||
$response = app(Kernel::class)->handle(
|
||||
Request::create('/art/15234/similar', 'GET', ['page' => '2'], [], [], ['HTTP_HOST' => 'fu4z7d.skinbase26.test'])
|
||||
);
|
||||
|
||||
expect($response->getStatusCode())->toBe(301);
|
||||
expect($response->headers->get('location'))->toBe('http://skinbase26.test/art/15234/similar?page=2');
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
use cPad\Plugins\News\Models\NewsTag;
|
||||
@@ -102,4 +103,29 @@ it('renders published news across public discovery routes', function (): void {
|
||||
$this->get(route('news.author', ['username' => $author->username]))
|
||||
->assertOk()
|
||||
->assertSee('Skinbase Nova Newsroom');
|
||||
});
|
||||
|
||||
it('renders a public news article when anonymous sessions are skipped', function (): void {
|
||||
Config::set('skinbase-sessions.enabled', true);
|
||||
Config::set('skinbase-sessions.debug_header', true);
|
||||
|
||||
$author = User::factory()->create([
|
||||
'username' => 'guestnewsauthor',
|
||||
'name' => 'Guest News Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Guest Announcements',
|
||||
'slug' => 'guest-announcements',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Guest Sessionless News Page',
|
||||
'slug' => 'guest-sessionless-news-page',
|
||||
]);
|
||||
|
||||
$this->get(route('news.show', ['slug' => $article->slug]))
|
||||
->assertOk()
|
||||
->assertHeader('X-Skinbase-Session', 'skipped')
|
||||
->assertSee('Guest Sessionless News Page');
|
||||
});
|
||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostShareService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
// ── SQLite polyfill + helpers ──────────────────────────────────────────────────
|
||||
@@ -154,6 +155,25 @@ test('GET /api/posts/following returns posts from followed users only', function
|
||||
expect($ids)->each->toBe($followed->id);
|
||||
});
|
||||
|
||||
test('GET /api/feed/trending returns formatted posts', function () {
|
||||
Cache::forget('feed:trending');
|
||||
|
||||
$author = User::factory()->create();
|
||||
$post = Post::factory()->create([
|
||||
'user_id' => $author->id,
|
||||
'visibility' => Post::VISIBILITY_PUBLIC,
|
||||
'status' => Post::STATUS_PUBLISHED,
|
||||
'body' => 'Trending post body',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/feed/trending');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.0.id', $post->id)
|
||||
->assertJsonPath('data.0.author.id', $author->id)
|
||||
->assertJsonPath('meta.current_page', 1);
|
||||
});
|
||||
|
||||
// ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('POST /api/posts/{id}/reactions adds reaction and increments counter', function () {
|
||||
|
||||
119
tests/Feature/Profile/ProfileArtworksApiTest.php
Normal file
119
tests/Feature/Profile/ProfileArtworksApiTest.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\Cursor;
|
||||
|
||||
it('supports cursor pagination for latest profile artworks', function () {
|
||||
[$user] = seedProfileArtworksApiFixtures(26);
|
||||
|
||||
$firstPage = $this->getJson("/api/profile/{$user->username}/artworks");
|
||||
|
||||
$firstPage->assertOk();
|
||||
$firstPage->assertJsonCount(24, 'data');
|
||||
|
||||
$nextCursor = $firstPage->json('next_cursor');
|
||||
|
||||
expect($nextCursor)->not->toBeNull();
|
||||
|
||||
$secondPage = $this->getJson("/api/profile/{$user->username}/artworks?cursor={$nextCursor}");
|
||||
|
||||
$secondPage->assertOk();
|
||||
$secondPage->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
it('supports cursor pagination for stats-sorted profile artworks', function () {
|
||||
[$user] = seedProfileArtworksApiFixtures(26);
|
||||
|
||||
$firstPage = $this->getJson("/api/profile/{$user->username}/artworks?sort=views");
|
||||
|
||||
$firstPage->assertOk();
|
||||
$firstPage->assertJsonCount(24, 'data');
|
||||
|
||||
$nextCursor = $firstPage->json('next_cursor');
|
||||
|
||||
expect($nextCursor)->not->toBeNull();
|
||||
|
||||
$secondPage = $this->getJson("/api/profile/{$user->username}/artworks?sort=views&cursor={$nextCursor}");
|
||||
|
||||
$secondPage->assertOk();
|
||||
$secondPage->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
it('falls back to the first page when a legacy profile artworks cursor is missing id', function () {
|
||||
[$user] = seedProfileArtworksApiFixtures(26);
|
||||
|
||||
$legacyCursor = new Cursor([
|
||||
'published_at' => Artwork::query()->orderByDesc('published_at')->value('published_at')?->format(DATE_ATOM),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/profile/{$user->username}/artworks?cursor={$legacyCursor->encode()}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(24, 'data');
|
||||
expect($response->json('next_cursor'))->not->toBeNull();
|
||||
});
|
||||
|
||||
function seedProfileArtworksApiFixtures(int $count): array
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Profile Artist',
|
||||
'username' => 'profileartist',
|
||||
'email' => 'profileartist@example.test',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Winamp',
|
||||
'slug' => 'winamp',
|
||||
'description' => 'Winamp skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$artwork = Artwork::factory()
|
||||
->for($user)
|
||||
->create([
|
||||
'title' => 'Profile Artwork ' . $i,
|
||||
'slug' => 'profile-artwork-' . $i,
|
||||
'published_at' => now()->subMinutes($i),
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
ArtworkStats::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => $count - $i,
|
||||
'downloads' => $i,
|
||||
'favorites' => $i,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
'ranking_score' => $count - $i,
|
||||
'engagement_velocity' => 0,
|
||||
'shares_24h' => 0,
|
||||
'comments_24h' => 0,
|
||||
'favourites_24h' => 0,
|
||||
'heat_score' => $count - $i,
|
||||
'heat_score_updated_at' => null,
|
||||
'views_1h' => 0,
|
||||
'favourites_1h' => 0,
|
||||
'comments_1h' => 0,
|
||||
'shares_1h' => 0,
|
||||
'downloads_1h' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$user, $contentType, $category];
|
||||
}
|
||||
@@ -115,4 +115,15 @@ it('prioritizes higher-signal world rewards ahead of participation on profile re
|
||||
->where('worldRewards.items.0.badge_label', 'Pixel Week 2026 Winner')
|
||||
->where('worldRewards.items.1.badge_label', 'Retro Month 2026 Participant')
|
||||
->where('worldRewards.recent.0.badge_label', 'Retro Month 2026 Participant'));
|
||||
});
|
||||
|
||||
it('redirects public profile paths from stray skinbase subdomains to the canonical host', function (): void {
|
||||
config()->set('app.url', 'https://skinbase.org');
|
||||
|
||||
User::factory()->create([
|
||||
'username' => 'ta2027',
|
||||
]);
|
||||
|
||||
$this->get('https://ffh84a.skinbase.org/@ta2027')
|
||||
->assertRedirect('https://skinbase.org/@ta2027');
|
||||
});
|
||||
@@ -247,7 +247,7 @@ it('build command creates a validated sitemap release artifact set', function ()
|
||||
|
||||
$releaseId = $releases[0]['release_id'];
|
||||
|
||||
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemap.xml');
|
||||
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/sitemap.xml');
|
||||
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/artworks-index.xml');
|
||||
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/artworks-0001.xml');
|
||||
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/manifest.json');
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\StudioAiAssistService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use function Pest\Laravel\actingAs;
|
||||
@@ -461,6 +462,84 @@ it('builds and exposes normalized studio ai suggestions', function (): void {
|
||||
->has('data.description_suggestions', 3));
|
||||
});
|
||||
|
||||
it('keeps category mapping queries bounded during direct ai analysis', function (): void {
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.gateway.base_url', 'https://vision.local');
|
||||
config()->set('cdn.files_url', 'https://files.local');
|
||||
|
||||
$skins = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
]);
|
||||
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
|
||||
$rootCategory = Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'name' => 'Audio',
|
||||
'slug' => 'audio',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
Category::query()->create([
|
||||
'content_type_id' => $skins->id,
|
||||
'parent_id' => $rootCategory->id,
|
||||
'name' => 'Child ' . $i,
|
||||
'slug' => 'child-' . $i,
|
||||
'is_active' => true,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Flowers',
|
||||
'slug' => 'flowers',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'boundedaa112233',
|
||||
'file_name' => 'jet-audio.jpg',
|
||||
'title' => 'Jet Audio Skin',
|
||||
'description' => 'A colorful audio interface skin.',
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($rootCategory->id);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.local/analyze/all' => Http::response([
|
||||
'clip' => [
|
||||
['tag' => 'audio interface', 'confidence' => 0.96],
|
||||
['tag' => 'skin', 'confidence' => 0.91],
|
||||
],
|
||||
'yolo' => [
|
||||
['label' => 'interface', 'confidence' => 0.79],
|
||||
],
|
||||
'blip' => 'an audio player interface skin with colorful controls',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$categoryQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$categoryQueryCount) {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?(categories|content_types)\b/i', $query->sql) === 1) {
|
||||
$categoryQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
app(StudioAiAssistService::class)->analyze($artwork->fresh(), false);
|
||||
|
||||
expect($categoryQueryCount)->toBeLessThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('applies ai suggestions to artwork fields and tracks ai sources', function (): void {
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
|
||||
146
tests/Feature/View/NovaLayoutViteManifestTest.php
Normal file
146
tests/Feature/View/NovaLayoutViteManifestTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Vite as LaravelVite;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
$originalManifest = null;
|
||||
|
||||
beforeEach(function () use (&$originalManifest): void {
|
||||
File::ensureDirectoryExists(public_path('build'));
|
||||
|
||||
$manifestPath = public_path('build/manifest.json');
|
||||
$originalManifest = File::exists($manifestPath)
|
||||
? File::get($manifestPath)
|
||||
: null;
|
||||
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
});
|
||||
|
||||
afterEach(function () use (&$originalManifest): void {
|
||||
$manifestPath = public_path('build/manifest.json');
|
||||
|
||||
if ($originalManifest === null) {
|
||||
File::delete($manifestPath);
|
||||
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($manifestPath, $originalManifest);
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
});
|
||||
|
||||
it('renders the nova error layout when stylesheet manifest entries do not include src', function () use (&$originalManifest): void {
|
||||
expect($originalManifest)->not->toBeNull();
|
||||
|
||||
$manifest = json_decode($originalManifest, true, 512, JSON_THROW_ON_ERROR);
|
||||
unset($manifest['resources/css/app.css']['src']);
|
||||
unset($manifest['resources/css/nova-grid.css']['src']);
|
||||
unset($manifest['resources/scss/nova.scss']['src']);
|
||||
|
||||
File::put(public_path('build/manifest.json'), json_encode($manifest, JSON_THROW_ON_ERROR));
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
|
||||
$vite = app(LaravelVite::class);
|
||||
|
||||
$this->view('errors.500', [
|
||||
'correlationId' => 'TEST-CORRELATION-ID',
|
||||
])
|
||||
->assertSee($vite->asset('resources/css/app.css'), false)
|
||||
->assertSee($vite->asset('resources/css/nova-grid.css'), false)
|
||||
->assertSee($vite->asset('resources/scss/nova.scss'), false);
|
||||
});
|
||||
|
||||
it('renders the similar artworks page when stylesheet manifest entries do not include src', function () use (&$originalManifest): void {
|
||||
expect($originalManifest)->not->toBeNull();
|
||||
|
||||
$manifest = json_decode($originalManifest, true, 512, JSON_THROW_ON_ERROR);
|
||||
unset($manifest['resources/css/app.css']['src']);
|
||||
unset($manifest['resources/css/nova-grid.css']['src']);
|
||||
unset($manifest['resources/scss/nova.scss']['src']);
|
||||
|
||||
File::put(public_path('build/manifest.json'), json_encode($manifest, JSON_THROW_ON_ERROR));
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
|
||||
$vite = app(LaravelVite::class);
|
||||
|
||||
$author = User::factory()->create([
|
||||
'username' => 'similarmanifestauthor',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Internet',
|
||||
'slug' => 'internet',
|
||||
'description' => 'Internet skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Similar Manifest Artwork',
|
||||
'slug' => 'similar-manifest-artwork',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
$this->get(route('art.similar', ['id' => $artwork->id]))
|
||||
->assertOk()
|
||||
->assertSee('Similar Artworks')
|
||||
->assertSee($vite->asset('resources/css/app.css'), false)
|
||||
->assertSee($vite->asset('resources/css/nova-grid.css'), false)
|
||||
->assertSee($vite->asset('resources/scss/nova.scss'), false);
|
||||
});
|
||||
|
||||
it('renders the profile page when the manifest does not contain profile.jsx', function () use (&$originalManifest): void {
|
||||
expect($originalManifest)->not->toBeNull();
|
||||
|
||||
$manifest = json_decode($originalManifest, true, 512, JSON_THROW_ON_ERROR);
|
||||
unset($manifest['resources/js/profile.jsx']);
|
||||
|
||||
File::put(public_path('build/manifest.json'), json_encode($manifest, JSON_THROW_ON_ERROR));
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
|
||||
$vite = app(LaravelVite::class);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'username' => 'profilemanifestuser',
|
||||
]);
|
||||
|
||||
$this->get(route('profile.show', ['username' => strtolower((string) $user->username)]))
|
||||
->assertOk()
|
||||
->assertSee($vite->asset('resources/js/collections.jsx'), false);
|
||||
});
|
||||
|
||||
it('renders the latest comments page when the manifest does not contain the community activity bundle', function () use (&$originalManifest): void {
|
||||
expect($originalManifest)->not->toBeNull();
|
||||
|
||||
$manifest = json_decode($originalManifest, true, 512, JSON_THROW_ON_ERROR);
|
||||
unset($manifest['resources/js/Pages/Community/CommunityActivityPage.jsx']);
|
||||
|
||||
File::put(public_path('build/manifest.json'), json_encode($manifest, JSON_THROW_ON_ERROR));
|
||||
app()->forgetInstance(LaravelVite::class);
|
||||
|
||||
$this->get('/latest-comments')
|
||||
->assertOk()
|
||||
->assertSee('Latest Comments');
|
||||
});
|
||||
@@ -10,7 +10,9 @@ use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Services\HomepageService;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
@@ -98,6 +100,25 @@ it('renders public worlds index and detail pages', function (): void {
|
||||
->where('world.slug', 'summer-slam-2026'));
|
||||
});
|
||||
|
||||
it('returns a relative latest world navigation link regardless of request host', function (): void {
|
||||
Cache::forget('worlds.navigation_campaign');
|
||||
|
||||
$world = publicWorld([
|
||||
'title' => 'Hello Again',
|
||||
'slug' => 'hello-again',
|
||||
'campaign_priority' => 999,
|
||||
]);
|
||||
|
||||
$this->get('https://fooyd0.skinbase.org/worlds')
|
||||
->assertOk();
|
||||
|
||||
$campaign = app(WorldService::class)->navigationCampaign();
|
||||
|
||||
expect($campaign)->not->toBeNull()
|
||||
->and($campaign['title'])->toBe('Hello Again')
|
||||
->and($campaign['url'])->toBe('/worlds/hello-again');
|
||||
});
|
||||
|
||||
it('includes rewarded contributors on public world pages', function (): void {
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'rewardedcreator-' . Str::lower(Str::random(6)),
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Models\HomepageAnnouncement;
|
||||
use App\Models\User;
|
||||
use App\Services\HomepageAnnouncementService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
@@ -235,7 +237,7 @@ it('preview sanitizes html content', function (): void {
|
||||
'title' => 'Preview announcement',
|
||||
'subtitle' => 'Unsafe html should be stripped.',
|
||||
'badge_text' => 'Preview',
|
||||
'content_html' => '<p>Hello<script>alert(1)</script></p><a href="https://skinbase.top" onclick="evil()">Visit</a>',
|
||||
'content_html' => '<p>Hello<script>alert(1)</script></p><a href="https://skinbase.org" onclick="evil()">Visit</a>',
|
||||
'type' => HomepageAnnouncement::TYPE_NOTICE,
|
||||
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
|
||||
'is_active' => true,
|
||||
@@ -255,7 +257,7 @@ it('preview sanitizes html content', function (): void {
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('announcement.content_html', '<p>Hello</p><a href="https://skinbase.top" rel="noopener noreferrer" target="_blank">Visit</a>');
|
||||
->assertJsonPath('announcement.content_html', '<p>Hello</p><a href="https://skinbase.org" rel="noopener noreferrer" target="_blank">Visit</a>');
|
||||
});
|
||||
|
||||
it('preview rejects unsafe custom links', function (): void {
|
||||
@@ -283,4 +285,38 @@ it('preview rejects unsafe custom links', function (): void {
|
||||
'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
|
||||
])
|
||||
->assertSessionHasErrors(['primary_link_url']);
|
||||
});
|
||||
|
||||
it('stores announcement background uploads on the configured object storage disk', function (): void {
|
||||
$admin = adminUser();
|
||||
|
||||
Storage::fake('s3');
|
||||
config([
|
||||
'homepage.announcements.background_image.disk' => 's3',
|
||||
'homepage.announcements.background_image.prefix' => 'homepage-announcements',
|
||||
'filesystems.disks.s3.url' => 'https://cdn.skinbase.test',
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\HandleInertiaRequests::class);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->from(route('admin.homepage-announcements.create'))
|
||||
->post(route('admin.homepage-announcements.store'), array_merge(announcementPayload(), [
|
||||
'background_image_file' => UploadedFile::fake()->image('announcement.png', 1600, 900),
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$announcement = HomepageAnnouncement::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($announcement->background_image)
|
||||
->toStartWith('homepage-announcements/')
|
||||
->toEndWith('.webp');
|
||||
|
||||
Storage::disk('s3')->assertExists($announcement->background_image);
|
||||
|
||||
$payload = app(HomepageAnnouncementService::class)->toHomepagePayload($announcement);
|
||||
|
||||
expect($payload['background_image_url'])
|
||||
->toBe('https://cdn.skinbase.test/' . $announcement->background_image);
|
||||
});
|
||||
Reference in New Issue
Block a user