This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

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

View File

@@ -46,7 +46,8 @@ it('allows complete onboarding user to access profile and upload', function () {
$this->actingAs($user)
->get('/profile')
->assertOk();
->assertRedirect('/dashboard/profile')
->assertStatus(301);
$this->actingAs($user)
->get('/upload')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -105,7 +105,7 @@ it('following tab returns 200 for users with no follows', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/community/activity?type=following')
->get('/community/activity?filter=following')
->assertStatus(200);
});
@@ -127,10 +127,12 @@ it('following tab shows only events from followed users', function () {
// Event from non-followed user (should not appear)
ActivityEvent::record($other->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
$response = $this->actingAs($user)->get('/community/activity?type=following');
$response = $this->actingAs($user)->get('/community/activity?filter=following');
$response->assertStatus(200);
$events = $response->original->gatherData()['events'];
expect($events->total())->toBe(1);
expect($events->first()->actor_id)->toBe($creator->id);
$props = $response->viewData('props');
$events = collect($props['initialActivities'] ?? []);
expect($events)->toHaveCount(1);
expect(data_get($events->first(), 'user.id'))->toBe($creator->id);
});

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
it('renders the canonical profile gallery page', function () {
$user = User::factory()->create([
'username' => 'gregor',
]);
Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Gallery Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$this->get('/@gregor/gallery')
->assertOk()
->assertSee('http://skinbase26.test/@gregor/gallery', false)
->assertSee('Profile\\/ProfileGallery', false);
});
it('redirects the legacy gallery route to the canonical profile gallery', function () {
$user = User::factory()->create([
'username' => 'gregor',
]);
$this->get('/gallery/' . $user->id . '/Gregor%20Klev%C5%BEe')
->assertRedirect('/@gregor/gallery')
->assertStatus(301);
});
it('redirects mixed-case profile gallery usernames to the lowercase canonical route', function () {
User::factory()->create([
'username' => 'gregor',
]);
$this->get('/@Gregor/gallery')
->assertRedirect('/@gregor/gallery')
->assertStatus(301);
});

View File

@@ -7,6 +7,9 @@ declare(strict_types=1);
* Routing Unification spec (§3.2 Explore, §4 Blog/Pages, §6.1 redirects).
*/
use App\Models\Category;
use App\Models\ContentType;
// ── /explore routes ──────────────────────────────────────────────────────────
it('GET /explore returns 200', function () {
@@ -78,6 +81,116 @@ it('GET /discover redirects to /discover/trending with 301', function () {
$this->get('/discover')->assertRedirect('/discover/trending')->assertStatus(301);
});
it('GET /sections redirects to /categories with 301', function () {
$this->get('/sections')->assertRedirect('/categories')->assertStatus(301);
});
it('GET /browse-categories redirects to /categories with 301', function () {
$this->get('/browse-categories')->assertRedirect('/categories')->assertStatus(301);
});
it('legacy mixed-case category route redirects to the canonical category URL with 301', function () {
$contentType = ContentType::query()->create([
'name' => 'Skins',
'slug' => 'skins',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'BrowserBob',
'slug' => 'browserbob',
]);
$this->get('/Skins/BrowserBob/' . $category->id . '?ref=legacy')
->assertRedirect('/skins/browserbob?ref=legacy')
->assertStatus(301);
});
it('legacy nested category route redirects to the canonical nested category URL with 301', function () {
$contentType = ContentType::query()->create([
'name' => 'Skins',
'slug' => 'skins',
]);
$parent = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'BrowserBob',
'slug' => 'browserbob',
]);
$child = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => $parent->id,
'name' => 'sdsdsdsd',
'slug' => 'sdsdsdsd',
]);
$this->get('/Skins/BrowserBob/sdsdsdsd/' . $child->id)
->assertRedirect('/skins/browserbob/sdsdsdsd')
->assertStatus(301);
});
it('legacy category route falls back to /categories and preserves query string when no category matches', function () {
$this->get('/Skins/does-not-exist/999999?source=old-site')
->assertRedirect('/categories?source=old-site')
->assertStatus(301);
});
it('legacy /category route redirects to the canonical category URL with 301', function () {
$contentType = ContentType::query()->create([
'name' => 'Skins',
'slug' => 'skins',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'BrowserBob',
'slug' => 'browserbob',
]);
$this->get('/category/skins/browserbob/' . $category->id . '?ref=legacy-category')
->assertRedirect('/skins/browserbob?ref=legacy-category')
->assertStatus(301);
});
it('legacy /category route falls back to /categories and preserves query string when no category matches', function () {
$this->get('/category/skins/does-not-exist/999999?source=legacy-category')
->assertRedirect('/categories?source=legacy-category')
->assertStatus(301);
});
it('GET /today-in-history redirects to /discover/on-this-day with 301', function () {
$this->get('/today-in-history')->assertRedirect('/discover/on-this-day')->assertStatus(301);
});
it('GET /members redirects to /creators/top with 301', function () {
$this->get('/members')->assertRedirect('/creators/top')->assertStatus(301);
});
it('GET /explore/members redirects to /creators/top with 301', function () {
$this->get('/explore/members')->assertRedirect('/creators/top')->assertStatus(301);
});
it('GET /latest-artworks redirects to /discover/fresh with 301', function () {
$this->get('/latest-artworks')->assertRedirect('/discover/fresh')->assertStatus(301);
});
it('GET /today-downloads redirects to /downloads/today with 301', function () {
$this->get('/today-downloads')->assertRedirect('/downloads/today')->assertStatus(301);
});
it('GET /monthly-commentators redirects to /comments/monthly with 301', function () {
$this->get('/monthly-commentators')->assertRedirect('/comments/monthly')->assertStatus(301);
});
it('GET /top-favourites redirects to /discover/top-rated with 301', function () {
$this->get('/top-favourites')->assertRedirect('/discover/top-rated')->assertStatus(301);
});
it('GET /downloads/today returns 200', function () {
$this->get('/downloads/today')->assertOk();
});
// ── /blog routes ─────────────────────────────────────────────────────────────
it('GET /blog returns 200', function () {

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\ActivityEvent;
use App\Models\Story;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
@@ -74,3 +75,21 @@ it('moderator can reject a pending story with reason and notify creator', functi
Notification::assertSentTo($creator, StoryStatusNotification::class);
});
it('admin approval records a story publish activity event', function () {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$story = createPendingReviewStory($creator);
$this->actingAs($admin)
->post(route('admin.stories.approve', ['story' => $story->id]))
->assertRedirect();
$this->assertDatabaseHas('activity_events', [
'actor_id' => $creator->id,
'type' => ActivityEvent::TYPE_UPLOAD,
'target_type' => ActivityEvent::TARGET_STORY,
'target_id' => $story->id,
]);
});

View File

@@ -1,5 +1,7 @@
<?php
use App\Models\ActivityEvent;
use App\Models\Notification;
use App\Models\Story;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@@ -94,3 +96,135 @@ it('creator can submit draft for review', function () {
expect($story->status)->toBe('pending_review');
expect($story->submitted_for_review_at)->not->toBeNull();
});
it('creator publish records story activity and stores a story_published notification', function () {
$creator = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $creator->id,
'title' => 'Publish Me',
'slug' => 'publish-me-' . Str::lower(Str::random(6)),
'content' => '<p>Publish content</p>',
'story_type' => 'creator_story',
'status' => 'draft',
]);
$response = $this->actingAs($creator)
->post(route('creator.stories.publish-now', ['story' => $story->id]));
$response->assertRedirect(route('stories.show', ['slug' => $story->slug]));
$story->refresh();
expect($story->status)->toBe('published');
expect($story->published_at)->not->toBeNull();
$this->assertDatabaseHas('activity_events', [
'actor_id' => $creator->id,
'type' => ActivityEvent::TYPE_UPLOAD,
'target_type' => ActivityEvent::TARGET_STORY,
'target_id' => $story->id,
]);
$notification = $creator->fresh()
->notifications()
->where('type', 'story_published')
->latest()
->first();
expect($notification)->toBeInstanceOf(Notification::class);
expect($notification->data['type'] ?? null)->toBe('story_published');
});
it('published story page renders successfully', function () {
$creator = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $creator->id,
'title' => 'Renderable Story',
'slug' => 'renderable-story-' . Str::lower(Str::random(6)),
'excerpt' => 'Renderable excerpt',
'content' => json_encode([
'type' => 'doc',
'content' => [
['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Renderable story content.']]],
['type' => 'codeBlock', 'attrs' => ['language' => 'bash'], 'content' => [['type' => 'text', 'text' => 'git clone https://github.com/klevze/sqlBackup.git']]],
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'story_type' => 'creator_story',
'status' => 'published',
'published_at' => now()->subMinute(),
]);
$this->get(route('stories.show', ['slug' => $story->slug]))
->assertOk()
->assertSee('Renderable Story', false)
->assertSee('language-bash', false)
->assertDontSee('{"type":"doc"', false);
});
it('creator can publish through story editor api create endpoint', function () {
$creator = User::factory()->create();
$response = $this->actingAs($creator)
->postJson(route('api.stories.create'), [
'title' => 'API Publish Story',
'story_type' => 'creator_story',
'content' => [
'type' => 'doc',
'content' => [
['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Story body for API publishing.']]],
],
],
'submit_action' => 'publish_now',
'status' => 'published',
]);
$response->assertOk()
->assertJsonPath('status', 'published')
->assertJsonPath('public_url', fn (string $url) => str_contains($url, '/stories/'));
$storyId = (int) $response->json('story_id');
$story = Story::query()->findOrFail($storyId);
expect($story->status)->toBe('published');
expect($story->published_at)->not->toBeNull();
});
it('creator can publish through story editor api update endpoint', function () {
$creator = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $creator->id,
'title' => 'API Draft Story',
'slug' => 'api-draft-story-' . Str::lower(Str::random(6)),
'content' => '<p>Draft content</p>',
'story_type' => 'creator_story',
'status' => 'draft',
]);
$response = $this->actingAs($creator)
->putJson(route('api.stories.update'), [
'story_id' => $story->id,
'title' => 'API Published Story',
'story_type' => 'creator_story',
'content' => [
'type' => 'doc',
'content' => [
['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Updated story content for publication.']]],
],
],
'submit_action' => 'publish_now',
'status' => 'published',
]);
$response->assertOk()
->assertJsonPath('status', 'published')
->assertJsonPath('preview_url', fn (string $url) => str_contains($url, '/preview'));
$story->refresh();
expect($story->title)->toBe('API Published Story');
expect($story->status)->toBe('published');
expect($story->published_at)->not->toBeNull();
});

