Save workspace changes
This commit is contained in:
207
tests/Feature/AiBiographyAdminTest.php
Normal file
207
tests/Feature/AiBiographyAdminTest.php
Normal 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);
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
98
tests/Feature/Auth/SetupEmailTest.php
Normal file
98
tests/Feature/Auth/SetupEmailTest.php
Normal 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');
|
||||
});
|
||||
167
tests/Feature/Console/AuditMissingMigratedUsersCommandTest.php
Normal file
167
tests/Feature/Console/AuditMissingMigratedUsersCommandTest.php
Normal 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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
17
tests/Feature/Console/MeilisearchConfigurationTest.php
Normal file
17
tests/Feature/Console/MeilisearchConfigurationTest.php
Normal 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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
tests/Feature/ExploreFiltersTest.php
Normal file
48
tests/Feature/ExploreFiltersTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
25
tests/Feature/Help/WorldsHelpPageTest.php
Normal file
25
tests/Feature/Help/WorldsHelpPageTest.php
Normal 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'))
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
1789
tests/Feature/Profile/AiBiographyTest.php
Normal file
1789
tests/Feature/Profile/AiBiographyTest.php
Normal file
File diff suppressed because it is too large
Load Diff
86
tests/Feature/Ranking/RankBuildListsDispatchTest.php
Normal file
86
tests/Feature/Ranking/RankBuildListsDispatchTest.php
Normal 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 === []));
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
49
tests/Feature/Studio/ScheduledArtworkPublicationTest.php
Normal file
49
tests/Feature/Studio/ScheduledArtworkPublicationTest.php
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
407
tests/Feature/Studio/StudioWorldPagesTest.php
Normal file
407
tests/Feature/Studio/StudioWorldPagesTest.php
Normal 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');
|
||||
});
|
||||
27
tests/Feature/Worlds/WorldLaunchSeederTest.php
Normal file
27
tests/Feature/Worlds/WorldLaunchSeederTest.php
Normal 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);
|
||||
});
|
||||
128
tests/Feature/Worlds/WorldPagesTest.php
Normal file
128
tests/Feature/Worlds/WorldPagesTest.php
Normal 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');
|
||||
});
|
||||
481
tests/Feature/Worlds/WorldSubmissionsWorkflowTest.php
Normal file
481
tests/Feature/Worlds/WorldSubmissionsWorkflowTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user