Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -10,6 +10,7 @@ use App\Models\AcademyCourse;
use App\Models\AcademyEvent;
use App\Models\AcademyLesson;
use App\Models\AcademyLike;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
@@ -23,6 +24,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
final class AcademyAnalyticsTest extends TestCase
@@ -406,6 +408,80 @@ final class AcademyAnalyticsTest extends TestCase
]);
}
public function test_prompt_library_index_exposes_dedicated_page_analytics_content_type(): void
{
$this->createPrompt('library-analytics-prompt');
$this->get(route('academy.prompts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.contentType', AcademyAnalyticsContentType::PROMPT_LIBRARY)
->where('analytics.contentId', null)
->where('analytics.pageName', 'academy_prompts_index')
);
}
public function test_popular_prompts_page_exposes_dedicated_page_analytics_content_type(): void
{
$prompt = $this->createPrompt('popular-page-prompt');
$this->createMetric(AcademyAnalyticsContentType::PROMPT, $prompt->id, [
'views' => 18,
'prompt_copies' => 4,
'popularity_score' => 51.2,
]);
$this->get(route('academy.prompts.popular'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.contentType', AcademyAnalyticsContentType::PROMPT_POPULAR)
->where('analytics.contentId', null)
->where('analytics.pageName', 'academy_prompts_popular')
);
}
public function test_popular_prompts_page_exposes_selected_period_in_analytics_metadata(): void
{
$prompt = $this->createPrompt('popular-period-analytics-prompt');
$this->createMetric(AcademyAnalyticsContentType::PROMPT, $prompt->id, [
'views' => 28,
'prompt_copies' => 6,
'popularity_score' => 64.4,
]);
$this->get(route('academy.prompts.popular', ['period' => '7d']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.metadata.period', '7d')
->where('analytics.metadata.period_days', 7)
->where('analytics.trackingKey', 'period:7d')
);
}
public function test_prompt_pack_library_index_exposes_dedicated_page_analytics_content_type(): void
{
AcademyPromptPack::query()->create([
'title' => 'Starter Pack',
'slug' => 'starter-pack',
'excerpt' => 'A free pack.',
'description' => 'Pack description.',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$this->get(route('academy.packs.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.contentType', AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY)
->where('analytics.contentId', null)
->where('analytics.pageName', 'academy_packs_index')
);
}
public function test_rollup_counts_search_result_clicks_for_clicked_content(): void
{
$prompt = $this->createPrompt('rollup-search-click-prompt');
@@ -948,6 +1024,151 @@ final class AcademyAnalyticsTest extends TestCase
$this->assertSame(20.0, (float) $metric->conversion_score);
}
public function test_admin_prompt_library_analytics_page_uses_dedicated_prompt_library_filter(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->get(route('admin.academy.analytics.prompt-library'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsContent')
->where('title', 'Prompt library analytics')
->where('filters.content_type', AcademyAnalyticsContentType::PROMPT_LIBRARY)
->where('contentTypeOptions', fn ($options): bool => collect($options)->contains(
fn (array $option): bool => ($option['value'] ?? null) === AcademyAnalyticsContentType::PROMPT_LIBRARY
&& ($option['label'] ?? null) === 'Prompt library'
))
);
}
public function test_admin_prompt_library_analytics_page_includes_prompt_library_summary(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->createMetric(AcademyAnalyticsContentType::PROMPT_LIBRARY, null, [
'views' => 42,
'unique_visitors' => 18,
'engaged_views' => 9,
'scroll_50' => 12,
'scroll_100' => 4,
'avg_engaged_seconds' => 31,
]);
$this->actingAs($admin)
->get(route('admin.academy.analytics.prompt-library'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsContent')
->where('summary.views', 42)
->where('summary.uniqueVisitors', 18)
->where('summary.engagedViews', 9)
->where('summary.avgEngagedSeconds', 31)
->where('summary.engagementRate', 50)
->where('summary.deepScrollRate', 22.2)
);
}
public function test_admin_analytics_overview_includes_prompt_library_trend(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$today = now()->toDateString();
$yesterday = now()->subDay()->toDateString();
$this->createMetric(AcademyAnalyticsContentType::PROMPT_LIBRARY, null, [
'date' => $today,
'views' => 40,
'unique_visitors' => 20,
'engaged_views' => 10,
'popularity_score' => 55,
]);
$this->createMetric(AcademyAnalyticsContentType::PROMPT_LIBRARY, null, [
'date' => $yesterday,
'views' => 20,
'unique_visitors' => 10,
'engaged_views' => 5,
'popularity_score' => 25,
]);
$this->actingAs($admin)
->get(route('admin.academy.analytics.overview', [
'range' => 'custom',
'from' => $today,
'to' => $today,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsOverview')
->where('promptLibraryTrend.current.views', 40)
->where('promptLibraryTrend.current.uniqueVisitors', 20)
->where('promptLibraryTrend.current.engagementRate', 50)
->where('promptLibraryTrend.previous.views', 20)
->where('promptLibraryTrend.deltas.views', 100)
->where('promptLibraryTrend.deltas.engagementRate', 0)
);
}
public function test_admin_analytics_overview_includes_popular_prompt_period_usage(): void
{
$admin = User::factory()->create(['role' => 'admin']);
AcademyEvent::query()->create([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'visitor_id' => 'visitor-30-a',
'is_logged_in' => false,
'is_subscriber' => false,
'is_admin' => false,
'is_bot' => false,
'is_crawler' => false,
'is_suspicious' => false,
'metadata' => ['period' => '30d', 'period_days' => 30],
'occurred_at' => now()->subHour(),
]);
AcademyEvent::query()->create([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'visitor_id' => 'visitor-30-b',
'is_logged_in' => false,
'is_subscriber' => false,
'is_admin' => false,
'is_bot' => false,
'is_crawler' => false,
'is_suspicious' => false,
'metadata' => ['period' => '30d', 'period_days' => 30],
'occurred_at' => now()->subMinutes(30),
]);
AcademyEvent::query()->create([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'visitor_id' => 'visitor-7-a',
'is_logged_in' => false,
'is_subscriber' => false,
'is_admin' => false,
'is_bot' => false,
'is_crawler' => false,
'is_suspicious' => false,
'metadata' => ['period' => '7d', 'period_days' => 7],
'occurred_at' => now()->subMinutes(10),
]);
$this->actingAs($admin)
->get(route('admin.academy.analytics.overview'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsOverview')
->where('popularPromptPeriodUsage.totalViews', 3)
->where('popularPromptPeriodUsage.totalVisitors', 3)
->where('popularPromptPeriodUsage.periods.0.period', '30d')
->where('popularPromptPeriodUsage.periods.0.views', 2)
->where('popularPromptPeriodUsage.periods.0.share', 66.7)
->where('popularPromptPeriodUsage.periods.1.period', '7d')
->where('popularPromptPeriodUsage.periods.1.views', 1));
}
public function test_prune_events_command_removes_only_old_raw_events(): void
{
$oldEvent = AcademyEvent::query()->create([

View File

@@ -8,6 +8,7 @@ use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyChallenge;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseEnrollment;
use App\Models\AcademyCourseLesson;
@@ -15,15 +16,19 @@ use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonProgress;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
use Laravel\Cashier\Subscription;
use Laravel\Cashier\SubscriptionItem;
use Tests\TestCase;
final class AcademyFeatureTest extends TestCase
@@ -43,7 +48,53 @@ final class AcademyFeatureTest extends TestCase
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/Index')
->where('featureFlags.paymentsEnabled', false));
->where('links.promptPopular', route('academy.prompts.popular'))
->where('academyAccess.signedIn', false)
->where('academyAccess.status', 'guest')
->where('academyAccess.billingUrl', route('academy.pricing')));
}
public function test_academy_homepage_exposes_access_summary_for_active_paid_user(): void
{
config()->set('academy_billing.plans', [
'pro_monthly' => [
'tier' => 'pro',
'stripe_price_id' => 'price_pro_test',
],
]);
$user = User::factory()->create();
$subscription = Subscription::query()->create([
'user_id' => $user->id,
'type' => 'academy',
'stripe_id' => 'sub_pro_test',
'stripe_status' => 'active',
'stripe_price' => 'price_pro_test',
'quantity' => 1,
'ends_at' => null,
]);
SubscriptionItem::query()->create([
'subscription_id' => $subscription->id,
'stripe_id' => 'si_pro_test',
'stripe_product' => 'prod_pro_test',
'stripe_price' => 'price_pro_test',
'quantity' => 1,
]);
$this->actingAs($user)
->get('/academy')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/Index')
->where('academyAccess.signedIn', true)
->where('academyAccess.tier', 'pro')
->where('academyAccess.tierLabel', 'Pro')
->where('academyAccess.status', 'active')
->where('academyAccess.statusLabel', 'Renews automatically')
->where('academyAccess.renewsAutomatically', true)
->where('academyAccess.billingUrl', route('academy.billing.account'))
->where('academyAccess.source', 'subscription'));
}
public function test_academy_routes_are_hidden_when_feature_is_disabled(): void
@@ -1149,6 +1200,271 @@ final class AcademyFeatureTest extends TestCase
]);
}
public function test_prompt_library_index_exposes_breadcrumbs_and_discovery_payloads(): void
{
$featured = AcademyPromptTemplate::query()->create([
'title' => 'Featured Prompt',
'slug' => 'featured-prompt',
'excerpt' => 'Featured prompt excerpt.',
'prompt' => 'Featured prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'featured' => true,
'active' => true,
'published_at' => now()->subMinute(),
]);
$popular = AcademyPromptTemplate::query()->create([
'title' => 'Popular Prompt',
'slug' => 'popular-prompt',
'excerpt' => 'Popular prompt excerpt.',
'prompt' => 'Popular prompt body',
'difficulty' => 'intermediate',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(2),
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $popular->id,
'views' => 42,
'prompt_copies' => 9,
'popularity_score' => 88.5,
]);
Cache::flush();
$this->get(route('academy.prompts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('breadcrumbs.0.label', 'Academy')
->where('breadcrumbs.0.href', route('academy.index'))
->where('breadcrumbs.1.label', 'Prompt Library')
->where('coursesUrl', route('academy.courses.index'))
->where('packsUrl', route('academy.packs.index'))
->where('featuredPrompts.0.slug', $featured->slug)
->where('featuredPrompts.0.spotlight.eyebrow', 'Featured pick')
->where('popularPrompts.0.slug', $popular->slug)
->where('popularPrompts.0.spotlight.eyebrow', '9 copies this month')
->where('academyAccess.signedIn', false)
->where('academyAccess.status', 'guest')
->where('academyAccess.billingUrl', route('academy.pricing')));
}
public function test_prompt_library_index_exposes_current_access_summary_for_grace_period_subscription(): void
{
config()->set('academy_billing.plans', [
'creator_monthly' => [
'tier' => 'creator',
'stripe_price_id' => 'price_creator_test',
],
]);
$user = User::factory()->create();
$subscription = Subscription::query()->create([
'user_id' => $user->id,
'type' => 'academy',
'stripe_id' => 'sub_creator_test',
'stripe_status' => 'active',
'stripe_price' => 'price_creator_test',
'quantity' => 1,
'ends_at' => now()->addDays(12),
]);
SubscriptionItem::query()->create([
'subscription_id' => $subscription->id,
'stripe_id' => 'si_creator_test',
'stripe_product' => 'prod_creator_test',
'stripe_price' => 'price_creator_test',
'quantity' => 1,
]);
$this->actingAs($user)
->get(route('academy.prompts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('academyAccess.signedIn', true)
->where('academyAccess.tier', 'creator')
->where('academyAccess.tierLabel', 'Creator')
->where('academyAccess.status', 'grace_period')
->where('academyAccess.statusLabel', 'Cancels soon')
->where('academyAccess.dateLabel', 'Access ends')
->where('academyAccess.renewsAutomatically', false)
->where('academyAccess.source', 'subscription')
->where('academyAccess.billingUrl', route('academy.billing.account'))
->where('academyAccess.expiresAt', $subscription->ends_at?->toISOString()));
}
public function test_popular_prompts_page_displays_ranked_prompt_payloads(): void
{
$featured = AcademyPromptTemplate::query()->create([
'title' => 'Featured Prompt',
'slug' => 'featured-popular-page-prompt',
'excerpt' => 'Featured prompt excerpt.',
'prompt' => 'Featured prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'featured' => true,
'active' => true,
'published_at' => now()->subMinute(),
]);
$topPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Top Prompt',
'slug' => 'top-prompt',
'excerpt' => 'Top prompt excerpt.',
'prompt' => 'Top prompt body',
'difficulty' => 'advanced',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(2),
]);
$runnerUpPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Runner Up Prompt',
'slug' => 'runner-up-prompt',
'excerpt' => 'Runner up prompt excerpt.',
'prompt' => 'Runner up prompt body',
'difficulty' => 'intermediate',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(3),
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $topPrompt->id,
'views' => 74,
'prompt_copies' => 11,
'popularity_score' => 128.7,
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $runnerUpPrompt->id,
'views' => 48,
'prompt_copies' => 5,
'popularity_score' => 91.4,
]);
Cache::flush();
$this->get(route('academy.prompts.popular'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('promptView', 'popular')
->where('popularPeriod.value', '30d')
->where('breadcrumbs.2.label', 'Popular Prompts')
->where('promptLibraryUrl', route('academy.prompts.index'))
->where('items.data.0.slug', $topPrompt->slug)
->where('items.data.0.ranking.rank', 1)
->where('items.data.0.ranking.prompt_copies', 11)
->where('items.data.1.slug', $runnerUpPrompt->slug)
->where('items.data.1.ranking.rank', 2)
->where('popularPeriods.0.value', '7d')
->where('popularPeriods.1.active', true)
->where('featuredPrompts.0.slug', $featured->slug));
}
public function test_popular_prompts_page_can_filter_to_last_seven_days(): void
{
$recentPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Recent Prompt',
'slug' => 'recent-prompt',
'excerpt' => 'Recent prompt excerpt.',
'prompt' => 'Recent prompt body',
'difficulty' => 'advanced',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$olderPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Older Prompt',
'slug' => 'older-prompt',
'excerpt' => 'Older prompt excerpt.',
'prompt' => 'Older prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(2),
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $recentPrompt->id,
'views' => 31,
'prompt_copies' => 7,
'popularity_score' => 79.5,
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->subDays(20)->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $olderPrompt->id,
'views' => 200,
'prompt_copies' => 22,
'popularity_score' => 240.1,
]);
Cache::flush();
$this->get(route('academy.prompts.popular', ['period' => '7d']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('popularPeriod.value', '7d')
->where('popularPeriod.label', '7 days')
->where('items.data.0.slug', $recentPrompt->slug)
->where('items.data.0.spotlight.eyebrow', '7 copies in the last 7 days')
->missing('items.data.1')
->where('popularPeriods.0.active', true)
->where('popularPeriods.1.active', false));
}
public function test_prompt_pack_index_does_not_include_nested_prompts_until_pack_detail(): void
{
$prompt = AcademyPromptTemplate::query()->create([
'title' => 'Pack Prompt',
'slug' => 'pack-prompt',
'excerpt' => 'Prompt excerpt.',
'prompt' => 'Pack prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$pack = AcademyPromptPack::query()->create([
'title' => 'Starter Prompt Pack',
'slug' => 'starter-prompt-pack',
'excerpt' => 'Pack excerpt.',
'description' => 'Pack description.',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$pack->prompts()->attach($prompt->id, ['order_num' => 0]);
$this->get(route('academy.packs.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('items.data.0.slug', 'starter-prompt-pack')
->where('items.data.0.prompts', [])
->where('analytics.contentType', 'academy_prompt_pack_library')
);
}
public function test_logged_in_user_can_submit_artwork_to_active_challenge(): void
{
$user = User::factory()->create();

View File

@@ -14,7 +14,7 @@ it('creates upload drafts as private artworks', function (): void {
$response = postJson('/api/artworks', [
'title' => 'Upload draft test',
'description' => '<p>Draft body</p>',
'description' => 'Draft body',
'is_mature' => false,
]);
@@ -28,4 +28,17 @@ it('creates upload drafts as private artworks', function (): void {
->and($artwork->is_public)->toBeFalse()
->and($artwork->artwork_status)->toBe('draft')
->and($artwork->published_at)->toBeNull();
});
it('rejects upload drafts with raw html in the description', function (): void {
$user = User::factory()->create();
actingAs($user);
postJson('/api/artworks', [
'title' => 'Upload draft test',
'description' => '<img src="https://spam.example/test.jpg" alt="">',
'is_mature' => false,
])->assertStatus(422)
->assertJsonValidationErrors(['description']);
});

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('rejects raw html when updating artwork descriptions from the dashboard editor', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->create([
'title' => 'Dashboard Artwork',
'slug' => 'dashboard-artwork',
'description' => 'Original description',
]);
$this->from(route('dashboard.artworks.edit', ['id' => $artwork->id]))
->actingAs($user)
->put(route('dashboard.artworks.update', ['id' => $artwork->id]), [
'title' => 'Dashboard Artwork',
'description' => '<img src="https://spam.example/test.jpg" alt="">',
])
->assertRedirect(route('dashboard.artworks.edit', ['id' => $artwork->id]))
->assertSessionHasErrors(['description']);
expect($artwork->fresh()->description)->toBe('Original description');
});

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
use Illuminate\Support\Facades\Validator;
it('rejects raw html in the legacy manage artwork update request', function (): void {
$request = ManageArtworkUpdateRequest::create('/manage/123', 'POST', [
'title' => 'Legacy Manage Artwork',
'description' => '<img src="https://spam.example/test.jpg" alt="">',
]);
$validator = Validator::make($request->all(), $request->rules());
$request->withValidator($validator);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('description'))->toBeTrue();
});

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
});
it('redirects guests away from enhance pages', function (): void {
$this->get(route('enhance.index'))->assertRedirect(route('login'));
$this->get(route('enhance.create'))->assertRedirect(route('login'));
});
it('prevents a user from viewing another users enhance job', function (): void {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$this->actingAs($intruder)
->get(route('enhance.show', ['enhanceJob' => $job]))
->assertForbidden();
});
it('shows the selected artwork source on the create page for the owner', function (): void {
$owner = User::factory()->create();
$artwork = Artwork::factory()->for($owner)->create();
$this->actingAs($owner)
->get(route('enhance.create', ['artwork' => $artwork->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Enhance/Create')
->where('selectedArtwork.id', $artwork->id)
->where('selectedArtwork.title', $artwork->title)
->where('selectedArtwork.store_url', route('artworks.enhance.store', ['artwork' => $artwork->id]))
);
});
it('returns a validation error when an artwork source is unavailable', function (): void {
$owner = User::factory()->create();
$artwork = Artwork::factory()->for($owner)->create([
'file_path' => 'uploads/artworks/missing.jpg',
'hash' => null,
'file_ext' => null,
'mime_type' => 'image/jpeg',
]);
$this->from(route('enhance.create', ['artwork' => $artwork->id]))
->actingAs($owner)
->post(route('artworks.enhance.store', ['artwork' => $artwork->id]), [
'scale' => 2,
'mode' => 'standard',
])
->assertRedirect(route('enhance.create', ['artwork' => $artwork->id]))
->assertSessionHasErrors([
'source' => 'Artwork source file is unavailable for enhance.',
]);
expect(EnhanceJob::query()->count())->toBe(0);
});
it('allows an owner to delete a completed job and its files', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/1/source.png', UploadedFile::fake()->image('source.png')->get());
Storage::disk('public')->put('enhance/outputs/1/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/1/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/1/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/1/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/1/preview.webp',
]);
$this->actingAs($owner)
->delete(route('enhance.destroy', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.index'));
$deletedJob = EnhanceJob::withTrashed()->find($job->id);
expect($deletedJob)->not->toBeNull();
expect($deletedJob->trashed())->toBeTrue();
Storage::disk('public')->assertMissing('enhance/sources/1/source.png');
Storage::disk('public')->assertMissing('enhance/outputs/1/output.webp');
Storage::disk('public')->assertMissing('enhance/previews/1/preview.webp');
});
it('allows an owner to retry a failed job without deleting the source', function (): void {
Queue::fake();
$owner = User::factory()->create();
$source = UploadedFile::fake()->image('source.png', 300, 300);
Storage::disk('public')->put('enhance/sources/1/source.png', $source->get());
Storage::disk('public')->put('enhance/outputs/1/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/1/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/1/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/1/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/1/preview.webp',
'error_message' => 'Example failure',
]);
$this->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
expect($job->output_path)->toBeNull();
expect($job->preview_path)->toBeNull();
Storage::disk('public')->assertExists('enhance/sources/1/source.png');
Storage::disk('public')->assertMissing('enhance/outputs/1/output.webp');
Storage::disk('public')->assertMissing('enhance/previews/1/preview.webp');
});

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
config()->set('enhance.lifecycle.cleanup_chunk_size', 10);
Storage::fake('public');
});
it('does not delete files during cleanup dry run', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/1/source.png', UploadedFile::fake()->image('source.png')->get());
Storage::disk('public')->put('enhance/outputs/1/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/1/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/1/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/1/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/1/preview.webp',
'expires_at' => now()->subMinute(),
]);
$this->artisan('enhance:cleanup --dry-run')->assertSuccessful();
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_COMPLETED);
Storage::disk('public')->assertExists('enhance/sources/1/source.png');
Storage::disk('public')->assertExists('enhance/outputs/1/output.webp');
Storage::disk('public')->assertExists('enhance/previews/1/preview.webp');
});
it('deletes expired completed job files in force mode', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/2/source.png', UploadedFile::fake()->image('source.png')->get());
Storage::disk('public')->put('enhance/outputs/2/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/2/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/2/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/2/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/2/preview.webp',
'expires_at' => now()->subMinute(),
]);
$this->artisan('enhance:cleanup --only=expired --force')->assertSuccessful();
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_EXPIRED);
expect($job->source_path)->toBeNull();
expect($job->output_path)->toBeNull();
expect($job->preview_path)->toBeNull();
expect($job->metadata['cleanup']['reason'])->toBe('expired');
Storage::disk('public')->assertMissing('enhance/sources/2/source.png');
Storage::disk('public')->assertMissing('enhance/outputs/2/output.webp');
Storage::disk('public')->assertMissing('enhance/previews/2/preview.webp');
});
it('does not delete non enhance paths during cleanup', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('uploads/artworks/unsafe.png', 'unsafe');
Storage::disk('public')->put('enhance/outputs/3/output.webp', UploadedFile::fake()->image('output.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'uploads/artworks/unsafe.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/3/output.webp',
'finished_at' => now()->subDays(10),
]);
$this->artisan('enhance:cleanup --only=failed --days=3 --force')->assertSuccessful();
$job->refresh();
expect($job->source_path)->toBe('uploads/artworks/unsafe.png');
expect($job->output_path)->toBeNull();
Storage::disk('public')->assertExists('uploads/artworks/unsafe.png');
Storage::disk('public')->assertMissing('enhance/outputs/3/output.webp');
});
it('cleans failed and soft deleted enhance files and records cleanup metadata', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/4/source.png', UploadedFile::fake()->image('source.png')->get());
$failed = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/4/source.png',
'finished_at' => now()->subDays(10),
]);
Storage::disk('public')->put('enhance/sources/5/source.png', UploadedFile::fake()->image('source.png')->get());
$deleted = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/5/source.png',
]);
$deleted->delete();
$deleted->forceFill(['deleted_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('enhance:cleanup --only=failed --days=3 --force')->assertSuccessful();
$this->artisan('enhance:cleanup --only=deleted --days=3 --force')->assertSuccessful();
$failed->refresh();
$deleted = EnhanceJob::withTrashed()->findOrFail($deleted->id);
expect($failed->metadata['cleanup']['reason'])->toBe('failed-expired');
expect($deleted->metadata['cleanup']['reason'])->toBe('deleted-grace');
Storage::disk('public')->assertMissing('enhance/sources/4/source.png');
Storage::disk('public')->assertMissing('enhance/sources/5/source.png');
});

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\Enhance\Processors\ExternalWorkerEnhanceProcessor;
use Illuminate\Http\Client\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('app.url', 'http://skinbase.test');
config()->set('enhance.disk', 'public');
config()->set('enhance.external_worker.url', 'http://127.0.0.1:8095');
config()->set('enhance.external_worker.token', 'worker-secret');
config()->set('enhance.external_worker.timeout', 15);
config()->set('enhance.external_worker.max_download_mb', 2);
Storage::fake('public');
});
function makeEnhanceJob(): EnhanceJob {
$user = User::factory()->create();
Storage::disk('public')->put('enhance/sources/10/source.png', UploadedFile::fake()->image('source.png', 40, 40)->get());
return EnhanceJob::query()->create([
'user_id' => $user->id,
'status' => EnhanceJob::STATUS_QUEUED,
'engine' => EnhanceJob::ENGINE_EXTERNAL_WORKER,
'mode' => 'artwork',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/10/source.png',
'input_mime' => 'image/png',
]);
}
it('requires a configured worker url', function (): void {
config()->set('enhance.external_worker.url', '');
app(ExternalWorkerEnhanceProcessor::class)->process(makeEnhanceJob());
})->throws(RuntimeException::class, 'Worker URL is missing.');
it('requires a configured worker token', function (): void {
config()->set('enhance.external_worker.token', '');
app(ExternalWorkerEnhanceProcessor::class)->process(makeEnhanceJob());
})->throws(RuntimeException::class, 'Worker token is missing.');
it('sends bearer token and stores successful worker output', function (): void {
$job = makeEnhanceJob();
$outputBinary = UploadedFile::fake()->image('output.png', 80, 80)->get();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response([
'success' => true,
'job_id' => $job->id,
'output_url' => 'http://127.0.0.1:8095/v1/results/result-output.png',
'width' => 80,
'height' => 80,
'filesize' => strlen($outputBinary),
'mime' => 'image/png',
'metadata' => ['engine' => 'pillow'],
], 200),
'http://127.0.0.1:8095/v1/results/result-output.png' => Http::response($outputBinary, 200, ['Content-Type' => 'image/png']),
'http://127.0.0.1:8095/v1/results/result-output.png*' => Http::response(['success' => true, 'deleted' => true], 200),
]);
$result = app(ExternalWorkerEnhanceProcessor::class)->process($job);
expect($result->width)->toBe(80);
expect($result->height)->toBe(80);
expect($result->mime)->toBe('image/png');
expect($result->metadata['engine'])->toBe('pillow');
Storage::disk('public')->assertExists($result->path);
Http::assertSent(function (Request $request): bool {
if ($request->url() !== 'http://127.0.0.1:8095/v1/upscale') {
return false;
}
$data = $request->data();
$sourceUrl = (string) ($data['source_url'] ?? '');
return $request->hasHeader('Authorization')
&& ($data['mode'] ?? null) === 'artwork'
&& (int) ($data['scale'] ?? 0) === 2
&& (int) ($data['job_id'] ?? 0) > 0
&& ($sourceUrl !== '')
&& (str_contains($sourceUrl, '/internal/enhance/source/') || str_contains($sourceUrl, '/enhance/sources/'));
});
});
it('handles a failed worker response', function (): void {
$job = makeEnhanceJob();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response(['success' => false, 'error' => 'Worker rejected the image.'], 422),
]);
app(ExternalWorkerEnhanceProcessor::class)->process($job);
})->throws(RuntimeException::class, 'Worker rejected the image.');
it('handles an invalid json worker response', function (): void {
$job = makeEnhanceJob();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response('not-json', 200, ['Content-Type' => 'text/plain']),
]);
app(ExternalWorkerEnhanceProcessor::class)->process($job);
})->throws(RuntimeException::class, 'Worker returned an invalid response.');
it('rejects oversized downloaded output', function (): void {
$job = makeEnhanceJob();
config()->set('enhance.external_worker.max_download_mb', 1);
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response([
'success' => true,
'job_id' => $job->id,
'output_url' => 'http://127.0.0.1:8095/v1/results/too-large.webp',
'mime' => 'image/webp',
], 200),
'http://127.0.0.1:8095/v1/results/too-large.webp' => Http::response(str_repeat('a', (1024 * 1024) + 1), 200, ['Content-Type' => 'image/webp']),
]);
app(ExternalWorkerEnhanceProcessor::class)->process($job);
})->throws(RuntimeException::class, 'The upscaled output exceeded the maximum allowed size.');
it('accepts base64 worker output', function (): void {
$job = makeEnhanceJob();
$outputBinary = UploadedFile::fake()->image('output.png', 90, 60)->get();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response([
'success' => true,
'job_id' => $job->id,
'output_base64' => base64_encode($outputBinary),
'mime' => 'image/png',
'metadata' => ['engine' => 'pillow', 'real_ai_upscale' => false],
], 200),
]);
$result = app(ExternalWorkerEnhanceProcessor::class)->process($job);
expect($result->width)->toBe(90);
expect($result->height)->toBe(60);
expect($result->metadata['real_ai_upscale'])->toBeFalse();
});

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
it('renders text health output for enhance', function (): void {
$owner = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'processing_seconds' => 4,
'finished_at' => now(),
]);
$this->artisan('enhance:health')
->expectsOutputToContain('Enhance health')
->expectsOutputToContain('Configured engine')
->assertSuccessful();
});
it('renders json health output with stuck job counts', function (): void {
config()->set('enhance.health.stuck_queued_after_minutes', 60);
config()->set('enhance.health.stuck_processing_after_minutes', 30);
config()->set('enhance.external_worker.url', null);
$owner = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_QUEUED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'queued_at' => now()->subMinutes(61),
]);
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_PROCESSING,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'started_at' => now()->subMinutes(31),
]);
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'processing_seconds' => 8,
'finished_at' => now(),
]);
Artisan::call('enhance:health', ['--json' => true]);
$payload = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR);
expect($payload['engine'])->toBe('stub');
expect($payload['queue'])->toBe((string) config('enhance.queue', 'default'));
expect($payload['worker_configured'])->toBeFalse();
expect($payload['health']['stuck_queued'])->toBe(1);
expect($payload['health']['stuck_processing'])->toBe(1);
expect($payload['counts']['completed'])->toBe(1);
expect($payload['today']['average_processing_seconds'])->toBe(8);
});

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
Queue::fake();
});
it('blocks regular users from moderation enhance write actions', function (): void {
$user = User::factory()->create();
$owner = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$this->actingAs($user)
->post(route('admin.enhance.retry', ['enhanceJob' => $job]))
->assertForbidden();
$this->actingAs($user)
->post(route('admin.enhance.mark-failed', ['enhanceJob' => $job]))
->assertForbidden();
$this->actingAs($user)
->delete(route('admin.enhance.destroy', ['enhanceJob' => $job]))
->assertForbidden();
});
it('allows a moderator to retry a failed enhance job', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/20/source.png', 'source');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/20/source.png',
]);
$this->actingAs($moderator)
->post(route('admin.enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('admin.enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
Queue::assertPushed(ProcessEnhanceJob::class);
});
it('allows a moderator to mark a stuck processing job as failed', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_PROCESSING,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'started_at' => now()->subMinutes(40),
]);
$this->actingAs($moderator)
->post(route('admin.enhance.mark-failed', ['enhanceJob' => $job]))
->assertRedirect(route('admin.enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_FAILED);
expect($job->error_message)->toBe('Marked as failed by moderator.');
expect($job->metadata['moderation']['marked_failed_by'])->toBe($moderator->id);
});
it('uses safe cleanup when moderation deletes a job', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
Storage::disk('public')->put('uploads/artworks/unsafe.png', 'unsafe');
Storage::disk('public')->put('enhance/outputs/21/output.webp', 'output');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'uploads/artworks/unsafe.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/21/output.webp',
]);
$this->actingAs($moderator)
->delete(route('admin.enhance.destroy', ['enhanceJob' => $job]))
->assertRedirect(route('admin.enhance.index'));
expect(EnhanceJob::withTrashed()->find($job->id)?->trashed())->toBeTrue();
Storage::disk('public')->assertExists('uploads/artworks/unsafe.png');
Storage::disk('public')->assertMissing('enhance/outputs/21/output.webp');
});

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
it('allows moderators to browse enhance jobs in moderation', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $creator->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$this->actingAs($moderator)
->get(route('admin.enhance.index'))
->assertOk()
->assertSee((string) $job->id);
});
it('blocks regular users from the moderation enhance surface', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.enhance.index'))
->assertForbidden();
});

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
Queue::fake();
});
it('increments retry metadata and clears failure fields on retry', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/10/source.png', 'source');
Storage::disk('public')->put('enhance/outputs/10/output.webp', 'output');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/10/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/10/output.webp',
'error_message' => 'Example failure',
'started_at' => now()->subMinute(),
'finished_at' => now()->subSeconds(5),
]);
$this->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
expect($job->error_message)->toBeNull();
expect($job->started_at)->toBeNull();
expect($job->finished_at)->toBeNull();
expect($job->metadata['retry_count'])->toBe(1);
expect($job->metadata['last_retried_at'])->not->toBeNull();
Queue::assertPushed(ProcessEnhanceJob::class);
});
it('does not dispatch retry when the source file is missing', function (): void {
$owner = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/11/missing.png',
'error_message' => 'Example failure',
]);
$this->from(route('enhance.show', ['enhanceJob' => $job]))
->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.show', ['enhanceJob' => $job]))
->assertSessionHasErrors([
'job' => 'This enhance job can no longer be retried because the original source file was cleaned up.',
]);
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_FAILED);
Queue::assertNothingPushed();
});
it('rejects retrying non failed jobs', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/12/source.png', 'source');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/12/source.png',
]);
$this->from(route('enhance.show', ['enhanceJob' => $job]))
->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertForbidden();
Queue::assertNothingPushed();
});

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceService;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
});
it('allows an authenticated user to create an enhance job from an upload', function (): void {
Queue::fake();
$user = User::factory()->create();
$file = UploadedFile::fake()->image('wallpaper.png', 1200, 800)->size(1024);
$response = $this->actingAs($user)->post(route('enhance.store'), [
'image' => $file,
'scale' => 2,
'mode' => 'standard',
]);
$job = EnhanceJob::query()->first();
$response->assertRedirect(route('enhance.show', ['enhanceJob' => $job]));
expect($job)->not->toBeNull();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
expect($job->source_path)->not->toBe('');
Storage::disk('public')->assertExists($job->source_path);
Queue::assertPushed(ProcessEnhanceJob::class, function (ProcessEnhanceJob $queuedJob) use ($job): bool {
return true;
});
});
it('rejects unsupported mime types', function (): void {
$user = User::factory()->create();
$file = UploadedFile::fake()->create('vector.svg', 10, 'image/svg+xml');
$this->actingAs($user)
->from(route('enhance.create'))
->post(route('enhance.store'), [
'image' => $file,
'scale' => 2,
'mode' => 'standard',
])
->assertRedirect(route('enhance.create'))
->assertSessionHasErrors('image');
});
it('rejects invalid scale and mode values', function (): void {
$user = User::factory()->create();
$file = UploadedFile::fake()->image('bad.png', 1200, 800)->size(512);
$this->actingAs($user)
->from(route('enhance.create'))
->post(route('enhance.store'), [
'image' => $file,
'scale' => 3,
'mode' => 'broken',
])
->assertRedirect(route('enhance.create'))
->assertSessionHasErrors(['scale', 'mode']);
});
it('enforces the daily enhance limit', function (): void {
config()->set('enhance.daily_limit', 1);
$user = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $user->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$file = UploadedFile::fake()->image('wallpaper.png', 1200, 800)->size(512);
$this->actingAs($user)
->from(route('enhance.create'))
->post(route('enhance.store'), [
'image' => $file,
'scale' => 2,
'mode' => 'standard',
])
->assertRedirect(route('enhance.create'))
->assertSessionHasErrors('image');
});
it('completes a queued job with the stub processor', function (): void {
Queue::fake();
$user = User::factory()->create();
$file = UploadedFile::fake()->image('art.png', 640, 480)->size(256);
$job = app(EnhanceService::class)->createFromUpload($user, $file, [
'scale' => 2,
'mode' => 'standard',
'engine' => 'stub',
]);
$processorJob = new ProcessEnhanceJob($job->id);
$processorJob->handle(app(EnhanceProcessorFactory::class), app(EnhanceStorageService::class));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_COMPLETED);
expect($job->output_path)->not->toBeNull();
expect($job->preview_path)->not->toBeNull();
Storage::disk('public')->assertExists($job->output_path);
Storage::disk('public')->assertExists($job->preview_path);
});

