Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -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));
}