View File

@@ -1,6 +1,4 @@
{
"status": "failed",
"failedTests": [
"598fdabf36083b33787e-d0e56fbd27a2103ba5b0"
]
"status": "passed",
"failedTests": []
}

View File

@@ -1,289 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e10]: Internal Server Error
- button "Copy as Markdown" [ref=e11] [cursor=pointer]:
- img [ref=e12]
- generic [ref=e15]: Copy as Markdown
- generic [ref=e18]:
- generic [ref=e19]:
- heading "Symfony\\Component\\Routing\\Exception\\RouteNotFoundException" [level=1] [ref=e20]
- generic [ref=e22]: vendor\laravel\framework\src\Illuminate\Routing\UrlGenerator.php:528
- paragraph [ref=e23]: Route [artwork.show] not defined.
- generic [ref=e24]:
- generic [ref=e25]:
- generic [ref=e26]:
- generic [ref=e27]: LARAVEL
- generic [ref=e28]: 12.53.0
- generic [ref=e29]:
- generic [ref=e30]: PHP
- generic [ref=e31]: 8.4.12
- generic [ref=e32]:
- img [ref=e33]
- text: UNHANDLED
- generic [ref=e36]: CODE 0
- generic [ref=e38]:
- generic [ref=e39]:
- img [ref=e40]
- text: "500"
- generic [ref=e43]:
- img [ref=e44]
- text: GET
- generic [ref=e47]: http://skinbase26.test/explore
- button [ref=e48] [cursor=pointer]:
- img [ref=e49]
- generic [ref=e53]:
- generic [ref=e54]:
- generic [ref=e55]:
- img [ref=e57]
- heading "Exception trace" [level=3] [ref=e60]
- generic [ref=e61]:
- generic [ref=e63] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e68]: 2 vendor frames
- button [ref=e69]:
- img [ref=e70]
- generic [ref=e74]:
- generic [ref=e75] [cursor=pointer]:
- generic [ref=e78]:
- code [ref=e82]:
- generic [ref=e83]: route(string, string)
- generic [ref=e85]: resources\views\web\explore\index.blade.php:18
- button [ref=e87]:
- img [ref=e88]
- code [ref=e96]:
- generic [ref=e97]: 13 <span class="text-xs font-semibold uppercase tracking-widest text-amber-400">✦ Featured Today</span>
- generic [ref=e98]: 14 <span class="flex-1 border-t border-white/10"></span>
- generic [ref=e99]: 15 </div>
- generic [ref=e100]: 16 <div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
- generic [ref=e101]: 17 @foreach($spotlight as $item)
- generic [ref=e102]: "18 <a href=\"{{ $item->slug ? route('artwork.show', $item->slug) : '#' }}\""
- generic [ref=e103]: 19 class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
- generic [ref=e104]: 20 bg-neutral-800 border border-white/10 hover:border-amber-400/40
- generic [ref=e105]: 21 hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"
- generic [ref=e106]: "22 title=\"{{ $item->name ?? '' }}\">"
- generic [ref=e107]: "23"
- generic [ref=e108]: "24 {{-- Thumbnail --}}"
- generic [ref=e109]: 25 <div class="aspect-[4/3] overflow-hidden bg-neutral-900">
- generic [ref=e110]: 26 <img
- generic [ref=e111]: "27 src=\"{{ $item->thumb_url ?? '' }}\""
- generic [ref=e112]: "28 @if(!empty($item->thumb_srcset)) srcset=\"{{ $item->thumb_srcset }}\" @endif"
- generic [ref=e113]: "29 alt=\"{{ $item->name ?? 'Featured artwork' }}\""
- generic [ref=e114]: "30"
- generic [ref=e116] [cursor=pointer]:
- img [ref=e117]
- generic [ref=e121]: 15 vendor frames
- button [ref=e122]:
- img [ref=e123]
- generic [ref=e128] [cursor=pointer]:
- generic [ref=e131]:
- code [ref=e135]:
- generic [ref=e136]: "Illuminate\\Pipeline\\Pipeline->{closure:{closure:Illuminate\\Pipeline\\Pipeline::carry():194}:195}(object(Illuminate\\Http\\Request))"
- generic [ref=e138]: app\Http\Middleware\EnsureOnboardingComplete.php:27
- button [ref=e140]:
- img [ref=e141]
- generic [ref=e146] [cursor=pointer]:
- img [ref=e147]
- generic [ref=e151]: 45 vendor frames
- button [ref=e152]:
- img [ref=e153]
- generic [ref=e158] [cursor=pointer]:
- generic [ref=e161]:
- code [ref=e165]:
- generic [ref=e166]: Illuminate\Foundation\Application->handleRequest(object(Illuminate\Http\Request))
- generic [ref=e168]: public\index.php:20
- button [ref=e170]:
- img [ref=e171]
- generic [ref=e175]:
- generic [ref=e176]:
- generic [ref=e177]:
- img [ref=e179]
- heading "Queries" [level=3] [ref=e181]
- generic [ref=e183]: 1-10 of 90
- generic [ref=e184]:
- generic [ref=e185]:
- generic [ref=e186]:
- generic [ref=e187]:
- img [ref=e188]
- generic [ref=e190]: mysql
- code [ref=e194]:
- generic [ref=e195]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
- generic [ref=e196]: 13.96ms
- generic [ref=e197]:
- generic [ref=e198]:
- generic [ref=e199]:
- img [ref=e200]
- generic [ref=e202]: mysql
- code [ref=e206]:
- generic [ref=e207]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
- generic [ref=e208]: 1.1ms
- generic [ref=e209]:
- generic [ref=e210]:
- generic [ref=e211]:
- img [ref=e212]
- generic [ref=e214]: mysql
- code [ref=e218]:
- generic [ref=e219]: "select * from `cache` where `key` in ('skinbasenova-cache-service:ConfigService:config:plugins')"
- generic [ref=e220]: 0.53ms
- generic [ref=e221]:
- generic [ref=e222]:
- generic [ref=e223]:
- img [ref=e224]
- generic [ref=e226]: mysql
- code [ref=e230]:
- generic [ref=e231]: "select * from `cpad` where `keycode` = 'plugins' limit 1"
- generic [ref=e232]: 0.69ms
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]:
- img [ref=e236]
- generic [ref=e238]: mysql
- code [ref=e242]:
- generic [ref=e243]: "select * from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry')"
- generic [ref=e244]: 0.51ms
- generic [ref=e245]:
- generic [ref=e246]:
- generic [ref=e247]:
- img [ref=e248]
- generic [ref=e250]: mysql
- code [ref=e254]:
- generic [ref=e255]: "insert into `cache` (`expiration`, `key`, `value`) values (1773088441, 'skinbasenova-cache-cpad_config_tabs_registry', 'a:1:{s:14:\"config.plugins\";O:50:\"Klevze\\ControlPanel\\Configuration\\PluginsConfigTab\":2:{s:14:\"*serviceName\";s:16:\"PluginsConfigTab\";s:11:\"*cacheTtl\";i:3600;}}') on duplicate key update `expiration` = values(`expiration`), `key` = values(`key`), `value` = values(`value`)"
- generic [ref=e256]: 3.31ms
- generic [ref=e257]:
- generic [ref=e258]:
- generic [ref=e259]:
- img [ref=e260]
- generic [ref=e262]: mysql
- code [ref=e266]:
- generic [ref=e267]: "delete from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry', 'skinbasenova-cache-illuminate:cache:flexible:created:cpad_config_tabs_registry')"
- generic [ref=e268]: 2.8ms
- generic [ref=e269]:
- generic [ref=e270]:
- generic [ref=e271]:
- img [ref=e272]
- generic [ref=e274]: mysql
- code [ref=e278]:
- generic [ref=e279]: "select * from `sessions` where `id` = '9JQSo5DrgARJAXNMelWZeiOWRA88DskBb5LukhVI' limit 1"
- generic [ref=e280]: 1.28ms
- generic [ref=e281]:
- generic [ref=e282]:
- generic [ref=e283]:
- img [ref=e284]
- generic [ref=e286]: mysql
- code [ref=e290]:
- generic [ref=e291]: "select * from `cache` where `key` in ('skinbasenova-cache-explore.all.trending.1')"
- generic [ref=e292]: 0.58ms
- generic [ref=e293]:
- generic [ref=e294]:
- generic [ref=e295]:
- img [ref=e296]
- generic [ref=e298]: mysql
- code [ref=e302]:
- generic [ref=e303]: "select * from `artworks` where `artworks`.`id` in (69610, 69611, 69606, 69597, 69599, 69601, 69417, 9517, 9518, 9523, 9524, 9494, 9496, 9497, 9500, 9501, 9502, 9504, 9505, 9506, 9507, 9508, 9509, 9511)"
- generic [ref=e304]: 3.13ms
- generic [ref=e305]:
- button [disabled] [ref=e306]:
- img [ref=e307]
- button [disabled] [ref=e310]:
- img [ref=e311]
- button "1" [ref=e314] [cursor=pointer]
- button "2" [ref=e316] [cursor=pointer]
- button "3" [ref=e318] [cursor=pointer]
- button "4" [ref=e320] [cursor=pointer]
- button "5" [ref=e322] [cursor=pointer]
- generic [ref=e324]: ...
- button "9" [ref=e326] [cursor=pointer]
- button [ref=e327] [cursor=pointer]:
- img [ref=e328]
- button [ref=e330] [cursor=pointer]:
- img [ref=e331]
- generic [ref=e335]:
- generic [ref=e336]:
- heading "Headers" [level=2] [ref=e337]
- generic [ref=e338]:
- generic [ref=e339]:
- generic [ref=e340]: cookie
- generic [ref=e342]: XSRF-TOKEN=eyJpdiI6IjB5YWlxRFhOOXMzMFZKNVo2anlvV0E9PSIsInZhbHVlIjoibnlXOStINjhmdmhTRUF2VTlFdHpXL3V4cDNwaFdpNnRYU0NhTUVNa0tublNvUVM0UUtlQ010UGFMOG1FRFpSVDNBZGhOUmR5c1VlQnJTbjJ2cGRJZzZYWEI2M2ZkMTh3M0hKNkhLKzJGR1VCUEJoUFpJUEd5YkhTMTJjdXcvQS8iLCJtYWMiOiIzN2M0NTViYTEyMWIxNTA3MTM3YmU4MjgyZjY3NTQxN2QyMTljNzY3Mzg3ZTk4OGVmMjA4MWQ5Zjg2ZGMyNDUxIiwidGFnIjoiIn0%3D; skinbasenova-session=eyJpdiI6IjZ6OHJOSTF1YlFhUG5DaEZmK0R5UGc9PSIsInZhbHVlIjoiSXBwOEFWT25RRlBpaXVKdzZNWWRySE96NUJwOHF6SUc1RVdsR2pEblhYQ1c4N0lTNHFSY1ZtRDY2MmxzVjFXT2RwSkVWSG9SUWNweDNLdkxHM1NmcXhJNllUNEpxeGZVN3JxQmZJM1plb3BZQ3BTTVd4Z05YV0VYb0g0UnBIKzMiLCJtYWMiOiJkNDQ3MDlhNmQ1OTdkNjI1MDliZTBlZTkzNTdkZmQ0ZDQwNTU1ZjcwNmRiZjIxMThjNmVjMjNhMGE1YTI2Nzk1IiwidGFnIjoiIn0%3D; PHPDEBUGBAR_STACK_DATA=%7B%2201KKA1F4SZKZ192GVC1Q09NG2K%22%3Anull%7D
- generic [ref=e343]:
- generic [ref=e344]: accept-encoding
- generic [ref=e346]: gzip, deflate
- generic [ref=e347]:
- generic [ref=e348]: accept
- generic [ref=e350]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
- generic [ref=e351]:
- generic [ref=e352]: accept-language
- generic [ref=e354]: en-US
- generic [ref=e355]:
- generic [ref=e356]: user-agent
- generic [ref=e358]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36
- generic [ref=e359]:
- generic [ref=e360]: upgrade-insecure-requests
- generic [ref=e362]: "1"
- generic [ref=e363]:
- generic [ref=e364]: connection
- generic [ref=e366]: close
- generic [ref=e367]:
- generic [ref=e368]: host
- generic [ref=e370]: skinbase26.test
- generic [ref=e371]:
- heading "Body" [level=2] [ref=e372]
- generic [ref=e373]: // No request body
- generic [ref=e374]:
- heading "Routing" [level=2] [ref=e375]
- generic [ref=e376]:
- generic [ref=e377]:
- generic [ref=e378]: controller
- generic [ref=e380]: App\Http\Controllers\Web\ExploreController@index
- generic [ref=e381]:
- generic [ref=e382]: route name
- generic [ref=e384]: explore.index
- generic [ref=e385]:
- generic [ref=e386]: middleware
- generic [ref=e388]: web
- generic [ref=e389]:
- heading "Routing parameters" [level=2] [ref=e390]
- generic [ref=e391]: // No routing parameters
- generic [ref=e394]:
- img [ref=e396]
- img [ref=e3434]
- generic [ref=e6474]:
- generic [ref=e6476]:
- generic [ref=e6477] [cursor=pointer]:
- generic: Request
- generic [ref=e6478]: "500"
- generic [ref=e6479] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e6480]: "2"
- generic [ref=e6481] [cursor=pointer]:
- generic: Messages
- generic [ref=e6482]: "5"
- generic [ref=e6483] [cursor=pointer]:
- generic: Timeline
- generic [ref=e6484] [cursor=pointer]:
- generic: Views
- generic [ref=e6485]: "513"
- generic [ref=e6486] [cursor=pointer]:
- generic: Queries
- generic [ref=e6487]: "91"
- generic [ref=e6488] [cursor=pointer]:
- generic: Models
- generic [ref=e6489]: "156"
- generic [ref=e6490] [cursor=pointer]:
- generic: Cache
- generic [ref=e6491]: "8"
- generic [ref=e6492]:
- generic [ref=e6499] [cursor=pointer]:
- generic [ref=e6500]: "2"
- generic [ref=e6501]: GET /explore
- generic [ref=e6502] [cursor=pointer]:
- generic: 4.91s
- generic [ref=e6504] [cursor=pointer]:
- generic: 51MB
- generic [ref=e6506] [cursor=pointer]:
- generic: 12.x
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,396 @@
import { test, expect, type Browser, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type DashboardFixture = {
email: string
password: string
username: string
}
const RECENT_VISITS_STORAGE_KEY = 'skinbase.dashboard.recent-visits'
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test'
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function seedDashboardFixture(): DashboardFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-dashboard-${token}@example.test`
const username = `e2ed${token}`.slice(0, 20)
const script = [
"use App\\Models\\User;",
"use Illuminate\\Support\\Facades\\Hash;",
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Dashboard User',",
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as DashboardFixture
}
function setPinnedSpacesForFixture(fixture: DashboardFixture, pinnedSpaces: string[] = []) {
const encodedPinnedSpaces = JSON.stringify(pinnedSpaces)
const script = [
"use App\\Models\\User;",
"use App\\Models\\DashboardPreference;",
`$user = User::where('email', '${fixture.email}')->firstOrFail();`,
`DashboardPreference::updateOrCreate(['user_id' => $user->id], ['pinned_spaces' => json_decode('${encodedPinnedSpaces}', true)]);`,
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
function resetBotProtectionState() {
const script = [
"use Illuminate\\Support\\Facades\\DB;",
"use Illuminate\\Support\\Facades\\Schema;",
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
" if (Schema::hasTable($table)) {",
" DB::table($table)->delete();",
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
async function login(page: Page, fixture: DashboardFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const viteManifestError = page.getByText(/Vite manifest not found/i)
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
viteManifestError.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if (await viteManifestError.isVisible().catch(() => false)) {
throw new Error('Dashboard Playwright login failed because the Vite manifest is missing. Run the frontend build before running this spec.')
}
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Dashboard Playwright login failed because the login page returned an internal server error before the form loaded.')
}
await emailField.fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /E2E Dashboard User/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Dashboard Playwright login failed before reaching an authenticated page.')
}
}
}
async function openDashboard(page: Page, fixture: DashboardFixture) {
await login(page, fixture)
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
}
async function openDashboardInFreshContext(browser: Browser, fixture: DashboardFixture) {
const context = await browser.newContext({ baseURL: BASE_URL, ignoreHTTPSErrors: true })
const page = await context.newPage()
await openDashboard(page, fixture)
return { context, page }
}
async function saveShortcutUpdate(page: Page, action: () => Promise<void>) {
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/dashboard/preferences/shortcuts') && response.request().method() === 'PUT'
)
await action()
const response = await responsePromise
expect(response.ok()).toBeTruthy()
}
async function performShortcutUpdate(page: Page, action: () => Promise<void>) {
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/dashboard/preferences/shortcuts') && response.request().method() === 'PUT'
)
await action()
return responsePromise
}
function shortcutToast(page: Page) {
return page.getByText(/Saving dashboard shortcuts|Dashboard shortcuts saved\./i).first()
}
async function seedRecentVisits(page: Page, items: Array<{ href: string; label: string; pinned?: boolean; lastVisitedAt?: string | null }>) {
await page.evaluate(
({ storageKey, recentItems }) => {
window.localStorage.setItem(storageKey, JSON.stringify(recentItems))
},
{ storageKey: RECENT_VISITS_STORAGE_KEY, recentItems: items }
)
}
function recentVisitsHeading(page: Page) {
return page.getByRole('heading', { name: 'Recently visited dashboard spaces' })
}
function pinnedSection(page: Page) {
return page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Your fastest dashboard shortcuts' }) })
.first()
}
async function pinnedShortcutLabels(page: Page): Promise<string[]> {
return pinnedSection(page).locator('article h3').allTextContents()
}
async function pinShortcut(page: Page, name: string) {
await saveShortcutUpdate(page, async () => {
await page.getByRole('button', { name: `Pin ${name}` }).click()
})
}
async function unpinShortcut(page: Page, name: string) {
await saveShortcutUpdate(page, async () => {
await pinnedSection(page).getByRole('button', { name: `Unpin ${name}` }).click()
})
}
async function pinRecentShortcut(page: Page, name: string) {
await saveShortcutUpdate(page, async () => {
await page.getByRole('button', { name: `Pin ${name}` }).first().click()
})
}
test.describe('Dashboard pinned shortcuts', () => {
test.describe.configure({ mode: 'serial' })
let fixture: DashboardFixture
test.beforeAll(() => {
ensureCompiledAssets()
fixture = seedDashboardFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
setPinnedSpacesForFixture(fixture, [])
})
test('pins shortcuts, preserves explicit order, and survives reload without local recents', async ({ page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await expect(shortcutToast(page)).toBeVisible()
await pinShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await saveShortcutUpdate(page, async () => {
await pinnedSection(page).getByRole('button', { name: 'Move Favorites earlier' }).click()
})
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications'])
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey)
}, RECENT_VISITS_STORAGE_KEY)
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications'])
})
test('pinned strip matches the visual baseline', async ({ page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await pinShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await expect(pinnedSection(page)).toHaveScreenshot('dashboard-pinned-strip.png', {
animations: 'disabled',
caret: 'hide',
maxDiffPixels: 50,
})
})
test('pinned strip matches the mobile visual baseline', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await pinShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await expect(pinnedSection(page)).toHaveScreenshot('dashboard-pinned-strip-mobile.png', {
animations: 'disabled',
caret: 'hide',
maxDiffPixels: 50,
})
})
test('shows an error toast when shortcut persistence fails', async ({ page }) => {
await openDashboard(page, fixture)
await page.route('**/api/dashboard/preferences/shortcuts', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Server error' }),
})
})
const response = await performShortcutUpdate(page, async () => {
await page.getByRole('button', { name: 'Pin Notifications' }).click()
})
expect(response.ok()).toBeFalsy()
await expect(page.getByText('Could not save dashboard shortcuts. Refresh and try again.')).toBeVisible()
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications'])
})
test('unpinning the last shortcut removes the pinned strip', async ({ page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications'])
await unpinShortcut(page, 'Notifications')
await expect(pinnedSection(page)).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Pin Notifications' }).first()).toBeVisible()
})
test('saved pinned order is restored in a fresh browser context', async ({ browser, page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await pinShortcut(page, 'Favorites')
await saveShortcutUpdate(page, async () => {
await pinnedSection(page).getByRole('button', { name: 'Move Favorites earlier' }).click()
})
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications'])
const fresh = await openDashboardInFreshContext(browser, fixture)
try {
await fresh.page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey)
}, RECENT_VISITS_STORAGE_KEY)
await fresh.page.reload({ waitUntil: 'domcontentloaded' })
await expect(fresh.page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(pinnedSection(fresh.page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(fresh.page)).toEqual(['Favorites', 'Notifications'])
} finally {
await fresh.context.close()
}
})
test('pinning from recent cards feeds the same persisted pinned order', async ({ page }) => {
await openDashboard(page, fixture)
const now = new Date().toISOString()
await seedRecentVisits(page, [
{
href: '/dashboard/notifications',
label: 'Notifications',
pinned: false,
lastVisitedAt: now,
},
{
href: '/dashboard/favorites',
label: 'Favorites',
pinned: false,
lastVisitedAt: now,
},
])
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(recentVisitsHeading(page)).toBeVisible()
await pinRecentShortcut(page, 'Notifications')
await pinRecentShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey)
}, RECENT_VISITS_STORAGE_KEY)
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -68,9 +68,9 @@ const PUBLIC_ROUTES: RouteFixture[] = [
{ url: '/comments/monthly', label: 'Monthly commentators (new)' },
{ url: '/downloads/today', label: 'Today downloads (new)' },
{ url: '/top-authors', label: 'Top authors (legacy)', expectUrlContains: '/creators/top' },
{ url: '/top-favourites', label: 'Top favourites (legacy)' },
{ url: '/top-favourites', label: 'Top favourites (legacy)', expectUrlContains: '/discover/top-rated' },
{ url: '/today-downloads', label: 'Today downloads (legacy)' },
{ url: '/today-in-history', label: 'Today in history' },
{ url: '/today-in-history', label: 'Today in history', expectUrlContains: '/discover/on-this-day' },
{ url: '/monthly-commentators',label: 'Monthly commentators (legacy)' },
{ url: '/latest-comments', label: 'Latest comments (legacy)' },
{ url: '/interviews', label: 'Interviews', expectUrlContains: '/stories' },