View File

@@ -644,6 +644,29 @@ it('applies ai suggestions to artwork fields and tracks ai sources', function ()
->toBeTrue();
});
it('rejects raw html when ai assist applies artwork descriptions', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'description' => 'Original description.',
]);
ArtworkAiAssist::query()->create([
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_READY,
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
'description' => '<img src="https://spam.example/test.jpg" alt="">',
])
->assertStatus(422)
->assertJsonValidationErrors(['description']);
expect($artwork->fresh()->description)->toBe('Original description.');
});
it('applies ai content type suggestions by resolving a default category', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('rejects raw html when updating artwork descriptions from studio', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->create([
'title' => 'Studio Artwork',
'slug' => 'studio-artwork',
'description' => 'Original description',
]);
$this->actingAs($user)
->putJson(route('api.studio.artworks.update', ['id' => $artwork->id]), [
'description' => '<img src="https://spam.example/test.jpg" alt="">',
])
->assertStatus(422)
->assertJsonValidationErrors(['description']);
});

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('rejects publishing existing artwork drafts with raw html descriptions', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->create([
'title' => 'Draft upload',
'slug' => 'draft-upload',
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => false,
'published_at' => null,
'artwork_status' => 'draft',
]);
$this->actingAs($user)
->postJson("/api/uploads/{$artwork->id}/publish", [
'description' => '<figure><img src="https://spam.example/test.jpg" alt=""></figure>',
])
->assertStatus(422)
->assertJsonValidationErrors(['description']);
});

