Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Tests\Feature\Academy;
use App\Mail\AcademyAccessIssue;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Models\StaffApplication;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Inertia\Testing\AssertableInertia;
use Laravel\Cashier\SubscriptionBuilder;
use Mockery;
@@ -182,6 +185,49 @@ final class AcademyBillingCheckoutTest extends TestCase
->assertRedirect(route('academy.billing.portal'));
}
public function test_support_report_sends_mail_immediately_and_stores_record(): void
{
Mail::fake();
config()->set('mail.from.address', 'info@skinbase.org');
$user = User::factory()->create([
'email_verified_at' => now(),
'name' => 'Billing Tester',
'email' => 'tester@example.com',
]);
$this->actingAs($user)
->from(route('academy.billing.account'))
->post(route('academy.billing.report_issue'), [
'issue_type' => 'access',
'contact_email' => 'reply@example.com',
'session_id' => 'cs_test_123',
'message' => 'I paid but access did not update.',
])
->assertRedirect(route('academy.billing.account'))
->assertSessionHas('success', 'Support request sent — we will verify and activate your access shortly.');
Mail::assertSent(AcademyAccessIssue::class, function (AcademyAccessIssue $mail) use ($user): bool {
return $mail->user->is($user)
&& $mail->issueType === 'access'
&& $mail->contactEmail === 'reply@example.com'
&& $mail->sessionId === 'cs_test_123'
&& $mail->message === 'I paid but access did not update.';
});
$this->assertDatabaseHas('staff_applications', [
'topic' => 'contact',
'email' => 'reply@example.com',
'role' => 'academy_billing_support',
]);
$application = StaffApplication::query()->latest('created_at')->first();
$this->assertNotNull($application);
$this->assertSame('academy_billing', data_get($application?->payload, 'data.source'));
$this->assertSame('access', data_get($application?->payload, 'data.issue_type'));
}
private function configureBilling(): void
{
config()->set('academy.enabled', true);
@@ -209,4 +255,4 @@ final class AcademyBillingCheckoutTest extends TestCase
],
]);
}
}
}

View File

@@ -1654,6 +1654,61 @@ final class AcademyFeatureTest extends TestCase
}
}
public function test_filled_examples_are_visible_only_to_pro_and_staff_users(): void
{
$prompt = AcademyPromptTemplate::query()->create([
'title' => 'Filled Example Prompt',
'slug' => 'filled-example-prompt',
'excerpt' => 'Prompt with pro-only filled examples.',
'prompt' => 'Base prompt body available to free users.',
'difficulty' => 'beginner',
'access_level' => 'free',
'filled_examples' => [[
'title' => 'Mountain lake sunrise',
'description' => 'A filled example for scenic output.',
'placeholder_values' => [
'LOCATION' => 'Lake Bled',
],
'prompt' => 'Create a sunrise landscape of Lake Bled with calm reflections and alpine light.',
'negative_prompt' => 'muddy water, flat light',
]],
'active' => true,
'published_at' => now()->subMinute(),
]);
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('item.has_filled_examples', true)
->where('item.can_access_filled_examples', false)
->where('item.filled_examples', []));
$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.has_filled_examples', true)
->where('item.can_access_filled_examples', false)
->where('item.filled_examples', []));
foreach ([
User::factory()->create(['role' => 'academy_pro']),
User::factory()->create(['role' => 'admin']),
] as $user) {
$this->actingAs($user)
->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('item.has_filled_examples', true)
->where('item.can_access_filled_examples', true)
->where('item.filled_examples.0.title', 'Mountain lake sunrise')
->where('item.filled_examples.0.placeholder_values.LOCATION', 'Lake Bled')
->where('item.filled_examples.0.prompt', 'Create a sunrise landscape of Lake Bled with calm reflections and alpine light.'));
}
}
public function test_challenge_routes_are_hidden_when_challenges_are_disabled(): void
{
config(['academy.challenges_enabled' => false]);

View File

@@ -631,6 +631,18 @@ final class AcademyAdminTest extends TestCase
'risk_notes' => ['Climate icons may still be abstract'],
],
],
'filled_examples' => [
[
'title' => 'Paris spring editorial poster',
'description' => 'Filled example for a travel poster run.',
'placeholder_values' => [
'CITY_NAME' => 'Paris',
'WEATHER_STYLE' => 'mild spring light',
],
'prompt' => 'Create a Paris travel poster with mild spring light and editorial composition.',
'negative_prompt' => 'muddy weather, cluttered text',
],
],
'difficulty' => 'intermediate',
'access_level' => 'creator',
'aspect_ratio' => '16:9',
@@ -653,6 +665,9 @@ final class AcademyAdminTest extends TestCase
$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->assertSame('Paris spring editorial poster', $prompt->filled_examples[0]['title'] ?? null);
$this->assertSame('Paris', $prompt->filled_examples[0]['placeholder_values']['CITY_NAME'] ?? null);
$this->assertSame('Create a Paris travel poster with mild spring light and editorial composition.', $prompt->filled_examples[0]['prompt'] ?? null);
$this->actingAs($admin)
->get(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]))
@@ -703,6 +718,18 @@ final class AcademyAdminTest extends TestCase
'risk_notes' => ['Climate icons may still be abstract'],
'active' => true,
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
->where('record.filled_examples', json_encode([
[
'title' => 'Paris spring editorial poster',
'description' => 'Filled example for a travel poster run.',
'placeholder_values' => [
'CITY_NAME' => 'Paris',
'WEATHER_STYLE' => 'mild spring light',
],
'prompt' => 'Create a Paris travel poster with mild spring light and editorial composition.',
'negative_prompt' => 'muddy weather, cluttered text',
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)));
}

