chore: commit current workspace changes

This commit is contained in:
2026-05-02 09:37:14 +02:00
parent 79235133f0
commit caf1464aa5
121 changed files with 485218 additions and 181663 deletions

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

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

View 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();
});

View File

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

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

View 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();
});

View File

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

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

View File

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

View File

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

View File

@@ -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 () {

View 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];
}

View File

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

View File

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

View File

@@ -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',

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

View File

@@ -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)),