Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
use App\Models\CreatorAiBiography;
use App\Models\Artwork;
use App\Models\User;
use App\Services\AiBiography\AiBiographyGenerator;
use App\Services\AiBiography\AiBiographyInputBuilder;
use App\Services\AiBiography\AiBiographyService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
use Klevze\ControlPanel\Models\Admin\AdminVerification;
uses(RefreshDatabase::class);
function aiBiographyAdminUser(array $attributes = []): User
{
$admin = User::factory()->create(array_merge(['role' => 'admin'], $attributes));
$admin->forceFill([
'isAdmin' => true,
'activated' => true,
])->save();
AdminVerification::createForUser($admin->fresh());
return $admin->fresh();
}
function biographyRecord(User $user, array $attributes = []): CreatorAiBiography
{
return CreatorAiBiography::query()->create(array_merge([
'user_id' => $user->id,
'text' => 'This creator has built a consistent public body of work on Skinbase, with a long-running profile, visible uploads, and a biography long enough to support admin review without tripping validation rules.',
'source_hash' => 'hash-' . fake()->unique()->numerify('####'),
'model' => 'vision-gateway',
'prompt_version' => 'v1.1',
'input_quality_tier' => CreatorAiBiography::TIER_MEDIUM,
'generation_reason' => CreatorAiBiography::REASON_ADMIN_BATCH,
'status' => CreatorAiBiography::STATUS_GENERATED,
'is_active' => true,
'is_hidden' => false,
'is_user_edited' => false,
'needs_review' => false,
'generated_at' => now()->subHour(),
'approved_at' => now()->subHour(),
'last_attempted_at' => now()->subHour(),
'last_error_code' => null,
'last_error_reason' => null,
], $attributes));
}
it('blocks non staff users from the ai biography admin area', function (): void {
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)
->get(route('cp.ai-biography.index'))
->assertRedirect(route('cp.login'));
});
it('renders the ai biography admin index with records and stats', function (): void {
$admin = aiBiographyAdminUser();
$creator = User::factory()->create(['username' => 'bioadmin']);
biographyRecord($creator, [
'needs_review' => true,
'status' => CreatorAiBiography::STATUS_NEEDS_REVIEW,
]);
biographyRecord($creator, [
'is_active' => false,
'status' => CreatorAiBiography::STATUS_FAILED,
'last_error_code' => 'generation_failed',
'last_error_reason' => 'Gateway timeout',
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('cp.ai-biography.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Moderation/AiBiographyAdmin')
->where('stats.total_records', 2)
->where('stats.needs_review', 1)
->where('stats.failed', 1)
->where('records.data.0.user.username', 'bioadmin')
->where('records.data.0.status', CreatorAiBiography::STATUS_FAILED)
->where('records.data.1.status', CreatorAiBiography::STATUS_NEEDS_REVIEW)
->where('endpoints.rebuildPattern', route('cp.ai-biography.rebuild', ['user' => '__USER__'])));
});
it('allows controlpanel-only admins to open the ai biography admin page', function (): void {
$admin = aiBiographyAdminUser();
$this->actingAs($admin, 'controlpanel')
->get(route('cp.ai-biography.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Moderation/AiBiographyAdmin')
->where('title', 'AI Biography Review'));
});
it('registers the ai biography entry in the cpad artworks menu', function (): void {
$admin = aiBiographyAdminUser();
$creator = User::factory()->create(['username' => 'menubio']);
biographyRecord($creator, [
'needs_review' => true,
'status' => CreatorAiBiography::STATUS_NEEDS_REVIEW,
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('cp.ai-biography.index'))
->assertOk();
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
$artworksRoot = $sidebarMenu
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
expect($artworksRoot)->toBeInstanceOf(MenuRootItem::class);
$aiBiographyItem = collect($artworksRoot->getItems())
->first(fn ($item): bool => str_starts_with((string) ($item->name ?? ''), 'AI Biographies'));
expect($aiBiographyItem)->not->toBeNull()
->and($aiBiographyItem->mainRoute)->toBe('cp.ai-biography.index')
->and($aiBiographyItem->icon)->toBe('fa-solid fa-feather-pointed');
});
it('rebuilds an existing active biography through the admin surface', function (): void {
$admin = aiBiographyAdminUser();
$creator = User::factory()->create(['username' => 'rebuildme']);
biographyRecord($creator);
for ($i = 0; $i < 5; $i++) {
Artwork::factory()->for($creator)->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays($i + 1),
'deleted_at' => null,
]);
}
$generator = Mockery::mock(AiBiographyGenerator::class);
$generator->shouldReceive('generate')
->once()
->andReturn([
'success' => true,
'text' => 'This refreshed biography gives the admin panel enough verified text to create a fresh active record while keeping the assertions here stable and predictable for the test suite.',
'action' => 'generated',
'errors' => [],
'model' => 'test-model',
'prompt_version' => 'v1.1',
'was_retried' => false,
]);
app()->instance(AiBiographyService::class, new AiBiographyService(new AiBiographyInputBuilder(), $generator));
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('cp.ai-biography.rebuild', ['user' => $creator->id]))
->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('message', 'Biography rebuild completed.');
expect(CreatorAiBiography::query()->where('user_id', $creator->id)->where('is_active', true)->count())->toBe(1)
->and(CreatorAiBiography::query()->where('user_id', $creator->id)->count())->toBe(2);
});
it('allows admins to approve flag and toggle visibility on biography records', function (): void {
$admin = aiBiographyAdminUser();
$creator = User::factory()->create(['username' => 'reviewstate']);
$record = biographyRecord($creator, [
'needs_review' => true,
'status' => CreatorAiBiography::STATUS_NEEDS_REVIEW,
]);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('cp.ai-biography.approve', ['biography' => $record->id]))
->assertOk()
->assertJsonPath('success', true);
expect($record->fresh()->needs_review)->toBeFalse()
->and($record->fresh()->status)->toBe(CreatorAiBiography::STATUS_APPROVED);
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('cp.ai-biography.hide', ['biography' => $record->id]))
->assertOk()
->assertJsonPath('success', true);
expect($record->fresh()->is_hidden)->toBeTrue();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('cp.ai-biography.show', ['biography' => $record->id]))
->assertOk()
->assertJsonPath('success', true);
expect($record->fresh()->is_hidden)->toBeFalse();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('cp.ai-biography.flag', ['biography' => $record->id]))
->assertOk()
->assertJsonPath('success', true);
expect($record->fresh()->needs_review)->toBeTrue()
->and($record->fresh()->status)->toBe(CreatorAiBiography::STATUS_NEEDS_REVIEW);
});

View File