View File

@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Http;
it('calls CLIP analyze and attaches AI tags', function () {
config()->set('vision.enabled', true);
config()->set('vision.auto_tagging.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.enabled', false);
@@ -34,6 +35,7 @@ it('calls CLIP analyze and attaches AI tags', function () {
it('optionally calls YOLO for photography', function () {
config()->set('vision.enabled', true);
config()->set('vision.auto_tagging.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.base_url', 'https://yolo.local');
@@ -61,6 +63,7 @@ it('optionally calls YOLO for photography', function () {
it('does not throw on CLIP 4xx and never blocks publish', function () {
config()->set('vision.enabled', true);
config()->set('vision.auto_tagging.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.enabled', false);
@@ -80,6 +83,7 @@ it('does not throw on CLIP 4xx and never blocks publish', function () {
it('persists clip tags blip caption and yolo objects from the unified gateway response', function () {
config()->set('vision.enabled', true);
config()->set('vision.auto_tagging.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');

View File

@@ -179,8 +179,8 @@ test('upload finish updates queue item when batch item id is supplied', function
$item->refresh();
expect($item->status)->toBe('processing')
->and($item->processing_stage)->toBe('maturity_check');
expect($item->status)->toBe('needs_metadata')
->and($item->processing_stage)->toBe('finalized');
});
test('upload queue bulk publish only publishes ready items', function () {
@@ -394,6 +394,8 @@ test('upload queue item failure does not break the rest of the batch', function
});
test('upload queue processing states update correctly per item', function () {
config()->set('vision.upload.maturity.enabled', true);
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Processing batch',
@@ -439,6 +441,8 @@ test('upload queue processing states update correctly per item', function () {
});
test('upload queue publish readiness respects metadata and maturity review rules', function () {
config()->set('vision.upload.maturity.enabled', true);
$category = uploadQueueCategory();
$batch = UploadBatch::query()->create([
@@ -580,21 +584,23 @@ test('upload queue retry works for safe failure cases', function () {
'error_message' => 'Vision analysis timed out.',
]);
config()->set('vision.auto_tagging.enabled', false);
$this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry')
->assertOk()
->assertJsonPath('ok', true);
$item->refresh();
expect($item->status)->toBe('processing')
->and($item->processing_stage)->toBe('maturity_check')
expect($item->status)->toBe('ready')
->and($item->processing_stage)->toBe('finalized')
->and($item->error_code)->toBeNull()
->and($item->error_message)->toBeNull();
Queue::assertPushed(AutoTagArtworkJob::class);
Queue::assertPushed(DetectArtworkMaturityJob::class);
Queue::assertNotPushed(AutoTagArtworkJob::class);
Queue::assertNotPushed(DetectArtworkMaturityJob::class);
Queue::assertPushed(GenerateArtworkEmbeddingJob::class);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class);
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
});
test('upload queue AI generation does not overwrite manual metadata silently', function () {

View File

@@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Models\Artwork;
use App\Models\User;
@@ -16,10 +18,11 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('dispatches AI processing jobs after upload finish publishes successfully', function () {
it('dispatches non-autotag AI processing jobs after upload finish publishes successfully', function () {
config()->set('forum_bot_protection.enabled', false);
config()->set('uploads.queue_derivatives', false);
config()->set('uploads.storage_root', storage_path('framework/testing/uploads'));
config()->set('vision.auto_tagging.enabled', false);
Queue::fake();
File::deleteDirectory((string) config('uploads.storage_root'));
@@ -60,8 +63,10 @@ it('dispatches AI processing jobs after upload finish publishes successfully', f
$response->assertJsonPath('artwork_id', $artwork->id);
$response->assertJsonPath('status', UploadSessionStatus::PROCESSED);
Queue::assertPushed(AutoTagArtworkJob::class, 1);
Queue::assertNotPushed(AutoTagArtworkJob::class);
Queue::assertNotPushed(DetectArtworkMaturityJob::class);
Queue::assertPushed(GenerateArtworkEmbeddingJob::class, 1);
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
$artwork->refresh();
expect($artwork->hash)->not->toBe('')

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Academy;
use App\Services\Academy\AcademyBillingPlanService;
use ReflectionMethod;
use Tests\TestCase;
final class AcademyBillingPlanServiceTest extends TestCase
{
private array $originalEnv = [];
protected function tearDown(): void
{
$this->restoreEnv('STRIPE_SECRET');
parent::tearDown();
}
public function test_stripe_secret_ignores_non_string_cashier_secret_and_falls_back_to_env(): void
{
config()->set('cashier.secret', ['secret' => 'invalid']);
$this->setEnv('STRIPE_SECRET', ' sk_test_fallback ');
$service = new AcademyBillingPlanService();
$this->assertSame('sk_test_fallback', $this->invokeStripeSecret($service));
}
public function test_stripe_secret_returns_null_when_no_string_secret_is_available(): void
{
config()->set('cashier.secret', ['secret' => 'invalid']);
$this->setEnv('STRIPE_SECRET', null);
$service = new AcademyBillingPlanService();
$this->assertNull($this->invokeStripeSecret($service));
}
private function invokeStripeSecret(AcademyBillingPlanService $service): ?string
{
$method = new ReflectionMethod($service, 'stripeSecret');
$method->setAccessible(true);
/** @var ?string $secret */
$secret = $method->invoke($service);
return $secret;
}
private function setEnv(string $key, ?string $value): void
{
if (! array_key_exists($key, $this->originalEnv)) {
$this->originalEnv[$key] = [
'server' => $_SERVER[$key] ?? null,
'env' => $_ENV[$key] ?? null,
'putenv' => getenv($key) === false ? null : getenv($key),
];
}
if ($value === null) {
putenv($key);
unset($_SERVER[$key], $_ENV[$key]);
return;
}
putenv($key.'='.$value);
$_SERVER[$key] = $value;
$_ENV[$key] = $value;
}
private function restoreEnv(string $key): void
{
if (! array_key_exists($key, $this->originalEnv)) {
return;
}
$original = $this->originalEnv[$key];
if ($original['putenv'] === null) {
putenv($key);
} else {
putenv($key.'='.$original['putenv']);
}
if ($original['server'] === null) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $original['server'];
}
if ($original['env'] === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $original['env'];
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs;
use App\Jobs\RecComputeSimilarByTagsJob;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
final class RecComputeSimilarByTagsJobTest extends TestCase
{
public function test_artwork_specific_jobs_use_without_overlapping_middleware(): void
{
$job = new RecComputeSimilarByTagsJob(123);
$middleware = $job->middleware();
$this->assertCount(1, $middleware);
$this->assertInstanceOf(WithoutOverlapping::class, $middleware[0]);
}
public function test_batch_jobs_do_not_use_without_overlapping_middleware(): void
{
$job = new RecComputeSimilarByTagsJob(null, 200);
$this->assertSame([], $job->middleware());
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Jobs;
use App\Jobs\RecComputeSimilarHybridJob;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
final class RecComputeSimilarHybridJobTest extends TestCase
{
public function test_job_uses_single_attempt_to_avoid_retry_loop_failures(): void
{
$job = new RecComputeSimilarHybridJob(123);
$this->assertSame(1, $job->tries);
}
public function test_artwork_specific_jobs_use_without_overlapping_middleware(): void
{
$job = new RecComputeSimilarHybridJob(123);
$middleware = $job->middleware();
$this->assertCount(1, $middleware);
$this->assertInstanceOf(WithoutOverlapping::class, $middleware[0]);
}
public function test_batch_jobs_do_not_use_without_overlapping_middleware(): void
{
$job = new RecComputeSimilarHybridJob(null, 200);
$this->assertSame([], $job->middleware());
}
}