Optimize academy
This commit is contained in:
@@ -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
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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('')
|
||||
|
||||
Reference in New Issue
Block a user