Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
147
tests/Feature/Enhance/EnhanceAuthorizationTest.php
Normal file
147
tests/Feature/Enhance/EnhanceAuthorizationTest.php
Normal 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');
|
||||
});
|
||||
148
tests/Feature/Enhance/EnhanceCleanupCommandTest.php
Normal file
148
tests/Feature/Enhance/EnhanceCleanupCommandTest.php
Normal 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');
|
||||
});
|
||||
151
tests/Feature/Enhance/EnhanceExternalWorkerProcessorTest.php
Normal file
151
tests/Feature/Enhance/EnhanceExternalWorkerProcessorTest.php
Normal 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();
|
||||
});
|
||||
73
tests/Feature/Enhance/EnhanceHealthCommandTest.php
Normal file
73
tests/Feature/Enhance/EnhanceHealthCommandTest.php
Normal 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);
|
||||
});
|
||||
116
tests/Feature/Enhance/EnhanceModerationActionsTest.php
Normal file
116
tests/Feature/Enhance/EnhanceModerationActionsTest.php
Normal 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');
|
||||
});
|
||||
32
tests/Feature/Enhance/EnhanceModerationTest.php
Normal file
32
tests/Feature/Enhance/EnhanceModerationTest.php
Normal 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();
|
||||
});
|
||||
100
tests/Feature/Enhance/EnhanceRetryHardeningTest.php
Normal file
100
tests/Feature/Enhance/EnhanceRetryHardeningTest.php
Normal 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();
|
||||
});
|
||||
124
tests/Feature/Enhance/EnhanceUploadTest.php
Normal file
124
tests/Feature/Enhance/EnhanceUploadTest.php
Normal 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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
29
tests/Feature/Uploads/UploadArtworkPublishValidationTest.php
Normal file
29
tests/Feature/Uploads/UploadArtworkPublishValidationTest.php
Normal 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']);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user