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('')
|
||||
|
||||
101
tests/Unit/Academy/AcademyBillingPlanServiceTest.php
Normal file
101
tests/Unit/Academy/AcademyBillingPlanServiceTest.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
28
tests/Unit/Jobs/RecComputeSimilarByTagsJobTest.php
Normal file
28
tests/Unit/Jobs/RecComputeSimilarByTagsJobTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
35
tests/Unit/Jobs/RecComputeSimilarHybridJobTest.php
Normal file
35
tests/Unit/Jobs/RecComputeSimilarHybridJobTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user