@@ -23,15 +23,76 @@ test('users can authenticate using the login screen', function () {
$response->assertRedirect(route('dashboard', absolute: false));
});
test('users can not authenticate with invalid password', function () {
test('users with incomplete onboarding can authenticate with username', function () {
$user = User::factory()->create([
'onboarding_step' => null,
]);
$response = $this->post('/login', [
'email' => $user->username,
'password' => 'password',
]);
$this->assertAuthenticatedAs($user);
$response->assertRedirect(route('setup.email.create', absolute: false));
});
test('legacy users pending email upgrade can authenticate with username', function () {
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'onboarding_step' => null,
]);
$response = $this->post('/login', [
'email' => $user->username,
'password' => 'password',
]);
$this->assertAuthenticatedAs($user);
$response->assertRedirect(route('setup.email.create', absolute: false));
});
test('standard users can not authenticate with username', function () {
config()->set('app.debug', false);
$user = User::factory()->create();
$this->post('/login', [
$response = $this->from('/login')->post('/login', [
'email' => $user->username,
'password' => 'password',
]);
$this->assertGuest();
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
});
test('username-login upgrade session redirects users to setup email flow', function () {
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'onboarding_step' => null,
]);
$response = $this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/dashboard');
$response->assertRedirect(route('setup.email.create', absolute: false));
});
test('users can not authenticate with invalid password', function () {
config()->set('app.debug', false);
$user = User::factory()->create();
$response = $this->from('/login')->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
});
test('users can logout', function () {

View File

@@ -53,3 +53,13 @@ it('allows complete onboarding user to access profile and upload', function () {
->get('/upload')
->assertOk();
});
it('allows legacy users with null onboarding step to continue without setup redirect', function () {
$user = User::factory()->create([
'onboarding_step' => null,
]);
$this->actingAs($user)
->get('/upload')
->assertOk();
});

View File

@@ -30,7 +30,9 @@ it('throttles excessive registration attempts by ip', function () {
for ($i = 0; $i < 2; $i++) {
$this->post('/register', [
'email' => 'user-rate-' . $i . '@example.com',
])->assertRedirect('/register/notice');
])->assertRedirect('/setup/password');
auth()->logout();
}
$this->post('/register', [
@@ -94,20 +96,23 @@ it('shows turnstile when ip is in rate-limited state', function () {
it('enforces verification email cooldown per address', function () {
Queue::fake();
$this->post('/register', [
$first = $this->post('/register', [
'email' => 'cooldown2@example.com',
])->assertRedirect('/register/notice');
]);
$first->assertRedirect('/setup/password');
auth()->logout();
$response = $this->post('/register', [
'email' => 'cooldown2@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class, 1);
$response->assertRedirect('/setup/password');
$response->assertSessionHas('status', 'Continue with password setup.');
Queue::assertNothingPushed();
});
it('returns generic success for existing verified emails (anti-enumeration)', function () {
it('rejects registration for existing completed emails', function () {
Queue::fake();
User::factory()->create([
@@ -117,12 +122,12 @@ it('returns generic success for existing verified emails (anti-enumeration)', fu
'is_active' => true,
]);
$response = $this->post('/register', [
$response = $this->from('/register')->post('/register', [
'email' => 'existing@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
$response->assertRedirect('/register');
$response->assertSessionHasErrors('email');
Queue::assertNothingPushed();
});
@@ -151,6 +156,7 @@ it('still allows registration when turnstile passes', function () {
'cf-turnstile-response' => 'good-token',
]);
$response->assertRedirect('/register/notice');
$response->assertRedirect('/setup/password');
$this->assertDatabaseHas('users', ['email' => 'captcha-pass@example.com']);
Queue::assertNothingPushed();
});

View File

@@ -2,35 +2,21 @@
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\DB;
use App\Jobs\SendVerificationEmailJob;
uses(RefreshDatabase::class);
it('completes happy path registration onboarding flow', function () {
Queue::fake();
$register = $this->post('/register', [
'email' => 'flow-user@example.com',
]);
$register->assertRedirect('/register/notice');
$register->assertRedirect('/setup/password');
$user = User::query()->where('email', 'flow-user@example.com')->firstOrFail();
expect($user->onboarding_step)->toBe('email');
$token = null;
Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$token) {
$token = $job->token;
return true;
});
$this->get('/verify/' . $token)->assertRedirect('/setup/password');
$user->refresh();
expect($user->onboarding_step)->toBe('verified');
expect($user->email_verified_at)->toBeNull();
$this->assertAuthenticatedAs($user);
$this->actingAs($user)
->post('/setup/password', [
@@ -79,8 +65,6 @@ it('rejects expired verification token', function () {
});
it('rejects duplicate email at registration', function () {
Queue::fake();
User::factory()->create([
'email' => 'duplicate-check@example.com',
'email_verified_at' => now(),
@@ -88,13 +72,12 @@ it('rejects duplicate email at registration', function () {
'is_active' => true,
]);
$response = $this->post('/register', [
$response = $this->from('/register')->post('/register', [
'email' => 'duplicate-check@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertNothingPushed();
$response->assertRedirect('/register');
$response->assertSessionHasErrors('email');
});
it('rejects username conflict during username setup', function () {

View File

@@ -8,13 +8,8 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('shows registration notice with email after first step', function () {
Queue::fake();
$this->post('/register', [
'email' => 'notice@example.com',
])->assertRedirect('/register/notice');
$this->get('/register/notice')
$this->withSession(['registration_email' => 'notice@example.com'])
->get('/register/notice')
->assertOk()
->assertSee('notice@example.com')
->assertSee('Change email');
@@ -29,9 +24,12 @@ it('prefills register form email from query string', function () {
it('blocks resend while cooldown is active', function () {
Queue::fake();
$this->post('/register', [
User::factory()->create([
'email' => 'cooldown@example.com',
])->assertRedirect('/register/notice');
'email_verified_at' => null,
'onboarding_step' => 'email',
'last_verification_sent_at' => now(),
]);
$response = $this->from('/register/notice')->post('/register/resend-verification', [
'email' => 'cooldown@example.com',
@@ -41,26 +39,24 @@ it('blocks resend while cooldown is active', function () {
$response->assertSessionHasNoErrors();
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class, 1);
Queue::assertNothingPushed();
});
it('resends verification after cooldown expires', function () {
Queue::fake();
$this->post('/register', [
User::factory()->create([
'email' => 'resend@example.com',
])->assertRedirect('/register/notice');
$user = User::query()->where('email', 'resend@example.com')->firstOrFail();
$user->forceFill([
'email_verified_at' => null,
'onboarding_step' => 'email',
'last_verification_sent_at' => now()->subMinutes(31),
])->save();
]);
$this->post('/register/resend-verification', [
'email' => 'resend@example.com',
])->assertRedirect('/register/notice');
Queue::assertPushed(SendVerificationEmailJob::class, 2);
Queue::assertPushed(SendVerificationEmailJob::class, 1);
expect(User::query()->where('email', 'resend@example.com')->exists())->toBeTrue();
});

View File

@@ -23,9 +23,9 @@ it('returns generic success even when quota is exceeded', function () {
'email' => 'quota-hit@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class);
$response->assertRedirect('/setup/password');
$response->assertSessionHas('status', 'Continue with password setup.');
Queue::assertNothingPushed();
});
it('blocks actual send in job when monthly quota is exceeded', function () {

View File

@@ -1,6 +1,6 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
test('registration screen can be rendered', function () {
@@ -23,18 +23,19 @@ test('new users can register', function () {
'email' => 'test@example.com',
]);
$this->assertGuest();
$response->assertRedirect(route('register.notice', absolute: false));
$user = User::query()->where('email', 'test@example.com')->firstOrFail();
$this->assertAuthenticatedAs($user);
$response->assertRedirect(route('setup.password.create', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
'onboarding_step' => 'email',
'is_active' => 0,
'onboarding_step' => 'verified',
'is_active' => 1,
'needs_password_reset' => 1,
]);
$this->assertDatabaseHas('user_verification_tokens', [
'user_id' => (int) \App\Models\User::query()->where('email', 'test@example.com')->value('id'),
]);
Queue::assertPushed(SendVerificationEmailJob::class);
expect($user->email_verified_at)->toBeNull();
$this->assertDatabaseCount('user_verification_tokens', 0);
Queue::assertNothingPushed();
});

View File

@@ -1,6 +1,5 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
@@ -9,29 +8,19 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('stores verification tokens hashed instead of raw token', function () {
it('registration no longer creates verification tokens', function () {
Queue::fake();
$this->post('/register', [
$response = $this->post('/register', [
'email' => 'token-hash@example.com',
])->assertRedirect('/register/notice');
$rawToken = null;
Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$rawToken) {
$rawToken = $job->token;
return true;
});
]);
$userId = (int) User::query()->where('email', 'token-hash@example.com')->value('id');
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
$storedToken = (string) DB::table('user_verification_tokens')
->where('user_id', $userId)
->value($column);
$response->assertRedirect('/setup/password');
expect($rawToken)->not->toBeNull();
expect($storedToken)->toBe(hash('sha256', (string) $rawToken));
expect($storedToken)->not->toBe((string) $rawToken);
expect($userId)->toBeGreaterThan(0);
expect(DB::table('user_verification_tokens')->where('user_id', $userId)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('verifies token and redirects to password setup', function () {

View File

@@ -1,7 +1,7 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
@@ -30,16 +30,20 @@ it('registration email contains verification link expiry and support url', funct
expect($html)->toContain('https://skinbase.example/support');
});
it('registration endpoint queues verification email job', function () {
it('registration endpoint skips verification email job and continues onboarding immediately', function () {
Queue::fake();
$this->post('/register', [
$response = $this->post('/register', [
'email' => 'mail-test@example.com',
])->assertRedirect('/register/notice');
]);
Queue::assertPushed(SendVerificationEmailJob::class);
$user = User::query()->where('email', 'mail-test@example.com')->firstOrFail();
$this->assertAuthenticatedAs($user);
$response->assertRedirect('/setup/password');
Queue::assertNothingPushed();
$this->assertDatabaseHas('users', [
'email' => 'mail-test@example.com',
'onboarding_step' => 'email',
'onboarding_step' => 'verified',
]);
});

View File

@@ -0,0 +1,98 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
it('requires authentication to open setup email screen', function () {
$this->get('/setup/email')
->assertRedirect('/login');
});
it('renders setup email screen for authenticated user', function () {
$user = User::factory()->create([
'onboarding_step' => null,
]);
$this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/setup/email')
->assertOk()
->assertSee('Add Your Email')
->assertSee('Continue');
});
it('saves email during setup email step and moves to password setup when reset is required', function () {
Mail::fake();
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'onboarding_step' => null,
'needs_password_reset' => true,
]);
$response = $this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->post('/setup/email', [
'email' => 'upgraded@example.com',
]);
$response->assertRedirect('/setup/password');
$response->assertSessionHas('status', 'Email saved. Continue with password setup.');
$user->refresh();
expect($user->email)->toBe('upgraded@example.com');
expect($user->onboarding_step)->toBe('verified');
expect($user->email_verified_at)->toBeNull();
Mail::assertNothingQueued();
});
it('saves email during setup email step and moves to username step when password reset is not required', function () {
Mail::fake();
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'email_verified_at' => null,
'onboarding_step' => null,
'needs_password_reset' => false,
]);
$response = $this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->post('/setup/email', [
'email' => 'upgraded@example.com',
]);
$response->assertRedirect('/setup/username');
$user->refresh();
expect($user->email)->toBe('upgraded@example.com');
expect($user->onboarding_step)->toBe('password');
expect($user->email_verified_at)->toBeNull();
Mail::assertNothingQueued();
});
it('redirects username-login upgrade users to username step after email save', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/dashboard')
->assertRedirect('/setup/username');
});
it('redirects username-login upgrade users to password step when reset is still required', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
]);
$this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/dashboard')
->assertRedirect('/setup/password');
});

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config()->set('database.connections.legacy', config('database.connections.' . config('database.default')));
DB::purge('legacy');
Schema::connection('legacy')->dropIfExists('legacy_users');
Schema::connection('legacy')->create('legacy_users', function (Blueprint $table): void {
$table->unsignedBigInteger('user_id')->primary();
$table->string('uname')->nullable();
$table->string('email')->nullable();
$table->string('real_name')->nullable();
$table->timestamp('joinDate')->nullable();
$table->timestamp('LastVisit')->nullable();
$table->unsignedTinyInteger('active')->default(1);
$table->unsignedTinyInteger('should_migrate')->default(0);
});
});
afterEach(function (): void {
Schema::connection('legacy')->dropIfExists('legacy_users');
});
it('passes when every legacy should_migrate user exists in the new users table', function (): void {
DB::table('users')->insert([
[
'id' => 101,
'username' => 'alpha',
'email' => 'alpha@example.test',
'password' => bcrypt('secret'),
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => 102,
'username' => 'beta',
'email' => 'beta@example.test',
'password' => bcrypt('secret'),
'created_at' => now(),
'updated_at' => now(),
],
]);
DB::connection('legacy')->table('legacy_users')->insert([
['user_id' => 101, 'uname' => 'alpha', 'email' => 'alpha@example.test', 'should_migrate' => 1],
['user_id' => 102, 'uname' => 'beta', 'email' => 'beta@example.test', 'should_migrate' => 1],
['user_id' => 103, 'uname' => 'gamma', 'email' => 'gamma@example.test', 'should_migrate' => 0],
]);
$code = Artisan::call('users:audit-missing-migrated', [
'--legacy-users-table' => 'legacy_users',
'--chunk' => 1,
]);
$output = Artisan::output();
expect($code)->toBe(0)
->and($output)->toContain('Scanning legacy.legacy_users for should_migrate=1 and checking')
->and($output)->toContain('Done. scanned=2 existing=2 missing=0')
->and($output)->not->toContain('[missing]');
});
it('fails and outputs legacy users that are missing in the new users table', function (): void {
DB::table('users')->insert([
'id' => 201,
'username' => 'present-user',
'email' => 'present@example.test',
'password' => bcrypt('secret'),
'created_at' => now(),
'updated_at' => now(),
]);
DB::connection('legacy')->table('legacy_users')->insert([
['user_id' => 201, 'uname' => 'present-user', 'email' => 'present@example.test', 'should_migrate' => 1],
['user_id' => 202, 'uname' => 'missing-user', 'email' => 'missing@example.test', 'should_migrate' => 1],
['user_id' => 203, 'uname' => null, 'email' => null, 'should_migrate' => 1],
]);
$code = Artisan::call('users:audit-missing-migrated', [
'--legacy-users-table' => 'legacy_users',
'--chunk' => 2,
]);
$output = Artisan::output();
expect($code)->toBe(1)
->and($output)->toContain('[missing] id=202 uname=@missing-user email=<missing@example.test>')
->and($output)->toContain('[missing] id=203 uname=(none) email=(none)')
->and($output)->toContain('Done. scanned=3 existing=1 missing=2');
});
it('can write missing users to a transaction wrapped sql file', function (): void {
$sqlPath = base_path('test-results/audit-missing-migrated-users.sql');
@unlink($sqlPath);
DB::table('users')->insert([
[
'id' => 301,
'username' => 'present-user',
'email' => 'present@example.test',
'password' => bcrypt('secret'),
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => 999,
'username' => 'missing-user',
'email' => 'missing@example.test',
'password' => bcrypt('secret'),
'created_at' => now(),
'updated_at' => now(),
],
]);
DB::connection('legacy')->table('legacy_users')->insert([
[
'user_id' => 301,
'uname' => 'present-user',
'email' => 'present@example.test',
'real_name' => 'Present User',
'joinDate' => '2024-01-01 10:00:00',
'LastVisit' => '2024-01-02 11:00:00',
'active' => 1,
'should_migrate' => 1,
],
[
'user_id' => 302,
'uname' => 'missing-user',
'email' => 'missing@example.test',
'real_name' => 'Legacy Missing',
'joinDate' => '2023-05-01 08:30:00',
'LastVisit' => '2023-05-03 09:45:00',
'active' => 0,
'should_migrate' => 1,
],
]);
$code = Artisan::call('users:audit-missing-migrated', [
'--legacy-users-table' => 'legacy_users',
'--sql-output' => $sqlPath,
]);
$output = Artisan::output();
$sql = file_get_contents($sqlPath);
expect($code)->toBe(1)
->and($output)->toContain('SQL export written to ' . $sqlPath . ' with 1 INSERT statement(s).')
->and($sql)->not->toBeFalse()
->and($sql)->toContain('START TRANSACTION;')
->and($sql)->toContain('INSERT INTO `users`')
->and($sql)->toContain("302, 'tmpu302'")
->and($sql)->toContain("'missing+1@example.test'")
->and($sql)->toContain("'Legacy Missing'")
->and($sql)->toContain('COMMIT;');
@unlink($sqlPath);
});

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkAiAssist;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use Illuminate\Support\Facades\Http;
it('generates and stores artwork ai suggestions from the artisan command', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.lm_studio.base_url', 'https://lmstudio.local');
config()->set('vision.lm_studio.model', 'google/gemma-3-4b');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'cmdaa112233',
'file_name' => 'rose-closeup.jpg',
'title' => 'Rose Study',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://lmstudio.local/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'rose macro',
'flower close-up',
'soft petals',
'natural light',
'botanical photography',
'pink tones',
'shallow depth',
'floral detail',
'macro photography',
'garden bloom',
], JSON_THROW_ON_ERROR),
],
],
],
], 200),
]);
$this->artisan('artworks:ai-suggest', ['artwork_id' => $artwork->id])
->expectsOutputToContain('provider: lm_studio')
->expectsOutputToContain('tags: rose-macro, flower-close-up')
->expectsOutputToContain('content type: photography | category: flowers')
->assertSuccessful();
$assist = ArtworkAiAssist::query()->where('artwork_id', $artwork->id)->first();
expect($assist)->not->toBeNull();
expect($assist?->status)->toBe(ArtworkAiAssist::STATUS_READY);
expect($assist?->tag_suggestions_json)->not->toBeEmpty();
expect(collect($assist?->tag_suggestions_json ?? [])->contains(fn (array $row): bool => ($row['tag'] ?? null) === 'rose-macro'))->toBeTrue();
expect($assist?->raw_response_json['tag_generation']['raw_content'] ?? null)->toBe('["rose macro","flower close-up","soft petals","natural light","botanical photography","pink tones","shallow depth","floral detail","macro photography","garden bloom"]');
expect($assist?->raw_response_json['tag_generation']['image_url'] ?? null)->toBe('https://files.local/artworks/md/cm/da/cmdaa112233.webp');
});
it('supports overriding the provider to together from the artisan command', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.together.base_url', 'https://api.together.xyz');
config()->set('vision.together.endpoint', '/v1/chat/completions');
config()->set('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo');
config()->set('vision.together.api_key', 'together-test-key');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'tgaa11223344',
'file_name' => 'rose-closeup.jpg',
'title' => 'Together Rose Study',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://api.together.xyz/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'rose macro',
'flower close-up',
'soft petals',
'natural light',
'botanical photography',
'pink tones',
'shallow depth',
'floral detail',
'macro photography',
'garden bloom',
], JSON_THROW_ON_ERROR),
],
],
],
], 200),
]);
$this->artisan('artworks:ai-suggest', [
'artwork_id' => $artwork->id,
'--provider' => 'together',
])
->expectsOutputToContain('provider: together')
->expectsOutputToContain('tags: rose-macro, flower-close-up')
->assertSuccessful();
$assist = ArtworkAiAssist::query()->where('artwork_id', $artwork->id)->first();
expect($assist)->not->toBeNull();
expect($assist?->raw_response_json['tag_generation']['provider'] ?? null)->toBe('together');
expect($assist?->raw_response_json['tag_generation']['endpoint'] ?? null)->toBe('https://api.together.xyz/v1/chat/completions');
Http::assertSent(function (\Illuminate\Http\Client\Request $request): bool {
return $request->url() === 'https://api.together.xyz/v1/chat/completions'
&& $request->hasHeader('Authorization', 'Bearer together-test-key');
});
});

View File

@@ -0,0 +1,17 @@
<?php
it('artworks scout index settings include maturity filter fields used by search filters', function () {
$indexName = (string) config('scout.prefix', '') . 'artworks';
$settings = config('scout.meilisearch.index-settings', []);
expect($settings)->toBeArray();
expect($settings)->toHaveKey($indexName);
$filterableAttributes = $settings[$indexName]['filterableAttributes'] ?? [];
expect($filterableAttributes)->toContain('is_mature');
expect($filterableAttributes)->toContain('is_mature_effective');
expect($filterableAttributes)->toContain('maturity_level');
expect($filterableAttributes)->toContain('maturity_status');
expect($filterableAttributes)->toContain('published_as_type');
});

View File

@@ -1,6 +1,7 @@
<?php
use App\Models\Artwork;
use App\Http\Controllers\Web\DiscoverController;
use App\Services\ArtworkService;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -12,6 +13,9 @@ beforeEach(function () {
$this->artworksMock->shouldReceive('getLatestArtworks')
->andReturn(collect())
->byDefault();
$this->artworksMock->shouldReceive('getFeaturedArtworkWinner')
->andReturn(null)
->byDefault();
$this->app->instance(ArtworkService::class, $this->artworksMock);
});
@@ -37,6 +41,80 @@ it('GET /discover/trending still returns 200', function () {
->assertStatus(200);
});
it('drops stale private artworks during discover search hydration', function () {
$visibleArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Visible Trending Artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_approved' => true,
'published_at' => now()->subHour(),
]));
$privateArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Private Trending Artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => true,
'published_at' => now()->subHour(),
]));
$controller = app(DiscoverController::class);
$method = new ReflectionMethod($controller, 'hydrateDiscoverSearchResults');
$method->setAccessible(true);
$paginator = new LengthAwarePaginator(
collect([
(object) ['id' => $visibleArtwork->id, 'title' => $visibleArtwork->title],
(object) ['id' => $privateArtwork->id, 'title' => $privateArtwork->title],
]),
2,
24,
1
);
$method->invoke($controller, $paginator);
expect($paginator->getCollection()->pluck('id')->all())->toBe([$visibleArtwork->id]);
expect($paginator->getCollection()->pluck('name')->all())->toBe(['Visible Trending Artwork']);
});
it('excludes private and unpublished artworks from trending database fallback', function () {
$visibleArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Visible Fallback Artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_approved' => true,
'published_at' => now()->subHours(2),
]));
$privateArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Private Fallback Artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => true,
'published_at' => now()->subHours(2),
]));
$scheduledArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Scheduled Fallback Artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_approved' => true,
'published_at' => now()->addDay(),
]));
$controller = app(DiscoverController::class);
$method = new ReflectionMethod($controller, 'fallbackTrendingFromDatabase');
$method->setAccessible(true);
$paginator = $method->invoke($controller, 24, 30);
$ids = $paginator->getCollection()->pluck('id')->all();
expect($ids)->toContain($visibleArtwork->id);
expect($ids)->not->toContain($privateArtwork->id);
expect($ids)->not->toContain($scheduledArtwork->id);
});
it('home page still renders with rising section data', function () {
$this->get('/')
->assertStatus(200);

View File

@@ -63,6 +63,17 @@ it('skips private artworks', function () {
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
});
it('skips artworks with private visibility even if is_public is true', function () {
Artwork::factory()->create([
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
});
it('skips unapproved artworks', function () {
Artwork::factory()->create([
'is_public' => true,

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Http\Controllers\Web\ExploreController;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
class ExploreFiltersTest extends TestCase
{
use RefreshDatabase;
public function test_explore_builds_a_filter_expression_from_selected_sidebar_filters(): void
{
$author = User::factory()->create([
'username' => 'catlover',
'name' => 'Cat Lover',
]);
$controller = app(ExploreController::class);
$request = Request::create('/explore', 'GET', [
'sort' => 'latest',
'orientation' => 'portrait',
'resolution' => 'fhd',
'date_from' => '2026-04-01',
'date_to' => '2026-04-16',
'author' => 'catlover',
]);
$reflection = new \ReflectionClass($controller);
$method = $reflection->getMethod('buildExploreFilterExpression');
$method->setAccessible(true);
$filter = $method->invoke($controller, $request, null);
$this->assertIsString($filter);
$this->assertStringContainsString('is_public = true', $filter);
$this->assertStringContainsString('is_approved = true', $filter);
$this->assertStringContainsString('orientation = "portrait"', $filter);
$this->assertStringContainsString('resolution = "1920x1080"', $filter);
$this->assertStringContainsString('created_at >= "2026-04-01"', $filter);
$this->assertStringContainsString('created_at <= "2026-04-16"', $filter);
$this->assertStringContainsString('author_id = ' . $author->id, $filter);
$this->assertStringContainsString('published_as_type = "user"', $filter);
}
}

View File

@@ -11,12 +11,16 @@ it('renders the help center homepage with key platform help links', function ()
->where('seo.canonical', route('help'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.help_worlds', route('help.worlds'))
->where('links.groups_documentation', route('help.groups'))
->where('links.groups_quickstart', route('help.groups.quickstart'))
->where('links.groups_faq', route('help.groups.faq'))
->where('links.open_studio', route('studio.index'))
->where('links.studio_home', route('studio.index'))
->where('links.studio_worlds', route('studio.worlds.index'))
->where('links.create_world', route('worlds.create.redirect'))
->where('links.upload', route('upload'))
->where('links.worlds_index', route('worlds.index'))
->where('links.help_cards', route('help.cards'))
->where('links.help_profile', route('help.profile'))
->where('links.help_auth', route('help.auth'))

View File

@@ -0,0 +1,25 @@
<?php
use Inertia\Testing\AssertableInertia;
it('renders the worlds help page with real internal links', function () {
$this->get(route('help.worlds'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Help/WorldsHelpPage')
->where('title', 'Worlds Help')
->where('seo.canonical', route('help.worlds'))
->where('links.help_home', route('help'))
->where('links.studio_help', route('help.studio'))
->where('links.upload_help', route('help.upload'))
->where('links.help_cards', route('help.cards'))
->where('links.groups_help', route('help.groups'))
->where('links.worlds_index', route('worlds.index'))
->where('links.create_world', route('worlds.create.redirect'))
->where('links.studio_worlds', route('studio.worlds.index'))
->where('links.studio_worlds_create', route('studio.worlds.create'))
->where('links.open_studio', route('studio.index'))
->where('links.contact_support', route('contact.show'))
->where('links.report_issue', route('bug-report'))
);
});

View File

@@ -99,6 +99,37 @@ it('derives mature artwork presentation from viewer preferences', function () {
->and($shown['requires_interstitial'])->toBeTrue();
});
it('treats guests as hide-mode viewers when filtering catalog results', function () {
$safeArtwork = Artwork::factory()->create([
'title' => 'Guest Visible Safe Artwork',
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
]);
$matureArtwork = Artwork::factory()->create([
'title' => 'Guest Hidden Mature Artwork',
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
]);
$maturity = app(ArtworkMaturityService::class);
$preferences = $maturity->viewerPreferences(null);
$visibleIds = $maturity->applyViewerFilter(
Artwork::query()->whereKey([$safeArtwork->id, $matureArtwork->id]),
null,
)->pluck('id')->all();
$searchFilter = $maturity->appendSearchFilter('is_public = true', null);
expect($preferences['is_guest'])->toBeTrue()
->and($preferences['visibility'])->toBe(ArtworkMaturityService::VIEW_HIDE)
->and($visibleIds)->toContain($safeArtwork->id)
->and($visibleIds)->not->toContain($matureArtwork->id)
->and($searchFilter)->toContain('is_mature_effective = false');
});
it('applies uploader mature declarations when publishing an existing artwork', function () {
Queue::fake();
@@ -728,6 +759,79 @@ it('hides mature items from the daily uploads page for hide-mode viewers', funct
->and($matureArtwork->exists)->toBeTrue();
});
it('hides mature items from the daily uploads page for guests', function () {
$safeArtwork = Artwork::factory()->create([
'title' => 'Guest Daily Safe Artwork',
'is_public' => true,
'is_approved' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now(),
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
]);
$matureArtwork = Artwork::factory()->create([
'title' => 'Guest Daily Mature Artwork',
'is_public' => true,
'is_approved' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now(),
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
]);
$this->get(route('uploads.daily'))
->assertOk()
->assertSee('Guest Daily Safe Artwork')
->assertDontSee('Guest Daily Mature Artwork');
expect($safeArtwork->exists)->toBeTrue()
->and($matureArtwork->exists)->toBeTrue();
});
it('shows mature items on the daily uploads page for blur-mode viewers', function () {
$viewer = User::factory()->create();
DB::table('user_profiles')->insert([
'user_id' => $viewer->id,
'mature_content_visibility' => 'blur',
'mature_content_warning_enabled' => true,
]);
$safeArtwork = Artwork::factory()->create([
'title' => 'Blur Daily Safe Artwork',
'is_public' => true,
'is_approved' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now(),
'is_mature' => false,
'maturity_level' => ArtworkMaturityService::LEVEL_SAFE,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
]);
$matureArtwork = Artwork::factory()->create([
'title' => 'Blur Daily Mature Artwork',
'is_public' => true,
'is_approved' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now(),
'is_mature' => true,
'maturity_level' => ArtworkMaturityService::LEVEL_MATURE,
'maturity_status' => ArtworkMaturityService::STATUS_DECLARED,
]);
$this->actingAs($viewer)
->get(route('uploads.daily'))
->assertOk()
->assertSee('Blur Daily Safe Artwork')
->assertSee('Blur Daily Mature Artwork');
expect($safeArtwork->exists)->toBeTrue()
->and($matureArtwork->exists)->toBeTrue();
});
it('filters collection artworks and cover fallbacks for hide-mode viewers', function () {
$viewer = User::factory()->create();
$owner = User::factory()->create();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Ranking;
use App\Jobs\RankBuildListsJob;
use App\Jobs\RankBuildScopeListsJob;
use App\Models\ContentType;
use App\Models\RankList;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class RankBuildListsDispatchTest extends TestCase
{
use RefreshDatabase;
public function test_dispatcher_fans_out_only_active_category_scopes(): void
{
Queue::fake();
$contentType = ContentType::create([
'name' => 'Wallpapers',
'slug' => 'wallpapers',
]);
$activeCategoryIds = [
(int) $contentType->categories()->create([
'name' => 'Abstract',
'slug' => 'abstract',
'is_active' => true,
])->id,
(int) $contentType->categories()->create([
'name' => 'Nature',
'slug' => 'nature',
'is_active' => true,
])->id,
];
$contentType->categories()->create([
'name' => 'Hidden',
'slug' => 'hidden',
'is_active' => false,
]);
(new RankBuildListsJob)->handle();
Queue::assertPushed(RankBuildScopeListsJob::class, 4);
Queue::assertPushed(RankBuildScopeListsJob::class, fn (RankBuildScopeListsJob $job) => $job->scopeType === 'global' && $job->scopeId === 0);
Queue::assertPushed(RankBuildScopeListsJob::class, fn (RankBuildScopeListsJob $job) => $job->scopeType === 'content_type' && $job->scopeId === (int) $contentType->id);
foreach ($activeCategoryIds as $categoryId) {
Queue::assertPushed(RankBuildScopeListsJob::class, fn (RankBuildScopeListsJob $job) => $job->scopeType === 'category' && $job->scopeId === $categoryId);
}
Queue::assertNotPushed(RankBuildScopeListsJob::class, fn (RankBuildScopeListsJob $job) => $job->scopeType === 'category' && ! in_array($job->scopeId, $activeCategoryIds, true));
}
public function test_scope_job_upserts_rows_even_when_scope_has_no_candidates(): void
{
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
]);
$category = $contentType->categories()->create([
'name' => 'Macro',
'slug' => 'macro',
'is_active' => true,
]);
app()->call([new RankBuildScopeListsJob('category', (int) $category->id), 'handle']);
$rows = RankList::query()
->where('scope_type', 'category')
->where('scope_id', $category->id)
->where('model_version', config('ranking.model_version'))
->orderBy('list_type')
->get();
$this->assertCount(3, $rows);
$this->assertSame(['best', 'new_hot', 'trending'], $rows->pluck('list_type')->all());
$this->assertTrue($rows->every(fn (RankList $row) => $row->artwork_ids === []));
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\RankList;
use App\Models\User;
use App\Services\RankingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
@@ -23,6 +24,13 @@ class RankGlobalTrendingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
// ── Test 1: ranked order ───────────────────────────────────────────────
/**
@@ -52,7 +60,7 @@ class RankGlobalTrendingTest extends TestCase
'scope_type' => 'global',
'scope_id' => 0,
'list_type' => 'trending',
'model_version' => 'rank_v1',
'model_version' => (string) config('ranking.model_version', 'rank_v1'),
'artwork_ids' => $rankedOrder,
'computed_at' => now(),
]);
@@ -78,7 +86,7 @@ class RankGlobalTrendingTest extends TestCase
// Meta block is present
$response->assertJsonPath('meta.list_type', 'trending');
$response->assertJsonPath('meta.fallback', false);
$response->assertJsonPath('meta.model_version', 'rank_v1');
$response->assertJsonPath('meta.model_version', (string) config('ranking.model_version', 'rank_v1'));
}
// ── Test 2: diversity constraint ───────────────────────────────────────

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Carbon\Carbon;
use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
it('publishes overdue scheduled artwork when opening the studio edit page', function (): void {
Carbon::setTestNow('2026-04-16 08:00:00');
try {
$user = User::factory()->create();
$artwork = Artwork::factory()->private()->create([
'user_id' => $user->id,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_approved' => true,
'is_public' => false,
'artwork_status' => 'scheduled',
'publish_at' => now()->subHour(),
'published_at' => null,
'artwork_timezone' => 'Europe/Ljubljana',
]);
actingAs($user);
get('/studio/artworks/' . $artwork->id . '/edit')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioArtworkEdit')
->where('artwork.artwork_status', 'published')
->where('artwork.publish_mode', 'now')
->where('artwork.publish_at', null)
->where('artwork.is_public', true));
$artwork->refresh();
expect($artwork->artwork_status)->toBe('published')
->and($artwork->is_public)->toBeTrue()
->and($artwork->publish_at)->toBeNull()
->and($artwork->artwork_timezone)->toBeNull()
->and($artwork->published_at)->not->toBeNull();
} finally {
Carbon::setTestNow();
}
});

View File

@@ -107,8 +107,8 @@ it('can analyze artwork ai suggestions directly without queueing', function ():
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.debug.request.hash', 'syncaa112233')
->assertJsonPath('data.debug.request.intent', 'title')
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/artworks/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/artworks/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.service', 'gateway_all');
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
@@ -127,6 +127,124 @@ it('can analyze artwork ai suggestions directly without queueing', function ():
expect($completedEvent?->meta['intent'] ?? null)->toBe('title');
});
it('accepts a together provider override for direct studio ai analysis', function (): void {
Queue::fake();
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.together.base_url', 'https://api.together.xyz');
config()->set('vision.together.endpoint', '/v1/chat/completions');
config()->set('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo');
config()->set('vision.together.api_key', 'together-test-key');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'togaa112233',
'file_name' => 'rose-closeup.jpg',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://api.together.xyz/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'rose macro',
'flower close-up',
'soft petals',
'natural light',
'botanical photography',
'pink tones',
'shallow depth',
'floral detail',
'macro photography',
'garden bloom',
], JSON_THROW_ON_ERROR),
],
],
],
], 200),
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'direct' => true,
'provider' => 'together',
'intent' => 'tags',
])
->assertOk()
->assertJsonPath('direct', true)
->assertJsonPath('data.debug.request.provider', 'together')
->assertJsonPath('data.debug.tag_generation.provider', 'together')
->assertJsonPath('data.debug.tag_generation.endpoint', 'https://api.together.xyz/v1/chat/completions');
Http::assertSent(function (\Illuminate\Http\Client\Request $request): bool {
return $request->url() === 'https://api.together.xyz/v1/chat/completions'
&& $request->hasHeader('Authorization', 'Bearer together-test-key');
});
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
});
it('passes a provider override into queued studio ai analysis', function (): void {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'queueprov1122',
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'provider' => 'together',
'intent' => 'tags',
])
->assertStatus(202)
->assertJsonPath('status', ArtworkAiAssist::STATUS_QUEUED);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class, function (AnalyzeArtworkAiAssistJob $job): bool {
$property = new \ReflectionProperty($job, 'provider');
$property->setAccessible(true);
return $property->getValue($job) === 'together';
});
$requestedEvent = ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'analysis_requested')
->latest('id')
->first();
expect($requestedEvent)->not->toBeNull();
expect($requestedEvent?->meta['provider'] ?? null)->toBe('together');
});
it('persists upload-style visibility options from studio save', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
@@ -269,6 +387,8 @@ it('can analyze artwork directly when exact and vector similar matches are both
it('builds and exposes normalized studio ai suggestions', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.lm_studio.base_url', 'https://lmstudio.local');
config()->set('vision.lm_studio.model', 'google/gemma-3-4b');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
@@ -302,6 +422,26 @@ it('builds and exposes normalized studio ai suggestions', function (): void {
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://lmstudio.local/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'rose macro',
'flower close-up',
'soft petals',
'natural light',
'botanical photography',
'pink tones',
'shallow depth',
'floral detail',
'macro photography',
'garden bloom',
], JSON_THROW_ON_ERROR),
],
],
],
], 200),
]);
app(StudioAiAssistService::class)->analyze($artwork->fresh(), false);
@@ -314,9 +454,10 @@ it('builds and exposes normalized studio ai suggestions', function (): void {
->assertJsonPath('data.mode', 'artwork')
->assertJsonPath('data.content_type.value', 'photography')
->assertJsonPath('data.category.value', 'flowers')
->assertJsonPath('data.debug.tag_generation.raw_content', '["rose macro","flower close-up","soft petals","natural light","botanical photography","pink tones","shallow depth","floral detail","macro photography","garden bloom"]')
->assertJson(fn ($json) => $json
->has('data.title_suggestions', 5)
->where('data.tag_suggestions.0.tag', 'rose')
->where('data.tag_suggestions', fn ($tags): bool => collect($tags)->contains(fn (array $row): bool => ($row['tag'] ?? null) === 'rose-macro'))
->has('data.description_suggestions', 3));
});

View File

@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Group;
use App\Models\World;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
function studioWorld(array $attributes = []): World
{
$creator = $attributes['creator'] ?? User::factory()->create([
'username' => 'worldbuilder',
'name' => 'World Builder',
]);
unset($attributes['creator']);
return World::query()->create(array_merge([
'title' => 'Halloween World 2026',
'slug' => 'halloween-world-2026',
'tagline' => 'Night drives, haunted pixels, and autumn launches.',
'summary' => 'A curated seasonal destination for Halloween programming.',
'description' => 'World description',
'theme_key' => 'halloween',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_SEASONAL,
'is_featured' => true,
'created_by_user_id' => $creator->id,
], $attributes));
}
it('forbids world studio pages for non moderators', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('studio.worlds.index'))
->assertForbidden();
$this->actingAs($user)
->get(route('studio.worlds.create'))
->assertRedirect(route('worlds.index'));
$this->actingAs($user)
->get('/worlds/create')
->assertRedirect(route('worlds.index'));
});
it('sends guests from the public worlds create shortcut to login', function (): void {
$this->get('/worlds/create')
->assertRedirect(route('login'));
});
it('renders world studio pages for moderators', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'modworlds',
'name' => 'Moderator Worlds',
]);
$world = studioWorld([
'creator' => $moderator,
'status' => World::STATUS_PUBLISHED,
'published_at' => Carbon::parse('2026-10-01 10:00:00'),
'starts_at' => Carbon::parse('2026-10-15 00:00:00'),
]);
$this->actingAs($moderator)
->get(route('studio.worlds.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldsIndex')
->where('title', 'Worlds')
->where('listing.items.0.title', 'Halloween World 2026')
->where('createUrl', route('studio.worlds.create')));
$this->actingAs($moderator)
->get('/worlds/create')
->assertRedirect(route('studio.worlds.create'));
$this->actingAs($moderator)
->get(route('studio.worlds.create'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldEditor')
->where('title', 'Create world')
->has('themeOptions')
->has('sectionOptions')
->has('relationTypeOptions')
->where('mediaSupport.picker_available', false));
$this->actingAs($moderator)
->get(route('studio.worlds.edit', ['world' => $world->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldEditor')
->where('world.title', 'Halloween World 2026')
->where('world.slug', 'halloween-world-2026')
->where('world.section_visibility_json.featured_artworks', true)
->where('duplicateActions.canCreateEdition', false));
$this->actingAs($moderator)
->get(route('studio.worlds.preview', ['world' => $world->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('previewMode', true)
->where('world.title', 'Halloween World 2026'));
});
it('renders world studio pages for legacy admin accounts', function (): void {
$admin = User::factory()->create([
'role' => 'user',
'username' => 'legacyadminworlds',
'name' => 'Legacy Admin Worlds',
]);
DB::table('users')
->where('id', $admin->id)
->update(['isAdmin' => 1]);
$admin->refresh();
$this->actingAs($admin)
->get(route('studio.worlds.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldsIndex')
->where('title', 'Worlds')
->where('createUrl', route('studio.worlds.create')));
$this->actingAs($admin)
->get(route('studio.worlds.create'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldEditor')
->where('title', 'Create world'));
});
it('searches artwork relations by creator and project context in the worlds picker', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'searchworldsmod',
'name' => 'Search Worlds Moderator',
]);
$creator = User::factory()->create([
'username' => 'springartist',
'name' => 'Spring Artist',
]);
$group = Group::factory()->create([
'name' => 'Spring Project',
'slug' => 'spring-project',
]);
$contentType = ContentType::query()->create([
'name' => 'Pixel Art',
'slug' => 'pixel-art',
'description' => 'Pixel art content type',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'Seasonal Spring',
'slug' => 'seasonal-spring',
'description' => 'Spring showcase',
'is_active' => true,
'sort_order' => 1,
]);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Morning Dew',
'slug' => 'morning-dew',
'description' => 'A calm scene with no direct spring keyword in the artwork copy.',
'artwork_status' => 'published',
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_public' => true,
]);
$artwork->categories()->attach($category->id);
$this->actingAs($moderator)
->getJson(route('studio.worlds.entity-search', ['type' => 'artwork', 'q' => 'spring']))
->assertOk()
->assertJsonPath('items.0.id', $artwork->id)
->assertJsonPath('items.0.title', 'Morning Dew')
->assertJsonPath('items.0.subtitle', 'Spring Artist');
});
it('stores a world draft through the studio flow', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'editorworlds',
'name' => 'Editor Worlds',
]);
$response = $this->actingAs($moderator)->post(route('studio.worlds.store'), [
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026',
'tagline' => 'Scanlines, diskmag culture, and old-school launches.',
'summary' => 'A recurring world for retro platform activity.',
'description' => 'World body copy',
'theme_key' => 'retro-month',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_CAMPAIGN,
'is_featured' => true,
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'recurrence_rule' => 'annual:04',
'edition_year' => 2026,
'cta_label' => 'Explore Retro Month',
'cta_url' => 'https://skinbase.test/worlds/retro-month-2026',
'badge_label' => 'Editorial pick',
'badge_description' => 'Featured by the Nova editorial team.',
'badge_url' => 'https://skinbase.test/badges/retro',
'seo_title' => 'Retro Month 2026 - Skinbase Nova',
'seo_description' => 'Retro Month seasonal campaign',
'published_at' => '2026-03-20T10:00',
'related_tags_json' => ['retro', 'demoscene'],
'section_order_json' => ['featured_artworks', 'featured_collections', 'news'],
'section_visibility_json' => [
'featured_artworks' => true,
'featured_collections' => true,
'featured_creators' => false,
'featured_groups' => false,
'news' => true,
'challenge' => false,
'events' => false,
'releases' => false,
'cards' => false,
],
'relations' => [],
]);
$world = World::query()->where('slug', 'retro-month-2026')->firstOrFail();
$response->assertRedirect(route('studio.worlds.edit', ['world' => $world->id]));
$this->assertDatabaseHas('worlds', [
'id' => $world->id,
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_CAMPAIGN,
'is_featured' => true,
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'edition_year' => 2026,
'published_at' => '2026-03-20 10:00:00',
'created_by_user_id' => $moderator->id,
]);
expect($world->fresh()->section_visibility_json)->toMatchArray([
'featured_artworks' => true,
'featured_collections' => true,
'featured_creators' => false,
'news' => true,
]);
});
it('rejects reserved world slugs in the studio flow', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'reservedslugmod',
'name' => 'Reserved Slug Moderator',
]);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Create',
'slug' => 'create',
'summary' => 'Reserved slug attempt',
'description' => 'Should fail validation',
'theme_key' => 'retro-month',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_CAMPAIGN,
'relations' => [],
])
->assertRedirect(route('studio.worlds.create'))
->assertSessionHasErrors(['slug']);
});
it('requires recurrence metadata and blocks duplicate recurrence editions', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'recurrencemod',
'name' => 'Recurrence Moderator',
]);
studioWorld([
'creator' => $moderator,
'title' => 'Halloween World 2026',
'slug' => 'halloween-world-2026-existing',
'is_recurring' => true,
'recurrence_key' => 'halloween',
'edition_year' => 2026,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Recurring World Without Metadata',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_CAMPAIGN,
'is_recurring' => true,
'relations' => [],
])
->assertRedirect(route('studio.worlds.create'))
->assertSessionHasErrors(['recurrence_key', 'edition_year']);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Halloween World Clone',
'slug' => 'halloween-world-clone',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_SEASONAL,
'is_recurring' => true,
'recurrence_key' => 'halloween',
'edition_year' => 2026,
'relations' => [],
])
->assertRedirect(route('studio.worlds.create'))
->assertSessionHasErrors(['edition_year']);
});
it('duplicates worlds and preserves editorial structure in a new draft', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'duplicateworldmod',
'name' => 'Duplicate World Moderator',
]);
$world = studioWorld([
'creator' => $moderator,
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026',
'theme_key' => 'pixel-week',
'section_visibility_json' => [
'featured_artworks' => true,
'featured_collections' => false,
'events' => true,
],
'starts_at' => Carbon::parse('2026-07-01 09:00:00'),
'published_at' => Carbon::parse('2026-06-28 18:00:00'),
]);
$world->worldRelations()->create([
'section_key' => 'featured_creators',
'related_type' => 'user',
'related_id' => $moderator->id,
'context_label' => 'Lead pixel artist',
'sort_order' => 0,
'is_featured' => true,
]);
$response = $this->actingAs($moderator)->post(route('studio.worlds.duplicate', ['world' => $world->id]));
$duplicate = World::query()->where('slug', 'like', 'pixel-week-2026-copy%')->latest('id')->firstOrFail();
$response->assertRedirect(route('studio.worlds.edit', ['world' => $duplicate->id]));
expect($duplicate->status)->toBe(World::STATUS_DRAFT);
expect($duplicate->is_featured)->toBeFalse();
expect($duplicate->starts_at)->toBeNull();
expect($duplicate->published_at)->toBeNull();
expect($duplicate->section_visibility_json)->toMatchArray([
'featured_artworks' => true,
'featured_collections' => false,
'events' => true,
]);
expect($duplicate->worldRelations()->count())->toBe(1);
});
it('creates the next edition draft for recurring worlds', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'editionworldmod',
'name' => 'Edition World Moderator',
]);
$world = studioWorld([
'creator' => $moderator,
'title' => 'Halloween 2026',
'slug' => 'halloween-2026',
'is_recurring' => true,
'recurrence_key' => 'halloween',
'edition_year' => 2026,
]);
$response = $this->actingAs($moderator)->post(route('studio.worlds.new-edition', ['world' => $world->id]));
$edition = World::query()->where('recurrence_key', 'halloween')->where('edition_year', 2027)->latest('id')->firstOrFail();
$response->assertRedirect(route('studio.worlds.edit', ['world' => $edition->id]));
expect($edition->parent_world_id)->toBe($world->id);
expect($edition->status)->toBe(World::STATUS_DRAFT);
expect($edition->is_recurring)->toBeTrue();
expect($edition->slug)->toContain('2027');
});

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Models\World;
use Database\Seeders\WorldLaunchSeeder;
it('seeds launch worlds with a featured current world and archived recurrence', function (): void {
$this->seed(WorldLaunchSeeder::class);
$featuredCurrent = World::query()
->where('slug', 'like', 'retro-month-%')
->where('is_featured', true)
->current()
->first();
expect($featuredCurrent)->not->toBeNull();
expect($featuredCurrent?->worldRelations()->count())->toBeGreaterThan(0);
$archivedEdition = World::query()
->where('parent_world_id', $featuredCurrent?->id)
->where('status', World::STATUS_ARCHIVED)
->first();
expect($archivedEdition)->not->toBeNull();
expect(World::query()->count())->toBeGreaterThanOrEqual(6);
});

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\World;
use App\Services\HomepageService;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia;
function publicWorld(array $attributes = []): World
{
$creator = $attributes['creator'] ?? User::factory()->create([
'username' => 'publicworlds',
'name' => 'Public Worlds',
]);
unset($attributes['creator']);
return World::query()->create(array_merge([
'title' => 'Summer Slam 2026',
'slug' => 'summer-slam-2026',
'tagline' => 'Sunlit publishing and warm-color campaigns.',
'summary' => 'A bright world for summer culture across the platform.',
'description' => 'Public world description',
'theme_key' => 'summer',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_SEASONAL,
'is_featured' => true,
'starts_at' => Carbon::parse('2026-06-01 00:00:00'),
'ends_at' => Carbon::parse('2026-08-31 23:59:59'),
'published_at' => Carbon::parse('2026-04-01 10:00:00'),
'created_by_user_id' => $creator->id,
], $attributes));
}
it('renders public worlds index and detail pages', function (): void {
$world = publicWorld();
$this->get(route('worlds.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldIndex')
->where('featuredWorld.title', 'Summer Slam 2026')
->has('activeWorlds'));
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Summer Slam 2026')
->where('world.slug', 'summer-slam-2026'));
});
it('falls back to the theme icon when the stored world icon is blank whitespace', function (): void {
$world = publicWorld([
'title' => 'Spring Vibes',
'slug' => 'spring-vibes',
'theme_key' => 'summer',
'icon_name' => ' ',
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Spring Vibes')
->where('world.icon_name', 'fa-solid fa-sun')
->where('world.theme.icon_name', 'fa-solid fa-sun'));
});
it('omits disabled sections from the public world payload', function (): void {
$world = publicWorld([
'title' => 'Curated Autumn 2026',
'slug' => 'curated-autumn-2026',
'section_visibility_json' => [
'featured_creators' => false,
],
]);
$world->worldRelations()->create([
'section_key' => 'featured_creators',
'related_type' => 'user',
'related_id' => $world->created_by_user_id,
'context_label' => 'Editorial spotlight',
'sort_order' => 0,
'is_featured' => true,
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Curated Autumn 2026')
->where('sections', []));
});
it('keeps archived worlds publicly visible', function (): void {
$world = publicWorld([
'title' => 'Halloween World 2025',
'slug' => 'halloween-world-2025',
'theme_key' => 'halloween',
'status' => World::STATUS_ARCHIVED,
'starts_at' => Carbon::parse('2025-10-01 00:00:00'),
'ends_at' => Carbon::parse('2025-11-01 00:00:00'),
'published_at' => Carbon::parse('2025-09-20 10:00:00'),
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertSee('Halloween World 2025');
});
it('exposes a homepage world spotlight when a featured world exists', function (): void {
publicWorld([
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026',
'theme_key' => 'pixel-week',
]);
app(HomepageService::class)->clearGuestPayloadCache();
$this->get(route('index'))
->assertOk()
->assertSee(route('worlds.index'), false)
->assertSee('pixel-week-2026')
->assertSee('Pixel Week 2026');
});

View File

@@ -0,0 +1,481 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Models\WorldSubmission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function worldSubmissionCategoryId(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'World Submission Type',
'slug' => 'world-submission-type-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'World Submission Category',
'slug' => 'world-submission-category-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
function acceptingWorld(?User $creator = null, array $attributes = []): World
{
$creator ??= User::factory()->create([
'role' => 'moderator',
'username' => 'worldmoderator-' . Str::lower(Str::random(6)),
'name' => 'World Moderator',
]);
return World::factory()->create(array_merge([
'created_by_user_id' => $creator->id,
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
'accepts_submissions' => true,
'participation_mode' => World::PARTICIPATION_MODE_MANUAL_APPROVAL,
'submission_note_enabled' => true,
'community_section_enabled' => true,
'allow_readd_after_removal' => true,
'submission_starts_at' => now()->subDay(),
'submission_ends_at' => now()->addDays(7),
], $attributes));
}
it('creates pending world submissions when publishing an artwork draft', function (): void {
$creator = User::factory()->create();
$world = acceptingWorld();
$categoryId = worldSubmissionCategoryId();
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Draft Upload',
'slug' => 'draft-upload',
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => false,
'published_at' => null,
'artwork_status' => 'draft',
]);
$this->actingAs($creator)
->postJson("/api/uploads/{$artwork->id}/publish", [
'title' => 'World Upload',
'category' => $categoryId,
'tags' => ['world', 'submission'],
'world_submissions' => [
['world_id' => $world->id, 'note' => 'Fits the active theme.'],
],
])
->assertOk()
->assertJsonPath('status', 'published');
$this->assertDatabaseHas('world_submissions', [
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_PENDING,
'is_featured' => false,
'note' => 'Fits the active theme.',
]);
});
it('creates live world participation immediately for auto-add worlds', function (): void {
$creator = User::factory()->create();
$world = acceptingWorld(attributes: [
'participation_mode' => World::PARTICIPATION_MODE_AUTO_ADD,
]);
$categoryId = worldSubmissionCategoryId();
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Auto Add Upload',
'slug' => 'auto-add-upload',
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => false,
'published_at' => null,
'artwork_status' => 'draft',
]);
$this->actingAs($creator)
->postJson("/api/uploads/{$artwork->id}/publish", [
'title' => 'Auto Add Upload',
'category' => $categoryId,
'tags' => ['world', 'auto-add'],
'world_submissions' => [
['world_id' => $world->id, 'note' => 'Ship it.'],
],
])
->assertOk();
$this->assertDatabaseHas('world_submissions', [
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => false,
]);
});
it('syncs world submissions from the studio artwork editor update flow', function (): void {
$creator = User::factory()->create();
$world = acceptingWorld();
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Studio Draft',
'slug' => 'studio-draft',
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'published_at' => null,
'artwork_status' => 'draft',
]);
$this->actingAs($creator)
->putJson(route('api.studio.artworks.update', ['id' => $artwork->id]), [
'world_submissions' => [
['world_id' => $world->id, 'note' => 'Added after upload.'],
],
])
->assertOk()
->assertJsonPath('world_submission_options.0.id', $world->id)
->assertJsonPath('world_submission_options.0.selected', true)
->assertJsonPath('world_submission_options.0.status', WorldSubmission::STATUS_PENDING);
$this->assertDatabaseHas('world_submissions', [
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'status' => WorldSubmission::STATUS_PENDING,
'note' => 'Added after upload.',
]);
});
it('allows removed submissions to be re-added when the world permits it', function (): void {
$creator = User::factory()->create();
$world = acceptingWorld();
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Re Add Artwork',
'slug' => 're-add-artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now()->subDay(),
'artwork_status' => 'published',
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_REMOVED,
'moderation_reason' => 'Needs tighter fit.',
'removed_at' => now()->subHour(),
'reviewed_at' => now()->subHour(),
]);
$this->actingAs($creator)
->putJson(route('api.studio.artworks.update', ['id' => $artwork->id]), [
'world_submissions' => [
['world_id' => $world->id, 'note' => 'Updated to fit the brief.'],
],
])
->assertOk()
->assertJsonPath('world_submission_options.0.can_resubmit', false)
->assertJsonPath('world_submission_options.0.status', WorldSubmission::STATUS_PENDING);
$this->assertDatabaseHas('world_submissions', [
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'status' => WorldSubmission::STATUS_PENDING,
'note' => 'Updated to fit the brief.',
'moderation_reason' => null,
]);
});
it('keeps existing live submissions live when the artwork is updated in a manual approval world', function (): void {
$creator = User::factory()->create();
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'livereviewmod-' . Str::lower(Str::random(6)),
'name' => 'Live Review Moderator',
]);
$world = acceptingWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Live World Artwork',
'slug' => 'live-world-artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now()->subDay(),
'artwork_status' => 'published',
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'note' => 'Original approved note.',
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
'featured_at' => now()->subHour(),
]);
$this->actingAs($creator)
->putJson(route('api.studio.artworks.update', ['id' => $artwork->id]), [
'world_submissions' => [
['world_id' => $world->id, 'note' => 'Updated creator note after going live.'],
],
])
->assertOk()
->assertJsonPath('world_submission_options.0.status', WorldSubmission::STATUS_LIVE)
->assertJsonPath('world_submission_options.0.selected', true)
->assertJsonPath('world_submission_options.0.note', 'Updated creator note after going live.');
$submission->refresh();
expect($submission->status)->toBe(WorldSubmission::STATUS_LIVE)
->and($submission->note)->toBe('Updated creator note after going live.')
->and($submission->is_featured)->toBeTrue()
->and((int) $submission->reviewed_by_user_id)->toBe($moderator->id)
->and($submission->reviewed_at)->not->toBeNull()
->and($submission->removed_at)->toBeNull()
->and($submission->blocked_at)->toBeNull();
});
it('does not expose closed worlds in creator submission options', function (): void {
$creator = User::factory()->create();
$openWorld = acceptingWorld(attributes: ['title' => 'Open World']);
$closedWorld = acceptingWorld(attributes: [
'title' => 'Closed World',
'accepts_submissions' => false,
'participation_mode' => World::PARTICIPATION_MODE_CLOSED,
]);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Selector Artwork',
'slug' => 'selector-artwork',
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'published_at' => now()->subDay(),
'artwork_status' => 'published',
]);
$this->actingAs($creator)
->putJson(route('api.studio.artworks.update', ['id' => $artwork->id]), [])
->assertOk()
->assertJsonCount(1, 'world_submission_options')
->assertJsonPath('world_submission_options.0.id', $openWorld->id);
expect($closedWorld->id)->not->toBe($openWorld->id);
});
it('shows and reviews world participation in the studio world editor', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'reviewmod',
'name' => 'Review Moderator',
]);
$creator = User::factory()->create([
'username' => 'queueartist',
'name' => 'Queue Artist',
]);
$world = acceptingWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Queue Artwork',
'slug' => 'queue-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_PENDING,
'note' => 'Please review this for the world.',
]);
$this->actingAs($moderator)
->get(route('studio.worlds.edit', ['world' => $world->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldEditor')
->where('world.participation_mode', World::PARTICIPATION_MODE_MANUAL_APPROVAL)
->where('world.submission_review_queue.counts.pending', 1)
->where('world.submission_review_queue.items.0.artwork.title', 'Queue Artwork'));
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.approve', ['world' => $world->id, 'submission' => $submission->id]))
->assertRedirect();
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.feature', ['world' => $world->id, 'submission' => $submission->id]))
->assertRedirect();
$this->assertDatabaseHas('world_submissions', [
'id' => $submission->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'reviewed_by_user_id' => $moderator->id,
]);
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.block', ['world' => $world->id, 'submission' => $submission->id]), [
'review_note' => 'Off brief for this world.',
])
->assertRedirect();
$this->assertDatabaseHas('world_submissions', [
'id' => $submission->id,
'status' => WorldSubmission::STATUS_BLOCKED,
'moderation_reason' => 'Off brief for this world.',
'is_featured' => false,
]);
});
it('renders only live community submissions on public world pages and hides pending or blocked ones', function (): void {
$world = acceptingWorld(attributes: [
'title' => 'Public World',
'slug' => 'public-world',
]);
$creator = User::factory()->create();
$featuredArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Featured Community Artwork',
'slug' => 'featured-community-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$approvedArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Approved Community Artwork',
'slug' => 'approved-community-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$pendingArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Pending Community Artwork',
'slug' => 'pending-community-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$matureArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Mature Community Artwork',
'slug' => 'mature-community-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_mature' => true,
]);
foreach ([
[$featuredArtwork, WorldSubmission::STATUS_LIVE, true],
[$approvedArtwork, WorldSubmission::STATUS_LIVE, false],
[$pendingArtwork, WorldSubmission::STATUS_PENDING, false],
[$matureArtwork, WorldSubmission::STATUS_LIVE, false],
] as [$artwork, $status, $isFeatured]) {
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => $status,
'is_featured' => $isFeatured,
'reviewed_at' => $status === WorldSubmission::STATUS_PENDING ? null : Carbon::now(),
'featured_at' => $isFeatured ? Carbon::now() : null,
]);
}
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => Artwork::factory()->for($creator)->create([
'title' => 'Blocked Community Artwork',
'slug' => 'blocked-community-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
])->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_BLOCKED,
'reviewed_at' => Carbon::now(),
'blocked_at' => Carbon::now(),
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('communitySubmissions.items.0.title', 'Featured Community Artwork')
->where('communitySubmissions.items.0.status', WorldSubmission::STATUS_LIVE)
->where('communitySubmissions.items.0.status_label', 'Featured')
->has('communitySubmissions.items', 2)
->where('communitySubmissions.items.1.title', 'Approved Community Artwork'));
});
it('exposes world participation badges on the artwork page for curated and live world placements', function (): void {
$world = acceptingWorld(attributes: [
'title' => 'Retro Month',
'slug' => 'retro-month',
]);
$creator = User::factory()->create();
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Badge Artwork',
'slug' => 'badge-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldRelation::query()->create([
'world_id' => $world->id,
'related_type' => WorldRelation::TYPE_ARTWORK,
'related_id' => $artwork->id,
'section_key' => 'featured_artworks',
'context_label' => 'Curated spotlight',
'sort_order' => 1,
'is_featured' => true,
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'reviewed_at' => Carbon::now(),
'featured_at' => Carbon::now(),
]);
$this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
->assertOk()
->assertViewHas('artworkData', function (array $artworkData): bool {
$items = collect($artworkData['world_participation'] ?? []);
return $items->count() === 1
&& $items->contains(fn (array $item): bool => ($item['badge_label'] ?? null) === 'Featured in Retro Month');
});
});

View File

@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Facades\File;
/*
|--------------------------------------------------------------------------
| Test Case
@@ -15,6 +17,18 @@ pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
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'],
'resources/css/nova-grid.css' => ['file' => 'assets/nova-grid.css'],
'resources/scss/nova.scss' => ['file' => 'assets/nova.css'],
'resources/js/nova.js' => ['file' => 'assets/nova.js'],
'resources/js/entry-search.jsx' => ['file' => 'assets/entry-search.js'],
'resources/js/app.js' => ['file' => 'assets/app.js'],
], JSON_THROW_ON_ERROR));
});
/*
|--------------------------------------------------------------------------
| Expectations

View File

@@ -1,4 +1,79 @@
{
"status": "passed",
"failedTests": []
"status": "failed",
"failedTests": [
"598fdabf36083b33787e-92cd3c249bf00a417f60",
"598fdabf36083b33787e-fbf71fa35525734fb3d7",
"598fdabf36083b33787e-d634daa6c458ab17786e",
"598fdabf36083b33787e-d0e56fbd27a2103ba5b0",
"598fdabf36083b33787e-af8f84ef5a95eb859652",
"598fdabf36083b33787e-6316e52196ba999e723f",
"598fdabf36083b33787e-7c04042e2276b7aef531",
"598fdabf36083b33787e-e1906007e82e22a2126b",
"598fdabf36083b33787e-452fe6bffeedf064b83a",
"598fdabf36083b33787e-6f7164f445fd6d044d0c",
"598fdabf36083b33787e-e48f30260d29173aeef8",
"598fdabf36083b33787e-7dbe4073f164989eb04c",
"598fdabf36083b33787e-a06e84f6d442f43d8289",
"598fdabf36083b33787e-18f588f0741656e5621b",
"598fdabf36083b33787e-189f7f13116c83880586",
"598fdabf36083b33787e-3a059b78ab41527011d3",
"598fdabf36083b33787e-de16d5878113874ab14c",
"598fdabf36083b33787e-ce643a889dea62abf158",
"598fdabf36083b33787e-961e73f45527b420dca5",
"598fdabf36083b33787e-916d738e4c1512363c29",
"598fdabf36083b33787e-579601249b35d657e6e6",
"598fdabf36083b33787e-e52b10fdeb7d176fda43",
"598fdabf36083b33787e-781644ef93a35502a23c",
"598fdabf36083b33787e-76f4eb2972043c4eb6ee",
"598fdabf36083b33787e-bfd0d56269285c95f765",
"598fdabf36083b33787e-bb3db01b1a626fea9af0",
"598fdabf36083b33787e-ae54eae9582b5ceecec9",
"598fdabf36083b33787e-4184e57a621844b8319a",
"598fdabf36083b33787e-30e441788aeb91f3f8c3",
"598fdabf36083b33787e-c3a69fbeecd45ea3c878",
"598fdabf36083b33787e-c8b1b4974293d5afc35d",
"598fdabf36083b33787e-6303c31040ac4f50b3d0",
"598fdabf36083b33787e-7004400a06b5eea7474d",
"598fdabf36083b33787e-eb0d021ba37eefc367c5",
"598fdabf36083b33787e-c9e38ab6ef995edfff84",
"598fdabf36083b33787e-19f133edf6bd5312ab18",
"598fdabf36083b33787e-da723b5469f54bdc6cbf",
"598fdabf36083b33787e-412e464357b5e4bb6f4b",
"598fdabf36083b33787e-6de11a8cf105d76d5c69",
"598fdabf36083b33787e-0001cc73a8d4e4d1f337",
"598fdabf36083b33787e-0bc37401ff546c3bf695",
"598fdabf36083b33787e-e515c4da01ae4b448318",
"598fdabf36083b33787e-1c76e6ead984b382d84b",
"598fdabf36083b33787e-bdea5bc984eb754a6ffc",
"598fdabf36083b33787e-cf719809999f55ff24c1",
"598fdabf36083b33787e-b49981609fab820bc7be",
"598fdabf36083b33787e-b90e390ddd8657993b66",
"598fdabf36083b33787e-03f91e65d9f017b8b41b",
"598fdabf36083b33787e-2ca4fb3010310bc74a0e",
"598fdabf36083b33787e-c30040da72a9cfa0cdf0",
"598fdabf36083b33787e-0577d3a795d7388fde51",
"598fdabf36083b33787e-d1dc986cfa8147b18197",
"598fdabf36083b33787e-1744ae79204906163c32",
"598fdabf36083b33787e-f65f8035d33d11dbee55",
"598fdabf36083b33787e-fbbad565f39160b1b1bd",
"598fdabf36083b33787e-da45cce3967ab12421b1",
"598fdabf36083b33787e-ef001bf0e62042021821",
"598fdabf36083b33787e-667c3061f576d160dec6",
"598fdabf36083b33787e-a34594f5f04f517aac80",
"598fdabf36083b33787e-ac1d2495a4dab332de8b",
"598fdabf36083b33787e-4c9a60bfef8d8e8d0174",
"598fdabf36083b33787e-d3ca60fdd71a660d8dd5",
"598fdabf36083b33787e-69bed33e420a291707d6",
"598fdabf36083b33787e-7e9633180c9c9a7013b8",
"598fdabf36083b33787e-a0133cb657c240ca9202",
"598fdabf36083b33787e-48dcd19baa1e47909877",
"598fdabf36083b33787e-84f9d512b76f68c787b2",
"598fdabf36083b33787e-5ab85adb13e0a0421949",
"598fdabf36083b33787e-44c27d26674c948a39f3",
"598fdabf36083b33787e-f3a133ed48111ed183ff",
"598fdabf36083b33787e-101f3094ad49d3fe8ee3",
"598fdabf36083b33787e-7f9de0cce07e95252530",
"598fdabf36083b33787e-b21a119791ac22a77bad",
"598fdabf36083b33787e-3d6d736c4e1fa733bd39"
]
}