View File

@@ -163,3 +163,16 @@ it('invalid category rejected', function () {
$response->assertStatus(422)->assertJsonValidationErrors(['category_id']);
});
it('rejects autosave descriptions with raw html', function () {
Storage::fake('local');
$owner = User::factory()->create();
$uploadId = createDraftUploadForAutosave($owner->id);
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
'description' => '<img src="https://spam.example/test.jpg" alt="">',
]);
$response->assertStatus(422)->assertJsonValidationErrors(['description']);
});

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\Processors\ExternalWorkerEnhanceProcessor;
use App\Services\Enhance\Processors\StubEnhanceProcessor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('returns the stub processor for the stub engine', function (): void {
expect(app(EnhanceProcessorFactory::class)->make(EnhanceJob::ENGINE_STUB))->toBeInstanceOf(StubEnhanceProcessor::class);
});
it('returns the external worker processor for the external worker engine', function (): void {
expect(app(EnhanceProcessorFactory::class)->make(EnhanceJob::ENGINE_EXTERNAL_WORKER))->toBeInstanceOf(ExternalWorkerEnhanceProcessor::class);
});
it('throws for an unknown enhance processor engine', function (): void {
app(EnhanceProcessorFactory::class)->make('unknown-engine');
})->throws(RuntimeException::class);

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
uses(Tests\TestCase::class);
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
});
it('allows enhance source output and preview paths', function (): void {
$service = app(EnhanceStorageService::class);
expect($service->isEnhancePath('enhance/sources/1/source.png'))->toBeTrue();
expect($service->isEnhancePath('enhance/outputs/1/output.webp'))->toBeTrue();
expect($service->isEnhancePath('enhance/previews/1/preview.webp'))->toBeTrue();
});
it('rejects artwork random and null paths safely', function (): void {
$service = app(EnhanceStorageService::class);
Storage::disk('public')->put('uploads/artworks/original.png', 'unsafe');
Storage::disk('public')->put('random/file.txt', 'unsafe');
expect($service->isEnhancePath('uploads/artworks/original.png'))->toBeFalse();
expect($service->isEnhancePath('random/file.txt'))->toBeFalse();
expect($service->isEnhancePath(null))->toBeFalse();
expect($service->safeDelete('public', 'uploads/artworks/original.png'))->toBeFalse();
expect($service->safeDelete('public', 'random/file.txt'))->toBeFalse();
expect($service->safeDelete('public', null))->toBeFalse();
Storage::disk('public')->assertExists('uploads/artworks/original.png');
Storage::disk('public')->assertExists('random/file.txt');
});
it('lists known job paths', function (): void {
$service = app(EnhanceStorageService::class);
$owner = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_path' => 'enhance/sources/known/source.png',
'output_path' => 'enhance/outputs/known/output.webp',
'preview_path' => 'enhance/previews/known/preview.webp',
]);
expect($service->listKnownJobPaths())->toContain(
'enhance/sources/known/source.png',
'enhance/outputs/known/output.webp',
'enhance/previews/known/preview.webp',
);
});

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Services\Enhance\EnhanceValidator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('validates a supported uploaded image and normalizes options', function (): void {
$file = UploadedFile::fake()->image('artwork.png', 1400, 900)->size(768);
$validated = app(EnhanceValidator::class)->validateUpload($file, [
'scale' => 4,
'mode' => 'illustration',
'engine' => 'stub',
]);
expect($validated['scale'])->toBe(4);
expect($validated['mode'])->toBe('illustration');
expect($validated['engine'])->toBe('stub');
expect($validated['input_width'])->toBe(1400);
expect($validated['input_height'])->toBe(900);
expect($validated['input_mime'])->toBe('image/png');
});
it('rejects unsupported image formats', function (): void {
$file = UploadedFile::fake()->create('vector.svg', 10, 'image/svg+xml');
app(EnhanceValidator::class)->validateUpload($file, [
'scale' => 2,
'mode' => 'standard',
'engine' => 'stub',
]);
})->throws(ValidationException::class);