Implement academy analytics, billing, and web stories updates
This commit is contained in:
1064
tests/Feature/Academy/AcademyAnalyticsTest.php
Normal file
1064
tests/Feature/Academy/AcademyAnalyticsTest.php
Normal file
File diff suppressed because it is too large
Load Diff
161
tests/Feature/Academy/AcademyBillingAccessTest.php
Normal file
161
tests/Feature/Academy/AcademyBillingAccessTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Cashier\Subscription;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AcademyBillingAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware(ConditionalValidateCsrfToken::class);
|
||||
$this->configureBilling();
|
||||
}
|
||||
|
||||
public function test_success_page_does_not_grant_access_by_itself(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Creator Prompt',
|
||||
'slug' => 'billing-success-does-not-unlock',
|
||||
'excerpt' => 'Locked creator prompt.',
|
||||
'prompt' => 'SECRET CREATOR PROMPT',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.billing.success', ['session_id' => 'cs_test_only']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('currentTier', 'free')
|
||||
->where('isSubscribed', false));
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertDontSee('SECRET CREATOR PROMPT')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.prompt', null));
|
||||
}
|
||||
|
||||
public function test_canceled_subscription_on_grace_period_still_has_access(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Creator Grace Lesson',
|
||||
'slug' => 'creator-grace-lesson',
|
||||
'excerpt' => 'Should remain available in grace period.',
|
||||
'content' => 'VISIBLE DURING GRACE PERIOD',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->attachSubscription($user, 'sub_grace', 'price_creator_month', now()->addDay());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertSee('VISIBLE DURING GRACE PERIOD')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', false)
|
||||
->where('item.content', 'VISIBLE DURING GRACE PERIOD'));
|
||||
}
|
||||
|
||||
public function test_ended_subscription_loses_paid_access(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Creator Ended Lesson',
|
||||
'slug' => 'creator-ended-lesson',
|
||||
'excerpt' => 'Should lock after grace period.',
|
||||
'content' => 'NO LONGER VISIBLE',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->attachSubscription($user, 'sub_ended', 'price_creator_month', now()->subMinute());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertDontSee('NO LONGER VISIBLE')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.content', null));
|
||||
}
|
||||
|
||||
public function test_billing_portal_route_requires_authentication(): void
|
||||
{
|
||||
$this->get(route('academy.billing.portal'))
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
private function attachSubscription(User $user, string $subscriptionId, string $priceId, ?\Illuminate\Support\Carbon $endsAt = null): Subscription
|
||||
{
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => $subscriptionId,
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => $priceId,
|
||||
'quantity' => 1,
|
||||
'ends_at' => $endsAt,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_'.$subscriptionId,
|
||||
'stripe_product' => 'prod_'.($priceId === 'price_pro_month' ? 'pro' : 'creator'),
|
||||
'stripe_price' => $priceId,
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
private function configureBilling(): void
|
||||
{
|
||||
config()->set('academy.enabled', true);
|
||||
config()->set('academy.payments_enabled', true);
|
||||
config()->set('academy_billing.enabled', true);
|
||||
config()->set('academy_billing.subscription_name', 'academy');
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_creator_month',
|
||||
'featured' => false,
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_pro_month',
|
||||
'featured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
212
tests/Feature/Academy/AcademyBillingCheckoutTest.php
Normal file
212
tests/Feature/Academy/AcademyBillingCheckoutTest.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Cashier\SubscriptionBuilder;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AcademyBillingCheckoutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware(ConditionalValidateCsrfToken::class);
|
||||
$this->configureBilling();
|
||||
}
|
||||
|
||||
public function test_guest_cannot_start_checkout_and_is_redirected_to_login(): void
|
||||
{
|
||||
$this->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_guest_can_view_pricing(): void
|
||||
{
|
||||
$this->get(route('academy.pricing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Billing/Pricing')
|
||||
->where('currentTier', 'free')
|
||||
->where('isSubscribed', false)
|
||||
->where('activePlanKey', null)
|
||||
->where('catalog.0.plans.0.price_display', '4.99 EUR')
|
||||
->where('catalog.1.plans.0.price_display', '9.99 EUR'));
|
||||
}
|
||||
|
||||
public function test_pricing_shows_active_plan_for_subscriber(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'stripe_id' => 'cus_pricing_active_plan',
|
||||
]);
|
||||
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_pricing_active_plan',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_pricing_active_plan',
|
||||
'stripe_product' => 'prod_creator',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.pricing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Billing/Pricing')
|
||||
->where('currentTier', 'creator')
|
||||
->where('isSubscribed', true)
|
||||
->where('activePlanKey', 'creator_monthly')
|
||||
->where('activePlanLabel', 'Creator Monthly'));
|
||||
}
|
||||
|
||||
public function test_invalid_plan_returns_validation_error(): void
|
||||
{
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('academy.pricing'))
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'not_real'])
|
||||
->assertRedirect(route('academy.pricing'))
|
||||
->assertSessionHasErrors('plan');
|
||||
}
|
||||
|
||||
public function test_missing_price_id_fails_safely(): void
|
||||
{
|
||||
config()->set('academy_billing.plans.creator_monthly.stripe_price_id', '');
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 'academy_billing_price_missing');
|
||||
}
|
||||
|
||||
public function test_numeric_price_id_fails_safely_before_stripe(): void
|
||||
{
|
||||
config()->set('academy_billing.plans.creator_monthly.stripe_price_id', '9.99');
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 'academy_billing_price_invalid');
|
||||
}
|
||||
|
||||
public function test_invalid_price_id_redirects_back_with_visible_flash_error(): void
|
||||
{
|
||||
config()->set('academy_billing.plans.creator_monthly.stripe_price_id', 'prod_not_a_price');
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('academy.pricing'))
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect(route('academy.pricing'))
|
||||
->assertSessionHas('error', 'The selected Academy plan is misconfigured. Please contact support before continuing.');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
'error' => 'The selected Academy plan is misconfigured. Please contact support before continuing.',
|
||||
])
|
||||
->get(route('academy.pricing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Billing/Pricing')
|
||||
->where('flash.error', 'The selected Academy plan is misconfigured. Please contact support before continuing.'));
|
||||
}
|
||||
|
||||
public function test_verified_user_can_start_checkout_for_configured_plan(): void
|
||||
{
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$user = Mockery::mock($user)->makePartial();
|
||||
|
||||
$builder = Mockery::mock(new SubscriptionBuilder($user, 'academy', 'price_1creatormonth'))->makePartial();
|
||||
$builder->shouldReceive('withMetadata')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
$builder->shouldReceive('checkout')
|
||||
->once()
|
||||
->andReturn(redirect('https://checkout.stripe.test/session'));
|
||||
|
||||
$user->shouldReceive('subscription')->once()->with('academy')->andReturn(null);
|
||||
$user->shouldReceive('newSubscription')->once()->with('academy', 'price_1creatormonth')->andReturn($builder);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect('https://checkout.stripe.test/session');
|
||||
}
|
||||
|
||||
public function test_existing_subscriber_is_redirected_to_billing_portal(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'stripe_id' => 'cus_checkout_redirect',
|
||||
]);
|
||||
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_checkout_redirect',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_checkout_redirect',
|
||||
'stripe_product' => 'prod_creator',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect(route('academy.billing.portal'));
|
||||
}
|
||||
|
||||
private function configureBilling(): void
|
||||
{
|
||||
config()->set('academy.enabled', true);
|
||||
config()->set('academy.payments_enabled', true);
|
||||
config()->set('academy_billing.enabled', true);
|
||||
config()->set('academy_billing.subscription_name', 'academy');
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'amount' => '4.99',
|
||||
'currency' => 'EUR',
|
||||
'stripe_price_id' => 'price_1creatormonth',
|
||||
'featured' => false,
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'amount' => '9.99',
|
||||
'currency' => 'EUR',
|
||||
'stripe_price_id' => 'price_1promonth',
|
||||
'featured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -500,8 +501,18 @@ final class AcademyFeatureTest extends TestCase
|
||||
'excerpt' => 'Locked preview.',
|
||||
'prompt' => 'SECRET PREMIUM PROMPT STRING',
|
||||
'negative_prompt' => 'SECRET NEGATIVE STRING',
|
||||
'usage_notes' => 'SECRET WORKFLOW NOTE',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'tool_notes' => [[
|
||||
'display_type' => 'soft studio version',
|
||||
'provider' => 'ChatGPT',
|
||||
'model_name' => '4o Image',
|
||||
'image_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison.webp',
|
||||
'settings' => 'SECRET SETTINGS STRING',
|
||||
'best_for' => 'SECRET BEST FOR STRING',
|
||||
'active' => true,
|
||||
]],
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
@@ -510,10 +521,21 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertOk()
|
||||
->assertDontSee('SECRET PREMIUM PROMPT STRING')
|
||||
->assertDontSee('SECRET NEGATIVE STRING')
|
||||
->assertDontSee('SECRET WORKFLOW NOTE')
|
||||
->assertDontSee('SECRET SETTINGS STRING')
|
||||
->assertDontSee('SECRET BEST FOR STRING')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.prompt', null)
|
||||
->where('item.negative_prompt', null));
|
||||
->where('item.negative_prompt', null)
|
||||
->where('item.access_requirement', 'Requires Creator or Pro access.')
|
||||
->where('item.unlock_heading', 'Unlock the full Creator prompt.')
|
||||
->where('item.tool_notes', [])
|
||||
->where('item.public_examples.0.provider', 'ChatGPT')
|
||||
->where('item.public_examples.0.model_name', '4o Image')
|
||||
->where('item.public_examples.0.image_path', 'academy/lessons/body/cc/dd/chatgpt-comparison.webp')
|
||||
->where('seo.json_ld.0.isAccessibleForFree', false)
|
||||
->where('seo.json_ld.0.hasPart.cssSelector', '.academy-paywalled-content'));
|
||||
|
||||
$version = app(HandleInertiaRequests::class)
|
||||
->version(Request::create(route('academy.prompts.show', ['slug' => $prompt->slug]), 'GET'));
|
||||
@@ -527,8 +549,11 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertJsonPath('props.item.locked', true)
|
||||
->assertJsonPath('props.item.prompt', null)
|
||||
->assertJsonPath('props.item.negative_prompt', null)
|
||||
->assertJsonPath('props.item.tool_notes', [])
|
||||
->assertJsonPath('props.item.public_examples.0.provider', 'ChatGPT')
|
||||
->assertDontSee('SECRET PREMIUM PROMPT STRING')
|
||||
->assertDontSee('SECRET NEGATIVE STRING');
|
||||
->assertDontSee('SECRET NEGATIVE STRING')
|
||||
->assertDontSee('SECRET SETTINGS STRING');
|
||||
}
|
||||
|
||||
public function test_authorized_user_can_view_premium_prompt(): void
|
||||
@@ -563,10 +588,236 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', false)
|
||||
->where('item.prompt', 'VISIBLE PREMIUM PROMPT')
|
||||
->where('item.public_examples.0.provider', 'ChatGPT')
|
||||
->where('item.tool_notes.0.provider', 'ChatGPT')
|
||||
->where('item.tool_notes.0.model_name', '4o Image')
|
||||
->where('item.tool_notes.0.image_path', 'academy/lessons/body/cc/dd/chatgpt-comparison.webp')
|
||||
->where('item.tool_notes.0.score', 8));
|
||||
->where('item.tool_notes.0.score', 8)
|
||||
->where('seo.json_ld.0.isAccessibleForFree', false)
|
||||
->where('seo.json_ld.0.hasPart.cssSelector', '.academy-paywalled-content'));
|
||||
}
|
||||
|
||||
public function test_prompt_payload_exposes_responsive_preview_and_comparison_images(): void
|
||||
{
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
Storage::fake('s3');
|
||||
|
||||
Storage::disk('s3')->put('academy-prompts/previews/sticker-pack.webp', 'preview');
|
||||
Storage::disk('s3')->put('academy-prompts/previews/sticker-pack-thumb.webp', 'preview-thumb');
|
||||
Storage::disk('s3')->put('academy-prompts/previews/sticker-pack-md.webp', 'preview-medium');
|
||||
Storage::disk('s3')->put('academy/lessons/body/cc/dd/chatgpt-comparison.webp', 'comparison');
|
||||
Storage::disk('s3')->put('academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp', 'comparison-thumb');
|
||||
Storage::disk('s3')->put('academy/lessons/body/cc/dd/chatgpt-comparison-md.webp', 'comparison-medium');
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Responsive Prompt Media',
|
||||
'slug' => 'responsive-prompt-media',
|
||||
'excerpt' => 'Prompt with responsive preview assets.',
|
||||
'prompt' => 'Create a chibi emoji sticker collection with bright outlines.',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'preview_image' => 'academy-prompts/previews/sticker-pack.webp',
|
||||
'tool_notes' => [[
|
||||
'display_type' => 'sticker pack',
|
||||
'provider' => 'ChatGPT',
|
||||
'model_name' => '4o Image',
|
||||
'image_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison.webp',
|
||||
'thumb_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp',
|
||||
'settings' => 'Square canvas, bold outline, soft pastel background.',
|
||||
'best_for' => 'Sticker-ready mascot packs.',
|
||||
'active' => true,
|
||||
]],
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.preview_image_thumb', fn ($value) => is_string($value) && str_contains($value, 'academy-prompts/previews/sticker-pack-thumb.webp'))
|
||||
->where('item.preview_image_srcset', fn ($value) => is_string($value) && str_contains($value, 'academy-prompts/previews/sticker-pack-thumb.webp 480w') && str_contains($value, 'academy-prompts/previews/sticker-pack-md.webp 960w'))
|
||||
->where('item.public_examples.0.thumb_path', 'academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp')
|
||||
->where('item.public_examples.0.image_srcset', fn ($value) => is_string($value) && str_contains($value, 'chatgpt-comparison-thumb.webp 480w') && str_contains($value, 'chatgpt-comparison-md.webp 960w'))
|
||||
->where('item.tool_notes.0.thumb_path', 'academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp')
|
||||
->where('item.tool_notes.0.image_srcset', fn ($value) => is_string($value) && str_contains($value, 'chatgpt-comparison-thumb.webp 480w') && str_contains($value, 'chatgpt-comparison-md.webp 960w')));
|
||||
}
|
||||
|
||||
public function test_authorized_user_receives_active_advanced_prompt_metadata(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Advanced Creator Prompt',
|
||||
'slug' => 'advanced-creator-prompt',
|
||||
'excerpt' => 'Full prompt visible.',
|
||||
'prompt' => 'VISIBLE PREMIUM PROMPT FOR [CITY_NAME]',
|
||||
'negative_prompt' => 'VISIBLE NEGATIVE PROMPT',
|
||||
'documentation' => [
|
||||
'summary' => 'Advanced summary visible to everyone.',
|
||||
'how_to_use' => ['Collect data', 'Prepare prompt'],
|
||||
'best_for' => ['city wallpapers'],
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'type' => 'text',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [
|
||||
[
|
||||
'title' => 'Collect city data',
|
||||
'description' => 'Gather landmark and climate data.',
|
||||
'prompt' => 'Collect city data for [CITY_NAME].',
|
||||
'expected_output' => 'json',
|
||||
'active' => true,
|
||||
],
|
||||
[
|
||||
'title' => 'Inactive helper',
|
||||
'description' => 'Should stay hidden publicly.',
|
||||
'prompt' => 'Hidden helper prompt.',
|
||||
'expected_output' => 'text',
|
||||
'active' => false,
|
||||
],
|
||||
],
|
||||
'prompt_variants' => [
|
||||
[
|
||||
'title' => 'Image-safe version',
|
||||
'slug' => 'image-safe-version',
|
||||
'description' => 'Safer for image models.',
|
||||
'prompt' => 'VISIBLE IMAGE SAFE PROMPT',
|
||||
'negative_prompt' => 'VISIBLE VARIANT NEGATIVE',
|
||||
'recommended' => true,
|
||||
'recommended_for' => ['general image generation'],
|
||||
'risk_notes' => ['Icons may still be abstract'],
|
||||
'active' => true,
|
||||
],
|
||||
[
|
||||
'title' => 'Inactive variant',
|
||||
'slug' => 'inactive-variant',
|
||||
'description' => 'Should stay hidden publicly.',
|
||||
'prompt' => 'HIDDEN VARIANT PROMPT',
|
||||
'active' => false,
|
||||
],
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$creator = User::factory()->create(['role' => 'academy_creator']);
|
||||
|
||||
$this->actingAs($creator)
|
||||
->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', false)
|
||||
->where('item.documentation.summary', 'Advanced summary visible to everyone.')
|
||||
->where('item.placeholders.0.key', 'CITY_NAME')
|
||||
->where('item.has_placeholder_inputs', true)
|
||||
->where('item.has_helper_prompts', true)
|
||||
->where('item.has_prompt_variants', true)
|
||||
->has('item.helper_prompts', 1)
|
||||
->where('item.helper_prompts.0.title', 'Collect city data')
|
||||
->has('item.prompt_variants', 1)
|
||||
->where('item.prompt_variants.0.title', 'Image-safe version')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_locked_prompt_still_exposes_documentation_and_placeholders_but_hides_helper_prompts_and_variants(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Locked Advanced Prompt',
|
||||
'slug' => 'locked-advanced-prompt',
|
||||
'excerpt' => 'Locked prompt with public guidance.',
|
||||
'prompt' => 'SECRET ADVANCED PROMPT FOR [CITY_NAME]',
|
||||
'negative_prompt' => 'SECRET ADVANCED NEGATIVE',
|
||||
'documentation' => [
|
||||
'summary' => 'Public-facing overview.',
|
||||
'how_to_use' => ['Choose a city', 'Collect climate data'],
|
||||
'tips' => ['Use real data'],
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'type' => 'text',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [
|
||||
[
|
||||
'title' => 'Collect city data',
|
||||
'description' => 'Hidden behind access.',
|
||||
'prompt' => 'SECRET HELPER PROMPT',
|
||||
'expected_output' => 'json',
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
'prompt_variants' => [
|
||||
[
|
||||
'title' => 'Image-safe version',
|
||||
'description' => 'Hidden behind access.',
|
||||
'prompt' => 'SECRET VARIANT PROMPT',
|
||||
'negative_prompt' => 'SECRET VARIANT NEGATIVE',
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertDontSee('SECRET ADVANCED PROMPT')
|
||||
->assertDontSee('SECRET HELPER PROMPT')
|
||||
->assertDontSee('SECRET VARIANT PROMPT')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.prompt', null)
|
||||
->where('item.documentation.summary', 'Public-facing overview.')
|
||||
->where('item.placeholders.0.key', 'CITY_NAME')
|
||||
->where('item.has_placeholder_inputs', true)
|
||||
->where('item.has_helper_prompts', true)
|
||||
->where('item.has_prompt_variants', true)
|
||||
->where('item.helper_prompts', [])
|
||||
->where('item.prompt_variants', []));
|
||||
}
|
||||
|
||||
public function test_prompt_without_placeholder_tokens_marks_placeholder_inputs_as_hidden(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Descriptor Only Prompt',
|
||||
'slug' => 'descriptor-only-prompt',
|
||||
'excerpt' => 'Has descriptive placeholder cards but no input tokens in the prompt.',
|
||||
'prompt' => 'Create a calm Roman rooftop garden scene at sunrise.',
|
||||
'documentation' => [
|
||||
'summary' => 'A fixed prompt with no user-substituted variables.',
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_STYLE',
|
||||
'label' => 'City style',
|
||||
'description' => 'Editorial guidance only.',
|
||||
'example' => 'Historic Rome rooftop terrace with distant domes',
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.placeholders.0.key', 'CITY_STYLE')
|
||||
->where('item.has_placeholder_inputs', false));
|
||||
}
|
||||
|
||||
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
|
||||
@@ -1008,6 +1259,7 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertDontSee((string) $prompt->negative_prompt)
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.access_requirement', $prompt->access_level === 'pro' ? 'Requires Pro access.' : 'Requires Creator or Pro access.')
|
||||
->where('item.prompt', null)
|
||||
->where('item.negative_prompt', null));
|
||||
}
|
||||
|
||||
131
tests/Feature/Academy/AcademyStripeWebhookTest.php
Normal file
131
tests/Feature/Academy/AcademyStripeWebhookTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Models\AcademyBillingEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Cashier\Events\WebhookHandled;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AcademyStripeWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_creator_month',
|
||||
'featured' => false,
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_pro_month',
|
||||
'featured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_webhook_listener_stores_safe_audit_summary_and_records_handling_outcome(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_webhook_test',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
Cache::put('academy.billing.account.'.$user->id, 'stale', 300);
|
||||
|
||||
event(new WebhookReceived([
|
||||
'id' => 'evt_academy_billing_test',
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_webhook_test',
|
||||
'customer' => 'cus_webhook_test',
|
||||
'status' => 'active',
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'price' => [
|
||||
'id' => 'price_creator_month',
|
||||
],
|
||||
]],
|
||||
],
|
||||
'metadata' => [
|
||||
'academy_plan' => 'creator_monthly',
|
||||
'academy_tier' => 'creator',
|
||||
'user_id' => (string) $user->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_webhook_test',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_creator_month',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_webhook_test',
|
||||
'stripe_product' => 'prod_creator_month',
|
||||
'stripe_price' => 'price_creator_month',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
event(new WebhookHandled([
|
||||
'id' => 'evt_academy_billing_test',
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_webhook_test',
|
||||
'customer' => 'cus_webhook_test',
|
||||
'status' => 'active',
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'price' => [
|
||||
'id' => 'price_creator_month',
|
||||
'product' => 'prod_creator_month',
|
||||
],
|
||||
'quantity' => 1,
|
||||
'id' => 'si_webhook_test',
|
||||
]],
|
||||
],
|
||||
'metadata' => [
|
||||
'academy_plan' => 'creator_monthly',
|
||||
'academy_tier' => 'creator',
|
||||
'user_id' => (string) $user->id,
|
||||
],
|
||||
'subscription' => 'sub_webhook_test',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$billingEvent = AcademyBillingEvent::query()->where('stripe_event_id', 'evt_academy_billing_test')->first();
|
||||
|
||||
$this->assertNotNull($billingEvent);
|
||||
$this->assertSame('customer.subscription.updated', $billingEvent->event_type);
|
||||
$this->assertSame('creator_monthly', $billingEvent->academy_plan);
|
||||
$this->assertSame('creator', $billingEvent->academy_tier);
|
||||
$this->assertSame('cus_webhook_test', $billingEvent->stripe_customer_id);
|
||||
$this->assertSame(['price_creator_month'], $billingEvent->payload_summary['price_ids'] ?? []);
|
||||
$this->assertTrue($billingEvent->payload_summary['received'] ?? false);
|
||||
$this->assertTrue($billingEvent->payload_summary['handled'] ?? false);
|
||||
$this->assertSame('local_subscription_synced', $billingEvent->payload_summary['outcome'] ?? null);
|
||||
$this->assertTrue($billingEvent->payload_summary['cache_cleared'] ?? false);
|
||||
$this->assertFalse(Cache::has('academy.billing.account.'.$user->id));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyBillingEvent;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
@@ -17,7 +18,11 @@ use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -46,6 +51,78 @@ final class AcademyAdminTest extends TestCase
|
||||
->where('stats.prompts', 0));
|
||||
}
|
||||
|
||||
public function test_admin_can_open_academy_billing_overview_with_live_stats(): void
|
||||
{
|
||||
config()->set('academy_billing.enabled', true);
|
||||
config()->set('academy_billing.subscription_name', 'academy');
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_creator_monthly_test',
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_pro_monthly_test',
|
||||
],
|
||||
]);
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$creatorUser = User::factory()->create();
|
||||
$graceUser = User::factory()->create();
|
||||
$proUser = User::factory()->create();
|
||||
|
||||
$this->seedAcademySubscription($creatorUser, 'price_creator_monthly_test');
|
||||
$this->seedAcademySubscription($graceUser, 'price_creator_monthly_test', 'canceled', now()->addDays(5));
|
||||
$this->seedAcademySubscription($proUser, 'price_pro_monthly_test');
|
||||
|
||||
AcademyBillingEvent::query()->create([
|
||||
'user_id' => $graceUser->id,
|
||||
'stripe_event_id' => 'evt_academy_billing_test_1',
|
||||
'stripe_customer_id' => 'cus_academy_test_1',
|
||||
'stripe_subscription_id' => 'sub_academy_test_1',
|
||||
'event_type' => 'customer.subscription.updated',
|
||||
'academy_tier' => 'creator',
|
||||
'academy_plan' => 'creator_monthly',
|
||||
'payload_summary' => ['status' => 'canceled', 'source' => 'test'],
|
||||
'processed_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/moderation/academy/dashboard')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/Dashboard')
|
||||
->where('stats.active_subscribers', 3)
|
||||
->where('stats.creator_subscribers', 2)
|
||||
->where('stats.pro_subscribers', 1)
|
||||
->where('stats.grace_period_subscribers', 1)
|
||||
->where('links.billing', route('admin.academy.billing')));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.academy.billing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/Billing')
|
||||
->where('summary.enabled', true)
|
||||
->where('summary.active_subscribers', 3)
|
||||
->where('summary.creator_subscribers', 2)
|
||||
->where('summary.pro_subscribers', 1)
|
||||
->where('summary.grace_period_subscribers', 1)
|
||||
->where('summary.missing_plan_keys', [])
|
||||
->has('planBreakdown', 2)
|
||||
->where('planBreakdown.0.key', 'creator_monthly')
|
||||
->where('planBreakdown.0.subscribers', 2)
|
||||
->where('planBreakdown.1.key', 'pro_monthly')
|
||||
->where('planBreakdown.1.subscribers', 1)
|
||||
->has('recentEvents', 1)
|
||||
->where('recentEvents.0.event_type', 'customer.subscription.updated')
|
||||
->where('recentEvents.0.user_id', $graceUser->id));
|
||||
}
|
||||
|
||||
public function test_admin_can_approve_and_reject_challenge_submission(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
@@ -94,6 +171,7 @@ final class AcademyAdminTest extends TestCase
|
||||
|
||||
foreach ([
|
||||
'/moderation/academy/dashboard',
|
||||
'/moderation/academy/billing',
|
||||
'/moderation/academy/courses',
|
||||
'/moderation/academy/categories',
|
||||
'/moderation/academy/lessons',
|
||||
@@ -102,11 +180,56 @@ final class AcademyAdminTest extends TestCase
|
||||
'/moderation/academy/challenges',
|
||||
'/moderation/academy/submissions',
|
||||
'/moderation/academy/badges',
|
||||
'/moderation/academy/analytics',
|
||||
'/moderation/academy/analytics/intelligence',
|
||||
'/moderation/academy/analytics/content',
|
||||
'/moderation/academy/analytics/prompts',
|
||||
'/moderation/academy/analytics/lessons',
|
||||
'/moderation/academy/analytics/courses',
|
||||
'/moderation/academy/analytics/search',
|
||||
'/moderation/academy/analytics/funnel',
|
||||
] as $path) {
|
||||
$this->actingAs($admin)->get($path)->assertOk();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_open_academy_analytics_pages(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
foreach ([
|
||||
'/moderation/academy/analytics',
|
||||
'/moderation/academy/analytics/intelligence',
|
||||
'/moderation/academy/analytics/content',
|
||||
'/moderation/academy/analytics/prompts',
|
||||
'/moderation/academy/analytics/lessons',
|
||||
'/moderation/academy/analytics/courses',
|
||||
'/moderation/academy/analytics/search',
|
||||
'/moderation/academy/analytics/funnel',
|
||||
] as $path) {
|
||||
$this->actingAs($user)->get($path)->assertStatus(302);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_can_open_academy_intelligence_dashboard(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/moderation/academy/analytics/intelligence')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/AnalyticsIntelligence')
|
||||
->where('range.active', '30d')
|
||||
->has('contentOpportunities.cards')
|
||||
->has('searchGaps.summary')
|
||||
->has('promptInsights.summary')
|
||||
->has('lessonDropoffs.summary')
|
||||
->has('courseHealth.summary')
|
||||
->has('premiumInterest.summary')
|
||||
->has('editorialRecommendations.summary'));
|
||||
}
|
||||
|
||||
public function test_admin_can_open_course_builder(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
@@ -363,11 +486,369 @@ final class AcademyAdminTest extends TestCase
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/CrudForm')
|
||||
->where('editorContext.comparisonMediaUploadUrl', route('api.studio.academy.lessons.media.upload'))
|
||||
->where('editorContext.comparisonMediaUploadUrl', route('api.studio.academy.lessons.media.upload'))
|
||||
->where('record.tool_notes.0.provider', 'Midjourney')
|
||||
->where('record.tool_notes.0.model_name', 'V7')
|
||||
->where('record.tool_notes.0.image_path', 'academy/lessons/body/aa/bb/prompt-midjourney.webp')
|
||||
->where('record.tool_notes.0.score', 9));
|
||||
->where('record.tool_notes.0.model_name', 'V7')
|
||||
->where('record.tool_notes.0.image_path', 'academy/lessons/body/aa/bb/prompt-midjourney.webp')
|
||||
->where('record.tool_notes.0.score', 9));
|
||||
}
|
||||
|
||||
public function test_prompt_comparison_upload_returns_thumbnail_and_medium_variants(): void
|
||||
{
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
Storage::fake('s3');
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('api.studio.academy.lessons.media.upload'), [
|
||||
'slot' => 'body',
|
||||
'image' => UploadedFile::fake()->image('comparison-source.png', 1600, 900),
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('slot', 'body')
|
||||
->assertJsonPath('thumb_width', 480)
|
||||
->assertJsonPath('medium_width', 960);
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
$this->assertIsString($payload['path'] ?? null);
|
||||
$this->assertIsString($payload['thumb_path'] ?? null);
|
||||
$this->assertIsString($payload['medium_path'] ?? null);
|
||||
$this->assertNotSame($payload['path'], $payload['thumb_path']);
|
||||
$this->assertNotSame('', $payload['medium_path']);
|
||||
|
||||
Storage::disk('s3')->assertExists($payload['path']);
|
||||
Storage::disk('s3')->assertExists($payload['thumb_path']);
|
||||
Storage::disk('s3')->assertExists($payload['medium_path']);
|
||||
}
|
||||
|
||||
public function test_prompt_thumbnail_backfill_command_generates_missing_variants(): void
|
||||
{
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
Storage::fake('s3');
|
||||
|
||||
$previewUpload = UploadedFile::fake()->image('prompt-preview.png', 1600, 900);
|
||||
$comparisonUpload = UploadedFile::fake()->image('prompt-comparison.png', 1400, 1400);
|
||||
|
||||
Storage::disk('s3')->put(
|
||||
'academy-prompts/previews/emoji-sticker-pack.webp',
|
||||
file_get_contents($previewUpload->getPathname()) ?: ''
|
||||
);
|
||||
|
||||
Storage::disk('s3')->put(
|
||||
'academy/lessons/body/aa/bb/emoji-sticker-pack.webp',
|
||||
file_get_contents($comparisonUpload->getPathname()) ?: ''
|
||||
);
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Emoji Sticker Prompt',
|
||||
'slug' => 'emoji-sticker-prompt',
|
||||
'excerpt' => 'Prompt waiting for thumbs.',
|
||||
'prompt' => 'Create a chibi emoji sticker collection.',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'preview_image' => 'academy-prompts/previews/emoji-sticker-pack.webp',
|
||||
'tool_notes' => [[
|
||||
'provider' => 'ChatGPT',
|
||||
'model_name' => '4o Image',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/emoji-sticker-pack.webp',
|
||||
'thumb_path' => '',
|
||||
'active' => true,
|
||||
]],
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->artisan('academy:prompts:generate-missing-thumbnails')
|
||||
->expectsOutputToContain('Prompt thumbnail backfill complete.')
|
||||
->assertSuccessful();
|
||||
|
||||
Storage::disk('s3')->assertExists('academy-prompts/previews/emoji-sticker-pack-thumb.webp');
|
||||
Storage::disk('s3')->assertExists('academy-prompts/previews/emoji-sticker-pack-md.webp');
|
||||
Storage::disk('s3')->assertExists('academy/lessons/body/aa/bb/emoji-sticker-pack-thumb.webp');
|
||||
Storage::disk('s3')->assertExists('academy/lessons/body/aa/bb/emoji-sticker-pack-md.webp');
|
||||
|
||||
$this->assertSame(
|
||||
'academy/lessons/body/aa/bb/emoji-sticker-pack-thumb.webp',
|
||||
$prompt->fresh()->tool_notes[0]['thumb_path'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_admin_can_store_prompt_with_advanced_prompt_metadata(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.prompts.store'), [
|
||||
'title' => 'City Climate Portrait',
|
||||
'slug' => 'city-climate-portrait',
|
||||
'excerpt' => 'Advanced prompt with structured documentation.',
|
||||
'prompt' => 'Create a climate-driven city portrait.',
|
||||
'negative_prompt' => 'blurry, low detail',
|
||||
'usage_notes' => 'Use real data before generating.',
|
||||
'workflow_notes' => 'Internal editorial workflow note.',
|
||||
'documentation' => [
|
||||
'summary' => 'This prompt creates a climate-aware city wallpaper.',
|
||||
'best_for' => ['travel wallpapers', 'editorial posters'],
|
||||
'how_to_use' => ['Choose a city', 'Collect climate data', 'Insert placeholders'],
|
||||
'required_inputs' => ['City name', 'Monthly weather data'],
|
||||
'workflow' => ['Research', 'Prompt prep', 'Generation'],
|
||||
'tips' => ['Keep the climate ribbon subtle'],
|
||||
'common_mistakes' => ['Inventing weather data'],
|
||||
'data_accuracy_notes' => ['Use climate normals where possible'],
|
||||
'display_notes' => 'Use the image-safe variant for most models.',
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'description' => 'The featured city.',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'type' => 'text',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [
|
||||
[
|
||||
'title' => 'Collect city climate data',
|
||||
'description' => 'Gather facts and monthly weather data.',
|
||||
'prompt' => 'Collect city and climate data for [CITY_NAME].',
|
||||
'expected_output' => 'json',
|
||||
],
|
||||
],
|
||||
'prompt_variants' => [
|
||||
[
|
||||
'title' => 'Image-safe version',
|
||||
'slug' => 'image-safe-version',
|
||||
'description' => 'Reduced text pressure for image models.',
|
||||
'prompt' => 'Create an image-safe city climate portrait.',
|
||||
'negative_prompt' => 'tiny text, clutter',
|
||||
'recommended' => true,
|
||||
'recommended_for' => ['general image generation'],
|
||||
'risk_notes' => ['Climate icons may still be abstract'],
|
||||
],
|
||||
],
|
||||
'difficulty' => 'intermediate',
|
||||
'access_level' => 'creator',
|
||||
'aspect_ratio' => '16:9',
|
||||
'tags' => ['city', 'climate'],
|
||||
'preview_image' => '',
|
||||
'featured' => false,
|
||||
'prompt_of_week' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
]);
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->where('slug', 'city-climate-portrait')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
|
||||
$this->assertSame('This prompt creates a climate-aware city wallpaper.', $prompt->documentation['summary'] ?? null);
|
||||
$this->assertSame('CITY_NAME', $prompt->placeholders[0]['key'] ?? null);
|
||||
$this->assertSame('other', $prompt->helper_prompts[0]['type'] ?? null);
|
||||
$this->assertTrue((bool) ($prompt->helper_prompts[0]['active'] ?? false));
|
||||
$this->assertSame('Image-safe version', $prompt->prompt_variants[0]['title'] ?? null);
|
||||
$this->assertTrue((bool) ($prompt->prompt_variants[0]['recommended'] ?? false));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/CrudForm')
|
||||
->where('record.documentation', json_encode([
|
||||
'summary' => 'This prompt creates a climate-aware city wallpaper.',
|
||||
'display_notes' => 'Use the image-safe variant for most models.',
|
||||
'best_for' => ['travel wallpapers', 'editorial posters'],
|
||||
'how_to_use' => ['Choose a city', 'Collect climate data', 'Insert placeholders'],
|
||||
'required_inputs' => ['City name', 'Monthly weather data'],
|
||||
'workflow' => ['Research', 'Prompt prep', 'Generation'],
|
||||
'tips' => ['Keep the climate ribbon subtle'],
|
||||
'common_mistakes' => ['Inventing weather data'],
|
||||
'data_accuracy_notes' => ['Use climate normals where possible'],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
|
||||
->where('record.placeholders', json_encode([
|
||||
[
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'description' => 'The featured city.',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'default' => null,
|
||||
'type' => 'text',
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
|
||||
->where('record.helper_prompts', json_encode([
|
||||
[
|
||||
'title' => 'Collect city climate data',
|
||||
'type' => 'other',
|
||||
'description' => 'Gather facts and monthly weather data.',
|
||||
'prompt' => 'Collect city and climate data for [CITY_NAME].',
|
||||
'expected_output' => 'json',
|
||||
'active' => true,
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
|
||||
->where('record.prompt_variants', json_encode([
|
||||
[
|
||||
'title' => 'Image-safe version',
|
||||
'slug' => 'image-safe-version',
|
||||
'description' => 'Reduced text pressure for image models.',
|
||||
'prompt' => 'Create an image-safe city climate portrait.',
|
||||
'negative_prompt' => 'tiny text, clutter',
|
||||
'recommended' => true,
|
||||
'recommended_for' => ['general image generation'],
|
||||
'risk_notes' => ['Climate icons may still be abstract'],
|
||||
'active' => true,
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)));
|
||||
}
|
||||
|
||||
public function test_admin_can_store_prompt_when_advanced_json_fields_are_single_objects(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.prompts.store'), [
|
||||
'title' => 'Single Object Prompt',
|
||||
'slug' => 'single-object-prompt',
|
||||
'excerpt' => 'Uses single-object advanced payloads.',
|
||||
'prompt' => 'Create a clean travel poster.',
|
||||
'negative_prompt' => '',
|
||||
'usage_notes' => '',
|
||||
'workflow_notes' => '',
|
||||
'documentation' => [
|
||||
'summary' => 'Documentation still uses an object.',
|
||||
],
|
||||
'placeholders' => [
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'description' => 'Featured city.',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'type' => 'text',
|
||||
],
|
||||
'helper_prompts' => [
|
||||
'title' => 'Collect city data',
|
||||
'description' => 'Gather source facts.',
|
||||
'prompt' => 'Collect city data for [CITY_NAME].',
|
||||
'expected_output' => 'json',
|
||||
],
|
||||
'prompt_variants' => [
|
||||
'title' => 'Image-safe version',
|
||||
'slug' => 'image-safe-version',
|
||||
'description' => 'Safer for image models.',
|
||||
'prompt' => 'Create an image-safe travel poster.',
|
||||
'recommended' => true,
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'aspect_ratio' => '16:9',
|
||||
'tags' => ['travel'],
|
||||
'preview_image' => '',
|
||||
'featured' => false,
|
||||
'prompt_of_week' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
]);
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->where('slug', 'single-object-prompt')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
|
||||
$this->assertSame('CITY_NAME', $prompt->placeholders[0]['key'] ?? null);
|
||||
$this->assertSame('Collect city data', $prompt->helper_prompts[0]['title'] ?? null);
|
||||
$this->assertSame('Image-safe version', $prompt->prompt_variants[0]['title'] ?? null);
|
||||
}
|
||||
|
||||
public function test_admin_can_store_prompt_placeholder_without_key_or_type(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.prompts.store'), [
|
||||
'title' => 'Loose Placeholder Prompt',
|
||||
'slug' => 'loose-placeholder-prompt',
|
||||
'excerpt' => 'Allows descriptive placeholders without a machine key.',
|
||||
'prompt' => 'Create a stylized city scene.',
|
||||
'negative_prompt' => '',
|
||||
'usage_notes' => '',
|
||||
'workflow_notes' => '',
|
||||
'documentation' => null,
|
||||
'placeholders' => [
|
||||
[
|
||||
'label' => 'City name',
|
||||
'description' => 'The city featured in the artwork.',
|
||||
'example' => 'Paris',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [],
|
||||
'prompt_variants' => [],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'aspect_ratio' => '16:9',
|
||||
'tags' => ['travel'],
|
||||
'preview_image' => '',
|
||||
'featured' => false,
|
||||
'prompt_of_week' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
]);
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->where('slug', 'loose-placeholder-prompt')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
|
||||
$this->assertSame('City name', $prompt->placeholders[0]['label'] ?? null);
|
||||
$this->assertSame('The city featured in the artwork.', $prompt->placeholders[0]['description'] ?? null);
|
||||
$this->assertNull($prompt->placeholders[0]['key'] ?? null);
|
||||
$this->assertNull($prompt->placeholders[0]['type'] ?? null);
|
||||
}
|
||||
|
||||
public function test_admin_can_store_prompt_placeholder_with_custom_type(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.prompts.store'), [
|
||||
'title' => 'Custom Type Prompt',
|
||||
'slug' => 'custom-type-prompt',
|
||||
'excerpt' => 'Allows custom placeholder type values.',
|
||||
'prompt' => 'Create a branded city poster.',
|
||||
'negative_prompt' => '',
|
||||
'usage_notes' => '',
|
||||
'workflow_notes' => '',
|
||||
'documentation' => null,
|
||||
'placeholders' => [
|
||||
[
|
||||
'label' => 'Location profile',
|
||||
'description' => 'Region-specific context block.',
|
||||
'type' => 'location_profile',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [],
|
||||
'prompt_variants' => [],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'aspect_ratio' => '16:9',
|
||||
'tags' => ['travel'],
|
||||
'preview_image' => '',
|
||||
'featured' => false,
|
||||
'prompt_of_week' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
]);
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->where('slug', 'custom-type-prompt')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
|
||||
$this->assertSame('location_profile', $prompt->placeholders[0]['type'] ?? null);
|
||||
}
|
||||
|
||||
public function test_admin_course_edit_form_includes_outline_summary(): void
|
||||
@@ -390,20 +871,54 @@ final class AcademyAdminTest extends TestCase
|
||||
'title' => 'Required Lesson',
|
||||
'slug' => 'required-lesson',
|
||||
'content' => '<p>Body</p>',
|
||||
'cover_image' => 'academy/lessons/covers/required-cover.webp',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'reading_minutes' => 5,
|
||||
'published_at' => Carbon::parse('2026-05-10 09:30:00'),
|
||||
'active' => true,
|
||||
]);
|
||||
$optionalLesson = AcademyLesson::query()->create([
|
||||
'title' => 'Optional Lesson',
|
||||
'slug' => 'optional-lesson',
|
||||
'content' => '<p>Body</p>',
|
||||
'article_cover_image' => 'academy/lessons/covers/optional-article-cover.webp',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'reading_minutes' => 5,
|
||||
'published_at' => Carbon::parse('2099-05-18 14:00:00'),
|
||||
'active' => true,
|
||||
]);
|
||||
$libraryLesson = AcademyLesson::query()->create([
|
||||
'title' => 'Library Lesson',
|
||||
'slug' => 'library-lesson',
|
||||
'content' => '<p>Body</p>',
|
||||
'cover_image' => 'academy/lessons/covers/library-cover.webp',
|
||||
'difficulty' => 'intermediate',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'reading_minutes' => 5,
|
||||
'published_at' => Carbon::parse('2099-06-01 08:15:00'),
|
||||
'active' => false,
|
||||
]);
|
||||
$otherCourse = AcademyCourse::query()->create([
|
||||
'title' => 'Other Course',
|
||||
'slug' => 'other-course',
|
||||
'excerpt' => 'Other course',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
$otherCourseLesson = AcademyLesson::query()->create([
|
||||
'title' => 'Used Elsewhere Lesson',
|
||||
'slug' => 'used-elsewhere-lesson',
|
||||
'content' => '<p>Body</p>',
|
||||
'difficulty' => 'advanced',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'reading_minutes' => 5,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
@@ -421,6 +936,13 @@ final class AcademyAdminTest extends TestCase
|
||||
'order_num' => 1,
|
||||
'is_required' => false,
|
||||
]);
|
||||
AcademyCourseLesson::query()->create([
|
||||
'course_id' => $otherCourse->id,
|
||||
'section_id' => null,
|
||||
'lesson_id' => $otherCourseLesson->id,
|
||||
'order_num' => 0,
|
||||
'is_required' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.academy.courses.edit', ['academyCourse' => $course]))
|
||||
@@ -433,7 +955,25 @@ final class AcademyAdminTest extends TestCase
|
||||
->where('editorContext.outlineSummary.required_lesson_count', 1)
|
||||
->where('editorContext.outlineSummary.unsectioned_lesson_count', 1)
|
||||
->where('editorContext.outlineSummary.sections.0.title', 'Introduction')
|
||||
->where('editorContext.outlineSummary.sections.0.lesson_count', 1));
|
||||
->where('editorContext.outlineSummary.sections.0.lesson_count', 1)
|
||||
->where('editorContext.sectionStoreUrl', route('admin.academy.courses.sections.store', ['academyCourse' => $course]))
|
||||
->where('editorContext.courseSections.0.title', 'Introduction')
|
||||
->where('editorContext.courseSections.0.update_url', route('admin.academy.courses.sections.update', ['academyCourse' => $course, 'academyCourseSection' => $section]))
|
||||
->where('editorContext.courseLessons.0.section_id', $section->id)
|
||||
->where('editorContext.courseLessons.0.cover_image_url', fn ($url) => is_string($url) && str_ends_with($url, '/academy/lessons/covers/required-cover.webp'))
|
||||
->where('editorContext.courseLessons.0.active', true)
|
||||
->where('editorContext.courseLessons.0.publication_state', 'published')
|
||||
->where('editorContext.courseLessons.0.publication_label', 'Published')
|
||||
->where('editorContext.courseLessons.1.cover_image_url', fn ($url) => is_string($url) && str_ends_with($url, '/academy/lessons/covers/optional-article-cover.webp'))
|
||||
->where('editorContext.courseLessons.1.publication_state', 'scheduled')
|
||||
->where('editorContext.courseLessons.1.publication_label', 'Publishes 2099-05-18 14:00')
|
||||
->where('editorContext.availableLessons', fn ($lessons) => count($lessons) === 1)
|
||||
->where('editorContext.availableLessons.0.title', 'Library Lesson')
|
||||
->where('editorContext.availableLessons.0.cover_image_url', fn ($url) => is_string($url) && str_ends_with($url, '/academy/lessons/covers/library-cover.webp'))
|
||||
->where('editorContext.availableLessons.0.active', false)
|
||||
->where('editorContext.availableLessons.0.publication_state', 'scheduled')
|
||||
->where('editorContext.availableLessons.0.publication_label', 'Publishes 2099-06-01 08:15')
|
||||
->where('editorContext.availableLessons.0.edit_url', route('admin.academy.lessons.edit', ['academyLesson' => $libraryLesson])));
|
||||
}
|
||||
|
||||
public function test_admin_can_store_course_with_rich_description_and_media_fields(): void
|
||||
@@ -474,6 +1014,123 @@ final class AcademyAdminTest extends TestCase
|
||||
$this->assertNotNull($course->published_at);
|
||||
}
|
||||
|
||||
public function test_admin_lessons_index_includes_course_names_and_order(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$course = AcademyCourse::query()->create([
|
||||
'title' => 'Prompt Foundations',
|
||||
'slug' => 'prompt-foundations',
|
||||
'excerpt' => 'Prompt course',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Subject and Scene Control',
|
||||
'slug' => 'subject-and-scene-control',
|
||||
'excerpt' => 'Learn how to direct the main subject cleanly.',
|
||||
'content' => '<p>Body</p>',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'course_order' => 4,
|
||||
'reading_minutes' => 5,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
AcademyCourseLesson::query()->create([
|
||||
'course_id' => $course->id,
|
||||
'lesson_id' => $lesson->id,
|
||||
'order_num' => 3,
|
||||
'is_required' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.academy.lessons.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/CrudIndex')
|
||||
->where('resource', 'lessons')
|
||||
->where('columns.1', 'course_names')
|
||||
->where('columns.2', 'course_order')
|
||||
->where('items.data.0.title', 'Subject and Scene Control')
|
||||
->where('items.data.0.course_names.0', 'Prompt Foundations')
|
||||
->where('items.data.0.course_order', 4)
|
||||
->where('items.data.0.active', true));
|
||||
}
|
||||
|
||||
public function test_admin_can_import_course_lessons_from_json_toc(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$category = AcademyCategory::query()->create([
|
||||
'type' => 'lesson',
|
||||
'name' => 'Wallpaper Prompting',
|
||||
'slug' => 'wallpaper-prompting',
|
||||
'order_num' => 1,
|
||||
'active' => true,
|
||||
]);
|
||||
$course = AcademyCourse::query()->create([
|
||||
'title' => 'Wallpaper Prompt Engineering',
|
||||
'slug' => 'wallpaper-prompt-engineering',
|
||||
'excerpt' => 'Learn to structure clean wallpaper prompts.',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'intermediate',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.courses.lessons.import', ['academyCourse' => $course]), [
|
||||
'defaults' => [
|
||||
'difficulty' => 'advanced',
|
||||
'access_level' => 'creator',
|
||||
'lesson_type' => 'article',
|
||||
'active' => false,
|
||||
'category_slug' => 'wallpaper-prompting',
|
||||
],
|
||||
'lessons' => [
|
||||
[
|
||||
'title' => 'What Makes a Great Wallpaper Prompt?',
|
||||
'slug' => 'what-makes-a-great-wallpaper-prompt',
|
||||
'goal' => 'Explain what separates random AI images from clean, usable wallpapers.',
|
||||
],
|
||||
[
|
||||
'title' => 'Composition for Wallpapers',
|
||||
'goal' => 'Cover centered subjects, negative space, cinematic framing, and icon-safe areas.',
|
||||
'difficulty' => 'beginner',
|
||||
'category' => 'Wallpaper Prompting',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.academy.courses.edit', ['academyCourse' => $course]));
|
||||
|
||||
$firstLesson = AcademyLesson::query()->where('slug', 'what-makes-a-great-wallpaper-prompt')->firstOrFail();
|
||||
$secondLesson = AcademyLesson::query()->where('slug', 'composition-for-wallpapers')->firstOrFail();
|
||||
|
||||
$this->assertSame('Explain what separates random AI images from clean, usable wallpapers.', $firstLesson->excerpt);
|
||||
$this->assertSame('Cover centered subjects, negative space, cinematic framing, and icon-safe areas.', $secondLesson->excerpt);
|
||||
$this->assertSame((int) $category->id, (int) $firstLesson->category_id);
|
||||
$this->assertSame((int) $category->id, (int) $secondLesson->category_id);
|
||||
$this->assertSame('advanced', $firstLesson->difficulty);
|
||||
$this->assertSame('beginner', $secondLesson->difficulty);
|
||||
$this->assertSame('creator', $firstLesson->access_level);
|
||||
$this->assertFalse((bool) $firstLesson->active);
|
||||
$this->assertFalse((bool) $secondLesson->active);
|
||||
|
||||
$courseLessons = AcademyCourseLesson::query()
|
||||
->where('course_id', $course->id)
|
||||
->orderBy('order_num')
|
||||
->get();
|
||||
|
||||
$this->assertSame([$firstLesson->id, $secondLesson->id], $courseLessons->pluck('lesson_id')->map(static fn ($value) => (int) $value)->all());
|
||||
$this->assertSame([0, 1], $courseLessons->pluck('order_num')->map(static fn ($value) => (int) $value)->all());
|
||||
$this->assertSame(1, (int) $firstLesson->fresh()->lesson_number);
|
||||
$this->assertSame(1, (int) $firstLesson->fresh()->course_order);
|
||||
$this->assertSame(2, (int) $secondLesson->fresh()->lesson_number);
|
||||
$this->assertSame(2, (int) $secondLesson->fresh()->course_order);
|
||||
$this->assertSame(2, (int) $course->fresh()->lessons_count_cache);
|
||||
}
|
||||
|
||||
public function test_admin_category_update_clears_academy_cache(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
@@ -602,6 +1259,7 @@ MD;
|
||||
public function test_admin_can_store_lesson_numbering_fields(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$longTag = str_repeat('a', 100);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.lessons.store'), [
|
||||
@@ -612,7 +1270,7 @@ MD;
|
||||
'series_name' => 'AI Art Basics',
|
||||
'excerpt' => 'Testing ordering field persistence.',
|
||||
'content' => '<p>Lesson body.</p>',
|
||||
'tags' => ['workflow', 'academy'],
|
||||
'tags' => [$longTag, 'academy'],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
@@ -636,13 +1294,13 @@ MD;
|
||||
'course_order' => 3,
|
||||
'series_name' => 'AI Art Basics',
|
||||
]);
|
||||
$this->assertSame(['workflow', 'academy'], $lesson->fresh()->tags);
|
||||
$this->assertSame([$longTag, 'academy'], $lesson->fresh()->tags);
|
||||
}
|
||||
|
||||
public function test_admin_lesson_reading_time_is_calculated_from_content(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$body = '<p>' . implode(' ', array_fill(0, 420, 'prompt')) . '</p>';
|
||||
$body = '<p>'.implode(' ', array_fill(0, 420, 'prompt')).'</p>';
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.lessons.store'), [
|
||||
@@ -838,6 +1496,34 @@ MD;
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_upload_lesson_cover_image_at_six_hundred_width(): void
|
||||
{
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
Storage::fake('s3');
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('api.studio.academy.lessons.media.upload'), [
|
||||
'slot' => 'cover',
|
||||
'image' => UploadedFile::fake()->image('lesson-cover.png', 600, 315),
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('slot', 'cover')
|
||||
->assertJsonPath('width', 600)
|
||||
->assertJsonPath('height', 315);
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
$this->assertIsString($payload['path'] ?? null);
|
||||
$this->assertIsString($payload['thumb_path'] ?? null);
|
||||
|
||||
Storage::disk('s3')->assertExists($payload['path']);
|
||||
Storage::disk('s3')->assertExists($payload['thumb_path']);
|
||||
}
|
||||
|
||||
public function test_admin_can_add_ai_comparison_result_to_existing_lesson(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
@@ -1113,4 +1799,30 @@ MD;
|
||||
$this->assertSoftDeleted('academy_lesson_blocks', ['id' => $block->id]);
|
||||
$this->assertSoftDeleted('academy_ai_comparison_results', ['id' => $result->id]);
|
||||
}
|
||||
|
||||
private function seedAcademySubscription(User $user, string $priceId, string $status = 'active', ?Carbon $endsAt = null): void
|
||||
{
|
||||
$subscriptionId = DB::table('subscriptions')->insertGetId([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_'.$user->id.'_'.md5($priceId.$status.($endsAt?->toISOString() ?? 'active')),
|
||||
'stripe_status' => $status,
|
||||
'stripe_price' => $priceId,
|
||||
'quantity' => 1,
|
||||
'trial_ends_at' => null,
|
||||
'ends_at' => $endsAt,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('subscription_items')->insert([
|
||||
'subscription_id' => $subscriptionId,
|
||||
'stripe_id' => 'si_'.$user->id.'_'.md5($priceId.$status),
|
||||
'stripe_product' => 'prod_'.md5($priceId),
|
||||
'stripe_price' => $priceId,
|
||||
'quantity' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
it('renders JSON-LD structured data on published artwork page', function () {
|
||||
$user = User::factory()->create(['name' => 'Schema Author']);
|
||||
$licenseUrl = route('terms-of-service');
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Photography',
|
||||
@@ -65,11 +66,15 @@ it('renders JSON-LD structured data on published artwork page', function () {
|
||||
)->toArray()['json_ld'], JSON_UNESCAPED_SLASHES))
|
||||
->toContain('"@type":"ImageObject"')
|
||||
->toContain('"name":"Schema Ready Artwork"')
|
||||
->toContain('"license":"' . $licenseUrl . '"')
|
||||
->toContain('"acquireLicensePage":"' . $licenseUrl . '"')
|
||||
->toContain('"copyrightNotice":"Schema Author"')
|
||||
->toContain('"keywords":["neon","city"]');
|
||||
});
|
||||
|
||||
it('builds artwork seo data with breadcrumb and image-license metadata', function () {
|
||||
$user = User::factory()->create(['name' => 'Schema Breadcrumb Author']);
|
||||
$licenseUrl = route('terms-of-service');
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Photography',
|
||||
@@ -131,6 +136,8 @@ it('builds artwork seo data with breadcrumb and image-license metadata', functio
|
||||
->toContain('"@type":"ImageObject"')
|
||||
->toContain('"creditText":"Schema Breadcrumb Author"')
|
||||
->toContain('"license":"https://skinbase.org/licenses/custom-license"')
|
||||
->toContain('"acquireLicensePage":"' . $licenseUrl . '"')
|
||||
->toContain('"copyrightNotice":"Schema Breadcrumb Author"')
|
||||
->toContain('"@type":"BreadcrumbList"')
|
||||
->toContain('"name":"Photography"')
|
||||
->toContain('"name":"Forest"');
|
||||
|
||||
@@ -44,5 +44,7 @@ it('returns latest comments api data', function (): void {
|
||||
->assertJsonPath('data.0.comment_id', $comment->id)
|
||||
->assertJsonPath('data.0.commenter.id', $author->id)
|
||||
->assertJsonPath('data.0.artwork.id', $artwork->id)
|
||||
->assertJsonPath('meta.total', 1);
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonPath('meta.per_page', 20)
|
||||
->assertJsonPath('meta.has_more', false);
|
||||
});
|
||||
40
tests/Feature/Console/HealthCheckCommandTest.php
Normal file
40
tests/Feature/Console/HealthCheckCommandTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Sitemaps\SitemapReleaseManager;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
it('reports the latest sitemap build timestamp', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$releaseId = '20260511123000-manual-release';
|
||||
$builtAt = now()->subHours(2)->toAtomString();
|
||||
|
||||
Storage::disk('local')->put(
|
||||
"sitemaps/releases/{$releaseId}/manifest.json",
|
||||
json_encode([
|
||||
'release_id' => $releaseId,
|
||||
'status' => 'published',
|
||||
'built_at' => $builtAt,
|
||||
'published_at' => now()->toAtomString(),
|
||||
'families' => [],
|
||||
'documents' => [],
|
||||
'totals' => [
|
||||
'families' => 0,
|
||||
'documents' => 0,
|
||||
'urls' => 0,
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
|
||||
);
|
||||
|
||||
$releases = app(SitemapReleaseManager::class)->listReleases();
|
||||
|
||||
expect($releases)
|
||||
->toHaveCount(1)
|
||||
->and($releases[0]['release_id'] ?? null)->toBe($releaseId)
|
||||
->and($releases[0]['built_at'] ?? null)->toBe($builtAt);
|
||||
|
||||
$this->artisan('health:check', ['--only' => 'sitemap', '--json' => true])
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
@@ -86,4 +87,79 @@ it('keeps board page opening-post queries bounded across many topics', function
|
||||
->assertSee('Opening post for topic 1');
|
||||
|
||||
expect($forumPostQueryCount)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('keeps board page artwork preview queries bounded when opening posts include embeds', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'illustrator-embeds',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Illustration Embed Author',
|
||||
'email' => 'illustration-embeds@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Art Query Budget Embeds',
|
||||
'title' => 'Art Embeds',
|
||||
'slug' => 'art-query-budget-embeds',
|
||||
'description' => 'Art discussion with embeds',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Illustration Embeds',
|
||||
'slug' => 'illustration-query-budget-embeds',
|
||||
'description' => 'Illustration board with embeds',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Forum Preview Artwork',
|
||||
'slug' => 'forum-preview-artwork',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
for ($index = 1; $index <= 10; $index++) {
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => 'Embed Topic ' . $index,
|
||||
'slug' => 'embed-topic-' . $index,
|
||||
'replies_count' => 1,
|
||||
'last_post_at' => now()->subMinutes($index),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => 'Opening post for embed topic ' . $index . ' [artwork:' . $artwork->id . ']',
|
||||
'created_at' => now()->subMinutes($index + 30),
|
||||
'updated_at' => now()->subMinutes($index + 30),
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$artworkQueryCount): void {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?artworks\b/i', $query->sql) === 1) {
|
||||
$artworkQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get(route('forum.board.show', ['boardSlug' => $board->slug]))
|
||||
->assertOk()
|
||||
->assertSee('Illustration Embeds')
|
||||
->assertSee('Embed Topic 1')
|
||||
->assertSee('Opening post for embed topic 1');
|
||||
|
||||
expect($artworkQueryCount)->toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -88,7 +88,11 @@ it('renders discussion forum structured data on forum topic pages', function ():
|
||||
->assertOk()
|
||||
->assertSee('application/ld+json', false)
|
||||
->assertSee('DiscussionForumPosting', false)
|
||||
->assertSee('<script type="application/ld+json">{"@context":"https://schema.org","@type":"DiscussionForumPosting"', false)
|
||||
->assertSee('"comment":[{"@type":"Comment"', false)
|
||||
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
|
||||
->assertSee('itemprop="author"', false)
|
||||
->assertSee('itemprop="text"', false)
|
||||
->assertSee('itemprop="comment"', false)
|
||||
->assertSee('itemtype="https://schema.org/Comment"', false)
|
||||
->assertSee('itemprop="headline"', false)
|
||||
@@ -101,6 +105,127 @@ it('renders discussion forum structured data on forum topic pages', function ():
|
||||
->assertSee(route('profile.show', ['username' => 'forumreplier']), false);
|
||||
});
|
||||
|
||||
it('falls back to the topic title when the opening post has no body', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'forumfallbackauthor',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Forum Fallback Author',
|
||||
'email' => 'forumfallbackauthor@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Forum SEO',
|
||||
'title' => 'Forum SEO',
|
||||
'slug' => 'forum-seo-fallback',
|
||||
'description' => 'SEO discussion category',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Technical SEO',
|
||||
'slug' => 'technical-seo-fallback',
|
||||
'description' => 'Technical SEO board',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => 'Sparse topic body',
|
||||
'slug' => 'sparse-topic-body',
|
||||
'views' => 7,
|
||||
'replies_count' => 0,
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => '',
|
||||
'created_at' => now()->subHour(),
|
||||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(route('forum.topic.show', ['topic' => $topic->slug]))
|
||||
->assertOk()
|
||||
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
|
||||
->assertSee('itemprop="author"', false)
|
||||
->assertSee('itemprop="text"', false)
|
||||
->assertSee('Sparse topic body', false)
|
||||
->assertSee('No comments yet.', false)
|
||||
->assertSee(route('profile.show', ['username' => 'forumfallbackauthor']), false);
|
||||
});
|
||||
|
||||
it('falls back to a default author when the topic author is missing', function (): void {
|
||||
$deletedAuthor = User::query()->create([
|
||||
'username' => 'forumdeletedauthor',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Deleted Forum Author',
|
||||
'email' => 'forumdeletedauthor@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Forum Missing Author',
|
||||
'title' => 'Forum Missing Author',
|
||||
'slug' => 'forum-missing-author',
|
||||
'description' => 'Category for orphaned forum topics',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Missing Author Board',
|
||||
'slug' => 'missing-author-board',
|
||||
'description' => 'Board for orphaned forum topics',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $deletedAuthor->id,
|
||||
'title' => 'Missing author topic',
|
||||
'slug' => 'missing-author-topic',
|
||||
'views' => 3,
|
||||
'replies_count' => 0,
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $deletedAuthor->id,
|
||||
'content' => '',
|
||||
'created_at' => now()->subHour(),
|
||||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$deletedAuthor->delete();
|
||||
|
||||
$this->get(route('forum.topic.show', ['topic' => $topic->slug]))
|
||||
->assertOk()
|
||||
->assertSee('DiscussionForumPosting', false)
|
||||
->assertSee('"author":{"@type":"Person","name":"Skinbase"}', false)
|
||||
->assertSee('"text":"Missing author topic"', false)
|
||||
->assertSee('"comment":[{"@type":"Comment"', false)
|
||||
->assertSee('No comments yet.', false);
|
||||
});
|
||||
|
||||
it('renders item list microdata on forum board pages', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'boardauthor',
|
||||
|
||||
107
tests/Feature/ForumIndexPagePerformanceTest.php
Normal file
107
tests/Feature/ForumIndexPagePerformanceTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumPost;
|
||||
use cPad\Plugins\Forum\Models\ForumPostReaction;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->withoutMiddleware(HandleInertiaRequests::class);
|
||||
});
|
||||
|
||||
it('keeps forum homepage source queries bounded across many boards and trending topics', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'forumhomepageauthor',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Forum Homepage Author',
|
||||
'email' => 'forumhomepageauthor@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Forum Home Performance',
|
||||
'title' => 'Forum Home Performance',
|
||||
'slug' => 'forum-home-performance',
|
||||
'description' => 'Forum home performance category',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$boards = collect();
|
||||
|
||||
for ($boardIndex = 1; $boardIndex <= 6; $boardIndex++) {
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Board ' . $boardIndex,
|
||||
'slug' => 'forum-home-board-' . $boardIndex,
|
||||
'description' => 'Forum home board ' . $boardIndex,
|
||||
'is_active' => true,
|
||||
'position' => $boardIndex,
|
||||
]);
|
||||
|
||||
$boards->push($board);
|
||||
|
||||
for ($topicIndex = 1; $topicIndex <= 3; $topicIndex++) {
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => "Board {$boardIndex} Topic {$topicIndex}",
|
||||
'slug' => "forum-home-board-{$boardIndex}-topic-{$topicIndex}",
|
||||
'replies_count' => $topicIndex,
|
||||
'views' => 25 + $topicIndex,
|
||||
'last_post_at' => now()->subMinutes(($boardIndex * 10) + $topicIndex),
|
||||
]);
|
||||
|
||||
$post = ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => 'Opening post for board ' . $boardIndex . ' topic ' . $topicIndex,
|
||||
'created_at' => now()->subMinutes(($boardIndex * 10) + $topicIndex + 5),
|
||||
'updated_at' => now()->subMinutes(($boardIndex * 10) + $topicIndex + 5),
|
||||
]);
|
||||
|
||||
ForumPostReaction::query()->create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $author->id,
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$forumTopicsQueryCount = 0;
|
||||
$forumPostsQueryCount = 0;
|
||||
$forumReactionsQueryCount = 0;
|
||||
|
||||
DB::listen(function ($query) use (&$forumTopicsQueryCount, &$forumPostsQueryCount, &$forumReactionsQueryCount): void {
|
||||
if (preg_match('/\bfrom\s+[`"\[]?forum_topics\b/i', $query->sql) === 1) {
|
||||
$forumTopicsQueryCount++;
|
||||
}
|
||||
|
||||
if (preg_match('/\bfrom\s+[`"\[]?forum_posts\b/i', $query->sql) === 1) {
|
||||
$forumPostsQueryCount++;
|
||||
}
|
||||
|
||||
if (preg_match('/\bfrom\s+[`"\[]?forum_post_reactions\b/i', $query->sql) === 1) {
|
||||
$forumReactionsQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get(route('forum.index'))
|
||||
->assertOk()
|
||||
->assertSee('Forum Home Performance')
|
||||
->assertSee('Board 1')
|
||||
->assertSee('Board 6');
|
||||
|
||||
expect($forumTopicsQueryCount)->toBeLessThanOrEqual(4);
|
||||
expect($forumPostsQueryCount)->toBeLessThanOrEqual(1);
|
||||
expect($forumReactionsQueryCount)->toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -314,6 +314,9 @@ it('renders structured data for public news pages', function (): void {
|
||||
->assertSee('NewsArticle', false)
|
||||
->assertSee('ImageObject', false)
|
||||
->assertSee('creditText', false)
|
||||
->assertSee('copyrightNotice', false)
|
||||
->assertSee('creator', false)
|
||||
->assertSee(route('profile.show', ['username' => $author->username]), false)
|
||||
->assertSee(route('terms-of-service'), false)
|
||||
->assertSee('acquireLicensePage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
143
tests/Feature/SimilarArtworksResultsPerformanceTest.php
Normal file
143
tests/Feature/SimilarArtworksResultsPerformanceTest.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps similar artworks results queries bounded when hydrating gallery items', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'similar-results-author',
|
||||
'name' => 'Similar Results Author',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Internet',
|
||||
'slug' => 'internet-similar-results',
|
||||
'description' => 'Internet skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$source = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Similar Results Source',
|
||||
'slug' => 'similar-results-source',
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
$source->categories()->attach($category->id);
|
||||
|
||||
foreach (range(1, 20) as $index) {
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Similar Results Artwork ' . $index,
|
||||
'slug' => 'similar-results-artwork-' . $index,
|
||||
'published_at' => now()->subMinutes($index),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
}
|
||||
|
||||
$categoryQueryCount = 0;
|
||||
$userQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$categoryQueryCount, &$userQueryCount): void {
|
||||
if (preg_match('/\b(from|join)\s+["`\[]?artwork_category\b/i', $query->sql) === 1 || preg_match('/\bfrom\s+["`\[]?categories\b/i', $query->sql) === 1) {
|
||||
$categoryQueryCount++;
|
||||
}
|
||||
|
||||
if (preg_match('/\bfrom\s+["`\[]?users\b/i', $query->sql) === 1) {
|
||||
$userQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->getJson('/art/' . $source->id . '/similar-results')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data', 'similarity_source', 'total', 'current_page', 'last_page']);
|
||||
|
||||
expect($categoryQueryCount)->toBeLessThanOrEqual(4);
|
||||
expect($userQueryCount)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('keeps similar artworks results group queries bounded for group publishers', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'similar-results-group-author',
|
||||
'name' => 'Similar Results Group Author',
|
||||
]);
|
||||
|
||||
$group = Group::factory()->create([
|
||||
'owner_user_id' => $author->id,
|
||||
'name' => 'Similar Results Group',
|
||||
'slug' => 'similar-results-group',
|
||||
'visibility' => Group::VISIBILITY_PUBLIC,
|
||||
'status' => Group::LIFECYCLE_ACTIVE,
|
||||
]);
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Skins content type',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Internet',
|
||||
'slug' => 'internet-similar-results-group',
|
||||
'description' => 'Internet skins',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$source = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Similar Results Group Source',
|
||||
'slug' => 'similar-results-group-source',
|
||||
'group_id' => $group->id,
|
||||
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
|
||||
'published_as_id' => $group->id,
|
||||
'published_at' => now()->subHour(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
$source->categories()->attach($category->id);
|
||||
|
||||
foreach (range(1, 16) as $index) {
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Similar Results Group Artwork ' . $index,
|
||||
'slug' => 'similar-results-group-artwork-' . $index,
|
||||
'group_id' => $group->id,
|
||||
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
|
||||
'published_as_id' => $group->id,
|
||||
'published_at' => now()->subMinutes($index),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
}
|
||||
|
||||
$groupQueryCount = 0;
|
||||
DB::listen(function ($query) use (&$groupQueryCount): void {
|
||||
if (preg_match('/\bfrom\s+["`\[]?groups\b/i', $query->sql) === 1) {
|
||||
$groupQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->getJson('/art/' . $source->id . '/similar-results')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data', 'similarity_source', 'total', 'current_page', 'last_page']);
|
||||
|
||||
expect($groupQueryCount)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
@@ -106,6 +106,38 @@ it('renders newsroom studio pages for moderators', function (): void {
|
||||
->assertSee('Moderated newsroom article');
|
||||
});
|
||||
|
||||
it('decodes legacy apostrophe entities in the newsroom editor', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'modedit',
|
||||
'name' => 'Moderator Edit',
|
||||
]);
|
||||
$author = User::factory()->create([
|
||||
'username' => 'writeredit',
|
||||
'name' => 'Writer Edit',
|
||||
]);
|
||||
$category = studioNewsCategory();
|
||||
|
||||
$article = NewsArticle::query()->create([
|
||||
'title' => 'Ericsson ENGINE to expand Telia´s international carrier network',
|
||||
'slug' => 'ericsson-engine-to-expand-telias-international-carrier-network',
|
||||
'excerpt' => 'Studio-managed newsroom article.',
|
||||
'content' => 'Studio body',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'type' => NewsArticle::TYPE_EDITORIAL,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.edit', ['article' => $article->id]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsEditor')
|
||||
->where('article.title', "Ericsson ENGINE to expand Telia's international carrier network"));
|
||||
});
|
||||
|
||||
it('filters newsroom listing by status type and category', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
@@ -172,6 +204,46 @@ it('filters newsroom listing by status type and category', function (): void {
|
||||
->where('listing.items.0.title', 'Keep Me'));
|
||||
});
|
||||
|
||||
it('paginates newsroom listing', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
]);
|
||||
$author = User::factory()->create();
|
||||
|
||||
foreach (range(1, 16) as $index) {
|
||||
NewsArticle::query()->create([
|
||||
'title' => "Paged Article {$index}",
|
||||
'slug' => "paged-article-{$index}",
|
||||
'excerpt' => 'Paginated newsroom article.',
|
||||
'content' => 'Content',
|
||||
'author_id' => $author->id,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsIndex')
|
||||
->where('listing.meta.current_page', 1)
|
||||
->where('listing.meta.last_page', 2)
|
||||
->where('listing.meta.total', 16)
|
||||
->has('listing.items', 15));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.index', ['page' => 2]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsIndex')
|
||||
->where('listing.meta.current_page', 2)
|
||||
->where('listing.meta.last_page', 2)
|
||||
->where('listing.meta.total', 16)
|
||||
->has('listing.items', 1));
|
||||
});
|
||||
|
||||
it('stores a newsroom draft with taxonomy links', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
@@ -220,6 +292,8 @@ it('stores a newsroom draft with taxonomy links', function (): void {
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
expect($article->canonical_url)->toBe(route('news.show', ['slug' => 'stored-newsroom-draft']));
|
||||
|
||||
expect($article->tags()->pluck('news_tags.name')->all())
|
||||
->toContain('Update')
|
||||
->toContain('Studio Exclusive');
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders the tag page with correct title and canonical', function (): void {
|
||||
$tag = Tag::factory()->create(['name' => 'Cyberpunk', 'slug' => 'cyberpunk', 'is_active' => true]);
|
||||
@@ -61,3 +69,61 @@ it('supports sort parameter without error', function (): void {
|
||||
$this->get("/tag/space?sort={$sort}")->assertOk();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps tag page artwork relation queries bounded when the gallery is populated', function (): void {
|
||||
$tag = Tag::factory()->create(['name' => 'Pixel Art', 'slug' => 'pixel-art', 'is_active' => true]);
|
||||
|
||||
$author = User::factory()->create([
|
||||
'name' => 'Pixel Artist',
|
||||
'username' => 'pixelartist',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
'description' => 'Photography content',
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Abstract',
|
||||
'slug' => 'abstract-pixel-art',
|
||||
'description' => 'Abstract works',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
foreach (range(1, 12) as $index) {
|
||||
$artwork = Artwork::factory()->for($author)->create([
|
||||
'title' => 'Pixel Art ' . $index,
|
||||
'slug' => 'pixel-art-' . $index,
|
||||
'published_at' => now()->subMinutes($index),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
$artwork->tags()->attach($tag->id, ['source' => 'user', 'confidence' => 1]);
|
||||
}
|
||||
|
||||
$categoryQueryCount = 0;
|
||||
$userQueryCount = 0;
|
||||
|
||||
DB::listen(function ($query) use (&$categoryQueryCount, &$userQueryCount): void {
|
||||
if (preg_match('/\bfrom\s+["`\[]?categories\b/i', $query->sql) === 1 || preg_match('/\bfrom\s+["`\[]?category_content_type\b/i', $query->sql) === 1) {
|
||||
$categoryQueryCount++;
|
||||
}
|
||||
|
||||
if (preg_match('/\bfrom\s+["`\[]?users\b/i', $query->sql) === 1) {
|
||||
$userQueryCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/tag/pixel-art')
|
||||
->assertOk()
|
||||
->assertSee('Pixel Art', false);
|
||||
|
||||
expect($categoryQueryCount)->toBeLessThanOrEqual(4);
|
||||
expect($userQueryCount)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
69
tests/Feature/Web/FeaturedArtworksPageTest.php
Normal file
69
tests/Feature/Web/FeaturedArtworksPageTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders featured artworks without lazy loading category content types', function (): void {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art content type',
|
||||
'order' => 1,
|
||||
'hide_from_menu' => false,
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Featured Category',
|
||||
'slug' => 'featured-category',
|
||||
'description' => 'Featured category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Featured Route Artwork',
|
||||
'slug' => 'featured-route-artwork-' . Str::lower((string) Str::uuid()),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
'has_missing_thumbnails' => false,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'priority' => 100,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
Model::preventLazyLoading();
|
||||
|
||||
try {
|
||||
$this->withoutExceptionHandling()
|
||||
->get(route('featured'))
|
||||
->assertOk()
|
||||
->assertSee('Featured Route Artwork');
|
||||
} finally {
|
||||
Model::preventLazyLoading(false);
|
||||
}
|
||||
});
|
||||
112
tests/Feature/WorldWebStoriesTest.php
Normal file
112
tests/Feature/WorldWebStoriesTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\World;
|
||||
use App\Models\WorldWebStory;
|
||||
use App\Models\WorldWebStoryPage;
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
|
||||
it('renders a published world web story as an amp story document', function (): void {
|
||||
$world = World::factory()->current()->create([
|
||||
'title' => 'Fantasy Realms',
|
||||
'slug' => 'fantasy-realms-world',
|
||||
]);
|
||||
|
||||
$story = WorldWebStory::factory()->visible()->for($world)->create([
|
||||
'slug' => 'fantasy-realms',
|
||||
'title' => 'Fantasy Realms',
|
||||
'excerpt' => 'A cinematic journey through magical wallpapers, enchanted landscapes, and dreamlike digital art.',
|
||||
'poster_portrait_path' => 'https://files.skinbase.org/web-stories/worlds/fantasy-realms/poster-portrait.webp',
|
||||
'publisher_logo_path' => 'https://cdn.skinbase.org/images/skinbase_logo_96.webp',
|
||||
]);
|
||||
|
||||
foreach (range(1, 5) as $position) {
|
||||
WorldWebStoryPage::factory()->for($story, 'story')->create([
|
||||
'position' => $position,
|
||||
'headline' => 'Page ' . $position,
|
||||
'background_path' => 'https://files.skinbase.org/web-stories/worlds/fantasy-realms/pages/page-0' . $position . '.webp',
|
||||
'background_mobile_path' => 'https://files.skinbase.org/web-stories/worlds/fantasy-realms/pages/page-0' . $position . '.webp',
|
||||
'alt_text' => 'Page ' . $position,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->get(route('web-stories.show', ['slug' => $story->slug]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('<html amp', false);
|
||||
$response->assertSee('<amp-story', false);
|
||||
$response->assertSee('publisher-logo-src="https://cdn.skinbase.org/images/skinbase_logo_96.webp"', false);
|
||||
$response->assertSee('poster-portrait-src="https://files.skinbase.org/web-stories/worlds/fantasy-realms/poster-portrait.webp"', false);
|
||||
$response->assertSee('<link rel="canonical" href="' . route('web-stories.show', ['slug' => $story->slug]) . '">', false);
|
||||
$response->assertDontSee('noindex,follow', false);
|
||||
expect(substr_count((string) $response->getContent(), '<amp-story-page '))->toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('does not expose draft world web stories publicly', function (): void {
|
||||
$world = World::factory()->current()->create();
|
||||
$story = WorldWebStory::factory()->for($world)->create([
|
||||
'slug' => 'draft-world-story',
|
||||
'status' => WorldWebStory::STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
$this->get(route('web-stories.show', ['slug' => $story->slug]))->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders the web stories index with published stories only', function (): void {
|
||||
$world = World::factory()->current()->create([
|
||||
'title' => 'Spring Vibes',
|
||||
'slug' => 'spring-vibes-world',
|
||||
]);
|
||||
|
||||
$visible = WorldWebStory::factory()->visible()->for($world)->create([
|
||||
'slug' => 'spring-vibes',
|
||||
'title' => 'Spring Vibes',
|
||||
]);
|
||||
|
||||
$draft = WorldWebStory::factory()->for($world)->create([
|
||||
'slug' => 'hidden-story',
|
||||
'title' => 'Hidden Story',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('web-stories.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee($visible->title);
|
||||
$response->assertDontSee($draft->title);
|
||||
});
|
||||
|
||||
it('includes visible web stories in the dedicated sitemap only', function (): void {
|
||||
$world = World::factory()->current()->create();
|
||||
|
||||
$visible = WorldWebStory::factory()->visible()->for($world)->create([
|
||||
'slug' => 'hello-again',
|
||||
]);
|
||||
|
||||
WorldWebStory::factory()->published()->for($world)->create([
|
||||
'slug' => 'noindex-story',
|
||||
'noindex' => true,
|
||||
]);
|
||||
|
||||
WorldWebStory::factory()->for($world)->create([
|
||||
'slug' => 'draft-story',
|
||||
]);
|
||||
|
||||
$built = app(SitemapBuildService::class)->buildNamed('web-stories', force: true, persist: false);
|
||||
|
||||
expect($built)->not->toBeNull();
|
||||
|
||||
$path = public_path('sitemaps/web-stories.xml');
|
||||
if (! is_dir(dirname($path))) {
|
||||
mkdir(dirname($path), 0777, true);
|
||||
}
|
||||
file_put_contents($path, $built['content']);
|
||||
|
||||
$this->get('/sitemaps/web-stories.xml')->assertOk();
|
||||
|
||||
$xml = file_get_contents($path);
|
||||
|
||||
expect(str_contains($xml, route('web-stories.show', ['slug' => $visible->slug])))->toBeTrue();
|
||||
expect(str_contains($xml, 'noindex-story'))->toBeFalse();
|
||||
expect(str_contains($xml, 'draft-story'))->toBeFalse();
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\WorldWebStory;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
@@ -152,6 +153,26 @@ it('includes rewarded contributors on public world pages', function (): void {
|
||||
->where('rewardedContributors.items.0.badge_label', $world->title . ' Winner'));
|
||||
});
|
||||
|
||||
it('exposes a published web story on the public world page payload', function (): void {
|
||||
$world = publicWorld([
|
||||
'title' => 'Fantasy Realms',
|
||||
'slug' => 'fantasy-realms',
|
||||
]);
|
||||
|
||||
$story = WorldWebStory::factory()->visible()->for($world)->create([
|
||||
'slug' => 'fantasy-realms',
|
||||
'title' => 'Fantasy Realms',
|
||||
'excerpt' => 'A cinematic journey through magical wallpapers and dreamlike digital art.',
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('webStory.slug', $story->slug)
|
||||
->where('webStory.url', route('web-stories.show', ['slug' => $story->slug])));
|
||||
});
|
||||
|
||||
it('renders recap payloads for ended worlds with published recaps', function (): void {
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'recapcreator-' . Str::lower(Str::random(6)),
|
||||
|
||||
Reference in New Issue
Block a user