Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user